From 019c458ac2b1ada9011f7cc94bb37865fa47d285 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Thu, 8 Oct 2026 18:21:00 -0300 Subject: [PATCH 01/36] feat(databricks): add the unity catalog client, access-path routing, and metastore sync Co-authored-by: Cursor --- README.md | 75 +++- baton_capabilities.json | 229 +++++++++- docs/connector.mdx | 96 ++++- docs/docs-info.md | 132 ++++++ pkg/connector/account.go | 1 + pkg/connector/connector.go | 32 +- pkg/connector/grant_visibility.go | 107 +++++ pkg/connector/groups.go | 2 +- pkg/connector/helpers.go | 76 ++++ pkg/connector/metastore.go | 604 +++++++++++++++++++++++++++ pkg/connector/principal_index.go | 264 ++++++++++++ pkg/connector/privileges.go | 53 +++ pkg/connector/raw_id_test.go | 5 + pkg/connector/resource_types.go | 39 ++ pkg/connector/roles.go | 2 +- pkg/connector/securable.go | 57 +++ pkg/connector/service-principals.go | 2 +- pkg/connector/unity_catalog.go | 248 +++++++++++ pkg/connector/unity_catalog_test.go | 119 ++++++ pkg/connector/users.go | 2 +- pkg/connector/workspaces.go | 2 +- pkg/databricks/client.go | 2 +- pkg/databricks/models.go | 69 ++- pkg/databricks/pagination.go | 80 ++++ pkg/databricks/request.go | 108 +++-- pkg/databricks/unity_catalog.go | 326 +++++++++++++++ pkg/databricks/unity_catalog_test.go | 112 +++++ pkg/databricks/vars.go | 24 ++ 28 files changed, 2795 insertions(+), 73 deletions(-) create mode 100644 pkg/connector/grant_visibility.go create mode 100644 pkg/connector/metastore.go create mode 100644 pkg/connector/principal_index.go create mode 100644 pkg/connector/privileges.go create mode 100644 pkg/connector/securable.go create mode 100644 pkg/connector/unity_catalog.go create mode 100644 pkg/connector/unity_catalog_test.go create mode 100644 pkg/databricks/pagination.go create mode 100644 pkg/databricks/unity_catalog.go create mode 100644 pkg/databricks/unity_catalog_test.go diff --git a/README.md b/README.md index a7d8508f..22fd40a5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ `baton-databricks` is a connector for Databricks built using the [Baton SDK](https://github.com/conductorone/baton-sdk). It communicates with the Databricks API, to sync data about Databricks identities (users, groups and -service principals), roles and workspaces. +service principals), roles, workspaces and Unity Catalog metastores. Check out [Baton](https://github.com/conductorone/baton) to learn more about the project in general. @@ -30,6 +30,11 @@ the Generate secret button. You can use this secret to authenticate across all workspaces that service principal has access to. This requires admin access to the Databricks account and each workspace you want to sync. +Syncing Unity Catalog needs more than account admin on its own: the service +principal also has to be able to read the privileges recorded on each metastore. +See [Unity Catalog permissions](#unity-catalog-permissions) for the exact +requirement. + # Using Azure Databricks To work with Azure Databricks, you need to provide the hostname flag. @@ -76,12 +81,16 @@ baton resources - Service Principals - Users - Roles +- Metastores The connector fetches all resources from the account and every workspace the service principal can access. To limit the scope, pass a comma-separated list of workspaces to the `--workspaces` flag. Each entry can be a workspace name, deployment name, or numeric workspace ID, matched case-insensitively. +Metastores are Unity Catalog securables and are opt-in: the type stays off until +it is enabled for the connector in ConductorOne. + ## Authentication OAuth is the only authentication method: an account-level service principal's @@ -115,6 +124,70 @@ A workspace entitlement can only be granted to a principal that is already assigned to that workspace. Otherwise the grant fails with a `FailedPrecondition` error; grant the workspace membership first. +## Unity Catalog + +A metastore is the top of the Databricks Unity Catalog hierarchy. The resource +tree the connector syncs is: + +``` +account +├── workspace +│ └── role +├── user +├── group +├── service principal +└── metastore +``` + +A metastore exposes its privileges as entitlements (`CREATE_CATALOG`, +`READ_METADATA`, `USE_SHARE` and so on) plus a non-grantable `owner`. A +privilege held by a group is expandable, so ConductorOne resolves the group's +members as holders. + +`MANAGE` and `ALL_PRIVILEGES` are not offered: Databricks rejects both at the +metastore level. + +The endpoints, the permission model, principal resolution and the connector's +limitations are in [docs/docs-info.md](./docs/docs-info.md). + +## Unity Catalog provisioning + +A metastore privilege can be granted to and revoked from a user, group or +service principal. The `owner` entitlement is read-only: granting or revoking it +fails with an `InvalidArgument` error. + +## Unity Catalog permissions + +Unity Catalog uses the same OAuth service principal as the rest of the +connector. It must be an account admin, and it must be assigned to a running +workspace the metastore is attached to, because the privilege endpoints are not +served on the account plane. To read a metastore's grants it must additionally +own the metastore or be an admin of that workspace — a privilege read cannot +settle it, since `MANAGE` is rejected there. When neither holds, the connector +fails the sync with a `PermissionDenied` error naming the metastore rather than +reporting a partial read. The scopes, roles and privileges behind each read, and +the reason the connector fails closed, are in +[docs/docs-info.md](./docs/docs-info.md). + +## Accounts with many workspaces + +Deciding which metastore a workspace serves needs a catalog listing from each +workspace that reports no metastore assignment, so the connector caches those +listings for the length of a run and keeps one per workspace. The working set +grows with the number of workspaces in the account. + +The default budget is 5 MB, which covers roughly ten workspaces holding a few +hundred catalogs each. Past that the cache evicts and the connector re-lists +catalogs it has already read, which lengthens the sync and uses more of the +Databricks API rate limit. Raising the budget removes the re-reads: + +``` +BATON_HTTP_CACHE_MAX_SIZE=32 baton-databricks +``` + +The value is in megabytes. It is a Baton SDK setting rather than a connector +flag, so it does not appear in `--help`. + # Contributing, Support and Issues We started Baton because we were tired of taking screenshots and manually diff --git a/baton_capabilities.json b/baton_capabilities.json index 91985cec..b3836ca2 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -4,13 +4,35 @@ { "resourceType": { "id": "account", - "displayName": "Account" + "displayName": "Account", + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.CapabilityPermissions", + "permissions": [ + { + "permission": "access-management" + }, + { + "permission": "Account admin" + } + ] + } + ] }, "capabilities": [ "CAPABILITY_SYNC", "CAPABILITY_PROVISION" ], - "permissions": {} + "permissions": { + "permissions": [ + { + "permission": "access-management" + }, + { + "permission": "Account admin" + } + ] + } }, { "resourceType": { @@ -18,13 +40,108 @@ "displayName": "Group", "traits": [ "TRAIT_GROUP" + ], + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.CapabilityPermissions", + "permissions": [ + { + "permission": "scim" + }, + { + "permission": "access-management" + }, + { + "permission": "Account admin" + } + ] + } ] }, "capabilities": [ "CAPABILITY_SYNC", "CAPABILITY_PROVISION" ], - "permissions": {} + "permissions": { + "permissions": [ + { + "permission": "scim" + }, + { + "permission": "access-management" + }, + { + "permission": "Account admin" + } + ] + } + }, + { + "resourceType": { + "id": "metastore", + "displayName": "Metastore", + "traits": [ + "TRAIT_APP" + ], + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.OptInRequired" + }, + { + "@type": "type.googleapis.com/c1.connector.v2.CapabilityPermissions", + "permissions": [ + { + "permission": "unity-catalog" + }, + { + "permission": "scim" + }, + { + "permission": "access-management" + }, + { + "permission": "provisioning" + }, + { + "permission": "Account admin" + }, + { + "permission": "Workspace admin" + } + ] + }, + { + "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlements" + } + ] + }, + "capabilities": [ + "CAPABILITY_SYNC", + "CAPABILITY_PROVISION" + ], + "permissions": { + "permissions": [ + { + "permission": "unity-catalog" + }, + { + "permission": "scim" + }, + { + "permission": "access-management" + }, + { + "permission": "provisioning" + }, + { + "permission": "Account admin" + }, + { + "permission": "Workspace admin" + } + ] + }, + "optInRequired": true }, { "resourceType": { @@ -32,13 +149,35 @@ "displayName": "Role", "traits": [ "TRAIT_ROLE" + ], + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.CapabilityPermissions", + "permissions": [ + { + "permission": "scim" + }, + { + "permission": "Account admin" + } + ] + } ] }, "capabilities": [ "CAPABILITY_SYNC", "CAPABILITY_PROVISION" ], - "permissions": {} + "permissions": { + "permissions": [ + { + "permission": "scim" + }, + { + "permission": "Account admin" + } + ] + } }, { "resourceType": { @@ -46,13 +185,41 @@ "displayName": "Service Principal", "traits": [ "TRAIT_GROUP" + ], + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.CapabilityPermissions", + "permissions": [ + { + "permission": "scim" + }, + { + "permission": "access-management" + }, + { + "permission": "Account admin" + } + ] + } ] }, "capabilities": [ "CAPABILITY_SYNC", "CAPABILITY_PROVISION" ], - "permissions": {} + "permissions": { + "permissions": [ + { + "permission": "scim" + }, + { + "permission": "access-management" + }, + { + "permission": "Account admin" + } + ] + } }, { "resourceType": { @@ -64,6 +231,17 @@ "annotations": [ { "@type": "type.googleapis.com/c1.connector.v2.SkipEntitlementsAndGrants" + }, + { + "@type": "type.googleapis.com/c1.connector.v2.CapabilityPermissions", + "permissions": [ + { + "permission": "scim" + }, + { + "permission": "Account admin" + } + ] } ] }, @@ -72,7 +250,16 @@ "CAPABILITY_ACCOUNT_PROVISIONING", "CAPABILITY_RESOURCE_DELETE" ], - "permissions": {} + "permissions": { + "permissions": [ + { + "permission": "scim" + }, + { + "permission": "Account admin" + } + ] + } }, { "resourceType": { @@ -80,13 +267,41 @@ "displayName": "Workspace", "traits": [ "TRAIT_GROUP" + ], + "annotations": [ + { + "@type": "type.googleapis.com/c1.connector.v2.CapabilityPermissions", + "permissions": [ + { + "permission": "provisioning" + }, + { + "permission": "access-management" + }, + { + "permission": "Account admin" + } + ] + } ] }, "capabilities": [ "CAPABILITY_SYNC", "CAPABILITY_PROVISION" ], - "permissions": {} + "permissions": { + "permissions": [ + { + "permission": "provisioning" + }, + { + "permission": "access-management" + }, + { + "permission": "Account admin" + } + ] + } } ], "connectorCapabilities": [ diff --git a/docs/connector.mdx b/docs/connector.mdx index baf1bca2..e1e36111 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -12,6 +12,7 @@ sidebarTitle: "Databricks" | :--- | :--- | :--- | | Accounts | | | | Groups | | | +| Metastores | | | | Roles | | | | Service principals | | | | Users | | | @@ -25,6 +26,28 @@ Workspace roles (such as **workspace-access** or **databricks-sql-access**) can [This connector syncs non-human identities](/product/admin/nhi) and displays them on the **Identities overview** dashboard. +## Understanding Unity Catalog in Databricks + +Unity Catalog is how Databricks governs access to data, and a **metastore** is the top of it: it is the regional container that holds the catalogs, schemas, tables and volumes underneath. One metastore usually serves several workspaces, so Unity Catalog access is granted once per object rather than once per workspace. + +Databricks records a set of privileges on a metastore — **CREATE_CATALOG**, **READ_METADATA**, **USE_SHARE**, and so on. C1 turns each privilege into an entitlement on the metastore, so you can review who holds it, request it, and revoke it for users, groups and service principals. The metastore also carries a read-only **owner** entitlement that shows who owns it; ownership cannot be requested or revoked through C1. + +When a group holds a privilege, C1 shows the group's members as holders of it, so access granted to a team still resolves to the people who have it. + + +The Metastores resource type is turned off by default. Turn it on from the connector's configuration page in C1. + + + +**Notes on Unity Catalog access in C1:** + +- C1 shows the privileges recorded directly on the metastore. Unity Catalog privileges inherit downward, and inherited access stays on the object that carries it rather than being repeated on everything beneath it. +- Ownership is shown but cannot be provisioned. An owner that is not a Databricks account identity, such as a system account or a workspace admins group, is not shown at all. +- **MANAGE** and **ALL_PRIVILEGES** are not offered as entitlements, because Databricks rejects both at the metastore level. +- Unity Catalog tags are not synced. +- Grants are ingested a few minutes behind resources, so an object can briefly show no access right after a sync. + + ## Authentication methods The connector authenticates with **OAuth** — an account-level service principal's client ID and secret. This is the only authentication method the connector supports. @@ -37,6 +60,8 @@ Configuring the connector requires you to pass in credentials generated in Datab A user with the **Account admin** role in each Databricks workspace you want to sync must perform this task. +Every step below happens in the Databricks **account console**. On AWS that is [https://accounts.cloud.databricks.com](https://accounts.cloud.databricks.com). Azure Databricks and Google Cloud Platform customers use their own account console host, and also have to supply the account hostname and hostname when they configure the connector. + ### Look up your Databricks account ID @@ -47,23 +72,65 @@ A user with the **Account admin** role in each Databricks workspace you want to In the Databricks account console, open the menu that appears next to your username in the upper right corner. - Carefully and copy and save account ID. + Carefully copy and save the account ID. -### Generate Databricks credentials +### Create a service principal and an OAuth secret The Databricks connector authenticates with OAuth, which syncs info from all Databricks workspaces the service principal can access. - Follow the [Databricks OAuth authentication documentation](https://docs.databricks.com/en/dev-tools/auth/oauth-m2m.html) to create a service principal and create an OAuth secret. + In the account console, go to **User management** > **Service principals** and click **Add service principal**. Give it a name you will recognize, such as `ConductorOne`. - Carefully copy and save the OAuth client ID and secret. + Open the new service principal, go to its **Secrets** tab, and generate an OAuth secret. + + Carefully copy and save the **Client ID** and **Secret**. The secret is shown only once. + + For the full walkthrough, see the [Databricks OAuth machine-to-machine documentation](https://docs.databricks.com/en/dev-tools/auth/oauth-m2m.html). + +There is no scope to pick in the console. The connector requests the `all-apis` scope itself when it exchanges the client ID and secret for a token, which covers the four API surfaces it reads: Unity Catalog, SCIM, access management and account provisioning. + + +### Grant the service principal the roles it needs + + + + Make the service principal an account admin: in the account console, go to **User management** > **Service principals**, open the service principal, select its **Roles** tab, and enable **Account admin**. + + + Assign it to each workspace you want C1 to sync, as an admin: in the account console, go to **Workspaces**, open the workspace, select **Permissions**, click **Add permissions**, select the service principal, and choose **Admin**. + + Repeat this for every workspace you want synced. Workspace **Admin** also satisfies the Unity Catalog requirement in the next step for every catalog reached through that workspace. + + + +### Let the service principal read Unity Catalog privileges + +Skip this section if you are not syncing metastores, or if you already made the service principal a workspace **Admin** in the step above — that is enough on its own. + +Otherwise, make the service principal a metastore admin: + + + + In the account console, go to **Catalog** and open the metastore. + + + Set the **Metastore Admin** field to the service principal's application ID. + + + + +This Unity Catalog permission is a hard requirement, not a recommendation. When a service principal without it reads the privileges on a metastore, Databricks returns a partial list rather than an error, and a partial list is indistinguishable from an object that has no access granted on it at all. C1 would read the privileges missing from that list as access that had been revoked. Rather than report an incomplete picture, the connector stops the sync and names the metastore it could not read in full. + + +For more information, see the [Databricks Unity Catalog privileges documentation](https://docs.databricks.com/aws/en/data-governance/unity-catalog/manage-privileges/privileges). + **Done.** Here's the set of credentials you'll need when setting up the connector: - Account ID @@ -117,10 +184,19 @@ To complete this task, you'll need: Enter your OAuth client ID and client secret into the **OAuth2 Client ID** and **OAuth2 Client Secret** fields. - **Google Cloud Platform and Azure Databricks customers only:** Enter your Databricks account hostname and hostname in the relevant fields. + Optionally, narrow what the connector syncs: + + - **Workspaces**: limit the sync to these workspaces, by workspace name, deployment name, or numeric workspace ID. + - **Exclude Workspaces**: workspaces to leave out of the sync, by workspace name, deployment name, or numeric workspace ID. + + Fill in **Workspaces** or **Exclude Workspaces**, not both. + + + **Google Cloud Platform and Azure Databricks customers only:** Enter your Databricks account hostname and hostname in the **Account Hostname** and **Hostname** fields. - For more on how to look up these values for GCP, see the GCP Databricks [REST API reference](https://docs.databricks.com/api/gcp/account/introduction) and [identifiers for workspace objects](https://docs.gcp.databricks.com/en/workspace/workspace-details.html) documentation. - For more on how to look up these values for Azure Databricks, see the the Azure Databricks [REST API reference](https://docs.databricks.com/api/azure/account/introduction) and [identifiers for workspace objects](https://learn.microsoft.com/en-us/azure/databricks/workspace/workspace-details) documentation. + Click **Save**. @@ -224,6 +300,10 @@ stringData: OAuth requires a reachable account API. If the account API check fails at startup, the connector fails validation instead of falling back to a workspace-only sync, even when `BATON_WORKSPACES` is set. + +**Accounts with many workspaces:** to decide which metastore each workspace serves, the connector may list a workspace's catalogs, and it holds one such listing per workspace while it runs — so a wider account needs a larger buffer. The 5 MB default suits about ten workspaces with a few hundred catalogs each. Above that the connector re-reads listings it has already fetched, which lengthens the sync and uses more of your Databricks API rate limit. Set `BATON_HTTP_CACHE_MAX_SIZE` to a larger number of megabytes in the Secret above. + + See the connector's README or run `--help` to see all available configuration flags and environment variables. #### Deployment configuration @@ -273,4 +353,8 @@ spec: **Done.** Your Databricks connector is now pulling access data into C1. - \ No newline at end of file + + + +Stable binaries and container images for the Databricks connector are available from the [official download center](https://dist.conductorone.com/ConductorOne/baton-databricks). + \ No newline at end of file diff --git a/docs/docs-info.md b/docs/docs-info.md index fcebe05c..528c67f7 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -5,6 +5,8 @@ While developing the connector, please fill out this form. This information is n 1. What resources does the connector sync? > The connector syncs the Databricks account, workspaces, users, groups, service principals, and roles. + > + > It also syncs Unity Catalog metastores. That resource type is opt-in — it stays off until it is enabled per resource type in ConductorOne. 2. Can the connector provision any resources? If so, which ones? @@ -12,6 +14,7 @@ While developing the connector, please fill out this form. This information is n > > - **User accounts**: create and delete account users. > - **Entitlements**: grant and revoke role and membership assignments on accounts, workspaces, groups, service principals, and roles. + > - **Unity Catalog privileges**: grant and revoke a metastore privilege for a user, group or service principal. The `owner` entitlement is read-only; granting or revoking it returns `InvalidArgument`. ## Connector credentials @@ -34,6 +37,10 @@ While developing the connector, please fill out this form. This information is n * Does the credential need any specific scopes or permissions? If so, list them here. > The credential must have admin access to each resource it reads or writes: account-admin on the Databricks account (for account-level sync and provisioning) and workspace-admin on each workspace being synced. + > + > The OAuth token carries scopes on top of that. The connector requests `all-apis`, which covers the four it uses: `unity-catalog` (the securables and the permissions endpoint), `scim` (account principals), `access-management` (roles and workspace permission assignments) and `provisioning` (the account workspaces listing). The scope-by-scope breakdown is in `## Permission model` below. + > + > Unity Catalog adds a third layer on top of scopes and roles: standing on the securable itself. For each metastore being synced, the service principal must either own the metastore or be an admin of a workspace it is assigned to. A privilege read cannot settle it at this level, because Databricks rejects `MANAGE` and `ALL_PRIVILEGES` on a metastore. `## Permission model` explains why neither is optional. * If applicable: Is the list of scopes or permissions different to sync (read) versus provision (read-write)? If so, list the difference here. @@ -42,3 +49,128 @@ While developing the connector, please fill out this form. This information is n * What level of access or permissions does the user need in order to create the credentials? (For example, must be a super administrator, must have access to the admin console, etc.) > Account admin access to the Databricks account console (to create the service principal and OAuth secret), and workspace admin on each workspace being synced. + +## Unity Catalog API + +A metastore lives on the account plane, but its privileges are only readable +through a workspace host assigned to it. The connector enumerates metastores from +the account, resolves which workspaces are assigned to each one, and issues the +permissions calls against one of those workspace hosts. + +The catalog listing is read here for one purpose only: Databricks answers the +workspace metastore-assignment endpoint with 404 both for a workspace outside +Unity Catalog and for an assignment the credential may not read, and listing a +workspace's catalogs is what separates the two. The catalogs themselves are not +synced by this release. + +| Level | Endpoint | +|-------|----------| +| Metastores | `GET /api/2.0/accounts/{account_id}/metastores` | +| Workspace metastore assignment | `GET /api/2.0/accounts/{account_id}/workspaces/{workspace_id}/metastore` | +| Catalogs (access-path resolution only) | `GET /api/2.1/unity-catalog/catalogs` | +| Privileges | `GET` and `PATCH` `/api/2.1/unity-catalog/permissions/{securable_type}/{full_name}` | + +Behaviours that shape the implementation: + +- **The permissions endpoint is the only one that returns `principal_id`.** Its + sibling `effective-permissions` adds inherited privileges but drops that + field, which is the stable key the connector resolves principals by. +- **`max_results` must be `0` on the permissions endpoint.** Any value from 1 to + 149 is rejected with HTTP 400, because a page may not split one principal's + privilege list. +- **A page token must never be synthesized.** The permissions endpoint silently + ignores a token it cannot deserialize and restarts at page 1, so a synthesized + token produces an endless re-read rather than an error. Only an absent token + terminates a sequence: an empty page, and a page shorter than `max_results`, + can both still carry one. +- **A metastore is addressed by its UUID.** Its name is rejected with HTTP 400. +- **A cached response is keyed to the host it came from.** The SDK's HTTP cache + keys on the path, the query and a header set, with no host component, and every + workspace deployment answers the catalog listing at the same path and query. The + client adds a host header to the key so one workspace's listing is never served + for another, which would file a workspace under a metastore it cannot reach. + +## Permission model + +Three independent layers have to line up, and granting one does not imply the +others: + +1. **OAuth scopes** decide which API surfaces the credential may touch at all. +2. **The service principal's roles** decide what it sees on those surfaces. +3. **Unity Catalog privileges** apply on the securable itself. + +The scopes: + +| Scope | Covers | +|-------|--------| +| `unity-catalog` | Metastores, workspace metastore assignments, the catalog listing used to resolve access paths, and the permissions endpoint | +| `scim` | Account users, groups and service principals | +| `access-management` | The rule-sets API, where roles live, plus the workspace permission assignments | +| `provisioning` | The account workspaces listing. It is the only scope that grants it — including not the one named `workspace` | + +The connector requests `all-apis`, which covers all four. Each resource type +declares its own scopes, roles and privileges, so `./baton-databricks +capabilities` reports them per type. + +A privilege read is only trustworthy when the service principal satisfies at +least one of these per metastore: + +- it is the owner of the metastore, or +- it is an admin of a workspace the metastore is assigned to. + +Holding a privilege is not an option at this level: Databricks rejects `MANAGE` +and `ALL_PRIVILEGES` on a metastore, so there is no privilege whose presence +would prove the read is complete. + +When neither holds, the connector fails the sync rather than reporting what +it can see. Databricks answers an under-permissioned privilege read with HTTP +200 and a partial list of privilege assignments, which is byte-indistinguishable +from an object that genuinely has no grants on it. Reporting that partial list +would make ConductorOne read every privilege the principal cannot see as revoked +access. + +## Identity resolution + +A privilege assignment carries only `principal` and `principal_id`, with no type +discriminator. `principal` holds a value from one of three namespaces: a user's +`userName`, a group's `displayName`, or a service principal's `applicationId`. + +A SCIM filter built from that name cannot disambiguate it, because Databricks +answers an unmatched filter with HTTP 200 and zero rows rather than an error — a +miss is indistinguishable from a wrong-type lookup. The connector therefore +cross-references a per-sync index, keyed primarily on the numeric id. + +The index is built from **account** SCIM, not workspace SCIM: workspace SCIM +sees only a fraction of the account's service principals and groups, and any of +the ones it cannot see can hold a Unity Catalog grant, because grants live on the +metastore rather than on a workspace. + +A principal deleted in Databricks keeps its grants. Its assignment arrives with +an empty `principal` and only its numeric id, which is why the index resolves by +`principal_id` first. `principal_id` is accepted on removals only, so grant and +revoke build different request bodies. + +## Limitations + +- **Direct grants only.** Unity Catalog privileges inherit downward — + `READ_METADATA` granted on a metastore inherits to every object beneath it — + but the connector reports the privilege assignments recorded on the object + itself. The endpoint that exposes inheritance does not return principal IDs, + which are what the connector needs to resolve a principal unambiguously. +- **Ownership is read-only.** The owner of a metastore is shown but cannot be + provisioned. An owner that is not an account identity — `System user`, or a + workspace admins group such as `_workspace_admins_` — does not + resolve, and no owner grant is emitted for it. +- **`MANAGE` and `ALL_PRIVILEGES` do not exist at the metastore level** — + Databricks rejects them there, so neither is offered as an entitlement. +- **A metastore needs a usable workspace.** Its privileges are not served on the + account plane, so a metastore whose every attached workspace is excluded, not + `RUNNING`, or unreadable fails the sync rather than being silently omitted. +- **Other securables are not synced yet:** catalogs, schemas, tables, volumes, + functions, registered models, external locations, storage credentials, + connections, shares and Delta Sharing recipients. +- **Tags are not synced**, including the `SYSTEM.TEAM` convention some customers + use to drive just-in-time access. +- **Grants land after resources.** ConductorOne ingests grants a few minutes + behind resources, so a resource can briefly show zero grants right after a + sync. diff --git a/pkg/connector/account.go b/pkg/connector/account.go index 5ff0911c..3aaeffcb 100644 --- a/pkg/connector/account.go +++ b/pkg/connector/account.go @@ -54,6 +54,7 @@ func (a *accountBuilder) accountResource(_ context.Context) (*v2.Resource, error &v2.ChildResourceType{ResourceTypeId: groupResourceType.Id}, &v2.ChildResourceType{ResourceTypeId: servicePrincipalResourceType.Id}, &v2.ChildResourceType{ResourceTypeId: roleResourceType.Id}, + &v2.ChildResourceType{ResourceTypeId: metastoreResourceType.Id}, ) } diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index f97021e6..0828503e 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -14,12 +14,19 @@ import ( ) type Databricks struct { - client *databricks.Client - workspaces []string + client *databricks.Client + workspaces []string + unityCatalog *unityCatalog + opts *cli.ConnectorOpts } // ResourceSyncers returns a ResourceSyncerV2 for each resource type that should be synced from the upstream service. func (d *Databricks) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 { + willSync := func(string) bool { return true } + if d.opts != nil { + willSync = d.opts.WillSyncResourceType + } + syncers := []connectorbuilder.ResourceSyncerV2{ newAccountBuilder(d.client), newGroupBuilder(d.client), @@ -27,6 +34,7 @@ func (d *Databricks) ResourceSyncers(ctx context.Context) []connectorbuilder.Res newUserBuilder(d.client), newWorkspaceBuilder(d.client, d.workspaces), newRoleBuilder(d.client), + newMetastoreBuilder(d.client, d.unityCatalog, willSync), } return syncers @@ -42,7 +50,8 @@ func (d *Databricks) Asset(ctx context.Context, asset *v2.AssetRef) (string, io. func (d *Databricks) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { return &v2.ConnectorMetadata{ DisplayName: "Databricks", - Description: "Connector syncing Databricks workspaces, users, groups, service principals and roles to Baton", + Description: "Connector syncing Databricks workspaces, users, groups, service principals and roles to Baton, " + + "along with Unity Catalog metastores and the privileges granted on them", AccountCreationSchema: &v2.ConnectorAccountCreationSchema{ FieldMap: map[string]*v2.ConnectorAccountCreationSchema_Field{ "email": { @@ -119,16 +128,19 @@ func (d *Databricks) Validate(ctx context.Context) (annotations.Annotations, err return nil, nil } -// New returns a new instance of the connector. +// New returns a new instance of the connector. clientID is the application id of +// the service principal the connector authenticates as. func New( ctx context.Context, hostname, accountHostname, accountID, - baseURL string, + baseURL, + clientID string, auth databricks.Auth, excludeWorkspaces []string, workspaces []string, + opts *cli.ConnectorOpts, ) (*Databricks, error) { httpClient, err := auth.GetClient(ctx) if err != nil { @@ -141,13 +153,15 @@ func New( } return &Databricks{ - client: client, - workspaces: workspaces, + client: client, + workspaces: workspaces, + unityCatalog: newUnityCatalog(client, clientID), + opts: opts, }, nil } // NewConnector returns a new connector builder from a configuration struct. -func NewConnector(ctx context.Context, cfg *config.Databricks, _ *cli.ConnectorOpts) (connectorbuilder.ConnectorBuilderV2, []connectorbuilder.Opt, error) { +func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.ConnectorOpts) (connectorbuilder.ConnectorBuilderV2, []connectorbuilder.Opt, error) { accountHostname := getAccountHostname(cfg, cfg.Hostname) auth := prepareClientAuth(cfg) @@ -157,9 +171,11 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, _ *cli.ConnectorO accountHostname, cfg.AccountId, cfg.BaseUrl, + cfg.DatabricksClientId, auth, cfg.DatabricksExcludeWorkspaces, cfg.Workspaces, + opts, ) if err != nil { return nil, nil, err diff --git a/pkg/connector/grant_visibility.go b/pkg/connector/grant_visibility.go new file mode 100644 index 00000000..28e70843 --- /dev/null +++ b/pkg/connector/grant_visibility.go @@ -0,0 +1,107 @@ +package connector + +import ( + "context" + "fmt" + "slices" + "strconv" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" +) + +const ( + workspacePermissionAdmin = "ADMIN" +) + +// metastoreGrantsAreTrustworthy reports whether every direct grant on a metastore +// is visible. MANAGE and ALL_PRIVILEGES are rejected at the metastore level, so the +// authority is owning it or administering a workspace it is assigned to. +func (u *unityCatalog) metastoreGrantsAreTrustworthy( + ctx context.Context, + snap routingSnapshot, + metastoreID, workspace string, +) (bool, *v2.RateLimitDescription, error) { + return u.metastoreProbe(ctx, snap, metastoreID, workspace, nil) +} + +func (u *unityCatalog) metastoreProbe( + ctx context.Context, + snap routingSnapshot, + metastoreID, workspace string, + rateLimit *v2.RateLimitDescription, +) (bool, *v2.RateLimitDescription, error) { + if metastore, ok := snap.metastores[metastoreID]; ok && u.ownsPrincipal(metastore.Owner) { + return true, rateLimit, nil + } + + admin, adminRateLimit, err := u.isWorkspaceAdmin(ctx, snap, workspace) + if adminRateLimit != nil { + rateLimit = adminRateLimit + } + if err != nil { + return false, rateLimit, err + } + + return admin, rateLimit, nil +} + +// isWorkspaceAdmin lists the workspace's permission assignments on every call. +func (u *unityCatalog) isWorkspaceAdmin( + ctx context.Context, + snap routingSnapshot, + workspace string, +) (bool, *v2.RateLimitDescription, error) { + if workspace == "" { + return false, nil, nil + } + + workspaceID := snap.workspaceIDs[workspace] + if workspaceID == "" { + return false, nil, nil + } + + assignments, rateLimit, err := u.client.ListWorkspaceMembers(ctx, workspaceID) + if err != nil { + return false, rateLimit, fmt.Errorf("failed to list the permission assignments of workspace %s: %w", workspace, err) + } + + return u.holdsWorkspaceAdmin(ctx, assignments, rateLimit) +} + +// holdsWorkspaceAdmin matches the connector's own principal against a workspace's +// ADMIN assignments. A service principal is named by its applicationId. +func (u *unityCatalog) holdsWorkspaceAdmin( + ctx context.Context, + assignments []databricks.WorkspaceAssignment, + rateLimit *v2.RateLimitDescription, +) (bool, *v2.RateLimitDescription, error) { + var adminIDs []string + + for _, assignment := range assignments { + principal := assignment.Principal + if principal == nil || !slices.Contains(assignment.Permissions, workspacePermissionAdmin) { + continue + } + if u.ownsPrincipal(principal.ServicePrincipalAppID) { + return true, rateLimit, nil + } + if principal.ID != 0 { + adminIDs = append(adminIDs, strconv.Itoa(principal.ID)) + } + } + + if len(adminIDs) == 0 { + return false, rateLimit, nil + } + + ownID, idRateLimit, err := u.ownPrincipalScimID(ctx) + if idRateLimit != nil { + rateLimit = idRateLimit + } + if err != nil { + return false, rateLimit, err + } + + return ownID != "" && slices.Contains(adminIDs, ownID), rateLimit, nil +} diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go index d888af93..7c75ff46 100644 --- a/pkg/connector/groups.go +++ b/pkg/connector/groups.go @@ -41,7 +41,7 @@ func groupResourceId(_ context.Context, groupId string, parentResourceId *v2.Res } func groupResource(ctx context.Context, group *databricks.Group, parent *v2.ResourceId) (*v2.Resource, error) { - profile := map[string]interface{}{ + profile := map[string]any{ "display_name": group.DisplayName, "group_id": group.ID, "parent_type": parent.GetResourceType(), diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 95fe9142..7cf5b6ba 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -49,9 +49,42 @@ func groupGrantExpansion(ctx context.Context, groupId string, parentResource *v2 }, nil } +func capabilityPermissions(perms ...string) *v2.CapabilityPermissions { + cp := &v2.CapabilityPermissions{Permissions: make([]*v2.CapabilityPermission, 0, len(perms))} + for _, perm := range perms { + cp.Permissions = append(cp.Permissions, &v2.CapabilityPermission{Permission: perm}) + } + + return cp +} + +// noteRateLimit puts the descriptor doRequest parsed on the sync annotations, +// which is the only place the SDK reads it from. +func noteRateLimit(annos *annotations.Annotations, rateLimit *v2.RateLimitDescription) { + if rateLimit != nil { + annos.WithRateLimiting(rateLimit) + } +} + func annotationsForUserResourceType() annotations.Annotations { annos := annotations.Annotations{} annos.Update(&v2.SkipEntitlementsAndGrants{}) + annos.Update(capabilityPermissions(scopeSCIM, permissionAccountAdmin)) + return annos +} + +func annotationsForSecurableResourceType(perms ...string) annotations.Annotations { + annos := annotations.Annotations{} + annos.Update(&v2.OptInRequired{}) + annos.Update(capabilityPermissions(perms...)) + return annos +} + +// StaticEntitlements is a separate SDK pass, so Entitlements() must not also run. +// Grants() still runs. +func annotationsForStaticSecurableResourceType(perms ...string) annotations.Annotations { + annos := annotationsForSecurableResourceType(perms...) + annos.Update(&v2.SkipEntitlements{}) return annos } @@ -201,6 +234,49 @@ func isNotFoundError(err error) bool { return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound } +func isForbiddenError(err error) bool { + var apiErr *databricks.APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden +} + +// A privilege assignment carries the numeric SCIM id in principal_id, which is the +// only handle left on a principal that no longer resolves to a name. +func unityPrincipalScimID(principal *v2.ResourceId) (string, error) { + if principal.GetResourceType() != groupResourceType.Id { + return principal.GetResource(), nil + } + + _, parsed, err := parseResourceId(principal.GetResource()) + if err != nil { + return "", fmt.Errorf("failed to parse group resource id: %w", err) + } + + return parsed.GetResource(), nil +} + +// One `principal` field carries a user's userName, a group's displayName or a service +// principal's applicationId. The second result is false when nothing resolves it, +// which is not an error: a deleted principal keeps its grants and is addressable +// only by principal_id. +func unityPrincipalName(ctx context.Context, c *databricks.Client, principal *v2.ResourceId) (string, bool, error) { + principalId, err := unityPrincipalScimID(principal) + if err != nil { + return "", false, err + } + + qualified, err := preparePrincipalId(ctx, c, "", principal.GetResourceType(), principalId) + if err != nil { + return "", false, err + } + + _, name, ok := strings.Cut(qualified, "/") + if !ok || name == "" { + return "", false, nil + } + + return name, true, nil +} + func prepareWorkspaceRole(entitlement string) string { parts := strings.Split(entitlement, ":") if len(parts) != 2 { diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go new file mode 100644 index 00000000..769cf020 --- /dev/null +++ b/pkg/connector/metastore.go @@ -0,0 +1,604 @@ +package connector + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + ent "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/types/grant" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var _ connectorbuilder.StaticEntitlementSyncerV2 = (*metastoreBuilder)(nil) + +type metastoreBuilder struct { + securableDeps +} + +func newMetastoreBuilder(client *databricks.Client, uc *unityCatalog, willSync func(string) bool) *metastoreBuilder { + return &metastoreBuilder{securableDeps{client: client, uc: uc, willSync: willSync}} +} + +func (m *metastoreBuilder) ResourceType(_ context.Context) *v2.ResourceType { + return metastoreResourceType +} + +func metastoreResource(_ context.Context, metastore *databricks.Metastore, parent *v2.ResourceId) (*v2.Resource, error) { + profile := map[string]any{ + profileKeyMetastoreID: metastore.MetastoreID, + profileKeyRegion: metastore.Region, + profileKeyOwner: metastore.Owner, + profileKeySecurableType: securableTypeMetastore, + } + + name := metastore.Name + if name == "" { + name = metastore.MetastoreID + } + + // The metastore id is the resource id and its RawId: it survives a rename, and + // the permissions endpoint addresses a metastore by UUID and rejects its name. + return rs.NewAppResource( + name, + metastoreResourceType, + metastore.MetastoreID, + nil, + rs.WithResourceProfile(profile), + rs.WithParentResourceID(parent), + rs.WithAnnotation( + &v2.RawId{Id: metastore.MetastoreID}, + ), + ) +} + +// List returns the account's metastores, which arrive in a single unpaged response. +func (m *metastoreBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { + if parentResourceID.GetResourceType() != accountResourceType.Id { + return nil, nil, nil + } + + metastores, rateLimit, err := m.client.ListMetastores(ctx) + annos := annotations.Annotations{} + noteRateLimit(&annos, rateLimit) + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf("databricks-connector: failed to list metastores: %w", err) + } + + var rv []*v2.Resource + for _, metastore := range metastores { + if metastore.MetastoreID == "" { + continue + } + + mr, err := metastoreResource(ctx, &metastore, parentResourceID) + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( + "databricks-connector: failed to build the resource for metastore %s: %w", metastore.MetastoreID, err) + } + + rv = append(rv, mr) + } + + return rv, &rs.SyncOpResults{Annotations: annos}, nil +} + +func (m *metastoreBuilder) StaticEntitlements(_ context.Context, _ rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { + grantable := ent.WithGrantableTo(userResourceType, groupResourceType, servicePrincipalResourceType) + rv := make([]*v2.Entitlement, 0, len(metastorePrivileges)+1) + for _, privilege := range metastorePrivileges { + rv = append(rv, ent.NewPermissionEntitlement(nil, privilege, + grantable, + ent.WithDescription(fmt.Sprintf("%s privilege on a metastore in Databricks", privilege)), + )) + } + + // Owner is not grantable. Ownership is single-valued and has no revoke. + rv = append(rv, ent.NewOwnershipEntitlement(nil, ownerEntitlement, + ent.WithDescription("Owns a metastore in Databricks"), + )) + + return rv, nil, nil +} + +func (m *metastoreBuilder) Entitlements(context.Context, *v2.Resource, rs.SyncOpAttrs) ([]*v2.Entitlement, *rs.SyncOpResults, error) { + return nil, nil, nil +} + +func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, attr rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { + resourceID := resource.GetId().GetResource() + if resourceID == "" || strings.Contains(resourceID, securableKeySeparator) { + return nil, nil, fmt.Errorf("databricks-connector: failed to parse metastore resource id: %w", + fmt.Errorf("invalid metastore resource id %q", resourceID)) + } + ref := securableRef{metastoreID: resourceID} + + annos := annotations.Annotations{} + snap, rateLimit, err := m.uc.buildRouting(ctx) + noteRateLimit(&annos, rateLimit) + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( + "databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) + } + workspace, exists, rateLimit, err := workspaceFromSnapshot(snap, ref.metastoreID, rateLimit) + noteRateLimit(&annos, rateLimit) + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( + "databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) + } + if !exists { + ctxzap.Extract(ctx).Debug("databricks-connector: metastore is no longer listed", + zap.String("metastore", ref.metastoreID), + ) + + return nil, &rs.SyncOpResults{Annotations: annos}, nil + } + + trustworthy, rateLimit, err := m.uc.metastoreGrantsAreTrustworthy(ctx, snap, ref.metastoreID, workspace) + noteRateLimit(&annos, rateLimit) + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( + "databricks-connector: failed to probe grant visibility on metastore %s: %w", ref.permissionsName(), err) + } + if !trustworthy { + return nil, &rs.SyncOpResults{Annotations: annos}, status.Errorf(codes.PermissionDenied, + "databricks-connector: cannot read every grant on metastore %s: the connector principal needs to be a metastore admin", + ref.metastoreID) + } + + l := ctxzap.Extract(ctx) + pageToken := attr.PageToken.Token + assignments, nextPageToken, rateLimit, err := m.client.ListPermissions( + ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), "", pageToken) + noteRateLimit(&annos, rateLimit) + // The permissions endpoint answers 404 for a securable that is gone, for a principal + // it does not know, and for a workspace that does not serve this metastore, and the + // three are separable only by message text. + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( + "databricks-connector: failed to list permissions on %s %s: %w", databricks.SecurableMetastore, ref.permissionsName(), err) + } + + needPrincipals := len(assignments) > 0 + if pageToken == "" { + if owner, ok := rs.GetProfileStringValue(rs.GetProfile(resource), profileKeyOwner); ok && owner != "" { + needPrincipals = true + } + } + + principals := newPrincipalIndex() + if needPrincipals { + var principalRateLimit *v2.RateLimitDescription + principals, principalRateLimit, err = m.uc.listPrincipals(ctx) + noteRateLimit(&annos, principalRateLimit) + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( + "databricks-connector: failed to list account principals: %w", err) + } + } + + var rv []*v2.Grant + for _, assignment := range assignments { + principalId, ok := principals.lookup(assignment) + if !ok { + l.Debug("databricks-connector: skipping a grant whose principal does not resolve", + zap.String("securable", ref.resourceKey()), + zap.String("principal", assignment.Principal), + zap.String("principal_id", assignment.PrincipalID.String()), + ) + + continue + } + + if principalId.GetResourceType() != userResourceType.Id && !m.willSync(principalId.GetResourceType()) { + continue + } + + var options []grant.GrantOption + if principalId.GetResourceType() == groupResourceType.Id { + // Shallow stays unset: setting it would suppress propagation through a nested group. + options = []grant.GrantOption{grant.WithAnnotation(&v2.GrantExpandable{ + EntitlementIds: []string{fmt.Sprintf("group:%s:%s", principalId.GetResource(), groupMemberEntitlement)}, + ResourceTypeIds: []string{userResourceType.Id, groupResourceType.Id, servicePrincipalResourceType.Id}, + })} + } + + for _, assigned := range assignment.Privileges { + if assigned == "" { + continue + } + if !slices.Contains(metastorePrivileges, assigned) { + l.Debug("databricks-connector: skipping a privilege the resource does not offer", + zap.String("securable", ref.resourceKey()), + zap.String("privilege", assigned), + ) + + continue + } + + rv = append(rv, grant.NewGrant(resource, assigned, principalId, options...)) + } + } + + if pageToken == "" { + owner, ok := rs.GetProfileStringValue(rs.GetProfile(resource), profileKeyOwner) + if ok && owner != "" { + principalId, found := principals.lookupName(owner) + if !found { + l.Debug("databricks-connector: securable owner does not resolve to a synced principal", + zap.String("securable", ref.resourceKey()), + zap.String("owner", owner), + ) + } else if principalId.GetResourceType() == userResourceType.Id || m.willSync(principalId.GetResourceType()) { + var options []grant.GrantOption + if principalId.GetResourceType() == groupResourceType.Id { + // Shallow stays unset: setting it would suppress propagation through a nested group. + options = []grant.GrantOption{grant.WithAnnotation(&v2.GrantExpandable{ + EntitlementIds: []string{fmt.Sprintf("group:%s:%s", principalId.GetResource(), groupMemberEntitlement)}, + ResourceTypeIds: []string{userResourceType.Id, groupResourceType.Id, servicePrincipalResourceType.Id}, + })} + } + options = append(options, grant.WithAnnotation(&v2.GrantImmutable{})) + rv = append(rv, grant.NewGrant(resource, ownerEntitlement, principalId, options...)) + } + } + } + + return rv, &rs.SyncOpResults{Annotations: annos, NextPageToken: nextPageToken}, nil +} + +func (m *metastoreBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { + annos := annotations.Annotations{} + principalID := principal.GetId() + + if !isValidPrincipal(principalID) { + return annos, status.Errorf(codes.InvalidArgument, + "databricks-connector: only users, groups and service principals can hold a Unity Catalog privilege, got %s", + principalID.GetResourceType()) + } + + resourceID := entitlement.GetResource().GetId().GetResource() + if resourceID == "" || strings.Contains(resourceID, securableKeySeparator) { + return annos, fmt.Errorf("databricks-connector: failed to parse metastore resource id: %w", + fmt.Errorf("invalid metastore resource id %q", resourceID)) + } + ref := securableRef{metastoreID: resourceID} + + entitlementID := entitlement.GetId() + separator := strings.LastIndex(entitlementID, ":") + if separator < 0 || separator == len(entitlementID)-1 { + return annos, fmt.Errorf("databricks-connector: failed to read the privilege from entitlement %q: %w", + entitlementID, fmt.Errorf("invalid entitlement id %q", entitlementID)) + } + privilege := entitlementID[separator+1:] + + if privilege == ownerEntitlement { + return annos, status.Errorf(codes.InvalidArgument, + "databricks-connector: ownership of metastore %s is single-valued and has no revoke, so it cannot be provisioned; change the owner in Databricks", + ref.permissionsName()) + } + + if isLegacyPrivilege(privilege) { + return annos, status.Errorf(codes.InvalidArgument, + "databricks-connector: %s is a Hive-era alias that Databricks rewrites server-side — CREATE on a schema becomes "+ + "CREATE_TABLE plus CREATE_FUNCTION — so it would grant more than was asked for and would never be read back "+ + "under that name; ask for the explicit privilege instead", + privilege) + } + + if !slices.Contains(metastorePrivileges, privilege) { + return annos, status.Errorf(codes.InvalidArgument, + "databricks-connector: %s is not a grantable privilege on metastore %s", + privilege, ref.permissionsName()) + } + + snap, rateLimit, err := m.uc.buildRouting(ctx) + noteRateLimit(&annos, rateLimit) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) + } + workspace, exists, rateLimit, err := workspaceFromSnapshot(snap, ref.metastoreID, rateLimit) + noteRateLimit(&annos, rateLimit) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) + } + if !exists { + return annos, status.Errorf(codes.NotFound, + "databricks-connector: metastore %s is no longer listed on the account, so it cannot be changed", ref.metastoreID) + } + + trustworthy, rateLimit, err := m.uc.metastoreGrantsAreTrustworthy(ctx, snap, ref.metastoreID, workspace) + noteRateLimit(&annos, rateLimit) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to probe grant visibility on metastore %s: %w", + ref.permissionsName(), err) + } + if !trustworthy { + return annos, status.Errorf(codes.PermissionDenied, + "databricks-connector: cannot read every grant on metastore %s: the connector principal needs to be a metastore admin", + ref.metastoreID) + } + + scimID, err := unityPrincipalScimID(principalID) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to read the SCIM id of principal %s: %w", + principalID.GetResource(), err) + } + + principalName, _, err := unityPrincipalName(ctx, m.client, principalID) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to resolve the Unity Catalog name of principal %s: %w", + principalID.GetResource(), err) + } + + if principalName == "" { + return annos, status.Errorf(codes.NotFound, + "databricks-connector: principal %s no longer exists in Databricks, so it cannot be granted %s on %s %s", + scimID, privilege, databricks.SecurableMetastore, ref.permissionsName()) + } + + matchHeld := func(assignments []databricks.PrivilegeAssignment) (databricks.PrivilegeAssignment, bool) { + var nameOnly databricks.PrivilegeAssignment + var sawNameOnly bool + for _, assignment := range assignments { + if !slices.Contains(assignment.Privileges, privilege) { + continue + } + + assignmentID := assignment.PrincipalID.String() + if scimID != "" && assignmentID == scimID && assignmentID != "0" { + return assignment, true + } + if principalName == "" || assignment.Principal != principalName { + continue + } + if scimID != "" && assignmentID != "" && assignmentID != "0" && assignmentID != scimID { + continue + } + if !sawNameOnly { + nameOnly = assignment + sawNameOnly = true + } + } + if sawNameOnly { + return nameOnly, true + } + + return databricks.PrivilegeAssignment{}, false + } + + assignments, rateLimit, err := m.client.DrainPermissions(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName()) + noteRateLimit(&annos, rateLimit) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to read privileges on %s %s: %w", + databricks.SecurableMetastore, ref.permissionsName(), err) + } + + if _, held := matchHeld(assignments); held { + annos.Update(&v2.GrantAlreadyExists{}) + + return annos, nil + } + + var echoed []databricks.PrivilegeAssignment + rateLimit, err = m.client.UpdatePermissionsUntil(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), + []databricks.PermissionsChange{{Principal: principalName, Add: []string{privilege}}}, + func(page []databricks.PrivilegeAssignment) (bool, error) { + echoed = append(echoed, page...) + _, held := matchHeld(echoed) + return held, nil + }) + noteRateLimit(&annos, rateLimit) + if err != nil { + if isForbiddenError(err) { + return annos, uhttp.WrapErrors( + codes.PermissionDenied, + fmt.Sprintf("databricks-connector: cannot grant %s on %s %s: the connector principal needs MANAGE on it", + privilege, databricks.SecurableMetastore, ref.permissionsName()), + err, + ) + } + + return annos, fmt.Errorf("databricks-connector: failed to grant %s on %s %s: %w", + privilege, databricks.SecurableMetastore, ref.permissionsName(), err) + } + + if _, held := matchHeld(echoed); !held { + return annos, status.Errorf(codes.Internal, + "databricks-connector: Databricks accepted granting %s to %s on %s %s but did not report the privilege on the securable afterwards, "+ + "so the grant was not applied", + privilege, principalName, databricks.SecurableMetastore, ref.permissionsName()) + } + + return annos, nil +} + +func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annotations.Annotations, error) { + annos := annotations.Annotations{} + principalID := revoked.GetPrincipal().GetId() + entitlement := revoked.GetEntitlement() + + if !isValidPrincipal(principalID) { + return annos, status.Errorf(codes.InvalidArgument, + "databricks-connector: only users, groups and service principals can hold a Unity Catalog privilege, got %s", + principalID.GetResourceType()) + } + + resourceID := entitlement.GetResource().GetId().GetResource() + if resourceID == "" || strings.Contains(resourceID, securableKeySeparator) { + return annos, fmt.Errorf("databricks-connector: failed to parse metastore resource id: %w", + fmt.Errorf("invalid metastore resource id %q", resourceID)) + } + ref := securableRef{metastoreID: resourceID} + + entitlementID := entitlement.GetId() + separator := strings.LastIndex(entitlementID, ":") + if separator < 0 || separator == len(entitlementID)-1 { + return annos, fmt.Errorf("databricks-connector: failed to read the privilege from entitlement %q: %w", + entitlementID, fmt.Errorf("invalid entitlement id %q", entitlementID)) + } + privilege := entitlementID[separator+1:] + + if privilege == ownerEntitlement { + return annos, status.Errorf(codes.InvalidArgument, + "databricks-connector: ownership of metastore %s is single-valued and has no revoke, so it cannot be provisioned; change the owner in Databricks", + ref.permissionsName()) + } + + if isLegacyPrivilege(privilege) { + return annos, status.Errorf(codes.InvalidArgument, + "databricks-connector: %s is a Hive-era alias that Databricks rewrites server-side — CREATE on a schema becomes "+ + "CREATE_TABLE plus CREATE_FUNCTION — so it would grant more than was asked for and would never be read back "+ + "under that name; ask for the explicit privilege instead", + privilege) + } + + if !slices.Contains(metastorePrivileges, privilege) { + return annos, status.Errorf(codes.InvalidArgument, + "databricks-connector: %s is not a grantable privilege on metastore %s", + privilege, ref.permissionsName()) + } + + snap, rateLimit, err := m.uc.buildRouting(ctx) + noteRateLimit(&annos, rateLimit) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) + } + workspace, exists, rateLimit, err := workspaceFromSnapshot(snap, ref.metastoreID, rateLimit) + noteRateLimit(&annos, rateLimit) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) + } + if !exists { + return annos, status.Errorf(codes.NotFound, + "databricks-connector: metastore %s is no longer listed on the account, so it cannot be changed", ref.metastoreID) + } + + trustworthy, rateLimit, err := m.uc.metastoreGrantsAreTrustworthy(ctx, snap, ref.metastoreID, workspace) + noteRateLimit(&annos, rateLimit) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to probe grant visibility on metastore %s: %w", + ref.permissionsName(), err) + } + if !trustworthy { + return annos, status.Errorf(codes.PermissionDenied, + "databricks-connector: cannot read every grant on metastore %s: the connector principal needs to be a metastore admin", + ref.metastoreID) + } + + scimID, err := unityPrincipalScimID(principalID) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to read the SCIM id of principal %s: %w", + principalID.GetResource(), err) + } + + principalName, _, err := unityPrincipalName(ctx, m.client, principalID) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to resolve the Unity Catalog name of principal %s: %w", + principalID.GetResource(), err) + } + + matchHeld := func(assignments []databricks.PrivilegeAssignment) (databricks.PrivilegeAssignment, bool) { + var nameOnly databricks.PrivilegeAssignment + var sawNameOnly bool + for _, assignment := range assignments { + if !slices.Contains(assignment.Privileges, privilege) { + continue + } + + assignmentID := assignment.PrincipalID.String() + if scimID != "" && assignmentID == scimID && assignmentID != "0" { + return assignment, true + } + if principalName == "" || assignment.Principal != principalName { + continue + } + if scimID != "" && assignmentID != "" && assignmentID != "0" && assignmentID != scimID { + continue + } + if !sawNameOnly { + nameOnly = assignment + sawNameOnly = true + } + } + if sawNameOnly { + return nameOnly, true + } + + return databricks.PrivilegeAssignment{}, false + } + + var seen []databricks.PrivilegeAssignment + rateLimit, err = m.client.ForEachPermissionsPage(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), "", + func(page []databricks.PrivilegeAssignment) (bool, error) { + seen = append(seen, page...) + matched, ok := matchHeld(seen) + if !ok { + return false, nil + } + matchedID := matched.PrincipalID.String() + return scimID != "" && matchedID == scimID && matchedID != "0", nil + }) + noteRateLimit(&annos, rateLimit) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to read privileges on %s %s: %w", + databricks.SecurableMetastore, ref.permissionsName(), err) + } + + held, ok := matchHeld(seen) + if !ok { + annos.Update(&v2.GrantAlreadyRevoked{}) + + return annos, nil + } + + var removal databricks.PermissionsChange + if heldID := held.PrincipalID.String(); heldID != "" && heldID != "0" { + removal = databricks.PermissionsChange{PrincipalID: held.PrincipalID, Remove: []string{privilege}} + } else if principalName != "" { + removal = databricks.PermissionsChange{Principal: principalName, Remove: []string{privilege}} + } else { + removal = databricks.PermissionsChange{PrincipalID: held.PrincipalID, Remove: []string{privilege}} + } + + result, rateLimit, err := m.client.UpdatePermissions(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), + []databricks.PermissionsChange{removal}) + noteRateLimit(&annos, rateLimit) + if err != nil { + if isForbiddenError(err) { + return annos, uhttp.WrapErrors( + codes.PermissionDenied, + fmt.Sprintf("databricks-connector: cannot revoke %s on %s %s: the connector principal needs MANAGE on it", + privilege, databricks.SecurableMetastore, ref.permissionsName()), + err, + ) + } + + return annos, fmt.Errorf("databricks-connector: failed to revoke %s on %s %s: %w", + privilege, databricks.SecurableMetastore, ref.permissionsName(), err) + } + + if _, stillHeld := matchHeld(result); stillHeld { + label := principalName + if label == "" { + label = scimID + } + + return annos, status.Errorf(codes.Internal, + "databricks-connector: Databricks accepted revoking %s from %s on %s %s but still reports the privilege on the securable, "+ + "so the revoke was not applied", + privilege, label, databricks.SecurableMetastore, ref.permissionsName()) + } + + return annos, nil +} diff --git a/pkg/connector/principal_index.go b/pkg/connector/principal_index.go new file mode 100644 index 00000000..a0183434 --- /dev/null +++ b/pkg/connector/principal_index.go @@ -0,0 +1,264 @@ +package connector + +import ( + "context" + "fmt" + "strings" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +// principalIndex resolves the principal a privilege assignment names. The payload +// carries no type discriminator and a name-built SCIM filter cannot settle it: +// Databricks answers an unmatched filter with 200 and zero results. Built from +// account SCIM, never workspace SCIM. +type principalIndex struct { + byID map[string]*v2.ResourceId + byName map[string]*v2.ResourceId +} + +// lookup prefers the numeric id and never falls back to the name when the id is +// present but unknown: userName, displayName and applicationId share that field. +// A deleted principal arrives with an empty name and only the id. +func (p *principalIndex) lookup(assignment databricks.PrivilegeAssignment) (*v2.ResourceId, bool) { + if id := assignment.PrincipalID.String(); id != "" && id != "0" { + resourceId, ok := p.byID[id] + return resourceId, ok + } + + return p.lookupName(assignment.Principal) +} + +func (p *principalIndex) lookupName(name string) (*v2.ResourceId, bool) { + if name == "" { + return nil, false + } + + resourceId, ok := p.byName[name] + + return resourceId, ok +} + +// add records a principal; the first writer of a key wins. A name collision is expected +// because the three namespaces share one field. An id collision is not: SCIM ids are +// unique across them, so one means a grant will resolve to the wrong principal. +func (p *principalIndex) add(ctx context.Context, id string, names []string, resourceId *v2.ResourceId) { + if id != "" { + if existing, ok := p.byID[id]; ok { + if existing.GetResourceType() != resourceId.GetResourceType() || existing.GetResource() != resourceId.GetResource() { + ctxzap.Extract(ctx).Debug("databricks-connector: two principals share one SCIM id; keeping the first", + zap.String("principal_id", id), + zap.String("kept", fmt.Sprintf("%s:%s", existing.GetResourceType(), existing.GetResource())), + zap.String("dropped", fmt.Sprintf("%s:%s", resourceId.GetResourceType(), resourceId.GetResource())), + ) + } + } else { + p.byID[id] = resourceId + } + } + + for _, name := range names { + if name == "" { + continue + } + if _, ok := p.byName[name]; !ok { + p.byName[name] = resourceId + } + } +} + +func newPrincipalIndex() *principalIndex { + return &principalIndex{ + byID: make(map[string]*v2.ResourceId), + byName: make(map[string]*v2.ResourceId), + } +} + +func (u *unityCatalog) ownsPrincipal(name string) bool { + return u.ownPrincipal != "" && strings.EqualFold(name, u.ownPrincipal) +} + +func (u *unityCatalog) listPrincipals(ctx context.Context) (*principalIndex, *v2.RateLimitDescription, error) { + accountResourceId, err := rs.NewResourceID(accountResourceType, u.client.GetAccountId()) + if err != nil { + return nil, nil, err + } + + index := newPrincipalIndex() + + rateLimit, err := u.indexUsers(ctx, index) + if err != nil { + return nil, rateLimit, err + } + groupRateLimit, err := u.indexGroups(ctx, index, accountResourceId) + if groupRateLimit != nil { + rateLimit = groupRateLimit + } + if err != nil { + return nil, rateLimit, err + } + servicePrincipalRateLimit, err := u.indexServicePrincipals(ctx, index) + if servicePrincipalRateLimit != nil { + rateLimit = servicePrincipalRateLimit + } + if err != nil { + return nil, rateLimit, err + } + + return index, rateLimit, nil +} + +func (u *unityCatalog) indexUsers(ctx context.Context, index *principalIndex) (*v2.RateLimitDescription, error) { + var rateLimit *v2.RateLimitDescription + + for page := uint(1); ; { + if err := ctx.Err(); err != nil { + return rateLimit, err + } + + users, total, pageRateLimit, err := u.client.ListUsers(ctx, "", databricks.NewPaginationVars(page, ResourcesPageSize), databricks.NewUserAttrVars()) + if pageRateLimit != nil { + rateLimit = pageRateLimit + } + if err != nil { + return rateLimit, fmt.Errorf("failed to list account users: %w", err) + } + if len(users) == 0 { + return rateLimit, nil + } + + for _, user := range users { + index.add(ctx, user.ID, []string{user.UserName}, &v2.ResourceId{ResourceType: userResourceType.Id, Resource: user.ID}) + } + + next, err := nextScimPage(page, len(users), total) + if err != nil { + return rateLimit, err + } + if next == 0 { + return rateLimit, nil + } + page = next + } +} + +func (u *unityCatalog) indexGroups(ctx context.Context, index *principalIndex, parent *v2.ResourceId) (*v2.RateLimitDescription, error) { + var rateLimit *v2.RateLimitDescription + + for page := uint(1); ; { + if err := ctx.Err(); err != nil { + return rateLimit, err + } + + groups, total, pageRateLimit, err := u.client.ListGroups(ctx, "", databricks.NewPaginationVars(page, ResourcesPageSize), databricks.NewGroupAttrVars()) + if pageRateLimit != nil { + rateLimit = pageRateLimit + } + if err != nil { + return rateLimit, fmt.Errorf("failed to list account groups: %w", err) + } + if len(groups) == 0 { + return rateLimit, nil + } + + for _, group := range groups { + // Groups sync under the account with a composite resource id; the index must + // hold the same form or a grant points at a resource that was never synced. + resourceId, err := rs.NewResourceID(groupResourceType, groupResourceId(ctx, group.ID, parent)) + if err != nil { + return rateLimit, err + } + index.add(ctx, group.ID, []string{group.DisplayName}, resourceId) + } + + next, err := nextScimPage(page, len(groups), total) + if err != nil { + return rateLimit, err + } + if next == 0 { + return rateLimit, nil + } + page = next + } +} + +func (u *unityCatalog) indexServicePrincipals(ctx context.Context, index *principalIndex) (*v2.RateLimitDescription, error) { + var rateLimit *v2.RateLimitDescription + + for page := uint(1); ; { + if err := ctx.Err(); err != nil { + return rateLimit, err + } + + servicePrincipals, total, pageRateLimit, err := u.client.ListServicePrincipals(ctx, "", databricks.NewPaginationVars(page, ResourcesPageSize), databricks.NewServicePrincipalAttrVars()) + if pageRateLimit != nil { + rateLimit = pageRateLimit + } + if err != nil { + return rateLimit, fmt.Errorf("failed to list account service principals: %w", err) + } + if len(servicePrincipals) == 0 { + return rateLimit, nil + } + + for _, servicePrincipal := range servicePrincipals { + // A privilege assignment names a service principal by its applicationId, + // not its displayName and not its SCIM id. + index.add( + ctx, + servicePrincipal.ID, + []string{servicePrincipal.ApplicationID}, + &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: servicePrincipal.ID}, + ) + } + + next, err := nextScimPage(page, len(servicePrincipals), total) + if err != nil { + return rateLimit, err + } + if next == 0 { + return rateLimit, nil + } + page = next + } +} + +// ownPrincipalScimID lists account SCIM for this call only. A name-built filter +// cannot tell a service principal from a user or a group that shares the field. +func (u *unityCatalog) ownPrincipalScimID(ctx context.Context) (string, *v2.RateLimitDescription, error) { + if u.ownPrincipal == "" { + return "", nil, nil + } + + index, rateLimit, err := u.listPrincipals(ctx) + if err != nil { + return "", rateLimit, err + } + + resourceId, ok := index.lookupName(u.ownPrincipal) + if !ok || resourceId.GetResourceType() != servicePrincipalResourceType.Id { + // The three namespaces share one name field, so a hit elsewhere is somebody else's. + return "", rateLimit, nil + } + + return resourceId.GetResource(), rateLimit, nil +} + +// nextScimPage returns zero once the listing is exhausted. +func nextScimPage(page uint, pageTotal int, total uint) (uint, error) { + token := prepareNextToken(page, pageTotal, total) + if token == "" { + return 0, nil + } + + next, err := convertPageToken(token) + if err != nil { + return 0, err + } + + return next, nil +} diff --git a/pkg/connector/privileges.go b/pkg/connector/privileges.go new file mode 100644 index 00000000..db53eb00 --- /dev/null +++ b/pkg/connector/privileges.go @@ -0,0 +1,53 @@ +package connector + +import ( + "slices" + "strings" +) + +// The canonical UPPER_SNAKE form the API answers with. It accepts lowercase and +// aliases on input, so a privilege offered under another spelling cannot round-trip. +const ( + // ownerEntitlement names the securable's owner. Ownership is single-valued and + // Databricks has no notion of removing an owner, so it is never grantable. + ownerEntitlement = "owner" +) + +var ( + // metastorePrivileges is the set for the metastore itself. ALL_PRIVILEGES and MANAGE + // are both rejected here, which is why a metastore's grant visibility cannot be + // answered by a privilege read. READ_METADATA is the only entry that inherits down. + metastorePrivileges = []string{ + "CREATE_CATALOG", + "CREATE_CLEAN_ROOM", + "CREATE_CONNECTION", + "CREATE_EXTERNAL_LOCATION", + "CREATE_EXTERNAL_METADATA", + "CREATE_PROVIDER", + "CREATE_RECIPIENT", + "CREATE_SHARE", + "CREATE_SERVICE_CREDENTIAL", + "CREATE_STORAGE_CREDENTIAL", + "MANAGE_ALLOWLIST", + "READ_METADATA", + "SET_SHARE_PERMISSION", + "USE_MARKETPLACE_ASSETS", + "USE_PROVIDER", + "USE_RECIPIENT", + "USE_SHARE", + } + + // legacyPrivileges are Hive-era aliases the API accepts on input and silently + // rewrites: CREATE on a schema becomes CREATE_TABLE plus CREATE_FUNCTION. They are + // never echoed back, so they must not be offered. + legacyPrivileges = []string{ + "CREATE", + "CREATE_VIEW", + "USAGE", + } +) + +// An alias is never in a level's set, so this only changes the explanation. +func isLegacyPrivilege(privilege string) bool { + return slices.Contains(legacyPrivileges, strings.ToUpper(strings.TrimSpace(privilege))) +} diff --git a/pkg/connector/raw_id_test.go b/pkg/connector/raw_id_test.go index d12a99ba..b5fc85a7 100644 --- a/pkg/connector/raw_id_test.go +++ b/pkg/connector/raw_id_test.go @@ -65,6 +65,11 @@ func TestResourcesHaveRawId(t *testing.T) { build((&servicePrincipalBuilder{}).servicePrincipalResource(ctx, &databricks.ServicePrincipal{BaseResponse: databricks.BaseResponse{ID: "sp1"}}, account)), "sp1", }, + { + "metastore", + build(metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms1", Name: "ms"}, account)), + "ms1", + }, } for _, tc := range cases { diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 748bdbef..2e07ce95 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -2,8 +2,36 @@ package connector import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" ) +// The Databricks permissions this connector's reads depend on. +const ( + scopeUnityCatalog = "unity-catalog" + scopeSCIM = "scim" + scopeAccessManagement = "access-management" + scopeProvisioning = "provisioning" +) + +const ( + permissionAccountAdmin = "Account admin" + permissionWorkspaceAdmin = "Workspace admin" +) + +var securableScopes = []string{ + scopeUnityCatalog, + scopeSCIM, + scopeAccessManagement, + scopeProvisioning, +} + +func securablePermissions(privileges ...string) []string { + perms := append([]string{}, securableScopes...) + perms = append(perms, permissionAccountAdmin, permissionWorkspaceAdmin) + + return append(perms, privileges...) +} + var ( // The user resource type is for all user objects from the database. userResourceType = &v2.ResourceType{ @@ -18,6 +46,7 @@ var ( Id: "group", DisplayName: "Group", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, + Annotations: annotations.New(capabilityPermissions(scopeSCIM, scopeAccessManagement, permissionAccountAdmin)), } // The service principal resource type is for all service principal objects from the database. @@ -25,6 +54,7 @@ var ( Id: "service_principal", DisplayName: "Service Principal", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, + Annotations: annotations.New(capabilityPermissions(scopeSCIM, scopeAccessManagement, permissionAccountAdmin)), } // The role resource type is for all static roles and entitlements available in API. @@ -32,6 +62,7 @@ var ( Id: "role", DisplayName: "Role", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}, + Annotations: annotations.New(capabilityPermissions(scopeSCIM, permissionAccountAdmin)), } // The workspace resource type is for all workspace objects from the database. @@ -39,11 +70,19 @@ var ( Id: "workspace", DisplayName: "Workspace", Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, + Annotations: annotations.New(capabilityPermissions(scopeProvisioning, scopeAccessManagement, permissionAccountAdmin)), } // The account resource type is for top level resource type. accountResourceType = &v2.ResourceType{ Id: "account", DisplayName: "Account", + Annotations: annotations.New(capabilityPermissions(scopeAccessManagement, permissionAccountAdmin)), + } + metastoreResourceType = &v2.ResourceType{ + Id: "metastore", + DisplayName: "Metastore", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_APP}, + Annotations: annotationsForStaticSecurableResourceType(securablePermissions()...), } ) diff --git a/pkg/connector/roles.go b/pkg/connector/roles.go index f268d45c..cd206e69 100644 --- a/pkg/connector/roles.go +++ b/pkg/connector/roles.go @@ -45,7 +45,7 @@ func (r *roleBuilder) ResourceType(ctx context.Context) *v2.ResourceType { func roleResource(ctx context.Context, role string, parent *v2.ResourceId) (*v2.Resource, error) { var roleID string - profile := map[string]interface{}{ + profile := map[string]any{ "role_name": role, "parent_type": parent.ResourceType, "parent_id": parent.Resource, diff --git a/pkg/connector/securable.go b/pkg/connector/securable.go new file mode 100644 index 00000000..29b5ef1e --- /dev/null +++ b/pkg/connector/securable.go @@ -0,0 +1,57 @@ +package connector + +import ( + "strings" + + "github.com/conductorone/baton-databricks/pkg/databricks" +) + +const ( + securableTypeMetastore = "METASTORE" + + profileKeyMetastoreID = "metastore_id" + profileKeyOwner = "owner" + profileKeySecurableType = "securable_type" + profileKeyRegion = "region" + + // Not a dot: a securable's full name is already dot-separated. + securableKeySeparator = "::" +) + +// securableRef is a securable keyed by its metastore plus its name components, +// catalog first. Databricks rejects a name holding a space, a period or a forward +// slash, so splitting a full name on "." is unambiguous. +type securableRef struct { + metastoreID string + parts []string +} + +func (r securableRef) fullName() string { + return strings.Join(r.parts, ".") +} + +// permissionsName is the dotted full name, or the metastore's UUID for the metastore +// itself, whose name the endpoint rejects with 400 "Invalid UUID string". +func (r securableRef) permissionsName() string { + if len(r.parts) == 0 { + return r.metastoreID + } + + return r.fullName() +} + +// resourceKey is the resource id, which every level also carries as its RawId. +func (r securableRef) resourceKey() string { + if len(r.parts) == 0 { + return r.metastoreID + } + + return r.metastoreID + securableKeySeparator + r.fullName() +} + +// securableDeps is the client state every Unity Catalog builder shares. +type securableDeps struct { + client *databricks.Client + uc *unityCatalog + willSync func(string) bool +} diff --git a/pkg/connector/service-principals.go b/pkg/connector/service-principals.go index 737e2238..a4359b3b 100644 --- a/pkg/connector/service-principals.go +++ b/pkg/connector/service-principals.go @@ -26,7 +26,7 @@ func (s *servicePrincipalBuilder) ResourceType(ctx context.Context) *v2.Resource } func (s *servicePrincipalBuilder) servicePrincipalResource(ctx context.Context, servicePrincipal *databricks.ServicePrincipal, parent *v2.ResourceId) (*v2.Resource, error) { - profile := map[string]interface{}{ + profile := map[string]any{ "application_id": servicePrincipal.ApplicationID, "display_name": servicePrincipal.DisplayName, "parent_type": parent.ResourceType, diff --git a/pkg/connector/unity_catalog.go b/pkg/connector/unity_catalog.go new file mode 100644 index 00000000..e8a4da34 --- /dev/null +++ b/pkg/connector/unity_catalog.go @@ -0,0 +1,248 @@ +package connector + +import ( + "context" + "fmt" + "slices" + "strconv" + "strings" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + workspaceStatusRunning = "RUNNING" +) + +// routingSnapshot is the account's Unity Catalog access paths for the call that +// just listed them. Nothing on the connector keeps it. +type routingSnapshot struct { + metastores map[string]databricks.Metastore + workspaces map[string][]string + workspaceIDs map[string]string + unusable []string + asked int +} + +// unityCatalog holds no state beyond the client: every call asks Databricks and +// drops the answer when it returns. +type unityCatalog struct { + client *databricks.Client + + // ownPrincipal is the applicationId of the service principal the connector authenticates as. + ownPrincipal string +} + +func newUnityCatalog(client *databricks.Client, ownPrincipal string) *unityCatalog { + return &unityCatalog{ + client: client, + ownPrincipal: ownPrincipal, + } +} + +func workspaceFromSnapshot(snap routingSnapshot, metastoreID string, rateLimit *v2.RateLimitDescription) (string, bool, *v2.RateLimitDescription, error) { + workspaces, exists, err := usableWorkspaces(snap, metastoreID) + if err != nil || !exists { + return "", exists, rateLimit, err + } + + return workspaces[0], true, rateLimit, nil +} + +func usableWorkspaces(snap routingSnapshot, metastoreID string) ([]string, bool, error) { + if workspaces := snap.workspaces[metastoreID]; len(workspaces) > 0 { + return workspaces, true, nil + } + if _, ok := snap.metastores[metastoreID]; !ok { + return nil, false, nil + } + + unusable := "" + if len(snap.unusable) > 0 { + unusable = fmt.Sprintf("; %d running workspace(s) reported neither an assignment nor a catalog: %s", len(snap.unusable), strings.Join(snap.unusable, ", ")) + } + + return nil, true, status.Errorf(codes.FailedPrecondition, + "metastore %s exists on the account but no workspace this sync can use is attached to it, so its securables cannot be read: "+ + "every attached workspace is either named in databricks-exclude-workspaces, not RUNNING, or reports no metastore assignment%s", + metastoreID, unusable) +} + +// buildRouting lists metastores and the workspaces each one is reachable through. +func (u *unityCatalog) buildRouting(ctx context.Context) (routingSnapshot, *v2.RateLimitDescription, error) { + metastores, rateLimit, err := u.listMetastores(ctx) + if err != nil { + return routingSnapshot{}, rateLimit, err + } + + snap := routingSnapshot{ + metastores: metastores, + workspaces: make(map[string][]string), + workspaceIDs: make(map[string]string), + } + + workspaceRateLimit, err := u.fillWorkspaces(ctx, &snap) + if workspaceRateLimit != nil { + rateLimit = workspaceRateLimit + } + if err != nil { + return routingSnapshot{}, rateLimit, err + } + + return snap, rateLimit, nil +} + +func (u *unityCatalog) listMetastores(ctx context.Context) (map[string]databricks.Metastore, *v2.RateLimitDescription, error) { + metastores, rateLimit, err := u.client.ListMetastores(ctx) + if err != nil { + return nil, rateLimit, fmt.Errorf("failed to list metastores: %w", err) + } + + byID := make(map[string]databricks.Metastore, len(metastores)) + for _, metastore := range metastores { + if metastore.MetastoreID == "" { + continue + } + byID[metastore.MetastoreID] = metastore + } + + return byID, rateLimit, nil +} + +// fillWorkspaces maps metastores to workspaces and deployment names to numeric ids. +// The securable endpoints take the deployment name; the assignment endpoint does not. +func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot) (*v2.RateLimitDescription, error) { + l := ctxzap.Extract(ctx) + + workspaces, rateLimit, err := u.client.ListWorkspaces(ctx) + if err != nil { + return rateLimit, fmt.Errorf("failed to list workspaces: %w", err) + } + + var unassigned []string + asked := 0 + for _, workspace := range workspaces { + if err := ctx.Err(); err != nil { + return rateLimit, err + } + + if workspace.DeploymentName == "" { + continue + } + if workspace.Status != "" && !strings.EqualFold(workspace.Status, workspaceStatusRunning) { + continue + } + + asked++ + accountID := strconv.Itoa(workspace.ID) + snap.workspaceIDs[workspace.DeploymentName] = accountID + + metastoreID, assignmentRateLimit, err := u.client.GetWorkspaceMetastore(ctx, accountID) + if assignmentRateLimit != nil { + rateLimit = assignmentRateLimit + } + if err != nil { + if isNotFoundError(err) { + // Databricks answers 404 both for a workspace outside Unity Catalog and + // for an assignment the credential may not read. + unassigned = append(unassigned, workspace.DeploymentName) + + continue + } + + return rateLimit, fmt.Errorf("failed to resolve metastore for workspace %s: %w", workspace.DeploymentName, err) + } + if metastoreID == "" { + continue + } + + snap.workspaces[metastoreID] = append(snap.workspaces[metastoreID], workspace.DeploymentName) + } + + // A catalog payload names its metastore, so the listing settles which metastore + // this workspace serves, if any. + var unusable []string + for _, workspace := range unassigned { + if err := ctx.Err(); err != nil { + return rateLimit, err + } + + metastoreIDs, listRateLimit, err := u.metastoresServedBy(ctx, workspace) + if listRateLimit != nil { + rateLimit = listRateLimit + } + if err != nil { + if isNotFoundError(err) { + unusable = append(unusable, workspace) + + continue + } + + return rateLimit, fmt.Errorf("failed to list catalogs through workspace %s: %w", workspace, err) + } + if len(metastoreIDs) == 0 { + unusable = append(unusable, workspace) + + continue + } + + for _, metastoreID := range metastoreIDs { + snap.workspaces[metastoreID] = append(snap.workspaces[metastoreID], workspace) + } + } + + // Every workspace carrying no securable is indistinguishable from an account with + // no Unity Catalog; the metastore list is the authority on which it is. + if asked > 0 && len(snap.workspaces) == 0 && len(snap.metastores) > 0 { + return rateLimit, status.Errorf(codes.FailedPrecondition, + "the account has %d Unity Catalog metastore(s) but none of the %d running workspace(s) reported a metastore assignment or could "+ + "list a catalog, so no securable can be listed; the connector principal needs permission to read workspace metastore "+ + "assignments on the account", + len(snap.metastores), asked) + } + + if len(unusable) > 0 { + l.Debug("databricks-connector: a running workspace reported no metastore assignment and listed no catalog, so it carries nothing this sync can read", + zap.Strings("workspaces", unusable), + zap.Int("running_workspaces", asked), + zap.Int("metastores_with_an_access_path", len(snap.workspaces)), + ) + } + + snap.unusable = unusable + snap.asked = asked + + return rateLimit, nil +} + +// metastoresServedBy names the metastores whose catalogs a workspace can list, which +// separates the two meanings of a 404 from the assignment endpoint. The catalog filter +// is deliberately not applied: an out-of-scope catalog is still an access path. +func (u *unityCatalog) metastoresServedBy(ctx context.Context, workspace string) ([]string, *v2.RateLimitDescription, error) { + served := make(map[string]struct{}) + rateLimit, err := u.client.ForEachCatalogPage(ctx, workspace, ResourcesPageSize, func(page []databricks.Catalog) (bool, error) { + for _, catalog := range page { + if catalog.MetastoreID == "" { + continue + } + served[catalog.MetastoreID] = struct{}{} + } + return false, nil + }) + if err != nil { + return nil, rateLimit, err + } + + metastoreIDs := make([]string, 0, len(served)) + for metastoreID := range served { + metastoreIDs = append(metastoreIDs, metastoreID) + } + slices.Sort(metastoreIDs) + + return metastoreIDs, rateLimit, nil +} diff --git a/pkg/connector/unity_catalog_test.go b/pkg/connector/unity_catalog_test.go new file mode 100644 index 00000000..442f62ff --- /dev/null +++ b/pkg/connector/unity_catalog_test.go @@ -0,0 +1,119 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + + "github.com/conductorone/baton-databricks/pkg/databricks" +) + +// workspaceRouter dispatches on the deployment-name label of the request host, +// the way Databricks routes an account whose workspaces sit on separate hosts. +type workspaceRouter struct { + target string + hits map[string]int +} + +func (r *workspaceRouter) RoundTrip(req *http.Request) (*http.Response, error) { + workspace, _, _ := strings.Cut(req.URL.Host, ".") + r.hits[workspace]++ + + routed := req.Clone(req.Context()) + routed.URL.Scheme = "http" + routed.URL.Host = r.target + routed.Header.Set("X-Test-Workspace", workspace) + + return http.DefaultTransport.RoundTrip(routed) +} + +func newWorkspaceRoutedClient(t *testing.T, handler http.HandlerFunc) (*databricks.Client, *workspaceRouter) { + t.Helper() + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + router := &workspaceRouter{target: strings.TrimPrefix(srv.URL, "http://"), hits: map[string]int{}} + client, err := databricks.NewClient(context.Background(), &http.Client{Transport: router}, + testHostname, testAccountHost, testAccountId, "", &databricks.NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + return client, router +} + +// These run at the cache's default settings on purpose: BATON_HTTP_CACHE_TTL is +// deliberately not set, because turning the cache off is what hid this and a test +// that turns it off guards nothing. + +// Every workspace answers /api/2.1/unity-catalog/catalogs with the same path and +// query, so a cache entry shared between hosts files a workspace under a metastore +// it cannot reach and routes every securable read below it to the wrong host. +func TestWorkspaceMetastoreLookupIsNotAnsweredByAnotherWorkspacesCachedListing(t *testing.T) { + served := map[string]string{"wsa": "ms-a", "wsb": "ms-b"} + + client, router := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + workspace := r.Header.Get("X-Test-Workspace") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "catalogs": []map[string]any{{ + "name": "main", + "metastore_id": served[workspace], + }}, + }) + }) + + uc := newUnityCatalog(client, "app-1") + ctx := context.Background() + + for _, workspace := range []string{"wsa", "wsb"} { + metastoreIDs, _, err := uc.metastoresServedBy(ctx, workspace) + if err != nil { + t.Fatalf("metastoresServedBy(%s): %v", workspace, err) + } + + if router.hits[workspace] == 0 { + t.Errorf("workspace %s was never asked for its catalogs; hits = %v", workspace, router.hits) + } + if want := []string{served[workspace]}; !slices.Equal(metastoreIDs, want) { + t.Errorf("workspace %s serves %v, want %v: a catalog listing crossed between workspace hosts", + workspace, metastoreIDs, want) + } + } +} + +// `principal` holds a userName, a displayName or an applicationId with no type +// discriminator, so falling back to the name when the id does not resolve +// attributes the grant to whichever principal happens to share the string. +func TestLookupDoesNotFallBackFromAnUnknownID(t *testing.T) { + t.Parallel() + + index := &principalIndex{ + byID: map[string]*v2.ResourceId{ + "10": {ResourceType: "user", Resource: "10"}, + }, + byName: map[string]*v2.ResourceId{ + "shared": {ResourceType: "user", Resource: "10"}, + }, + } + + if _, ok := index.lookup(databricks.PrivilegeAssignment{ + Principal: "shared", PrincipalID: json.Number("99"), + }); ok { + t.Fatal("an unknown principal id must not resolve through a shared name") + } + + got, ok := index.lookup(databricks.PrivilegeAssignment{ + Principal: "shared", PrincipalID: json.Number("10"), + }) + if !ok || got.GetResource() != "10" { + t.Fatalf("known id lookup = %v %v", got, ok) + } +} diff --git a/pkg/connector/users.go b/pkg/connector/users.go index 842f3bd9..00acb4bb 100644 --- a/pkg/connector/users.go +++ b/pkg/connector/users.go @@ -42,7 +42,7 @@ func (u *userBuilder) userResource(ctx context.Context, user *databricks.User, p } firstName, lastName := rs.SplitFullName(user.DisplayName) - profile := map[string]interface{}{ + profile := map[string]any{ "first_name": firstName, "last_name": lastName, "email": primaryEmail, diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index 4a2aa1ff..0c3d3209 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -32,7 +32,7 @@ func (w *workspaceBuilder) ResourceType(ctx context.Context) *v2.ResourceType { } func workspaceResource(_ context.Context, workspace *databricks.Workspace, parent *v2.ResourceId) (*v2.Resource, error) { - profile := map[string]interface{}{ + profile := map[string]any{ "workspace_id": workspace.ID, } diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index 7da652d2..78254dc0 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -102,7 +102,7 @@ func NewClient(ctx context.Context, httpClient *http.Client, hostname, accountHo Host: accountHostname, } - cli, err := uhttp.NewBaseHttpClientWithContext(ctx, httpClient) + cli, err := uhttp.NewBaseHttpClientWithContext(ctx, httpClient, uhttp.WithCacheKeyHeaders(cacheScopeHeader)) return &Client{ httpClient: cli, auth: auth, diff --git a/pkg/databricks/models.go b/pkg/databricks/models.go index 0bb3aa8d..e1451b3a 100644 --- a/pkg/databricks/models.go +++ b/pkg/databricks/models.go @@ -1,5 +1,7 @@ package databricks +import "encoding/json" + type BaseResponse struct { ID string `json:"id"` } @@ -129,7 +131,8 @@ type WorkspacePrincipal struct { } type WorkspaceAssignment struct { - Principal *WorkspacePrincipal `json:"principal"` + Principal *WorkspacePrincipal `json:"principal"` + Permissions []string `json:"permissions"` } type Role struct { @@ -140,3 +143,67 @@ type RuleSet struct { Principals []string `json:"principals"` Role string `json:"role"` } + +type Metastore struct { + MetastoreID string `json:"metastore_id"` + Name string `json:"name"` + Region string `json:"region"` + Owner string `json:"owner"` +} + +// Catalog carries only what resolving an access path needs: which metastore a +// workspace can list catalogs for. +type Catalog struct { + Name string `json:"name"` + MetastoreID string `json:"metastore_id"` +} + +type PrivilegeAssignment struct { + Principal string `json:"principal"` + PrincipalID json.Number `json:"principal_id"` + Privileges []string `json:"privileges"` +} + +type PermissionsChange struct { + Principal string `json:"principal,omitempty"` + PrincipalID json.Number `json:"principal_id,omitempty"` + Add []string `json:"add,omitempty"` + Remove []string `json:"remove,omitempty"` +} + +func (p PermissionsChange) validate() error { + switch { + case p.Principal == "" && p.PrincipalID == "": + return invalidArgument("a principal or a principal id is required") + case p.Principal != "" && p.PrincipalID != "": + return invalidArgument("principal %q and principal id %s cannot both be set", p.Principal, p.PrincipalID) + case p.PrincipalID != "" && len(p.Add) > 0: + return invalidArgument("principal id %s addresses removals only, so it cannot add %v", p.PrincipalID, p.Add) + case len(p.Add) == 0 && len(p.Remove) == 0: + return invalidArgument("no privileges to add or remove") + } + + return nil +} + +type metastoresResponse struct { + Metastores []Metastore `json:"metastores"` +} + +type metastoreAssignment struct { + MetastoreID string `json:"metastore_id"` +} + +type workspaceMetastoreResponse struct { + MetastoreAssignment metastoreAssignment `json:"metastore_assignment"` +} + +type catalogsResponse struct { + Catalogs []Catalog `json:"catalogs"` + NextPageToken string `json:"next_page_token"` +} + +type permissionsResponse struct { + PrivilegeAssignments []PrivilegeAssignment `json:"privilege_assignments"` + NextPageToken string `json:"next_page_token"` +} diff --git a/pkg/databricks/pagination.go b/pkg/databricks/pagination.go new file mode 100644 index 00000000..219486ad --- /dev/null +++ b/pkg/databricks/pagination.go @@ -0,0 +1,80 @@ +package databricks + +import ( + "context" + "fmt" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" +) + +const ( + // The SDK caps the walks it drives; this one is driven here. + maxCursorPages = 1000 +) + +// forEachPageFrom walks listPage from start. A page can come back empty and still +// carry a next token, so only an absent token ends the walk, and a repeated token +// is an error: Databricks restarts at page one on a token it does not recognize. +func forEachPageFrom[T any]( + ctx context.Context, + start string, + listPage func(pageToken string) ([]T, string, *v2.RateLimitDescription, error), + visit func(page []T) (stop bool, err error), +) (*v2.RateLimitDescription, error) { + var rateLimit *v2.RateLimitDescription + seen := map[string]struct{}{} + + for pageToken, pages := start, 0; ; pages++ { + if pageToken != "" { + if _, ok := seen[pageToken]; ok { + return rateLimit, fmt.Errorf("cursor walk repeated a page token after %d pages", pages) + } + seen[pageToken] = struct{}{} + } + if pages >= maxCursorPages { + return rateLimit, fmt.Errorf("cursor walk exceeded %d pages", maxCursorPages) + } + if err := ctx.Err(); err != nil { + return rateLimit, err + } + + page, next, pageRateLimit, err := listPage(pageToken) + if pageRateLimit != nil { + rateLimit = pageRateLimit + } + if err != nil { + return rateLimit, err + } + + stop, err := visit(page) + if err != nil { + return rateLimit, err + } + if stop || next == "" { + return rateLimit, nil + } + pageToken = next + } +} + +// drainPagesFrom walks listPage to exhaustion. +func drainPagesFrom[T any]( + ctx context.Context, + start string, + listPage func(pageToken string) ([]T, string, *v2.RateLimitDescription, error), +) ( + []T, + *v2.RateLimitDescription, + error, +) { + var items []T + rateLimit, err := forEachPageFrom(ctx, start, listPage, func(page []T) (bool, error) { + items = append(items, page...) + return false, nil + }) + if err != nil { + return nil, rateLimit, err + } + + return items, rateLimit, nil +} diff --git a/pkg/databricks/request.go b/pkg/databricks/request.go index 359475a7..96c77cad 100644 --- a/pkg/databricks/request.go +++ b/pkg/databricks/request.go @@ -88,10 +88,15 @@ func nameWorkspace403Remedy(workspaceId string, err error) error { return err } +// invalidArgument marks a malformed request so it does not reach C1 as Unknown and get retried. +func invalidArgument(format string, args ...any) error { + return uhttp.WrapErrors(codes.InvalidArgument, fmt.Sprintf(format, args...)) +} + func (c *Client) Get( ctx context.Context, urlAddress *url.URL, - response interface{}, + response any, params ...Vars, ) (*v2.RateLimitDescription, error) { return c.doRequest( @@ -107,8 +112,8 @@ func (c *Client) Get( func (c *Client) Put( ctx context.Context, urlAddress *url.URL, - body interface{}, - response interface{}, + body any, + response any, params ...Vars, ) (*v2.RateLimitDescription, error) { return c.doRequest( @@ -124,8 +129,8 @@ func (c *Client) Put( func (c *Client) Post( ctx context.Context, urlAddress *url.URL, - body interface{}, - response interface{}, + body any, + response any, params ...Vars, ) (*v2.RateLimitDescription, error) { return c.doRequest( @@ -138,6 +143,23 @@ func (c *Client) Post( ) } +func (c *Client) Patch( + ctx context.Context, + urlAddress *url.URL, + body any, + response any, + params ...Vars, +) (*v2.RateLimitDescription, error) { + return c.doRequest( + ctx, + urlAddress, + http.MethodPatch, + body, + response, + params..., + ) +} + func (c *Client) Delete( ctx context.Context, urlAddress *url.URL, @@ -152,7 +174,7 @@ func (c *Client) Delete( ) } -func parseJSON(body io.Reader, res interface{}) error { +func parseJSON(body io.Reader, res any) error { // Databricks seems to return content-type text/plain even though it's json, // so don't check content type. if err := json.NewDecoder(body).Decode(res); err != nil { @@ -162,21 +184,29 @@ func parseJSON(body io.Reader, res interface{}) error { return nil } -func (c *Client) doRequest( +// cacheScopeHeader pins a cached response to the host it was read from: uhttp +// keys its cache on path, query and headers with no host component, so two +// workspace deployments answering the same path share one entry. net/http builds +// the Host line from the URL and never sends this header on the wire. +const cacheScopeHeader = "Host" + +// prepareRequest builds every request this client sends, so none can reach the +// cache without the scope header. uhttp writes an entry even for a caller that +// asked not to read one. +func (c *Client) prepareRequest( ctx context.Context, urlAddress *url.URL, method string, - body interface{}, - response interface{}, + body any, params ...Vars, -) (*v2.RateLimitDescription, error) { +) (*http.Request, error) { // TODO(marcos): Refactor URLs so that we don't have to unescape. - u, err := url.PathUnescape(urlAddress.String()) + unescaped, err := url.PathUnescape(urlAddress.String()) if err != nil { return nil, err } - uri, err := url.Parse(u) + requestURL, err := url.Parse(unescaped) if err != nil { return nil, err } @@ -188,7 +218,7 @@ func (c *Client) doRequest( options = append(options, uhttp.WithJSONBody(body)) } - req, err := c.httpClient.NewRequest(ctx, method, uri, options...) + req, err := c.httpClient.NewRequest(ctx, method, requestURL, options...) if err != nil { return nil, err } @@ -202,8 +232,26 @@ func (c *Client) doRequest( req.URL.RawQuery = query.Encode() } + req.Header.Set(cacheScopeHeader, req.URL.Host) + c.auth.Apply(req) + return req, nil +} + +func (c *Client) doRequest( + ctx context.Context, + urlAddress *url.URL, + method string, + body any, + response any, + params ...Vars, +) (*v2.RateLimitDescription, error) { + req, err := c.prepareRequest(ctx, urlAddress, method, body, params...) + if err != nil { + return nil, err + } + ratelimitData := &v2.RateLimitDescription{} resp, err := c.httpClient.Do( req, @@ -242,43 +290,15 @@ func (c *Client) doRequestNoResponse( ctx context.Context, urlAddress *url.URL, method string, - body interface{}, - response interface{}, + body any, + response any, params ...Vars, ) (*v2.RateLimitDescription, error) { - u, err := url.PathUnescape(urlAddress.String()) - if err != nil { - return nil, err - } - - uri, err := url.Parse(u) - if err != nil { - return nil, err - } - - options := []uhttp.RequestOption{ - uhttp.WithAcceptJSONHeader(), - } - if body != nil { - options = append(options, uhttp.WithJSONBody(body)) - } - - req, err := c.httpClient.NewRequest(ctx, method, uri, options...) + req, err := c.prepareRequest(ctx, urlAddress, method, body, params...) if err != nil { return nil, err } - if len(params) > 0 { - query := url.Values{} - for _, param := range params { - param.Apply(&query) - } - - req.URL.RawQuery = query.Encode() - } - - c.auth.Apply(req) - ratelimitData := &v2.RateLimitDescription{} resp, err := c.httpClient.Do( req, diff --git a/pkg/databricks/unity_catalog.go b/pkg/databricks/unity_catalog.go new file mode 100644 index 00000000..9c17acf1 --- /dev/null +++ b/pkg/databricks/unity_catalog.go @@ -0,0 +1,326 @@ +package databricks + +import ( + "context" + "fmt" + "net/url" + "strings" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" +) + +// A metastore lives on the account plane; every securable below it is only +// reachable through a workspace host the metastore is assigned to. +const ( + // https://docs.databricks.com/api/account/accountmetastores/list + accountMetastoresEndpoint = "/api/2.0/accounts/%s/metastores" + + // https://docs.databricks.com/api/account/accountmetastoreassignments/get + accountWorkspaceMetastoreEndpoint = "/api/2.0/accounts/%s/workspaces/%s/metastore" + + // https://docs.databricks.com/api/workspace/catalogs/list + unityCatalogCatalogsEndpoint = "/api/2.1/unity-catalog/catalogs" + + // GET https://docs.databricks.com/api/workspace/grants/get + // PATCH https://docs.databricks.com/api/workspace/grants/update + unityCatalogPermissionsEndpoint = "/api/2.1/unity-catalog/permissions" + + // Path segments for the permissions endpoint, not interchangeable with the + // uppercase securable_type a listed securable carries. A metastore is + // addressed by its UUID: its name is rejected. + SecurableMetastore = "metastore" + + // The permissions endpoint rejects any max_results from 1 to 149 with a 400. + // Zero selects the paginated response that replaces the unpaginated form. + permissionsMaxResults uint = 0 +) + +// ListMetastores (GET /api/2.0/accounts/{account_id}/metastores). Not paginated. +func (c *Client) ListMetastores( + ctx context.Context, +) ( + []Metastore, + *v2.RateLimitDescription, + error, +) { + var res metastoresResponse + + u := c.accountBaseUrl.JoinPath(fmt.Sprintf(accountMetastoresEndpoint, c.accountId)) + ratelimitData, err := c.Get(ctx, u, &res) + if err != nil { + return nil, ratelimitData, fmt.Errorf("failed to list metastores: %w", err) + } + + return res.Metastores, ratelimitData, nil +} + +// GetWorkspaceMetastore (GET /api/2.0/accounts/{account_id}/workspaces/{workspace_id}/metastore), +// empty when the workspace has none. workspaceID is the numeric account-level id, +// not the deployment name the securable endpoints are addressed by. +func (c *Client) GetWorkspaceMetastore( + ctx context.Context, + workspaceID string, +) ( + string, + *v2.RateLimitDescription, + error, +) { + var res workspaceMetastoreResponse + + u := c.accountBaseUrl.JoinPath(fmt.Sprintf(accountWorkspaceMetastoreEndpoint, c.accountId, workspaceID)) + ratelimitData, err := c.Get(ctx, u, &res) + if err != nil { + return "", ratelimitData, fmt.Errorf("failed to get metastore assignment for workspace %s: %w", workspaceID, err) + } + + return res.MetastoreAssignment.MetastoreID, ratelimitData, nil +} + +// listSecurables reads one page off a workspace-scoped securable endpoint. +func listSecurables[R any, T any]( + ctx context.Context, + c *Client, + workspaceId, endpoint string, + page func(res *R) ([]T, string), + vars ...Vars, +) ( + []T, + string, + *v2.RateLimitDescription, + error, +) { + var res R + + u := c.workspaceUrl(workspaceId).JoinPath(endpoint) + ratelimitData, err := c.Get(ctx, u, &res, vars...) + if err != nil { + return nil, "", ratelimitData, nameWorkspace403Remedy(workspaceId, err) + } + + items, nextPageToken := page(&res) + + return items, nextPageToken, ratelimitData, nil +} + +// listCatalogs (GET /api/2.1/unity-catalog/catalogs) pages the catalogs of the +// metastore attached to workspaceId, which is a deployment name. +func (c *Client) listCatalogs( + ctx context.Context, + workspaceId, pageToken string, + maxResults uint, +) ( + []Catalog, + string, + *v2.RateLimitDescription, + error, +) { + catalogs, next, ratelimitData, err := listSecurables(ctx, c, workspaceId, unityCatalogCatalogsEndpoint, + func(res *catalogsResponse) ([]Catalog, string) { return res.Catalogs, res.NextPageToken }, + &unityCatalogVars{ + maxResults: &maxResults, + pageToken: pageToken, + }) + if err != nil { + return nil, "", ratelimitData, fmt.Errorf("failed to list catalogs: %w", err) + } + + return catalogs, next, ratelimitData, nil +} + +// ForEachCatalogPage walks listCatalogs without retaining the pages. +func (c *Client) ForEachCatalogPage( + ctx context.Context, + workspaceId string, + maxResults uint, + visit func([]Catalog) (bool, error), +) (*v2.RateLimitDescription, error) { + return forEachPageFrom(ctx, "", func(pageToken string) ([]Catalog, string, *v2.RateLimitDescription, error) { + return c.listCatalogs(ctx, workspaceId, pageToken, maxResults) + }, visit) +} + +// ListPermissions (GET /api/2.1/unity-catalog/permissions/{securable_type}/{full_name}). +// The principal query filters effectively, not literally: an inherited assignment +// comes back naming the group it is inherited through, so a caller must read every +// row's privileges and never match on the principal field. Empty lists every row. +func (c *Client) ListPermissions( + ctx context.Context, + workspaceId, securableType, fullName, principal, pageToken string, +) ( + []PrivilegeAssignment, + string, + *v2.RateLimitDescription, + error, +) { + u, err := c.permissionsUrl(workspaceId, securableType, fullName) + if err != nil { + return nil, "", nil, err + } + + var res permissionsResponse + + maxResults := permissionsMaxResults + ratelimitData, err := c.Get(ctx, u, &res, &unityCatalogVars{ + principal: principal, + maxResults: &maxResults, + pageToken: pageToken, + }) + if err != nil { + if principal != "" { + return nil, "", ratelimitData, fmt.Errorf("failed to list permissions for principal %s on %s %s: %w", principal, securableType, fullName, err) + } + + return nil, "", ratelimitData, fmt.Errorf("failed to list permissions on %s %s: %w", securableType, fullName, err) + } + + return res.PrivilegeAssignments, res.NextPageToken, ratelimitData, nil +} + +// DrainPermissions walks ListPermissions to exhaustion for every principal. +func (c *Client) DrainPermissions( + ctx context.Context, + workspaceId, securableType, fullName string, +) ( + []PrivilegeAssignment, + *v2.RateLimitDescription, + error, +) { + return drainPagesFrom(ctx, "", func(pageToken string) ([]PrivilegeAssignment, string, *v2.RateLimitDescription, error) { + return c.ListPermissions(ctx, workspaceId, securableType, fullName, "", pageToken) + }) +} + +// ForEachPermissionsPage walks ListPermissions without retaining the pages. +func (c *Client) ForEachPermissionsPage( + ctx context.Context, + workspaceId, securableType, fullName, principal string, + visit func([]PrivilegeAssignment) (bool, error), +) (*v2.RateLimitDescription, error) { + return forEachPageFrom(ctx, "", func(pageToken string) ([]PrivilegeAssignment, string, *v2.RateLimitDescription, error) { + return c.ListPermissions(ctx, workspaceId, securableType, fullName, principal, pageToken) + }, visit) +} + +// UpdatePermissions (PATCH /api/2.1/unity-catalog/permissions/{securable_type}/{full_name}). +// Databricks no-ops a privilege already held or already absent, so the echoed +// assignments are the only confirmation the change landed — and that echo is +// itself paginated, so a principal on a later page must not read as success. +func (c *Client) UpdatePermissions( + ctx context.Context, + workspaceId, securableType, fullName string, + changes []PermissionsChange, +) ( + []PrivilegeAssignment, + *v2.RateLimitDescription, + error, +) { + var assignments []PrivilegeAssignment + ratelimitData, err := c.updatePermissions(ctx, workspaceId, securableType, fullName, changes, + func(page []PrivilegeAssignment) (bool, error) { + assignments = append(assignments, page...) + return false, nil + }) + if err != nil { + return nil, ratelimitData, err + } + + return assignments, ratelimitData, nil +} + +// UpdatePermissionsUntil is UpdatePermissions for a caller that can stop early. +func (c *Client) UpdatePermissionsUntil( + ctx context.Context, + workspaceId, securableType, fullName string, + changes []PermissionsChange, + visit func([]PrivilegeAssignment) (bool, error), +) (*v2.RateLimitDescription, error) { + return c.updatePermissions(ctx, workspaceId, securableType, fullName, changes, visit) +} + +func (c *Client) updatePermissions( + ctx context.Context, + workspaceId, securableType, fullName string, + changes []PermissionsChange, + visit func([]PrivilegeAssignment) (bool, error), +) (*v2.RateLimitDescription, error) { + if len(changes) == 0 { + return nil, invalidArgument("no permission changes given for %s %s", securableType, fullName) + } + + for i, change := range changes { + if err := change.validate(); err != nil { + return nil, fmt.Errorf("invalid permission change %d for %s %s: %w", i, securableType, fullName, err) + } + } + + u, err := c.permissionsUrl(workspaceId, securableType, fullName) + if err != nil { + return nil, err + } + + payload := struct { + Changes []PermissionsChange `json:"changes"` + }{ + Changes: changes, + } + + var res permissionsResponse + + ratelimitData, err := c.Patch(ctx, u, payload, &res) + if err != nil { + return ratelimitData, fmt.Errorf("failed to update permissions on %s %s: %w", securableType, fullName, err) + } + + stop, err := visit(res.PrivilegeAssignments) + if err != nil || stop || res.NextPageToken == "" { + return ratelimitData, err + } + + // The PATCH body is already page one. + drainRateLimit, drainErr := forEachPageFrom(ctx, res.NextPageToken, + func(pageToken string) ([]PrivilegeAssignment, string, *v2.RateLimitDescription, error) { + return c.ListPermissions(ctx, workspaceId, securableType, fullName, "", pageToken) + }, visit) + if drainRateLimit != nil { + ratelimitData = drainRateLimit + } + if drainErr != nil { + return ratelimitData, fmt.Errorf( + "failed to read the remaining permissions pages after updating %s %s: %w", + securableType, fullName, drainErr) + } + + return ratelimitData, nil +} + +// A name that does not survive URL assembly addresses a different securable, +// which on a PATCH writes someone else's permissions. +func (c *Client) permissionsUrl(workspaceId, securableType, fullName string) (*url.URL, error) { + if securableType == "" || fullName == "" { + return nil, invalidArgument("securable type and full name are required, got %q and %q", securableType, fullName) + } + + u := c.workspaceUrl(workspaceId).JoinPath(unityCatalogPermissionsEndpoint, securableType, fullName) + if !addressesSecurable(u, "/"+securableType+"/"+fullName) { + return nil, invalidArgument("cannot address %s %q: the name does not survive URL assembly", securableType, fullName) + } + + return u, nil +} + +// JoinPath reads each element as pre-escaped, so a bare "%" in a name drops the +// whole path, and the request layer's unescape round trip then turns a "?" into +// a query string and a "#" into a fragment. +func addressesSecurable(u *url.URL, suffix string) bool { + unescaped, err := url.PathUnescape(u.String()) + if err != nil { + return false + } + + sent, err := url.Parse(unescaped) + if err != nil { + return false + } + + return sent.RawQuery == "" && sent.Fragment == "" && strings.HasSuffix(sent.Path, suffix) +} diff --git a/pkg/databricks/unity_catalog_test.go b/pkg/databricks/unity_catalog_test.go new file mode 100644 index 00000000..c07c1cec --- /dev/null +++ b/pkg/databricks/unity_catalog_test.go @@ -0,0 +1,112 @@ +package databricks + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" +) + +// Databricks ignores a page token it cannot deserialize and silently restarts at +// page one, so a walk that accepts a repeated token re-reads the same page forever. +func TestDrainPagesFromStartsAtSeedAndRejectsARepeat(t *testing.T) { + t.Parallel() + + var tokens []string + _, _, err := drainPagesFrom(context.Background(), "page-2", func(pageToken string) ([]string, string, *v2.RateLimitDescription, error) { + tokens = append(tokens, pageToken) + if pageToken == "page-2" { + return []string{"from-seed"}, "page-2", nil, nil + } + return []string{"should-not-run"}, "", nil, nil + }) + if err == nil { + t.Fatal("drainPagesFrom returned nil error for a repeated token") + } + if len(tokens) != 1 || tokens[0] != "page-2" { + t.Fatalf("fetched tokens %v, want only the seed page-2", tokens) + } +} + +// The PATCH echo is itself paginated, so reading just the PATCH body makes a Revoke +// report success while the principal still holds the privilege on a later page. The +// walk continues from the PATCH's own token rather than re-fetching page one. +func TestUpdatePermissionsDrainsFromPatchToken(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + var gets []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodPatch: + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read patch body: %v", err) + } + if len(body) == 0 { + t.Error("patch body is empty") + } + _ = json.NewEncoder(w).Encode(permissionsResponse{ + PrivilegeAssignments: []PrivilegeAssignment{{Principal: "alice", Privileges: []string{"SELECT"}}}, + NextPageToken: "page-2", + }) + case http.MethodGet: + token := r.URL.Query().Get("page_token") + gets = append(gets, token) + principal := "bob" + if token != "page-2" { + principal = "unexpected" + } + _ = json.NewEncoder(w).Encode(permissionsResponse{ + PrivilegeAssignments: []PrivilegeAssignment{{Principal: principal, Privileges: []string{"MODIFY"}}}, + }) + default: + http.Error(w, "unexpected method", http.StatusMethodNotAllowed) + } + })) + t.Cleanup(srv.Close) + + client := srv.Client() + client.Transport = rewriteHost(srv.Listener.Addr().String(), client.Transport) + c, err := NewClient(context.Background(), client, "example.cloud.databricks.com", "accounts.cloud.databricks.com", "acc-1", srv.URL, &NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + got, _, err := c.UpdatePermissions(context.Background(), "ws", SecurableMetastore, "ms-1", []PermissionsChange{{ + Principal: "alice", + Add: []string{"CREATE_CATALOG"}, + }}) + if err != nil { + t.Fatalf("UpdatePermissions: %v", err) + } + if len(gets) != 1 || gets[0] != "page-2" { + t.Fatalf("GET page tokens %v, want [page-2] and not a re-fetch of the PATCH page", gets) + } + if len(got) != 2 || got[0].Principal != "alice" || got[1].Principal != "bob" { + t.Fatalf("assignments = %+v, want alice then bob", got) + } +} + +type hostRewriter struct { + host http.RoundTripper + addr string +} + +func rewriteHost(addr string, next http.RoundTripper) http.RoundTripper { + if next == nil { + next = http.DefaultTransport + } + return hostRewriter{host: next, addr: addr} +} + +func (h hostRewriter) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.URL.Scheme = "http" + clone.URL.Host = h.addr + return h.host.RoundTrip(clone) +} diff --git a/pkg/databricks/vars.go b/pkg/databricks/vars.go index cbf06e05..9e9c7cfe 100644 --- a/pkg/databricks/vars.go +++ b/pkg/databricks/vars.go @@ -3,6 +3,7 @@ package databricks import ( "fmt" "net/url" + "strconv" ) type Vars interface { @@ -167,3 +168,26 @@ func NewNameVars(name string, etag string) *NameVars { Etag: etag, } } + +// Databricks reads max_results=0 as "page at the server-configured length" +// while an absent max_results asks for the deprecated unpaginated response, so +// maxResults is a pointer: its presence is the signal. +type unityCatalogVars struct { + principal string + maxResults *uint + pageToken string +} + +func (v *unityCatalogVars) Apply(params *url.Values) { + if v.principal != "" { + params.Add("principal", v.principal) + } + + if v.maxResults != nil { + params.Add("max_results", strconv.FormatUint(uint64(*v.maxResults), 10)) + } + + if v.pageToken != "" { + params.Add("page_token", v.pageToken) + } +} From 026cd039c9e93e11291ecacbac85091f09cec2f2 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Fri, 9 Oct 2026 11:26:30 -0300 Subject: [PATCH 02/36] ci(databricks): serialize the sync test against the shared tenant Co-authored-by: Cursor --- .github/workflows/ci.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6030d7f1..f3eef539 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,6 +8,12 @@ on: jobs: test: runs-on: ubuntu-latest + # This job grants and revokes one hardcoded group membership on the shared + # Databricks tenant, so two PRs running it at once read each other's writes. + # The group is deliberately not per-ref: it serializes across every branch. + concurrency: + group: databricks-sync-test + cancel-in-progress: false env: BATON_LOG_LEVEL: debug # Add any environment variables needed to run baton-bitbucket-datacenter From ffd87c25f94e3063f55c8a6cb71758507d628a7b Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Fri, 9 Oct 2026 13:41:42 -0300 Subject: [PATCH 03/36] fix(databricks): read permissions past the cache when provisioning, scope routing to the configured workspaces The uhttp response cache holds a GET 200 for an hour and a PATCH does not invalidate it, so Grant and Revoke could decide not to write against a page that predated the last change: Grant X then Revoke X in the same process left the principal with access while C1 recorded it as revoked. Both pre-reads and the post-PATCH confirmation now bypass the cache. Unity Catalog routing ignored the workspaces allowlist, because ListWorkspaces only applies the exclude list, so a securable could be read through a deployment the operator had scoped out of the sync. A workspace the credential cannot read answered 401 or 403 out of buildRouting, and only codes.NotFound is a warning to the SDK, so one unreadable workspace failed the whole sync including the resource types that already worked. It is now set aside the way a 404 already was. Also: a revoke against a deleted metastore reports GrantAlreadyRevoked rather than failing forever, the 403 remedy no longer names MANAGE (not assignable on a metastore), and the connector's own service principal is resolved with an applicationId filter instead of a full account SCIM walk. Co-authored-by: Cursor --- pkg/connector/connector.go | 2 +- pkg/connector/helpers.go | 20 +++++ pkg/connector/metastore.go | 54 +++++++++----- pkg/connector/principal_index.go | 20 +++-- pkg/connector/unity_catalog.go | 28 +++++-- pkg/connector/unity_catalog_test.go | 93 +++++++++++++++++++++++- pkg/connector/workspaces.go | 10 ++- pkg/databricks/permissions_cache_test.go | 84 +++++++++++++++++++++ pkg/databricks/request.go | 34 ++++++++- pkg/databricks/unity_catalog.go | 47 ++++++++++-- 10 files changed, 343 insertions(+), 49 deletions(-) create mode 100644 pkg/databricks/permissions_cache_test.go diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 0828503e..3746ba04 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -155,7 +155,7 @@ func New( return &Databricks{ client: client, workspaces: workspaces, - unityCatalog: newUnityCatalog(client, clientID), + unityCatalog: newUnityCatalog(client, clientID, configuredWorkspaceSet(workspaces)), opts: opts, }, nil } diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 7cf5b6ba..79fc786c 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -239,6 +239,26 @@ func isForbiddenError(err error) bool { return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden } +// isUnreadableWorkspaceError reports an answer that says this credential cannot +// read this workspace, which is a fact about that workspace and not about the +// account. Access-path discovery treats all three alike: the workspace carries +// nothing this sync can read. The distinction matters because only codes.NotFound +// is a warning to the SDK — every other code fails the whole sync, so letting one +// unreadable workspace escape here would take down the resource types that work. +func isUnreadableWorkspaceError(err error) bool { + var apiErr *databricks.APIError + if !errors.As(err, &apiErr) { + return false + } + + switch apiErr.StatusCode { + case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden: + return true + default: + return false + } +} + // A privilege assignment carries the numeric SCIM id in principal_id, which is the // only handle left on a principal that no longer resolves to a name. func unityPrincipalScimID(principal *v2.ResourceId) (string, error) { diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index 769cf020..cd722d4f 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -115,13 +115,22 @@ func (m *metastoreBuilder) Entitlements(context.Context, *v2.Resource, rs.SyncOp return nil, nil, nil } -func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, attr rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { - resourceID := resource.GetId().GetResource() +// parseMetastoreID reads a metastore reference out of a resource id. A metastore +// is addressed by a bare id; the separator only appears in the composite ids of +// the securables below it, so finding one here means the wrong resource arrived. +func parseMetastoreID(resourceID string) (securableRef, error) { if resourceID == "" || strings.Contains(resourceID, securableKeySeparator) { - return nil, nil, fmt.Errorf("databricks-connector: failed to parse metastore resource id: %w", - fmt.Errorf("invalid metastore resource id %q", resourceID)) + return securableRef{}, fmt.Errorf("databricks-connector: invalid metastore resource id %q", resourceID) + } + + return securableRef{metastoreID: resourceID}, nil +} + +func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, attr rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { + ref, err := parseMetastoreID(resource.GetId().GetResource()) + if err != nil { + return nil, nil, err } - ref := securableRef{metastoreID: resourceID} annos := annotations.Annotations{} snap, rateLimit, err := m.uc.buildRouting(ctx) @@ -267,12 +276,10 @@ func (m *metastoreBuilder) Grant(ctx context.Context, principal *v2.Resource, en principalID.GetResourceType()) } - resourceID := entitlement.GetResource().GetId().GetResource() - if resourceID == "" || strings.Contains(resourceID, securableKeySeparator) { - return annos, fmt.Errorf("databricks-connector: failed to parse metastore resource id: %w", - fmt.Errorf("invalid metastore resource id %q", resourceID)) + ref, err := parseMetastoreID(entitlement.GetResource().GetId().GetResource()) + if err != nil { + return annos, err } - ref := securableRef{metastoreID: resourceID} entitlementID := entitlement.GetId() separator := strings.LastIndex(entitlementID, ":") @@ -403,7 +410,9 @@ func (m *metastoreBuilder) Grant(ctx context.Context, principal *v2.Resource, en if isForbiddenError(err) { return annos, uhttp.WrapErrors( codes.PermissionDenied, - fmt.Sprintf("databricks-connector: cannot grant %s on %s %s: the connector principal needs MANAGE on it", + // MANAGE is not assignable on a metastore, so naming it here would send the + // operator after a privilege Databricks rejects. The authority is ownership. + fmt.Sprintf("databricks-connector: cannot grant %s on %s %s: the connector principal needs to be a metastore admin (its owner) or an account admin", privilege, databricks.SecurableMetastore, ref.permissionsName()), err, ) @@ -434,12 +443,10 @@ func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annot principalID.GetResourceType()) } - resourceID := entitlement.GetResource().GetId().GetResource() - if resourceID == "" || strings.Contains(resourceID, securableKeySeparator) { - return annos, fmt.Errorf("databricks-connector: failed to parse metastore resource id: %w", - fmt.Errorf("invalid metastore resource id %q", resourceID)) + ref, err := parseMetastoreID(entitlement.GetResource().GetId().GetResource()) + if err != nil { + return annos, err } - ref := securableRef{metastoreID: resourceID} entitlementID := entitlement.GetId() separator := strings.LastIndex(entitlementID, ":") @@ -480,8 +487,12 @@ func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annot return annos, fmt.Errorf("databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) } if !exists { - return annos, status.Errorf(codes.NotFound, - "databricks-connector: metastore %s is no longer listed on the account, so it cannot be changed", ref.metastoreID) + // The metastore is gone from the account, so the privilege is gone with it and + // the end state the revoke asked for already holds. Reporting NotFound instead + // would fail the task on every retry against a securable that can never return. + annos.Update(&v2.GrantAlreadyRevoked{}) + + return annos, nil } trustworthy, rateLimit, err := m.uc.metastoreGrantsAreTrustworthy(ctx, snap, ref.metastoreID, workspace) @@ -502,6 +513,9 @@ func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annot principalID.GetResource(), err) } + // Grant rejects an empty name because it has to send one to add a privilege. + // Revoke does not: a principal deleted after the grant was synced keeps its + // numeric id in the assignment, and removing by id is what clears the row. principalName, _, err := unityPrincipalName(ctx, m.client, principalID) if err != nil { return annos, fmt.Errorf("databricks-connector: failed to resolve the Unity Catalog name of principal %s: %w", @@ -539,7 +553,7 @@ func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annot } var seen []databricks.PrivilegeAssignment - rateLimit, err = m.client.ForEachPermissionsPage(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), "", + rateLimit, err = m.client.ForEachUncachedPermissionsPage(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), "", func(page []databricks.PrivilegeAssignment) (bool, error) { seen = append(seen, page...) matched, ok := matchHeld(seen) @@ -578,7 +592,7 @@ func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annot if isForbiddenError(err) { return annos, uhttp.WrapErrors( codes.PermissionDenied, - fmt.Sprintf("databricks-connector: cannot revoke %s on %s %s: the connector principal needs MANAGE on it", + fmt.Sprintf("databricks-connector: cannot revoke %s on %s %s: the connector principal needs to be a metastore admin (its owner) or an account admin", privilege, databricks.SecurableMetastore, ref.permissionsName()), err, ) diff --git a/pkg/connector/principal_index.go b/pkg/connector/principal_index.go index a0183434..7a0d0bf2 100644 --- a/pkg/connector/principal_index.go +++ b/pkg/connector/principal_index.go @@ -227,25 +227,23 @@ func (u *unityCatalog) indexServicePrincipals(ctx context.Context, index *princi } } -// ownPrincipalScimID lists account SCIM for this call only. A name-built filter -// cannot tell a service principal from a user or a group that shares the field. +// ownPrincipalScimID resolves the connector's own service principal. The index +// cannot answer this: it is keyed by a name field the three principal namespaces +// share, so a hit there may be somebody else's. An applicationId filter on the +// service principal endpoint has no such ambiguity — it can only match a service +// principal — which is why this asks for the one record instead of walking all +// of account SCIM to find it. func (u *unityCatalog) ownPrincipalScimID(ctx context.Context) (string, *v2.RateLimitDescription, error) { if u.ownPrincipal == "" { return "", nil, nil } - index, rateLimit, err := u.listPrincipals(ctx) + id, rateLimit, err := u.client.FindServicePrincipalID(ctx, "", u.ownPrincipal) if err != nil { - return "", rateLimit, err + return "", rateLimit, fmt.Errorf("failed to resolve the connector's own service principal: %w", err) } - resourceId, ok := index.lookupName(u.ownPrincipal) - if !ok || resourceId.GetResourceType() != servicePrincipalResourceType.Id { - // The three namespaces share one name field, so a hit elsewhere is somebody else's. - return "", rateLimit, nil - } - - return resourceId.GetResource(), rateLimit, nil + return id, rateLimit, nil } // nextScimPage returns zero once the listing is exhausted. diff --git a/pkg/connector/unity_catalog.go b/pkg/connector/unity_catalog.go index e8a4da34..519b81c0 100644 --- a/pkg/connector/unity_catalog.go +++ b/pkg/connector/unity_catalog.go @@ -29,19 +29,27 @@ type routingSnapshot struct { asked int } -// unityCatalog holds no state beyond the client: every call asks Databricks and -// drops the answer when it returns. +// unityCatalog holds no state beyond the client and the workspace filter: every +// call asks Databricks and drops the answer when it returns. type unityCatalog struct { client *databricks.Client // ownPrincipal is the applicationId of the service principal the connector authenticates as. ownPrincipal string + + // workspaces is the configured allowlist, empty when none was given. Every + // Unity Catalog read is addressed to a workspace, so routing through one the + // operator scoped out would read the metastore through a deployment this sync + // does not cover. ListWorkspaces only applies the exclude list, which is why + // the allowlist has to be applied here as well as in the workspace builder. + workspaces map[string]struct{} } -func newUnityCatalog(client *databricks.Client, ownPrincipal string) *unityCatalog { +func newUnityCatalog(client *databricks.Client, ownPrincipal string, workspaces map[string]struct{}) *unityCatalog { return &unityCatalog{ client: client, ownPrincipal: ownPrincipal, + workspaces: workspaces, } } @@ -137,6 +145,11 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot if workspace.Status != "" && !strings.EqualFold(workspace.Status, workspaceStatusRunning) { continue } + if len(u.workspaces) > 0 { + if _, ok := matchConfiguredWorkspace(u.workspaces, workspace.DeploymentName, workspace.Name, strconv.Itoa(workspace.ID)); !ok { + continue + } + } asked++ accountID := strconv.Itoa(workspace.ID) @@ -147,9 +160,12 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot rateLimit = assignmentRateLimit } if err != nil { - if isNotFoundError(err) { + if isUnreadableWorkspaceError(err) { // Databricks answers 404 both for a workspace outside Unity Catalog and - // for an assignment the credential may not read. + // for an assignment the credential may not read, and 401/403 when the + // credential is not a member of the workspace at all. None of the three + // says anything about the other workspaces, so the workspace is set + // aside rather than failing the snapshot that every securable needs. unassigned = append(unassigned, workspace.DeploymentName) continue @@ -177,7 +193,7 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot rateLimit = listRateLimit } if err != nil { - if isNotFoundError(err) { + if isUnreadableWorkspaceError(err) { unusable = append(unusable, workspace) continue diff --git a/pkg/connector/unity_catalog_test.go b/pkg/connector/unity_catalog_test.go index 442f62ff..7b0892ff 100644 --- a/pkg/connector/unity_catalog_test.go +++ b/pkg/connector/unity_catalog_test.go @@ -70,7 +70,7 @@ func TestWorkspaceMetastoreLookupIsNotAnsweredByAnotherWorkspacesCachedListing(t }) }) - uc := newUnityCatalog(client, "app-1") + uc := newUnityCatalog(client, "app-1", nil) ctx := context.Background() for _, workspace := range []string{"wsa", "wsb"} { @@ -117,3 +117,94 @@ func TestLookupDoesNotFallBackFromAnUnknownID(t *testing.T) { t.Fatalf("known id lookup = %v %v", got, ok) } } + +// routingHandler answers the three account-plane calls buildRouting makes. Each +// workspace's metastore assignment is whatever assignments names it, and a status +// code there stands in for a credential that cannot read that workspace. +func routingHandler(t *testing.T, assignments map[string]any, probed map[string]int) http.HandlerFunc { + t.Helper() + + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + + switch { + case strings.HasSuffix(path, "/metastores"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main"}}, + }) + case strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, + }) + case strings.HasSuffix(path, "/metastore"): + parts := strings.Split(path, "/") + workspaceID := parts[len(parts)-2] + probed[workspaceID]++ + switch assigned := assignments[workspaceID].(type) { + case int: + http.Error(w, `{"message":"denied"}`, assigned) + case string: + _ = json.NewEncoder(w).Encode(map[string]any{ + "metastore_assignment": map[string]any{"metastore_id": assigned}, + }) + default: + http.Error(w, `{"message":"missing"}`, http.StatusNotFound) + } + default: + probed[r.Header.Get("X-Test-Workspace")]++ + _ = json.NewEncoder(w).Encode(map[string]any{"catalogs": []map[string]any{}}) + } + } +} + +// The allowlist is what the operator scoped the sync to, but ListWorkspaces only +// applies the exclude list. Unity Catalog reads are addressed to a workspace, so +// routing has to filter too or the connector reads the account through a +// deployment that never appears in the sync. +func TestRoutingSkipsAWorkspaceOutsideTheConfiguredAllowlist(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, + map[string]any{"1": "ms-a", "2": "ms-a"}, probed)) + + uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod"})) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("buildRouting: %v", err) + } + + if probed["2"] != 0 { + t.Errorf("the out-of-scope workspace was asked for its metastore assignment %d time(s)", probed["2"]) + } + if want := []string{"wsa"}; !slices.Equal(snap.workspaces["ms-a"], want) { + t.Errorf("ms-a routes through %v, want %v", snap.workspaces["ms-a"], want) + } +} + +// Only codes.NotFound is a warning to the SDK; every other code fails the whole +// sync. A credential that is not a member of one workspace answers 401 or 403 +// there, which says nothing about the rest of the account, so letting it escape +// buildRouting would take down the resource types that do work. +func TestRoutingSetsAsideAWorkspaceTheCredentialCannotRead(t *testing.T) { + for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { + t.Run(http.StatusText(status), func(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, + map[string]any{"1": "ms-a", "2": status}, probed)) + + uc := newUnityCatalog(client, "app-1", nil) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("one unreadable workspace failed the whole snapshot: %v", err) + } + + if want := []string{"wsa"}; !slices.Equal(snap.workspaces["ms-a"], want) { + t.Errorf("ms-a routes through %v, want %v", snap.workspaces["ms-a"], want) + } + if !slices.Contains(snap.unusable, "wsb") { + t.Errorf("unusable = %v, want it to name wsb", snap.unusable) + } + }) + } +} diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index 0c3d3209..04148a04 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -300,15 +300,21 @@ func (w *workspaceBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotat return nil, nil } -func newWorkspaceBuilder(client *databricks.Client, workspaces []string) *workspaceBuilder { +// configuredWorkspaceSet indexes the allowlist. Both the workspace builder and +// Unity Catalog routing filter on it, so they read it from one place. +func configuredWorkspaceSet(workspaces []string) map[string]struct{} { wMap := make(map[string]struct{}, len(workspaces)) for _, w := range workspaces { wMap[w] = struct{}{} } + return wMap +} + +func newWorkspaceBuilder(client *databricks.Client, workspaces []string) *workspaceBuilder { return &workspaceBuilder{ client: client, resourceType: workspaceResourceType, - workspaces: wMap, + workspaces: configuredWorkspaceSet(workspaces), } } diff --git a/pkg/databricks/permissions_cache_test.go b/pkg/databricks/permissions_cache_test.go new file mode 100644 index 00000000..c98bb3fe --- /dev/null +++ b/pkg/databricks/permissions_cache_test.go @@ -0,0 +1,84 @@ +package databricks + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// This runs at the cache's default settings on purpose: BATON_HTTP_CACHE_TTL is +// deliberately not set, because turning the cache off is what hides this. +// +// Grant and Revoke both decide whether to PATCH by reading the securable's +// current assignments first, and both reads produce the same URL and query, so +// they share one cache entry. uhttp holds a GET 200 for an hour and a PATCH does +// not invalidate it, so a cached pre-read makes Revoke see the state from before +// the Grant: it reports GrantAlreadyRevoked and never sends the PATCH, leaving +// the principal with access that C1 believes is gone. +func TestProvisioningPreReadsAreNotServedFromTheCache(t *testing.T) { + var gets int + granted := false + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + gets++ + var assignments []PrivilegeAssignment + if granted { + assignments = []PrivilegeAssignment{{Principal: "alice", Privileges: []string{"SELECT"}}} + } + _ = json.NewEncoder(w).Encode(permissionsResponse{PrivilegeAssignments: assignments}) + case http.MethodPatch: + granted = true + _ = json.NewEncoder(w).Encode(permissionsResponse{ + PrivilegeAssignments: []PrivilegeAssignment{{Principal: "alice", Privileges: []string{"SELECT"}}}, + }) + default: + http.Error(w, "unexpected method", http.StatusMethodNotAllowed) + } + })) + t.Cleanup(srv.Close) + + client := srv.Client() + client.Transport = rewriteHost(srv.Listener.Addr().String(), client.Transport) + c, err := NewClient(context.Background(), client, "example.cloud.databricks.com", "accounts.cloud.databricks.com", "acc-1", srv.URL, &NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + ctx := context.Background() + + // Grant's pre-read: nothing is held yet, so the Grant proceeds to the PATCH. + held, _, err := c.DrainPermissions(ctx, "ws", SecurableMetastore, "ms-1") + if err != nil { + t.Fatalf("Grant pre-read: %v", err) + } + if len(held) != 0 { + t.Fatalf("Grant pre-read saw %d assignment(s), want none", len(held)) + } + + if _, _, err := c.UpdatePermissions(ctx, "ws", SecurableMetastore, "ms-1", + []PermissionsChange{{Principal: "alice", Add: []string{"SELECT"}}}); err != nil { + t.Fatalf("UpdatePermissions: %v", err) + } + + // Revoke's pre-read, same process and same hour, against the same cache key. + var seen []PrivilegeAssignment + if _, err := c.ForEachUncachedPermissionsPage(ctx, "ws", SecurableMetastore, "ms-1", "", + func(page []PrivilegeAssignment) (bool, error) { + seen = append(seen, page...) + return false, nil + }); err != nil { + t.Fatalf("Revoke pre-read: %v", err) + } + + if len(seen) != 1 { + t.Fatalf("Revoke pre-read saw %d assignment(s), want the privilege the Grant just added: "+ + "a cached page here reports GrantAlreadyRevoked and skips the PATCH", len(seen)) + } + if gets != 2 { + t.Errorf("%d GET(s) reached the API, want 2: both pre-reads must bypass the cache", gets) + } +} diff --git a/pkg/databricks/request.go b/pkg/databricks/request.go index 96c77cad..0ea04c07 100644 --- a/pkg/databricks/request.go +++ b/pkg/databricks/request.go @@ -105,6 +105,30 @@ func (c *Client) Get( http.MethodGet, nil, response, + nil, + params..., + ) +} + +// GetUncached is Get with the uhttp response cache bypassed on the read side. +// The cache holds a GET 200 for an hour and a PATCH does not invalidate it, so a +// read that decides whether a write still has to happen must not be served from +// it: the write would be skipped against a page that predates the last change. +// uhttp still records the fresh response, so the entry a later reader sees is the +// one this call just observed. +func (c *Client) GetUncached( + ctx context.Context, + urlAddress *url.URL, + response any, + params ...Vars, +) (*v2.RateLimitDescription, error) { + return c.doRequest( + ctx, + urlAddress, + http.MethodGet, + nil, + response, + []uhttp.RequestOption{uhttp.WithNoCache()}, params..., ) } @@ -122,6 +146,7 @@ func (c *Client) Put( http.MethodPut, body, response, + nil, params..., ) } @@ -139,6 +164,7 @@ func (c *Client) Post( http.MethodPost, body, response, + nil, params..., ) } @@ -156,6 +182,7 @@ func (c *Client) Patch( http.MethodPatch, body, response, + nil, params..., ) } @@ -198,6 +225,7 @@ func (c *Client) prepareRequest( urlAddress *url.URL, method string, body any, + requestOptions []uhttp.RequestOption, params ...Vars, ) (*http.Request, error) { // TODO(marcos): Refactor URLs so that we don't have to unescape. @@ -217,6 +245,7 @@ func (c *Client) prepareRequest( if body != nil { options = append(options, uhttp.WithJSONBody(body)) } + options = append(options, requestOptions...) req, err := c.httpClient.NewRequest(ctx, method, requestURL, options...) if err != nil { @@ -245,9 +274,10 @@ func (c *Client) doRequest( method string, body any, response any, + requestOptions []uhttp.RequestOption, params ...Vars, ) (*v2.RateLimitDescription, error) { - req, err := c.prepareRequest(ctx, urlAddress, method, body, params...) + req, err := c.prepareRequest(ctx, urlAddress, method, body, requestOptions, params...) if err != nil { return nil, err } @@ -294,7 +324,7 @@ func (c *Client) doRequestNoResponse( response any, params ...Vars, ) (*v2.RateLimitDescription, error) { - req, err := c.prepareRequest(ctx, urlAddress, method, body, params...) + req, err := c.prepareRequest(ctx, urlAddress, method, body, nil, params...) if err != nil { return nil, err } diff --git a/pkg/databricks/unity_catalog.go b/pkg/databricks/unity_catalog.go index 9c17acf1..9fc38090 100644 --- a/pkg/databricks/unity_catalog.go +++ b/pkg/databricks/unity_catalog.go @@ -151,6 +151,19 @@ func (c *Client) ListPermissions( string, *v2.RateLimitDescription, error, +) { + return c.listPermissions(ctx, workspaceId, securableType, fullName, principal, pageToken, false) +} + +func (c *Client) listPermissions( + ctx context.Context, + workspaceId, securableType, fullName, principal, pageToken string, + uncached bool, +) ( + []PrivilegeAssignment, + string, + *v2.RateLimitDescription, + error, ) { u, err := c.permissionsUrl(workspaceId, securableType, fullName) if err != nil { @@ -160,7 +173,11 @@ func (c *Client) ListPermissions( var res permissionsResponse maxResults := permissionsMaxResults - ratelimitData, err := c.Get(ctx, u, &res, &unityCatalogVars{ + read := c.Get + if uncached { + read = c.GetUncached + } + ratelimitData, err := read(ctx, u, &res, &unityCatalogVars{ principal: principal, maxResults: &maxResults, pageToken: pageToken, @@ -176,7 +193,10 @@ func (c *Client) ListPermissions( return res.PrivilegeAssignments, res.NextPageToken, ratelimitData, nil } -// DrainPermissions walks ListPermissions to exhaustion for every principal. +// DrainPermissions walks ListPermissions to exhaustion for every principal, +// bypassing the response cache. Its only caller is the pre-read that decides +// whether a Grant still has to PATCH, and that decision is wrong when it is made +// against a page the cache has been holding since before the last change. func (c *Client) DrainPermissions( ctx context.Context, workspaceId, securableType, fullName string, @@ -186,11 +206,13 @@ func (c *Client) DrainPermissions( error, ) { return drainPagesFrom(ctx, "", func(pageToken string) ([]PrivilegeAssignment, string, *v2.RateLimitDescription, error) { - return c.ListPermissions(ctx, workspaceId, securableType, fullName, "", pageToken) + return c.listPermissions(ctx, workspaceId, securableType, fullName, "", pageToken, true) }) } -// ForEachPermissionsPage walks ListPermissions without retaining the pages. +// ForEachPermissionsPage walks ListPermissions without retaining the pages. The +// grant-visibility probe reads through this, where a cached page costs nothing: +// it reports who may administer the securable, not who holds what. func (c *Client) ForEachPermissionsPage( ctx context.Context, workspaceId, securableType, fullName, principal string, @@ -201,6 +223,18 @@ func (c *Client) ForEachPermissionsPage( }, visit) } +// ForEachUncachedPermissionsPage is ForEachPermissionsPage for the pre-read that +// decides whether a Revoke still has to PATCH. See DrainPermissions. +func (c *Client) ForEachUncachedPermissionsPage( + ctx context.Context, + workspaceId, securableType, fullName, principal string, + visit func([]PrivilegeAssignment) (bool, error), +) (*v2.RateLimitDescription, error) { + return forEachPageFrom(ctx, "", func(pageToken string) ([]PrivilegeAssignment, string, *v2.RateLimitDescription, error) { + return c.listPermissions(ctx, workspaceId, securableType, fullName, principal, pageToken, true) + }, visit) +} + // UpdatePermissions (PATCH /api/2.1/unity-catalog/permissions/{securable_type}/{full_name}). // Databricks no-ops a privilege already held or already absent, so the echoed // assignments are the only confirmation the change landed — and that echo is @@ -276,10 +310,11 @@ func (c *Client) updatePermissions( return ratelimitData, err } - // The PATCH body is already page one. + // The PATCH body is already page one. The remaining pages confirm the change + // landed, so they are read past the cache for the same reason the pre-read is. drainRateLimit, drainErr := forEachPageFrom(ctx, res.NextPageToken, func(pageToken string) ([]PrivilegeAssignment, string, *v2.RateLimitDescription, error) { - return c.ListPermissions(ctx, workspaceId, securableType, fullName, "", pageToken) + return c.listPermissions(ctx, workspaceId, securableType, fullName, "", pageToken, true) }, visit) if drainRateLimit != nil { ratelimitData = drainRateLimit From 73eaf14e815766e0ba8506db27c4ad12d7ce87c9 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Fri, 9 Oct 2026 13:52:23 -0300 Subject: [PATCH 04/36] fix(databricks): treat a metastore no in-scope workspace reaches as out of scope Filtering routing by the workspaces allowlist left a metastore that only an out-of-scope workspace is attached to still listed on the account but with no access path, so usableWorkspaces answered FailedPrecondition and, since only codes.NotFound is a warning to the SDK, scoping a sync to one workspace failed it for every metastore the others hold. Such a metastore now reads as absent, the way a filtered catalog already does, and List emits from the routing snapshot so it cannot advertise a metastore whose grants cannot be read. The remedy text names the allowlist alongside the exclude list. Co-authored-by: Cursor --- pkg/connector/metastore.go | 7 ++++-- pkg/connector/unity_catalog.go | 16 ++++++++++++- pkg/connector/unity_catalog_test.go | 37 ++++++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index cd722d4f..20d40407 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -68,7 +68,10 @@ func (m *metastoreBuilder) List(ctx context.Context, parentResourceID *v2.Resour return nil, nil, nil } - metastores, rateLimit, err := m.client.ListMetastores(ctx) + // Read through the routing snapshot rather than the raw listing: with a + // workspace allowlist the two differ, and emitting a metastore no in-scope + // workspace can reach would advertise a resource whose grants cannot be read. + snap, rateLimit, err := m.uc.buildRouting(ctx) annos := annotations.Annotations{} noteRateLimit(&annos, rateLimit) if err != nil { @@ -76,7 +79,7 @@ func (m *metastoreBuilder) List(ctx context.Context, parentResourceID *v2.Resour } var rv []*v2.Resource - for _, metastore := range metastores { + for _, metastore := range snap.metastores { if metastore.MetastoreID == "" { continue } diff --git a/pkg/connector/unity_catalog.go b/pkg/connector/unity_catalog.go index 519b81c0..c00b7b62 100644 --- a/pkg/connector/unity_catalog.go +++ b/pkg/connector/unity_catalog.go @@ -77,7 +77,8 @@ func usableWorkspaces(snap routingSnapshot, metastoreID string) ([]string, bool, return nil, true, status.Errorf(codes.FailedPrecondition, "metastore %s exists on the account but no workspace this sync can use is attached to it, so its securables cannot be read: "+ - "every attached workspace is either named in databricks-exclude-workspaces, not RUNNING, or reports no metastore assignment%s", + "every attached workspace is either left out of workspaces, named in databricks-exclude-workspaces, not RUNNING, "+ + "or reports no metastore assignment%s", metastoreID, unusable) } @@ -102,6 +103,19 @@ func (u *unityCatalog) buildRouting(ctx context.Context) (routingSnapshot, *v2.R return routingSnapshot{}, rateLimit, err } + // With an allowlist, a metastore no in-scope workspace is attached to is out of + // scope, and out of scope is an absence here: the metastore is not listed, not + // listed-but-unreachable. Keeping it would make every securable under it fail + // with FailedPrecondition, so scoping a sync to one workspace would break the + // sync for every metastore the other workspaces hold. + if len(u.workspaces) > 0 { + for metastoreID := range snap.metastores { + if len(snap.workspaces[metastoreID]) == 0 { + delete(snap.metastores, metastoreID) + } + } + } + return snap, rateLimit, nil } diff --git a/pkg/connector/unity_catalog_test.go b/pkg/connector/unity_catalog_test.go index 7b0892ff..714af7e1 100644 --- a/pkg/connector/unity_catalog_test.go +++ b/pkg/connector/unity_catalog_test.go @@ -131,7 +131,10 @@ func routingHandler(t *testing.T, assignments map[string]any, probed map[string] switch { case strings.HasSuffix(path, "/metastores"): _ = json.NewEncoder(w).Encode(map[string]any{ - "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main"}}, + "metastores": []map[string]any{ + {"metastore_id": "ms-a", "name": "main"}, + {"metastore_id": "ms-b", "name": "other"}, + }, }) case strings.HasSuffix(path, "/workspaces"): _ = json.NewEncoder(w).Encode([]map[string]any{ @@ -182,6 +185,38 @@ func TestRoutingSkipsAWorkspaceOutsideTheConfiguredAllowlist(t *testing.T) { } } +// Scoping the sync to one workspace must not fail it for the metastores the other +// workspaces hold. A metastore only the excluded workspace can reach has to read +// as absent, the way a filtered catalog does: left in the snapshot it reaches +// usableWorkspaces, which answers FailedPrecondition, and that is not a warning +// to the SDK, so every metastore-enabled sync would fail. +func TestAMetastoreOnlyAnOutOfScopeWorkspaceReachesIsAbsentRatherThanFatal(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, + map[string]any{"1": "ms-a", "2": "ms-b"}, probed)) + + uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod"})) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("buildRouting: %v", err) + } + + if _, ok := snap.metastores["ms-a"]; !ok { + t.Error("ms-a is missing from the snapshot, so the in-scope metastore would not sync") + } + if _, ok := snap.metastores["ms-b"]; ok { + t.Error("ms-b is still in the snapshot; its Grants() would fail the whole sync with FailedPrecondition") + } + + workspace, exists, _, err := workspaceFromSnapshot(snap, "ms-b", nil) + if err != nil { + t.Fatalf("resolving the out-of-scope metastore errored instead of reporting it absent: %v", err) + } + if exists || workspace != "" { + t.Errorf("ms-b resolved to workspace %q (exists=%v), want absent", workspace, exists) + } +} + // Only codes.NotFound is a warning to the SDK; every other code fails the whole // sync. A credential that is not a member of one workspace answers 401 or 403 // there, which says nothing about the rest of the account, so letting it escape From c1658034a6ca1d527236a7f661e5f99929d5ebdc Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Fri, 9 Oct 2026 15:15:07 -0300 Subject: [PATCH 05/36] fix(databricks): never prune a metastore while an in-scope workspace is unreadable Dropping an unrouted metastore under a workspace allowlist assumed the only reason it could be unrouted was the workspaces left out. An in-scope workspace that answers neither its metastore assignment nor a catalog listing may be the one serving it, so the metastore would read as absent and C1 would delete it and every grant under it. Failing the sync is recoverable; that is not. fillWorkspaces now records those workspaces separately from the ones whose empty catalog listing settles that they carry nothing, and the prune only runs when none of them is unknown. Co-authored-by: Cursor --- pkg/connector/unity_catalog.go | 19 +++++++++-- pkg/connector/unity_catalog_test.go | 51 ++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/pkg/connector/unity_catalog.go b/pkg/connector/unity_catalog.go index c00b7b62..bfd1407c 100644 --- a/pkg/connector/unity_catalog.go +++ b/pkg/connector/unity_catalog.go @@ -27,6 +27,12 @@ type routingSnapshot struct { workspaceIDs map[string]string unusable []string asked int + + // unreadable names the in-scope workspaces that answered neither their + // assignment nor a catalog listing, so what they serve is unknown rather than + // known to be nothing. It is separate from unusable, where a successful but + // empty catalog listing settles that the workspace carries nothing. + unreadable []string } // unityCatalog holds no state beyond the client and the workspace filter: every @@ -108,7 +114,13 @@ func (u *unityCatalog) buildRouting(ctx context.Context) (routingSnapshot, *v2.R // listed-but-unreachable. Keeping it would make every securable under it fail // with FailedPrecondition, so scoping a sync to one workspace would break the // sync for every metastore the other workspaces hold. - if len(u.workspaces) > 0 { + // + // Only when every in-scope workspace answered, though. An unrouted metastore + // means "out of scope" only if the workspaces left out are the reason; if an + // in-scope workspace could not be read, the same metastore may well be one it + // serves, and dropping it here would report it deleted and take its grants + // with it. Unknown is not absence, so that case keeps the loud failure. + if len(u.workspaces) > 0 && len(snap.unreadable) == 0 { for metastoreID := range snap.metastores { if len(snap.workspaces[metastoreID]) == 0 { delete(snap.metastores, metastoreID) @@ -196,7 +208,7 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot // A catalog payload names its metastore, so the listing settles which metastore // this workspace serves, if any. - var unusable []string + var unusable, unreadable []string for _, workspace := range unassigned { if err := ctx.Err(); err != nil { return rateLimit, err @@ -208,7 +220,9 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot } if err != nil { if isUnreadableWorkspaceError(err) { + // Neither endpoint answered, so this workspace's metastores are unknown. unusable = append(unusable, workspace) + unreadable = append(unreadable, workspace) continue } @@ -245,6 +259,7 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot } snap.unusable = unusable + snap.unreadable = unreadable snap.asked = asked return rateLimit, nil diff --git a/pkg/connector/unity_catalog_test.go b/pkg/connector/unity_catalog_test.go index 714af7e1..efdb2efa 100644 --- a/pkg/connector/unity_catalog_test.go +++ b/pkg/connector/unity_catalog_test.go @@ -124,6 +124,19 @@ func TestLookupDoesNotFallBackFromAnUnknownID(t *testing.T) { func routingHandler(t *testing.T, assignments map[string]any, probed map[string]int) http.HandlerFunc { t.Helper() + return routingHandlerWithCatalogs(t, assignments, nil, probed) +} + +// catalogStatus, keyed by deployment name, makes the catalog listing fail for a +// workspace, which is how a credential that is not a member of it answers. +func routingHandlerWithCatalogs( + t *testing.T, + assignments map[string]any, + catalogStatus map[string]int, + probed map[string]int, +) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") path := r.URL.Path @@ -156,7 +169,13 @@ func routingHandler(t *testing.T, assignments map[string]any, probed map[string] http.Error(w, `{"message":"missing"}`, http.StatusNotFound) } default: - probed[r.Header.Get("X-Test-Workspace")]++ + workspace := r.Header.Get("X-Test-Workspace") + probed[workspace]++ + if status, ok := catalogStatus[workspace]; ok { + http.Error(w, `{"message":"denied"}`, status) + + return + } _ = json.NewEncoder(w).Encode(map[string]any{"catalogs": []map[string]any{}}) } } @@ -217,6 +236,36 @@ func TestAMetastoreOnlyAnOutOfScopeWorkspaceReachesIsAbsentRatherThanFatal(t *te } } +// Dropping an unrouted metastore is only safe when the workspaces left out are +// the reason it is unrouted. An in-scope workspace that answered neither its +// assignment nor a catalog listing may be exactly the one serving it, so pruning +// there would report the metastore deleted and take its grants with it. Failing +// the sync is recoverable; a deletion is not. +func TestAnUnreadableInScopeWorkspaceStopsTheMetastoreFromBeingPruned(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandlerWithCatalogs(t, + map[string]any{"1": "ms-a", "2": http.StatusForbidden}, + map[string]int{"wsb": http.StatusForbidden}, + probed)) + + uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod", "dev"})) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("buildRouting: %v", err) + } + + if !slices.Contains(snap.unreadable, "wsb") { + t.Fatalf("unreadable = %v, want it to name wsb", snap.unreadable) + } + if _, ok := snap.metastores["ms-b"]; !ok { + t.Fatal("ms-b was pruned while an in-scope workspace was unreadable, so C1 would delete it and its grants") + } + + if _, _, _, err := workspaceFromSnapshot(snap, "ms-b", nil); err == nil { + t.Error("ms-b resolved without an error, want the sync to fail loudly rather than report an absence") + } +} + // Only codes.NotFound is a warning to the SDK; every other code fails the whole // sync. A credential that is not a member of one workspace answers 401 or 403 // there, which says nothing about the rest of the account, so letting it escape From b1a0243f5f21947b5bc5c79e46a8749ddaea1c5b Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Fri, 9 Oct 2026 16:30:02 -0300 Subject: [PATCH 06/36] fix(databricks): do not read an empty catalog listing as proof a denied workspace serves nothing The catalogs endpoint returns only the catalogs the caller may use, so an empty listing from a credential that was also refused the workspace's metastore assignment is equally explained by a missing USE_CATALOG. That pair settled nothing, yet it left the workspace out of the unreadable set, so the allowlist prune still ran and a metastore the workspace does serve would have been reported deleted. Only a 404 on the assignment together with an empty listing now reads as a workspace outside Unity Catalog. A 401 or 403 there keeps the workspace unreadable and the metastore listed. Co-authored-by: Cursor --- pkg/connector/helpers.go | 5 ++++ pkg/connector/unity_catalog.go | 31 ++++++++++++++++++++++--- pkg/connector/unity_catalog_test.go | 36 +++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 79fc786c..90992e71 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -239,6 +239,11 @@ func isForbiddenError(err error) bool { return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden } +func isUnauthorizedError(err error) bool { + var apiErr *databricks.APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized +} + // isUnreadableWorkspaceError reports an answer that says this credential cannot // read this workspace, which is a fact about that workspace and not about the // account. Access-path discovery treats all three alike: the workspace carries diff --git a/pkg/connector/unity_catalog.go b/pkg/connector/unity_catalog.go index bfd1407c..0579a394 100644 --- a/pkg/connector/unity_catalog.go +++ b/pkg/connector/unity_catalog.go @@ -88,6 +88,15 @@ func usableWorkspaces(snap routingSnapshot, metastoreID string) ([]string, bool, metastoreID, unusable) } +// deferredWorkspace is a workspace whose metastore assignment could not be read, +// held over for the catalog listing. denied separates a credential that was +// refused the assignment from one that was told there is none, because the two +// give the listing different weight. +type deferredWorkspace struct { + name string + denied bool +} + // buildRouting lists metastores and the workspaces each one is reachable through. func (u *unityCatalog) buildRouting(ctx context.Context) (routingSnapshot, *v2.RateLimitDescription, error) { metastores, rateLimit, err := u.listMetastores(ctx) @@ -158,7 +167,7 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot return rateLimit, fmt.Errorf("failed to list workspaces: %w", err) } - var unassigned []string + var unassigned []deferredWorkspace asked := 0 for _, workspace := range workspaces { if err := ctx.Err(); err != nil { @@ -192,7 +201,12 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot // credential is not a member of the workspace at all. None of the three // says anything about the other workspaces, so the workspace is set // aside rather than failing the snapshot that every securable needs. - unassigned = append(unassigned, workspace.DeploymentName) + // Which of the three it was decides how much the catalog listing can + // then settle, so it is carried along. + unassigned = append(unassigned, deferredWorkspace{ + name: workspace.DeploymentName, + denied: isForbiddenError(err) || isUnauthorizedError(err), + }) continue } @@ -209,11 +223,12 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot // A catalog payload names its metastore, so the listing settles which metastore // this workspace serves, if any. var unusable, unreadable []string - for _, workspace := range unassigned { + for _, deferred := range unassigned { if err := ctx.Err(); err != nil { return rateLimit, err } + workspace := deferred.name metastoreIDs, listRateLimit, err := u.metastoresServedBy(ctx, workspace) if listRateLimit != nil { rateLimit = listRateLimit @@ -232,6 +247,16 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot if len(metastoreIDs) == 0 { unusable = append(unusable, workspace) + // An empty catalog listing is not the same evidence in both cases. The + // endpoint returns only the catalogs the caller may use, so a credential + // that was also denied the assignment read can be looking at an empty list + // purely for want of USE_CATALOG. Only a 404 on the assignment plus an + // empty listing reads as a workspace outside Unity Catalog; a denial plus + // an empty listing settles nothing. + if deferred.denied { + unreadable = append(unreadable, workspace) + } + continue } diff --git a/pkg/connector/unity_catalog_test.go b/pkg/connector/unity_catalog_test.go index efdb2efa..05c3e70b 100644 --- a/pkg/connector/unity_catalog_test.go +++ b/pkg/connector/unity_catalog_test.go @@ -266,6 +266,42 @@ func TestAnUnreadableInScopeWorkspaceStopsTheMetastoreFromBeingPruned(t *testing } } +// The catalogs endpoint returns only the catalogs the caller may use, so an empty +// listing from a credential that was also refused the assignment read is equally +// explained by missing USE_CATALOG. That pair settles nothing, and treating it as +// "this workspace carries nothing" would prune a metastore it does serve. Only a +// 404 on the assignment plus an empty listing reads as outside Unity Catalog. +func TestADeniedAssignmentWithAnEmptyCatalogListingIsNotEvidenceOfNothing(t *testing.T) { + for name, tc := range map[string]struct { + assignment any + wantPruned bool + wantUnreadab bool + }{ + "denied assignment": {assignment: http.StatusForbidden, wantPruned: false, wantUnreadab: true}, + "no assignment": {assignment: http.StatusNotFound, wantPruned: true, wantUnreadab: false}, + } { + t.Run(name, func(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, + map[string]any{"1": "ms-a", "2": tc.assignment}, probed)) + + uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod", "dev"})) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("buildRouting: %v", err) + } + + if got := slices.Contains(snap.unreadable, "wsb"); got != tc.wantUnreadab { + t.Errorf("unreadable names wsb = %v, want %v (unreadable = %v)", got, tc.wantUnreadab, snap.unreadable) + } + _, stillListed := snap.metastores["ms-b"] + if pruned := !stillListed; pruned != tc.wantPruned { + t.Errorf("ms-b pruned = %v, want %v", pruned, tc.wantPruned) + } + }) + } +} + // Only codes.NotFound is a warning to the SDK; every other code fails the whole // sync. A credential that is not a member of one workspace answers 401 or 403 // there, which says nothing about the rest of the account, so letting it escape From 23720aa7e23e31dc6a4728039132851726d0238c Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Mon, 12 Oct 2026 10:38:42 -0300 Subject: [PATCH 07/36] fix(databricks): keep vendor error_code on api errors Co-authored-by: Cursor --- pkg/databricks/request.go | 45 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/pkg/databricks/request.go b/pkg/databricks/request.go index 0ea04c07..5c1c6e37 100644 --- a/pkg/databricks/request.go +++ b/pkg/databricks/request.go @@ -48,9 +48,20 @@ func wrapTransportAuthError(err error) error { return uhttp.WrapErrors(code, "databricks-connector: authentication failed", err) } +// Databricks error_code values that mean the named securable is gone. +// A route-level 404 (workspace does not serve this metastore) uses a +// different code, so status-only matching cannot tell the two apart. +const ( + ErrorCodeCatalogDoesNotExist = "CATALOG_DOES_NOT_EXIST" + ErrorCodeSchemaDoesNotExist = "SCHEMA_DOES_NOT_EXIST" + ErrorCodeTableDoesNotExist = "TABLE_DOES_NOT_EXIST" + ErrorCodeVolumeDoesNotExist = "VOLUME_DOES_NOT_EXIST" +) + // APIError represents an error response from the Databricks API. type APIError struct { StatusCode int + ErrorCode string Detail string Message string Err error @@ -58,14 +69,40 @@ type APIError struct { func (e *APIError) Error() string { return fmt.Sprintf( - "unexpected status code %d: %s %s %v", + "unexpected status code %d: %s %s %s %v", e.StatusCode, + e.ErrorCode, e.Detail, e.Message, e.Err, ) } +// HasErrorCode reports a 404 whose body named one of the given error codes. +func (e *APIError) HasErrorCode(codes ...string) bool { + if e == nil || e.StatusCode != http.StatusNotFound || e.ErrorCode == "" { + return false + } + for _, code := range codes { + if e.ErrorCode == code { + return true + } + } + + return false +} + +// IsSecurableGone reports a 404 for a named securable that no longer exists. +// A 404 with any other error_code (or none) is a routing or permission miss. +func IsSecurableGone(err error, errorCodes ...string) bool { + var apiErr *APIError + if !errors.As(err, &apiErr) { + return false + } + + return apiErr.HasErrorCode(errorCodes...) +} + func (e *APIError) Unwrap() error { return e.Err } @@ -301,8 +338,9 @@ func (c *Client) doRequest( } var errorResponse struct { - Detail string `json:"detail"` - Message string `json:"message"` + ErrorCode string `json:"error_code"` + Detail string `json:"detail"` + Message string `json:"message"` } if err := parseJSON(resp.Body, &errorResponse); err != nil { return nil, err @@ -310,6 +348,7 @@ func (c *Client) doRequest( return ratelimitData, &APIError{ StatusCode: resp.StatusCode, + ErrorCode: errorResponse.ErrorCode, Detail: errorResponse.Detail, Message: errorResponse.Message, Err: err, From c16d41e42f08249c67eccd2f08dcd78215418cd1 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Mon, 12 Oct 2026 15:35:37 -0300 Subject: [PATCH 08/36] perf(databricks): resolve owner-only grants without listing account scim Co-authored-by: Cursor --- pkg/connector/metastore.go | 20 ++- pkg/connector/principal_index.go | 76 ++++++++++ pkg/connector/principal_index_test.go | 200 ++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 pkg/connector/principal_index_test.go diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index 20d40407..3d0f072b 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -181,22 +181,18 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at "databricks-connector: failed to list permissions on %s %s: %w", databricks.SecurableMetastore, ref.permissionsName(), err) } - needPrincipals := len(assignments) > 0 + owner := "" if pageToken == "" { - if owner, ok := rs.GetProfileStringValue(rs.GetProfile(resource), profileKeyOwner); ok && owner != "" { - needPrincipals = true + if v, ok := rs.GetProfileStringValue(rs.GetProfile(resource), profileKeyOwner); ok { + owner = v } } - principals := newPrincipalIndex() - if needPrincipals { - var principalRateLimit *v2.RateLimitDescription - principals, principalRateLimit, err = m.uc.listPrincipals(ctx) - noteRateLimit(&annos, principalRateLimit) - if err != nil { - return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( - "databricks-connector: failed to list account principals: %w", err) - } + principals, principalRateLimit, err := m.uc.principalsForGrants(ctx, assignments, owner) + noteRateLimit(&annos, principalRateLimit) + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( + "databricks-connector: failed to resolve account principals: %w", err) } var rv []*v2.Grant diff --git a/pkg/connector/principal_index.go b/pkg/connector/principal_index.go index 7a0d0bf2..4d401a72 100644 --- a/pkg/connector/principal_index.go +++ b/pkg/connector/principal_index.go @@ -82,6 +82,82 @@ func (u *unityCatalog) ownsPrincipal(name string) bool { return u.ownPrincipal != "" && strings.EqualFold(name, u.ownPrincipal) } +// principalsForGrants builds the index Grants uses for assignments and owner. +// Privilege assignments carry a principal_id with no type discriminator, so they +// still require a full account SCIM walk. An owner-only page — the common +// table/schema case — is one to three targeted filters. A workspace-admins +// owner matches none of them and must not pay for listing every account principal. +func (u *unityCatalog) principalsForGrants( + ctx context.Context, + assignments []databricks.PrivilegeAssignment, + owner string, +) (*principalIndex, *v2.RateLimitDescription, error) { + if len(assignments) > 0 { + return u.listPrincipals(ctx) + } + if owner == "" { + return newPrincipalIndex(), nil, nil + } + + return u.indexOwner(ctx, owner) +} + +// indexOwner asks each principal namespace for that one name. The first hit +// wins, in the same order listPrincipals writes the index (users, groups, +// service principals). +func (u *unityCatalog) indexOwner(ctx context.Context, owner string) (*principalIndex, *v2.RateLimitDescription, error) { + index := newPrincipalIndex() + + userID, rateLimit, err := u.client.FindUserID(ctx, "", owner) + if err != nil { + return nil, rateLimit, fmt.Errorf("failed to resolve owner %q as a user: %w", owner, err) + } + if userID != "" { + index.add(ctx, userID, []string{owner}, &v2.ResourceId{ResourceType: userResourceType.Id, Resource: userID}) + + return index, rateLimit, nil + } + + groupID, groupRateLimit, err := u.client.FindGroupID(ctx, "", owner) + if groupRateLimit != nil { + rateLimit = groupRateLimit + } + if err != nil { + return nil, rateLimit, fmt.Errorf("failed to resolve owner %q as a group: %w", owner, err) + } + if groupID != "" { + accountResourceId, err := rs.NewResourceID(accountResourceType, u.client.GetAccountId()) + if err != nil { + return nil, rateLimit, err + } + resourceId, err := rs.NewResourceID(groupResourceType, groupResourceId(ctx, groupID, accountResourceId)) + if err != nil { + return nil, rateLimit, err + } + index.add(ctx, groupID, []string{owner}, resourceId) + + return index, rateLimit, nil + } + + servicePrincipalID, servicePrincipalRateLimit, err := u.client.FindServicePrincipalID(ctx, "", owner) + if servicePrincipalRateLimit != nil { + rateLimit = servicePrincipalRateLimit + } + if err != nil { + return nil, rateLimit, fmt.Errorf("failed to resolve owner %q as a service principal: %w", owner, err) + } + if servicePrincipalID != "" { + index.add(ctx, servicePrincipalID, []string{owner}, &v2.ResourceId{ + ResourceType: servicePrincipalResourceType.Id, + Resource: servicePrincipalID, + }) + + return index, rateLimit, nil + } + + return index, rateLimit, nil +} + func (u *unityCatalog) listPrincipals(ctx context.Context) (*principalIndex, *v2.RateLimitDescription, error) { accountResourceId, err := rs.NewResourceID(accountResourceType, u.client.GetAccountId()) if err != nil { diff --git a/pkg/connector/principal_index_test.go b/pkg/connector/principal_index_test.go new file mode 100644 index 00000000..9f79c60a --- /dev/null +++ b/pkg/connector/principal_index_test.go @@ -0,0 +1,200 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/conductorone/baton-databricks/pkg/databricks" +) + +func TestPrincipalsForGrantsOwnerOnlyDoesNotListAccount(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + var unfiltered int + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + if isUnfilteredAccountSCIM(r) { + unfiltered++ + http.Error(w, "account listing is not allowed on the owner-only path", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(r.URL.Path, "/scim/v2/Users"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "Resources": []map[string]any{{"id": "u-1", "userName": "alice@example.com"}}, + "totalResults": 1, + }) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"Resources": []any{}, "totalResults": 0}) + } + }) + + uc := newUnityCatalog(client, "app-1", nil) + index, _, err := uc.principalsForGrants(context.Background(), nil, "alice@example.com") + if err != nil { + t.Fatalf("principalsForGrants: %v", err) + } + if unfiltered != 0 { + t.Fatalf("unfiltered account SCIM listings = %d, want 0", unfiltered) + } + got, ok := index.lookupName("alice@example.com") + if !ok { + t.Fatal("owner did not resolve") + } + if got.GetResourceType() != userResourceType.Id || got.GetResource() != "u-1" { + t.Fatalf("owner = %s:%s, want user:u-1", got.GetResourceType(), got.GetResource()) + } +} + +func TestPrincipalsForGrantsOwnerOnlyResolvesGroupAndServicePrincipal(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + tests := []struct { + name string + owner string + resourceType string + resource string + }{ + {name: "group", owner: "data-eng", resourceType: groupResourceType.Id, resource: "account/acc-1/group/g-1"}, + {name: "service-principal", owner: "11111111-1111-1111-1111-111111111111", resourceType: servicePrincipalResourceType.Id, resource: "sp-1"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var unfiltered int + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + if isUnfilteredAccountSCIM(r) { + unfiltered++ + http.Error(w, "account listing is not allowed on the owner-only path", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(r.URL.Path, "/scim/v2/Groups") && strings.Contains(r.URL.Query().Get("filter"), "data-eng"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "Resources": []map[string]any{{"id": "g-1", "displayName": "data-eng"}}, + "totalResults": 1, + }) + case strings.Contains(r.URL.Path, "/scim/v2/ServicePrincipals") && strings.Contains(r.URL.Query().Get("filter"), "11111111-1111-1111-1111-111111111111"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "Resources": []map[string]any{{"id": "sp-1", "applicationId": "11111111-1111-1111-1111-111111111111"}}, + "totalResults": 1, + }) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"Resources": []any{}, "totalResults": 0}) + } + }) + + uc := newUnityCatalog(client, "app-1", nil) + index, _, err := uc.principalsForGrants(context.Background(), nil, test.owner) + if err != nil { + t.Fatalf("principalsForGrants: %v", err) + } + if unfiltered != 0 { + t.Fatalf("unfiltered account SCIM listings = %d, want 0", unfiltered) + } + got, ok := index.lookupName(test.owner) + if !ok { + t.Fatal("owner did not resolve") + } + if got.GetResourceType() != test.resourceType || got.GetResource() != test.resource { + t.Fatalf("owner = %s:%s, want %s:%s", got.GetResourceType(), got.GetResource(), test.resourceType, test.resource) + } + }) + } +} + +func TestPrincipalsForGrantsWorkspaceAdminsOwnerDoesNotListAccount(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + var unfiltered int + var filtered int + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + if isUnfilteredAccountSCIM(r) { + unfiltered++ + http.Error(w, "account listing is not allowed on the owner-only path", http.StatusInternalServerError) + return + } + if strings.Contains(r.URL.Path, "/scim/v2/") { + filtered++ + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"Resources": []any{}, "totalResults": 0}) + }) + + uc := newUnityCatalog(client, "app-1", nil) + index, _, err := uc.principalsForGrants(context.Background(), nil, "_workspace_admins_workspace-1") + if err != nil { + t.Fatalf("principalsForGrants: %v", err) + } + if unfiltered != 0 { + t.Fatalf("unfiltered account SCIM listings = %d, want 0", unfiltered) + } + if filtered != 3 { + t.Fatalf("filtered lookups = %d, want 3 (user, group, service principal)", filtered) + } + if _, ok := index.lookupName("_workspace_admins_workspace-1"); ok { + t.Fatal("workspace-admins owner should stay unresolved") + } +} + +func TestPrincipalsForGrantsAssignmentsStillListAccount(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + var unfiltered int + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + if isUnfilteredAccountSCIM(r) { + unfiltered++ + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"Resources": []any{}, "totalResults": 0}) + }) + + uc := newUnityCatalog(client, "app-1", nil) + _, _, err := uc.principalsForGrants(context.Background(), []databricks.PrivilegeAssignment{{ + Principal: "alice@example.com", + PrincipalID: json.Number("1"), + Privileges: []string{"USE_CATALOG"}, + }}, "") + if err != nil { + t.Fatalf("principalsForGrants: %v", err) + } + if unfiltered == 0 { + t.Fatal("privilege assignments still need the account SCIM walk") + } +} + +func TestPrincipalsForGrantsEmptyPageMakesNoSCIMCalls(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + var hits int + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + hits++ + http.Error(w, "no SCIM call expected", http.StatusInternalServerError) + }) + + uc := newUnityCatalog(client, "app-1", nil) + index, _, err := uc.principalsForGrants(context.Background(), nil, "") + if err != nil { + t.Fatalf("principalsForGrants: %v", err) + } + if hits != 0 { + t.Fatalf("SCIM calls = %d, want 0", hits) + } + if _, ok := index.lookupName("anyone"); ok { + t.Fatal("empty index should resolve nobody") + } +} + +func isUnfilteredAccountSCIM(r *http.Request) bool { + if r.URL.Query().Get("filter") != "" { + return false + } + + return strings.Contains(r.URL.Path, "/scim/v2/Users") || + strings.Contains(r.URL.Path, "/scim/v2/Groups") || + strings.Contains(r.URL.Path, "/scim/v2/ServicePrincipals") +} From ca2d9be24b3cf5127c00086a3123cab84e314385 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Tue, 13 Oct 2026 09:15:26 -0300 Subject: [PATCH 09/36] fix(databricks): document direct workspace admin and share group grant expansion Co-authored-by: Cursor --- README.md | 15 ++++------- docs/connector.mdx | 43 ++++++++++--------------------- docs/docs-info.md | 13 +++++++--- pkg/connector/grant_visibility.go | 24 ++++------------- pkg/connector/helpers.go | 15 +++++++++++ pkg/connector/metastore.go | 32 ++++++----------------- 6 files changed, 55 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 22fd40a5..9b3c40e8 100644 --- a/README.md +++ b/README.md @@ -158,16 +158,11 @@ fails with an `InvalidArgument` error. ## Unity Catalog permissions -Unity Catalog uses the same OAuth service principal as the rest of the -connector. It must be an account admin, and it must be assigned to a running -workspace the metastore is attached to, because the privilege endpoints are not -served on the account plane. To read a metastore's grants it must additionally -own the metastore or be an admin of that workspace — a privilege read cannot -settle it, since `MANAGE` is rejected there. When neither holds, the connector -fails the sync with a `PermissionDenied` error naming the metastore rather than -reporting a partial read. The scopes, roles and privileges behind each read, and -the reason the connector fails closed, are in -[docs/docs-info.md](./docs/docs-info.md). +The same OAuth service principal must own the metastore or be assigned +**Admin** directly on a workspace attached to it (Admin inherited only through +a group is not enough). When neither holds, the sync fails with a +`PermissionDenied` error naming the metastore rather than reporting a partial +read. Details are in [docs/docs-info.md](./docs/docs-info.md). ## Accounts with many workspaces diff --git a/docs/connector.mdx b/docs/connector.mdx index e1e36111..c74628ae 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -26,13 +26,15 @@ Workspace roles (such as **workspace-access** or **databricks-sql-access**) can [This connector syncs non-human identities](/product/admin/nhi) and displays them on the **Identities overview** dashboard. -## Understanding Unity Catalog in Databricks +## Metastore access in C1 -Unity Catalog is how Databricks governs access to data, and a **metastore** is the top of it: it is the regional container that holds the catalogs, schemas, tables and volumes underneath. One metastore usually serves several workspaces, so Unity Catalog access is granted once per object rather than once per workspace. - -Databricks records a set of privileges on a metastore — **CREATE_CATALOG**, **READ_METADATA**, **USE_SHARE**, and so on. C1 turns each privilege into an entitlement on the metastore, so you can review who holds it, request it, and revoke it for users, groups and service principals. The metastore also carries a read-only **owner** entitlement that shows who owns it; ownership cannot be requested or revoked through C1. - -When a group holds a privilege, C1 shows the group's members as holders of it, so access granted to a team still resolves to the people who have it. +The connector syncs the Databricks [Unity Catalog](https://docs.databricks.com/aws/en/data-governance/unity-catalog/manage-privileges/privileges) +metastore as a C1 resource. Each privilege Databricks records on it +(`CREATE_CATALOG`, `READ_METADATA`, `USE_SHARE` and so on) becomes an +entitlement in C1, so you can review who holds it, request it, and revoke it +for users, groups and service principals. A grant held by a group is expandable +so C1 can resolve the group's members as holders. Ownership is shown as a +read-only `owner` entitlement — it cannot be granted or revoked through C1. The Metastores resource type is turned off by default. Turn it on from the connector's configuration page in C1. @@ -41,11 +43,9 @@ The Metastores resource type is turned off by default. Turn it on from the conne **Notes on Unity Catalog access in C1:** -- C1 shows the privileges recorded directly on the metastore. Unity Catalog privileges inherit downward, and inherited access stays on the object that carries it rather than being repeated on everything beneath it. - Ownership is shown but cannot be provisioned. An owner that is not a Databricks account identity, such as a system account or a workspace admins group, is not shown at all. - **MANAGE** and **ALL_PRIVILEGES** are not offered as entitlements, because Databricks rejects both at the metastore level. - Unity Catalog tags are not synced. -- Grants are ingested a few minutes behind resources, so an object can briefly show no access right after a sync. ## Authentication methods @@ -60,7 +60,7 @@ Configuring the connector requires you to pass in credentials generated in Datab A user with the **Account admin** role in each Databricks workspace you want to sync must perform this task. -Every step below happens in the Databricks **account console**. On AWS that is [https://accounts.cloud.databricks.com](https://accounts.cloud.databricks.com). Azure Databricks and Google Cloud Platform customers use their own account console host, and also have to supply the account hostname and hostname when they configure the connector. +Every step below happens in the Databricks **account console**. On AWS that is [https://accounts.cloud.databricks.com](https://accounts.cloud.databricks.com). Azure Databricks and Google Cloud Platform customers use their own account console host. When they configure the connector they only need the workspace **Hostname**; Account Hostname is optional. ### Look up your Databricks account ID @@ -104,33 +104,16 @@ There is no scope to pick in the console. The connector requests the `all-apis` Make the service principal an account admin: in the account console, go to **User management** > **Service principals**, open the service principal, select its **Roles** tab, and enable **Account admin**. - Assign it to each workspace you want C1 to sync, as an admin: in the account console, go to **Workspaces**, open the workspace, select **Permissions**, click **Add permissions**, select the service principal, and choose **Admin**. + Assign it to each workspace you want C1 to sync, as an admin: in the account console, go to **Workspaces**, open the workspace, select **Permissions**, click **Add permissions**, select the service principal, and choose **Admin**. Assign **Admin** directly to the service principal — the connector does not treat Admin inherited through a group as enough. - Repeat this for every workspace you want synced. Workspace **Admin** also satisfies the Unity Catalog requirement in the next step for every catalog reached through that workspace. - - - -### Let the service principal read Unity Catalog privileges - -Skip this section if you are not syncing metastores, or if you already made the service principal a workspace **Admin** in the step above — that is enough on its own. - -Otherwise, make the service principal a metastore admin: - - - - In the account console, go to **Catalog** and open the metastore. - - - Set the **Metastore Admin** field to the service principal's application ID. + Repeat this for every workspace you want synced. Workspace **Admin** is enough to read Unity Catalog privileges on a metastore reached through that workspace. If the metastore's workspaces are not synced, make the service principal the **Metastore Admin** instead: in the account console, go to **Catalog**, open the metastore, and set **Metastore Admin** to the service principal's application ID. -This Unity Catalog permission is a hard requirement, not a recommendation. When a service principal without it reads the privileges on a metastore, Databricks returns a partial list rather than an error, and a partial list is indistinguishable from an object that has no access granted on it at all. C1 would read the privileges missing from that list as access that had been revoked. Rather than report an incomplete picture, the connector stops the sync and names the metastore it could not read in full. +Without this, the metastore sync fails with a permission error naming the metastore. Databricks returns a partial privilege list rather than an error, and C1 would treat the missing grants as revoked. -For more information, see the [Databricks Unity Catalog privileges documentation](https://docs.databricks.com/aws/en/data-governance/unity-catalog/manage-privileges/privileges). - **Done.** Here's the set of credentials you'll need when setting up the connector: - Account ID @@ -192,7 +175,7 @@ To complete this task, you'll need: Fill in **Workspaces** or **Exclude Workspaces**, not both. - **Google Cloud Platform and Azure Databricks customers only:** Enter your Databricks account hostname and hostname in the **Account Hostname** and **Hostname** fields. + **Google Cloud Platform and Azure Databricks customers only:** Enter your Databricks workspace hostname in the **Hostname** field. Account Hostname is optional: the connector derives it from Hostname when unset. - For more on how to look up these values for GCP, see the GCP Databricks [REST API reference](https://docs.databricks.com/api/gcp/account/introduction) and [identifiers for workspace objects](https://docs.gcp.databricks.com/en/workspace/workspace-details.html) documentation. - For more on how to look up these values for Azure Databricks, see the the Azure Databricks [REST API reference](https://docs.databricks.com/api/azure/account/introduction) and [identifiers for workspace objects](https://learn.microsoft.com/en-us/azure/databricks/workspace/workspace-details) documentation. diff --git a/docs/docs-info.md b/docs/docs-info.md index 528c67f7..fe2f43ec 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -24,7 +24,7 @@ While developing the connector, please fill out this form. This information is n > > - **OAuth**: a service principal OAuth client ID and client secret. Syncs the account and every workspace the service principal can access. This is the connector's only authentication method. > - > Google Cloud Platform and Azure Databricks customers also provide the account hostname and hostname. + > Google Cloud Platform and Azure Databricks customers also provide the workspace hostname. Account Hostname is optional: the connector derives it from Hostname when unset. 2. For each item in the list above: @@ -36,11 +36,11 @@ While developing the connector, please fill out this form. This information is n * Does the credential need any specific scopes or permissions? If so, list them here. - > The credential must have admin access to each resource it reads or writes: account-admin on the Databricks account (for account-level sync and provisioning) and workspace-admin on each workspace being synced. + > The credential must have admin access to each resource it reads or writes: account-admin on the Databricks account (for account-level sync and provisioning) and workspace-admin assigned directly to the service principal on each workspace being synced (Admin inherited only through a group is not enough). > > The OAuth token carries scopes on top of that. The connector requests `all-apis`, which covers the four it uses: `unity-catalog` (the securables and the permissions endpoint), `scim` (account principals), `access-management` (roles and workspace permission assignments) and `provisioning` (the account workspaces listing). The scope-by-scope breakdown is in `## Permission model` below. > - > Unity Catalog adds a third layer on top of scopes and roles: standing on the securable itself. For each metastore being synced, the service principal must either own the metastore or be an admin of a workspace it is assigned to. A privilege read cannot settle it at this level, because Databricks rejects `MANAGE` and `ALL_PRIVILEGES` on a metastore. `## Permission model` explains why neither is optional. + > Unity Catalog adds a third layer on top of scopes and roles: standing on the securable itself. For each metastore being synced, the service principal must either own the metastore or be assigned Admin directly on a workspace it is assigned to. A privilege read cannot settle it at this level, because Databricks rejects `MANAGE` and `ALL_PRIVILEGES` on a metastore. `## Permission model` explains why neither is optional. * If applicable: Is the list of scopes or permissions different to sync (read) versus provision (read-write)? If so, list the difference here. @@ -116,7 +116,8 @@ A privilege read is only trustworthy when the service principal satisfies at least one of these per metastore: - it is the owner of the metastore, or -- it is an admin of a workspace the metastore is assigned to. +- it is assigned Admin directly on a workspace the metastore is assigned to + (Admin inherited only through a group is not enough). Holding a privilege is not an option at this level: Databricks rejects `MANAGE` and `ALL_PRIVILEGES` on a metastore, so there is no privilege whose presence @@ -157,6 +158,10 @@ revoke build different request bodies. but the connector reports the privilege assignments recorded on the object itself. The endpoint that exposes inheritance does not return principal IDs, which are what the connector needs to resolve a principal unambiguously. +- **Workspace Admin must be assigned directly.** The connector matches the + service principal's application ID or its own SCIM id against the workspace's + ADMIN assignments. Admin inherited only through a group is not treated as + enough. - **Ownership is read-only.** The owner of a metastore is shown but cannot be provisioned. An owner that is not an account identity — `System user`, or a workspace admins group such as `_workspace_admins_` — does not diff --git a/pkg/connector/grant_visibility.go b/pkg/connector/grant_visibility.go index 28e70843..94117edf 100644 --- a/pkg/connector/grant_visibility.go +++ b/pkg/connector/grant_visibility.go @@ -21,29 +21,12 @@ func (u *unityCatalog) metastoreGrantsAreTrustworthy( ctx context.Context, snap routingSnapshot, metastoreID, workspace string, -) (bool, *v2.RateLimitDescription, error) { - return u.metastoreProbe(ctx, snap, metastoreID, workspace, nil) -} - -func (u *unityCatalog) metastoreProbe( - ctx context.Context, - snap routingSnapshot, - metastoreID, workspace string, - rateLimit *v2.RateLimitDescription, ) (bool, *v2.RateLimitDescription, error) { if metastore, ok := snap.metastores[metastoreID]; ok && u.ownsPrincipal(metastore.Owner) { - return true, rateLimit, nil - } - - admin, adminRateLimit, err := u.isWorkspaceAdmin(ctx, snap, workspace) - if adminRateLimit != nil { - rateLimit = adminRateLimit - } - if err != nil { - return false, rateLimit, err + return true, nil, nil } - return admin, rateLimit, nil + return u.isWorkspaceAdmin(ctx, snap, workspace) } // isWorkspaceAdmin lists the workspace's permission assignments on every call. @@ -71,6 +54,9 @@ func (u *unityCatalog) isWorkspaceAdmin( // holdsWorkspaceAdmin matches the connector's own principal against a workspace's // ADMIN assignments. A service principal is named by its applicationId. +// Admin inherited only through a group is ignored: the connector principal must +// hold ADMIN itself. Walking every admin group's membership on every Grants call +// is the cost that would close this gap. func (u *unityCatalog) holdsWorkspaceAdmin( ctx context.Context, assignments []databricks.WorkspaceAssignment, diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 90992e71..db54a8b6 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -11,6 +11,7 @@ import ( "github.com/conductorone/baton-databricks/pkg/databricks" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/types/grant" rs "github.com/conductorone/baton-sdk/pkg/types/resource" "google.golang.org/protobuf/types/known/structpb" ) @@ -37,6 +38,20 @@ func parseResourceId(resourceId string) (*v2.ResourceId, *v2.ResourceId, error) return nil, nil, fmt.Errorf("invalid resource ID: %s", resourceId) } +// groupSecurableGrantOptions annotates a group-held Unity Catalog grant so C1 +// expands it through the group's member entitlement. Shallow stays unset: +// setting it would suppress propagation through a nested group. +func groupSecurableGrantOptions(principalId *v2.ResourceId) []grant.GrantOption { + if principalId.GetResourceType() != groupResourceType.Id { + return nil + } + + return []grant.GrantOption{grant.WithAnnotation(&v2.GrantExpandable{ + EntitlementIds: []string{fmt.Sprintf("group:%s:%s", principalId.GetResource(), groupMemberEntitlement)}, + ResourceTypeIds: []string{userResourceType.Id, groupResourceType.Id, servicePrincipalResourceType.Id}, + })} +} + func groupGrantExpansion(ctx context.Context, groupId string, parentResource *v2.ResourceId) (*v2.ResourceId, *v2.GrantExpandable, error) { groupResourceStr := groupResourceId(ctx, groupId, parentResource) resourceId, err := rs.NewResourceID(groupResourceType, groupResourceStr) diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index 3d0f072b..c4724792 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -164,7 +164,7 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at } if !trustworthy { return nil, &rs.SyncOpResults{Annotations: annos}, status.Errorf(codes.PermissionDenied, - "databricks-connector: cannot read every grant on metastore %s: the connector principal needs to be a metastore admin", + "databricks-connector: cannot read every grant on metastore %s: the connector principal must own the metastore or be assigned Admin directly on a workspace attached to it", ref.metastoreID) } @@ -212,14 +212,7 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at continue } - var options []grant.GrantOption - if principalId.GetResourceType() == groupResourceType.Id { - // Shallow stays unset: setting it would suppress propagation through a nested group. - options = []grant.GrantOption{grant.WithAnnotation(&v2.GrantExpandable{ - EntitlementIds: []string{fmt.Sprintf("group:%s:%s", principalId.GetResource(), groupMemberEntitlement)}, - ResourceTypeIds: []string{userResourceType.Id, groupResourceType.Id, servicePrincipalResourceType.Id}, - })} - } + options := groupSecurableGrantOptions(principalId) for _, assigned := range assignment.Privileges { if assigned == "" { @@ -248,14 +241,7 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at zap.String("owner", owner), ) } else if principalId.GetResourceType() == userResourceType.Id || m.willSync(principalId.GetResourceType()) { - var options []grant.GrantOption - if principalId.GetResourceType() == groupResourceType.Id { - // Shallow stays unset: setting it would suppress propagation through a nested group. - options = []grant.GrantOption{grant.WithAnnotation(&v2.GrantExpandable{ - EntitlementIds: []string{fmt.Sprintf("group:%s:%s", principalId.GetResource(), groupMemberEntitlement)}, - ResourceTypeIds: []string{userResourceType.Id, groupResourceType.Id, servicePrincipalResourceType.Id}, - })} - } + options := groupSecurableGrantOptions(principalId) options = append(options, grant.WithAnnotation(&v2.GrantImmutable{})) rv = append(rv, grant.NewGrant(resource, ownerEntitlement, principalId, options...)) } @@ -331,7 +317,7 @@ func (m *metastoreBuilder) Grant(ctx context.Context, principal *v2.Resource, en } if !trustworthy { return annos, status.Errorf(codes.PermissionDenied, - "databricks-connector: cannot read every grant on metastore %s: the connector principal needs to be a metastore admin", + "databricks-connector: cannot read every grant on metastore %s: the connector principal must own the metastore or be assigned Admin directly on a workspace attached to it", ref.metastoreID) } @@ -411,7 +397,7 @@ func (m *metastoreBuilder) Grant(ctx context.Context, principal *v2.Resource, en codes.PermissionDenied, // MANAGE is not assignable on a metastore, so naming it here would send the // operator after a privilege Databricks rejects. The authority is ownership. - fmt.Sprintf("databricks-connector: cannot grant %s on %s %s: the connector principal needs to be a metastore admin (its owner) or an account admin", + fmt.Sprintf("databricks-connector: cannot grant %s on %s %s: the connector principal must own the metastore or be assigned Admin directly on a workspace attached to it", privilege, databricks.SecurableMetastore, ref.permissionsName()), err, ) @@ -502,7 +488,7 @@ func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annot } if !trustworthy { return annos, status.Errorf(codes.PermissionDenied, - "databricks-connector: cannot read every grant on metastore %s: the connector principal needs to be a metastore admin", + "databricks-connector: cannot read every grant on metastore %s: the connector principal must own the metastore or be assigned Admin directly on a workspace attached to it", ref.metastoreID) } @@ -578,10 +564,8 @@ func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annot var removal databricks.PermissionsChange if heldID := held.PrincipalID.String(); heldID != "" && heldID != "0" { removal = databricks.PermissionsChange{PrincipalID: held.PrincipalID, Remove: []string{privilege}} - } else if principalName != "" { - removal = databricks.PermissionsChange{Principal: principalName, Remove: []string{privilege}} } else { - removal = databricks.PermissionsChange{PrincipalID: held.PrincipalID, Remove: []string{privilege}} + removal = databricks.PermissionsChange{Principal: principalName, Remove: []string{privilege}} } result, rateLimit, err := m.client.UpdatePermissions(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), @@ -591,7 +575,7 @@ func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annot if isForbiddenError(err) { return annos, uhttp.WrapErrors( codes.PermissionDenied, - fmt.Sprintf("databricks-connector: cannot revoke %s on %s %s: the connector principal needs to be a metastore admin (its owner) or an account admin", + fmt.Sprintf("databricks-connector: cannot revoke %s on %s %s: the connector principal must own the metastore or be assigned Admin directly on a workspace attached to it", privilege, databricks.SecurableMetastore, ref.permissionsName()), err, ) From 01d9cacdbb46ef6ce6abc37d11617ee966dd1bd3 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Tue, 13 Oct 2026 17:01:47 -0300 Subject: [PATCH 10/36] fix(databricks): route unity catalog through metastore assignments, resolve principals per id, and share securable provisioning Co-authored-by: Cursor --- README.md | 28 +- docs/connector.mdx | 9 +- docs/docs-info.md | 80 +--- pkg/connector/connector.go | 21 +- pkg/connector/connector_test.go | 56 +++ pkg/connector/helpers.go | 13 +- pkg/connector/metastore.go | 307 +++------------ pkg/connector/principal_index.go | 236 +++++------ pkg/connector/principal_index_test.go | 31 +- pkg/connector/raw_id_test.go | 2 +- pkg/connector/securable.go | 57 --- pkg/connector/securable_helpers.go | 378 ++++++++++++++++++ pkg/connector/unity_catalog.go | 276 +++++-------- pkg/connector/unity_catalog_test.go | 546 ++++++++++++++++++++------ pkg/databricks/client.go | 12 +- pkg/databricks/client_test.go | 57 +++ pkg/databricks/models.go | 8 +- pkg/databricks/unity_catalog.go | 49 ++- 18 files changed, 1278 insertions(+), 888 deletions(-) delete mode 100644 pkg/connector/securable.go create mode 100644 pkg/connector/securable_helpers.go diff --git a/README.md b/README.md index 9b3c40e8..8fef81af 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,11 @@ service principal can access. To limit the scope, pass a comma-separated list of workspaces to the `--workspaces` flag. Each entry can be a workspace name, deployment name, or numeric workspace ID, matched case-insensitively. +`--workspaces` also scopes which metastores sync: only a metastore attached to a +listed workspace is synced, so narrowing the list removes the other metastores +and their grants from ConductorOne. A list that matches no workspace on the +account fails the metastore sync. + Metastores are Unity Catalog securables and are opt-in: the type stays off until it is enabled for the connector in ConductorOne. @@ -106,7 +111,9 @@ To instead exclude specific workspaces from the sync, pass them to the `--databricks-exclude-workspaces` flag (or the `BATON_DATABRICKS_EXCLUDE_WORKSPACES` environment variable) as a comma-separated list. Each entry can be a workspace name, deployment name, or numeric workspace -ID. Excluded workspaces and their roles are skipped entirely. +ID. Excluded workspaces and their roles are skipped entirely. A metastore +attached only to excluded workspaces is not synced, and its grants are removed +from ConductorOne. ## Group provisioning @@ -164,25 +171,6 @@ a group is not enough). When neither holds, the sync fails with a `PermissionDenied` error naming the metastore rather than reporting a partial read. Details are in [docs/docs-info.md](./docs/docs-info.md). -## Accounts with many workspaces - -Deciding which metastore a workspace serves needs a catalog listing from each -workspace that reports no metastore assignment, so the connector caches those -listings for the length of a run and keeps one per workspace. The working set -grows with the number of workspaces in the account. - -The default budget is 5 MB, which covers roughly ten workspaces holding a few -hundred catalogs each. Past that the cache evicts and the connector re-lists -catalogs it has already read, which lengthens the sync and uses more of the -Databricks API rate limit. Raising the budget removes the re-reads: - -``` -BATON_HTTP_CACHE_MAX_SIZE=32 baton-databricks -``` - -The value is in megabytes. It is a Baton SDK setting rather than a connector -flag, so it does not appear in `--help`. - # Contributing, Support and Issues We started Baton because we were tired of taking screenshots and manually diff --git a/docs/connector.mdx b/docs/connector.mdx index c74628ae..c035dc41 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -169,8 +169,8 @@ To complete this task, you'll need: Optionally, narrow what the connector syncs: - - **Workspaces**: limit the sync to these workspaces, by workspace name, deployment name, or numeric workspace ID. - - **Exclude Workspaces**: workspaces to leave out of the sync, by workspace name, deployment name, or numeric workspace ID. + - **Workspaces**: limit the sync to these workspaces, by workspace name, deployment name, or numeric workspace ID. This also scopes which metastores sync: only a metastore attached to one of these workspaces is synced, so narrowing the list removes the other metastores and their grants from C1. + - **Exclude Workspaces**: workspaces to leave out of the sync, by workspace name, deployment name, or numeric workspace ID. A metastore attached only to excluded workspaces is not synced either, and C1 removes it and its grants. Fill in **Workspaces** or **Exclude Workspaces**, not both. @@ -263,6 +263,7 @@ stringData: # Optional: limit the sync to specific workspaces # (workspace name, deployment name, or numeric ID). + # Also scopes which metastores sync: narrowing it removes metastores that no listed workspace is attached to. # Mutually exclusive with BATON_DATABRICKS_EXCLUDE_WORKSPACES — set one or the other, never both. # BATON_WORKSPACES: @@ -283,10 +284,6 @@ stringData: OAuth requires a reachable account API. If the account API check fails at startup, the connector fails validation instead of falling back to a workspace-only sync, even when `BATON_WORKSPACES` is set. - -**Accounts with many workspaces:** to decide which metastore each workspace serves, the connector may list a workspace's catalogs, and it holds one such listing per workspace while it runs — so a wider account needs a larger buffer. The 5 MB default suits about ten workspaces with a few hundred catalogs each. Above that the connector re-reads listings it has already fetched, which lengthens the sync and uses more of your Databricks API rate limit. Set `BATON_HTTP_CACHE_MAX_SIZE` to a larger number of megabytes in the Secret above. - - See the connector's README or run `--help` to see all available configuration flags and environment variables. #### Deployment configuration diff --git a/docs/docs-info.md b/docs/docs-info.md index fe2f43ec..acd91432 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -50,46 +50,6 @@ While developing the connector, please fill out this form. This information is n > Account admin access to the Databricks account console (to create the service principal and OAuth secret), and workspace admin on each workspace being synced. -## Unity Catalog API - -A metastore lives on the account plane, but its privileges are only readable -through a workspace host assigned to it. The connector enumerates metastores from -the account, resolves which workspaces are assigned to each one, and issues the -permissions calls against one of those workspace hosts. - -The catalog listing is read here for one purpose only: Databricks answers the -workspace metastore-assignment endpoint with 404 both for a workspace outside -Unity Catalog and for an assignment the credential may not read, and listing a -workspace's catalogs is what separates the two. The catalogs themselves are not -synced by this release. - -| Level | Endpoint | -|-------|----------| -| Metastores | `GET /api/2.0/accounts/{account_id}/metastores` | -| Workspace metastore assignment | `GET /api/2.0/accounts/{account_id}/workspaces/{workspace_id}/metastore` | -| Catalogs (access-path resolution only) | `GET /api/2.1/unity-catalog/catalogs` | -| Privileges | `GET` and `PATCH` `/api/2.1/unity-catalog/permissions/{securable_type}/{full_name}` | - -Behaviours that shape the implementation: - -- **The permissions endpoint is the only one that returns `principal_id`.** Its - sibling `effective-permissions` adds inherited privileges but drops that - field, which is the stable key the connector resolves principals by. -- **`max_results` must be `0` on the permissions endpoint.** Any value from 1 to - 149 is rejected with HTTP 400, because a page may not split one principal's - privilege list. -- **A page token must never be synthesized.** The permissions endpoint silently - ignores a token it cannot deserialize and restarts at page 1, so a synthesized - token produces an endless re-read rather than an error. Only an absent token - terminates a sequence: an empty page, and a page shorter than `max_results`, - can both still carry one. -- **A metastore is addressed by its UUID.** Its name is rejected with HTTP 400. -- **A cached response is keyed to the host it came from.** The SDK's HTTP cache - keys on the path, the query and a header set, with no host component, and every - workspace deployment answers the catalog listing at the same path and query. The - client adds a host header to the key so one workspace's listing is never served - for another, which would file a workspace under a metastore it cannot reach. - ## Permission model Three independent layers have to line up, and granting one does not imply the @@ -103,7 +63,7 @@ The scopes: | Scope | Covers | |-------|--------| -| `unity-catalog` | Metastores, workspace metastore assignments, the catalog listing used to resolve access paths, and the permissions endpoint | +| `unity-catalog` | Metastores, metastore workspace assignments, and the permissions endpoint | | `scim` | Account users, groups and service principals | | `access-management` | The rule-sets API, where roles live, plus the workspace permission assignments | | `provisioning` | The account workspaces listing. It is the only scope that grants it — including not the one named `workspace` | @@ -130,27 +90,6 @@ from an object that genuinely has no grants on it. Reporting that partial list would make ConductorOne read every privilege the principal cannot see as revoked access. -## Identity resolution - -A privilege assignment carries only `principal` and `principal_id`, with no type -discriminator. `principal` holds a value from one of three namespaces: a user's -`userName`, a group's `displayName`, or a service principal's `applicationId`. - -A SCIM filter built from that name cannot disambiguate it, because Databricks -answers an unmatched filter with HTTP 200 and zero rows rather than an error — a -miss is indistinguishable from a wrong-type lookup. The connector therefore -cross-references a per-sync index, keyed primarily on the numeric id. - -The index is built from **account** SCIM, not workspace SCIM: workspace SCIM -sees only a fraction of the account's service principals and groups, and any of -the ones it cannot see can hold a Unity Catalog grant, because grants live on the -metastore rather than on a workspace. - -A principal deleted in Databricks keeps its grants. Its assignment arrives with -an empty `principal` and only its numeric id, which is why the index resolves by -`principal_id` first. `principal_id` is accepted on removals only, so grant and -revoke build different request bodies. - ## Limitations - **Direct grants only.** Unity Catalog privileges inherit downward — @@ -169,13 +108,20 @@ revoke build different request bodies. - **`MANAGE` and `ALL_PRIVILEGES` do not exist at the metastore level** — Databricks rejects them there, so neither is offered as an entitlement. - **A metastore needs a usable workspace.** Its privileges are not served on the - account plane, so a metastore whose every attached workspace is excluded, not - `RUNNING`, or unreadable fails the sync rather than being silently omitted. + account plane, only through a `RUNNING` workspace attached to it. A metastore + with no workspace attached, or whose attached workspaces are all left out by + `workspaces` or `databricks-exclude-workspaces`, is not synced, and + ConductorOne removes it and its grants. Granting or revoking a privilege on + such a metastore fails with `FailedPrecondition` and changes nothing in + Databricks. An allowlist that matches no + workspace on the account fails the sync. While an attached in-scope workspace + is not `RUNNING`, or a metastore's workspace assignments cannot be read, + nothing is removed: that metastore fails the sync instead. +- **Admin on any attached workspace is enough.** The connector tries each + attached workspace in turn and reads through the first one where it holds + Admin directly. - **Other securables are not synced yet:** catalogs, schemas, tables, volumes, functions, registered models, external locations, storage credentials, connections, shares and Delta Sharing recipients. - **Tags are not synced**, including the `SYSTEM.TEAM` convention some customers use to drive just-in-time access. -- **Grants land after resources.** ConductorOne ingests grants a few minutes - behind resources, so a resource can briefly show zero grants right after a - sync. diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 3746ba04..a5cc4688 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -22,22 +22,27 @@ type Databricks struct { // ResourceSyncers returns a ResourceSyncerV2 for each resource type that should be synced from the upstream service. func (d *Databricks) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 { - willSync := func(string) bool { return true } - if d.opts != nil { - willSync = d.opts.WillSyncResourceType - } - - syncers := []connectorbuilder.ResourceSyncerV2{ + return []connectorbuilder.ResourceSyncerV2{ newAccountBuilder(d.client), newGroupBuilder(d.client), newServicePrincipalBuilder(d.client), newUserBuilder(d.client), newWorkspaceBuilder(d.client, d.workspaces), newRoleBuilder(d.client), - newMetastoreBuilder(d.client, d.unityCatalog, willSync), + newMetastoreBuilder(d.client, d.unityCatalog, syncPrincipals{ + groups: d.willSyncResourceType(groupResourceType.Id), + servicePrincipals: d.willSyncResourceType(servicePrincipalResourceType.Id), + }), } +} - return syncers +// willSyncResourceType is true when the type is in this sync. Nil opts (the +// capabilities prototype) means no filter, so every advertised type is in scope. +func (d *Databricks) willSyncResourceType(resourceTypeID string) bool { + if d.opts == nil { + return true + } + return d.opts.WillSyncResourceType(resourceTypeID) } // Asset takes an input AssetRef and attempts to fetch it using the connector's authenticated http client diff --git a/pkg/connector/connector_test.go b/pkg/connector/connector_test.go index a26b87ff..e64c4558 100644 --- a/pkg/connector/connector_test.go +++ b/pkg/connector/connector_test.go @@ -1,9 +1,12 @@ package connector import ( + "context" + "slices" "testing" "github.com/conductorone/baton-databricks/pkg/config" + "github.com/conductorone/baton-sdk/pkg/cli" ) // Regression for CXH-2165: with account-hostname unset the Azure/GCP hostname @@ -35,3 +38,56 @@ func TestAccountHostnameFieldHasNoDefault(t *testing.T) { t.Errorf("account-hostname field must have no default, got %q", v) } } + +func TestWillSyncResourceType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts *cli.ConnectorOpts + want map[string]bool + }{ + {"nil opts is the capabilities prototype", nil, map[string]bool{ + metastoreResourceType.Id: true, + groupResourceType.Id: true, + servicePrincipalResourceType.Id: true, + }}, + {"a filter leaving out group", &cli.ConnectorOpts{ + SyncResourceTypeIDs: []string{accountResourceType.Id, servicePrincipalResourceType.Id, metastoreResourceType.Id}, + }, map[string]bool{ + groupResourceType.Id: false, + servicePrincipalResourceType.Id: true, + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + d := &Databricks{opts: tc.opts} + for resourceTypeID, want := range tc.want { + if got := d.willSyncResourceType(resourceTypeID); got != want { + t.Errorf("willSyncResourceType(%q) = %v, want %v", resourceTypeID, got, want) + } + } + }) + } +} + +// The SDK derives capabilities and Grant/Revoke dispatch from the registered +// syncers, so leaving metastore out of a sync must not unregister it. +func TestResourceSyncersAlwaysRegisterTheMetastoreBuilder(t *testing.T) { + t.Parallel() + + d := &Databricks{opts: &cli.ConnectorOpts{ + SyncResourceTypeIDs: []string{accountResourceType.Id, groupResourceType.Id, userResourceType.Id}, + }} + + ctx := context.Background() + var registered []string + for _, syncer := range d.ResourceSyncers(ctx) { + registered = append(registered, syncer.ResourceType(ctx).GetId()) + } + if !slices.Contains(registered, metastoreResourceType.Id) { + t.Fatalf("registered syncers = %v, want them to include %s", registered, metastoreResourceType.Id) + } +} diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index db54a8b6..2b3c7f5f 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -254,17 +254,14 @@ func isForbiddenError(err error) bool { return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden } -func isUnauthorizedError(err error) bool { +func isRateLimitError(err error) bool { var apiErr *databricks.APIError - return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests } // isUnreadableWorkspaceError reports an answer that says this credential cannot -// read this workspace, which is a fact about that workspace and not about the -// account. Access-path discovery treats all three alike: the workspace carries -// nothing this sync can read. The distinction matters because only codes.NotFound -// is a warning to the SDK — every other code fails the whole sync, so letting one -// unreadable workspace escape here would take down the resource types that work. +// read this workspace, not a fact about the account. Only codes.NotFound is a +// warning to the SDK; any other code fails the whole sync. func isUnreadableWorkspaceError(err error) bool { var apiErr *databricks.APIError if !errors.As(err, &apiErr) { @@ -272,7 +269,7 @@ func isUnreadableWorkspaceError(err error) bool { } switch apiErr.StatusCode { - case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden: + case http.StatusUnauthorized, http.StatusForbidden: return true default: return false diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index c4724792..654fb66d 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -26,20 +26,29 @@ type metastoreBuilder struct { securableDeps } -func newMetastoreBuilder(client *databricks.Client, uc *unityCatalog, willSync func(string) bool) *metastoreBuilder { - return &metastoreBuilder{securableDeps{client: client, uc: uc, willSync: willSync}} +func newMetastoreBuilder(client *databricks.Client, uc *unityCatalog, sync syncPrincipals) *metastoreBuilder { + return &metastoreBuilder{securableDeps{client: client, uc: uc, sync: sync}} } func (m *metastoreBuilder) ResourceType(_ context.Context) *v2.ResourceType { return metastoreResourceType } -func metastoreResource(_ context.Context, metastore *databricks.Metastore, parent *v2.ResourceId) (*v2.Resource, error) { +var metastoreKind = securableKind{ + name: databricks.SecurableMetastore, + parse: parseMetastoreID, + privileges: metastorePrivileges, +} + +func metastoreResource(_ context.Context, metastore *databricks.Metastore, parent *v2.ResourceId, workspace, workspaceID, workspaceName string) (*v2.Resource, error) { profile := map[string]any{ profileKeyMetastoreID: metastore.MetastoreID, profileKeyRegion: metastore.Region, profileKeyOwner: metastore.Owner, profileKeySecurableType: securableTypeMetastore, + profileKeyWorkspace: workspace, + profileKeyWorkspaceID: workspaceID, + profileKeyWorkspaceName: workspaceName, } name := metastore.Name @@ -84,7 +93,11 @@ func (m *metastoreBuilder) List(ctx context.Context, parentResourceID *v2.Resour continue } - mr, err := metastoreResource(ctx, &metastore, parentResourceID) + workspace := "" + if routed := snap.workspaces[metastore.MetastoreID]; len(routed) > 0 { + workspace = routed[0] + } + mr, err := metastoreResource(ctx, &metastore, parentResourceID, workspace, snap.workspaceIDs[workspace], snap.workspaceNames[workspace]) if err != nil { return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( "databricks-connector: failed to build the resource for metastore %s: %w", metastore.MetastoreID, err) @@ -123,7 +136,7 @@ func (m *metastoreBuilder) Entitlements(context.Context, *v2.Resource, rs.SyncOp // the securables below it, so finding one here means the wrong resource arrived. func parseMetastoreID(resourceID string) (securableRef, error) { if resourceID == "" || strings.Contains(resourceID, securableKeySeparator) { - return securableRef{}, fmt.Errorf("databricks-connector: invalid metastore resource id %q", resourceID) + return securableRef{}, status.Errorf(codes.InvalidArgument, "databricks-connector: invalid metastore resource id %q", resourceID) } return securableRef{metastoreID: resourceID}, nil @@ -136,36 +149,30 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at } annos := annotations.Annotations{} - snap, rateLimit, err := m.uc.buildRouting(ctx) - noteRateLimit(&annos, rateLimit) - if err != nil { - return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( - "databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) - } - workspace, exists, rateLimit, err := workspaceFromSnapshot(snap, ref.metastoreID, rateLimit) + workspace, state, rateLimit, err := m.accessPath(ctx, resource, ref, false) noteRateLimit(&annos, rateLimit) if err != nil { + if status.Code(err) == codes.PermissionDenied { + return nil, &rs.SyncOpResults{Annotations: annos}, err + } + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( "databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) } - if !exists { + switch state { + case metastoreGone: ctxzap.Extract(ctx).Debug("databricks-connector: metastore is no longer listed", zap.String("metastore", ref.metastoreID), ) return nil, &rs.SyncOpResults{Annotations: annos}, nil - } + case metastoreOutOfScope: + ctxzap.Extract(ctx).Debug("databricks-connector: no workspace this sync can use is attached to the metastore; skipping its grants", + zap.String("metastore", ref.metastoreID), + ) - trustworthy, rateLimit, err := m.uc.metastoreGrantsAreTrustworthy(ctx, snap, ref.metastoreID, workspace) - noteRateLimit(&annos, rateLimit) - if err != nil { - return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( - "databricks-connector: failed to probe grant visibility on metastore %s: %w", ref.permissionsName(), err) - } - if !trustworthy { - return nil, &rs.SyncOpResults{Annotations: annos}, status.Errorf(codes.PermissionDenied, - "databricks-connector: cannot read every grant on metastore %s: the connector principal must own the metastore or be assigned Admin directly on a workspace attached to it", - ref.metastoreID) + return nil, &rs.SyncOpResults{Annotations: annos}, nil + case metastoreInScope: } l := ctxzap.Extract(ctx) @@ -173,9 +180,9 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at assignments, nextPageToken, rateLimit, err := m.client.ListPermissions( ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), "", pageToken) noteRateLimit(&annos, rateLimit) - // The permissions endpoint answers 404 for a securable that is gone, for a principal - // it does not know, and for a workspace that does not serve this metastore, and the - // three are separable only by message text. + // A 404 is returned, not read as an absence: the permissions endpoint also answers + // 404 for a workspace that does not serve this metastore, and only the error_code + // the APIError keeps tells that apart from a securable that is gone. if err != nil { return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( "databricks-connector: failed to list permissions on %s %s: %w", databricks.SecurableMetastore, ref.permissionsName(), err) @@ -208,7 +215,7 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at continue } - if principalId.GetResourceType() != userResourceType.Id && !m.willSync(principalId.GetResourceType()) { + if !emitPrincipal(m.sync, principalId.GetResourceType()) { continue } @@ -240,7 +247,7 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at zap.String("securable", ref.resourceKey()), zap.String("owner", owner), ) - } else if principalId.GetResourceType() == userResourceType.Id || m.willSync(principalId.GetResourceType()) { + } else if emitPrincipal(m.sync, principalId.GetResourceType()) { options := groupSecurableGrantOptions(principalId) options = append(options, grant.WithAnnotation(&v2.GrantImmutable{})) rv = append(rv, grant.NewGrant(resource, ownerEntitlement, principalId, options...)) @@ -252,121 +259,18 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at } func (m *metastoreBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { - annos := annotations.Annotations{} - principalID := principal.GetId() - - if !isValidPrincipal(principalID) { - return annos, status.Errorf(codes.InvalidArgument, - "databricks-connector: only users, groups and service principals can hold a Unity Catalog privilege, got %s", - principalID.GetResourceType()) - } - - ref, err := parseMetastoreID(entitlement.GetResource().GetId().GetResource()) - if err != nil { + change, annos, done, err := m.resolveSecurableChange(ctx, principal.GetId(), entitlement, metastoreKind, changeGrant) + if done { return annos, err } - entitlementID := entitlement.GetId() - separator := strings.LastIndex(entitlementID, ":") - if separator < 0 || separator == len(entitlementID)-1 { - return annos, fmt.Errorf("databricks-connector: failed to read the privilege from entitlement %q: %w", - entitlementID, fmt.Errorf("invalid entitlement id %q", entitlementID)) - } - privilege := entitlementID[separator+1:] - - if privilege == ownerEntitlement { - return annos, status.Errorf(codes.InvalidArgument, - "databricks-connector: ownership of metastore %s is single-valued and has no revoke, so it cannot be provisioned; change the owner in Databricks", - ref.permissionsName()) - } - - if isLegacyPrivilege(privilege) { - return annos, status.Errorf(codes.InvalidArgument, - "databricks-connector: %s is a Hive-era alias that Databricks rewrites server-side — CREATE on a schema becomes "+ - "CREATE_TABLE plus CREATE_FUNCTION — so it would grant more than was asked for and would never be read back "+ - "under that name; ask for the explicit privilege instead", - privilege) - } - - if !slices.Contains(metastorePrivileges, privilege) { - return annos, status.Errorf(codes.InvalidArgument, - "databricks-connector: %s is not a grantable privilege on metastore %s", - privilege, ref.permissionsName()) - } - - snap, rateLimit, err := m.uc.buildRouting(ctx) - noteRateLimit(&annos, rateLimit) - if err != nil { - return annos, fmt.Errorf("databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) - } - workspace, exists, rateLimit, err := workspaceFromSnapshot(snap, ref.metastoreID, rateLimit) - noteRateLimit(&annos, rateLimit) - if err != nil { - return annos, fmt.Errorf("databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) - } - if !exists { - return annos, status.Errorf(codes.NotFound, - "databricks-connector: metastore %s is no longer listed on the account, so it cannot be changed", ref.metastoreID) - } - - trustworthy, rateLimit, err := m.uc.metastoreGrantsAreTrustworthy(ctx, snap, ref.metastoreID, workspace) - noteRateLimit(&annos, rateLimit) - if err != nil { - return annos, fmt.Errorf("databricks-connector: failed to probe grant visibility on metastore %s: %w", - ref.permissionsName(), err) - } - if !trustworthy { - return annos, status.Errorf(codes.PermissionDenied, - "databricks-connector: cannot read every grant on metastore %s: the connector principal must own the metastore or be assigned Admin directly on a workspace attached to it", - ref.metastoreID) - } - - scimID, err := unityPrincipalScimID(principalID) - if err != nil { - return annos, fmt.Errorf("databricks-connector: failed to read the SCIM id of principal %s: %w", - principalID.GetResource(), err) - } - - principalName, _, err := unityPrincipalName(ctx, m.client, principalID) - if err != nil { - return annos, fmt.Errorf("databricks-connector: failed to resolve the Unity Catalog name of principal %s: %w", - principalID.GetResource(), err) - } - - if principalName == "" { - return annos, status.Errorf(codes.NotFound, - "databricks-connector: principal %s no longer exists in Databricks, so it cannot be granted %s on %s %s", - scimID, privilege, databricks.SecurableMetastore, ref.permissionsName()) - } - + ref := change.ref + privilege := change.privilege + workspace := change.workspace + scimID := change.scimID + principalName := change.principalName matchHeld := func(assignments []databricks.PrivilegeAssignment) (databricks.PrivilegeAssignment, bool) { - var nameOnly databricks.PrivilegeAssignment - var sawNameOnly bool - for _, assignment := range assignments { - if !slices.Contains(assignment.Privileges, privilege) { - continue - } - - assignmentID := assignment.PrincipalID.String() - if scimID != "" && assignmentID == scimID && assignmentID != "0" { - return assignment, true - } - if principalName == "" || assignment.Principal != principalName { - continue - } - if scimID != "" && assignmentID != "" && assignmentID != "0" && assignmentID != scimID { - continue - } - if !sawNameOnly { - nameOnly = assignment - sawNameOnly = true - } - } - if sawNameOnly { - return nameOnly, true - } - - return databricks.PrivilegeAssignment{}, false + return matchHeldPrivilege(assignments, scimID, principalName, privilege) } assignments, rateLimit, err := m.client.DrainPermissions(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName()) @@ -418,127 +322,22 @@ func (m *metastoreBuilder) Grant(ctx context.Context, principal *v2.Resource, en } func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annotations.Annotations, error) { - annos := annotations.Annotations{} - principalID := revoked.GetPrincipal().GetId() - entitlement := revoked.GetEntitlement() - - if !isValidPrincipal(principalID) { - return annos, status.Errorf(codes.InvalidArgument, - "databricks-connector: only users, groups and service principals can hold a Unity Catalog privilege, got %s", - principalID.GetResourceType()) - } - - ref, err := parseMetastoreID(entitlement.GetResource().GetId().GetResource()) - if err != nil { + change, annos, done, err := m.resolveSecurableChange(ctx, revoked.GetPrincipal().GetId(), revoked.GetEntitlement(), metastoreKind, changeRevoke) + if done { return annos, err } - entitlementID := entitlement.GetId() - separator := strings.LastIndex(entitlementID, ":") - if separator < 0 || separator == len(entitlementID)-1 { - return annos, fmt.Errorf("databricks-connector: failed to read the privilege from entitlement %q: %w", - entitlementID, fmt.Errorf("invalid entitlement id %q", entitlementID)) - } - privilege := entitlementID[separator+1:] - - if privilege == ownerEntitlement { - return annos, status.Errorf(codes.InvalidArgument, - "databricks-connector: ownership of metastore %s is single-valued and has no revoke, so it cannot be provisioned; change the owner in Databricks", - ref.permissionsName()) - } - - if isLegacyPrivilege(privilege) { - return annos, status.Errorf(codes.InvalidArgument, - "databricks-connector: %s is a Hive-era alias that Databricks rewrites server-side — CREATE on a schema becomes "+ - "CREATE_TABLE plus CREATE_FUNCTION — so it would grant more than was asked for and would never be read back "+ - "under that name; ask for the explicit privilege instead", - privilege) - } - - if !slices.Contains(metastorePrivileges, privilege) { - return annos, status.Errorf(codes.InvalidArgument, - "databricks-connector: %s is not a grantable privilege on metastore %s", - privilege, ref.permissionsName()) - } - - snap, rateLimit, err := m.uc.buildRouting(ctx) - noteRateLimit(&annos, rateLimit) - if err != nil { - return annos, fmt.Errorf("databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) - } - workspace, exists, rateLimit, err := workspaceFromSnapshot(snap, ref.metastoreID, rateLimit) - noteRateLimit(&annos, rateLimit) - if err != nil { - return annos, fmt.Errorf("databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) - } - if !exists { - // The metastore is gone from the account, so the privilege is gone with it and - // the end state the revoke asked for already holds. Reporting NotFound instead - // would fail the task on every retry against a securable that can never return. - annos.Update(&v2.GrantAlreadyRevoked{}) - - return annos, nil - } - - trustworthy, rateLimit, err := m.uc.metastoreGrantsAreTrustworthy(ctx, snap, ref.metastoreID, workspace) - noteRateLimit(&annos, rateLimit) - if err != nil { - return annos, fmt.Errorf("databricks-connector: failed to probe grant visibility on metastore %s: %w", - ref.permissionsName(), err) - } - if !trustworthy { - return annos, status.Errorf(codes.PermissionDenied, - "databricks-connector: cannot read every grant on metastore %s: the connector principal must own the metastore or be assigned Admin directly on a workspace attached to it", - ref.metastoreID) - } - - scimID, err := unityPrincipalScimID(principalID) - if err != nil { - return annos, fmt.Errorf("databricks-connector: failed to read the SCIM id of principal %s: %w", - principalID.GetResource(), err) - } - - // Grant rejects an empty name because it has to send one to add a privilege. - // Revoke does not: a principal deleted after the grant was synced keeps its - // numeric id in the assignment, and removing by id is what clears the row. - principalName, _, err := unityPrincipalName(ctx, m.client, principalID) - if err != nil { - return annos, fmt.Errorf("databricks-connector: failed to resolve the Unity Catalog name of principal %s: %w", - principalID.GetResource(), err) - } - + ref := change.ref + privilege := change.privilege + workspace := change.workspace + scimID := change.scimID + principalName := change.principalName matchHeld := func(assignments []databricks.PrivilegeAssignment) (databricks.PrivilegeAssignment, bool) { - var nameOnly databricks.PrivilegeAssignment - var sawNameOnly bool - for _, assignment := range assignments { - if !slices.Contains(assignment.Privileges, privilege) { - continue - } - - assignmentID := assignment.PrincipalID.String() - if scimID != "" && assignmentID == scimID && assignmentID != "0" { - return assignment, true - } - if principalName == "" || assignment.Principal != principalName { - continue - } - if scimID != "" && assignmentID != "" && assignmentID != "0" && assignmentID != scimID { - continue - } - if !sawNameOnly { - nameOnly = assignment - sawNameOnly = true - } - } - if sawNameOnly { - return nameOnly, true - } - - return databricks.PrivilegeAssignment{}, false + return matchHeldPrivilege(assignments, scimID, principalName, privilege) } var seen []databricks.PrivilegeAssignment - rateLimit, err = m.client.ForEachUncachedPermissionsPage(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), "", + rateLimit, err := m.client.ForEachUncachedPermissionsPage(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), "", func(page []databricks.PrivilegeAssignment) (bool, error) { seen = append(seen, page...) matched, ok := matchHeld(seen) diff --git a/pkg/connector/principal_index.go b/pkg/connector/principal_index.go index 4d401a72..5394303d 100644 --- a/pkg/connector/principal_index.go +++ b/pkg/connector/principal_index.go @@ -83,27 +83,26 @@ func (u *unityCatalog) ownsPrincipal(name string) bool { } // principalsForGrants builds the index Grants uses for assignments and owner. -// Privilege assignments carry a principal_id with no type discriminator, so they -// still require a full account SCIM walk. An owner-only page — the common -// table/schema case — is one to three targeted filters. A workspace-admins -// owner matches none of them and must not pay for listing every account principal. +// Each distinct principal is resolved on demand: Get* by principal_id, then +// Find* by name. func (u *unityCatalog) principalsForGrants( ctx context.Context, assignments []databricks.PrivilegeAssignment, owner string, ) (*principalIndex, *v2.RateLimitDescription, error) { - if len(assignments) > 0 { - return u.listPrincipals(ctx) - } - if owner == "" { - return newPrincipalIndex(), nil, nil + if len(assignments) == 0 { + if owner == "" { + return newPrincipalIndex(), nil, nil + } + + return u.indexOwner(ctx, owner) } - return u.indexOwner(ctx, owner) + return u.indexAssignments(ctx, assignments, owner) } // indexOwner asks each principal namespace for that one name. The first hit -// wins, in the same order listPrincipals writes the index (users, groups, +// wins, in the same order lookupPrincipalByID tries (users, groups, // service principals). func (u *unityCatalog) indexOwner(ctx context.Context, owner string) (*principalIndex, *v2.RateLimitDescription, error) { index := newPrincipalIndex() @@ -158,149 +157,117 @@ func (u *unityCatalog) indexOwner(ctx context.Context, owner string) (*principal return index, rateLimit, nil } -func (u *unityCatalog) listPrincipals(ctx context.Context) (*principalIndex, *v2.RateLimitDescription, error) { - accountResourceId, err := rs.NewResourceID(accountResourceType, u.client.GetAccountId()) - if err != nil { - return nil, nil, err - } - +func (u *unityCatalog) indexAssignments( + ctx context.Context, + assignments []databricks.PrivilegeAssignment, + owner string, +) (*principalIndex, *v2.RateLimitDescription, error) { index := newPrincipalIndex() - - rateLimit, err := u.indexUsers(ctx, index) - if err != nil { - return nil, rateLimit, err - } - groupRateLimit, err := u.indexGroups(ctx, index, accountResourceId) - if groupRateLimit != nil { - rateLimit = groupRateLimit - } - if err != nil { - return nil, rateLimit, err - } - servicePrincipalRateLimit, err := u.indexServicePrincipals(ctx, index) - if servicePrincipalRateLimit != nil { - rateLimit = servicePrincipalRateLimit - } - if err != nil { - return nil, rateLimit, err - } - - return index, rateLimit, nil -} - -func (u *unityCatalog) indexUsers(ctx context.Context, index *principalIndex) (*v2.RateLimitDescription, error) { var rateLimit *v2.RateLimitDescription + seenID := map[string]struct{}{} + seenName := map[string]struct{}{} + + resolve := func(assignment databricks.PrivilegeAssignment) error { + id := assignment.PrincipalID.String() + if id != "" && id != "0" { + if _, ok := seenID[id]; ok { + return nil + } + seenID[id] = struct{}{} + resourceId, pageRateLimit, err := u.lookupPrincipalByID(ctx, id) + if pageRateLimit != nil { + rateLimit = pageRateLimit + } + if err != nil { + return err + } + if resourceId != nil { + index.add(ctx, id, []string{assignment.Principal}, resourceId) + } - for page := uint(1); ; { - if err := ctx.Err(); err != nil { - return rateLimit, err - } - - users, total, pageRateLimit, err := u.client.ListUsers(ctx, "", databricks.NewPaginationVars(page, ResourcesPageSize), databricks.NewUserAttrVars()) - if pageRateLimit != nil { - rateLimit = pageRateLimit - } - if err != nil { - return rateLimit, fmt.Errorf("failed to list account users: %w", err) - } - if len(users) == 0 { - return rateLimit, nil - } - - for _, user := range users { - index.add(ctx, user.ID, []string{user.UserName}, &v2.ResourceId{ResourceType: userResourceType.Id, Resource: user.ID}) + return nil } - next, err := nextScimPage(page, len(users), total) - if err != nil { - return rateLimit, err - } - if next == 0 { - return rateLimit, nil + name := assignment.Principal + if name == "" { + return nil } - page = next - } -} - -func (u *unityCatalog) indexGroups(ctx context.Context, index *principalIndex, parent *v2.ResourceId) (*v2.RateLimitDescription, error) { - var rateLimit *v2.RateLimitDescription - - for page := uint(1); ; { - if err := ctx.Err(); err != nil { - return rateLimit, err + if _, ok := seenName[name]; ok { + return nil } - - groups, total, pageRateLimit, err := u.client.ListGroups(ctx, "", databricks.NewPaginationVars(page, ResourcesPageSize), databricks.NewGroupAttrVars()) + seenName[name] = struct{}{} + partial, pageRateLimit, err := u.indexOwner(ctx, name) if pageRateLimit != nil { rateLimit = pageRateLimit } if err != nil { - return rateLimit, fmt.Errorf("failed to list account groups: %w", err) + return err } - if len(groups) == 0 { - return rateLimit, nil + if resourceId, ok := partial.lookupName(name); ok { + index.add(ctx, "", []string{name}, resourceId) } - for _, group := range groups { - // Groups sync under the account with a composite resource id; the index must - // hold the same form or a grant points at a resource that was never synced. - resourceId, err := rs.NewResourceID(groupResourceType, groupResourceId(ctx, group.ID, parent)) - if err != nil { - return rateLimit, err - } - index.add(ctx, group.ID, []string{group.DisplayName}, resourceId) - } + return nil + } - next, err := nextScimPage(page, len(groups), total) - if err != nil { - return rateLimit, err + for _, assignment := range assignments { + if err := resolve(assignment); err != nil { + return nil, rateLimit, err } - if next == 0 { - return rateLimit, nil + } + if owner != "" { + if err := resolve(databricks.PrivilegeAssignment{Principal: owner}); err != nil { + return nil, rateLimit, err } - page = next } -} -func (u *unityCatalog) indexServicePrincipals(ctx context.Context, index *principalIndex) (*v2.RateLimitDescription, error) { - var rateLimit *v2.RateLimitDescription + return index, rateLimit, nil +} - for page := uint(1); ; { - if err := ctx.Err(); err != nil { - return rateLimit, err - } +func (u *unityCatalog) lookupPrincipalByID( + ctx context.Context, + id string, +) (*v2.ResourceId, *v2.RateLimitDescription, error) { + user, rateLimit, err := u.client.GetUser(ctx, "", id) + if err != nil && !isNotFoundError(err) { + return nil, rateLimit, fmt.Errorf("failed to resolve principal %s as a user: %w", id, err) + } + if err == nil && user != nil && user.ID != "" { + return &v2.ResourceId{ResourceType: userResourceType.Id, Resource: user.ID}, rateLimit, nil + } - servicePrincipals, total, pageRateLimit, err := u.client.ListServicePrincipals(ctx, "", databricks.NewPaginationVars(page, ResourcesPageSize), databricks.NewServicePrincipalAttrVars()) - if pageRateLimit != nil { - rateLimit = pageRateLimit - } + group, groupRateLimit, err := u.client.GetGroup(ctx, "", id) + if groupRateLimit != nil { + rateLimit = groupRateLimit + } + if err != nil && !isNotFoundError(err) { + return nil, rateLimit, fmt.Errorf("failed to resolve principal %s as a group: %w", id, err) + } + if err == nil && group != nil && group.ID != "" { + accountResourceId, err := rs.NewResourceID(accountResourceType, u.client.GetAccountId()) if err != nil { - return rateLimit, fmt.Errorf("failed to list account service principals: %w", err) + return nil, rateLimit, err } - if len(servicePrincipals) == 0 { - return rateLimit, nil + resourceId, err := rs.NewResourceID(groupResourceType, groupResourceId(ctx, group.ID, accountResourceId)) + if err != nil { + return nil, rateLimit, err } - for _, servicePrincipal := range servicePrincipals { - // A privilege assignment names a service principal by its applicationId, - // not its displayName and not its SCIM id. - index.add( - ctx, - servicePrincipal.ID, - []string{servicePrincipal.ApplicationID}, - &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: servicePrincipal.ID}, - ) - } + return resourceId, rateLimit, nil + } - next, err := nextScimPage(page, len(servicePrincipals), total) - if err != nil { - return rateLimit, err - } - if next == 0 { - return rateLimit, nil - } - page = next + servicePrincipal, servicePrincipalRateLimit, err := u.client.GetServicePrincipal(ctx, "", id) + if servicePrincipalRateLimit != nil { + rateLimit = servicePrincipalRateLimit + } + if err != nil && !isNotFoundError(err) { + return nil, rateLimit, fmt.Errorf("failed to resolve principal %s as a service principal: %w", id, err) + } + if err == nil && servicePrincipal != nil && servicePrincipal.ID != "" { + return &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: servicePrincipal.ID}, rateLimit, nil } + + return nil, rateLimit, nil } // ownPrincipalScimID resolves the connector's own service principal. The index @@ -321,18 +288,3 @@ func (u *unityCatalog) ownPrincipalScimID(ctx context.Context) (string, *v2.Rate return id, rateLimit, nil } - -// nextScimPage returns zero once the listing is exhausted. -func nextScimPage(page uint, pageTotal int, total uint) (uint, error) { - token := prepareNextToken(page, pageTotal, total) - if token == "" { - return 0, nil - } - - next, err := convertPageToken(token) - if err != nil { - return 0, err - } - - return next, nil -} diff --git a/pkg/connector/principal_index_test.go b/pkg/connector/principal_index_test.go index 9f79c60a..55b7fed1 100644 --- a/pkg/connector/principal_index_test.go +++ b/pkg/connector/principal_index_test.go @@ -141,20 +141,28 @@ func TestPrincipalsForGrantsWorkspaceAdminsOwnerDoesNotListAccount(t *testing.T) } } -func TestPrincipalsForGrantsAssignmentsStillListAccount(t *testing.T) { +func TestPrincipalsForGrantsAssignmentsResolvePerPrincipal(t *testing.T) { t.Setenv("BATON_HTTP_CACHE_TTL", "0") var unfiltered int + var gets int client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { if isUnfilteredAccountSCIM(r) { unfiltered++ + http.Error(w, "account listing is not allowed on the assignment path", http.StatusInternalServerError) + return } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"Resources": []any{}, "totalResults": 0}) + if strings.HasSuffix(r.URL.Path, "/scim/v2/Users/1") { + gets++ + _ = json.NewEncoder(w).Encode(map[string]any{"id": "1", "userName": "alice@example.com"}) + return + } + http.Error(w, `{"message":"missing"}`, http.StatusNotFound) }) uc := newUnityCatalog(client, "app-1", nil) - _, _, err := uc.principalsForGrants(context.Background(), []databricks.PrivilegeAssignment{{ + index, _, err := uc.principalsForGrants(context.Background(), []databricks.PrivilegeAssignment{{ Principal: "alice@example.com", PrincipalID: json.Number("1"), Privileges: []string{"USE_CATALOG"}, @@ -162,8 +170,15 @@ func TestPrincipalsForGrantsAssignmentsStillListAccount(t *testing.T) { if err != nil { t.Fatalf("principalsForGrants: %v", err) } - if unfiltered == 0 { - t.Fatal("privilege assignments still need the account SCIM walk") + if unfiltered != 0 { + t.Fatalf("unfiltered account SCIM listings = %d, want 0", unfiltered) + } + if gets == 0 { + t.Fatal("expected a GetUser by principal_id") + } + got, ok := index.lookup(databricks.PrivilegeAssignment{Principal: "alice@example.com", PrincipalID: json.Number("1")}) + if !ok || got.GetResource() != "1" { + t.Fatalf("assignment lookup = %v %v, want user 1", got, ok) } } @@ -194,7 +209,7 @@ func isUnfilteredAccountSCIM(r *http.Request) bool { return false } - return strings.Contains(r.URL.Path, "/scim/v2/Users") || - strings.Contains(r.URL.Path, "/scim/v2/Groups") || - strings.Contains(r.URL.Path, "/scim/v2/ServicePrincipals") + return strings.HasSuffix(r.URL.Path, "/scim/v2/Users") || + strings.HasSuffix(r.URL.Path, "/scim/v2/Groups") || + strings.HasSuffix(r.URL.Path, "/scim/v2/ServicePrincipals") } diff --git a/pkg/connector/raw_id_test.go b/pkg/connector/raw_id_test.go index b5fc85a7..2809a80b 100644 --- a/pkg/connector/raw_id_test.go +++ b/pkg/connector/raw_id_test.go @@ -67,7 +67,7 @@ func TestResourcesHaveRawId(t *testing.T) { }, { "metastore", - build(metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms1", Name: "ms"}, account)), + build(metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms1", Name: "ms"}, account, "", "", "")), "ms1", }, } diff --git a/pkg/connector/securable.go b/pkg/connector/securable.go deleted file mode 100644 index 29b5ef1e..00000000 --- a/pkg/connector/securable.go +++ /dev/null @@ -1,57 +0,0 @@ -package connector - -import ( - "strings" - - "github.com/conductorone/baton-databricks/pkg/databricks" -) - -const ( - securableTypeMetastore = "METASTORE" - - profileKeyMetastoreID = "metastore_id" - profileKeyOwner = "owner" - profileKeySecurableType = "securable_type" - profileKeyRegion = "region" - - // Not a dot: a securable's full name is already dot-separated. - securableKeySeparator = "::" -) - -// securableRef is a securable keyed by its metastore plus its name components, -// catalog first. Databricks rejects a name holding a space, a period or a forward -// slash, so splitting a full name on "." is unambiguous. -type securableRef struct { - metastoreID string - parts []string -} - -func (r securableRef) fullName() string { - return strings.Join(r.parts, ".") -} - -// permissionsName is the dotted full name, or the metastore's UUID for the metastore -// itself, whose name the endpoint rejects with 400 "Invalid UUID string". -func (r securableRef) permissionsName() string { - if len(r.parts) == 0 { - return r.metastoreID - } - - return r.fullName() -} - -// resourceKey is the resource id, which every level also carries as its RawId. -func (r securableRef) resourceKey() string { - if len(r.parts) == 0 { - return r.metastoreID - } - - return r.metastoreID + securableKeySeparator + r.fullName() -} - -// securableDeps is the client state every Unity Catalog builder shares. -type securableDeps struct { - client *databricks.Client - uc *unityCatalog - willSync func(string) bool -} diff --git a/pkg/connector/securable_helpers.go b/pkg/connector/securable_helpers.go new file mode 100644 index 00000000..21569e0f --- /dev/null +++ b/pkg/connector/securable_helpers.go @@ -0,0 +1,378 @@ +package connector + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + securableTypeMetastore = "METASTORE" + + profileKeyMetastoreID = "metastore_id" + profileKeyOwner = "owner" + profileKeySecurableType = "securable_type" + profileKeyRegion = "region" + profileKeyWorkspace = "workspace" + profileKeyWorkspaceID = "workspace_id" + profileKeyWorkspaceName = "workspace_name" + + // Not a dot: a securable's full name is already dot-separated. + securableKeySeparator = "::" +) + +// syncPrincipals says which principal types this sync includes. +type syncPrincipals struct { + groups bool + servicePrincipals bool +} + +func (s syncPrincipals) enabled(resourceType string) bool { + switch resourceType { + case groupResourceType.Id: + return s.groups + case servicePrincipalResourceType.Id: + return s.servicePrincipals + default: + return false + } +} + +func emitPrincipal(sync syncPrincipals, resourceType string) bool { + return resourceType == userResourceType.Id || sync.enabled(resourceType) +} + +// securableRef is a securable keyed by its metastore plus its name components, +// catalog first. Databricks rejects a name holding a space, a period or a forward +// slash, so splitting a full name on "." is unambiguous. +type securableRef struct { + metastoreID string + parts []string +} + +func (r securableRef) fullName() string { + return strings.Join(r.parts, ".") +} + +// permissionsName is the dotted full name, or the metastore's UUID for the metastore +// itself, whose name the endpoint rejects with 400 "Invalid UUID string". +func (r securableRef) permissionsName() string { + if len(r.parts) == 0 { + return r.metastoreID + } + + return r.fullName() +} + +// resourceKey is the resource id, which every level also carries as its RawId. +func (r securableRef) resourceKey() string { + if len(r.parts) == 0 { + return r.metastoreID + } + + return r.metastoreID + securableKeySeparator + r.fullName() +} + +// securableDeps is the client state every Unity Catalog builder shares. +type securableDeps struct { + client *databricks.Client + uc *unityCatalog + sync syncPrincipals +} + +// securableKind is what Grant/Revoke need to know about one securable level. +type securableKind struct { + // name is the permissions endpoint path segment, also used in messages. + name string + parse func(string) (securableRef, error) + privileges []string +} + +type changeMode int + +const ( + changeGrant changeMode = iota + changeRevoke +) + +func parseEntitlementPrivilege(entitlementID string) (string, error) { + separator := strings.LastIndex(entitlementID, ":") + if separator < 0 || separator == len(entitlementID)-1 { + return "", status.Errorf(codes.InvalidArgument, + "databricks-connector: failed to read the privilege from entitlement %q: invalid entitlement id", entitlementID) + } + + return entitlementID[separator+1:], nil +} + +// matchHeldPrivilege finds the assignment that carries privilege for this +// principal. Id wins; name is used only when the assignment has no id. +func matchHeldPrivilege( + assignments []databricks.PrivilegeAssignment, + scimID, principalName, privilege string, +) (databricks.PrivilegeAssignment, bool) { + var nameOnly databricks.PrivilegeAssignment + var sawNameOnly bool + for _, assignment := range assignments { + if !slices.Contains(assignment.Privileges, privilege) { + continue + } + + assignmentID := assignment.PrincipalID.String() + if scimID != "" && assignmentID == scimID && assignmentID != "0" { + return assignment, true + } + if principalName == "" || assignment.Principal != principalName { + continue + } + if scimID != "" && assignmentID != "" && assignmentID != "0" && assignmentID != scimID { + continue + } + if !sawNameOnly { + nameOnly = assignment + sawNameOnly = true + } + } + if sawNameOnly { + return nameOnly, true + } + + return databricks.PrivilegeAssignment{}, false +} + +type securableChange struct { + ref securableRef + privilege string + workspace string + scimID string + principalName string +} + +// resolveSecurableChange is the Grant/Revoke prelude: principal, privilege, +// routing, visibility probe, and the Unity Catalog principal name. +func (d *securableDeps) resolveSecurableChange( + ctx context.Context, + principalID *v2.ResourceId, + entitlement *v2.Entitlement, + kind securableKind, + mode changeMode, +) (securableChange, annotations.Annotations, bool, error) { + annos := annotations.Annotations{} + if !isValidPrincipal(principalID) { + return securableChange{}, annos, true, status.Errorf(codes.InvalidArgument, + "databricks-connector: only users, groups and service principals can hold a Unity Catalog privilege, got %s", + principalID.GetResourceType()) + } + + ref, err := kind.parse(entitlement.GetResource().GetId().GetResource()) + if err != nil { + return securableChange{}, annos, true, err + } + + privilege, err := parseEntitlementPrivilege(entitlement.GetId()) + if err != nil { + return securableChange{}, annos, true, err + } + + if privilege == ownerEntitlement { + return securableChange{}, annos, true, status.Errorf(codes.InvalidArgument, + "databricks-connector: ownership of %s %s is single-valued and has no revoke, so it cannot be provisioned; change the owner in Databricks", + kind.name, ref.permissionsName()) + } + + if isLegacyPrivilege(privilege) { + return securableChange{}, annos, true, status.Errorf(codes.InvalidArgument, + "databricks-connector: %s is a Hive-era alias that Databricks rewrites server-side — CREATE on a schema becomes "+ + "CREATE_TABLE plus CREATE_FUNCTION — so it would grant more than was asked for and would never be read back "+ + "under that name; ask for the explicit privilege instead", + privilege) + } + + if !slices.Contains(kind.privileges, privilege) { + return securableChange{}, annos, true, status.Errorf(codes.InvalidArgument, + "databricks-connector: %s is not a grantable privilege on %s %s", + privilege, kind.name, ref.permissionsName()) + } + + // The profile was written by the last sync, and the metastore may have been + // deleted since: a write must confirm it is still listed before addressing it. + workspace, state, rateLimit, err := d.accessPath(ctx, entitlement.GetResource(), ref, true) + noteRateLimit(&annos, rateLimit) + if err != nil { + if status.Code(err) == codes.PermissionDenied { + return securableChange{}, annos, true, err + } + + return securableChange{}, annos, true, fmt.Errorf("databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) + } + switch state { + case metastoreGone: + if mode == changeRevoke { + annos.Update(&v2.GrantAlreadyRevoked{}) + return securableChange{}, annos, true, nil + } + + return securableChange{}, annos, true, status.Errorf(codes.NotFound, + "databricks-connector: %s %s no longer exists in Databricks, so it cannot be changed", kind.name, ref.permissionsName()) + case metastoreOutOfScope: + // The metastore still holds its privileges, so a revoke cannot be reported done. + return securableChange{}, annos, true, status.Errorf(codes.FailedPrecondition, + "databricks-connector: %s %s cannot be changed: no workspace this sync can use is attached to it — every attached one is "+ + "left out of workspaces or named in databricks-exclude-workspaces, or none is assigned — so it is outside the sync scope", + kind.name, ref.permissionsName()) + case metastoreInScope: + } + + scimID, err := unityPrincipalScimID(principalID) + if err != nil { + return securableChange{}, annos, true, fmt.Errorf("databricks-connector: failed to read the SCIM id of principal %s: %w", + principalID.GetResource(), err) + } + + principalName, _, err := unityPrincipalName(ctx, d.client, principalID) + if err != nil { + return securableChange{}, annos, true, fmt.Errorf("databricks-connector: failed to resolve the Unity Catalog name of principal %s: %w", + principalID.GetResource(), err) + } + // Revoke does not need the name: a deleted principal keeps its numeric id in + // the assignment, and removing by id is what clears the row. + if mode == changeGrant && principalName == "" { + return securableChange{}, annos, true, status.Errorf(codes.NotFound, + "databricks-connector: principal %s no longer exists in Databricks, so it cannot be granted %s on %s %s", + scimID, privilege, kind.name, ref.permissionsName()) + } + + return securableChange{ + ref: ref, + privilege: privilege, + workspace: workspace, + scimID: scimID, + principalName: principalName, + }, annos, false, nil +} + +// accessPath names the workspace a metastore's permissions are read through. +// Unless confirm is set, the workspace the last sync wrote on the profile is +// tried first, which skips listing every metastore's workspaces per Grants call. +func (d *securableDeps) accessPath( + ctx context.Context, + resource *v2.Resource, + ref securableRef, + confirm bool, +) (string, metastoreState, *v2.RateLimitDescription, error) { + if !confirm { + workspace, ok, rateLimit, err := d.profileWorkspace(ctx, resource, ref) + if err != nil { + return "", metastoreInScope, rateLimit, err + } + if ok { + return workspace, metastoreInScope, rateLimit, nil + } + } + + snap, rateLimit, err := d.uc.buildRouting(ctx) + if err != nil { + return "", metastoreInScope, rateLimit, err + } + workspaces, state, err := usableWorkspaces(snap, ref.metastoreID) + if err != nil || state != metastoreInScope { + return "", state, rateLimit, err + } + + // Admin on any one attached workspace is enough, and ListWorkspaces order says + // nothing about which one that is, so every usable workspace is tried. + for _, workspace := range workspaces { + trustworthy, probeRateLimit, err := d.uc.metastoreGrantsAreTrustworthy(ctx, snap, ref.metastoreID, workspace) + if probeRateLimit != nil { + rateLimit = probeRateLimit + } + if err != nil { + if ctx.Err() != nil || !isUnreadableWorkspaceError(err) { + return "", metastoreInScope, rateLimit, err + } + ctxzap.Extract(ctx).Debug("databricks-connector: could not read the permission assignments of an attached workspace; trying the next one", + zap.String("metastore", ref.metastoreID), + zap.String("workspace", workspace), + zap.Error(err), + ) + + continue + } + if trustworthy { + return workspace, metastoreInScope, rateLimit, nil + } + } + + return "", metastoreInScope, rateLimit, status.Errorf(codes.PermissionDenied, + "databricks-connector: cannot read every grant on metastore %s: the connector principal must own the metastore or be assigned Admin directly on a workspace attached to it", + ref.metastoreID) +} + +// profileWorkspace reports false whenever the profile workspace cannot be used +// as is, so the caller rebuilds the routing instead. Only a rate limit or a +// cancelled context is returned: retrying elsewhere would not help either. +func (d *securableDeps) profileWorkspace( + ctx context.Context, + resource *v2.Resource, + ref securableRef, +) (string, bool, *v2.RateLimitDescription, error) { + workspace, workspaceID, workspaceName, owner := profileAccessPath(resource) + if workspace == "" || workspaceID == "" { + return "", false, nil, nil + } + if len(d.uc.workspaces) > 0 { + if _, ok := matchConfiguredWorkspace(d.uc.workspaces, workspace, workspaceName, workspaceID); !ok { + return "", false, nil, nil + } + } + + snap := profileRoutingSnapshot(ref.metastoreID, workspace, workspaceID, owner) + trustworthy, rateLimit, err := d.uc.metastoreGrantsAreTrustworthy(ctx, snap, ref.metastoreID, workspace) + if err != nil { + if ctx.Err() != nil || isRateLimitError(err) { + return "", false, rateLimit, err + } + ctxzap.Extract(ctx).Debug("databricks-connector: the workspace on the metastore profile did not answer; rebuilding the routing", + zap.String("metastore", ref.metastoreID), + zap.String("workspace", workspace), + zap.Error(err), + ) + + return "", false, rateLimit, nil + } + + return workspace, trustworthy, rateLimit, nil +} + +func profileAccessPath(resource *v2.Resource) (string, string, string, string) { + if resource == nil { + return "", "", "", "" + } + profile := rs.GetProfile(resource) + workspace, _ := rs.GetProfileStringValue(profile, profileKeyWorkspace) + workspaceID, _ := rs.GetProfileStringValue(profile, profileKeyWorkspaceID) + workspaceName, _ := rs.GetProfileStringValue(profile, profileKeyWorkspaceName) + owner, _ := rs.GetProfileStringValue(profile, profileKeyOwner) + + return workspace, workspaceID, workspaceName, owner +} + +func profileRoutingSnapshot(metastoreID, workspace, workspaceID, owner string) routingSnapshot { + return routingSnapshot{ + metastores: map[string]databricks.Metastore{ + metastoreID: {MetastoreID: metastoreID, Owner: owner}, + }, + workspaces: map[string][]string{metastoreID: {workspace}}, + workspaceIDs: map[string]string{workspace: workspaceID}, + } +} diff --git a/pkg/connector/unity_catalog.go b/pkg/connector/unity_catalog.go index 0579a394..23ed0ac4 100644 --- a/pkg/connector/unity_catalog.go +++ b/pkg/connector/unity_catalog.go @@ -22,19 +22,38 @@ const ( // routingSnapshot is the account's Unity Catalog access paths for the call that // just listed them. Nothing on the connector keeps it. type routingSnapshot struct { - metastores map[string]databricks.Metastore - workspaces map[string][]string - workspaceIDs map[string]string - unusable []string - asked int - - // unreadable names the in-scope workspaces that answered neither their - // assignment nor a catalog listing, so what they serve is unknown rather than - // known to be nothing. It is separate from unusable, where a successful but - // empty catalog listing settles that the workspace carries nothing. + metastores map[string]databricks.Metastore + workspaces map[string][]string + workspaceIDs map[string]string + workspaceNames map[string]string + asked int + + // unreadable names the metastores whose workspace assignment list answered + // 401 or 403, so which workspaces serve them is unknown rather than none. unreadable []string + + // pending maps a metastore to its in-scope assigned workspaces that are not + // RUNNING. Whether they will serve it is unknown until they are, so they keep + // that metastore from being pruned. + pending map[string][]string + + // pruned names the listed metastores left out because no workspace this sync + // can use is attached to them. They still exist in Databricks. + pruned map[string]struct{} } +// metastoreState is where a metastore stands in a routing snapshot. +type metastoreState int + +const ( + // metastoreGone is a metastore the account no longer lists. + metastoreGone metastoreState = iota + // metastoreInScope is listed and not pruned. + metastoreInScope + // metastoreOutOfScope is listed but pruned: no workspace this sync can use is attached. + metastoreOutOfScope +) + // unityCatalog holds no state beyond the client and the workspace filter: every // call asks Databricks and drops the answer when it returns. type unityCatalog struct { @@ -59,42 +78,29 @@ func newUnityCatalog(client *databricks.Client, ownPrincipal string, workspaces } } -func workspaceFromSnapshot(snap routingSnapshot, metastoreID string, rateLimit *v2.RateLimitDescription) (string, bool, *v2.RateLimitDescription, error) { - workspaces, exists, err := usableWorkspaces(snap, metastoreID) - if err != nil || !exists { - return "", exists, rateLimit, err - } - - return workspaces[0], true, rateLimit, nil -} - -func usableWorkspaces(snap routingSnapshot, metastoreID string) ([]string, bool, error) { +// usableWorkspaces returns workspaces only for an in-scope metastore. One that is +// in scope but has no usable workspace yet fails with FailedPrecondition. +func usableWorkspaces(snap routingSnapshot, metastoreID string) ([]string, metastoreState, error) { if workspaces := snap.workspaces[metastoreID]; len(workspaces) > 0 { - return workspaces, true, nil + return workspaces, metastoreInScope, nil + } + if _, ok := snap.pruned[metastoreID]; ok { + return nil, metastoreOutOfScope, nil } if _, ok := snap.metastores[metastoreID]; !ok { - return nil, false, nil + return nil, metastoreGone, nil } - unusable := "" - if len(snap.unusable) > 0 { - unusable = fmt.Sprintf("; %d running workspace(s) reported neither an assignment nor a catalog: %s", len(snap.unusable), strings.Join(snap.unusable, ", ")) + pending := "" + if waiting := snap.pending[metastoreID]; len(waiting) > 0 { + pending = fmt.Sprintf("; %d attached workspace(s) are not RUNNING: %s", len(waiting), strings.Join(waiting, ", ")) } - return nil, true, status.Errorf(codes.FailedPrecondition, + return nil, metastoreInScope, status.Errorf(codes.FailedPrecondition, "metastore %s exists on the account but no workspace this sync can use is attached to it, so its securables cannot be read: "+ "every attached workspace is either left out of workspaces, named in databricks-exclude-workspaces, not RUNNING, "+ "or reports no metastore assignment%s", - metastoreID, unusable) -} - -// deferredWorkspace is a workspace whose metastore assignment could not be read, -// held over for the catalog listing. denied separates a credential that was -// refused the assignment from one that was told there is none, because the two -// give the listing different weight. -type deferredWorkspace struct { - name string - denied bool + metastoreID, pending) } // buildRouting lists metastores and the workspaces each one is reachable through. @@ -105,9 +111,12 @@ func (u *unityCatalog) buildRouting(ctx context.Context) (routingSnapshot, *v2.R } snap := routingSnapshot{ - metastores: metastores, - workspaces: make(map[string][]string), - workspaceIDs: make(map[string]string), + metastores: metastores, + workspaces: make(map[string][]string), + workspaceIDs: make(map[string]string), + workspaceNames: make(map[string]string), + pending: make(map[string][]string), + pruned: make(map[string]struct{}), } workspaceRateLimit, err := u.fillWorkspaces(ctx, &snap) @@ -118,23 +127,21 @@ func (u *unityCatalog) buildRouting(ctx context.Context) (routingSnapshot, *v2.R return routingSnapshot{}, rateLimit, err } - // With an allowlist, a metastore no in-scope workspace is attached to is out of - // scope, and out of scope is an absence here: the metastore is not listed, not - // listed-but-unreachable. Keeping it would make every securable under it fail - // with FailedPrecondition, so scoping a sync to one workspace would break the - // sync for every metastore the other workspaces hold. - // - // Only when every in-scope workspace answered, though. An unrouted metastore - // means "out of scope" only if the workspaces left out are the reason; if an - // in-scope workspace could not be read, the same metastore may well be one it - // serves, and dropping it here would report it deleted and take its grants - // with it. Unknown is not absence, so that case keeps the loud failure. - if len(u.workspaces) > 0 && len(snap.unreadable) == 0 { - for metastoreID := range snap.metastores { - if len(snap.workspaces[metastoreID]) == 0 { - delete(snap.metastores, metastoreID) - } + // A metastore with no usable workspace would fail its Grants with + // FailedPrecondition, which fails the whole sync, so it is pruned. An unknown + // answer (unreadable list, non-RUNNING workspace) blocks the prune: dropping it + // would read as a deletion in C1. Pruned is recorded apart from deleted + // because the metastore still holds its privileges. + l := ctxzap.Extract(ctx) + for metastoreID := range snap.metastores { + if len(snap.workspaces[metastoreID]) > 0 || len(snap.pending[metastoreID]) > 0 || slices.Contains(snap.unreadable, metastoreID) { + continue } + l.Debug("databricks-connector: no workspace this sync can use is attached to the metastore; leaving it out of the sync", + zap.String("metastore", metastoreID), + ) + delete(snap.metastores, metastoreID) + snap.pruned[metastoreID] = struct{}{} } return snap, rateLimit, nil @@ -158,161 +165,94 @@ func (u *unityCatalog) listMetastores(ctx context.Context) (map[string]databrick } // fillWorkspaces maps metastores to workspaces and deployment names to numeric ids. -// The securable endpoints take the deployment name; the assignment endpoint does not. +// The reverse assignment list is one call per metastore (usually one per region), +// so a workspace missing from every metastore's list is simply not on Unity Catalog. func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot) (*v2.RateLimitDescription, error) { - l := ctxzap.Extract(ctx) - workspaces, rateLimit, err := u.client.ListWorkspaces(ctx) if err != nil { return rateLimit, fmt.Errorf("failed to list workspaces: %w", err) } - var unassigned []deferredWorkspace + inScope := map[string]databricks.Workspace{} + notRunning := map[string]string{} asked := 0 for _, workspace := range workspaces { if err := ctx.Err(); err != nil { return rateLimit, err } - if workspace.DeploymentName == "" { continue } - if workspace.Status != "" && !strings.EqualFold(workspace.Status, workspaceStatusRunning) { - continue - } + accountID := strconv.Itoa(workspace.ID) if len(u.workspaces) > 0 { - if _, ok := matchConfiguredWorkspace(u.workspaces, workspace.DeploymentName, workspace.Name, strconv.Itoa(workspace.ID)); !ok { + if _, ok := matchConfiguredWorkspace(u.workspaces, workspace.DeploymentName, workspace.Name, accountID); !ok { continue } } + if workspace.Status != "" && !strings.EqualFold(workspace.Status, workspaceStatusRunning) { + notRunning[accountID] = workspace.DeploymentName + continue + } asked++ - accountID := strconv.Itoa(workspace.ID) + inScope[accountID] = workspace snap.workspaceIDs[workspace.DeploymentName] = accountID + snap.workspaceNames[workspace.DeploymentName] = workspace.Name + } - metastoreID, assignmentRateLimit, err := u.client.GetWorkspaceMetastore(ctx, accountID) - if assignmentRateLimit != nil { - rateLimit = assignmentRateLimit - } - if err != nil { - if isUnreadableWorkspaceError(err) { - // Databricks answers 404 both for a workspace outside Unity Catalog and - // for an assignment the credential may not read, and 401/403 when the - // credential is not a member of the workspace at all. None of the three - // says anything about the other workspaces, so the workspace is set - // aside rather than failing the snapshot that every securable needs. - // Which of the three it was decides how much the catalog listing can - // then settle, so it is carried along. - unassigned = append(unassigned, deferredWorkspace{ - name: workspace.DeploymentName, - denied: isForbiddenError(err) || isUnauthorizedError(err), - }) - - continue - } - - return rateLimit, fmt.Errorf("failed to resolve metastore for workspace %s: %w", workspace.DeploymentName, err) - } - if metastoreID == "" { - continue - } + if len(u.workspaces) > 0 && asked == 0 && len(notRunning) == 0 && len(snap.metastores) > 0 { + configured := configuredWorkspaceNames(u.workspaces) + slices.Sort(configured) - snap.workspaces[metastoreID] = append(snap.workspaces[metastoreID], workspace.DeploymentName) + return rateLimit, status.Errorf(codes.FailedPrecondition, + "none of the workspaces in the workspaces allowlist (%s) matches a workspace on the account by deployment name, name or id, "+ + "so no metastore can be scoped to it", + strings.Join(configured, ", ")) } - // A catalog payload names its metastore, so the listing settles which metastore - // this workspace serves, if any. - var unusable, unreadable []string - for _, deferred := range unassigned { + var unreadable []string + for metastoreID := range snap.metastores { if err := ctx.Err(); err != nil { return rateLimit, err } - workspace := deferred.name - metastoreIDs, listRateLimit, err := u.metastoresServedBy(ctx, workspace) - if listRateLimit != nil { - rateLimit = listRateLimit + ids, assignmentRateLimit, err := u.client.ListMetastoreWorkspaces(ctx, metastoreID) + if assignmentRateLimit != nil { + rateLimit = assignmentRateLimit } if err != nil { - if isUnreadableWorkspaceError(err) { - // Neither endpoint answered, so this workspace's metastores are unknown. - unusable = append(unusable, workspace) - unreadable = append(unreadable, workspace) - + // The metastore was listed a moment ago, so a 404 here means it has + // since been deleted. + if isNotFoundError(err) { + ctxzap.Extract(ctx).Debug("databricks-connector: metastore disappeared while its workspaces were being listed", + zap.String("metastore", metastoreID), + ) + delete(snap.metastores, metastoreID) continue } - - return rateLimit, fmt.Errorf("failed to list catalogs through workspace %s: %w", workspace, err) - } - if len(metastoreIDs) == 0 { - unusable = append(unusable, workspace) - - // An empty catalog listing is not the same evidence in both cases. The - // endpoint returns only the catalogs the caller may use, so a credential - // that was also denied the assignment read can be looking at an empty list - // purely for want of USE_CATALOG. Only a 404 on the assignment plus an - // empty listing reads as a workspace outside Unity Catalog; a denial plus - // an empty listing settles nothing. - if deferred.denied { - unreadable = append(unreadable, workspace) + if isUnreadableWorkspaceError(err) { + unreadable = append(unreadable, metastoreID) + continue } - continue + return rateLimit, fmt.Errorf("failed to list workspaces for metastore %s: %w", metastoreID, err) } - for _, metastoreID := range metastoreIDs { - snap.workspaces[metastoreID] = append(snap.workspaces[metastoreID], workspace) + for _, id := range ids { + if deployment, ok := notRunning[id]; ok { + snap.pending[metastoreID] = append(snap.pending[metastoreID], deployment) + continue + } + workspace, ok := inScope[id] + if !ok { + continue + } + snap.workspaces[metastoreID] = append(snap.workspaces[metastoreID], workspace.DeploymentName) } } - // Every workspace carrying no securable is indistinguishable from an account with - // no Unity Catalog; the metastore list is the authority on which it is. - if asked > 0 && len(snap.workspaces) == 0 && len(snap.metastores) > 0 { - return rateLimit, status.Errorf(codes.FailedPrecondition, - "the account has %d Unity Catalog metastore(s) but none of the %d running workspace(s) reported a metastore assignment or could "+ - "list a catalog, so no securable can be listed; the connector principal needs permission to read workspace metastore "+ - "assignments on the account", - len(snap.metastores), asked) - } - - if len(unusable) > 0 { - l.Debug("databricks-connector: a running workspace reported no metastore assignment and listed no catalog, so it carries nothing this sync can read", - zap.Strings("workspaces", unusable), - zap.Int("running_workspaces", asked), - zap.Int("metastores_with_an_access_path", len(snap.workspaces)), - ) - } - - snap.unusable = unusable snap.unreadable = unreadable snap.asked = asked return rateLimit, nil } - -// metastoresServedBy names the metastores whose catalogs a workspace can list, which -// separates the two meanings of a 404 from the assignment endpoint. The catalog filter -// is deliberately not applied: an out-of-scope catalog is still an access path. -func (u *unityCatalog) metastoresServedBy(ctx context.Context, workspace string) ([]string, *v2.RateLimitDescription, error) { - served := make(map[string]struct{}) - rateLimit, err := u.client.ForEachCatalogPage(ctx, workspace, ResourcesPageSize, func(page []databricks.Catalog) (bool, error) { - for _, catalog := range page { - if catalog.MetastoreID == "" { - continue - } - served[catalog.MetastoreID] = struct{}{} - } - return false, nil - }) - if err != nil { - return nil, rateLimit, err - } - - metastoreIDs := make([]string, 0, len(served)) - for metastoreID := range served { - metastoreIDs = append(metastoreIDs, metastoreID) - } - slices.Sort(metastoreIDs) - - return metastoreIDs, rateLimit, nil -} diff --git a/pkg/connector/unity_catalog_test.go b/pkg/connector/unity_catalog_test.go index 05c3e70b..08a278b2 100644 --- a/pkg/connector/unity_catalog_test.go +++ b/pkg/connector/unity_catalog_test.go @@ -6,10 +6,16 @@ import ( "net/http" "net/http/httptest" "slices" + "strconv" "strings" "testing" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/types/grant" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/conductorone/baton-databricks/pkg/databricks" ) @@ -53,38 +59,35 @@ func newWorkspaceRoutedClient(t *testing.T, handler http.HandlerFunc) (*databric // deliberately not set, because turning the cache off is what hid this and a test // that turns it off guards nothing. -// Every workspace answers /api/2.1/unity-catalog/catalogs with the same path and -// query, so a cache entry shared between hosts files a workspace under a metastore -// it cannot reach and routes every securable read below it to the wrong host. -func TestWorkspaceMetastoreLookupIsNotAnsweredByAnotherWorkspacesCachedListing(t *testing.T) { - served := map[string]string{"wsa": "ms-a", "wsb": "ms-b"} +// Every workspace answers /api/2.1/unity-catalog/permissions with the same path +// and query, so a cache shared between hosts would misattribute privileges. +func TestPermissionsListingIsNotAnsweredByAnotherWorkspacesCachedPage(t *testing.T) { + held := map[string]string{"wsa": "alice", "wsb": "bob"} client, router := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { workspace := r.Header.Get("X-Test-Workspace") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ - "catalogs": []map[string]any{{ - "name": "main", - "metastore_id": served[workspace], + "privilege_assignments": []map[string]any{{ + "principal": held[workspace], + "privileges": []string{"CREATE_CATALOG"}, }}, }) }) - uc := newUnityCatalog(client, "app-1", nil) ctx := context.Background() - for _, workspace := range []string{"wsa", "wsb"} { - metastoreIDs, _, err := uc.metastoresServedBy(ctx, workspace) + assignments, _, _, err := client.ListPermissions(ctx, workspace, databricks.SecurableMetastore, "ms-1", "", "") if err != nil { - t.Fatalf("metastoresServedBy(%s): %v", workspace, err) + t.Fatalf("ListPermissions(%s): %v", workspace, err) } if router.hits[workspace] == 0 { - t.Errorf("workspace %s was never asked for its catalogs; hits = %v", workspace, router.hits) + t.Errorf("workspace %s was never asked for its permissions; hits = %v", workspace, router.hits) } - if want := []string{served[workspace]}; !slices.Equal(metastoreIDs, want) { - t.Errorf("workspace %s serves %v, want %v: a catalog listing crossed between workspace hosts", - workspace, metastoreIDs, want) + if len(assignments) != 1 || assignments[0].Principal != held[workspace] { + t.Errorf("workspace %s answered %+v, want %s: a permissions page crossed between workspace hosts", + workspace, assignments, held[workspace]) } } } @@ -118,25 +121,36 @@ func TestLookupDoesNotFallBackFromAnUnknownID(t *testing.T) { } } -// routingHandler answers the three account-plane calls buildRouting makes. Each -// workspace's metastore assignment is whatever assignments names it, and a status -// code there stands in for a credential that cannot read that workspace. +// routingHandler answers ListMetastores, ListWorkspaces, and +// ListMetastoreWorkspaces. assignments maps workspace id → metastore id (or an +// HTTP status that ListMetastoreWorkspaces should return for that metastore). func routingHandler(t *testing.T, assignments map[string]any, probed map[string]int) http.HandlerFunc { t.Helper() - return routingHandlerWithCatalogs(t, assignments, nil, probed) + return routingHandlerWith(t, assignments, probed, []map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, + }) } -// catalogStatus, keyed by deployment name, makes the catalog listing fail for a -// workspace, which is how a credential that is not a member of it answers. -func routingHandlerWithCatalogs( - t *testing.T, - assignments map[string]any, - catalogStatus map[string]int, - probed map[string]int, -) http.HandlerFunc { +// routingHandlerWith is routingHandler over a given workspace listing. Any other +// request counts as a probe of its workspace and answers 404, which is what the +// permissions endpoint returns for a metastore that is gone. +func routingHandlerWith(t *testing.T, assignments map[string]any, probed map[string]int, workspaces []map[string]any) http.HandlerFunc { t.Helper() + byMetastore := map[string][]int{} + statusByMetastore := map[string]int{} + for workspaceID, assigned := range assignments { + switch value := assigned.(type) { + case int: + statusByMetastore[workspaceID] = value + case string: + id, _ := strconv.Atoi(workspaceID) + byMetastore[value] = append(byMetastore[value], id) + } + } + return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") path := r.URL.Path @@ -149,34 +163,21 @@ func routingHandlerWithCatalogs( {"metastore_id": "ms-b", "name": "other"}, }, }) - case strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, - }) - case strings.HasSuffix(path, "/metastore"): - parts := strings.Split(path, "/") - workspaceID := parts[len(parts)-2] - probed[workspaceID]++ - switch assigned := assignments[workspaceID].(type) { - case int: - http.Error(w, `{"message":"denied"}`, assigned) - case string: - _ = json.NewEncoder(w).Encode(map[string]any{ - "metastore_assignment": map[string]any{"metastore_id": assigned}, - }) - default: - http.Error(w, `{"message":"missing"}`, http.StatusNotFound) + case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): + parts := strings.Split(strings.Trim(path, "/"), "/") + metastoreID := parts[len(parts)-2] + probed[metastoreID]++ + if status, ok := statusByMetastore[metastoreID]; ok { + http.Error(w, `{"message":"denied"}`, status) + return } + _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": byMetastore[metastoreID]}) + case strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode(workspaces) default: workspace := r.Header.Get("X-Test-Workspace") probed[workspace]++ - if status, ok := catalogStatus[workspace]; ok { - http.Error(w, `{"message":"denied"}`, status) - - return - } - _ = json.NewEncoder(w).Encode(map[string]any{"catalogs": []map[string]any{}}) + http.Error(w, `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"not found"}`, http.StatusNotFound) } } } @@ -196,17 +197,14 @@ func TestRoutingSkipsAWorkspaceOutsideTheConfiguredAllowlist(t *testing.T) { t.Fatalf("buildRouting: %v", err) } - if probed["2"] != 0 { - t.Errorf("the out-of-scope workspace was asked for its metastore assignment %d time(s)", probed["2"]) - } if want := []string{"wsa"}; !slices.Equal(snap.workspaces["ms-a"], want) { t.Errorf("ms-a routes through %v, want %v", snap.workspaces["ms-a"], want) } } // Scoping the sync to one workspace must not fail it for the metastores the other -// workspaces hold. A metastore only the excluded workspace can reach has to read -// as absent, the way a filtered catalog does: left in the snapshot it reaches +// workspaces hold. A metastore only the excluded workspace can reach is left out +// of scope, the way a filtered catalog is: left in the snapshot it reaches // usableWorkspaces, which answers FailedPrecondition, and that is not a warning // to the SDK, so every metastore-enabled sync would fail. func TestAMetastoreOnlyAnOutOfScopeWorkspaceReachesIsAbsentRatherThanFatal(t *testing.T) { @@ -227,26 +225,22 @@ func TestAMetastoreOnlyAnOutOfScopeWorkspaceReachesIsAbsentRatherThanFatal(t *te t.Error("ms-b is still in the snapshot; its Grants() would fail the whole sync with FailedPrecondition") } - workspace, exists, _, err := workspaceFromSnapshot(snap, "ms-b", nil) + workspaces, state, err := usableWorkspaces(snap, "ms-b") if err != nil { - t.Fatalf("resolving the out-of-scope metastore errored instead of reporting it absent: %v", err) + t.Fatalf("resolving the out-of-scope metastore errored instead of reporting it out of scope: %v", err) } - if exists || workspace != "" { - t.Errorf("ms-b resolved to workspace %q (exists=%v), want absent", workspace, exists) + if state != metastoreOutOfScope || len(workspaces) != 0 { + t.Errorf("ms-b resolved to workspaces %v (state=%v), want out of scope", workspaces, state) } } // Dropping an unrouted metastore is only safe when the workspaces left out are -// the reason it is unrouted. An in-scope workspace that answered neither its -// assignment nor a catalog listing may be exactly the one serving it, so pruning -// there would report the metastore deleted and take its grants with it. Failing -// the sync is recoverable; a deletion is not. -func TestAnUnreadableInScopeWorkspaceStopsTheMetastoreFromBeingPruned(t *testing.T) { +// the reason it is unrouted. An unreadable assignment list may hide an in-scope +// workspace, and pruning would read as a deletion in C1. +func TestAnUnreadableMetastoreAssignmentListStopsTheMetastoreFromBeingPruned(t *testing.T) { probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandlerWithCatalogs(t, - map[string]any{"1": "ms-a", "2": http.StatusForbidden}, - map[string]int{"wsb": http.StatusForbidden}, - probed)) + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, + map[string]any{"1": "ms-a", "ms-b": http.StatusForbidden}, probed)) uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod", "dev"})) snap, _, err := uc.buildRouting(context.Background()) @@ -254,51 +248,33 @@ func TestAnUnreadableInScopeWorkspaceStopsTheMetastoreFromBeingPruned(t *testing t.Fatalf("buildRouting: %v", err) } - if !slices.Contains(snap.unreadable, "wsb") { - t.Fatalf("unreadable = %v, want it to name wsb", snap.unreadable) + if !slices.Contains(snap.unreadable, "ms-b") { + t.Fatalf("unreadable = %v, want it to name ms-b", snap.unreadable) } if _, ok := snap.metastores["ms-b"]; !ok { - t.Fatal("ms-b was pruned while an in-scope workspace was unreadable, so C1 would delete it and its grants") + t.Fatal("ms-b was pruned while its workspace list was unreadable, so C1 would delete it and its grants") } - if _, _, _, err := workspaceFromSnapshot(snap, "ms-b", nil); err == nil { + if _, _, err := usableWorkspaces(snap, "ms-b"); err == nil { t.Error("ms-b resolved without an error, want the sync to fail loudly rather than report an absence") } } -// The catalogs endpoint returns only the catalogs the caller may use, so an empty -// listing from a credential that was also refused the assignment read is equally -// explained by missing USE_CATALOG. That pair settles nothing, and treating it as -// "this workspace carries nothing" would prune a metastore it does serve. Only a -// 404 on the assignment plus an empty listing reads as outside Unity Catalog. -func TestADeniedAssignmentWithAnEmptyCatalogListingIsNotEvidenceOfNothing(t *testing.T) { - for name, tc := range map[string]struct { - assignment any - wantPruned bool - wantUnreadab bool - }{ - "denied assignment": {assignment: http.StatusForbidden, wantPruned: false, wantUnreadab: true}, - "no assignment": {assignment: http.StatusNotFound, wantPruned: true, wantUnreadab: false}, - } { - t.Run(name, func(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, - map[string]any{"1": "ms-a", "2": tc.assignment}, probed)) +// A metastore whose readable assignment list names no in-scope workspace is out +// of scope under an allowlist. +func TestAMetastoreMissingFromEveryAssignmentListIsPrunedUnderAnAllowlist(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, + map[string]any{"1": "ms-a"}, probed)) - uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod", "dev"})) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("buildRouting: %v", err) - } + uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod", "dev"})) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("buildRouting: %v", err) + } - if got := slices.Contains(snap.unreadable, "wsb"); got != tc.wantUnreadab { - t.Errorf("unreadable names wsb = %v, want %v (unreadable = %v)", got, tc.wantUnreadab, snap.unreadable) - } - _, stillListed := snap.metastores["ms-b"] - if pruned := !stillListed; pruned != tc.wantPruned { - t.Errorf("ms-b pruned = %v, want %v", pruned, tc.wantPruned) - } - }) + if _, ok := snap.metastores["ms-b"]; ok { + t.Fatal("ms-b is still in the snapshot even though no workspace is assigned to it") } } @@ -306,24 +282,374 @@ func TestADeniedAssignmentWithAnEmptyCatalogListingIsNotEvidenceOfNothing(t *tes // sync. A credential that is not a member of one workspace answers 401 or 403 // there, which says nothing about the rest of the account, so letting it escape // buildRouting would take down the resource types that do work. -func TestRoutingSetsAsideAWorkspaceTheCredentialCannotRead(t *testing.T) { +func TestRoutingSetsAsideAMetastoreWhoseAssignmentListCannotBeRead(t *testing.T) { for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { t.Run(http.StatusText(status), func(t *testing.T) { probed := map[string]int{} client, _ := newWorkspaceRoutedClient(t, routingHandler(t, - map[string]any{"1": "ms-a", "2": status}, probed)) + map[string]any{"1": "ms-a", "ms-b": status}, probed)) uc := newUnityCatalog(client, "app-1", nil) snap, _, err := uc.buildRouting(context.Background()) if err != nil { - t.Fatalf("one unreadable workspace failed the whole snapshot: %v", err) + t.Fatalf("one unreadable metastore failed the whole snapshot: %v", err) } if want := []string{"wsa"}; !slices.Equal(snap.workspaces["ms-a"], want) { t.Errorf("ms-a routes through %v, want %v", snap.workspaces["ms-a"], want) } - if !slices.Contains(snap.unusable, "wsb") { - t.Errorf("unusable = %v, want it to name wsb", snap.unusable) + if !slices.Contains(snap.unreadable, "ms-b") { + t.Errorf("unreadable = %v, want it to name ms-b", snap.unreadable) + } + }) + } +} + +// A PROVISIONING workspace may serve the unrouted metastores once RUNNING, so it +// blocks the prune like an unreadable assignment list. +func TestANotRunningInScopeWorkspaceStopsTheMetastoreFromBeingPruned(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandlerWith(t, + map[string]any{"1": "ms-a", "2": "ms-b"}, probed, []map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "PROVISIONING"}, + })) + + uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod", "dev"})) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("buildRouting: %v", err) + } + + if !slices.Equal(snap.pending["ms-b"], []string{"wsb"}) { + t.Fatalf("pending[ms-b] = %v, want [wsb]", snap.pending["ms-b"]) + } + if _, ok := snap.metastores["ms-b"]; !ok { + t.Fatal("ms-b was pruned while an in-scope workspace was still PROVISIONING, so C1 would delete it and its grants") + } + if _, _, err := usableWorkspaces(snap, "ms-b"); status.Code(err) != codes.FailedPrecondition { + t.Errorf("resolving ms-b = %v, want FailedPrecondition", err) + } +} + +// An allowlist that names no workspace on the account is a configuration error, +// not an account with no metastores: pruning would delete every metastore in C1. +func TestAnAllowlistMatchingNoWorkspaceFailsInsteadOfPruningEverything(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, + map[string]any{"1": "ms-a", "2": "ms-b"}, probed)) + + uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"does-not-exist"})) + _, _, err := uc.buildRouting(context.Background()) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("buildRouting = %v, want FailedPrecondition", err) + } + if !strings.Contains(err.Error(), "does-not-exist") { + t.Errorf("error %q does not name the allowlist", err) + } +} + +// Scoping the sync to a workspace that is not on Unity Catalog leaves every +// metastore out of scope. That is an empty result, not a failed sync. +func TestAnAllowlistedWorkspaceWithoutUnityCatalogPrunesTheMetastores(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, + map[string]any{"1": "ms-a"}, probed)) + + uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"dev"})) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("buildRouting: %v", err) + } + if len(snap.metastores) != 0 { + t.Fatalf("metastores = %v, want none: no allowlisted workspace is attached to any", snap.metastores) + } +} + +// Without an allowlist, a metastore with no usable workspace would still fail its +// Grants with FailedPrecondition and, with it, the whole sync. +func TestAnUnroutableMetastoreIsPrunedWithoutAnAllowlist(t *testing.T) { + tests := []struct { + name string + assignments map[string]any + }{ + {"no workspace assigned", map[string]any{"1": "ms-a"}}, + {"only an excluded workspace assigned", map[string]any{"1": "ms-a", "3": "ms-b"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, test.assignments, probed)) + + uc := newUnityCatalog(client, "app-1", nil) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("buildRouting: %v", err) + } + if _, ok := snap.metastores["ms-a"]; !ok { + t.Error("ms-a is missing from the snapshot") + } + if _, ok := snap.metastores["ms-b"]; ok { + t.Error("ms-b is still in the snapshot; its Grants would fail the whole sync with FailedPrecondition") + } + }) + } +} + +// Admin on any attached workspace is enough, so Grants tries the next workspace +// before reporting PermissionDenied. +func TestGrantsRoutesThroughTheFirstWorkspaceWhereTheProbePasses(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + var permissionsHosts []string + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/workspaces/1/permissionassignments"): + _ = json.NewEncoder(w).Encode(map[string]any{"permission_assignments": []any{}}) + case strings.HasSuffix(path, "/workspaces/2/permissionassignments"): + _ = json.NewEncoder(w).Encode(map[string]any{"permission_assignments": []map[string]any{{ + "principal": map[string]any{"service_principal_name": "app-1", "principal_id": 7}, + "permissions": []string{"ADMIN"}, + }}}) + case strings.HasSuffix(path, "/metastores"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "someone-else"}}, + }) + case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{1, 2}}) + case strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, + }) + case strings.Contains(path, "/unity-catalog/permissions/"): + permissionsHosts = append(permissionsHosts, r.Header.Get("X-Test-Workspace")) + _ = json.NewEncoder(w).Encode(map[string]any{"privilege_assignments": []any{}}) + default: + http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) + } + }) + m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) + + ctx := context.Background() + account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + metastore, err := metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms-a"}, account, "", "", "") + if err != nil { + t.Fatalf("metastoreResource: %v", err) + } + + if _, _, err := m.Grants(ctx, metastore, rs.SyncOpAttrs{}); err != nil { + t.Fatalf("Grants: %v", err) + } + if !slices.Equal(permissionsHosts, []string{"wsb"}) { + t.Errorf("permissions read through %v, want [wsb], the workspace where the connector is Admin", permissionsHosts) + } +} + +// The metastore was listed a moment before its workspaces were asked for, so a +// 404 there means it was deleted in between, which is an absence. +func TestAMetastoreDeletedBetweenListingsIsAbsent(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, + map[string]any{"1": "ms-a", "ms-b": http.StatusNotFound}, probed)) + + uc := newUnityCatalog(client, "app-1", nil) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("buildRouting: %v", err) + } + if _, ok := snap.metastores["ms-b"]; ok { + t.Error("ms-b is still in the snapshot after its assignment list answered 404") + } + if slices.Contains(snap.unreadable, "ms-b") { + t.Error("ms-b is counted unreadable; a 404 means it is gone") + } +} + +// The profile workspace says nothing about whether the metastore still exists; +// without asking, Revoke on a deleted metastore fails on every retry. +func TestProvisioningOnADeletedMetastoreDoesNotTrustTheProfile(t *testing.T) { + tests := []struct { + name string + change func(ctx context.Context, m *metastoreBuilder, principal *v2.Resource, g *v2.Grant) (annotations.Annotations, error) + code codes.Code + }{ + {"revoke", func(ctx context.Context, m *metastoreBuilder, _ *v2.Resource, g *v2.Grant) (annotations.Annotations, error) { + return m.Revoke(ctx, g) + }, codes.OK}, + {"grant", func(ctx context.Context, m *metastoreBuilder, principal *v2.Resource, g *v2.Grant) (annotations.Annotations, error) { + return m.Grant(ctx, principal, g.GetEntitlement()) + }, codes.NotFound}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, + map[string]any{"1": "ms-a", "2": "ms-b"}, probed)) + m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) + + ctx := context.Background() + account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + metastore, err := metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms-gone", Owner: "app-1"}, account, "wsa", "1", "prod") + if err != nil { + t.Fatalf("metastoreResource: %v", err) + } + principal := &v2.Resource{Id: &v2.ResourceId{ResourceType: userResourceType.Id, Resource: "1"}} + g := grant.NewGrant(metastore, "CREATE_CATALOG", principal.GetId()) + + annos, err := test.change(ctx, m, principal, g) + if status.Code(err) != test.code { + t.Fatalf("err = %v, want %s", err, test.code) + } + if test.code == codes.OK && !annos.Contains(&v2.GrantAlreadyRevoked{}) { + t.Error("revoke on a deleted metastore did not report GrantAlreadyRevoked") + } + if probed["wsa"] != 0 { + t.Errorf("the profile workspace was asked %d time(s) about a metastore the account no longer lists", probed["wsa"]) + } + }) + } +} + +// A pruned metastore still holds its privileges, so a revoke cannot be reported +// done, and a grant has no workspace this sync may address. +func TestProvisioningOnAnOutOfScopeMetastoreFailsWithoutWriting(t *testing.T) { + tests := []struct { + name string + change func(ctx context.Context, m *metastoreBuilder, principal *v2.Resource, g *v2.Grant) (annotations.Annotations, error) + }{ + {"revoke", func(ctx context.Context, m *metastoreBuilder, _ *v2.Resource, g *v2.Grant) (annotations.Annotations, error) { + return m.Revoke(ctx, g) + }}, + {"grant", func(ctx context.Context, m *metastoreBuilder, principal *v2.Resource, g *v2.Grant) (annotations.Annotations, error) { + return m.Grant(ctx, principal, g.GetEntitlement()) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + probed := map[string]int{} + var writes []string + routing := routingHandler(t, map[string]any{"1": "ms-a", "3": "ms-b"}, probed) + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writes = append(writes, r.Method+" "+r.URL.Path) + } + routing(w, r) + }) + m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) + + ctx := context.Background() + account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + metastore, err := metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms-b", Owner: "app-1"}, account, "wsb", "2", "dev") + if err != nil { + t.Fatalf("metastoreResource: %v", err) + } + principal := &v2.Resource{Id: &v2.ResourceId{ResourceType: userResourceType.Id, Resource: "1"}} + g := grant.NewGrant(metastore, "CREATE_CATALOG", principal.GetId()) + + annos, err := test.change(ctx, m, principal, g) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("err = %v, want FailedPrecondition", err) + } + if !strings.Contains(err.Error(), "ms-b") || !strings.Contains(err.Error(), "outside the sync scope") { + t.Errorf("error %q does not name the metastore as outside the sync scope", err) + } + if annos.Contains(&v2.GrantAlreadyRevoked{}) { + t.Error("an out-of-scope metastore was reported as already revoked while it still holds the privilege") + } + if len(writes) != 0 { + t.Errorf("sent %v to an out-of-scope metastore, want no write", writes) + } + if probed["wsa"] != 0 || probed["wsb"] != 0 { + t.Errorf("a workspace was asked about an out-of-scope metastore: %v", probed) + } + }) + } +} + +// List leaves a pruned metastore out, so Grants on it is an empty page, not an error. +func TestGrantsOnAnOutOfScopeMetastoreIsEmpty(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, map[string]any{"1": "ms-a", "3": "ms-b"}, probed)) + m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) + + ctx := context.Background() + account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + metastore, err := metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms-b"}, account, "", "", "") + if err != nil { + t.Fatalf("metastoreResource: %v", err) + } + + grants, _, err := m.Grants(ctx, metastore, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("Grants: %v", err) + } + if len(grants) != 0 { + t.Errorf("Grants = %v, want none", grants) + } +} + +// A profile workspace the credential can no longer read, or one the allowlist +// now leaves out, is a stale hint: Grants rebuilds the routing instead of failing. +func TestGrantsFallsBackWhenTheProfileWorkspaceCannotBeUsed(t *testing.T) { + tests := []struct { + name string + workspaces []string + profileWS string + profileID string + }{ + {"probe answers 403", nil, "wsb", "2"}, + {"workspace left out of the allowlist", []string{"prod"}, "wsb", "2"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + var permissionsHosts []string + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/permissionassignments"): + http.Error(w, `{"message":"forbidden"}`, http.StatusForbidden) + case strings.HasSuffix(path, "/metastores"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "app-1"}}, + }) + case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{1, 2}}) + case strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, + }) + case strings.Contains(path, "/unity-catalog/permissions/"): + permissionsHosts = append(permissionsHosts, r.Header.Get("X-Test-Workspace")) + _ = json.NewEncoder(w).Encode(map[string]any{"privilege_assignments": []any{}}) + default: + http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) + } + }) + m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", configuredWorkspaceSet(test.workspaces)), syncPrincipals{}) + + ctx := context.Background() + account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + metastore, err := metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms-a"}, account, test.profileWS, test.profileID, "dev") + if err != nil { + t.Fatalf("metastoreResource: %v", err) + } + + if _, _, err := m.Grants(ctx, metastore, rs.SyncOpAttrs{}); err != nil { + t.Fatalf("Grants: %v", err) + } + if !slices.Equal(permissionsHosts, []string{"wsa"}) { + t.Errorf("permissions read through %v, want [wsa] from the rebuilt routing", permissionsHosts) } }) } diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index 78254dc0..b6500aa8 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -246,7 +246,7 @@ func (c *Client) FindUserID( ctx, workspaceId, &PaginationVars{Count: 1}, - NewFilterVars(fmt.Sprintf("userName eq '%s'", username)), + NewFilterVars("userName eq "+strconv.Quote(username)), ) if err != nil { @@ -273,7 +273,7 @@ func (c *Client) FindUsername( ctx, workspaceId, &PaginationVars{Count: 1}, - NewFilterVars(fmt.Sprintf("id eq '%s'", userID)), + NewFilterVars("id eq "+strconv.Quote(userID)), ) if err != nil { @@ -358,7 +358,7 @@ func (c *Client) FindGroupID( ctx, workspaceId, &PaginationVars{Count: 1}, - NewFilterVars(fmt.Sprintf("displayName eq '%s'", displayName)), + NewFilterVars("displayName eq "+strconv.Quote(displayName)), ) if err != nil { @@ -385,7 +385,7 @@ func (c *Client) FindGroupDisplayName( ctx, workspaceId, &PaginationVars{Count: 1}, - NewFilterVars(fmt.Sprintf("id eq '%s'", groupID)), + NewFilterVars("id eq "+strconv.Quote(groupID)), ) if err != nil { @@ -476,7 +476,7 @@ func (c *Client) FindServicePrincipalID( ctx, workspaceId, &PaginationVars{Count: 1}, - NewFilterVars(fmt.Sprintf("applicationId eq '%s'", appID)), + NewFilterVars("applicationId eq "+strconv.Quote(appID)), ) if err != nil { @@ -503,7 +503,7 @@ func (c *Client) FindServicePrincipalAppID( ctx, workspaceId, &PaginationVars{Count: 1}, - NewFilterVars(fmt.Sprintf("id eq '%s'", servicePrincipalID)), + NewFilterVars("id eq "+strconv.Quote(servicePrincipalID)), ) if err != nil { diff --git a/pkg/databricks/client_test.go b/pkg/databricks/client_test.go index e276ceb5..ece98a8e 100644 --- a/pkg/databricks/client_test.go +++ b/pkg/databricks/client_test.go @@ -2,7 +2,9 @@ package databricks import ( "context" + "encoding/json" "net/http" + "net/http/httptest" "testing" ) @@ -70,6 +72,61 @@ func TestGetAccountHostname(t *testing.T) { } } +// A single-quoted value is not RFC 7644 filter syntax, and an apostrophe in a +// userName would end it early, so the value is sent as a quoted JSON string. +func TestFindFiltersQuoteTheValue(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + tests := []struct { + name string + find func(c *Client) error + want string + }{ + {"user by userName", func(c *Client) error { + _, _, err := c.FindUserID(context.Background(), "", "o'brien@example.com") + return err + }, `userName eq "o'brien@example.com"`}, + {"group by displayName", func(c *Client) error { + _, _, err := c.FindGroupID(context.Background(), "", `data "eng"`) + return err + }, `displayName eq "data \"eng\""`}, + {"service principal by applicationId", func(c *Client) error { + _, _, err := c.FindServicePrincipalID(context.Background(), "", "app-1") + return err + }, `applicationId eq "app-1"`}, + {"user by id", func(c *Client) error { + _, _, err := c.FindUsername(context.Background(), "", "42") + return err + }, `id eq "42"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.URL.Query().Get("filter") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"Resources": []any{}, "totalResults": 0}) + })) + t.Cleanup(srv.Close) + + client := srv.Client() + client.Transport = rewriteHost(srv.Listener.Addr().String(), client.Transport) + c, err := NewClient(context.Background(), client, "example.cloud.databricks.com", "accounts.cloud.databricks.com", "acc-1", "", &NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + if err := tt.find(c); err != nil { + t.Fatalf("find: %v", err) + } + if got != tt.want { + t.Errorf("filter = %q, want %q", got, tt.want) + } + }) + } +} + func TestIsWorkspaceExcludedReturnsEveryMatchedKey(t *testing.T) { ws := Workspace{ID: 12345, Name: "prod", DeploymentName: "dbc-abc"} c := newTestClient(t, []string{"prod", "dbc-abc", "staging"}) diff --git a/pkg/databricks/models.go b/pkg/databricks/models.go index e1451b3a..37315013 100644 --- a/pkg/databricks/models.go +++ b/pkg/databricks/models.go @@ -190,12 +190,8 @@ type metastoresResponse struct { Metastores []Metastore `json:"metastores"` } -type metastoreAssignment struct { - MetastoreID string `json:"metastore_id"` -} - -type workspaceMetastoreResponse struct { - MetastoreAssignment metastoreAssignment `json:"metastore_assignment"` +type metastoreWorkspacesResponse struct { + WorkspaceIDs []json.Number `json:"workspace_ids"` } type catalogsResponse struct { diff --git a/pkg/databricks/unity_catalog.go b/pkg/databricks/unity_catalog.go index 9fc38090..507aa1ad 100644 --- a/pkg/databricks/unity_catalog.go +++ b/pkg/databricks/unity_catalog.go @@ -15,8 +15,8 @@ const ( // https://docs.databricks.com/api/account/accountmetastores/list accountMetastoresEndpoint = "/api/2.0/accounts/%s/metastores" - // https://docs.databricks.com/api/account/accountmetastoreassignments/get - accountWorkspaceMetastoreEndpoint = "/api/2.0/accounts/%s/workspaces/%s/metastore" + // https://docs.databricks.com/api/account/accountmetastoreassignments/list + accountMetastoreWorkspacesEndpoint = "/api/2.0/accounts/%s/metastores/%s/workspaces" // https://docs.databricks.com/api/workspace/catalogs/list unityCatalogCatalogsEndpoint = "/api/2.1/unity-catalog/catalogs" @@ -54,26 +54,33 @@ func (c *Client) ListMetastores( return res.Metastores, ratelimitData, nil } -// GetWorkspaceMetastore (GET /api/2.0/accounts/{account_id}/workspaces/{workspace_id}/metastore), -// empty when the workspace has none. workspaceID is the numeric account-level id, -// not the deployment name the securable endpoints are addressed by. -func (c *Client) GetWorkspaceMetastore( +// ListMetastoreWorkspaces (GET /api/2.0/accounts/{account_id}/metastores/{metastore_id}/workspaces) +// returns the numeric workspace ids assigned to that metastore. +func (c *Client) ListMetastoreWorkspaces( ctx context.Context, - workspaceID string, + metastoreID string, ) ( - string, + []string, *v2.RateLimitDescription, error, ) { - var res workspaceMetastoreResponse + var res metastoreWorkspacesResponse - u := c.accountBaseUrl.JoinPath(fmt.Sprintf(accountWorkspaceMetastoreEndpoint, c.accountId, workspaceID)) + u := c.accountBaseUrl.JoinPath(fmt.Sprintf(accountMetastoreWorkspacesEndpoint, c.accountId, metastoreID)) ratelimitData, err := c.Get(ctx, u, &res) if err != nil { - return "", ratelimitData, fmt.Errorf("failed to get metastore assignment for workspace %s: %w", workspaceID, err) + return nil, ratelimitData, fmt.Errorf("failed to list workspaces for metastore %s: %w", metastoreID, err) + } + + ids := make([]string, 0, len(res.WorkspaceIDs)) + for _, id := range res.WorkspaceIDs { + if id == "" { + continue + } + ids = append(ids, id.String()) } - return res.MetastoreAssignment.MetastoreID, ratelimitData, nil + return ids, ratelimitData, nil } // listSecurables reads one page off a workspace-scoped securable endpoint. @@ -210,21 +217,9 @@ func (c *Client) DrainPermissions( }) } -// ForEachPermissionsPage walks ListPermissions without retaining the pages. The -// grant-visibility probe reads through this, where a cached page costs nothing: -// it reports who may administer the securable, not who holds what. -func (c *Client) ForEachPermissionsPage( - ctx context.Context, - workspaceId, securableType, fullName, principal string, - visit func([]PrivilegeAssignment) (bool, error), -) (*v2.RateLimitDescription, error) { - return forEachPageFrom(ctx, "", func(pageToken string) ([]PrivilegeAssignment, string, *v2.RateLimitDescription, error) { - return c.ListPermissions(ctx, workspaceId, securableType, fullName, principal, pageToken) - }, visit) -} - -// ForEachUncachedPermissionsPage is ForEachPermissionsPage for the pre-read that -// decides whether a Revoke still has to PATCH. See DrainPermissions. +// ForEachUncachedPermissionsPage walks ListPermissions past the cache, without +// retaining the pages, for the pre-read that decides whether a Revoke still has +// to PATCH. See DrainPermissions. func (c *Client) ForEachUncachedPermissionsPage( ctx context.Context, workspaceId, securableType, fullName, principal string, From 8b587419ff259f670ba4008a42803e22b56e068b Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Tue, 13 Oct 2026 17:42:08 -0300 Subject: [PATCH 11/36] fix(databricks): treat a metastore assignment 404 as unknown and refresh the permissions cache after a patch Co-authored-by: Cursor --- pkg/connector/unity_catalog.go | 17 +++------ pkg/connector/unity_catalog_test.go | 14 +++---- pkg/databricks/client.go | 6 +++ pkg/databricks/permissions_cache_test.go | 48 +++++++++++++++++++++++- pkg/databricks/unity_catalog.go | 8 ++++ pkg/databricks/unity_catalog_test.go | 5 ++- 6 files changed, 76 insertions(+), 22 deletions(-) diff --git a/pkg/connector/unity_catalog.go b/pkg/connector/unity_catalog.go index 23ed0ac4..4e4cf212 100644 --- a/pkg/connector/unity_catalog.go +++ b/pkg/connector/unity_catalog.go @@ -29,7 +29,7 @@ type routingSnapshot struct { asked int // unreadable names the metastores whose workspace assignment list answered - // 401 or 403, so which workspaces serve them is unknown rather than none. + // 401, 403 or 404, so which workspaces serve them is unknown rather than none. unreadable []string // pending maps a metastore to its in-scope assigned workspaces that are not @@ -221,16 +221,11 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot rateLimit = assignmentRateLimit } if err != nil { - // The metastore was listed a moment ago, so a 404 here means it has - // since been deleted. - if isNotFoundError(err) { - ctxzap.Extract(ctx).Debug("databricks-connector: metastore disappeared while its workspaces were being listed", - zap.String("metastore", metastoreID), - ) - delete(snap.metastores, metastoreID) - continue - } - if isUnreadableWorkspaceError(err) { + // Databricks does not document what a 404 here means, so it is read as + // unknown rather than as a deletion: pruning on it would make C1 delete + // the metastore and its grants. A metastore that really is gone stops + // appearing in ListMetastores on the next sync. + if isUnreadableWorkspaceError(err) || isNotFoundError(err) { unreadable = append(unreadable, metastoreID) continue } diff --git a/pkg/connector/unity_catalog_test.go b/pkg/connector/unity_catalog_test.go index 08a278b2..a518891d 100644 --- a/pkg/connector/unity_catalog_test.go +++ b/pkg/connector/unity_catalog_test.go @@ -449,9 +449,9 @@ func TestGrantsRoutesThroughTheFirstWorkspaceWhereTheProbePasses(t *testing.T) { } } -// The metastore was listed a moment before its workspaces were asked for, so a -// 404 there means it was deleted in between, which is an absence. -func TestAMetastoreDeletedBetweenListingsIsAbsent(t *testing.T) { +// Databricks does not document a 404 on the assignment list, so it must not be +// read as a deletion: pruning on it would make C1 delete the metastore's grants. +func TestA404OnTheAssignmentListIsUnknownRatherThanDeleted(t *testing.T) { probed := map[string]int{} client, _ := newWorkspaceRoutedClient(t, routingHandler(t, map[string]any{"1": "ms-a", "ms-b": http.StatusNotFound}, probed)) @@ -461,11 +461,11 @@ func TestAMetastoreDeletedBetweenListingsIsAbsent(t *testing.T) { if err != nil { t.Fatalf("buildRouting: %v", err) } - if _, ok := snap.metastores["ms-b"]; ok { - t.Error("ms-b is still in the snapshot after its assignment list answered 404") + if _, ok := snap.metastores["ms-b"]; !ok { + t.Error("ms-b was dropped after its assignment list answered 404, so C1 would delete it and its grants") } - if slices.Contains(snap.unreadable, "ms-b") { - t.Error("ms-b is counted unreadable; a 404 means it is gone") + if !slices.Contains(snap.unreadable, "ms-b") { + t.Errorf("unreadable = %v, want it to name ms-b", snap.unreadable) } } diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index b6500aa8..c44653bd 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -197,6 +197,8 @@ func (c *Client) ListUsers( return res.Resources, res.Total, ratelimitData, nil } +// GetUser reads one SCIM user by id. A 404 means no user has that id +// (RFC 7644 §3.4.1), which is how a group or service principal id answers here. func (c *Client) GetUser( ctx context.Context, workspaceId string, @@ -313,6 +315,8 @@ func (c *Client) ListGroups( return res.Resources, res.Total, ratelimitData, nil } +// GetGroup reads one SCIM group by id. A 404 means no group has that id +// (RFC 7644 §3.4.1), which is how a user or service principal id answers here. func (c *Client) GetGroup(ctx context.Context, workspaceId, groupId string, vars ...Vars) ( *Group, *v2.RateLimitDescription, @@ -425,6 +429,8 @@ func (c *Client) ListServicePrincipals( return res.Resources, res.Total, ratelimitData, nil } +// GetServicePrincipal reads one SCIM service principal by id. A 404 means no +// service principal has that id (RFC 7644 §3.4.1). func (c *Client) GetServicePrincipal( ctx context.Context, workspaceId string, diff --git a/pkg/databricks/permissions_cache_test.go b/pkg/databricks/permissions_cache_test.go index c98bb3fe..a63302eb 100644 --- a/pkg/databricks/permissions_cache_test.go +++ b/pkg/databricks/permissions_cache_test.go @@ -78,7 +78,51 @@ func TestProvisioningPreReadsAreNotServedFromTheCache(t *testing.T) { t.Fatalf("Revoke pre-read saw %d assignment(s), want the privilege the Grant just added: "+ "a cached page here reports GrantAlreadyRevoked and skips the PATCH", len(seen)) } - if gets != 2 { - t.Errorf("%d GET(s) reached the API, want 2: both pre-reads must bypass the cache", gets) + if gets != 3 { + t.Errorf("%d GET(s) reached the API, want 3: both pre-reads and the post-PATCH refresh must bypass the cache", gets) + } +} + +// The pre-read stores page one under the key the sync's cached ListPermissions +// uses. Without a refresh after the PATCH, a sync in the same process within the +// cache TTL reads the state from before the grant. +func TestASyncReadAfterAGrantSeesThePostChangeState(t *testing.T) { + granted := false + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPatch { + granted = true + } + var assignments []PrivilegeAssignment + if granted { + assignments = []PrivilegeAssignment{{Principal: "alice", Privileges: []string{"SELECT"}}} + } + _ = json.NewEncoder(w).Encode(permissionsResponse{PrivilegeAssignments: assignments}) + })) + t.Cleanup(srv.Close) + + client := srv.Client() + client.Transport = rewriteHost(srv.Listener.Addr().String(), client.Transport) + c, err := NewClient(context.Background(), client, "example.cloud.databricks.com", "accounts.cloud.databricks.com", "acc-1", srv.URL, &NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + ctx := context.Background() + + if _, _, err := c.DrainPermissions(ctx, "ws", SecurableMetastore, "ms-1"); err != nil { + t.Fatalf("Grant pre-read: %v", err) + } + if _, _, err := c.UpdatePermissions(ctx, "ws", SecurableMetastore, "ms-1", + []PermissionsChange{{Principal: "alice", Add: []string{"SELECT"}}}); err != nil { + t.Fatalf("UpdatePermissions: %v", err) + } + + assignments, _, _, err := c.ListPermissions(ctx, "ws", SecurableMetastore, "ms-1", "", "") + if err != nil { + t.Fatalf("ListPermissions: %v", err) + } + if len(assignments) != 1 { + t.Fatalf("the cached sync read saw %d assignment(s), want the privilege the Grant just added", len(assignments)) } } diff --git a/pkg/databricks/unity_catalog.go b/pkg/databricks/unity_catalog.go index 507aa1ad..58db1946 100644 --- a/pkg/databricks/unity_catalog.go +++ b/pkg/databricks/unity_catalog.go @@ -300,6 +300,14 @@ func (c *Client) updatePermissions( return ratelimitData, fmt.Errorf("failed to update permissions on %s %s: %w", securableType, fullName, err) } + // The provisioning pre-read stored page one under the key the sync reads, + // and a PATCH does not invalidate it. Re-reading past the cache overwrites + // that entry with the post-change state. The write already landed, so a + // failed refresh must not fail it. + if _, _, refreshRateLimit, refreshErr := c.listPermissions(ctx, workspaceId, securableType, fullName, "", "", true); refreshErr == nil && refreshRateLimit != nil { + ratelimitData = refreshRateLimit + } + stop, err := visit(res.PrivilegeAssignments) if err != nil || stop || res.NextPageToken == "" { return ratelimitData, err diff --git a/pkg/databricks/unity_catalog_test.go b/pkg/databricks/unity_catalog_test.go index c07c1cec..3c5e66d8 100644 --- a/pkg/databricks/unity_catalog_test.go +++ b/pkg/databricks/unity_catalog_test.go @@ -84,8 +84,9 @@ func TestUpdatePermissionsDrainsFromPatchToken(t *testing.T) { if err != nil { t.Fatalf("UpdatePermissions: %v", err) } - if len(gets) != 1 || gets[0] != "page-2" { - t.Fatalf("GET page tokens %v, want [page-2] and not a re-fetch of the PATCH page", gets) + // Page one is re-read only to refresh the cache; confirmation comes from the PATCH body. + if len(gets) != 2 || gets[0] != "" || gets[1] != "page-2" { + t.Fatalf("GET page tokens %v, want the cache refresh then page-2", gets) } if len(got) != 2 || got[0].Principal != "alice" || got[1].Principal != "bob" { t.Fatalf("assignments = %+v, want alice then bob", got) From 92af630dbcb2d6bd6a1c1f2232d8c766a68e715f Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Wed, 14 Oct 2026 16:27:43 -0300 Subject: [PATCH 12/36] docs(databricks): list the metastore endpoints the connector reads Co-authored-by: Cursor --- docs/docs-info.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/docs-info.md b/docs/docs-info.md index acd91432..a252f813 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -50,6 +50,17 @@ While developing the connector, please fill out this form. This information is n > Account admin access to the Databricks account console (to create the service principal and OAuth secret), and workspace admin on each workspace being synced. +## Unity Catalog API + +A metastore lives on the account plane, but its privileges are read through a +workspace host assigned to it. + +| Level | Endpoint | +|-------|----------| +| Metastores | `GET /api/2.0/accounts/{account_id}/metastores` | +| Workspace metastore assignment | `GET /api/2.0/accounts/{account_id}/workspaces/{workspace_id}/metastore` | +| Privileges on every level | `GET` and `PATCH` `/api/2.1/unity-catalog/permissions/{securable_type}/{full_name}` | + ## Permission model Three independent layers have to line up, and granting one does not imply the From 0aab946d167ad9a9ddabdf9c20e0bf09618a1bf4 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Wed, 14 Oct 2026 16:28:40 -0300 Subject: [PATCH 13/36] docs(databricks): note the host-scoped http cache key Co-authored-by: Cursor --- docs/docs-info.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/docs-info.md b/docs/docs-info.md index a252f813..45ee00da 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -61,6 +61,10 @@ workspace host assigned to it. | Workspace metastore assignment | `GET /api/2.0/accounts/{account_id}/workspaces/{workspace_id}/metastore` | | Privileges on every level | `GET` and `PATCH` `/api/2.1/unity-catalog/permissions/{securable_type}/{full_name}` | +- **HTTP cache keys include the request Host.** Two workspaces share paths such + as `/unity-catalog/permissions/...`, and without the host in the key one + workspace's response would be served for another. + ## Permission model Three independent layers have to line up, and granting one does not imply the From a81fdd720ea915b5472c1149d50cc70fc3c3f6bc Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Thu, 15 Oct 2026 13:55:12 -0300 Subject: [PATCH 14/36] fix(databricks): stop a non-absence metastore 404 from deleting grants and record the readable workspace Co-authored-by: Cursor --- README.md | 4 +- docs/connector.mdx | 2 +- docs/docs-info.md | 15 ++- pkg/connector/helpers.go | 7 +- pkg/connector/metastore.go | 67 ++++++++++- pkg/connector/principal_index.go | 10 +- pkg/connector/unity_catalog.go | 87 +++++++++++++-- pkg/connector/unity_catalog_test.go | 165 +++++++++++++++++++++++++++- pkg/databricks/request.go | 9 +- pkg/databricks/unity_catalog.go | 14 ++- 10 files changed, 341 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 8fef81af..f66602e7 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,9 @@ deployment name, or numeric workspace ID, matched case-insensitively. `--workspaces` also scopes which metastores sync: only a metastore attached to a listed workspace is synced, so narrowing the list removes the other metastores and their grants from ConductorOne. A list that matches no workspace on the -account fails the metastore sync. +account fails the whole sync, including users, groups, service principals and +workspaces. A name that is also in `databricks-exclude-workspaces` is reported +as excluded. Metastores are Unity Catalog securables and are opt-in: the type stays off until it is enabled for the connector in ConductorOne. diff --git a/docs/connector.mdx b/docs/connector.mdx index c035dc41..db669e8d 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -106,7 +106,7 @@ There is no scope to pick in the console. The connector requests the `all-apis` Assign it to each workspace you want C1 to sync, as an admin: in the account console, go to **Workspaces**, open the workspace, select **Permissions**, click **Add permissions**, select the service principal, and choose **Admin**. Assign **Admin** directly to the service principal — the connector does not treat Admin inherited through a group as enough. - Repeat this for every workspace you want synced. Workspace **Admin** is enough to read Unity Catalog privileges on a metastore reached through that workspace. If the metastore's workspaces are not synced, make the service principal the **Metastore Admin** instead: in the account console, go to **Catalog**, open the metastore, and set **Metastore Admin** to the service principal's application ID. + Repeat this for every workspace you want synced. Workspace **Admin** is enough to read Unity Catalog privileges on a metastore reached through that workspace. If the service principal can't be assigned Admin directly on a synced workspace attached to the metastore, make it the **Metastore Admin** instead: in the account console, go to **Catalog**, open the metastore, and set **Metastore Admin** to the service principal's application ID. A metastore with no synced workspace attached is left out either way. diff --git a/docs/docs-info.md b/docs/docs-info.md index 45ee00da..bfd5c604 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -58,7 +58,7 @@ workspace host assigned to it. | Level | Endpoint | |-------|----------| | Metastores | `GET /api/2.0/accounts/{account_id}/metastores` | -| Workspace metastore assignment | `GET /api/2.0/accounts/{account_id}/workspaces/{workspace_id}/metastore` | +| Metastore workspace assignments | `GET /api/2.0/accounts/{account_id}/metastores/{metastore_id}/workspaces` | | Privileges on every level | `GET` and `PATCH` `/api/2.1/unity-catalog/permissions/{securable_type}/{full_name}` | - **HTTP cache keys include the request Host.** Two workspaces share paths such @@ -129,9 +129,16 @@ access. ConductorOne removes it and its grants. Granting or revoking a privilege on such a metastore fails with `FailedPrecondition` and changes nothing in Databricks. An allowlist that matches no - workspace on the account fails the sync. While an attached in-scope workspace - is not `RUNNING`, or a metastore's workspace assignments cannot be read, - nothing is removed: that metastore fails the sync instead. + workspace on the account fails the whole sync, including users, groups, + service principals and workspaces. A name that is also in + `databricks-exclude-workspaces` is reported as excluded; a name Databricks + does not have is reported as not on the account. A workspace that is + `PROVISIONING` or `NOT_PROVISIONED` keeps its metastore, and that metastore's + grants fail until it is `RUNNING`. A workspace that is `FAILED` or `BANNED` + does not: when it is the only one attached, the metastore is left out of the + sync. While a metastore's workspace assignments cannot be read, nothing is + removed: that metastore fails the sync, and the error says the assignment + list could not be read. - **Admin on any attached workspace is enough.** The connector tries each attached workspace in turn and reads through the first one where it holds Admin directly. diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 2b3c7f5f..64e19255 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -260,8 +260,9 @@ func isRateLimitError(err error) bool { } // isUnreadableWorkspaceError reports an answer that says this credential cannot -// read this workspace, not a fact about the account. Only codes.NotFound is a -// warning to the SDK; any other code fails the whole sync. +// read this workspace, not a fact about the account. A 404 is included so the +// caller tries the next workspace: returning it would surface as codes.NotFound, +// and the SDK treats that as a warning and drops the grants. func isUnreadableWorkspaceError(err error) bool { var apiErr *databricks.APIError if !errors.As(err, &apiErr) { @@ -269,7 +270,7 @@ func isUnreadableWorkspaceError(err error) bool { } switch apiErr.StatusCode { - case http.StatusUnauthorized, http.StatusForbidden: + case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: return true default: return false diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index 654fb66d..699e2809 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -30,6 +30,41 @@ func newMetastoreBuilder(client *databricks.Client, uc *unityCatalog, sync syncP return &metastoreBuilder{securableDeps{client: client, uc: uc, sync: sync}} } +// workspaceForProfile is the first attached workspace whose grants are visible. +// A later one wins over routed[0] when that is the only place the connector +// holds Admin. When none pass, routed[0] is still recorded so Grants can say why. +func (m *metastoreBuilder) workspaceForProfile( + ctx context.Context, + snap routingSnapshot, + metastoreID string, + routed []string, +) (string, *v2.RateLimitDescription, error) { + var rateLimit *v2.RateLimitDescription + for _, workspace := range routed { + trustworthy, probeRateLimit, err := m.uc.metastoreGrantsAreTrustworthy(ctx, snap, metastoreID, workspace) + if probeRateLimit != nil { + rateLimit = probeRateLimit + } + if err != nil { + if ctx.Err() != nil || isRateLimitError(err) || !isUnreadableWorkspaceError(err) { + return "", rateLimit, err + } + ctxzap.Extract(ctx).Debug("databricks-connector: could not read a workspace while choosing the metastore profile; trying the next one", + zap.String("metastore", metastoreID), + zap.String("workspace", workspace), + zap.Error(err), + ) + + continue + } + if trustworthy { + return workspace, rateLimit, nil + } + } + + return routed[0], rateLimit, nil +} + func (m *metastoreBuilder) ResourceType(_ context.Context) *v2.ResourceType { return metastoreResourceType } @@ -95,7 +130,15 @@ func (m *metastoreBuilder) List(ctx context.Context, parentResourceID *v2.Resour workspace := "" if routed := snap.workspaces[metastore.MetastoreID]; len(routed) > 0 { - workspace = routed[0] + // Grants reads this workspace back and skips routing when the probe + // passes. The first attached workspace is not always that one. + chosen, probeRateLimit, err := m.workspaceForProfile(ctx, snap, metastore.MetastoreID, routed) + noteRateLimit(&annos, probeRateLimit) + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( + "databricks-connector: failed to choose a workspace for metastore %s: %w", metastore.MetastoreID, err) + } + workspace = chosen } mr, err := metastoreResource(ctx, &metastore, parentResourceID, workspace, snap.workspaceIDs[workspace], snap.workspaceNames[workspace]) if err != nil { @@ -180,10 +223,26 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at assignments, nextPageToken, rateLimit, err := m.client.ListPermissions( ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), "", pageToken) noteRateLimit(&annos, rateLimit) - // A 404 is returned, not read as an absence: the permissions endpoint also answers - // 404 for a workspace that does not serve this metastore, and only the error_code - // the APIError keeps tells that apart from a securable that is gone. + // The permissions endpoint answers 404 both when the metastore is gone and + // when this workspace does not serve it. Only METASTORE_DOES_NOT_EXIST is + // the first. Any other 404 is re-wrapped: uhttp maps HTTP 404 to + // codes.NotFound, and the SDK skips a NotFound grants read, which makes + // C1 delete the bindings. if err != nil { + if databricks.IsSecurableGone(err, databricks.ErrorCodeMetastoreDoesNotExist) { + l.Debug("databricks-connector: metastore is gone; returning no grants", + zap.String("metastore", ref.metastoreID), + ) + + return nil, &rs.SyncOpResults{Annotations: annos}, nil + } + if isNotFoundError(err) { + return nil, &rs.SyncOpResults{Annotations: annos}, uhttp.WrapErrors(codes.FailedPrecondition, + fmt.Sprintf("databricks-connector: permissions on metastore %s returned 404 without %s, so this is not an absence", + ref.metastoreID, databricks.ErrorCodeMetastoreDoesNotExist), + err) + } + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( "databricks-connector: failed to list permissions on %s %s: %w", databricks.SecurableMetastore, ref.permissionsName(), err) } diff --git a/pkg/connector/principal_index.go b/pkg/connector/principal_index.go index 5394303d..1489f28c 100644 --- a/pkg/connector/principal_index.go +++ b/pkg/connector/principal_index.go @@ -236,19 +236,21 @@ func (u *unityCatalog) lookupPrincipalByID( return &v2.ResourceId{ResourceType: userResourceType.Id, Resource: user.ID}, rateLimit, nil } - group, groupRateLimit, err := u.client.GetGroup(ctx, "", id) + // Account GET /Groups/{id} returns members. A filtered list with count 1 + // answers the same question without pulling that list into the cache. + displayName, groupRateLimit, err := u.client.FindGroupDisplayName(ctx, "", id) if groupRateLimit != nil { rateLimit = groupRateLimit } - if err != nil && !isNotFoundError(err) { + if err != nil { return nil, rateLimit, fmt.Errorf("failed to resolve principal %s as a group: %w", id, err) } - if err == nil && group != nil && group.ID != "" { + if displayName != "" { accountResourceId, err := rs.NewResourceID(accountResourceType, u.client.GetAccountId()) if err != nil { return nil, rateLimit, err } - resourceId, err := rs.NewResourceID(groupResourceType, groupResourceId(ctx, group.ID, accountResourceId)) + resourceId, err := rs.NewResourceID(groupResourceType, groupResourceId(ctx, id, accountResourceId)) if err != nil { return nil, rateLimit, err } diff --git a/pkg/connector/unity_catalog.go b/pkg/connector/unity_catalog.go index 4e4cf212..b85ef1d7 100644 --- a/pkg/connector/unity_catalog.go +++ b/pkg/connector/unity_catalog.go @@ -16,7 +16,9 @@ import ( ) const ( - workspaceStatusRunning = "RUNNING" + workspaceStatusRunning = "RUNNING" + workspaceStatusProvisioning = "PROVISIONING" + workspaceStatusNotProvisioned = "NOT_PROVISIONED" ) // routingSnapshot is the account's Unity Catalog access paths for the call that @@ -78,6 +80,50 @@ func newUnityCatalog(client *databricks.Client, ownPrincipal string, workspaces } } +// workspaceIsPending is a status that can still become RUNNING. FAILED and BANNED +// are terminal, so they are not pending: a pending workspace fails the sync. +func workspaceIsPending(status string) bool { + switch strings.ToUpper(status) { + case workspaceStatusProvisioning, workspaceStatusNotProvisioned: + return true + default: + return false + } +} + +// allowlistMiss distinguishes a name Databricks does not have from one the +// exclude list already dropped. Either way the failure is the whole sync, +// because metastore List is what returns it. +func allowlistMiss(u *unityCatalog) error { + var excluded, missing []string + for name := range u.workspaces { + if u.client.IsWorkspaceNameExcluded(name) { + excluded = append(excluded, name) + } else { + missing = append(missing, name) + } + } + slices.Sort(excluded) + slices.Sort(missing) + + switch { + case len(missing) == 0: + return status.Errorf(codes.FailedPrecondition, + "every workspace in the workspaces allowlist (%s) is also named in databricks-exclude-workspaces, "+ + "so no metastore can be scoped to it; this fails the whole sync", + strings.Join(excluded, ", ")) + case len(excluded) == 0: + return status.Errorf(codes.FailedPrecondition, + "none of the workspaces in the workspaces allowlist (%s) matches a workspace on the account by deployment name, name or id, "+ + "so no metastore can be scoped to it; this fails the whole sync", + strings.Join(missing, ", ")) + default: + return status.Errorf(codes.FailedPrecondition, + "the workspaces allowlist matches nothing this sync can use: %s is not on the account, and %s is also named in databricks-exclude-workspaces; this fails the whole sync", + strings.Join(missing, ", "), strings.Join(excluded, ", ")) + } +} + // usableWorkspaces returns workspaces only for an in-scope metastore. One that is // in scope but has no usable workspace yet fails with FailedPrecondition. func usableWorkspaces(snap routingSnapshot, metastoreID string) ([]string, metastoreState, error) { @@ -90,6 +136,11 @@ func usableWorkspaces(snap routingSnapshot, metastoreID string) ([]string, metas if _, ok := snap.metastores[metastoreID]; !ok { return nil, metastoreGone, nil } + if slices.Contains(snap.unreadable, metastoreID) { + return nil, metastoreInScope, status.Errorf(codes.FailedPrecondition, + "metastore %s exists on the account but its workspace assignment list could not be read, so the sync cannot tell which workspaces serve it", + metastoreID) + } pending := "" if waiting := snap.pending[metastoreID]; len(waiting) > 0 { @@ -129,8 +180,9 @@ func (u *unityCatalog) buildRouting(ctx context.Context) (routingSnapshot, *v2.R // A metastore with no usable workspace would fail its Grants with // FailedPrecondition, which fails the whole sync, so it is pruned. An unknown - // answer (unreadable list, non-RUNNING workspace) blocks the prune: dropping it - // would read as a deletion in C1. Pruned is recorded apart from deleted + // answer (unreadable list, or a workspace still provisioning) blocks the prune: + // dropping it would read as a deletion in C1. A FAILED or BANNED workspace + // does not: it will not become RUNNING. Pruned is recorded apart from deleted // because the metastore still holds its privileges. l := ctxzap.Extract(ctx) for metastoreID := range snap.metastores { @@ -175,6 +227,7 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot inScope := map[string]databricks.Workspace{} notRunning := map[string]string{} + unusable := 0 asked := 0 for _, workspace := range workspaces { if err := ctx.Err(); err != nil { @@ -190,7 +243,19 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot } } if workspace.Status != "" && !strings.EqualFold(workspace.Status, workspaceStatusRunning) { - notRunning[accountID] = workspace.DeploymentName + // PROVISIONING and NOT_PROVISIONED can still become RUNNING. + // FAILED, BANNED and anything else do not, so they must not + // fail the sync on every run. + if workspaceIsPending(workspace.Status) { + notRunning[accountID] = fmt.Sprintf("%s (%s)", workspace.DeploymentName, workspace.Status) + } else { + unusable++ + ctxzap.Extract(ctx).Debug("databricks-connector: skipping a workspace that will not become RUNNING", + zap.String("workspace", workspace.DeploymentName), + zap.String("status", workspace.Status), + ) + } + continue } @@ -200,14 +265,8 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot snap.workspaceNames[workspace.DeploymentName] = workspace.Name } - if len(u.workspaces) > 0 && asked == 0 && len(notRunning) == 0 && len(snap.metastores) > 0 { - configured := configuredWorkspaceNames(u.workspaces) - slices.Sort(configured) - - return rateLimit, status.Errorf(codes.FailedPrecondition, - "none of the workspaces in the workspaces allowlist (%s) matches a workspace on the account by deployment name, name or id, "+ - "so no metastore can be scoped to it", - strings.Join(configured, ", ")) + if len(u.workspaces) > 0 && asked == 0 && len(notRunning) == 0 && unusable == 0 && len(snap.metastores) > 0 { + return rateLimit, allowlistMiss(u) } var unreadable []string @@ -226,6 +285,10 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot // the metastore and its grants. A metastore that really is gone stops // appearing in ListMetastores on the next sync. if isUnreadableWorkspaceError(err) || isNotFoundError(err) { + ctxzap.Extract(ctx).Debug("databricks-connector: could not read the workspace assignment list", + zap.String("metastore", metastoreID), + zap.Error(err), + ) unreadable = append(unreadable, metastoreID) continue } diff --git a/pkg/connector/unity_catalog_test.go b/pkg/connector/unity_catalog_test.go index a518891d..a18f2d57 100644 --- a/pkg/connector/unity_catalog_test.go +++ b/pkg/connector/unity_catalog_test.go @@ -321,14 +321,14 @@ func TestANotRunningInScopeWorkspaceStopsTheMetastoreFromBeingPruned(t *testing. t.Fatalf("buildRouting: %v", err) } - if !slices.Equal(snap.pending["ms-b"], []string{"wsb"}) { - t.Fatalf("pending[ms-b] = %v, want [wsb]", snap.pending["ms-b"]) + if !slices.Equal(snap.pending["ms-b"], []string{"wsb (PROVISIONING)"}) { + t.Fatalf("pending[ms-b] = %v, want [wsb (PROVISIONING)]", snap.pending["ms-b"]) } if _, ok := snap.metastores["ms-b"]; !ok { t.Fatal("ms-b was pruned while an in-scope workspace was still PROVISIONING, so C1 would delete it and its grants") } - if _, _, err := usableWorkspaces(snap, "ms-b"); status.Code(err) != codes.FailedPrecondition { - t.Errorf("resolving ms-b = %v, want FailedPrecondition", err) + if _, _, err := usableWorkspaces(snap, "ms-b"); status.Code(err) != codes.FailedPrecondition || !strings.Contains(err.Error(), "PROVISIONING") { + t.Errorf("resolving ms-b = %v, want FailedPrecondition naming PROVISIONING", err) } } @@ -654,3 +654,160 @@ func TestGrantsFallsBackWhenTheProfileWorkspaceCannotBeUsed(t *testing.T) { }) } } + +func TestAFailedWorkspaceDoesNotKeepItsMetastorePending(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandlerWith(t, + map[string]any{"1": "ms-a", "2": "ms-b"}, probed, []map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "FAILED"}, + })) + + uc := newUnityCatalog(client, "app-1", nil) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("buildRouting: %v", err) + } + if _, ok := snap.metastores["ms-b"]; ok { + t.Fatal("ms-b stayed in the snapshot while its only workspace is FAILED, so every sync would fail") + } + if len(snap.pending["ms-b"]) != 0 { + t.Fatalf("pending[ms-b] = %v, want none", snap.pending["ms-b"]) + } + if _, ok := snap.metastores["ms-a"]; !ok { + t.Fatal("ms-a was dropped along with the FAILED workspace's metastore") + } +} + +func TestAnAllowlistThatIsAlsoExcludedNamesTheExcludeList(t *testing.T) { + probed := map[string]int{} + handler := routingHandler(t, map[string]any{"1": "ms-a", "2": "ms-b"}, probed) + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + router := &workspaceRouter{target: strings.TrimPrefix(srv.URL, "http://"), hits: map[string]int{}} + client, err := databricks.NewClient(context.Background(), &http.Client{Transport: router}, + testHostname, testAccountHost, testAccountId, "", &databricks.NoAuth{}, []string{"prod"}) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod"})) + _, _, err = uc.buildRouting(context.Background()) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("buildRouting = %v, want FailedPrecondition", err) + } + if !strings.Contains(err.Error(), "databricks-exclude-workspaces") || strings.Contains(err.Error(), "not on the account") { + t.Errorf("error %q does not say the allowlisted workspace was excluded", err) + } +} + +func TestAnUnreadableAssignmentListNamesThatFailure(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandler(t, + map[string]any{"1": "ms-a", "ms-b": http.StatusForbidden}, probed)) + + uc := newUnityCatalog(client, "app-1", nil) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("buildRouting: %v", err) + } + _, _, err = usableWorkspaces(snap, "ms-b") + if status.Code(err) != codes.FailedPrecondition || !strings.Contains(err.Error(), "assignment list") { + t.Fatalf("resolving ms-b = %v, want FailedPrecondition about the assignment list", err) + } + if strings.Contains(err.Error(), "databricks-exclude-workspaces") { + t.Errorf("error %q sends the operator to check the exclude list", err) + } +} + +func TestAPermissions404WithoutTheGoneCodeIsNotAWarning(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + tests := []struct { + name string + body string + code codes.Code + }{ + {"routing miss", `{"message":"nope"}`, codes.FailedPrecondition}, + {"gone", `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"not found"}`, codes.OK}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/metastores"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "app-1"}}, + }) + case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{1}}) + case strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + }) + case strings.Contains(path, "/unity-catalog/permissions/"): + http.Error(w, test.body, http.StatusNotFound) + default: + http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) + } + }) + m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) + account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + metastore, err := metastoreResource(context.Background(), &databricks.Metastore{MetastoreID: "ms-a", Owner: "app-1"}, account, "", "", "") + if err != nil { + t.Fatalf("metastoreResource: %v", err) + } + + _, _, err = m.Grants(context.Background(), metastore, rs.SyncOpAttrs{}) + if status.Code(err) != test.code { + t.Fatalf("Grants = %v, want %s", err, test.code) + } + }) + } +} + +func TestListRecordsTheWorkspaceWhereTheProbePasses(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/workspaces/1/permissionassignments"): + _ = json.NewEncoder(w).Encode(map[string]any{"permission_assignments": []any{}}) + case strings.HasSuffix(path, "/workspaces/2/permissionassignments"): + _ = json.NewEncoder(w).Encode(map[string]any{"permission_assignments": []map[string]any{{ + "principal": map[string]any{"service_principal_name": "app-1", "principal_id": 7}, + "permissions": []string{"ADMIN"}, + }}}) + case strings.HasSuffix(path, "/metastores"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "someone-else"}}, + }) + case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{1, 2}}) + case strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, + }) + default: + http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) + } + }) + m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) + account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + resources, _, err := m.List(context.Background(), account, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(resources) != 1 { + t.Fatalf("List returned %d resources, want 1", len(resources)) + } + workspace, _ := rs.GetProfileStringValue(rs.GetProfile(resources[0]), profileKeyWorkspace) + if workspace != "wsb" { + t.Errorf("profile workspace = %q, want wsb, the workspace where the connector is Admin", workspace) + } +} diff --git a/pkg/databricks/request.go b/pkg/databricks/request.go index 5c1c6e37..c870bf53 100644 --- a/pkg/databricks/request.go +++ b/pkg/databricks/request.go @@ -52,10 +52,11 @@ func wrapTransportAuthError(err error) error { // A route-level 404 (workspace does not serve this metastore) uses a // different code, so status-only matching cannot tell the two apart. const ( - ErrorCodeCatalogDoesNotExist = "CATALOG_DOES_NOT_EXIST" - ErrorCodeSchemaDoesNotExist = "SCHEMA_DOES_NOT_EXIST" - ErrorCodeTableDoesNotExist = "TABLE_DOES_NOT_EXIST" - ErrorCodeVolumeDoesNotExist = "VOLUME_DOES_NOT_EXIST" + ErrorCodeMetastoreDoesNotExist = "METASTORE_DOES_NOT_EXIST" + ErrorCodeCatalogDoesNotExist = "CATALOG_DOES_NOT_EXIST" + ErrorCodeSchemaDoesNotExist = "SCHEMA_DOES_NOT_EXIST" + ErrorCodeTableDoesNotExist = "TABLE_DOES_NOT_EXIST" + ErrorCodeVolumeDoesNotExist = "VOLUME_DOES_NOT_EXIST" ) // APIError represents an error response from the Databricks API. diff --git a/pkg/databricks/unity_catalog.go b/pkg/databricks/unity_catalog.go index 58db1946..b233fa32 100644 --- a/pkg/databricks/unity_catalog.go +++ b/pkg/databricks/unity_catalog.go @@ -7,6 +7,8 @@ import ( "strings" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" ) // A metastore lives on the account plane; every securable below it is only @@ -303,8 +305,16 @@ func (c *Client) updatePermissions( // The provisioning pre-read stored page one under the key the sync reads, // and a PATCH does not invalidate it. Re-reading past the cache overwrites // that entry with the post-change state. The write already landed, so a - // failed refresh must not fail it. - if _, _, refreshRateLimit, refreshErr := c.listPermissions(ctx, workspaceId, securableType, fullName, "", "", true); refreshErr == nil && refreshRateLimit != nil { + // failed refresh must not fail it. A read that stops before the last page + // leaves those later pages until the next cycle; max_results 0 makes that rare. + _, _, refreshRateLimit, refreshErr := c.listPermissions(ctx, workspaceId, securableType, fullName, "", "", true) + if refreshErr != nil { + ctxzap.Extract(ctx).Debug("could not refresh the permissions cache after the write", + zap.String("securable_type", securableType), + zap.String("full_name", fullName), + zap.Error(refreshErr), + ) + } else if refreshRateLimit != nil { ratelimitData = refreshRateLimit } From 4aa99d97893eb4f745f70d60ea7c934984438240 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Tue, 20 Oct 2026 13:02:07 -0300 Subject: [PATCH 15/36] fix(databricks): confirm metastore absence against the account and skip a failed profile probe Co-authored-by: Cursor --- pkg/connector/metastore.go | 37 +++++++--- pkg/connector/unity_catalog_test.go | 110 +++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 11 deletions(-) diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index 699e2809..5e98cded 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -32,7 +32,10 @@ func newMetastoreBuilder(client *databricks.Client, uc *unityCatalog, sync syncP // workspaceForProfile is the first attached workspace whose grants are visible. // A later one wins over routed[0] when that is the only place the connector -// holds Admin. When none pass, routed[0] is still recorded so Grants can say why. +// holds Admin. A probe error other than a rate limit or a cancelled context is +// skipped: failing List here fails the whole sync, and Grants re-routes when +// the recorded workspace does not answer. When none pass, routed[0] is still +// recorded so Grants can say why. func (m *metastoreBuilder) workspaceForProfile( ctx context.Context, snap routingSnapshot, @@ -46,7 +49,7 @@ func (m *metastoreBuilder) workspaceForProfile( rateLimit = probeRateLimit } if err != nil { - if ctx.Err() != nil || isRateLimitError(err) || !isUnreadableWorkspaceError(err) { + if ctx.Err() != nil || isRateLimitError(err) { return "", rateLimit, err } ctxzap.Extract(ctx).Debug("databricks-connector: could not read a workspace while choosing the metastore profile; trying the next one", @@ -224,17 +227,31 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), "", pageToken) noteRateLimit(&annos, rateLimit) // The permissions endpoint answers 404 both when the metastore is gone and - // when this workspace does not serve it. Only METASTORE_DOES_NOT_EXIST is - // the first. Any other 404 is re-wrapped: uhttp maps HTTP 404 to - // codes.NotFound, and the SDK skips a NotFound grants read, which makes - // C1 delete the bindings. + // when this workspace does not serve it, and Databricks uses + // METASTORE_DOES_NOT_EXIST for both ("No metastore assigned for the current + // workspace"). The account list is what separates them. Any other 404 is + // re-wrapped: uhttp maps HTTP 404 to codes.NotFound, and the SDK skips a + // NotFound grants read, which makes C1 delete the bindings. if err != nil { if databricks.IsSecurableGone(err, databricks.ErrorCodeMetastoreDoesNotExist) { - l.Debug("databricks-connector: metastore is gone; returning no grants", - zap.String("metastore", ref.metastoreID), - ) + _, confirmed, confirmRateLimit, confirmErr := m.accessPath(ctx, resource, ref, true) + noteRateLimit(&annos, confirmRateLimit) + if confirmErr != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( + "databricks-connector: failed to confirm whether metastore %s is still listed: %w", ref.metastoreID, confirmErr) + } + if confirmed == metastoreGone { + l.Debug("databricks-connector: metastore is gone; returning no grants", + zap.String("metastore", ref.metastoreID), + ) - return nil, &rs.SyncOpResults{Annotations: annos}, nil + return nil, &rs.SyncOpResults{Annotations: annos}, nil + } + + return nil, &rs.SyncOpResults{Annotations: annos}, uhttp.WrapErrors(codes.FailedPrecondition, + fmt.Sprintf("databricks-connector: permissions on metastore %s returned %s, but the account still lists the metastore, so this is not an absence", + ref.metastoreID, databricks.ErrorCodeMetastoreDoesNotExist), + err) } if isNotFoundError(err) { return nil, &rs.SyncOpResults{Annotations: annos}, uhttp.WrapErrors(codes.FailedPrecondition, diff --git a/pkg/connector/unity_catalog_test.go b/pkg/connector/unity_catalog_test.go index a18f2d57..d60eeace 100644 --- a/pkg/connector/unity_catalog_test.go +++ b/pkg/connector/unity_catalog_test.go @@ -729,7 +729,8 @@ func TestAPermissions404WithoutTheGoneCodeIsNotAWarning(t *testing.T) { code codes.Code }{ {"routing miss", `{"message":"nope"}`, codes.FailedPrecondition}, - {"gone", `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"not found"}`, codes.OK}, + // The account still lists the metastore, so the error code alone is not absence. + {"still listed", `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"No metastore assigned for the current workspace"}`, codes.FailedPrecondition}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -811,3 +812,110 @@ func TestListRecordsTheWorkspaceWhereTheProbePasses(t *testing.T) { t.Errorf("profile workspace = %q, want wsb, the workspace where the connector is Admin", workspace) } } + +func TestMetastoreDoesNotExistIsAbsenceOnlyWhenTheAccountDropsIt(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + tests := []struct { + name string + listed bool + want codes.Code + }{ + {"account still lists it", true, codes.FailedPrecondition}, + {"account dropped it", false, codes.OK}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/metastores"): + metastores := []map[string]any{} + if test.listed { + metastores = append(metastores, map[string]any{"metastore_id": "ms-a", "name": "main", "owner": "app-1"}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"metastores": metastores}) + case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{1}}) + case strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + }) + case strings.Contains(path, "/unity-catalog/permissions/"): + http.Error(w, `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"No metastore assigned for the current workspace"}`, http.StatusNotFound) + default: + http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) + } + }) + m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) + account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + metastore, err := metastoreResource(context.Background(), &databricks.Metastore{MetastoreID: "ms-a", Owner: "app-1"}, account, "wsa", "1", "prod") + if err != nil { + t.Fatalf("metastoreResource: %v", err) + } + + _, _, err = m.Grants(context.Background(), metastore, rs.SyncOpAttrs{}) + if status.Code(err) != test.want { + t.Fatalf("Grants = %v, want %s", err, test.want) + } + }) + } +} + +func TestListSkipsAProfileProbeThatIsNotARefusal(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + tests := []struct { + name string + firstCode int + want string + }{ + {"one workspace returns 500", http.StatusInternalServerError, "wsb"}, + {"every probe fails", http.StatusInternalServerError, "wsa"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/workspaces/1/permissionassignments"): + http.Error(w, `{"message":"unavailable"}`, test.firstCode) + case strings.HasSuffix(path, "/workspaces/2/permissionassignments"): + if test.want == "wsa" { + http.Error(w, `{"message":"unavailable"}`, http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"permission_assignments": []map[string]any{{ + "principal": map[string]any{"service_principal_name": "app-1", "principal_id": 7}, + "permissions": []string{"ADMIN"}, + }}}) + case strings.HasSuffix(path, "/metastores"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "someone-else"}}, + }) + case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{1, 2}}) + case strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, + }) + default: + http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) + } + }) + m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) + account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + resources, _, err := m.List(context.Background(), account, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("List: %v", err) + } + workspace, _ := rs.GetProfileStringValue(rs.GetProfile(resources[0]), profileKeyWorkspace) + if workspace != test.want { + t.Errorf("profile workspace = %q, want %q", workspace, test.want) + } + }) + } +} From df1bb1a2e7dc94ca353d95a601c2ddbcb984531f Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Tue, 20 Oct 2026 13:43:08 -0300 Subject: [PATCH 16/36] fix(databricks): keep a cancelling workspace from pruning its metastore Co-authored-by: Cursor --- docs/docs-info.md | 2 +- pkg/connector/unity_catalog.go | 18 +++++++++++------- pkg/connector/unity_catalog_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/docs-info.md b/docs/docs-info.md index bfd5c604..fabc1a20 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -133,7 +133,7 @@ access. service principals and workspaces. A name that is also in `databricks-exclude-workspaces` is reported as excluded; a name Databricks does not have is reported as not on the account. A workspace that is - `PROVISIONING` or `NOT_PROVISIONED` keeps its metastore, and that metastore's + `PROVISIONING`, `NOT_PROVISIONED` or `CANCELLING` keeps its metastore, and that metastore's grants fail until it is `RUNNING`. A workspace that is `FAILED` or `BANNED` does not: when it is the only one attached, the metastore is left out of the sync. While a metastore's workspace assignments cannot be read, nothing is diff --git a/pkg/connector/unity_catalog.go b/pkg/connector/unity_catalog.go index b85ef1d7..f048dfe2 100644 --- a/pkg/connector/unity_catalog.go +++ b/pkg/connector/unity_catalog.go @@ -19,6 +19,7 @@ const ( workspaceStatusRunning = "RUNNING" workspaceStatusProvisioning = "PROVISIONING" workspaceStatusNotProvisioned = "NOT_PROVISIONED" + workspaceStatusCancelling = "CANCELLING" ) // routingSnapshot is the account's Unity Catalog access paths for the call that @@ -80,11 +81,14 @@ func newUnityCatalog(client *databricks.Client, ownPrincipal string, workspaces } } -// workspaceIsPending is a status that can still become RUNNING. FAILED and BANNED -// are terminal, so they are not pending: a pending workspace fails the sync. +// workspaceIsPending is a status that can still change. PROVISIONING and +// NOT_PROVISIONED can become RUNNING. CANCELLING is in process, so pruning +// during it would delete the metastore and its grants. FAILED and BANNED are +// the terminal set. A status Databricks has not listed takes that path too: +// treating it as pending would fail the sync on every run. func workspaceIsPending(status string) bool { switch strings.ToUpper(status) { - case workspaceStatusProvisioning, workspaceStatusNotProvisioned: + case workspaceStatusProvisioning, workspaceStatusNotProvisioned, workspaceStatusCancelling: return true default: return false @@ -180,7 +184,7 @@ func (u *unityCatalog) buildRouting(ctx context.Context) (routingSnapshot, *v2.R // A metastore with no usable workspace would fail its Grants with // FailedPrecondition, which fails the whole sync, so it is pruned. An unknown - // answer (unreadable list, or a workspace still provisioning) blocks the prune: + // answer (unreadable list, or a workspace still provisioning or cancelling) blocks the prune: // dropping it would read as a deletion in C1. A FAILED or BANNED workspace // does not: it will not become RUNNING. Pruned is recorded apart from deleted // because the metastore still holds its privileges. @@ -243,9 +247,9 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot } } if workspace.Status != "" && !strings.EqualFold(workspace.Status, workspaceStatusRunning) { - // PROVISIONING and NOT_PROVISIONED can still become RUNNING. - // FAILED, BANNED and anything else do not, so they must not - // fail the sync on every run. + // PROVISIONING, NOT_PROVISIONED and CANCELLING can still change. + // FAILED, BANNED and a status Databricks has not listed do not, + // so they must not fail the sync on every run. if workspaceIsPending(workspace.Status) { notRunning[accountID] = fmt.Sprintf("%s (%s)", workspace.DeploymentName, workspace.Status) } else { diff --git a/pkg/connector/unity_catalog_test.go b/pkg/connector/unity_catalog_test.go index d60eeace..e1f041c6 100644 --- a/pkg/connector/unity_catalog_test.go +++ b/pkg/connector/unity_catalog_test.go @@ -332,6 +332,33 @@ func TestANotRunningInScopeWorkspaceStopsTheMetastoreFromBeingPruned(t *testing. } } +// CANCELLING is in the documented workspace status set and means the cancellation +// is still running. Pruning it would delete the metastore before that finishes. +func TestACancellingWorkspaceStopsTheMetastoreFromBeingPruned(t *testing.T) { + probed := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, routingHandlerWith(t, + map[string]any{"1": "ms-a", "2": "ms-b"}, probed, []map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "CANCELLING"}, + })) + + uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod", "dev"})) + snap, _, err := uc.buildRouting(context.Background()) + if err != nil { + t.Fatalf("buildRouting: %v", err) + } + + if !slices.Equal(snap.pending["ms-b"], []string{"wsb (CANCELLING)"}) { + t.Fatalf("pending[ms-b] = %v, want [wsb (CANCELLING)]", snap.pending["ms-b"]) + } + if _, ok := snap.metastores["ms-b"]; !ok { + t.Fatal("ms-b was pruned while its only in-scope workspace was CANCELLING, so C1 would delete it and its grants") + } + if _, _, err := usableWorkspaces(snap, "ms-b"); status.Code(err) != codes.FailedPrecondition || !strings.Contains(err.Error(), "CANCELLING") { + t.Errorf("resolving ms-b = %v, want FailedPrecondition naming CANCELLING", err) + } +} + // An allowlist that names no workspace on the account is a configuration error, // not an account with no metastores: pruning would delete every metastore in C1. func TestAnAllowlistMatchingNoWorkspaceFailsInsteadOfPruningEverything(t *testing.T) { From 0286ed6e5c0a598bd96dc3f224010d6958b1159f Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Wed, 21 Oct 2026 12:09:04 -0300 Subject: [PATCH 17/36] fix(databricks): rebuild the metastore route only on the first page and remove the duplicate workspace probe Co-authored-by: Cursor --- README.md | 70 +++++++- docs/connector.mdx | 6 +- docs/docs-info.md | 87 ++-------- pkg/connector/metastore.go | 119 +++++-------- pkg/connector/unity_catalog.go | 60 +++---- pkg/connector/unity_catalog_test.go | 252 +++++++++++++++++++--------- pkg/databricks/request.go | 8 +- 7 files changed, 326 insertions(+), 276 deletions(-) diff --git a/README.md b/README.md index f66602e7..38d3767b 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,7 @@ deployment name, or numeric workspace ID, matched case-insensitively. listed workspace is synced, so narrowing the list removes the other metastores and their grants from ConductorOne. A list that matches no workspace on the account fails the whole sync, including users, groups, service principals and -workspaces. A name that is also in `databricks-exclude-workspaces` is reported -as excluded. +workspaces, once the `metastore` resource type is enabled. Metastores are Unity Catalog securables and are opt-in: the type stays off until it is enabled for the connector in ConductorOne. @@ -156,8 +155,33 @@ members as holders. `MANAGE` and `ALL_PRIVILEGES` are not offered: Databricks rejects both at the metastore level. -The endpoints, the permission model, principal resolution and the connector's -limitations are in [docs/docs-info.md](./docs/docs-info.md). +A metastore lives on the account plane, but its privileges are read through a +workspace host assigned to it. + +| Level | Endpoint | +|-------|----------| +| Metastores | `GET /api/2.0/accounts/{account_id}/metastores` | +| Metastore workspace assignments | `GET /api/2.0/accounts/{account_id}/metastores/{metastore_id}/workspaces` | +| Privileges on every level | `GET` and `PATCH` `/api/2.1/unity-catalog/permissions/{securable_type}/{full_name}` | + +HTTP cache keys include the request Host. Two workspaces share paths such as +`/unity-catalog/permissions/...`, and without the host in the key one +workspace's response would be served for another. + +A metastore is synced only while a workspace this sync can use is attached to +it. The connector never guesses when the answer is unknown: + +| Attached workspace | Result | +|--------------------|--------| +| `RUNNING` and in scope | the metastore syncs through it | +| `PROVISIONING`, `NOT_PROVISIONED`, `CANCELLING` | still changing, so the metastore is kept and its grants fail with `FailedPrecondition` until the state settles | +| `FAILED`, `BANNED`, or a status Databricks does not document | will not become `RUNNING`, so it is skipped; a metastore left with no other workspace is not synced | +| assignment list unreadable (401, 403, 404) | unknown, so the metastore is kept and the sync fails naming the assignment list | + +A metastore is left out of the sync only when the answer is known: every +attached workspace is out of scope or unusable. Leaving it out is what makes +ConductorOne remove it and its grants, so an unknown answer must not take that +path. ## Unity Catalog provisioning @@ -167,11 +191,39 @@ fails with an `InvalidArgument` error. ## Unity Catalog permissions -The same OAuth service principal must own the metastore or be assigned -**Admin** directly on a workspace attached to it (Admin inherited only through -a group is not enough). When neither holds, the sync fails with a -`PermissionDenied` error naming the metastore rather than reporting a partial -read. Details are in [docs/docs-info.md](./docs/docs-info.md). +Three independent layers have to line up, and granting one does not imply the +others: + +1. **OAuth scopes** decide which API surfaces the credential may touch at all. +2. **The service principal's roles** decide what it sees on those surfaces. +3. **Unity Catalog privileges** apply on the securable itself. + +The scopes: + +| Scope | Covers | +|-------|--------| +| `unity-catalog` | Metastores, metastore workspace assignments, and the permissions endpoint | +| `scim` | Account users, groups and service principals | +| `access-management` | The rule-sets API, where roles live, plus the workspace permission assignments | +| `provisioning` | The account workspaces listing. It is the only scope that grants it — including not the one named `workspace` | + +The connector requests `all-apis`, which covers all four. Each resource type +declares its own scopes, roles and privileges, so `./baton-databricks +capabilities` reports them per type. + +A privilege read is only trustworthy when the service principal owns the +metastore, or is assigned **Admin** directly on a workspace the metastore is +assigned to (Admin inherited only through a group is not enough). Holding a +privilege is not an option at this level: Databricks rejects `MANAGE` and +`ALL_PRIVILEGES` on a metastore, so there is no privilege whose presence would +prove the read is complete. + +When neither holds, the sync fails with a `PermissionDenied` error naming the +metastore rather than reporting a partial read. Databricks answers an +under-permissioned privilege read with HTTP 200 and a partial list of privilege +assignments, which is byte-indistinguishable from an object that genuinely has +no grants on it. Reporting that partial list would make ConductorOne read every +privilege the principal cannot see as revoked access. # Contributing, Support and Issues diff --git a/docs/connector.mdx b/docs/connector.mdx index db669e8d..f2a20299 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -60,7 +60,7 @@ Configuring the connector requires you to pass in credentials generated in Datab A user with the **Account admin** role in each Databricks workspace you want to sync must perform this task. -Every step below happens in the Databricks **account console**. On AWS that is [https://accounts.cloud.databricks.com](https://accounts.cloud.databricks.com). Azure Databricks and Google Cloud Platform customers use their own account console host. When they configure the connector they only need the workspace **Hostname**; Account Hostname is optional. +Every step below happens in the Databricks **account console**. On AWS that is [https://accounts.cloud.databricks.com](https://accounts.cloud.databricks.com). Azure Databricks and Google Cloud Platform customers use their own account console host. When they configure the connector they only need their Databricks domain in **Hostname**; Account Hostname is optional. ### Look up your Databricks account ID @@ -175,9 +175,9 @@ To complete this task, you'll need: Fill in **Workspaces** or **Exclude Workspaces**, not both. - **Google Cloud Platform and Azure Databricks customers only:** Enter your Databricks workspace hostname in the **Hostname** field. Account Hostname is optional: the connector derives it from Hostname when unset. + **Google Cloud Platform and Azure Databricks customers only:** Enter your Databricks domain in the **Hostname** field, for example `gcp.databricks.com` or `azuredatabricks.net`. This is the domain your workspace URLs end with, not the URL of one workspace. Account Hostname is optional: the connector derives it from Hostname when unset. - For more on how to look up these values for GCP, see the GCP Databricks [REST API reference](https://docs.databricks.com/api/gcp/account/introduction) and [identifiers for workspace objects](https://docs.gcp.databricks.com/en/workspace/workspace-details.html) documentation. - - For more on how to look up these values for Azure Databricks, see the the Azure Databricks [REST API reference](https://docs.databricks.com/api/azure/account/introduction) and [identifiers for workspace objects](https://learn.microsoft.com/en-us/azure/databricks/workspace/workspace-details) documentation. + - For more on how to look up these values for Azure Databricks, see the Azure Databricks [REST API reference](https://docs.databricks.com/api/azure/account/introduction) and [identifiers for workspace objects](https://learn.microsoft.com/en-us/azure/databricks/workspace/workspace-details) documentation. diff --git a/docs/docs-info.md b/docs/docs-info.md index fabc1a20..70b85352 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -14,7 +14,7 @@ While developing the connector, please fill out this form. This information is n > > - **User accounts**: create and delete account users. > - **Entitlements**: grant and revoke role and membership assignments on accounts, workspaces, groups, service principals, and roles. - > - **Unity Catalog privileges**: grant and revoke a metastore privilege for a user, group or service principal. The `owner` entitlement is read-only; granting or revoking it returns `InvalidArgument`. + > - **Unity Catalog privileges**: grant and revoke a metastore privilege for a user, group or service principal. The `owner` entitlement is read-only and cannot be granted or revoked. ## Connector credentials @@ -24,7 +24,7 @@ While developing the connector, please fill out this form. This information is n > > - **OAuth**: a service principal OAuth client ID and client secret. Syncs the account and every workspace the service principal can access. This is the connector's only authentication method. > - > Google Cloud Platform and Azure Databricks customers also provide the workspace hostname. Account Hostname is optional: the connector derives it from Hostname when unset. + > Google Cloud Platform and Azure Databricks customers also provide their Databricks domain, such as `gcp.databricks.com` or `azuredatabricks.net`. Account Hostname is optional: the connector derives it from Hostname when unset. 2. For each item in the list above: @@ -50,61 +50,6 @@ While developing the connector, please fill out this form. This information is n > Account admin access to the Databricks account console (to create the service principal and OAuth secret), and workspace admin on each workspace being synced. -## Unity Catalog API - -A metastore lives on the account plane, but its privileges are read through a -workspace host assigned to it. - -| Level | Endpoint | -|-------|----------| -| Metastores | `GET /api/2.0/accounts/{account_id}/metastores` | -| Metastore workspace assignments | `GET /api/2.0/accounts/{account_id}/metastores/{metastore_id}/workspaces` | -| Privileges on every level | `GET` and `PATCH` `/api/2.1/unity-catalog/permissions/{securable_type}/{full_name}` | - -- **HTTP cache keys include the request Host.** Two workspaces share paths such - as `/unity-catalog/permissions/...`, and without the host in the key one - workspace's response would be served for another. - -## Permission model - -Three independent layers have to line up, and granting one does not imply the -others: - -1. **OAuth scopes** decide which API surfaces the credential may touch at all. -2. **The service principal's roles** decide what it sees on those surfaces. -3. **Unity Catalog privileges** apply on the securable itself. - -The scopes: - -| Scope | Covers | -|-------|--------| -| `unity-catalog` | Metastores, metastore workspace assignments, and the permissions endpoint | -| `scim` | Account users, groups and service principals | -| `access-management` | The rule-sets API, where roles live, plus the workspace permission assignments | -| `provisioning` | The account workspaces listing. It is the only scope that grants it — including not the one named `workspace` | - -The connector requests `all-apis`, which covers all four. Each resource type -declares its own scopes, roles and privileges, so `./baton-databricks -capabilities` reports them per type. - -A privilege read is only trustworthy when the service principal satisfies at -least one of these per metastore: - -- it is the owner of the metastore, or -- it is assigned Admin directly on a workspace the metastore is assigned to - (Admin inherited only through a group is not enough). - -Holding a privilege is not an option at this level: Databricks rejects `MANAGE` -and `ALL_PRIVILEGES` on a metastore, so there is no privilege whose presence -would prove the read is complete. - -When neither holds, the connector fails the sync rather than reporting what -it can see. Databricks answers an under-permissioned privilege read with HTTP -200 and a partial list of privilege assignments, which is byte-indistinguishable -from an object that genuinely has no grants on it. Reporting that partial list -would make ConductorOne read every privilege the principal cannot see as revoked -access. - ## Limitations - **Direct grants only.** Unity Catalog privileges inherit downward — @@ -122,23 +67,19 @@ access. resolve, and no owner grant is emitted for it. - **`MANAGE` and `ALL_PRIVILEGES` do not exist at the metastore level** — Databricks rejects them there, so neither is offered as an entitlement. -- **A metastore needs a usable workspace.** Its privileges are not served on the - account plane, only through a `RUNNING` workspace attached to it. A metastore - with no workspace attached, or whose attached workspaces are all left out by - `workspaces` or `databricks-exclude-workspaces`, is not synced, and +- **A metastore needs a running workspace.** Its privileges are not served on + the account plane, only through a running workspace attached to it. A + metastore that no running workspace in the sync reaches is not synced, and ConductorOne removes it and its grants. Granting or revoking a privilege on - such a metastore fails with `FailedPrecondition` and changes nothing in - Databricks. An allowlist that matches no - workspace on the account fails the whole sync, including users, groups, - service principals and workspaces. A name that is also in - `databricks-exclude-workspaces` is reported as excluded; a name Databricks - does not have is reported as not on the account. A workspace that is - `PROVISIONING`, `NOT_PROVISIONED` or `CANCELLING` keeps its metastore, and that metastore's - grants fail until it is `RUNNING`. A workspace that is `FAILED` or `BANNED` - does not: when it is the only one attached, the metastore is left out of the - sync. While a metastore's workspace assignments cannot be read, nothing is - removed: that metastore fails the sync, and the error says the assignment - list could not be read. + such a metastore fails and changes nothing in Databricks. +- **Narrowing `workspaces` narrows the metastores.** Only a metastore attached + to a listed workspace is synced. When the Metastores resource type is enabled, + a list that matches no workspace on the account fails the whole sync, + including users, groups, service principals and workspaces. +- **The workspace shown on a metastore is an access path, not an authority.** + It names a workspace the metastore is assigned to. The connector confirms its + access before every read and uses another attached workspace when that one + does not serve the metastore, so the grants may have been read elsewhere. - **Admin on any attached workspace is enough.** The connector tries each attached workspace in turn and reads through the first one where it holds Admin directly. diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index 5e98cded..ca3d86da 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -30,44 +30,6 @@ func newMetastoreBuilder(client *databricks.Client, uc *unityCatalog, sync syncP return &metastoreBuilder{securableDeps{client: client, uc: uc, sync: sync}} } -// workspaceForProfile is the first attached workspace whose grants are visible. -// A later one wins over routed[0] when that is the only place the connector -// holds Admin. A probe error other than a rate limit or a cancelled context is -// skipped: failing List here fails the whole sync, and Grants re-routes when -// the recorded workspace does not answer. When none pass, routed[0] is still -// recorded so Grants can say why. -func (m *metastoreBuilder) workspaceForProfile( - ctx context.Context, - snap routingSnapshot, - metastoreID string, - routed []string, -) (string, *v2.RateLimitDescription, error) { - var rateLimit *v2.RateLimitDescription - for _, workspace := range routed { - trustworthy, probeRateLimit, err := m.uc.metastoreGrantsAreTrustworthy(ctx, snap, metastoreID, workspace) - if probeRateLimit != nil { - rateLimit = probeRateLimit - } - if err != nil { - if ctx.Err() != nil || isRateLimitError(err) { - return "", rateLimit, err - } - ctxzap.Extract(ctx).Debug("databricks-connector: could not read a workspace while choosing the metastore profile; trying the next one", - zap.String("metastore", metastoreID), - zap.String("workspace", workspace), - zap.Error(err), - ) - - continue - } - if trustworthy { - return workspace, rateLimit, nil - } - } - - return routed[0], rateLimit, nil -} - func (m *metastoreBuilder) ResourceType(_ context.Context) *v2.ResourceType { return metastoreResourceType } @@ -127,21 +89,12 @@ func (m *metastoreBuilder) List(ctx context.Context, parentResourceID *v2.Resour var rv []*v2.Resource for _, metastore := range snap.metastores { - if metastore.MetastoreID == "" { - continue - } - + // The workspace on the profile is a hint, not a decision: Grants probes it + // and rebuilds the route when it does not serve the metastore. List does + // not probe here, because one workspace refusing would fail the whole sync. workspace := "" if routed := snap.workspaces[metastore.MetastoreID]; len(routed) > 0 { - // Grants reads this workspace back and skips routing when the probe - // passes. The first attached workspace is not always that one. - chosen, probeRateLimit, err := m.workspaceForProfile(ctx, snap, metastore.MetastoreID, routed) - noteRateLimit(&annos, probeRateLimit) - if err != nil { - return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( - "databricks-connector: failed to choose a workspace for metastore %s: %w", metastore.MetastoreID, err) - } - workspace = chosen + workspace = routed[0] } mr, err := metastoreResource(ctx, &metastore, parentResourceID, workspace, snap.workspaceIDs[workspace], snap.workspaceNames[workspace]) if err != nil { @@ -226,37 +179,51 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at assignments, nextPageToken, rateLimit, err := m.client.ListPermissions( ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), "", pageToken) noteRateLimit(&annos, rateLimit) - // The permissions endpoint answers 404 both when the metastore is gone and - // when this workspace does not serve it, and Databricks uses - // METASTORE_DOES_NOT_EXIST for both ("No metastore assigned for the current - // workspace"). The account list is what separates them. Any other 404 is - // re-wrapped: uhttp maps HTTP 404 to codes.NotFound, and the SDK skips a - // NotFound grants read, which makes C1 delete the bindings. - if err != nil { - if databricks.IsSecurableGone(err, databricks.ErrorCodeMetastoreDoesNotExist) { - _, confirmed, confirmRateLimit, confirmErr := m.accessPath(ctx, resource, ref, true) - noteRateLimit(&annos, confirmRateLimit) - if confirmErr != nil { - return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( - "databricks-connector: failed to confirm whether metastore %s is still listed: %w", ref.metastoreID, confirmErr) + // Databricks answers METASTORE_DOES_NOT_EXIST both when the metastore is gone + // and when the workspace read through does not serve it ("No metastore + // assigned for the current workspace"), so only the account list separates + // them. The route is rebuilt before the read is called an absence. + // + // Only on the first page. A page token is opaque and minted by one workspace's + // permissions endpoint, so it cannot be replayed on another, and ending the + // pages early would report the grants already emitted as the complete set. + // Mid-pagination the read fails instead, and the next sync starts this + // metastore's grants from the first page. + if err != nil && pageToken == "" && databricks.IsSecurableGone(err, databricks.ErrorCodeMetastoreDoesNotExist) { + routed, routedState, confirmRateLimit, confirmErr := m.accessPath(ctx, resource, ref, true) + noteRateLimit(&annos, confirmRateLimit) + if confirmErr != nil { + if status.Code(confirmErr) == codes.PermissionDenied { + return nil, &rs.SyncOpResults{Annotations: annos}, confirmErr } - if confirmed == metastoreGone { - l.Debug("databricks-connector: metastore is gone; returning no grants", - zap.String("metastore", ref.metastoreID), - ) - return nil, &rs.SyncOpResults{Annotations: annos}, nil - } + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( + "databricks-connector: failed to confirm whether metastore %s is still listed: %w", ref.metastoreID, confirmErr) + } + switch { + case routedState != metastoreInScope: + // Deleted from the account, or left with no workspace this sync can use. + // Both answer the way the access path above answers them. + l.Debug("databricks-connector: metastore is no longer readable through this sync; returning no grants", + zap.String("metastore", ref.metastoreID), + ) - return nil, &rs.SyncOpResults{Annotations: annos}, uhttp.WrapErrors(codes.FailedPrecondition, - fmt.Sprintf("databricks-connector: permissions on metastore %s returned %s, but the account still lists the metastore, so this is not an absence", - ref.metastoreID, databricks.ErrorCodeMetastoreDoesNotExist), - err) + return nil, &rs.SyncOpResults{Annotations: annos}, nil + case routed != workspace: + // The metastore was reassigned after List wrote the profile workspace. + assignments, nextPageToken, rateLimit, err = m.client.ListPermissions( + ctx, routed, databricks.SecurableMetastore, ref.permissionsName(), "", pageToken) + noteRateLimit(&annos, rateLimit) } + } + // uhttp maps HTTP 404 to codes.NotFound, and the SDK skips a NotFound grants + // read, which makes C1 delete the bindings. The account still lists this + // metastore, so the read is a failed precondition rather than an absence. + if err != nil { if isNotFoundError(err) { return nil, &rs.SyncOpResults{Annotations: annos}, uhttp.WrapErrors(codes.FailedPrecondition, - fmt.Sprintf("databricks-connector: permissions on metastore %s returned 404 without %s, so this is not an absence", - ref.metastoreID, databricks.ErrorCodeMetastoreDoesNotExist), + fmt.Sprintf("databricks-connector: permissions on metastore %s returned 404 while the account still lists it, so this is not an absence", + ref.metastoreID), err) } diff --git a/pkg/connector/unity_catalog.go b/pkg/connector/unity_catalog.go index f048dfe2..fe3f3dac 100644 --- a/pkg/connector/unity_catalog.go +++ b/pkg/connector/unity_catalog.go @@ -29,7 +29,6 @@ type routingSnapshot struct { workspaces map[string][]string workspaceIDs map[string]string workspaceNames map[string]string - asked int // unreadable names the metastores whose workspace assignment list answered // 401, 403 or 404, so which workspaces serve them is unknown rather than none. @@ -95,37 +94,21 @@ func workspaceIsPending(status string) bool { } } -// allowlistMiss distinguishes a name Databricks does not have from one the -// exclude list already dropped. Either way the failure is the whole sync, -// because metastore List is what returns it. +// allowlistMiss names an allowlist that matches no workspace on the account. +// The failure is the whole sync, because metastore List is what returns it. +// The allowlist cannot hold an excluded name: the two fields are declared +// mutually exclusive in pkg/config. func allowlistMiss(u *unityCatalog) error { - var excluded, missing []string + missing := make([]string, 0, len(u.workspaces)) for name := range u.workspaces { - if u.client.IsWorkspaceNameExcluded(name) { - excluded = append(excluded, name) - } else { - missing = append(missing, name) - } + missing = append(missing, name) } - slices.Sort(excluded) slices.Sort(missing) - switch { - case len(missing) == 0: - return status.Errorf(codes.FailedPrecondition, - "every workspace in the workspaces allowlist (%s) is also named in databricks-exclude-workspaces, "+ - "so no metastore can be scoped to it; this fails the whole sync", - strings.Join(excluded, ", ")) - case len(excluded) == 0: - return status.Errorf(codes.FailedPrecondition, - "none of the workspaces in the workspaces allowlist (%s) matches a workspace on the account by deployment name, name or id, "+ - "so no metastore can be scoped to it; this fails the whole sync", - strings.Join(missing, ", ")) - default: - return status.Errorf(codes.FailedPrecondition, - "the workspaces allowlist matches nothing this sync can use: %s is not on the account, and %s is also named in databricks-exclude-workspaces; this fails the whole sync", - strings.Join(missing, ", "), strings.Join(excluded, ", ")) - } + return status.Errorf(codes.FailedPrecondition, + "none of the workspaces in the workspaces allowlist (%s) matches a workspace on the account by deployment name, name or id, "+ + "so no metastore can be scoped to it; this fails the whole sync", + strings.Join(missing, ", ")) } // usableWorkspaces returns workspaces only for an in-scope metastore. One that is @@ -140,22 +123,26 @@ func usableWorkspaces(snap routingSnapshot, metastoreID string) ([]string, metas if _, ok := snap.metastores[metastoreID]; !ok { return nil, metastoreGone, nil } + // The sync fails rather than reporting an empty metastore, so the message has + // to name what the operator can act on instead of listing every reason a + // metastore can lack a workspace. if slices.Contains(snap.unreadable, metastoreID) { return nil, metastoreInScope, status.Errorf(codes.FailedPrecondition, - "metastore %s exists on the account but its workspace assignment list could not be read, so the sync cannot tell which workspaces serve it", + "metastore %s exists on the account but its workspace assignment list answered 401, 403 or 404, so the sync cannot tell which workspaces serve it; "+ + "grant the connector's service principal account admin, or scope the metastore out by narrowing workspaces", metastoreID) } - - pending := "" if waiting := snap.pending[metastoreID]; len(waiting) > 0 { - pending = fmt.Sprintf("; %d attached workspace(s) are not RUNNING: %s", len(waiting), strings.Join(waiting, ", ")) + return nil, metastoreInScope, status.Errorf(codes.FailedPrecondition, + "metastore %s is attached only to workspaces that are not RUNNING (%s), so its grants cannot be read; "+ + "the sync fails until Databricks settles their state, because reporting no grants would remove the access ConductorOne already holds", + metastoreID, strings.Join(waiting, ", ")) } return nil, metastoreInScope, status.Errorf(codes.FailedPrecondition, - "metastore %s exists on the account but no workspace this sync can use is attached to it, so its securables cannot be read: "+ - "every attached workspace is either left out of workspaces, named in databricks-exclude-workspaces, not RUNNING, "+ - "or reports no metastore assignment%s", - metastoreID, pending) + "metastore %s exists on the account but reports no workspace assignment, so its securables cannot be read; "+ + "assign a running workspace to it in the Databricks account console", + metastoreID) } // buildRouting lists metastores and the workspaces each one is reachable through. @@ -288,7 +275,7 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot // unknown rather than as a deletion: pruning on it would make C1 delete // the metastore and its grants. A metastore that really is gone stops // appearing in ListMetastores on the next sync. - if isUnreadableWorkspaceError(err) || isNotFoundError(err) { + if isUnreadableWorkspaceError(err) { ctxzap.Extract(ctx).Debug("databricks-connector: could not read the workspace assignment list", zap.String("metastore", metastoreID), zap.Error(err), @@ -314,7 +301,6 @@ func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot } snap.unreadable = unreadable - snap.asked = asked return rateLimit, nil } diff --git a/pkg/connector/unity_catalog_test.go b/pkg/connector/unity_catalog_test.go index e1f041c6..7f14be5e 100644 --- a/pkg/connector/unity_catalog_test.go +++ b/pkg/connector/unity_catalog_test.go @@ -12,6 +12,7 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" "github.com/conductorone/baton-sdk/pkg/types/grant" rs "github.com/conductorone/baton-sdk/pkg/types/resource" "google.golang.org/grpc/codes" @@ -706,28 +707,6 @@ func TestAFailedWorkspaceDoesNotKeepItsMetastorePending(t *testing.T) { } } -func TestAnAllowlistThatIsAlsoExcludedNamesTheExcludeList(t *testing.T) { - probed := map[string]int{} - handler := routingHandler(t, map[string]any{"1": "ms-a", "2": "ms-b"}, probed) - srv := httptest.NewServer(handler) - t.Cleanup(srv.Close) - router := &workspaceRouter{target: strings.TrimPrefix(srv.URL, "http://"), hits: map[string]int{}} - client, err := databricks.NewClient(context.Background(), &http.Client{Transport: router}, - testHostname, testAccountHost, testAccountId, "", &databricks.NoAuth{}, []string{"prod"}) - if err != nil { - t.Fatalf("NewClient: %v", err) - } - - uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod"})) - _, _, err = uc.buildRouting(context.Background()) - if status.Code(err) != codes.FailedPrecondition { - t.Fatalf("buildRouting = %v, want FailedPrecondition", err) - } - if !strings.Contains(err.Error(), "databricks-exclude-workspaces") || strings.Contains(err.Error(), "not on the account") { - t.Errorf("error %q does not say the allowlisted workspace was excluded", err) - } -} - func TestAnUnreadableAssignmentListNamesThatFailure(t *testing.T) { probed := map[string]int{} client, _ := newWorkspaceRoutedClient(t, routingHandler(t, @@ -796,9 +775,13 @@ func TestAPermissions404WithoutTheGoneCodeIsNotAWarning(t *testing.T) { } } -func TestListRecordsTheWorkspaceWhereTheProbePasses(t *testing.T) { +// List records an attached workspace without probing it, so one workspace +// refusing cannot fail the sync. Grants is what validates the hint: here the +// connector is Admin on wsb only, and the read still lands there. +func TestGrantsRouteAroundTheWorkspaceOnTheProfile(t *testing.T) { t.Setenv("BATON_HTTP_CACHE_TTL", "0") + reads := map[string]int{} client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") path := r.URL.Path @@ -821,6 +804,12 @@ func TestListRecordsTheWorkspaceWhereTheProbePasses(t *testing.T) { {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, }) + case strings.Contains(path, "/unity-catalog/permissions/"): + reads[strings.Split(r.Host, ".")[0]]++ + _ = json.NewEncoder(w).Encode(map[string]any{"privilege_assignments": []any{}}) + case strings.Contains(path, "/scim/v2/"): + // The owner is not an account identity, so no owner grant is emitted. + _ = json.NewEncoder(w).Encode(map[string]any{"totalResults": 0, "Resources": []any{}}) default: http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) } @@ -835,8 +824,15 @@ func TestListRecordsTheWorkspaceWhereTheProbePasses(t *testing.T) { t.Fatalf("List returned %d resources, want 1", len(resources)) } workspace, _ := rs.GetProfileStringValue(rs.GetProfile(resources[0]), profileKeyWorkspace) - if workspace != "wsb" { - t.Errorf("profile workspace = %q, want wsb, the workspace where the connector is Admin", workspace) + if workspace != "wsa" { + t.Errorf("profile workspace = %q, want wsa, the first attached workspace", workspace) + } + + if _, _, err := m.Grants(context.Background(), resources[0], rs.SyncOpAttrs{}); err != nil { + t.Fatalf("Grants: %v", err) + } + if reads["wsb"] == 0 || reads["wsa"] != 0 { + t.Errorf("permissions reads = %v, want the read to go to wsb, where the connector is Admin", reads) } } @@ -890,59 +886,165 @@ func TestMetastoreDoesNotExistIsAbsenceOnlyWhenTheAccountDropsIt(t *testing.T) { } } -func TestListSkipsAProfileProbeThatIsNotARefusal(t *testing.T) { +// The profile workspace can stop serving the metastore between List and Grants. +// Rebuilding the route is what tells a reassignment apart from a deletion, so the +// read is retried on the workspace the account now names. +func TestGrantsRetryTheWorkspaceTheAccountNowNames(t *testing.T) { t.Setenv("BATON_HTTP_CACHE_TTL", "0") - tests := []struct { - name string - firstCode int - want string - }{ - {"one workspace returns 500", http.StatusInternalServerError, "wsb"}, - {"every probe fails", http.StatusInternalServerError, "wsa"}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - path := r.URL.Path - switch { - case strings.HasSuffix(path, "/workspaces/1/permissionassignments"): - http.Error(w, `{"message":"unavailable"}`, test.firstCode) - case strings.HasSuffix(path, "/workspaces/2/permissionassignments"): - if test.want == "wsa" { - http.Error(w, `{"message":"unavailable"}`, http.StatusInternalServerError) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{"permission_assignments": []map[string]any{{ - "principal": map[string]any{"service_principal_name": "app-1", "principal_id": 7}, - "permissions": []string{"ADMIN"}, - }}}) - case strings.HasSuffix(path, "/metastores"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "someone-else"}}, - }) - case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{1, 2}}) - case strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, - }) - default: - http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) - } + reads := map[string]int{} + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/metastores"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "app-1"}}, }) - m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) - account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} - resources, _, err := m.List(context.Background(), account, rs.SyncOpAttrs{}) - if err != nil { - t.Fatalf("List: %v", err) - } - workspace, _ := rs.GetProfileStringValue(rs.GetProfile(resources[0]), profileKeyWorkspace) - if workspace != test.want { - t.Errorf("profile workspace = %q, want %q", workspace, test.want) + case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): + // wsa was unassigned after List wrote it on the profile. + _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{2}}) + case strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, + }) + case strings.Contains(path, "/unity-catalog/permissions/"): + host := strings.Split(r.Host, ".")[0] + reads[host]++ + if host == "wsa" { + http.Error(w, `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"No metastore assigned for the current workspace"}`, http.StatusNotFound) + + return } - }) + _ = json.NewEncoder(w).Encode(map[string]any{"privilege_assignments": []map[string]any{{ + "principal": "dana@example.com", + "principal_id": 7, + "privileges": []string{"CREATE_CATALOG"}, + }}}) + case strings.Contains(path, "/scim/v2/Users/"): + _ = json.NewEncoder(w).Encode(map[string]any{"id": "7", "userName": "dana@example.com"}) + case strings.HasSuffix(path, "/scim/v2/ServicePrincipals"): + _ = json.NewEncoder(w).Encode(map[string]any{"totalResults": 1, "Resources": []map[string]any{ + {"id": "9", "applicationId": "app-1", "displayName": "baton"}, + }}) + case strings.HasSuffix(path, "/scim/v2/Users"), strings.HasSuffix(path, "/scim/v2/Groups"): + _ = json.NewEncoder(w).Encode(map[string]any{"totalResults": 0, "Resources": []any{}}) + default: + http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) + } + }) + + m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) + account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + metastore, err := metastoreResource(context.Background(), &databricks.Metastore{MetastoreID: "ms-a", Owner: "app-1"}, account, "wsa", "1", "prod") + if err != nil { + t.Fatalf("metastoreResource: %v", err) + } + + _, _, err = m.Grants(context.Background(), metastore, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("Grants = %v, want the read to be retried on wsb", err) + } + if reads["wsb"] == 0 { + t.Errorf("permissions were never read through wsb; reads = %v", reads) + } +} + +// A page token belongs to the workspace that minted it, and the pages already +// emitted would read as the complete set if this one ended them. Past the first +// page the read fails instead of rebuilding the route. +func TestGrantsDoNotRerouteInTheMiddleOfPagination(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + assignmentLists := 0 + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/metastores"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "app-1"}}, + }) + case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): + assignmentLists++ + _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{2}}) + case strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, + }) + case strings.Contains(path, "/unity-catalog/permissions/"): + http.Error(w, `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"No metastore assigned for the current workspace"}`, http.StatusNotFound) + default: + http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) + } + }) + + m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) + account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + metastore, err := metastoreResource(context.Background(), &databricks.Metastore{MetastoreID: "ms-a", Owner: "app-1"}, account, "wsa", "1", "prod") + if err != nil { + t.Fatalf("metastoreResource: %v", err) + } + + // A cursor the first workspace's permissions endpoint handed back. + secondPage := "cursor-from-wsa" + grants, _, err := m.Grants(context.Background(), metastore, rs.SyncOpAttrs{ + PageToken: pagination.Token{Token: secondPage}, + }) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("Grants = %v, want FailedPrecondition instead of re-routing mid-pagination", err) + } + if len(grants) != 0 { + t.Errorf("Grants returned %d grants on a failed page, want none", len(grants)) + } + if assignmentLists != 0 { + t.Errorf("the route was rebuilt %d time(s) while a page token was in hand", assignmentLists) + } +} + +// A metastore pruned between List and Grants answers the way the access path +// answers it before the first read: no grants, not a failed sync. +func TestGrantsReturnNoGrantsWhenTheMetastoreLeftTheSyncScope(t *testing.T) { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + + client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + switch { + case strings.HasSuffix(path, "/metastores"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "app-1"}}, + }) + case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): + // Every attached workspace is now outside the configured allowlist. + _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{2}}) + case strings.HasSuffix(path, "/workspaces"): + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, + }) + case strings.Contains(path, "/unity-catalog/permissions/"): + http.Error(w, `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"No metastore assigned for the current workspace"}`, http.StatusNotFound) + default: + http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) + } + }) + + uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod"})) + m := newMetastoreBuilder(client, uc, syncPrincipals{}) + account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + metastore, err := metastoreResource(context.Background(), &databricks.Metastore{MetastoreID: "ms-a", Owner: "app-1"}, account, "wsa", "1", "prod") + if err != nil { + t.Fatalf("metastoreResource: %v", err) + } + + grants, _, err := m.Grants(context.Background(), metastore, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("Grants = %v, want no grants and no error", err) + } + if len(grants) != 0 { + t.Errorf("Grants returned %d grants, want none", len(grants)) } } diff --git a/pkg/databricks/request.go b/pkg/databricks/request.go index c870bf53..ce0a16f2 100644 --- a/pkg/databricks/request.go +++ b/pkg/databricks/request.go @@ -48,9 +48,11 @@ func wrapTransportAuthError(err error) error { return uhttp.WrapErrors(code, "databricks-connector: authentication failed", err) } -// Databricks error_code values that mean the named securable is gone. -// A route-level 404 (workspace does not serve this metastore) uses a -// different code, so status-only matching cannot tell the two apart. +// Databricks error_code values for a named securable that is not there. +// The code narrows a 404 to the named securable, but it does not prove the +// securable is gone: METASTORE_DOES_NOT_EXIST also answers a workspace that +// serves no metastore. Only the account list separates absence from a route +// that does not reach it. const ( ErrorCodeMetastoreDoesNotExist = "METASTORE_DOES_NOT_EXIST" ErrorCodeCatalogDoesNotExist = "CATALOG_DOES_NOT_EXIST" From d92adca9068f10be1b33cde2e01d97c1ed117fb9 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Thu, 22 Oct 2026 12:09:51 -0300 Subject: [PATCH 18/36] refactor(databricks): fold the unity catalog layer into the metastore syncer Co-authored-by: Cursor --- README.md | 60 +- docs/docs-info.md | 29 +- pkg/connector/connector.go | 36 +- pkg/connector/grant_visibility.go | 93 --- pkg/connector/helpers.go | 81 -- pkg/connector/metastore.go | 444 +++++------ pkg/connector/metastore_helpers.go | 356 +++++++++ pkg/connector/metastore_test.go | 491 ++++++++++++ pkg/connector/principal_index.go | 292 ------- pkg/connector/principal_index_test.go | 215 ----- pkg/connector/privileges.go | 53 -- pkg/connector/securable_helpers.go | 378 --------- pkg/connector/unity_catalog.go | 306 ------- pkg/connector/unity_catalog_test.go | 1050 ------------------------- pkg/databricks/models.go | 12 - pkg/databricks/request.go | 38 - pkg/databricks/unity_catalog.go | 66 -- 17 files changed, 1083 insertions(+), 2917 deletions(-) delete mode 100644 pkg/connector/grant_visibility.go create mode 100644 pkg/connector/metastore_helpers.go create mode 100644 pkg/connector/metastore_test.go delete mode 100644 pkg/connector/principal_index.go delete mode 100644 pkg/connector/principal_index_test.go delete mode 100644 pkg/connector/privileges.go delete mode 100644 pkg/connector/securable_helpers.go delete mode 100644 pkg/connector/unity_catalog.go delete mode 100644 pkg/connector/unity_catalog_test.go diff --git a/README.md b/README.md index 38d3767b..413b7499 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,8 @@ deployment name, or numeric workspace ID, matched case-insensitively. `--workspaces` also scopes which metastores sync: only a metastore attached to a listed workspace is synced, so narrowing the list removes the other metastores -and their grants from ConductorOne. A list that matches no workspace on the -account fails the whole sync, including users, groups, service principals and -workspaces, once the `metastore` resource type is enabled. +and their grants from ConductorOne. A list that names no workspace on the +account is rejected during credential validation, before any sync runs. Metastores are Unity Catalog securables and are opt-in: the type stays off until it is enabled for the connector in ConductorOne. @@ -156,17 +155,7 @@ members as holders. metastore level. A metastore lives on the account plane, but its privileges are read through a -workspace host assigned to it. - -| Level | Endpoint | -|-------|----------| -| Metastores | `GET /api/2.0/accounts/{account_id}/metastores` | -| Metastore workspace assignments | `GET /api/2.0/accounts/{account_id}/metastores/{metastore_id}/workspaces` | -| Privileges on every level | `GET` and `PATCH` `/api/2.1/unity-catalog/permissions/{securable_type}/{full_name}` | - -HTTP cache keys include the request Host. Two workspaces share paths such as -`/unity-catalog/permissions/...`, and without the host in the key one -workspace's response would be served for another. +workspace attached to it. A metastore is synced only while a workspace this sync can use is attached to it. The connector never guesses when the answer is unknown: @@ -191,39 +180,16 @@ fails with an `InvalidArgument` error. ## Unity Catalog permissions -Three independent layers have to line up, and granting one does not imply the -others: - -1. **OAuth scopes** decide which API surfaces the credential may touch at all. -2. **The service principal's roles** decide what it sees on those surfaces. -3. **Unity Catalog privileges** apply on the securable itself. - -The scopes: - -| Scope | Covers | -|-------|--------| -| `unity-catalog` | Metastores, metastore workspace assignments, and the permissions endpoint | -| `scim` | Account users, groups and service principals | -| `access-management` | The rule-sets API, where roles live, plus the workspace permission assignments | -| `provisioning` | The account workspaces listing. It is the only scope that grants it — including not the one named `workspace` | - -The connector requests `all-apis`, which covers all four. Each resource type -declares its own scopes, roles and privileges, so `./baton-databricks -capabilities` reports them per type. - -A privilege read is only trustworthy when the service principal owns the -metastore, or is assigned **Admin** directly on a workspace the metastore is -assigned to (Admin inherited only through a group is not enough). Holding a -privilege is not an option at this level: Databricks rejects `MANAGE` and -`ALL_PRIVILEGES` on a metastore, so there is no privilege whose presence would -prove the read is complete. - -When neither holds, the sync fails with a `PermissionDenied` error naming the -metastore rather than reporting a partial read. Databricks answers an -under-permissioned privilege read with HTTP 200 and a partial list of privilege -assignments, which is byte-indistinguishable from an object that genuinely has -no grants on it. Reporting that partial list would make ConductorOne read every -privilege the principal cannot see as revoked access. +The service principal must own the metastore, or be assigned **Admin** directly +on a workspace the metastore is attached to. Admin inherited only through a +group is not enough. Databricks answers an under-permissioned privilege read +with HTTP 200 and the subset the caller may see, which is indistinguishable +from an object with no grants, so a principal without that standing makes +ConductorOne read the privileges it cannot see as revoked access. + +The connector requests the `all-apis` OAuth scope. Each resource type declares +the scopes, roles and privileges it needs, so `./baton-databricks capabilities` +reports them per type. # Contributing, Support and Issues diff --git a/docs/docs-info.md b/docs/docs-info.md index 70b85352..0356cc66 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -38,9 +38,9 @@ While developing the connector, please fill out this form. This information is n > The credential must have admin access to each resource it reads or writes: account-admin on the Databricks account (for account-level sync and provisioning) and workspace-admin assigned directly to the service principal on each workspace being synced (Admin inherited only through a group is not enough). > - > The OAuth token carries scopes on top of that. The connector requests `all-apis`, which covers the four it uses: `unity-catalog` (the securables and the permissions endpoint), `scim` (account principals), `access-management` (roles and workspace permission assignments) and `provisioning` (the account workspaces listing). The scope-by-scope breakdown is in `## Permission model` below. + > The OAuth token carries scopes on top of that. The connector requests `all-apis`, which covers the four it uses: `unity-catalog` (the securables and the permissions endpoint), `scim` (account principals), `access-management` (roles and workspace permission assignments) and `provisioning` (the account workspaces listing). > - > Unity Catalog adds a third layer on top of scopes and roles: standing on the securable itself. For each metastore being synced, the service principal must either own the metastore or be assigned Admin directly on a workspace it is assigned to. A privilege read cannot settle it at this level, because Databricks rejects `MANAGE` and `ALL_PRIVILEGES` on a metastore. `## Permission model` explains why neither is optional. + > Unity Catalog adds a third layer on top of scopes and roles: standing on the securable itself. For each metastore being synced, the service principal must either own the metastore or be assigned Admin directly on a workspace it is assigned to. Without that standing Databricks answers the privilege read with the subset the caller may see, and ConductorOne reads the rest as revoked. * If applicable: Is the list of scopes or permissions different to sync (read) versus provision (read-write)? If so, list the difference here. @@ -57,10 +57,10 @@ While developing the connector, please fill out this form. This information is n but the connector reports the privilege assignments recorded on the object itself. The endpoint that exposes inheritance does not return principal IDs, which are what the connector needs to resolve a principal unambiguously. -- **Workspace Admin must be assigned directly.** The connector matches the - service principal's application ID or its own SCIM id against the workspace's - ADMIN assignments. Admin inherited only through a group is not treated as - enough. +- **Workspace Admin must be assigned directly.** Admin inherited only through a + group does not let Databricks return every privilege assignment on a + metastore, so the sync would report the subset the service principal can see + as the complete set. - **Ownership is read-only.** The owner of a metastore is shown but cannot be provisioned. An owner that is not an account identity — `System user`, or a workspace admins group such as `_workspace_admins_` — does not @@ -73,16 +73,13 @@ While developing the connector, please fill out this form. This information is n ConductorOne removes it and its grants. Granting or revoking a privilege on such a metastore fails and changes nothing in Databricks. - **Narrowing `workspaces` narrows the metastores.** Only a metastore attached - to a listed workspace is synced. When the Metastores resource type is enabled, - a list that matches no workspace on the account fails the whole sync, - including users, groups, service principals and workspaces. -- **The workspace shown on a metastore is an access path, not an authority.** - It names a workspace the metastore is assigned to. The connector confirms its - access before every read and uses another attached workspace when that one - does not serve the metastore, so the grants may have been read elsewhere. -- **Admin on any attached workspace is enough.** The connector tries each - attached workspace in turn and reads through the first one where it holds - Admin directly. + to a listed workspace is synced. A list that names no workspace on the account + is rejected when the connector validates its credentials, before any sync + runs. +- **The workspace shown on a metastore is the access path.** `List` records the + first workspace in the sync that the metastore is assigned to, and every + privilege read, grant and revoke goes through it. Admin on that workspace is + what the service principal needs. - **Other securables are not synced yet:** catalogs, schemas, tables, volumes, functions, registered models, external locations, storage credentials, connections, shares and Delta Sharing recipients. diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index a5cc4688..0585c9fe 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "io" + "slices" + "strconv" + "strings" "github.com/conductorone/baton-databricks/pkg/config" "github.com/conductorone/baton-databricks/pkg/databricks" @@ -14,10 +17,9 @@ import ( ) type Databricks struct { - client *databricks.Client - workspaces []string - unityCatalog *unityCatalog - opts *cli.ConnectorOpts + client *databricks.Client + workspaces []string + opts *cli.ConnectorOpts } // ResourceSyncers returns a ResourceSyncerV2 for each resource type that should be synced from the upstream service. @@ -29,7 +31,7 @@ func (d *Databricks) ResourceSyncers(ctx context.Context) []connectorbuilder.Res newUserBuilder(d.client), newWorkspaceBuilder(d.client, d.workspaces), newRoleBuilder(d.client), - newMetastoreBuilder(d.client, d.unityCatalog, syncPrincipals{ + newMetastoreBuilder(d.client, configuredWorkspaceSet(d.workspaces), syncPrincipals{ groups: d.willSyncResourceType(groupResourceType.Id), servicePrincipals: d.willSyncResourceType(servicePrincipalResourceType.Id), }), @@ -126,22 +128,28 @@ func (d *Databricks) Validate(ctx context.Context) (annotations.Annotations, err // Workspace enumeration is the other account-plane call every sync depends on. // Per-workspace probing is deliberately not done here: a workspace the service // principal can't reach is skipped during sync rather than failing validation. - if _, _, err := d.client.ListWorkspaces(ctx); err != nil { + workspaces, _, err := d.client.ListWorkspaces(ctx) + if err != nil { return nil, fmt.Errorf("databricks-connector: failed to list workspaces: %w", err) } + if len(d.workspaces) > 0 && !slices.ContainsFunc(workspaces, func(w databricks.Workspace) bool { + _, ok := matchConfiguredWorkspace(configuredWorkspaceSet(d.workspaces), w.DeploymentName, w.Name, strconv.Itoa(w.ID)) + return ok + }) { + return nil, fmt.Errorf("databricks-connector: none of the configured workspaces (%s) exists on the account", strings.Join(d.workspaces, ", ")) + } + return nil, nil } -// New returns a new instance of the connector. clientID is the application id of -// the service principal the connector authenticates as. +// New returns a new instance of the connector. func New( ctx context.Context, hostname, accountHostname, accountID, - baseURL, - clientID string, + baseURL string, auth databricks.Auth, excludeWorkspaces []string, workspaces []string, @@ -158,10 +166,9 @@ func New( } return &Databricks{ - client: client, - workspaces: workspaces, - unityCatalog: newUnityCatalog(client, clientID, configuredWorkspaceSet(workspaces)), - opts: opts, + client: client, + workspaces: workspaces, + opts: opts, }, nil } @@ -176,7 +183,6 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.Connect accountHostname, cfg.AccountId, cfg.BaseUrl, - cfg.DatabricksClientId, auth, cfg.DatabricksExcludeWorkspaces, cfg.Workspaces, diff --git a/pkg/connector/grant_visibility.go b/pkg/connector/grant_visibility.go deleted file mode 100644 index 94117edf..00000000 --- a/pkg/connector/grant_visibility.go +++ /dev/null @@ -1,93 +0,0 @@ -package connector - -import ( - "context" - "fmt" - "slices" - "strconv" - - "github.com/conductorone/baton-databricks/pkg/databricks" - v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" -) - -const ( - workspacePermissionAdmin = "ADMIN" -) - -// metastoreGrantsAreTrustworthy reports whether every direct grant on a metastore -// is visible. MANAGE and ALL_PRIVILEGES are rejected at the metastore level, so the -// authority is owning it or administering a workspace it is assigned to. -func (u *unityCatalog) metastoreGrantsAreTrustworthy( - ctx context.Context, - snap routingSnapshot, - metastoreID, workspace string, -) (bool, *v2.RateLimitDescription, error) { - if metastore, ok := snap.metastores[metastoreID]; ok && u.ownsPrincipal(metastore.Owner) { - return true, nil, nil - } - - return u.isWorkspaceAdmin(ctx, snap, workspace) -} - -// isWorkspaceAdmin lists the workspace's permission assignments on every call. -func (u *unityCatalog) isWorkspaceAdmin( - ctx context.Context, - snap routingSnapshot, - workspace string, -) (bool, *v2.RateLimitDescription, error) { - if workspace == "" { - return false, nil, nil - } - - workspaceID := snap.workspaceIDs[workspace] - if workspaceID == "" { - return false, nil, nil - } - - assignments, rateLimit, err := u.client.ListWorkspaceMembers(ctx, workspaceID) - if err != nil { - return false, rateLimit, fmt.Errorf("failed to list the permission assignments of workspace %s: %w", workspace, err) - } - - return u.holdsWorkspaceAdmin(ctx, assignments, rateLimit) -} - -// holdsWorkspaceAdmin matches the connector's own principal against a workspace's -// ADMIN assignments. A service principal is named by its applicationId. -// Admin inherited only through a group is ignored: the connector principal must -// hold ADMIN itself. Walking every admin group's membership on every Grants call -// is the cost that would close this gap. -func (u *unityCatalog) holdsWorkspaceAdmin( - ctx context.Context, - assignments []databricks.WorkspaceAssignment, - rateLimit *v2.RateLimitDescription, -) (bool, *v2.RateLimitDescription, error) { - var adminIDs []string - - for _, assignment := range assignments { - principal := assignment.Principal - if principal == nil || !slices.Contains(assignment.Permissions, workspacePermissionAdmin) { - continue - } - if u.ownsPrincipal(principal.ServicePrincipalAppID) { - return true, rateLimit, nil - } - if principal.ID != 0 { - adminIDs = append(adminIDs, strconv.Itoa(principal.ID)) - } - } - - if len(adminIDs) == 0 { - return false, rateLimit, nil - } - - ownID, idRateLimit, err := u.ownPrincipalScimID(ctx) - if idRateLimit != nil { - rateLimit = idRateLimit - } - if err != nil { - return false, rateLimit, err - } - - return ownID != "" && slices.Contains(adminIDs, ownID), rateLimit, nil -} diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go index 64e19255..1e93df6c 100644 --- a/pkg/connector/helpers.go +++ b/pkg/connector/helpers.go @@ -11,7 +11,6 @@ import ( "github.com/conductorone/baton-databricks/pkg/databricks" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" - "github.com/conductorone/baton-sdk/pkg/types/grant" rs "github.com/conductorone/baton-sdk/pkg/types/resource" "google.golang.org/protobuf/types/known/structpb" ) @@ -38,20 +37,6 @@ func parseResourceId(resourceId string) (*v2.ResourceId, *v2.ResourceId, error) return nil, nil, fmt.Errorf("invalid resource ID: %s", resourceId) } -// groupSecurableGrantOptions annotates a group-held Unity Catalog grant so C1 -// expands it through the group's member entitlement. Shallow stays unset: -// setting it would suppress propagation through a nested group. -func groupSecurableGrantOptions(principalId *v2.ResourceId) []grant.GrantOption { - if principalId.GetResourceType() != groupResourceType.Id { - return nil - } - - return []grant.GrantOption{grant.WithAnnotation(&v2.GrantExpandable{ - EntitlementIds: []string{fmt.Sprintf("group:%s:%s", principalId.GetResource(), groupMemberEntitlement)}, - ResourceTypeIds: []string{userResourceType.Id, groupResourceType.Id, servicePrincipalResourceType.Id}, - })} -} - func groupGrantExpansion(ctx context.Context, groupId string, parentResource *v2.ResourceId) (*v2.ResourceId, *v2.GrantExpandable, error) { groupResourceStr := groupResourceId(ctx, groupId, parentResource) resourceId, err := rs.NewResourceID(groupResourceType, groupResourceStr) @@ -249,72 +234,6 @@ func isNotFoundError(err error) bool { return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound } -func isForbiddenError(err error) bool { - var apiErr *databricks.APIError - return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusForbidden -} - -func isRateLimitError(err error) bool { - var apiErr *databricks.APIError - return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusTooManyRequests -} - -// isUnreadableWorkspaceError reports an answer that says this credential cannot -// read this workspace, not a fact about the account. A 404 is included so the -// caller tries the next workspace: returning it would surface as codes.NotFound, -// and the SDK treats that as a warning and drops the grants. -func isUnreadableWorkspaceError(err error) bool { - var apiErr *databricks.APIError - if !errors.As(err, &apiErr) { - return false - } - - switch apiErr.StatusCode { - case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: - return true - default: - return false - } -} - -// A privilege assignment carries the numeric SCIM id in principal_id, which is the -// only handle left on a principal that no longer resolves to a name. -func unityPrincipalScimID(principal *v2.ResourceId) (string, error) { - if principal.GetResourceType() != groupResourceType.Id { - return principal.GetResource(), nil - } - - _, parsed, err := parseResourceId(principal.GetResource()) - if err != nil { - return "", fmt.Errorf("failed to parse group resource id: %w", err) - } - - return parsed.GetResource(), nil -} - -// One `principal` field carries a user's userName, a group's displayName or a service -// principal's applicationId. The second result is false when nothing resolves it, -// which is not an error: a deleted principal keeps its grants and is addressable -// only by principal_id. -func unityPrincipalName(ctx context.Context, c *databricks.Client, principal *v2.ResourceId) (string, bool, error) { - principalId, err := unityPrincipalScimID(principal) - if err != nil { - return "", false, err - } - - qualified, err := preparePrincipalId(ctx, c, "", principal.GetResourceType(), principalId) - if err != nil { - return "", false, err - } - - _, name, ok := strings.Cut(qualified, "/") - if !ok || name == "" { - return "", false, nil - } - - return name, true, nil -} - func prepareWorkspaceRole(entitlement string) string { parts := strings.Split(entitlement, ":") if len(parts) != 2 { diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index ca3d86da..6b029745 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "slices" + "strconv" "strings" "github.com/conductorone/baton-databricks/pkg/databricks" @@ -23,23 +24,20 @@ import ( var _ connectorbuilder.StaticEntitlementSyncerV2 = (*metastoreBuilder)(nil) type metastoreBuilder struct { - securableDeps + client *databricks.Client + // workspaces is the configured allowlist, empty when none was given. + workspaces map[string]struct{} + sync syncPrincipals } -func newMetastoreBuilder(client *databricks.Client, uc *unityCatalog, sync syncPrincipals) *metastoreBuilder { - return &metastoreBuilder{securableDeps{client: client, uc: uc, sync: sync}} +func newMetastoreBuilder(client *databricks.Client, workspaces map[string]struct{}, sync syncPrincipals) *metastoreBuilder { + return &metastoreBuilder{client: client, workspaces: workspaces, sync: sync} } func (m *metastoreBuilder) ResourceType(_ context.Context) *v2.ResourceType { return metastoreResourceType } -var metastoreKind = securableKind{ - name: databricks.SecurableMetastore, - parse: parseMetastoreID, - privileges: metastorePrivileges, -} - func metastoreResource(_ context.Context, metastore *databricks.Metastore, parent *v2.ResourceId, workspace, workspaceID, workspaceName string) (*v2.Resource, error) { profile := map[string]any{ profileKeyMetastoreID: metastore.MetastoreID, @@ -56,8 +54,7 @@ func metastoreResource(_ context.Context, metastore *databricks.Metastore, paren name = metastore.MetastoreID } - // The metastore id is the resource id and its RawId: it survives a rename, and - // the permissions endpoint addresses a metastore by UUID and rejects its name. + // The permissions endpoint addresses a metastore by UUID, so that is the id. return rs.NewAppResource( name, metastoreResourceType, @@ -65,43 +62,60 @@ func metastoreResource(_ context.Context, metastore *databricks.Metastore, paren nil, rs.WithResourceProfile(profile), rs.WithParentResourceID(parent), - rs.WithAnnotation( - &v2.RawId{Id: metastore.MetastoreID}, - ), + rs.WithAnnotation(&v2.RawId{Id: metastore.MetastoreID}), ) } -// List returns the account's metastores, which arrive in a single unpaged response. +// List emits each metastore with the workspace its permissions are read through. +// A metastore with no usable workspace is left out: its grants cannot be read. func (m *metastoreBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { if parentResourceID.GetResourceType() != accountResourceType.Id { return nil, nil, nil } - // Read through the routing snapshot rather than the raw listing: with a - // workspace allowlist the two differ, and emitting a metastore no in-scope - // workspace can reach would advertise a resource whose grants cannot be read. - snap, rateLimit, err := m.uc.buildRouting(ctx) annos := annotations.Annotations{} + metastores, rateLimit, err := m.client.ListMetastores(ctx) noteRateLimit(&annos, rateLimit) if err != nil { return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf("databricks-connector: failed to list metastores: %w", err) } + scoped, running, err := m.scopedWorkspaces(ctx, &annos) + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, err + } + + l := ctxzap.Extract(ctx) var rv []*v2.Resource - for _, metastore := range snap.metastores { - // The workspace on the profile is a hint, not a decision: Grants probes it - // and rebuilds the route when it does not serve the metastore. List does - // not probe here, because one workspace refusing would fail the whole sync. - workspace := "" - if routed := snap.workspaces[metastore.MetastoreID]; len(routed) > 0 { - workspace = routed[0] + for _, metastore := range metastores { + if metastore.MetastoreID == "" { + continue } - mr, err := metastoreResource(ctx, &metastore, parentResourceID, workspace, snap.workspaceIDs[workspace], snap.workspaceNames[workspace]) + + workspace, inScope, err := m.routeMetastore(ctx, metastore.MetastoreID, scoped, running, &annos) if err != nil { + if isNotFoundError(err) { + l.Debug("databricks-connector: metastore is no longer on the account; skipping it", + zap.String("metastore", metastore.MetastoreID)) + continue + } return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( - "databricks-connector: failed to build the resource for metastore %s: %w", metastore.MetastoreID, err) + "databricks-connector: failed to list workspaces for metastore %s: %w", metastore.MetastoreID, err) + } + if workspace == nil { + if inScope { + return nil, &rs.SyncOpResults{Annotations: annos}, status.Errorf(codes.FailedPrecondition, + "databricks-connector: metastore %s has no running workspace in sync scope", metastore.MetastoreID) + } + l.Debug("databricks-connector: metastore has no workspace in sync scope; skipping it", + zap.String("metastore", metastore.MetastoreID)) + continue } + mr, err := metastoreResource(ctx, &metastore, parentResourceID, workspace.DeploymentName, strconv.Itoa(workspace.ID), workspace.Name) + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, err + } rv = append(rv, mr) } @@ -118,7 +132,6 @@ func (m *metastoreBuilder) StaticEntitlements(_ context.Context, _ rs.SyncOpAttr )) } - // Owner is not grantable. Ownership is single-valued and has no revoke. rv = append(rv, ent.NewOwnershipEntitlement(nil, ownerEntitlement, ent.WithDescription("Owns a metastore in Databricks"), )) @@ -130,313 +143,234 @@ func (m *metastoreBuilder) Entitlements(context.Context, *v2.Resource, rs.SyncOp return nil, nil, nil } -// parseMetastoreID reads a metastore reference out of a resource id. A metastore -// is addressed by a bare id; the separator only appears in the composite ids of -// the securables below it, so finding one here means the wrong resource arrived. -func parseMetastoreID(resourceID string) (securableRef, error) { - if resourceID == "" || strings.Contains(resourceID, securableKeySeparator) { - return securableRef{}, status.Errorf(codes.InvalidArgument, "databricks-connector: invalid metastore resource id %q", resourceID) - } - - return securableRef{metastoreID: resourceID}, nil -} - func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, attr rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { - ref, err := parseMetastoreID(resource.GetId().GetResource()) - if err != nil { - return nil, nil, err + l := ctxzap.Extract(ctx) + metastoreID := resource.GetId().GetResource() + profile := rs.GetProfile(resource) + workspace, _ := rs.GetProfileStringValue(profile, profileKeyWorkspace) + if workspace == "" { + l.Debug("databricks-connector: metastore has no workspace on its profile; returning no grants", zap.String("metastore", metastoreID)) + return nil, nil, nil } annos := annotations.Annotations{} - workspace, state, rateLimit, err := m.accessPath(ctx, resource, ref, false) - noteRateLimit(&annos, rateLimit) - if err != nil { - if status.Code(err) == codes.PermissionDenied { - return nil, &rs.SyncOpResults{Annotations: annos}, err - } - - return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( - "databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) - } - switch state { - case metastoreGone: - ctxzap.Extract(ctx).Debug("databricks-connector: metastore is no longer listed", - zap.String("metastore", ref.metastoreID), - ) - - return nil, &rs.SyncOpResults{Annotations: annos}, nil - case metastoreOutOfScope: - ctxzap.Extract(ctx).Debug("databricks-connector: no workspace this sync can use is attached to the metastore; skipping its grants", - zap.String("metastore", ref.metastoreID), - ) - - return nil, &rs.SyncOpResults{Annotations: annos}, nil - case metastoreInScope: - } - - l := ctxzap.Extract(ctx) pageToken := attr.PageToken.Token - assignments, nextPageToken, rateLimit, err := m.client.ListPermissions( - ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), "", pageToken) + assignments, nextPageToken, rateLimit, err := m.client.ListPermissions(ctx, workspace, databricks.SecurableMetastore, metastoreID, "", pageToken) noteRateLimit(&annos, rateLimit) - // Databricks answers METASTORE_DOES_NOT_EXIST both when the metastore is gone - // and when the workspace read through does not serve it ("No metastore - // assigned for the current workspace"), so only the account list separates - // them. The route is rebuilt before the read is called an absence. - // - // Only on the first page. A page token is opaque and minted by one workspace's - // permissions endpoint, so it cannot be replayed on another, and ending the - // pages early would report the grants already emitted as the complete set. - // Mid-pagination the read fails instead, and the next sync starts this - // metastore's grants from the first page. - if err != nil && pageToken == "" && databricks.IsSecurableGone(err, databricks.ErrorCodeMetastoreDoesNotExist) { - routed, routedState, confirmRateLimit, confirmErr := m.accessPath(ctx, resource, ref, true) - noteRateLimit(&annos, confirmRateLimit) - if confirmErr != nil { - if status.Code(confirmErr) == codes.PermissionDenied { - return nil, &rs.SyncOpResults{Annotations: annos}, confirmErr - } - - return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( - "databricks-connector: failed to confirm whether metastore %s is still listed: %w", ref.metastoreID, confirmErr) - } - switch { - case routedState != metastoreInScope: - // Deleted from the account, or left with no workspace this sync can use. - // Both answer the way the access path above answers them. - l.Debug("databricks-connector: metastore is no longer readable through this sync; returning no grants", - zap.String("metastore", ref.metastoreID), - ) - - return nil, &rs.SyncOpResults{Annotations: annos}, nil - case routed != workspace: - // The metastore was reassigned after List wrote the profile workspace. - assignments, nextPageToken, rateLimit, err = m.client.ListPermissions( - ctx, routed, databricks.SecurableMetastore, ref.permissionsName(), "", pageToken) - noteRateLimit(&annos, rateLimit) - } - } - // uhttp maps HTTP 404 to codes.NotFound, and the SDK skips a NotFound grants - // read, which makes C1 delete the bindings. The account still lists this - // metastore, so the read is a failed precondition rather than an absence. - if err != nil { - if isNotFoundError(err) { - return nil, &rs.SyncOpResults{Annotations: annos}, uhttp.WrapErrors(codes.FailedPrecondition, - fmt.Sprintf("databricks-connector: permissions on metastore %s returned 404 while the account still lists it, so this is not an absence", - ref.metastoreID), - err) - } - - return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( - "databricks-connector: failed to list permissions on %s %s: %w", databricks.SecurableMetastore, ref.permissionsName(), err) - } - - owner := "" - if pageToken == "" { - if v, ok := rs.GetProfileStringValue(rs.GetProfile(resource), profileKeyOwner); ok { - owner = v - } - } - - principals, principalRateLimit, err := m.uc.principalsForGrants(ctx, assignments, owner) - noteRateLimit(&annos, principalRateLimit) if err != nil { - return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( - "databricks-connector: failed to resolve account principals: %w", err) + // FailedPrecondition, not NotFound: the SDK reads NotFound as "no grants". + // Whether the metastore is gone is settled by the next List. + return nil, &rs.SyncOpResults{Annotations: annos}, uhttp.WrapErrors(codes.FailedPrecondition, + fmt.Sprintf("databricks-connector: failed to list permissions on metastore %s through workspace %s", metastoreID, workspace), err) } + resolver := newPrincipalResolver(m.client, &annos) var rv []*v2.Grant for _, assignment := range assignments { - principalId, ok := principals.lookup(assignment) - if !ok { + principalId, err := resolver.resolve(ctx, assignment.PrincipalID.String(), assignment.Principal) + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf("databricks-connector: %w", err) + } + if principalId == nil { l.Debug("databricks-connector: skipping a grant whose principal does not resolve", - zap.String("securable", ref.resourceKey()), + zap.String("metastore", metastoreID), zap.String("principal", assignment.Principal), - zap.String("principal_id", assignment.PrincipalID.String()), - ) - + zap.String("principal_id", assignment.PrincipalID.String())) continue } - if !emitPrincipal(m.sync, principalId.GetResourceType()) { continue } options := groupSecurableGrantOptions(principalId) - - for _, assigned := range assignment.Privileges { - if assigned == "" { - continue + for _, privilege := range assignment.Privileges { + if slices.Contains(metastorePrivileges, privilege) { + rv = append(rv, grant.NewGrant(resource, privilege, principalId, options...)) } - if !slices.Contains(metastorePrivileges, assigned) { - l.Debug("databricks-connector: skipping a privilege the resource does not offer", - zap.String("securable", ref.resourceKey()), - zap.String("privilege", assigned), - ) - - continue - } - - rv = append(rv, grant.NewGrant(resource, assigned, principalId, options...)) } } - if pageToken == "" { - owner, ok := rs.GetProfileStringValue(rs.GetProfile(resource), profileKeyOwner) - if ok && owner != "" { - principalId, found := principals.lookupName(owner) - if !found { - l.Debug("databricks-connector: securable owner does not resolve to a synced principal", - zap.String("securable", ref.resourceKey()), - zap.String("owner", owner), - ) - } else if emitPrincipal(m.sync, principalId.GetResourceType()) { - options := groupSecurableGrantOptions(principalId) - options = append(options, grant.WithAnnotation(&v2.GrantImmutable{})) - rv = append(rv, grant.NewGrant(resource, ownerEntitlement, principalId, options...)) - } + // The owner is emitted once, with the first page. + if owner, _ := rs.GetProfileStringValue(profile, profileKeyOwner); pageToken == "" && owner != "" { + principalId, err := resolver.resolve(ctx, "", owner) + if err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf("databricks-connector: %w", err) + } + if principalId != nil && emitPrincipal(m.sync, principalId.GetResourceType()) { + options := append(groupSecurableGrantOptions(principalId), grant.WithAnnotation(&v2.GrantImmutable{})) + rv = append(rv, grant.NewGrant(resource, ownerEntitlement, principalId, options...)) } } return rv, &rs.SyncOpResults{Annotations: annos, NextPageToken: nextPageToken}, nil } -func (m *metastoreBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { - change, annos, done, err := m.resolveSecurableChange(ctx, principal.GetId(), entitlement, metastoreKind, changeGrant) - if done { - return annos, err +type metastoreChange struct { + metastoreID string + workspace string + privilege string + scimID string + principalName string +} + +// resolveChange validates a Grant/Revoke and resolves what the PATCH needs. +func (m *metastoreBuilder) resolveChange( + ctx context.Context, + principalID *v2.ResourceId, + entitlement *v2.Entitlement, + isGrant bool, +) (metastoreChange, error) { + if !isValidPrincipal(principalID) { + return metastoreChange{}, status.Errorf(codes.InvalidArgument, + "databricks-connector: only users, groups and service principals can hold a metastore privilege, got %s", principalID.GetResourceType()) } - ref := change.ref - privilege := change.privilege - workspace := change.workspace - scimID := change.scimID - principalName := change.principalName - matchHeld := func(assignments []databricks.PrivilegeAssignment) (databricks.PrivilegeAssignment, bool) { - return matchHeldPrivilege(assignments, scimID, principalName, privilege) + resource := entitlement.GetResource() + metastoreID := resource.GetId().GetResource() + if metastoreID == "" { + return metastoreChange{}, status.Error(codes.InvalidArgument, "databricks-connector: missing metastore resource id") } - assignments, rateLimit, err := m.client.DrainPermissions(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName()) - noteRateLimit(&annos, rateLimit) + privilege, err := parseEntitlementPrivilege(entitlement.GetId()) if err != nil { - return annos, fmt.Errorf("databricks-connector: failed to read privileges on %s %s: %w", - databricks.SecurableMetastore, ref.permissionsName(), err) + return metastoreChange{}, err + } + switch { + case privilege == ownerEntitlement: + return metastoreChange{}, status.Errorf(codes.InvalidArgument, + "databricks-connector: metastore ownership cannot be provisioned; change the owner in Databricks") + case slices.Contains(legacyPrivileges, strings.ToUpper(strings.TrimSpace(privilege))): + return metastoreChange{}, status.Errorf(codes.InvalidArgument, + "databricks-connector: %s is a legacy alias Databricks rewrites; use the explicit privilege", privilege) + case !slices.Contains(metastorePrivileges, privilege): + return metastoreChange{}, status.Errorf(codes.InvalidArgument, + "databricks-connector: %s is not a grantable metastore privilege", privilege) } - if _, held := matchHeld(assignments); held { - annos.Update(&v2.GrantAlreadyExists{}) + workspace, _ := rs.GetProfileStringValue(rs.GetProfile(resource), profileKeyWorkspace) + if workspace == "" { + return metastoreChange{}, status.Errorf(codes.FailedPrecondition, + "databricks-connector: metastore %s has no workspace on its profile", metastoreID) + } + + scimID, err := unityPrincipalScimID(principalID) + if err != nil { + return metastoreChange{}, fmt.Errorf("databricks-connector: %w", err) + } + + principalName, err := unityPrincipalName(ctx, m.client, principalID) + if err != nil { + return metastoreChange{}, fmt.Errorf("databricks-connector: failed to resolve principal %s: %w", principalID.GetResource(), err) + } + // Revoke can still remove a deleted principal's row by its id. + if isGrant && principalName == "" { + return metastoreChange{}, status.Errorf(codes.NotFound, "databricks-connector: principal %s no longer exists in Databricks", scimID) + } + + return metastoreChange{ + metastoreID: metastoreID, + workspace: workspace, + privilege: privilege, + scimID: scimID, + principalName: principalName, + }, nil +} + +func (c metastoreChange) matchHeld(assignments []databricks.PrivilegeAssignment) (databricks.PrivilegeAssignment, bool) { + return matchHeldPrivilege(assignments, c.scimID, c.principalName, c.privilege) +} + +func (c metastoreChange) updateError(verb string, err error) error { + if isForbiddenError(err) { + return uhttp.WrapErrors(codes.PermissionDenied, + fmt.Sprintf("databricks-connector: cannot %s %s on metastore %s: the connector must own the metastore or be Admin on an attached workspace", + verb, c.privilege, c.metastoreID), + err) + } + + return fmt.Errorf("databricks-connector: failed to %s %s on metastore %s: %w", verb, c.privilege, c.metastoreID, err) +} +func (m *metastoreBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { + annos := annotations.Annotations{} + change, err := m.resolveChange(ctx, principal.GetId(), entitlement, true) + if err != nil { + return annos, err + } + + // Read past the cache: a stale page would skip a needed PATCH. + assignments, rateLimit, err := m.client.DrainPermissions(ctx, change.workspace, databricks.SecurableMetastore, change.metastoreID) + noteRateLimit(&annos, rateLimit) + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to read privileges on metastore %s: %w", change.metastoreID, err) + } + if _, held := change.matchHeld(assignments); held { + annos.Update(&v2.GrantAlreadyExists{}) return annos, nil } var echoed []databricks.PrivilegeAssignment - rateLimit, err = m.client.UpdatePermissionsUntil(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), - []databricks.PermissionsChange{{Principal: principalName, Add: []string{privilege}}}, + rateLimit, err = m.client.UpdatePermissionsUntil(ctx, change.workspace, databricks.SecurableMetastore, change.metastoreID, + []databricks.PermissionsChange{{Principal: change.principalName, Add: []string{change.privilege}}}, func(page []databricks.PrivilegeAssignment) (bool, error) { echoed = append(echoed, page...) - _, held := matchHeld(echoed) + _, held := change.matchHeld(echoed) return held, nil }) noteRateLimit(&annos, rateLimit) if err != nil { - if isForbiddenError(err) { - return annos, uhttp.WrapErrors( - codes.PermissionDenied, - // MANAGE is not assignable on a metastore, so naming it here would send the - // operator after a privilege Databricks rejects. The authority is ownership. - fmt.Sprintf("databricks-connector: cannot grant %s on %s %s: the connector principal must own the metastore or be assigned Admin directly on a workspace attached to it", - privilege, databricks.SecurableMetastore, ref.permissionsName()), - err, - ) - } - - return annos, fmt.Errorf("databricks-connector: failed to grant %s on %s %s: %w", - privilege, databricks.SecurableMetastore, ref.permissionsName(), err) + return annos, change.updateError("grant", err) } - if _, held := matchHeld(echoed); !held { + // Databricks no-ops a PATCH it cannot apply, so only the echo confirms it. + if _, held := change.matchHeld(echoed); !held { return annos, status.Errorf(codes.Internal, - "databricks-connector: Databricks accepted granting %s to %s on %s %s but did not report the privilege on the securable afterwards, "+ - "so the grant was not applied", - privilege, principalName, databricks.SecurableMetastore, ref.permissionsName()) + "databricks-connector: granting %s on metastore %s was accepted but not applied", change.privilege, change.metastoreID) } return annos, nil } func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annotations.Annotations, error) { - change, annos, done, err := m.resolveSecurableChange(ctx, revoked.GetPrincipal().GetId(), revoked.GetEntitlement(), metastoreKind, changeRevoke) - if done { + annos := annotations.Annotations{} + change, err := m.resolveChange(ctx, revoked.GetPrincipal().GetId(), revoked.GetEntitlement(), false) + if err != nil { return annos, err } - ref := change.ref - privilege := change.privilege - workspace := change.workspace - scimID := change.scimID - principalName := change.principalName - matchHeld := func(assignments []databricks.PrivilegeAssignment) (databricks.PrivilegeAssignment, bool) { - return matchHeldPrivilege(assignments, scimID, principalName, privilege) - } - var seen []databricks.PrivilegeAssignment - rateLimit, err := m.client.ForEachUncachedPermissionsPage(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), "", + rateLimit, err := m.client.ForEachUncachedPermissionsPage(ctx, change.workspace, databricks.SecurableMetastore, change.metastoreID, "", func(page []databricks.PrivilegeAssignment) (bool, error) { seen = append(seen, page...) - matched, ok := matchHeld(seen) - if !ok { - return false, nil - } - matchedID := matched.PrincipalID.String() - return scimID != "" && matchedID == scimID && matchedID != "0", nil + matched, ok := change.matchHeld(seen) + // Only an id match is final; a name match may be superseded on a later page. + return ok && change.scimID != "" && matched.PrincipalID.String() == change.scimID && change.scimID != "0", nil }) noteRateLimit(&annos, rateLimit) if err != nil { - return annos, fmt.Errorf("databricks-connector: failed to read privileges on %s %s: %w", - databricks.SecurableMetastore, ref.permissionsName(), err) + return annos, fmt.Errorf("databricks-connector: failed to read privileges on metastore %s: %w", change.metastoreID, err) } - held, ok := matchHeld(seen) + held, ok := change.matchHeld(seen) if !ok { annos.Update(&v2.GrantAlreadyRevoked{}) - return annos, nil } - var removal databricks.PermissionsChange + removal := databricks.PermissionsChange{Principal: change.principalName, Remove: []string{change.privilege}} if heldID := held.PrincipalID.String(); heldID != "" && heldID != "0" { - removal = databricks.PermissionsChange{PrincipalID: held.PrincipalID, Remove: []string{privilege}} - } else { - removal = databricks.PermissionsChange{Principal: principalName, Remove: []string{privilege}} + removal = databricks.PermissionsChange{PrincipalID: held.PrincipalID, Remove: []string{change.privilege}} } - result, rateLimit, err := m.client.UpdatePermissions(ctx, workspace, databricks.SecurableMetastore, ref.permissionsName(), + result, rateLimit, err := m.client.UpdatePermissions(ctx, change.workspace, databricks.SecurableMetastore, change.metastoreID, []databricks.PermissionsChange{removal}) noteRateLimit(&annos, rateLimit) if err != nil { - if isForbiddenError(err) { - return annos, uhttp.WrapErrors( - codes.PermissionDenied, - fmt.Sprintf("databricks-connector: cannot revoke %s on %s %s: the connector principal must own the metastore or be assigned Admin directly on a workspace attached to it", - privilege, databricks.SecurableMetastore, ref.permissionsName()), - err, - ) - } - - return annos, fmt.Errorf("databricks-connector: failed to revoke %s on %s %s: %w", - privilege, databricks.SecurableMetastore, ref.permissionsName(), err) + return annos, change.updateError("revoke", err) } - - if _, stillHeld := matchHeld(result); stillHeld { - label := principalName - if label == "" { - label = scimID - } - + if _, stillHeld := change.matchHeld(result); stillHeld { return annos, status.Errorf(codes.Internal, - "databricks-connector: Databricks accepted revoking %s from %s on %s %s but still reports the privilege on the securable, "+ - "so the revoke was not applied", - privilege, label, databricks.SecurableMetastore, ref.permissionsName()) + "databricks-connector: revoking %s on metastore %s was accepted but not applied", change.privilege, change.metastoreID) } return annos, nil diff --git a/pkg/connector/metastore_helpers.go b/pkg/connector/metastore_helpers.go new file mode 100644 index 00000000..ce330f38 --- /dev/null +++ b/pkg/connector/metastore_helpers.go @@ -0,0 +1,356 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/types/grant" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + securableTypeMetastore = "METASTORE" + workspaceStatusRunning = "RUNNING" + + profileKeyMetastoreID = "metastore_id" + profileKeyOwner = "owner" + profileKeySecurableType = "securable_type" + profileKeyRegion = "region" + profileKeyWorkspace = "workspace" + profileKeyWorkspaceID = "workspace_id" + profileKeyWorkspaceName = "workspace_name" + + // ownerEntitlement is single-valued and Databricks cannot remove an owner, so it is never grantable. + ownerEntitlement = "owner" +) + +var ( + // Canonical UPPER_SNAKE names, the form the permissions API answers with. + // ALL_PRIVILEGES and MANAGE are rejected on a metastore. + metastorePrivileges = []string{ + "CREATE_CATALOG", + "CREATE_CLEAN_ROOM", + "CREATE_CONNECTION", + "CREATE_EXTERNAL_LOCATION", + "CREATE_EXTERNAL_METADATA", + "CREATE_PROVIDER", + "CREATE_RECIPIENT", + "CREATE_SHARE", + "CREATE_SERVICE_CREDENTIAL", + "CREATE_STORAGE_CREDENTIAL", + "MANAGE_ALLOWLIST", + "READ_METADATA", + "SET_SHARE_PERMISSION", + "USE_MARKETPLACE_ASSETS", + "USE_PROVIDER", + "USE_RECIPIENT", + "USE_SHARE", + } + + // Hive-era aliases Databricks accepts and rewrites server-side, so they never read back. + legacyPrivileges = []string{"CREATE", "CREATE_VIEW", "USAGE"} +) + +// syncPrincipals says which principal types this sync includes. +type syncPrincipals struct { + groups bool + servicePrincipals bool +} + +func emitPrincipal(sync syncPrincipals, resourceType string) bool { + switch resourceType { + case userResourceType.Id: + return true + case groupResourceType.Id: + return sync.groups + case servicePrincipalResourceType.Id: + return sync.servicePrincipals + default: + return false + } +} + +func parseEntitlementPrivilege(entitlementID string) (string, error) { + separator := strings.LastIndex(entitlementID, ":") + if separator < 0 || separator == len(entitlementID)-1 { + return "", status.Errorf(codes.InvalidArgument, "databricks-connector: invalid entitlement id %q", entitlementID) + } + + return entitlementID[separator+1:], nil +} + +// matchHeldPrivilege finds the assignment carrying privilege for this principal. +// The SCIM id wins; the name is used only when the assignment has no id. +func matchHeldPrivilege(assignments []databricks.PrivilegeAssignment, scimID, principalName, privilege string) (databricks.PrivilegeAssignment, bool) { + var nameOnly databricks.PrivilegeAssignment + var sawNameOnly bool + for _, assignment := range assignments { + if !slices.Contains(assignment.Privileges, privilege) { + continue + } + + assignmentID := assignment.PrincipalID.String() + if scimID != "" && assignmentID == scimID && assignmentID != "0" { + return assignment, true + } + if principalName == "" || assignment.Principal != principalName { + continue + } + if scimID != "" && assignmentID != "" && assignmentID != "0" && assignmentID != scimID { + continue + } + if !sawNameOnly { + nameOnly, sawNameOnly = assignment, true + } + } + + return nameOnly, sawNameOnly +} + +// groupSecurableGrantOptions lets C1 expand a group-held privilege through the +// group's members. Shallow stays unset so nested groups propagate. +func groupSecurableGrantOptions(principalId *v2.ResourceId) []grant.GrantOption { + if principalId.GetResourceType() != groupResourceType.Id { + return nil + } + + return []grant.GrantOption{grant.WithAnnotation(&v2.GrantExpandable{ + EntitlementIds: []string{fmt.Sprintf("group:%s:%s", principalId.GetResource(), groupMemberEntitlement)}, + ResourceTypeIds: []string{userResourceType.Id, groupResourceType.Id, servicePrincipalResourceType.Id}, + })} +} + +func isForbiddenError(err error) bool { + return apiStatus(err) == http.StatusForbidden +} + +func apiStatus(err error) int { + var apiErr *databricks.APIError + if errors.As(err, &apiErr) { + return apiErr.StatusCode + } + + return 0 +} + +// unityPrincipalScimID is the numeric SCIM id a privilege assignment carries in principal_id. +func unityPrincipalScimID(principal *v2.ResourceId) (string, error) { + if principal.GetResourceType() != groupResourceType.Id { + return principal.GetResource(), nil + } + + _, parsed, err := parseResourceId(principal.GetResource()) + if err != nil { + return "", fmt.Errorf("failed to parse group resource id: %w", err) + } + + return parsed.GetResource(), nil +} + +// unityPrincipalName is the user's userName, the group's displayName or the +// service principal's applicationId. Empty when the principal no longer exists. +func unityPrincipalName(ctx context.Context, c *databricks.Client, principal *v2.ResourceId) (string, error) { + principalId, err := unityPrincipalScimID(principal) + if err != nil { + return "", err + } + + qualified, err := preparePrincipalId(ctx, c, "", principal.GetResourceType(), principalId) + if err != nil { + return "", err + } + + _, name, _ := strings.Cut(qualified, "/") + + return name, nil +} + +// scopedWorkspaces indexes, by numeric id, the workspaces this sync may read +// through, and marks which of them are RUNNING. ListWorkspaces already drops the +// excluded ones. +func (m *metastoreBuilder) scopedWorkspaces(ctx context.Context, annos *annotations.Annotations) (map[string]databricks.Workspace, map[string]bool, error) { + workspaces, rateLimit, err := m.client.ListWorkspaces(ctx) + noteRateLimit(annos, rateLimit) + if err != nil { + return nil, nil, fmt.Errorf("databricks-connector: failed to list workspaces: %w", err) + } + + scoped := make(map[string]databricks.Workspace, len(workspaces)) + running := make(map[string]bool, len(workspaces)) + for _, workspace := range workspaces { + id := strconv.Itoa(workspace.ID) + if workspace.DeploymentName == "" { + continue + } + if len(m.workspaces) > 0 { + if _, ok := matchConfiguredWorkspace(m.workspaces, workspace.DeploymentName, workspace.Name, id); !ok { + continue + } + } + scoped[id] = workspace + running[id] = workspace.Status == "" || strings.EqualFold(workspace.Status, workspaceStatusRunning) + } + + return scoped, running, nil +} + +// routeMetastore picks the first running in-scope workspace attached to the +// metastore. inScope reports whether any in-scope workspace is attached at all. +func (m *metastoreBuilder) routeMetastore( + ctx context.Context, + metastoreID string, + scoped map[string]databricks.Workspace, + running map[string]bool, + annos *annotations.Annotations, +) (*databricks.Workspace, bool, error) { + ids, rateLimit, err := m.client.ListMetastoreWorkspaces(ctx, metastoreID) + noteRateLimit(annos, rateLimit) + if err != nil { + return nil, false, err + } + + inScope := false + for _, id := range ids { + workspace, ok := scoped[id] + if !ok { + continue + } + if running[id] { + return &workspace, true, nil + } + inScope = true + } + + return nil, inScope, nil +} + +// principalResolver maps a privilege assignment's principal to a C1 resource id. +// One field carries a userName, a displayName or an applicationId with no type, +// so lookups go through account SCIM in a fixed order. Results are cached for +// one Grants call. +type principalResolver struct { + client *databricks.Client + annos *annotations.Annotations + byID map[string]*v2.ResourceId + byName map[string]*v2.ResourceId +} + +func newPrincipalResolver(client *databricks.Client, annos *annotations.Annotations) *principalResolver { + return &principalResolver{client: client, annos: annos, byID: map[string]*v2.ResourceId{}, byName: map[string]*v2.ResourceId{}} +} + +// resolve prefers principal_id and never falls back to the name when the id is +// present: a deleted principal keeps only its id, and the name is ambiguous. +func (p *principalResolver) resolve(ctx context.Context, id, name string) (*v2.ResourceId, error) { + if id != "" && id != "0" { + if resourceId, ok := p.byID[id]; ok { + return resourceId, nil + } + resourceId, err := p.lookupID(ctx, id) + if err != nil { + return nil, err + } + p.byID[id] = resourceId + if _, ok := p.byName[name]; !ok && name != "" && resourceId != nil { + p.byName[name] = resourceId + } + + return resourceId, nil + } + if name == "" { + return nil, nil + } + if resourceId, ok := p.byName[name]; ok { + return resourceId, nil + } + resourceId, err := p.lookupName(ctx, name) + if err != nil { + return nil, err + } + p.byName[name] = resourceId + + return resourceId, nil +} + +func (p *principalResolver) lookupID(ctx context.Context, id string) (*v2.ResourceId, error) { + user, rateLimit, err := p.client.GetUser(ctx, "", id) + noteRateLimit(p.annos, rateLimit) + if err != nil && !isNotFoundError(err) { + return nil, fmt.Errorf("failed to resolve principal %s as a user: %w", id, err) + } + if err == nil && user != nil && user.ID != "" { + return &v2.ResourceId{ResourceType: userResourceType.Id, Resource: user.ID}, nil + } + + // A filtered list answers this without pulling the group's members. + displayName, rateLimit, err := p.client.FindGroupDisplayName(ctx, "", id) + noteRateLimit(p.annos, rateLimit) + if err != nil { + return nil, fmt.Errorf("failed to resolve principal %s as a group: %w", id, err) + } + if displayName != "" { + return p.groupID(ctx, id) + } + + servicePrincipal, rateLimit, err := p.client.GetServicePrincipal(ctx, "", id) + noteRateLimit(p.annos, rateLimit) + if err != nil && !isNotFoundError(err) { + return nil, fmt.Errorf("failed to resolve principal %s as a service principal: %w", id, err) + } + if err == nil && servicePrincipal != nil && servicePrincipal.ID != "" { + return &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: servicePrincipal.ID}, nil + } + + return nil, nil +} + +func (p *principalResolver) lookupName(ctx context.Context, name string) (*v2.ResourceId, error) { + userID, rateLimit, err := p.client.FindUserID(ctx, "", name) + noteRateLimit(p.annos, rateLimit) + if err != nil { + return nil, fmt.Errorf("failed to resolve %q as a user: %w", name, err) + } + if userID != "" { + return &v2.ResourceId{ResourceType: userResourceType.Id, Resource: userID}, nil + } + + groupID, rateLimit, err := p.client.FindGroupID(ctx, "", name) + noteRateLimit(p.annos, rateLimit) + if err != nil { + return nil, fmt.Errorf("failed to resolve %q as a group: %w", name, err) + } + if groupID != "" { + return p.groupID(ctx, groupID) + } + + servicePrincipalID, rateLimit, err := p.client.FindServicePrincipalID(ctx, "", name) + noteRateLimit(p.annos, rateLimit) + if err != nil { + return nil, fmt.Errorf("failed to resolve %q as a service principal: %w", name, err) + } + if servicePrincipalID != "" { + return &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: servicePrincipalID}, nil + } + + return nil, nil +} + +// groupID matches the id the group syncer emits for an account-level group. +func (p *principalResolver) groupID(ctx context.Context, groupID string) (*v2.ResourceId, error) { + account, err := rs.NewResourceID(accountResourceType, p.client.GetAccountId()) + if err != nil { + return nil, err + } + + return rs.NewResourceID(groupResourceType, groupResourceId(ctx, groupID, account)) +} diff --git a/pkg/connector/metastore_test.go b/pkg/connector/metastore_test.go new file mode 100644 index 00000000..d2c61efb --- /dev/null +++ b/pkg/connector/metastore_test.go @@ -0,0 +1,491 @@ +package connector + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/types/grant" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/conductorone/baton-databricks/pkg/databricks" +) + +// workspaceRouter dispatches on the deployment-name label of the request host, +// the way Databricks routes an account whose workspaces sit on separate hosts. +type workspaceRouter struct { + target string + hits map[string]int +} + +func (r *workspaceRouter) RoundTrip(req *http.Request) (*http.Response, error) { + workspace, _, _ := strings.Cut(req.URL.Host, ".") + r.hits[workspace]++ + + routed := req.Clone(req.Context()) + routed.URL.Scheme = "http" + routed.URL.Host = r.target + routed.Header.Set("X-Test-Workspace", workspace) + + return http.DefaultTransport.RoundTrip(routed) +} + +func newWorkspaceRoutedClient(t *testing.T, handler http.HandlerFunc) (*databricks.Client, *workspaceRouter) { + t.Helper() + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + router := &workspaceRouter{target: strings.TrimPrefix(srv.URL, "http://"), hits: map[string]int{}} + client, err := databricks.NewClient(context.Background(), &http.Client{Transport: router}, + testHostname, testAccountHost, testAccountId, "", &databricks.NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + return client, router +} + +// fakeAccount serves the account, Unity Catalog and SCIM endpoints the metastore syncer calls. +type fakeAccount struct { + metastores []map[string]any + assignments map[string]any // metastore id -> []int workspace ids, or an HTTP status + workspaces []map[string]any + permissions []map[string]any + permStatus int + patchStatus int + users map[string]string // id -> userName + groups map[string]string // id -> displayName + sps map[string]string // id -> applicationId + + permHosts []string + patches []map[string]any + scimGets map[string]int +} + +func defaultFakeAccount() *fakeAccount { + return &fakeAccount{ + metastores: []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "eng", "region": "us-east-1"}}, + assignments: map[string]any{"ms-a": []int{1}}, + workspaces: []map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + }, + users: map[string]string{"1": "alice@example.com"}, + groups: map[string]string{"2": "eng"}, + sps: map[string]string{"3": "app-9"}, + scimGets: map[string]int{}, + } +} + +func scimList(field string, records map[string]string, filter string) []map[string]any { + key, value, _ := strings.Cut(filter, " eq ") + value, _ = strconv.Unquote(value) + var out []map[string]any + for id, name := range records { + if (key == "id" && id == value) || (key != "id" && name == value) { + out = append(out, map[string]any{"id": id, field: name}) + } + } + + return out +} + +func (f *fakeAccount) handler(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + encode := func(v any) { _ = json.NewEncoder(w).Encode(v) } + switch { + case strings.HasSuffix(path, "/metastores"): + encode(map[string]any{"metastores": f.metastores}) + case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): + parts := strings.Split(path, "/") + switch v := f.assignments[parts[len(parts)-2]].(type) { + case int: + http.Error(w, `{"message":"denied"}`, v) + default: + encode(map[string]any{"workspace_ids": v}) + } + case strings.HasSuffix(path, "/workspaces"): + encode(f.workspaces) + case strings.Contains(path, "/unity-catalog/permissions/metastore/"): + f.permHosts = append(f.permHosts, r.Header.Get("X-Test-Workspace")) + if f.permStatus != 0 { + http.Error(w, `{"message":"nope"}`, f.permStatus) + return + } + if r.Method == http.MethodPatch { + if f.patchStatus != 0 { + http.Error(w, `{"message":"denied"}`, f.patchStatus) + return + } + var body struct { + Changes []map[string]any `json:"changes"` + } + decoder := json.NewDecoder(r.Body) + decoder.UseNumber() + _ = decoder.Decode(&body) + for _, change := range body.Changes { + f.patches = append(f.patches, change) + f.apply(change) + } + } + encode(map[string]any{"privilege_assignments": f.permissions}) + case strings.Contains(path, "/scim/v2/"): + f.scim(w, r) + default: + t.Errorf("unexpected request %s %s", r.Method, path) + http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) + } + } +} + +func (f *fakeAccount) scim(w http.ResponseWriter, r *http.Request) { + _, rest, _ := strings.Cut(r.URL.Path, "/scim/v2/") + kind, id, _ := strings.Cut(rest, "/") + field, records := map[string]string{"Users": "userName", "Groups": "displayName", "ServicePrincipals": "applicationId"}[kind], + map[string]map[string]string{"Users": f.users, "Groups": f.groups, "ServicePrincipals": f.sps}[kind] + if id != "" { + f.scimGets[kind+"/"+id]++ + if name, ok := records[id]; ok { + _ = json.NewEncoder(w).Encode(map[string]any{"id": id, field: name}) + return + } + http.Error(w, `{"message":"missing"}`, http.StatusNotFound) + return + } + found := scimList(field, records, r.URL.Query().Get("filter")) + _ = json.NewEncoder(w).Encode(map[string]any{"totalResults": len(found), "Resources": found}) +} + +func (f *fakeAccount) apply(change map[string]any) { + added, _ := change["add"].([]any) + for _, privilege := range added { + f.permissions = append(f.permissions, map[string]any{ + "principal": change["principal"], "principal_id": 1, "privileges": []string{privilege.(string)}, + }) + } + if remove, ok := change["remove"].([]any); ok { + kept := f.permissions[:0] + for _, p := range f.permissions { + if fmt.Sprint(p["principal_id"]) != fmt.Sprint(change["principal_id"]) || !slices.Contains(p["privileges"].([]string), remove[0].(string)) { + kept = append(kept, p) + } + } + f.permissions = kept + } +} + +func newTestMetastoreBuilder(t *testing.T, f *fakeAccount, allowlist []string, sync syncPrincipals) *metastoreBuilder { + t.Setenv("BATON_HTTP_CACHE_TTL", "0") + client, _ := newWorkspaceRoutedClient(t, f.handler(t)) + + return newMetastoreBuilder(client, configuredWorkspaceSet(allowlist), sync) +} + +var testAccountResourceID = &v2.ResourceId{ResourceType: "account", Resource: testAccountId} + +func testMetastore(t *testing.T, workspace string) *v2.Resource { + t.Helper() + r, err := metastoreResource(context.Background(), &databricks.Metastore{MetastoreID: "ms-a", Name: "main", Owner: "eng"}, + testAccountResourceID, workspace, "1", "prod") + if err != nil { + t.Fatalf("metastoreResource: %v", err) + } + + return r +} + +func TestMetastoreListPicksTheFirstUsableWorkspace(t *testing.T) { + tests := []struct { + name string + allowlist []string + want map[string]string // metastore id -> profile workspace + }{ + {"no allowlist", nil, map[string]string{"ms-a": "wsa"}}, + {"allowlist by name", []string{"third"}, map[string]string{"ms-a": "wsc"}}, + {"allowlist matching nothing", []string{"nope"}, map[string]string{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := defaultFakeAccount() + f.metastores = append(f.metastores, + map[string]any{"metastore_id": "ms-missing"}, + map[string]any{"metastore_id": "ms-elsewhere"}) + f.assignments = map[string]any{ + "ms-a": []int{2, 1, 3}, + "ms-missing": http.StatusNotFound, + "ms-elsewhere": []int{4}, + } + f.workspaces = []map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "PROVISIONING"}, + {"workspace_id": 3, "workspace_name": "third", "deployment_name": "wsc", "workspace_status": "RUNNING"}, + } + m := newTestMetastoreBuilder(t, f, test.allowlist, syncPrincipals{}) + + resources, _, err := m.List(context.Background(), testAccountResourceID, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("List: %v", err) + } + got := map[string]string{} + for _, r := range resources { + got[r.GetId().GetResource()], _ = rs.GetProfileStringValue(rs.GetProfile(r), profileKeyWorkspace) + } + if len(got) != len(test.want) { + t.Fatalf("List = %v, want %v", got, test.want) + } + for id, workspace := range test.want { + if got[id] != workspace { + t.Errorf("metastore %s routed through %q, want %q", id, got[id], workspace) + } + } + }) + } +} + +func TestMetastoreListProfile(t *testing.T) { + m := newTestMetastoreBuilder(t, defaultFakeAccount(), nil, syncPrincipals{}) + resources, _, err := m.List(context.Background(), testAccountResourceID, rs.SyncOpAttrs{}) + if err != nil || len(resources) != 1 { + t.Fatalf("List = %v, %v", resources, err) + } + profile := rs.GetProfile(resources[0]) + for key, want := range map[string]string{ + profileKeyWorkspace: "wsa", profileKeyWorkspaceID: "1", profileKeyWorkspaceName: "prod", + profileKeyOwner: "eng", profileKeyMetastoreID: "ms-a", profileKeySecurableType: securableTypeMetastore, + } { + if got, _ := rs.GetProfileStringValue(profile, key); got != want { + t.Errorf("profile[%s] = %q, want %q", key, got, want) + } + } + if resources[0].GetDisplayName() != "main" { + t.Errorf("display name = %q, want main", resources[0].GetDisplayName()) + } +} + +func TestMetastoreListFailsWhenItCannotRoute(t *testing.T) { + tests := []struct { + name string + assignment any + }{ + {"assignment list answers 403", http.StatusForbidden}, + {"assignment list answers 500", http.StatusInternalServerError}, + {"only in-scope workspace is not running", []int{2}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := defaultFakeAccount() + f.assignments["ms-a"] = test.assignment + f.workspaces = append(f.workspaces, + map[string]any{"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "PROVISIONING"}) + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) + + if _, _, err := m.List(context.Background(), testAccountResourceID, rs.SyncOpAttrs{}); err == nil { + t.Fatal("List succeeded") + } + }) + } +} + +func grantKeys(grants []*v2.Grant) []string { + keys := make([]string, 0, len(grants)) + for _, g := range grants { + keys = append(keys, g.GetPrincipal().GetId().GetResource()+" "+g.GetEntitlement().GetId()) + } + slices.Sort(keys) + + return keys +} + +func TestMetastoreGrants(t *testing.T) { + f := defaultFakeAccount() + f.permissions = []map[string]any{ + {"principal": "alice@example.com", "principal_id": 1, "privileges": []string{"CREATE_CATALOG", "NOT_OFFERED"}}, + {"principal": "alice@example.com", "principal_id": 1, "privileges": []string{"USE_SHARE"}}, + {"principal": "eng", "principal_id": 2, "privileges": []string{"READ_METADATA"}}, + {"principal": "app-9", "principal_id": 3, "privileges": []string{"CREATE_SHARE"}}, + {"principal": "", "principal_id": 99, "privileges": []string{"CREATE_SHARE"}}, + } + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{groups: true}) + + grants, _, err := m.Grants(context.Background(), testMetastore(t, "wsa"), rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("Grants: %v", err) + } + group := "account/" + testAccountId + "/group/2" + want := []string{ + "1 metastore:ms-a:CREATE_CATALOG", + "1 metastore:ms-a:USE_SHARE", + group + " metastore:ms-a:READ_METADATA", + group + " metastore:ms-a:owner", + } + slices.Sort(want) + if got := grantKeys(grants); !slices.Equal(got, want) { + t.Fatalf("grants = %v, want %v", got, want) + } + if !slices.Equal(f.permHosts, []string{"wsa"}) { + t.Errorf("permissions read through %v, want [wsa]", f.permHosts) + } + if f.scimGets["Users/1"] != 1 { + t.Errorf("user 1 resolved %d times, want 1", f.scimGets["Users/1"]) + } + for _, g := range grants { + annos := g.GetAnnotations() + if !strings.HasSuffix(g.GetEntitlement().GetId(), ":owner") { + continue + } + if len(annos) != 2 { + t.Errorf("owner grant annotations = %v, want expandable and immutable", annos) + } + } +} + +func TestMetastoreGrantsReturnNothing(t *testing.T) { + tests := []struct { + name string + workspace string + permStatus int + }{ + {"no workspace on the profile", "", 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := defaultFakeAccount() + f.permStatus = test.permStatus + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{groups: true}) + + grants, _, err := m.Grants(context.Background(), testMetastore(t, test.workspace), rs.SyncOpAttrs{}) + if err != nil || len(grants) != 0 { + t.Fatalf("Grants = %v, %v; want none and no error", grants, err) + } + }) + } +} + +func TestMetastoreGrantsFailOnErrors(t *testing.T) { + for _, code := range []int{http.StatusNotFound, http.StatusInternalServerError} { + f := defaultFakeAccount() + f.permStatus = code + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) + + _, _, err := m.Grants(context.Background(), testMetastore(t, "wsa"), rs.SyncOpAttrs{}) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("Grants on %d = %v, want FailedPrecondition", code, err) + } + } +} + +var alice = &v2.Resource{Id: &v2.ResourceId{ResourceType: "user", Resource: "1"}} + +func TestMetastoreGrant(t *testing.T) { + f := defaultFakeAccount() + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) + g := grant.NewGrant(testMetastore(t, "wsa"), "CREATE_CATALOG", alice.GetId()) + + if _, err := m.Grant(context.Background(), alice, g.GetEntitlement()); err != nil { + t.Fatalf("Grant: %v", err) + } + if len(f.patches) != 1 || f.patches[0]["principal"] != "alice@example.com" { + t.Fatalf("patches = %v, want one add for alice@example.com", f.patches) + } + + annos, err := m.Grant(context.Background(), alice, g.GetEntitlement()) + if err != nil || !annos.Contains(&v2.GrantAlreadyExists{}) { + t.Fatalf("second Grant = %v, %v; want GrantAlreadyExists", annos, err) + } + if len(f.patches) != 1 { + t.Errorf("a held privilege was patched again: %v", f.patches) + } +} + +func TestMetastoreGrantRejections(t *testing.T) { + tests := []struct { + name string + workspace string + privilege string + patchStatus int + code codes.Code + }{ + {"owner", "wsa", ownerEntitlement, 0, codes.InvalidArgument}, + {"legacy alias", "wsa", "USAGE", 0, codes.InvalidArgument}, + {"not offered", "wsa", "MANAGE", 0, codes.InvalidArgument}, + {"no workspace on the profile", "", "CREATE_CATALOG", 0, codes.FailedPrecondition}, + {"forbidden", "wsa", "CREATE_CATALOG", http.StatusForbidden, codes.PermissionDenied}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := defaultFakeAccount() + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) + g := grant.NewGrant(testMetastore(t, test.workspace), test.privilege, alice.GetId()) + f.patchStatus = test.patchStatus + + _, err := m.Grant(context.Background(), alice, g.GetEntitlement()) + if status.Code(err) != test.code { + t.Fatalf("Grant = %v, want %s", err, test.code) + } + if len(f.patches) != 0 { + t.Errorf("patches = %v, want none", f.patches) + } + }) + } +} + +func TestMetastoreRevoke(t *testing.T) { + f := defaultFakeAccount() + f.permissions = []map[string]any{{"principal": "alice@example.com", "principal_id": 1, "privileges": []string{"CREATE_CATALOG"}}} + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) + g := grant.NewGrant(testMetastore(t, "wsa"), "CREATE_CATALOG", alice.GetId()) + + if _, err := m.Revoke(context.Background(), g); err != nil { + t.Fatalf("Revoke: %v", err) + } + if len(f.patches) != 1 || fmt.Sprint(f.patches[0]["principal_id"]) != "1" { + t.Fatalf("patches = %v, want one removal by principal_id 1", f.patches) + } + + annos, err := m.Revoke(context.Background(), g) + if err != nil || !annos.Contains(&v2.GrantAlreadyRevoked{}) { + t.Fatalf("second Revoke = %v, %v; want GrantAlreadyRevoked", annos, err) + } +} + +// Every workspace answers /api/2.1/unity-catalog/permissions with the same path +// and query, so a cache shared between hosts would misattribute privileges. +func TestPermissionsListingIsNotAnsweredByAnotherWorkspacesCachedPage(t *testing.T) { + held := map[string]string{"wsa": "alice", "wsb": "bob"} + + client, router := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { + workspace := r.Header.Get("X-Test-Workspace") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "privilege_assignments": []map[string]any{{ + "principal": held[workspace], + "privileges": []string{"CREATE_CATALOG"}, + }}, + }) + }) + + ctx := context.Background() + for _, workspace := range []string{"wsa", "wsb"} { + assignments, _, _, err := client.ListPermissions(ctx, workspace, databricks.SecurableMetastore, "ms-1", "", "") + if err != nil { + t.Fatalf("ListPermissions(%s): %v", workspace, err) + } + if router.hits[workspace] == 0 { + t.Errorf("workspace %s was never asked for its permissions; hits = %v", workspace, router.hits) + } + if len(assignments) != 1 || assignments[0].Principal != held[workspace] { + t.Errorf("workspace %s answered %+v, want %s", workspace, assignments, held[workspace]) + } + } +} diff --git a/pkg/connector/principal_index.go b/pkg/connector/principal_index.go deleted file mode 100644 index 1489f28c..00000000 --- a/pkg/connector/principal_index.go +++ /dev/null @@ -1,292 +0,0 @@ -package connector - -import ( - "context" - "fmt" - "strings" - - "github.com/conductorone/baton-databricks/pkg/databricks" - v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" - rs "github.com/conductorone/baton-sdk/pkg/types/resource" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" - "go.uber.org/zap" -) - -// principalIndex resolves the principal a privilege assignment names. The payload -// carries no type discriminator and a name-built SCIM filter cannot settle it: -// Databricks answers an unmatched filter with 200 and zero results. Built from -// account SCIM, never workspace SCIM. -type principalIndex struct { - byID map[string]*v2.ResourceId - byName map[string]*v2.ResourceId -} - -// lookup prefers the numeric id and never falls back to the name when the id is -// present but unknown: userName, displayName and applicationId share that field. -// A deleted principal arrives with an empty name and only the id. -func (p *principalIndex) lookup(assignment databricks.PrivilegeAssignment) (*v2.ResourceId, bool) { - if id := assignment.PrincipalID.String(); id != "" && id != "0" { - resourceId, ok := p.byID[id] - return resourceId, ok - } - - return p.lookupName(assignment.Principal) -} - -func (p *principalIndex) lookupName(name string) (*v2.ResourceId, bool) { - if name == "" { - return nil, false - } - - resourceId, ok := p.byName[name] - - return resourceId, ok -} - -// add records a principal; the first writer of a key wins. A name collision is expected -// because the three namespaces share one field. An id collision is not: SCIM ids are -// unique across them, so one means a grant will resolve to the wrong principal. -func (p *principalIndex) add(ctx context.Context, id string, names []string, resourceId *v2.ResourceId) { - if id != "" { - if existing, ok := p.byID[id]; ok { - if existing.GetResourceType() != resourceId.GetResourceType() || existing.GetResource() != resourceId.GetResource() { - ctxzap.Extract(ctx).Debug("databricks-connector: two principals share one SCIM id; keeping the first", - zap.String("principal_id", id), - zap.String("kept", fmt.Sprintf("%s:%s", existing.GetResourceType(), existing.GetResource())), - zap.String("dropped", fmt.Sprintf("%s:%s", resourceId.GetResourceType(), resourceId.GetResource())), - ) - } - } else { - p.byID[id] = resourceId - } - } - - for _, name := range names { - if name == "" { - continue - } - if _, ok := p.byName[name]; !ok { - p.byName[name] = resourceId - } - } -} - -func newPrincipalIndex() *principalIndex { - return &principalIndex{ - byID: make(map[string]*v2.ResourceId), - byName: make(map[string]*v2.ResourceId), - } -} - -func (u *unityCatalog) ownsPrincipal(name string) bool { - return u.ownPrincipal != "" && strings.EqualFold(name, u.ownPrincipal) -} - -// principalsForGrants builds the index Grants uses for assignments and owner. -// Each distinct principal is resolved on demand: Get* by principal_id, then -// Find* by name. -func (u *unityCatalog) principalsForGrants( - ctx context.Context, - assignments []databricks.PrivilegeAssignment, - owner string, -) (*principalIndex, *v2.RateLimitDescription, error) { - if len(assignments) == 0 { - if owner == "" { - return newPrincipalIndex(), nil, nil - } - - return u.indexOwner(ctx, owner) - } - - return u.indexAssignments(ctx, assignments, owner) -} - -// indexOwner asks each principal namespace for that one name. The first hit -// wins, in the same order lookupPrincipalByID tries (users, groups, -// service principals). -func (u *unityCatalog) indexOwner(ctx context.Context, owner string) (*principalIndex, *v2.RateLimitDescription, error) { - index := newPrincipalIndex() - - userID, rateLimit, err := u.client.FindUserID(ctx, "", owner) - if err != nil { - return nil, rateLimit, fmt.Errorf("failed to resolve owner %q as a user: %w", owner, err) - } - if userID != "" { - index.add(ctx, userID, []string{owner}, &v2.ResourceId{ResourceType: userResourceType.Id, Resource: userID}) - - return index, rateLimit, nil - } - - groupID, groupRateLimit, err := u.client.FindGroupID(ctx, "", owner) - if groupRateLimit != nil { - rateLimit = groupRateLimit - } - if err != nil { - return nil, rateLimit, fmt.Errorf("failed to resolve owner %q as a group: %w", owner, err) - } - if groupID != "" { - accountResourceId, err := rs.NewResourceID(accountResourceType, u.client.GetAccountId()) - if err != nil { - return nil, rateLimit, err - } - resourceId, err := rs.NewResourceID(groupResourceType, groupResourceId(ctx, groupID, accountResourceId)) - if err != nil { - return nil, rateLimit, err - } - index.add(ctx, groupID, []string{owner}, resourceId) - - return index, rateLimit, nil - } - - servicePrincipalID, servicePrincipalRateLimit, err := u.client.FindServicePrincipalID(ctx, "", owner) - if servicePrincipalRateLimit != nil { - rateLimit = servicePrincipalRateLimit - } - if err != nil { - return nil, rateLimit, fmt.Errorf("failed to resolve owner %q as a service principal: %w", owner, err) - } - if servicePrincipalID != "" { - index.add(ctx, servicePrincipalID, []string{owner}, &v2.ResourceId{ - ResourceType: servicePrincipalResourceType.Id, - Resource: servicePrincipalID, - }) - - return index, rateLimit, nil - } - - return index, rateLimit, nil -} - -func (u *unityCatalog) indexAssignments( - ctx context.Context, - assignments []databricks.PrivilegeAssignment, - owner string, -) (*principalIndex, *v2.RateLimitDescription, error) { - index := newPrincipalIndex() - var rateLimit *v2.RateLimitDescription - seenID := map[string]struct{}{} - seenName := map[string]struct{}{} - - resolve := func(assignment databricks.PrivilegeAssignment) error { - id := assignment.PrincipalID.String() - if id != "" && id != "0" { - if _, ok := seenID[id]; ok { - return nil - } - seenID[id] = struct{}{} - resourceId, pageRateLimit, err := u.lookupPrincipalByID(ctx, id) - if pageRateLimit != nil { - rateLimit = pageRateLimit - } - if err != nil { - return err - } - if resourceId != nil { - index.add(ctx, id, []string{assignment.Principal}, resourceId) - } - - return nil - } - - name := assignment.Principal - if name == "" { - return nil - } - if _, ok := seenName[name]; ok { - return nil - } - seenName[name] = struct{}{} - partial, pageRateLimit, err := u.indexOwner(ctx, name) - if pageRateLimit != nil { - rateLimit = pageRateLimit - } - if err != nil { - return err - } - if resourceId, ok := partial.lookupName(name); ok { - index.add(ctx, "", []string{name}, resourceId) - } - - return nil - } - - for _, assignment := range assignments { - if err := resolve(assignment); err != nil { - return nil, rateLimit, err - } - } - if owner != "" { - if err := resolve(databricks.PrivilegeAssignment{Principal: owner}); err != nil { - return nil, rateLimit, err - } - } - - return index, rateLimit, nil -} - -func (u *unityCatalog) lookupPrincipalByID( - ctx context.Context, - id string, -) (*v2.ResourceId, *v2.RateLimitDescription, error) { - user, rateLimit, err := u.client.GetUser(ctx, "", id) - if err != nil && !isNotFoundError(err) { - return nil, rateLimit, fmt.Errorf("failed to resolve principal %s as a user: %w", id, err) - } - if err == nil && user != nil && user.ID != "" { - return &v2.ResourceId{ResourceType: userResourceType.Id, Resource: user.ID}, rateLimit, nil - } - - // Account GET /Groups/{id} returns members. A filtered list with count 1 - // answers the same question without pulling that list into the cache. - displayName, groupRateLimit, err := u.client.FindGroupDisplayName(ctx, "", id) - if groupRateLimit != nil { - rateLimit = groupRateLimit - } - if err != nil { - return nil, rateLimit, fmt.Errorf("failed to resolve principal %s as a group: %w", id, err) - } - if displayName != "" { - accountResourceId, err := rs.NewResourceID(accountResourceType, u.client.GetAccountId()) - if err != nil { - return nil, rateLimit, err - } - resourceId, err := rs.NewResourceID(groupResourceType, groupResourceId(ctx, id, accountResourceId)) - if err != nil { - return nil, rateLimit, err - } - - return resourceId, rateLimit, nil - } - - servicePrincipal, servicePrincipalRateLimit, err := u.client.GetServicePrincipal(ctx, "", id) - if servicePrincipalRateLimit != nil { - rateLimit = servicePrincipalRateLimit - } - if err != nil && !isNotFoundError(err) { - return nil, rateLimit, fmt.Errorf("failed to resolve principal %s as a service principal: %w", id, err) - } - if err == nil && servicePrincipal != nil && servicePrincipal.ID != "" { - return &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: servicePrincipal.ID}, rateLimit, nil - } - - return nil, rateLimit, nil -} - -// ownPrincipalScimID resolves the connector's own service principal. The index -// cannot answer this: it is keyed by a name field the three principal namespaces -// share, so a hit there may be somebody else's. An applicationId filter on the -// service principal endpoint has no such ambiguity — it can only match a service -// principal — which is why this asks for the one record instead of walking all -// of account SCIM to find it. -func (u *unityCatalog) ownPrincipalScimID(ctx context.Context) (string, *v2.RateLimitDescription, error) { - if u.ownPrincipal == "" { - return "", nil, nil - } - - id, rateLimit, err := u.client.FindServicePrincipalID(ctx, "", u.ownPrincipal) - if err != nil { - return "", rateLimit, fmt.Errorf("failed to resolve the connector's own service principal: %w", err) - } - - return id, rateLimit, nil -} diff --git a/pkg/connector/principal_index_test.go b/pkg/connector/principal_index_test.go deleted file mode 100644 index 55b7fed1..00000000 --- a/pkg/connector/principal_index_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package connector - -import ( - "context" - "encoding/json" - "net/http" - "strings" - "testing" - - "github.com/conductorone/baton-databricks/pkg/databricks" -) - -func TestPrincipalsForGrantsOwnerOnlyDoesNotListAccount(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - var unfiltered int - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - if isUnfilteredAccountSCIM(r) { - unfiltered++ - http.Error(w, "account listing is not allowed on the owner-only path", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - switch { - case strings.Contains(r.URL.Path, "/scim/v2/Users"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "Resources": []map[string]any{{"id": "u-1", "userName": "alice@example.com"}}, - "totalResults": 1, - }) - default: - _ = json.NewEncoder(w).Encode(map[string]any{"Resources": []any{}, "totalResults": 0}) - } - }) - - uc := newUnityCatalog(client, "app-1", nil) - index, _, err := uc.principalsForGrants(context.Background(), nil, "alice@example.com") - if err != nil { - t.Fatalf("principalsForGrants: %v", err) - } - if unfiltered != 0 { - t.Fatalf("unfiltered account SCIM listings = %d, want 0", unfiltered) - } - got, ok := index.lookupName("alice@example.com") - if !ok { - t.Fatal("owner did not resolve") - } - if got.GetResourceType() != userResourceType.Id || got.GetResource() != "u-1" { - t.Fatalf("owner = %s:%s, want user:u-1", got.GetResourceType(), got.GetResource()) - } -} - -func TestPrincipalsForGrantsOwnerOnlyResolvesGroupAndServicePrincipal(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - tests := []struct { - name string - owner string - resourceType string - resource string - }{ - {name: "group", owner: "data-eng", resourceType: groupResourceType.Id, resource: "account/acc-1/group/g-1"}, - {name: "service-principal", owner: "11111111-1111-1111-1111-111111111111", resourceType: servicePrincipalResourceType.Id, resource: "sp-1"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - var unfiltered int - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - if isUnfilteredAccountSCIM(r) { - unfiltered++ - http.Error(w, "account listing is not allowed on the owner-only path", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - switch { - case strings.Contains(r.URL.Path, "/scim/v2/Groups") && strings.Contains(r.URL.Query().Get("filter"), "data-eng"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "Resources": []map[string]any{{"id": "g-1", "displayName": "data-eng"}}, - "totalResults": 1, - }) - case strings.Contains(r.URL.Path, "/scim/v2/ServicePrincipals") && strings.Contains(r.URL.Query().Get("filter"), "11111111-1111-1111-1111-111111111111"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "Resources": []map[string]any{{"id": "sp-1", "applicationId": "11111111-1111-1111-1111-111111111111"}}, - "totalResults": 1, - }) - default: - _ = json.NewEncoder(w).Encode(map[string]any{"Resources": []any{}, "totalResults": 0}) - } - }) - - uc := newUnityCatalog(client, "app-1", nil) - index, _, err := uc.principalsForGrants(context.Background(), nil, test.owner) - if err != nil { - t.Fatalf("principalsForGrants: %v", err) - } - if unfiltered != 0 { - t.Fatalf("unfiltered account SCIM listings = %d, want 0", unfiltered) - } - got, ok := index.lookupName(test.owner) - if !ok { - t.Fatal("owner did not resolve") - } - if got.GetResourceType() != test.resourceType || got.GetResource() != test.resource { - t.Fatalf("owner = %s:%s, want %s:%s", got.GetResourceType(), got.GetResource(), test.resourceType, test.resource) - } - }) - } -} - -func TestPrincipalsForGrantsWorkspaceAdminsOwnerDoesNotListAccount(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - var unfiltered int - var filtered int - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - if isUnfilteredAccountSCIM(r) { - unfiltered++ - http.Error(w, "account listing is not allowed on the owner-only path", http.StatusInternalServerError) - return - } - if strings.Contains(r.URL.Path, "/scim/v2/") { - filtered++ - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"Resources": []any{}, "totalResults": 0}) - }) - - uc := newUnityCatalog(client, "app-1", nil) - index, _, err := uc.principalsForGrants(context.Background(), nil, "_workspace_admins_workspace-1") - if err != nil { - t.Fatalf("principalsForGrants: %v", err) - } - if unfiltered != 0 { - t.Fatalf("unfiltered account SCIM listings = %d, want 0", unfiltered) - } - if filtered != 3 { - t.Fatalf("filtered lookups = %d, want 3 (user, group, service principal)", filtered) - } - if _, ok := index.lookupName("_workspace_admins_workspace-1"); ok { - t.Fatal("workspace-admins owner should stay unresolved") - } -} - -func TestPrincipalsForGrantsAssignmentsResolvePerPrincipal(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - var unfiltered int - var gets int - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - if isUnfilteredAccountSCIM(r) { - unfiltered++ - http.Error(w, "account listing is not allowed on the assignment path", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - if strings.HasSuffix(r.URL.Path, "/scim/v2/Users/1") { - gets++ - _ = json.NewEncoder(w).Encode(map[string]any{"id": "1", "userName": "alice@example.com"}) - return - } - http.Error(w, `{"message":"missing"}`, http.StatusNotFound) - }) - - uc := newUnityCatalog(client, "app-1", nil) - index, _, err := uc.principalsForGrants(context.Background(), []databricks.PrivilegeAssignment{{ - Principal: "alice@example.com", - PrincipalID: json.Number("1"), - Privileges: []string{"USE_CATALOG"}, - }}, "") - if err != nil { - t.Fatalf("principalsForGrants: %v", err) - } - if unfiltered != 0 { - t.Fatalf("unfiltered account SCIM listings = %d, want 0", unfiltered) - } - if gets == 0 { - t.Fatal("expected a GetUser by principal_id") - } - got, ok := index.lookup(databricks.PrivilegeAssignment{Principal: "alice@example.com", PrincipalID: json.Number("1")}) - if !ok || got.GetResource() != "1" { - t.Fatalf("assignment lookup = %v %v, want user 1", got, ok) - } -} - -func TestPrincipalsForGrantsEmptyPageMakesNoSCIMCalls(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - var hits int - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - hits++ - http.Error(w, "no SCIM call expected", http.StatusInternalServerError) - }) - - uc := newUnityCatalog(client, "app-1", nil) - index, _, err := uc.principalsForGrants(context.Background(), nil, "") - if err != nil { - t.Fatalf("principalsForGrants: %v", err) - } - if hits != 0 { - t.Fatalf("SCIM calls = %d, want 0", hits) - } - if _, ok := index.lookupName("anyone"); ok { - t.Fatal("empty index should resolve nobody") - } -} - -func isUnfilteredAccountSCIM(r *http.Request) bool { - if r.URL.Query().Get("filter") != "" { - return false - } - - return strings.HasSuffix(r.URL.Path, "/scim/v2/Users") || - strings.HasSuffix(r.URL.Path, "/scim/v2/Groups") || - strings.HasSuffix(r.URL.Path, "/scim/v2/ServicePrincipals") -} diff --git a/pkg/connector/privileges.go b/pkg/connector/privileges.go deleted file mode 100644 index db53eb00..00000000 --- a/pkg/connector/privileges.go +++ /dev/null @@ -1,53 +0,0 @@ -package connector - -import ( - "slices" - "strings" -) - -// The canonical UPPER_SNAKE form the API answers with. It accepts lowercase and -// aliases on input, so a privilege offered under another spelling cannot round-trip. -const ( - // ownerEntitlement names the securable's owner. Ownership is single-valued and - // Databricks has no notion of removing an owner, so it is never grantable. - ownerEntitlement = "owner" -) - -var ( - // metastorePrivileges is the set for the metastore itself. ALL_PRIVILEGES and MANAGE - // are both rejected here, which is why a metastore's grant visibility cannot be - // answered by a privilege read. READ_METADATA is the only entry that inherits down. - metastorePrivileges = []string{ - "CREATE_CATALOG", - "CREATE_CLEAN_ROOM", - "CREATE_CONNECTION", - "CREATE_EXTERNAL_LOCATION", - "CREATE_EXTERNAL_METADATA", - "CREATE_PROVIDER", - "CREATE_RECIPIENT", - "CREATE_SHARE", - "CREATE_SERVICE_CREDENTIAL", - "CREATE_STORAGE_CREDENTIAL", - "MANAGE_ALLOWLIST", - "READ_METADATA", - "SET_SHARE_PERMISSION", - "USE_MARKETPLACE_ASSETS", - "USE_PROVIDER", - "USE_RECIPIENT", - "USE_SHARE", - } - - // legacyPrivileges are Hive-era aliases the API accepts on input and silently - // rewrites: CREATE on a schema becomes CREATE_TABLE plus CREATE_FUNCTION. They are - // never echoed back, so they must not be offered. - legacyPrivileges = []string{ - "CREATE", - "CREATE_VIEW", - "USAGE", - } -) - -// An alias is never in a level's set, so this only changes the explanation. -func isLegacyPrivilege(privilege string) bool { - return slices.Contains(legacyPrivileges, strings.ToUpper(strings.TrimSpace(privilege))) -} diff --git a/pkg/connector/securable_helpers.go b/pkg/connector/securable_helpers.go deleted file mode 100644 index 21569e0f..00000000 --- a/pkg/connector/securable_helpers.go +++ /dev/null @@ -1,378 +0,0 @@ -package connector - -import ( - "context" - "fmt" - "slices" - "strings" - - "github.com/conductorone/baton-databricks/pkg/databricks" - v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" - "github.com/conductorone/baton-sdk/pkg/annotations" - rs "github.com/conductorone/baton-sdk/pkg/types/resource" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" - "go.uber.org/zap" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -const ( - securableTypeMetastore = "METASTORE" - - profileKeyMetastoreID = "metastore_id" - profileKeyOwner = "owner" - profileKeySecurableType = "securable_type" - profileKeyRegion = "region" - profileKeyWorkspace = "workspace" - profileKeyWorkspaceID = "workspace_id" - profileKeyWorkspaceName = "workspace_name" - - // Not a dot: a securable's full name is already dot-separated. - securableKeySeparator = "::" -) - -// syncPrincipals says which principal types this sync includes. -type syncPrincipals struct { - groups bool - servicePrincipals bool -} - -func (s syncPrincipals) enabled(resourceType string) bool { - switch resourceType { - case groupResourceType.Id: - return s.groups - case servicePrincipalResourceType.Id: - return s.servicePrincipals - default: - return false - } -} - -func emitPrincipal(sync syncPrincipals, resourceType string) bool { - return resourceType == userResourceType.Id || sync.enabled(resourceType) -} - -// securableRef is a securable keyed by its metastore plus its name components, -// catalog first. Databricks rejects a name holding a space, a period or a forward -// slash, so splitting a full name on "." is unambiguous. -type securableRef struct { - metastoreID string - parts []string -} - -func (r securableRef) fullName() string { - return strings.Join(r.parts, ".") -} - -// permissionsName is the dotted full name, or the metastore's UUID for the metastore -// itself, whose name the endpoint rejects with 400 "Invalid UUID string". -func (r securableRef) permissionsName() string { - if len(r.parts) == 0 { - return r.metastoreID - } - - return r.fullName() -} - -// resourceKey is the resource id, which every level also carries as its RawId. -func (r securableRef) resourceKey() string { - if len(r.parts) == 0 { - return r.metastoreID - } - - return r.metastoreID + securableKeySeparator + r.fullName() -} - -// securableDeps is the client state every Unity Catalog builder shares. -type securableDeps struct { - client *databricks.Client - uc *unityCatalog - sync syncPrincipals -} - -// securableKind is what Grant/Revoke need to know about one securable level. -type securableKind struct { - // name is the permissions endpoint path segment, also used in messages. - name string - parse func(string) (securableRef, error) - privileges []string -} - -type changeMode int - -const ( - changeGrant changeMode = iota - changeRevoke -) - -func parseEntitlementPrivilege(entitlementID string) (string, error) { - separator := strings.LastIndex(entitlementID, ":") - if separator < 0 || separator == len(entitlementID)-1 { - return "", status.Errorf(codes.InvalidArgument, - "databricks-connector: failed to read the privilege from entitlement %q: invalid entitlement id", entitlementID) - } - - return entitlementID[separator+1:], nil -} - -// matchHeldPrivilege finds the assignment that carries privilege for this -// principal. Id wins; name is used only when the assignment has no id. -func matchHeldPrivilege( - assignments []databricks.PrivilegeAssignment, - scimID, principalName, privilege string, -) (databricks.PrivilegeAssignment, bool) { - var nameOnly databricks.PrivilegeAssignment - var sawNameOnly bool - for _, assignment := range assignments { - if !slices.Contains(assignment.Privileges, privilege) { - continue - } - - assignmentID := assignment.PrincipalID.String() - if scimID != "" && assignmentID == scimID && assignmentID != "0" { - return assignment, true - } - if principalName == "" || assignment.Principal != principalName { - continue - } - if scimID != "" && assignmentID != "" && assignmentID != "0" && assignmentID != scimID { - continue - } - if !sawNameOnly { - nameOnly = assignment - sawNameOnly = true - } - } - if sawNameOnly { - return nameOnly, true - } - - return databricks.PrivilegeAssignment{}, false -} - -type securableChange struct { - ref securableRef - privilege string - workspace string - scimID string - principalName string -} - -// resolveSecurableChange is the Grant/Revoke prelude: principal, privilege, -// routing, visibility probe, and the Unity Catalog principal name. -func (d *securableDeps) resolveSecurableChange( - ctx context.Context, - principalID *v2.ResourceId, - entitlement *v2.Entitlement, - kind securableKind, - mode changeMode, -) (securableChange, annotations.Annotations, bool, error) { - annos := annotations.Annotations{} - if !isValidPrincipal(principalID) { - return securableChange{}, annos, true, status.Errorf(codes.InvalidArgument, - "databricks-connector: only users, groups and service principals can hold a Unity Catalog privilege, got %s", - principalID.GetResourceType()) - } - - ref, err := kind.parse(entitlement.GetResource().GetId().GetResource()) - if err != nil { - return securableChange{}, annos, true, err - } - - privilege, err := parseEntitlementPrivilege(entitlement.GetId()) - if err != nil { - return securableChange{}, annos, true, err - } - - if privilege == ownerEntitlement { - return securableChange{}, annos, true, status.Errorf(codes.InvalidArgument, - "databricks-connector: ownership of %s %s is single-valued and has no revoke, so it cannot be provisioned; change the owner in Databricks", - kind.name, ref.permissionsName()) - } - - if isLegacyPrivilege(privilege) { - return securableChange{}, annos, true, status.Errorf(codes.InvalidArgument, - "databricks-connector: %s is a Hive-era alias that Databricks rewrites server-side — CREATE on a schema becomes "+ - "CREATE_TABLE plus CREATE_FUNCTION — so it would grant more than was asked for and would never be read back "+ - "under that name; ask for the explicit privilege instead", - privilege) - } - - if !slices.Contains(kind.privileges, privilege) { - return securableChange{}, annos, true, status.Errorf(codes.InvalidArgument, - "databricks-connector: %s is not a grantable privilege on %s %s", - privilege, kind.name, ref.permissionsName()) - } - - // The profile was written by the last sync, and the metastore may have been - // deleted since: a write must confirm it is still listed before addressing it. - workspace, state, rateLimit, err := d.accessPath(ctx, entitlement.GetResource(), ref, true) - noteRateLimit(&annos, rateLimit) - if err != nil { - if status.Code(err) == codes.PermissionDenied { - return securableChange{}, annos, true, err - } - - return securableChange{}, annos, true, fmt.Errorf("databricks-connector: failed to resolve the access path for %s: %w", ref.resourceKey(), err) - } - switch state { - case metastoreGone: - if mode == changeRevoke { - annos.Update(&v2.GrantAlreadyRevoked{}) - return securableChange{}, annos, true, nil - } - - return securableChange{}, annos, true, status.Errorf(codes.NotFound, - "databricks-connector: %s %s no longer exists in Databricks, so it cannot be changed", kind.name, ref.permissionsName()) - case metastoreOutOfScope: - // The metastore still holds its privileges, so a revoke cannot be reported done. - return securableChange{}, annos, true, status.Errorf(codes.FailedPrecondition, - "databricks-connector: %s %s cannot be changed: no workspace this sync can use is attached to it — every attached one is "+ - "left out of workspaces or named in databricks-exclude-workspaces, or none is assigned — so it is outside the sync scope", - kind.name, ref.permissionsName()) - case metastoreInScope: - } - - scimID, err := unityPrincipalScimID(principalID) - if err != nil { - return securableChange{}, annos, true, fmt.Errorf("databricks-connector: failed to read the SCIM id of principal %s: %w", - principalID.GetResource(), err) - } - - principalName, _, err := unityPrincipalName(ctx, d.client, principalID) - if err != nil { - return securableChange{}, annos, true, fmt.Errorf("databricks-connector: failed to resolve the Unity Catalog name of principal %s: %w", - principalID.GetResource(), err) - } - // Revoke does not need the name: a deleted principal keeps its numeric id in - // the assignment, and removing by id is what clears the row. - if mode == changeGrant && principalName == "" { - return securableChange{}, annos, true, status.Errorf(codes.NotFound, - "databricks-connector: principal %s no longer exists in Databricks, so it cannot be granted %s on %s %s", - scimID, privilege, kind.name, ref.permissionsName()) - } - - return securableChange{ - ref: ref, - privilege: privilege, - workspace: workspace, - scimID: scimID, - principalName: principalName, - }, annos, false, nil -} - -// accessPath names the workspace a metastore's permissions are read through. -// Unless confirm is set, the workspace the last sync wrote on the profile is -// tried first, which skips listing every metastore's workspaces per Grants call. -func (d *securableDeps) accessPath( - ctx context.Context, - resource *v2.Resource, - ref securableRef, - confirm bool, -) (string, metastoreState, *v2.RateLimitDescription, error) { - if !confirm { - workspace, ok, rateLimit, err := d.profileWorkspace(ctx, resource, ref) - if err != nil { - return "", metastoreInScope, rateLimit, err - } - if ok { - return workspace, metastoreInScope, rateLimit, nil - } - } - - snap, rateLimit, err := d.uc.buildRouting(ctx) - if err != nil { - return "", metastoreInScope, rateLimit, err - } - workspaces, state, err := usableWorkspaces(snap, ref.metastoreID) - if err != nil || state != metastoreInScope { - return "", state, rateLimit, err - } - - // Admin on any one attached workspace is enough, and ListWorkspaces order says - // nothing about which one that is, so every usable workspace is tried. - for _, workspace := range workspaces { - trustworthy, probeRateLimit, err := d.uc.metastoreGrantsAreTrustworthy(ctx, snap, ref.metastoreID, workspace) - if probeRateLimit != nil { - rateLimit = probeRateLimit - } - if err != nil { - if ctx.Err() != nil || !isUnreadableWorkspaceError(err) { - return "", metastoreInScope, rateLimit, err - } - ctxzap.Extract(ctx).Debug("databricks-connector: could not read the permission assignments of an attached workspace; trying the next one", - zap.String("metastore", ref.metastoreID), - zap.String("workspace", workspace), - zap.Error(err), - ) - - continue - } - if trustworthy { - return workspace, metastoreInScope, rateLimit, nil - } - } - - return "", metastoreInScope, rateLimit, status.Errorf(codes.PermissionDenied, - "databricks-connector: cannot read every grant on metastore %s: the connector principal must own the metastore or be assigned Admin directly on a workspace attached to it", - ref.metastoreID) -} - -// profileWorkspace reports false whenever the profile workspace cannot be used -// as is, so the caller rebuilds the routing instead. Only a rate limit or a -// cancelled context is returned: retrying elsewhere would not help either. -func (d *securableDeps) profileWorkspace( - ctx context.Context, - resource *v2.Resource, - ref securableRef, -) (string, bool, *v2.RateLimitDescription, error) { - workspace, workspaceID, workspaceName, owner := profileAccessPath(resource) - if workspace == "" || workspaceID == "" { - return "", false, nil, nil - } - if len(d.uc.workspaces) > 0 { - if _, ok := matchConfiguredWorkspace(d.uc.workspaces, workspace, workspaceName, workspaceID); !ok { - return "", false, nil, nil - } - } - - snap := profileRoutingSnapshot(ref.metastoreID, workspace, workspaceID, owner) - trustworthy, rateLimit, err := d.uc.metastoreGrantsAreTrustworthy(ctx, snap, ref.metastoreID, workspace) - if err != nil { - if ctx.Err() != nil || isRateLimitError(err) { - return "", false, rateLimit, err - } - ctxzap.Extract(ctx).Debug("databricks-connector: the workspace on the metastore profile did not answer; rebuilding the routing", - zap.String("metastore", ref.metastoreID), - zap.String("workspace", workspace), - zap.Error(err), - ) - - return "", false, rateLimit, nil - } - - return workspace, trustworthy, rateLimit, nil -} - -func profileAccessPath(resource *v2.Resource) (string, string, string, string) { - if resource == nil { - return "", "", "", "" - } - profile := rs.GetProfile(resource) - workspace, _ := rs.GetProfileStringValue(profile, profileKeyWorkspace) - workspaceID, _ := rs.GetProfileStringValue(profile, profileKeyWorkspaceID) - workspaceName, _ := rs.GetProfileStringValue(profile, profileKeyWorkspaceName) - owner, _ := rs.GetProfileStringValue(profile, profileKeyOwner) - - return workspace, workspaceID, workspaceName, owner -} - -func profileRoutingSnapshot(metastoreID, workspace, workspaceID, owner string) routingSnapshot { - return routingSnapshot{ - metastores: map[string]databricks.Metastore{ - metastoreID: {MetastoreID: metastoreID, Owner: owner}, - }, - workspaces: map[string][]string{metastoreID: {workspace}}, - workspaceIDs: map[string]string{workspace: workspaceID}, - } -} diff --git a/pkg/connector/unity_catalog.go b/pkg/connector/unity_catalog.go deleted file mode 100644 index fe3f3dac..00000000 --- a/pkg/connector/unity_catalog.go +++ /dev/null @@ -1,306 +0,0 @@ -package connector - -import ( - "context" - "fmt" - "slices" - "strconv" - "strings" - - "github.com/conductorone/baton-databricks/pkg/databricks" - v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" - "go.uber.org/zap" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -const ( - workspaceStatusRunning = "RUNNING" - workspaceStatusProvisioning = "PROVISIONING" - workspaceStatusNotProvisioned = "NOT_PROVISIONED" - workspaceStatusCancelling = "CANCELLING" -) - -// routingSnapshot is the account's Unity Catalog access paths for the call that -// just listed them. Nothing on the connector keeps it. -type routingSnapshot struct { - metastores map[string]databricks.Metastore - workspaces map[string][]string - workspaceIDs map[string]string - workspaceNames map[string]string - - // unreadable names the metastores whose workspace assignment list answered - // 401, 403 or 404, so which workspaces serve them is unknown rather than none. - unreadable []string - - // pending maps a metastore to its in-scope assigned workspaces that are not - // RUNNING. Whether they will serve it is unknown until they are, so they keep - // that metastore from being pruned. - pending map[string][]string - - // pruned names the listed metastores left out because no workspace this sync - // can use is attached to them. They still exist in Databricks. - pruned map[string]struct{} -} - -// metastoreState is where a metastore stands in a routing snapshot. -type metastoreState int - -const ( - // metastoreGone is a metastore the account no longer lists. - metastoreGone metastoreState = iota - // metastoreInScope is listed and not pruned. - metastoreInScope - // metastoreOutOfScope is listed but pruned: no workspace this sync can use is attached. - metastoreOutOfScope -) - -// unityCatalog holds no state beyond the client and the workspace filter: every -// call asks Databricks and drops the answer when it returns. -type unityCatalog struct { - client *databricks.Client - - // ownPrincipal is the applicationId of the service principal the connector authenticates as. - ownPrincipal string - - // workspaces is the configured allowlist, empty when none was given. Every - // Unity Catalog read is addressed to a workspace, so routing through one the - // operator scoped out would read the metastore through a deployment this sync - // does not cover. ListWorkspaces only applies the exclude list, which is why - // the allowlist has to be applied here as well as in the workspace builder. - workspaces map[string]struct{} -} - -func newUnityCatalog(client *databricks.Client, ownPrincipal string, workspaces map[string]struct{}) *unityCatalog { - return &unityCatalog{ - client: client, - ownPrincipal: ownPrincipal, - workspaces: workspaces, - } -} - -// workspaceIsPending is a status that can still change. PROVISIONING and -// NOT_PROVISIONED can become RUNNING. CANCELLING is in process, so pruning -// during it would delete the metastore and its grants. FAILED and BANNED are -// the terminal set. A status Databricks has not listed takes that path too: -// treating it as pending would fail the sync on every run. -func workspaceIsPending(status string) bool { - switch strings.ToUpper(status) { - case workspaceStatusProvisioning, workspaceStatusNotProvisioned, workspaceStatusCancelling: - return true - default: - return false - } -} - -// allowlistMiss names an allowlist that matches no workspace on the account. -// The failure is the whole sync, because metastore List is what returns it. -// The allowlist cannot hold an excluded name: the two fields are declared -// mutually exclusive in pkg/config. -func allowlistMiss(u *unityCatalog) error { - missing := make([]string, 0, len(u.workspaces)) - for name := range u.workspaces { - missing = append(missing, name) - } - slices.Sort(missing) - - return status.Errorf(codes.FailedPrecondition, - "none of the workspaces in the workspaces allowlist (%s) matches a workspace on the account by deployment name, name or id, "+ - "so no metastore can be scoped to it; this fails the whole sync", - strings.Join(missing, ", ")) -} - -// usableWorkspaces returns workspaces only for an in-scope metastore. One that is -// in scope but has no usable workspace yet fails with FailedPrecondition. -func usableWorkspaces(snap routingSnapshot, metastoreID string) ([]string, metastoreState, error) { - if workspaces := snap.workspaces[metastoreID]; len(workspaces) > 0 { - return workspaces, metastoreInScope, nil - } - if _, ok := snap.pruned[metastoreID]; ok { - return nil, metastoreOutOfScope, nil - } - if _, ok := snap.metastores[metastoreID]; !ok { - return nil, metastoreGone, nil - } - // The sync fails rather than reporting an empty metastore, so the message has - // to name what the operator can act on instead of listing every reason a - // metastore can lack a workspace. - if slices.Contains(snap.unreadable, metastoreID) { - return nil, metastoreInScope, status.Errorf(codes.FailedPrecondition, - "metastore %s exists on the account but its workspace assignment list answered 401, 403 or 404, so the sync cannot tell which workspaces serve it; "+ - "grant the connector's service principal account admin, or scope the metastore out by narrowing workspaces", - metastoreID) - } - if waiting := snap.pending[metastoreID]; len(waiting) > 0 { - return nil, metastoreInScope, status.Errorf(codes.FailedPrecondition, - "metastore %s is attached only to workspaces that are not RUNNING (%s), so its grants cannot be read; "+ - "the sync fails until Databricks settles their state, because reporting no grants would remove the access ConductorOne already holds", - metastoreID, strings.Join(waiting, ", ")) - } - - return nil, metastoreInScope, status.Errorf(codes.FailedPrecondition, - "metastore %s exists on the account but reports no workspace assignment, so its securables cannot be read; "+ - "assign a running workspace to it in the Databricks account console", - metastoreID) -} - -// buildRouting lists metastores and the workspaces each one is reachable through. -func (u *unityCatalog) buildRouting(ctx context.Context) (routingSnapshot, *v2.RateLimitDescription, error) { - metastores, rateLimit, err := u.listMetastores(ctx) - if err != nil { - return routingSnapshot{}, rateLimit, err - } - - snap := routingSnapshot{ - metastores: metastores, - workspaces: make(map[string][]string), - workspaceIDs: make(map[string]string), - workspaceNames: make(map[string]string), - pending: make(map[string][]string), - pruned: make(map[string]struct{}), - } - - workspaceRateLimit, err := u.fillWorkspaces(ctx, &snap) - if workspaceRateLimit != nil { - rateLimit = workspaceRateLimit - } - if err != nil { - return routingSnapshot{}, rateLimit, err - } - - // A metastore with no usable workspace would fail its Grants with - // FailedPrecondition, which fails the whole sync, so it is pruned. An unknown - // answer (unreadable list, or a workspace still provisioning or cancelling) blocks the prune: - // dropping it would read as a deletion in C1. A FAILED or BANNED workspace - // does not: it will not become RUNNING. Pruned is recorded apart from deleted - // because the metastore still holds its privileges. - l := ctxzap.Extract(ctx) - for metastoreID := range snap.metastores { - if len(snap.workspaces[metastoreID]) > 0 || len(snap.pending[metastoreID]) > 0 || slices.Contains(snap.unreadable, metastoreID) { - continue - } - l.Debug("databricks-connector: no workspace this sync can use is attached to the metastore; leaving it out of the sync", - zap.String("metastore", metastoreID), - ) - delete(snap.metastores, metastoreID) - snap.pruned[metastoreID] = struct{}{} - } - - return snap, rateLimit, nil -} - -func (u *unityCatalog) listMetastores(ctx context.Context) (map[string]databricks.Metastore, *v2.RateLimitDescription, error) { - metastores, rateLimit, err := u.client.ListMetastores(ctx) - if err != nil { - return nil, rateLimit, fmt.Errorf("failed to list metastores: %w", err) - } - - byID := make(map[string]databricks.Metastore, len(metastores)) - for _, metastore := range metastores { - if metastore.MetastoreID == "" { - continue - } - byID[metastore.MetastoreID] = metastore - } - - return byID, rateLimit, nil -} - -// fillWorkspaces maps metastores to workspaces and deployment names to numeric ids. -// The reverse assignment list is one call per metastore (usually one per region), -// so a workspace missing from every metastore's list is simply not on Unity Catalog. -func (u *unityCatalog) fillWorkspaces(ctx context.Context, snap *routingSnapshot) (*v2.RateLimitDescription, error) { - workspaces, rateLimit, err := u.client.ListWorkspaces(ctx) - if err != nil { - return rateLimit, fmt.Errorf("failed to list workspaces: %w", err) - } - - inScope := map[string]databricks.Workspace{} - notRunning := map[string]string{} - unusable := 0 - asked := 0 - for _, workspace := range workspaces { - if err := ctx.Err(); err != nil { - return rateLimit, err - } - if workspace.DeploymentName == "" { - continue - } - accountID := strconv.Itoa(workspace.ID) - if len(u.workspaces) > 0 { - if _, ok := matchConfiguredWorkspace(u.workspaces, workspace.DeploymentName, workspace.Name, accountID); !ok { - continue - } - } - if workspace.Status != "" && !strings.EqualFold(workspace.Status, workspaceStatusRunning) { - // PROVISIONING, NOT_PROVISIONED and CANCELLING can still change. - // FAILED, BANNED and a status Databricks has not listed do not, - // so they must not fail the sync on every run. - if workspaceIsPending(workspace.Status) { - notRunning[accountID] = fmt.Sprintf("%s (%s)", workspace.DeploymentName, workspace.Status) - } else { - unusable++ - ctxzap.Extract(ctx).Debug("databricks-connector: skipping a workspace that will not become RUNNING", - zap.String("workspace", workspace.DeploymentName), - zap.String("status", workspace.Status), - ) - } - - continue - } - - asked++ - inScope[accountID] = workspace - snap.workspaceIDs[workspace.DeploymentName] = accountID - snap.workspaceNames[workspace.DeploymentName] = workspace.Name - } - - if len(u.workspaces) > 0 && asked == 0 && len(notRunning) == 0 && unusable == 0 && len(snap.metastores) > 0 { - return rateLimit, allowlistMiss(u) - } - - var unreadable []string - for metastoreID := range snap.metastores { - if err := ctx.Err(); err != nil { - return rateLimit, err - } - - ids, assignmentRateLimit, err := u.client.ListMetastoreWorkspaces(ctx, metastoreID) - if assignmentRateLimit != nil { - rateLimit = assignmentRateLimit - } - if err != nil { - // Databricks does not document what a 404 here means, so it is read as - // unknown rather than as a deletion: pruning on it would make C1 delete - // the metastore and its grants. A metastore that really is gone stops - // appearing in ListMetastores on the next sync. - if isUnreadableWorkspaceError(err) { - ctxzap.Extract(ctx).Debug("databricks-connector: could not read the workspace assignment list", - zap.String("metastore", metastoreID), - zap.Error(err), - ) - unreadable = append(unreadable, metastoreID) - continue - } - - return rateLimit, fmt.Errorf("failed to list workspaces for metastore %s: %w", metastoreID, err) - } - - for _, id := range ids { - if deployment, ok := notRunning[id]; ok { - snap.pending[metastoreID] = append(snap.pending[metastoreID], deployment) - continue - } - workspace, ok := inScope[id] - if !ok { - continue - } - snap.workspaces[metastoreID] = append(snap.workspaces[metastoreID], workspace.DeploymentName) - } - } - - snap.unreadable = unreadable - - return rateLimit, nil -} diff --git a/pkg/connector/unity_catalog_test.go b/pkg/connector/unity_catalog_test.go deleted file mode 100644 index 7f14be5e..00000000 --- a/pkg/connector/unity_catalog_test.go +++ /dev/null @@ -1,1050 +0,0 @@ -package connector - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "slices" - "strconv" - "strings" - "testing" - - v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" - "github.com/conductorone/baton-sdk/pkg/annotations" - "github.com/conductorone/baton-sdk/pkg/pagination" - "github.com/conductorone/baton-sdk/pkg/types/grant" - rs "github.com/conductorone/baton-sdk/pkg/types/resource" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - - "github.com/conductorone/baton-databricks/pkg/databricks" -) - -// workspaceRouter dispatches on the deployment-name label of the request host, -// the way Databricks routes an account whose workspaces sit on separate hosts. -type workspaceRouter struct { - target string - hits map[string]int -} - -func (r *workspaceRouter) RoundTrip(req *http.Request) (*http.Response, error) { - workspace, _, _ := strings.Cut(req.URL.Host, ".") - r.hits[workspace]++ - - routed := req.Clone(req.Context()) - routed.URL.Scheme = "http" - routed.URL.Host = r.target - routed.Header.Set("X-Test-Workspace", workspace) - - return http.DefaultTransport.RoundTrip(routed) -} - -func newWorkspaceRoutedClient(t *testing.T, handler http.HandlerFunc) (*databricks.Client, *workspaceRouter) { - t.Helper() - - srv := httptest.NewServer(handler) - t.Cleanup(srv.Close) - - router := &workspaceRouter{target: strings.TrimPrefix(srv.URL, "http://"), hits: map[string]int{}} - client, err := databricks.NewClient(context.Background(), &http.Client{Transport: router}, - testHostname, testAccountHost, testAccountId, "", &databricks.NoAuth{}, nil) - if err != nil { - t.Fatalf("NewClient: %v", err) - } - - return client, router -} - -// These run at the cache's default settings on purpose: BATON_HTTP_CACHE_TTL is -// deliberately not set, because turning the cache off is what hid this and a test -// that turns it off guards nothing. - -// Every workspace answers /api/2.1/unity-catalog/permissions with the same path -// and query, so a cache shared between hosts would misattribute privileges. -func TestPermissionsListingIsNotAnsweredByAnotherWorkspacesCachedPage(t *testing.T) { - held := map[string]string{"wsa": "alice", "wsb": "bob"} - - client, router := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - workspace := r.Header.Get("X-Test-Workspace") - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "privilege_assignments": []map[string]any{{ - "principal": held[workspace], - "privileges": []string{"CREATE_CATALOG"}, - }}, - }) - }) - - ctx := context.Background() - for _, workspace := range []string{"wsa", "wsb"} { - assignments, _, _, err := client.ListPermissions(ctx, workspace, databricks.SecurableMetastore, "ms-1", "", "") - if err != nil { - t.Fatalf("ListPermissions(%s): %v", workspace, err) - } - - if router.hits[workspace] == 0 { - t.Errorf("workspace %s was never asked for its permissions; hits = %v", workspace, router.hits) - } - if len(assignments) != 1 || assignments[0].Principal != held[workspace] { - t.Errorf("workspace %s answered %+v, want %s: a permissions page crossed between workspace hosts", - workspace, assignments, held[workspace]) - } - } -} - -// `principal` holds a userName, a displayName or an applicationId with no type -// discriminator, so falling back to the name when the id does not resolve -// attributes the grant to whichever principal happens to share the string. -func TestLookupDoesNotFallBackFromAnUnknownID(t *testing.T) { - t.Parallel() - - index := &principalIndex{ - byID: map[string]*v2.ResourceId{ - "10": {ResourceType: "user", Resource: "10"}, - }, - byName: map[string]*v2.ResourceId{ - "shared": {ResourceType: "user", Resource: "10"}, - }, - } - - if _, ok := index.lookup(databricks.PrivilegeAssignment{ - Principal: "shared", PrincipalID: json.Number("99"), - }); ok { - t.Fatal("an unknown principal id must not resolve through a shared name") - } - - got, ok := index.lookup(databricks.PrivilegeAssignment{ - Principal: "shared", PrincipalID: json.Number("10"), - }) - if !ok || got.GetResource() != "10" { - t.Fatalf("known id lookup = %v %v", got, ok) - } -} - -// routingHandler answers ListMetastores, ListWorkspaces, and -// ListMetastoreWorkspaces. assignments maps workspace id → metastore id (or an -// HTTP status that ListMetastoreWorkspaces should return for that metastore). -func routingHandler(t *testing.T, assignments map[string]any, probed map[string]int) http.HandlerFunc { - t.Helper() - - return routingHandlerWith(t, assignments, probed, []map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, - }) -} - -// routingHandlerWith is routingHandler over a given workspace listing. Any other -// request counts as a probe of its workspace and answers 404, which is what the -// permissions endpoint returns for a metastore that is gone. -func routingHandlerWith(t *testing.T, assignments map[string]any, probed map[string]int, workspaces []map[string]any) http.HandlerFunc { - t.Helper() - - byMetastore := map[string][]int{} - statusByMetastore := map[string]int{} - for workspaceID, assigned := range assignments { - switch value := assigned.(type) { - case int: - statusByMetastore[workspaceID] = value - case string: - id, _ := strconv.Atoi(workspaceID) - byMetastore[value] = append(byMetastore[value], id) - } - } - - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - path := r.URL.Path - - switch { - case strings.HasSuffix(path, "/metastores"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "metastores": []map[string]any{ - {"metastore_id": "ms-a", "name": "main"}, - {"metastore_id": "ms-b", "name": "other"}, - }, - }) - case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): - parts := strings.Split(strings.Trim(path, "/"), "/") - metastoreID := parts[len(parts)-2] - probed[metastoreID]++ - if status, ok := statusByMetastore[metastoreID]; ok { - http.Error(w, `{"message":"denied"}`, status) - return - } - _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": byMetastore[metastoreID]}) - case strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode(workspaces) - default: - workspace := r.Header.Get("X-Test-Workspace") - probed[workspace]++ - http.Error(w, `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"not found"}`, http.StatusNotFound) - } - } -} - -// The allowlist is what the operator scoped the sync to, but ListWorkspaces only -// applies the exclude list. Unity Catalog reads are addressed to a workspace, so -// routing has to filter too or the connector reads the account through a -// deployment that never appears in the sync. -func TestRoutingSkipsAWorkspaceOutsideTheConfiguredAllowlist(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, - map[string]any{"1": "ms-a", "2": "ms-a"}, probed)) - - uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod"})) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("buildRouting: %v", err) - } - - if want := []string{"wsa"}; !slices.Equal(snap.workspaces["ms-a"], want) { - t.Errorf("ms-a routes through %v, want %v", snap.workspaces["ms-a"], want) - } -} - -// Scoping the sync to one workspace must not fail it for the metastores the other -// workspaces hold. A metastore only the excluded workspace can reach is left out -// of scope, the way a filtered catalog is: left in the snapshot it reaches -// usableWorkspaces, which answers FailedPrecondition, and that is not a warning -// to the SDK, so every metastore-enabled sync would fail. -func TestAMetastoreOnlyAnOutOfScopeWorkspaceReachesIsAbsentRatherThanFatal(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, - map[string]any{"1": "ms-a", "2": "ms-b"}, probed)) - - uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod"})) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("buildRouting: %v", err) - } - - if _, ok := snap.metastores["ms-a"]; !ok { - t.Error("ms-a is missing from the snapshot, so the in-scope metastore would not sync") - } - if _, ok := snap.metastores["ms-b"]; ok { - t.Error("ms-b is still in the snapshot; its Grants() would fail the whole sync with FailedPrecondition") - } - - workspaces, state, err := usableWorkspaces(snap, "ms-b") - if err != nil { - t.Fatalf("resolving the out-of-scope metastore errored instead of reporting it out of scope: %v", err) - } - if state != metastoreOutOfScope || len(workspaces) != 0 { - t.Errorf("ms-b resolved to workspaces %v (state=%v), want out of scope", workspaces, state) - } -} - -// Dropping an unrouted metastore is only safe when the workspaces left out are -// the reason it is unrouted. An unreadable assignment list may hide an in-scope -// workspace, and pruning would read as a deletion in C1. -func TestAnUnreadableMetastoreAssignmentListStopsTheMetastoreFromBeingPruned(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, - map[string]any{"1": "ms-a", "ms-b": http.StatusForbidden}, probed)) - - uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod", "dev"})) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("buildRouting: %v", err) - } - - if !slices.Contains(snap.unreadable, "ms-b") { - t.Fatalf("unreadable = %v, want it to name ms-b", snap.unreadable) - } - if _, ok := snap.metastores["ms-b"]; !ok { - t.Fatal("ms-b was pruned while its workspace list was unreadable, so C1 would delete it and its grants") - } - - if _, _, err := usableWorkspaces(snap, "ms-b"); err == nil { - t.Error("ms-b resolved without an error, want the sync to fail loudly rather than report an absence") - } -} - -// A metastore whose readable assignment list names no in-scope workspace is out -// of scope under an allowlist. -func TestAMetastoreMissingFromEveryAssignmentListIsPrunedUnderAnAllowlist(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, - map[string]any{"1": "ms-a"}, probed)) - - uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod", "dev"})) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("buildRouting: %v", err) - } - - if _, ok := snap.metastores["ms-b"]; ok { - t.Fatal("ms-b is still in the snapshot even though no workspace is assigned to it") - } -} - -// Only codes.NotFound is a warning to the SDK; every other code fails the whole -// sync. A credential that is not a member of one workspace answers 401 or 403 -// there, which says nothing about the rest of the account, so letting it escape -// buildRouting would take down the resource types that do work. -func TestRoutingSetsAsideAMetastoreWhoseAssignmentListCannotBeRead(t *testing.T) { - for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} { - t.Run(http.StatusText(status), func(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, - map[string]any{"1": "ms-a", "ms-b": status}, probed)) - - uc := newUnityCatalog(client, "app-1", nil) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("one unreadable metastore failed the whole snapshot: %v", err) - } - - if want := []string{"wsa"}; !slices.Equal(snap.workspaces["ms-a"], want) { - t.Errorf("ms-a routes through %v, want %v", snap.workspaces["ms-a"], want) - } - if !slices.Contains(snap.unreadable, "ms-b") { - t.Errorf("unreadable = %v, want it to name ms-b", snap.unreadable) - } - }) - } -} - -// A PROVISIONING workspace may serve the unrouted metastores once RUNNING, so it -// blocks the prune like an unreadable assignment list. -func TestANotRunningInScopeWorkspaceStopsTheMetastoreFromBeingPruned(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandlerWith(t, - map[string]any{"1": "ms-a", "2": "ms-b"}, probed, []map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "PROVISIONING"}, - })) - - uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod", "dev"})) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("buildRouting: %v", err) - } - - if !slices.Equal(snap.pending["ms-b"], []string{"wsb (PROVISIONING)"}) { - t.Fatalf("pending[ms-b] = %v, want [wsb (PROVISIONING)]", snap.pending["ms-b"]) - } - if _, ok := snap.metastores["ms-b"]; !ok { - t.Fatal("ms-b was pruned while an in-scope workspace was still PROVISIONING, so C1 would delete it and its grants") - } - if _, _, err := usableWorkspaces(snap, "ms-b"); status.Code(err) != codes.FailedPrecondition || !strings.Contains(err.Error(), "PROVISIONING") { - t.Errorf("resolving ms-b = %v, want FailedPrecondition naming PROVISIONING", err) - } -} - -// CANCELLING is in the documented workspace status set and means the cancellation -// is still running. Pruning it would delete the metastore before that finishes. -func TestACancellingWorkspaceStopsTheMetastoreFromBeingPruned(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandlerWith(t, - map[string]any{"1": "ms-a", "2": "ms-b"}, probed, []map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "CANCELLING"}, - })) - - uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod", "dev"})) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("buildRouting: %v", err) - } - - if !slices.Equal(snap.pending["ms-b"], []string{"wsb (CANCELLING)"}) { - t.Fatalf("pending[ms-b] = %v, want [wsb (CANCELLING)]", snap.pending["ms-b"]) - } - if _, ok := snap.metastores["ms-b"]; !ok { - t.Fatal("ms-b was pruned while its only in-scope workspace was CANCELLING, so C1 would delete it and its grants") - } - if _, _, err := usableWorkspaces(snap, "ms-b"); status.Code(err) != codes.FailedPrecondition || !strings.Contains(err.Error(), "CANCELLING") { - t.Errorf("resolving ms-b = %v, want FailedPrecondition naming CANCELLING", err) - } -} - -// An allowlist that names no workspace on the account is a configuration error, -// not an account with no metastores: pruning would delete every metastore in C1. -func TestAnAllowlistMatchingNoWorkspaceFailsInsteadOfPruningEverything(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, - map[string]any{"1": "ms-a", "2": "ms-b"}, probed)) - - uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"does-not-exist"})) - _, _, err := uc.buildRouting(context.Background()) - if status.Code(err) != codes.FailedPrecondition { - t.Fatalf("buildRouting = %v, want FailedPrecondition", err) - } - if !strings.Contains(err.Error(), "does-not-exist") { - t.Errorf("error %q does not name the allowlist", err) - } -} - -// Scoping the sync to a workspace that is not on Unity Catalog leaves every -// metastore out of scope. That is an empty result, not a failed sync. -func TestAnAllowlistedWorkspaceWithoutUnityCatalogPrunesTheMetastores(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, - map[string]any{"1": "ms-a"}, probed)) - - uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"dev"})) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("buildRouting: %v", err) - } - if len(snap.metastores) != 0 { - t.Fatalf("metastores = %v, want none: no allowlisted workspace is attached to any", snap.metastores) - } -} - -// Without an allowlist, a metastore with no usable workspace would still fail its -// Grants with FailedPrecondition and, with it, the whole sync. -func TestAnUnroutableMetastoreIsPrunedWithoutAnAllowlist(t *testing.T) { - tests := []struct { - name string - assignments map[string]any - }{ - {"no workspace assigned", map[string]any{"1": "ms-a"}}, - {"only an excluded workspace assigned", map[string]any{"1": "ms-a", "3": "ms-b"}}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, test.assignments, probed)) - - uc := newUnityCatalog(client, "app-1", nil) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("buildRouting: %v", err) - } - if _, ok := snap.metastores["ms-a"]; !ok { - t.Error("ms-a is missing from the snapshot") - } - if _, ok := snap.metastores["ms-b"]; ok { - t.Error("ms-b is still in the snapshot; its Grants would fail the whole sync with FailedPrecondition") - } - }) - } -} - -// Admin on any attached workspace is enough, so Grants tries the next workspace -// before reporting PermissionDenied. -func TestGrantsRoutesThroughTheFirstWorkspaceWhereTheProbePasses(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - var permissionsHosts []string - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - path := r.URL.Path - switch { - case strings.HasSuffix(path, "/workspaces/1/permissionassignments"): - _ = json.NewEncoder(w).Encode(map[string]any{"permission_assignments": []any{}}) - case strings.HasSuffix(path, "/workspaces/2/permissionassignments"): - _ = json.NewEncoder(w).Encode(map[string]any{"permission_assignments": []map[string]any{{ - "principal": map[string]any{"service_principal_name": "app-1", "principal_id": 7}, - "permissions": []string{"ADMIN"}, - }}}) - case strings.HasSuffix(path, "/metastores"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "someone-else"}}, - }) - case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{1, 2}}) - case strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, - }) - case strings.Contains(path, "/unity-catalog/permissions/"): - permissionsHosts = append(permissionsHosts, r.Header.Get("X-Test-Workspace")) - _ = json.NewEncoder(w).Encode(map[string]any{"privilege_assignments": []any{}}) - default: - http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) - } - }) - m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) - - ctx := context.Background() - account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} - metastore, err := metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms-a"}, account, "", "", "") - if err != nil { - t.Fatalf("metastoreResource: %v", err) - } - - if _, _, err := m.Grants(ctx, metastore, rs.SyncOpAttrs{}); err != nil { - t.Fatalf("Grants: %v", err) - } - if !slices.Equal(permissionsHosts, []string{"wsb"}) { - t.Errorf("permissions read through %v, want [wsb], the workspace where the connector is Admin", permissionsHosts) - } -} - -// Databricks does not document a 404 on the assignment list, so it must not be -// read as a deletion: pruning on it would make C1 delete the metastore's grants. -func TestA404OnTheAssignmentListIsUnknownRatherThanDeleted(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, - map[string]any{"1": "ms-a", "ms-b": http.StatusNotFound}, probed)) - - uc := newUnityCatalog(client, "app-1", nil) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("buildRouting: %v", err) - } - if _, ok := snap.metastores["ms-b"]; !ok { - t.Error("ms-b was dropped after its assignment list answered 404, so C1 would delete it and its grants") - } - if !slices.Contains(snap.unreadable, "ms-b") { - t.Errorf("unreadable = %v, want it to name ms-b", snap.unreadable) - } -} - -// The profile workspace says nothing about whether the metastore still exists; -// without asking, Revoke on a deleted metastore fails on every retry. -func TestProvisioningOnADeletedMetastoreDoesNotTrustTheProfile(t *testing.T) { - tests := []struct { - name string - change func(ctx context.Context, m *metastoreBuilder, principal *v2.Resource, g *v2.Grant) (annotations.Annotations, error) - code codes.Code - }{ - {"revoke", func(ctx context.Context, m *metastoreBuilder, _ *v2.Resource, g *v2.Grant) (annotations.Annotations, error) { - return m.Revoke(ctx, g) - }, codes.OK}, - {"grant", func(ctx context.Context, m *metastoreBuilder, principal *v2.Resource, g *v2.Grant) (annotations.Annotations, error) { - return m.Grant(ctx, principal, g.GetEntitlement()) - }, codes.NotFound}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, - map[string]any{"1": "ms-a", "2": "ms-b"}, probed)) - m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) - - ctx := context.Background() - account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} - metastore, err := metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms-gone", Owner: "app-1"}, account, "wsa", "1", "prod") - if err != nil { - t.Fatalf("metastoreResource: %v", err) - } - principal := &v2.Resource{Id: &v2.ResourceId{ResourceType: userResourceType.Id, Resource: "1"}} - g := grant.NewGrant(metastore, "CREATE_CATALOG", principal.GetId()) - - annos, err := test.change(ctx, m, principal, g) - if status.Code(err) != test.code { - t.Fatalf("err = %v, want %s", err, test.code) - } - if test.code == codes.OK && !annos.Contains(&v2.GrantAlreadyRevoked{}) { - t.Error("revoke on a deleted metastore did not report GrantAlreadyRevoked") - } - if probed["wsa"] != 0 { - t.Errorf("the profile workspace was asked %d time(s) about a metastore the account no longer lists", probed["wsa"]) - } - }) - } -} - -// A pruned metastore still holds its privileges, so a revoke cannot be reported -// done, and a grant has no workspace this sync may address. -func TestProvisioningOnAnOutOfScopeMetastoreFailsWithoutWriting(t *testing.T) { - tests := []struct { - name string - change func(ctx context.Context, m *metastoreBuilder, principal *v2.Resource, g *v2.Grant) (annotations.Annotations, error) - }{ - {"revoke", func(ctx context.Context, m *metastoreBuilder, _ *v2.Resource, g *v2.Grant) (annotations.Annotations, error) { - return m.Revoke(ctx, g) - }}, - {"grant", func(ctx context.Context, m *metastoreBuilder, principal *v2.Resource, g *v2.Grant) (annotations.Annotations, error) { - return m.Grant(ctx, principal, g.GetEntitlement()) - }}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - probed := map[string]int{} - var writes []string - routing := routingHandler(t, map[string]any{"1": "ms-a", "3": "ms-b"}, probed) - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writes = append(writes, r.Method+" "+r.URL.Path) - } - routing(w, r) - }) - m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) - - ctx := context.Background() - account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} - metastore, err := metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms-b", Owner: "app-1"}, account, "wsb", "2", "dev") - if err != nil { - t.Fatalf("metastoreResource: %v", err) - } - principal := &v2.Resource{Id: &v2.ResourceId{ResourceType: userResourceType.Id, Resource: "1"}} - g := grant.NewGrant(metastore, "CREATE_CATALOG", principal.GetId()) - - annos, err := test.change(ctx, m, principal, g) - if status.Code(err) != codes.FailedPrecondition { - t.Fatalf("err = %v, want FailedPrecondition", err) - } - if !strings.Contains(err.Error(), "ms-b") || !strings.Contains(err.Error(), "outside the sync scope") { - t.Errorf("error %q does not name the metastore as outside the sync scope", err) - } - if annos.Contains(&v2.GrantAlreadyRevoked{}) { - t.Error("an out-of-scope metastore was reported as already revoked while it still holds the privilege") - } - if len(writes) != 0 { - t.Errorf("sent %v to an out-of-scope metastore, want no write", writes) - } - if probed["wsa"] != 0 || probed["wsb"] != 0 { - t.Errorf("a workspace was asked about an out-of-scope metastore: %v", probed) - } - }) - } -} - -// List leaves a pruned metastore out, so Grants on it is an empty page, not an error. -func TestGrantsOnAnOutOfScopeMetastoreIsEmpty(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, map[string]any{"1": "ms-a", "3": "ms-b"}, probed)) - m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) - - ctx := context.Background() - account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} - metastore, err := metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms-b"}, account, "", "", "") - if err != nil { - t.Fatalf("metastoreResource: %v", err) - } - - grants, _, err := m.Grants(ctx, metastore, rs.SyncOpAttrs{}) - if err != nil { - t.Fatalf("Grants: %v", err) - } - if len(grants) != 0 { - t.Errorf("Grants = %v, want none", grants) - } -} - -// A profile workspace the credential can no longer read, or one the allowlist -// now leaves out, is a stale hint: Grants rebuilds the routing instead of failing. -func TestGrantsFallsBackWhenTheProfileWorkspaceCannotBeUsed(t *testing.T) { - tests := []struct { - name string - workspaces []string - profileWS string - profileID string - }{ - {"probe answers 403", nil, "wsb", "2"}, - {"workspace left out of the allowlist", []string{"prod"}, "wsb", "2"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - var permissionsHosts []string - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - path := r.URL.Path - switch { - case strings.HasSuffix(path, "/permissionassignments"): - http.Error(w, `{"message":"forbidden"}`, http.StatusForbidden) - case strings.HasSuffix(path, "/metastores"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "app-1"}}, - }) - case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{1, 2}}) - case strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, - }) - case strings.Contains(path, "/unity-catalog/permissions/"): - permissionsHosts = append(permissionsHosts, r.Header.Get("X-Test-Workspace")) - _ = json.NewEncoder(w).Encode(map[string]any{"privilege_assignments": []any{}}) - default: - http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) - } - }) - m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", configuredWorkspaceSet(test.workspaces)), syncPrincipals{}) - - ctx := context.Background() - account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} - metastore, err := metastoreResource(ctx, &databricks.Metastore{MetastoreID: "ms-a"}, account, test.profileWS, test.profileID, "dev") - if err != nil { - t.Fatalf("metastoreResource: %v", err) - } - - if _, _, err := m.Grants(ctx, metastore, rs.SyncOpAttrs{}); err != nil { - t.Fatalf("Grants: %v", err) - } - if !slices.Equal(permissionsHosts, []string{"wsa"}) { - t.Errorf("permissions read through %v, want [wsa] from the rebuilt routing", permissionsHosts) - } - }) - } -} - -func TestAFailedWorkspaceDoesNotKeepItsMetastorePending(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandlerWith(t, - map[string]any{"1": "ms-a", "2": "ms-b"}, probed, []map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "FAILED"}, - })) - - uc := newUnityCatalog(client, "app-1", nil) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("buildRouting: %v", err) - } - if _, ok := snap.metastores["ms-b"]; ok { - t.Fatal("ms-b stayed in the snapshot while its only workspace is FAILED, so every sync would fail") - } - if len(snap.pending["ms-b"]) != 0 { - t.Fatalf("pending[ms-b] = %v, want none", snap.pending["ms-b"]) - } - if _, ok := snap.metastores["ms-a"]; !ok { - t.Fatal("ms-a was dropped along with the FAILED workspace's metastore") - } -} - -func TestAnUnreadableAssignmentListNamesThatFailure(t *testing.T) { - probed := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, routingHandler(t, - map[string]any{"1": "ms-a", "ms-b": http.StatusForbidden}, probed)) - - uc := newUnityCatalog(client, "app-1", nil) - snap, _, err := uc.buildRouting(context.Background()) - if err != nil { - t.Fatalf("buildRouting: %v", err) - } - _, _, err = usableWorkspaces(snap, "ms-b") - if status.Code(err) != codes.FailedPrecondition || !strings.Contains(err.Error(), "assignment list") { - t.Fatalf("resolving ms-b = %v, want FailedPrecondition about the assignment list", err) - } - if strings.Contains(err.Error(), "databricks-exclude-workspaces") { - t.Errorf("error %q sends the operator to check the exclude list", err) - } -} - -func TestAPermissions404WithoutTheGoneCodeIsNotAWarning(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - tests := []struct { - name string - body string - code codes.Code - }{ - {"routing miss", `{"message":"nope"}`, codes.FailedPrecondition}, - // The account still lists the metastore, so the error code alone is not absence. - {"still listed", `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"No metastore assigned for the current workspace"}`, codes.FailedPrecondition}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - path := r.URL.Path - switch { - case strings.HasSuffix(path, "/metastores"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "app-1"}}, - }) - case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{1}}) - case strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - }) - case strings.Contains(path, "/unity-catalog/permissions/"): - http.Error(w, test.body, http.StatusNotFound) - default: - http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) - } - }) - m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) - account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} - metastore, err := metastoreResource(context.Background(), &databricks.Metastore{MetastoreID: "ms-a", Owner: "app-1"}, account, "", "", "") - if err != nil { - t.Fatalf("metastoreResource: %v", err) - } - - _, _, err = m.Grants(context.Background(), metastore, rs.SyncOpAttrs{}) - if status.Code(err) != test.code { - t.Fatalf("Grants = %v, want %s", err, test.code) - } - }) - } -} - -// List records an attached workspace without probing it, so one workspace -// refusing cannot fail the sync. Grants is what validates the hint: here the -// connector is Admin on wsb only, and the read still lands there. -func TestGrantsRouteAroundTheWorkspaceOnTheProfile(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - reads := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - path := r.URL.Path - switch { - case strings.HasSuffix(path, "/workspaces/1/permissionassignments"): - _ = json.NewEncoder(w).Encode(map[string]any{"permission_assignments": []any{}}) - case strings.HasSuffix(path, "/workspaces/2/permissionassignments"): - _ = json.NewEncoder(w).Encode(map[string]any{"permission_assignments": []map[string]any{{ - "principal": map[string]any{"service_principal_name": "app-1", "principal_id": 7}, - "permissions": []string{"ADMIN"}, - }}}) - case strings.HasSuffix(path, "/metastores"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "someone-else"}}, - }) - case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{1, 2}}) - case strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, - }) - case strings.Contains(path, "/unity-catalog/permissions/"): - reads[strings.Split(r.Host, ".")[0]]++ - _ = json.NewEncoder(w).Encode(map[string]any{"privilege_assignments": []any{}}) - case strings.Contains(path, "/scim/v2/"): - // The owner is not an account identity, so no owner grant is emitted. - _ = json.NewEncoder(w).Encode(map[string]any{"totalResults": 0, "Resources": []any{}}) - default: - http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) - } - }) - m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) - account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} - resources, _, err := m.List(context.Background(), account, rs.SyncOpAttrs{}) - if err != nil { - t.Fatalf("List: %v", err) - } - if len(resources) != 1 { - t.Fatalf("List returned %d resources, want 1", len(resources)) - } - workspace, _ := rs.GetProfileStringValue(rs.GetProfile(resources[0]), profileKeyWorkspace) - if workspace != "wsa" { - t.Errorf("profile workspace = %q, want wsa, the first attached workspace", workspace) - } - - if _, _, err := m.Grants(context.Background(), resources[0], rs.SyncOpAttrs{}); err != nil { - t.Fatalf("Grants: %v", err) - } - if reads["wsb"] == 0 || reads["wsa"] != 0 { - t.Errorf("permissions reads = %v, want the read to go to wsb, where the connector is Admin", reads) - } -} - -func TestMetastoreDoesNotExistIsAbsenceOnlyWhenTheAccountDropsIt(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - tests := []struct { - name string - listed bool - want codes.Code - }{ - {"account still lists it", true, codes.FailedPrecondition}, - {"account dropped it", false, codes.OK}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - path := r.URL.Path - switch { - case strings.HasSuffix(path, "/metastores"): - metastores := []map[string]any{} - if test.listed { - metastores = append(metastores, map[string]any{"metastore_id": "ms-a", "name": "main", "owner": "app-1"}) - } - _ = json.NewEncoder(w).Encode(map[string]any{"metastores": metastores}) - case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{1}}) - case strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - }) - case strings.Contains(path, "/unity-catalog/permissions/"): - http.Error(w, `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"No metastore assigned for the current workspace"}`, http.StatusNotFound) - default: - http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) - } - }) - m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) - account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} - metastore, err := metastoreResource(context.Background(), &databricks.Metastore{MetastoreID: "ms-a", Owner: "app-1"}, account, "wsa", "1", "prod") - if err != nil { - t.Fatalf("metastoreResource: %v", err) - } - - _, _, err = m.Grants(context.Background(), metastore, rs.SyncOpAttrs{}) - if status.Code(err) != test.want { - t.Fatalf("Grants = %v, want %s", err, test.want) - } - }) - } -} - -// The profile workspace can stop serving the metastore between List and Grants. -// Rebuilding the route is what tells a reassignment apart from a deletion, so the -// read is retried on the workspace the account now names. -func TestGrantsRetryTheWorkspaceTheAccountNowNames(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - reads := map[string]int{} - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - path := r.URL.Path - switch { - case strings.HasSuffix(path, "/metastores"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "app-1"}}, - }) - case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): - // wsa was unassigned after List wrote it on the profile. - _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{2}}) - case strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, - }) - case strings.Contains(path, "/unity-catalog/permissions/"): - host := strings.Split(r.Host, ".")[0] - reads[host]++ - if host == "wsa" { - http.Error(w, `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"No metastore assigned for the current workspace"}`, http.StatusNotFound) - - return - } - _ = json.NewEncoder(w).Encode(map[string]any{"privilege_assignments": []map[string]any{{ - "principal": "dana@example.com", - "principal_id": 7, - "privileges": []string{"CREATE_CATALOG"}, - }}}) - case strings.Contains(path, "/scim/v2/Users/"): - _ = json.NewEncoder(w).Encode(map[string]any{"id": "7", "userName": "dana@example.com"}) - case strings.HasSuffix(path, "/scim/v2/ServicePrincipals"): - _ = json.NewEncoder(w).Encode(map[string]any{"totalResults": 1, "Resources": []map[string]any{ - {"id": "9", "applicationId": "app-1", "displayName": "baton"}, - }}) - case strings.HasSuffix(path, "/scim/v2/Users"), strings.HasSuffix(path, "/scim/v2/Groups"): - _ = json.NewEncoder(w).Encode(map[string]any{"totalResults": 0, "Resources": []any{}}) - default: - http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) - } - }) - - m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) - account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} - metastore, err := metastoreResource(context.Background(), &databricks.Metastore{MetastoreID: "ms-a", Owner: "app-1"}, account, "wsa", "1", "prod") - if err != nil { - t.Fatalf("metastoreResource: %v", err) - } - - _, _, err = m.Grants(context.Background(), metastore, rs.SyncOpAttrs{}) - if err != nil { - t.Fatalf("Grants = %v, want the read to be retried on wsb", err) - } - if reads["wsb"] == 0 { - t.Errorf("permissions were never read through wsb; reads = %v", reads) - } -} - -// A page token belongs to the workspace that minted it, and the pages already -// emitted would read as the complete set if this one ended them. Past the first -// page the read fails instead of rebuilding the route. -func TestGrantsDoNotRerouteInTheMiddleOfPagination(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - assignmentLists := 0 - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - path := r.URL.Path - switch { - case strings.HasSuffix(path, "/metastores"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "app-1"}}, - }) - case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): - assignmentLists++ - _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{2}}) - case strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, - }) - case strings.Contains(path, "/unity-catalog/permissions/"): - http.Error(w, `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"No metastore assigned for the current workspace"}`, http.StatusNotFound) - default: - http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) - } - }) - - m := newMetastoreBuilder(client, newUnityCatalog(client, "app-1", nil), syncPrincipals{}) - account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} - metastore, err := metastoreResource(context.Background(), &databricks.Metastore{MetastoreID: "ms-a", Owner: "app-1"}, account, "wsa", "1", "prod") - if err != nil { - t.Fatalf("metastoreResource: %v", err) - } - - // A cursor the first workspace's permissions endpoint handed back. - secondPage := "cursor-from-wsa" - grants, _, err := m.Grants(context.Background(), metastore, rs.SyncOpAttrs{ - PageToken: pagination.Token{Token: secondPage}, - }) - if status.Code(err) != codes.FailedPrecondition { - t.Fatalf("Grants = %v, want FailedPrecondition instead of re-routing mid-pagination", err) - } - if len(grants) != 0 { - t.Errorf("Grants returned %d grants on a failed page, want none", len(grants)) - } - if assignmentLists != 0 { - t.Errorf("the route was rebuilt %d time(s) while a page token was in hand", assignmentLists) - } -} - -// A metastore pruned between List and Grants answers the way the access path -// answers it before the first read: no grants, not a failed sync. -func TestGrantsReturnNoGrantsWhenTheMetastoreLeftTheSyncScope(t *testing.T) { - t.Setenv("BATON_HTTP_CACHE_TTL", "0") - - client, _ := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - path := r.URL.Path - switch { - case strings.HasSuffix(path, "/metastores"): - _ = json.NewEncoder(w).Encode(map[string]any{ - "metastores": []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "app-1"}}, - }) - case strings.Contains(path, "/metastores/") && strings.HasSuffix(path, "/workspaces"): - // Every attached workspace is now outside the configured allowlist. - _ = json.NewEncoder(w).Encode(map[string]any{"workspace_ids": []int{2}}) - case strings.HasSuffix(path, "/workspaces"): - _ = json.NewEncoder(w).Encode([]map[string]any{ - {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, - {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, - }) - case strings.Contains(path, "/unity-catalog/permissions/"): - http.Error(w, `{"error_code":"METASTORE_DOES_NOT_EXIST","message":"No metastore assigned for the current workspace"}`, http.StatusNotFound) - default: - http.Error(w, `{"message":"unexpected"}`, http.StatusInternalServerError) - } - }) - - uc := newUnityCatalog(client, "app-1", configuredWorkspaceSet([]string{"prod"})) - m := newMetastoreBuilder(client, uc, syncPrincipals{}) - account := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} - metastore, err := metastoreResource(context.Background(), &databricks.Metastore{MetastoreID: "ms-a", Owner: "app-1"}, account, "wsa", "1", "prod") - if err != nil { - t.Fatalf("metastoreResource: %v", err) - } - - grants, _, err := m.Grants(context.Background(), metastore, rs.SyncOpAttrs{}) - if err != nil { - t.Fatalf("Grants = %v, want no grants and no error", err) - } - if len(grants) != 0 { - t.Errorf("Grants returned %d grants, want none", len(grants)) - } -} diff --git a/pkg/databricks/models.go b/pkg/databricks/models.go index 37315013..541b8ccb 100644 --- a/pkg/databricks/models.go +++ b/pkg/databricks/models.go @@ -151,13 +151,6 @@ type Metastore struct { Owner string `json:"owner"` } -// Catalog carries only what resolving an access path needs: which metastore a -// workspace can list catalogs for. -type Catalog struct { - Name string `json:"name"` - MetastoreID string `json:"metastore_id"` -} - type PrivilegeAssignment struct { Principal string `json:"principal"` PrincipalID json.Number `json:"principal_id"` @@ -194,11 +187,6 @@ type metastoreWorkspacesResponse struct { WorkspaceIDs []json.Number `json:"workspace_ids"` } -type catalogsResponse struct { - Catalogs []Catalog `json:"catalogs"` - NextPageToken string `json:"next_page_token"` -} - type permissionsResponse struct { PrivilegeAssignments []PrivilegeAssignment `json:"privilege_assignments"` NextPageToken string `json:"next_page_token"` diff --git a/pkg/databricks/request.go b/pkg/databricks/request.go index ce0a16f2..f878d056 100644 --- a/pkg/databricks/request.go +++ b/pkg/databricks/request.go @@ -48,19 +48,6 @@ func wrapTransportAuthError(err error) error { return uhttp.WrapErrors(code, "databricks-connector: authentication failed", err) } -// Databricks error_code values for a named securable that is not there. -// The code narrows a 404 to the named securable, but it does not prove the -// securable is gone: METASTORE_DOES_NOT_EXIST also answers a workspace that -// serves no metastore. Only the account list separates absence from a route -// that does not reach it. -const ( - ErrorCodeMetastoreDoesNotExist = "METASTORE_DOES_NOT_EXIST" - ErrorCodeCatalogDoesNotExist = "CATALOG_DOES_NOT_EXIST" - ErrorCodeSchemaDoesNotExist = "SCHEMA_DOES_NOT_EXIST" - ErrorCodeTableDoesNotExist = "TABLE_DOES_NOT_EXIST" - ErrorCodeVolumeDoesNotExist = "VOLUME_DOES_NOT_EXIST" -) - // APIError represents an error response from the Databricks API. type APIError struct { StatusCode int @@ -81,31 +68,6 @@ func (e *APIError) Error() string { ) } -// HasErrorCode reports a 404 whose body named one of the given error codes. -func (e *APIError) HasErrorCode(codes ...string) bool { - if e == nil || e.StatusCode != http.StatusNotFound || e.ErrorCode == "" { - return false - } - for _, code := range codes { - if e.ErrorCode == code { - return true - } - } - - return false -} - -// IsSecurableGone reports a 404 for a named securable that no longer exists. -// A 404 with any other error_code (or none) is a routing or permission miss. -func IsSecurableGone(err error, errorCodes ...string) bool { - var apiErr *APIError - if !errors.As(err, &apiErr) { - return false - } - - return apiErr.HasErrorCode(errorCodes...) -} - func (e *APIError) Unwrap() error { return e.Err } diff --git a/pkg/databricks/unity_catalog.go b/pkg/databricks/unity_catalog.go index b233fa32..45a2acee 100644 --- a/pkg/databricks/unity_catalog.go +++ b/pkg/databricks/unity_catalog.go @@ -20,9 +20,6 @@ const ( // https://docs.databricks.com/api/account/accountmetastoreassignments/list accountMetastoreWorkspacesEndpoint = "/api/2.0/accounts/%s/metastores/%s/workspaces" - // https://docs.databricks.com/api/workspace/catalogs/list - unityCatalogCatalogsEndpoint = "/api/2.1/unity-catalog/catalogs" - // GET https://docs.databricks.com/api/workspace/grants/get // PATCH https://docs.databricks.com/api/workspace/grants/update unityCatalogPermissionsEndpoint = "/api/2.1/unity-catalog/permissions" @@ -85,69 +82,6 @@ func (c *Client) ListMetastoreWorkspaces( return ids, ratelimitData, nil } -// listSecurables reads one page off a workspace-scoped securable endpoint. -func listSecurables[R any, T any]( - ctx context.Context, - c *Client, - workspaceId, endpoint string, - page func(res *R) ([]T, string), - vars ...Vars, -) ( - []T, - string, - *v2.RateLimitDescription, - error, -) { - var res R - - u := c.workspaceUrl(workspaceId).JoinPath(endpoint) - ratelimitData, err := c.Get(ctx, u, &res, vars...) - if err != nil { - return nil, "", ratelimitData, nameWorkspace403Remedy(workspaceId, err) - } - - items, nextPageToken := page(&res) - - return items, nextPageToken, ratelimitData, nil -} - -// listCatalogs (GET /api/2.1/unity-catalog/catalogs) pages the catalogs of the -// metastore attached to workspaceId, which is a deployment name. -func (c *Client) listCatalogs( - ctx context.Context, - workspaceId, pageToken string, - maxResults uint, -) ( - []Catalog, - string, - *v2.RateLimitDescription, - error, -) { - catalogs, next, ratelimitData, err := listSecurables(ctx, c, workspaceId, unityCatalogCatalogsEndpoint, - func(res *catalogsResponse) ([]Catalog, string) { return res.Catalogs, res.NextPageToken }, - &unityCatalogVars{ - maxResults: &maxResults, - pageToken: pageToken, - }) - if err != nil { - return nil, "", ratelimitData, fmt.Errorf("failed to list catalogs: %w", err) - } - - return catalogs, next, ratelimitData, nil -} - -// ForEachCatalogPage walks listCatalogs without retaining the pages. -func (c *Client) ForEachCatalogPage( - ctx context.Context, - workspaceId string, - maxResults uint, - visit func([]Catalog) (bool, error), -) (*v2.RateLimitDescription, error) { - return forEachPageFrom(ctx, "", func(pageToken string) ([]Catalog, string, *v2.RateLimitDescription, error) { - return c.listCatalogs(ctx, workspaceId, pageToken, maxResults) - }, visit) -} - // ListPermissions (GET /api/2.1/unity-catalog/permissions/{securable_type}/{full_name}). // The principal query filters effectively, not literally: an inherited assignment // comes back naming the group it is inherited through, so a caller must read every From 1906849272dfff3d04ba4c528982eb9a85a721ed Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Thu, 22 Oct 2026 12:57:42 -0300 Subject: [PATCH 19/36] fix(databricks): keep a metastore whose assignment list cannot be read and skip terminal workspaces Co-authored-by: Cursor --- pkg/connector/metastore.go | 13 ++++--- pkg/connector/metastore_helpers.go | 26 ++++++++++++- pkg/connector/metastore_test.go | 62 ++++++++++++++++++++++++++++-- 3 files changed, 90 insertions(+), 11 deletions(-) diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index 6b029745..4679afdd 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -92,15 +92,16 @@ func (m *metastoreBuilder) List(ctx context.Context, parentResourceID *v2.Resour continue } + // An assignment list that cannot be read, including a 404, leaves which + // workspaces serve the metastore unknown. Skipping it here would drop the + // metastore and make ConductorOne remove its grants, so the sync fails and + // keeps what it already holds. A metastore that is really gone stops being + // returned by ListMetastores instead. workspace, inScope, err := m.routeMetastore(ctx, metastore.MetastoreID, scoped, running, &annos) if err != nil { - if isNotFoundError(err) { - l.Debug("databricks-connector: metastore is no longer on the account; skipping it", - zap.String("metastore", metastore.MetastoreID)) - continue - } return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( - "databricks-connector: failed to list workspaces for metastore %s: %w", metastore.MetastoreID, err) + "databricks-connector: failed to list the workspaces assigned to metastore %s, so the sync cannot tell which serve it: %w", + metastore.MetastoreID, err) } if workspace == nil { if inScope { diff --git a/pkg/connector/metastore_helpers.go b/pkg/connector/metastore_helpers.go index ce330f38..7f833f6e 100644 --- a/pkg/connector/metastore_helpers.go +++ b/pkg/connector/metastore_helpers.go @@ -20,7 +20,13 @@ import ( const ( securableTypeMetastore = "METASTORE" - workspaceStatusRunning = "RUNNING" + + // Statuses the account API documents for a workspace. PROVISIONING, + // NOT_PROVISIONED and CANCELLING can still change; FAILED and BANNED cannot. + workspaceStatusRunning = "RUNNING" + workspaceStatusProvisioning = "PROVISIONING" + workspaceStatusNotProvisioned = "NOT_PROVISIONED" + workspaceStatusCancelling = "CANCELLING" profileKeyMetastoreID = "metastore_id" profileKeyOwner = "owner" @@ -197,6 +203,12 @@ func (m *metastoreBuilder) scopedWorkspaces(ctx context.Context, annos *annotati continue } } + // A workspace that will not become RUNNING is left out of scope entirely, + // so a metastore attached only to one is skipped rather than failing every + // sync. Only a status that can still change keeps the metastore waiting. + if !workspaceIsUsable(workspace.Status) { + continue + } scoped[id] = workspace running[id] = workspace.Status == "" || strings.EqualFold(workspace.Status, workspaceStatusRunning) } @@ -204,6 +216,18 @@ func (m *metastoreBuilder) scopedWorkspaces(ctx context.Context, annos *annotati return scoped, running, nil } +// workspaceIsUsable reports a status that is RUNNING or can still become it. +// FAILED and BANNED are terminal, and a status Databricks has not documented +// takes that path too: treating one as pending would fail the sync on every run. +func workspaceIsUsable(workspaceStatus string) bool { + switch strings.ToUpper(workspaceStatus) { + case "", workspaceStatusRunning, workspaceStatusProvisioning, workspaceStatusNotProvisioned, workspaceStatusCancelling: + return true + default: + return false + } +} + // routeMetastore picks the first running in-scope workspace attached to the // metastore. inScope reports whether any in-scope workspace is attached at all. func (m *metastoreBuilder) routeMetastore( diff --git a/pkg/connector/metastore_test.go b/pkg/connector/metastore_test.go index d2c61efb..0f729f26 100644 --- a/pkg/connector/metastore_test.go +++ b/pkg/connector/metastore_test.go @@ -205,6 +205,63 @@ func testMetastore(t *testing.T, workspace string) *v2.Resource { return r } +// Which workspaces serve a metastore decides whether it syncs at all, so an +// assignment list the connector cannot read is unknown, not absent. Skipping it +// would drop the metastore and make ConductorOne remove its grants. +func TestMetastoreListFailsWhenAnAssignmentListCannotBeRead(t *testing.T) { + for _, code := range []int{http.StatusNotFound, http.StatusForbidden, http.StatusUnauthorized} { + t.Run(http.StatusText(code), func(t *testing.T) { + f := defaultFakeAccount() + f.assignments = map[string]any{"ms-a": code} + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) + + resources, _, err := m.List(context.Background(), testAccountResourceID, rs.SyncOpAttrs{}) + if err == nil { + t.Fatalf("List returned %d resources and no error, want the sync to fail", len(resources)) + } + if !strings.Contains(err.Error(), "ms-a") { + t.Errorf("error %q does not name the metastore", err) + } + }) + } +} + +// A workspace that will not become RUNNING leaves the metastore out of the sync +// instead of failing it on every run; one that can still change keeps it waiting. +func TestMetastoreListSeparatesTerminalFromPendingWorkspaces(t *testing.T) { + tests := []struct { + workspaceStatus string + wantErr bool + wantSynced bool + }{ + {"RUNNING", false, true}, + {"PROVISIONING", true, false}, + {"NOT_PROVISIONED", true, false}, + {"CANCELLING", true, false}, + {"FAILED", false, false}, + {"BANNED", false, false}, + {"SOMETHING_NEW", false, false}, + } + for _, test := range tests { + t.Run(test.workspaceStatus, func(t *testing.T) { + f := defaultFakeAccount() + f.assignments = map[string]any{"ms-a": []int{1}} + f.workspaces = []map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": test.workspaceStatus}, + } + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) + + resources, _, err := m.List(context.Background(), testAccountResourceID, rs.SyncOpAttrs{}) + if test.wantErr != (err != nil) { + t.Fatalf("List error = %v, want error: %v", err, test.wantErr) + } + if got := len(resources) > 0; got != test.wantSynced { + t.Errorf("metastore synced = %v, want %v", got, test.wantSynced) + } + }) + } +} + func TestMetastoreListPicksTheFirstUsableWorkspace(t *testing.T) { tests := []struct { name string @@ -218,12 +275,9 @@ func TestMetastoreListPicksTheFirstUsableWorkspace(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { f := defaultFakeAccount() - f.metastores = append(f.metastores, - map[string]any{"metastore_id": "ms-missing"}, - map[string]any{"metastore_id": "ms-elsewhere"}) + f.metastores = append(f.metastores, map[string]any{"metastore_id": "ms-elsewhere"}) f.assignments = map[string]any{ "ms-a": []int{2, 1, 3}, - "ms-missing": http.StatusNotFound, "ms-elsewhere": []int{4}, } f.workspaces = []map[string]any{ From 08c294e87eb1eb9827a0ff9e0b77209bab5d78f2 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Thu, 22 Oct 2026 13:30:07 -0300 Subject: [PATCH 20/36] fix(databricks): refuse to report a metastore privilege list the connector cannot fully read Co-authored-by: Cursor --- pkg/connector/connector.go | 15 ++- pkg/connector/metastore.go | 22 +++- pkg/connector/metastore_helpers.go | 173 +++++++++++++++++++++++++++++ pkg/connector/metastore_test.go | 123 +++++++++++++++++++- 4 files changed, 321 insertions(+), 12 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 0585c9fe..9d0feb3c 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -17,7 +17,10 @@ import ( ) type Databricks struct { - client *databricks.Client + client *databricks.Client + // clientID is the applicationId of the service principal the connector + // authenticates as. Grant visibility is decided against it. + clientID string workspaces []string opts *cli.ConnectorOpts } @@ -31,7 +34,7 @@ func (d *Databricks) ResourceSyncers(ctx context.Context) []connectorbuilder.Res newUserBuilder(d.client), newWorkspaceBuilder(d.client, d.workspaces), newRoleBuilder(d.client), - newMetastoreBuilder(d.client, configuredWorkspaceSet(d.workspaces), syncPrincipals{ + newMetastoreBuilder(d.client, d.clientID, configuredWorkspaceSet(d.workspaces), syncPrincipals{ groups: d.willSyncResourceType(groupResourceType.Id), servicePrincipals: d.willSyncResourceType(servicePrincipalResourceType.Id), }), @@ -143,13 +146,15 @@ func (d *Databricks) Validate(ctx context.Context) (annotations.Annotations, err return nil, nil } -// New returns a new instance of the connector. +// New returns a new instance of the connector. clientID is the application id of +// the service principal the connector authenticates as. func New( ctx context.Context, hostname, accountHostname, accountID, - baseURL string, + baseURL, + clientID string, auth databricks.Auth, excludeWorkspaces []string, workspaces []string, @@ -167,6 +172,7 @@ func New( return &Databricks{ client: client, + clientID: clientID, workspaces: workspaces, opts: opts, }, nil @@ -183,6 +189,7 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, opts *cli.Connect accountHostname, cfg.AccountId, cfg.BaseUrl, + cfg.DatabricksClientId, auth, cfg.DatabricksExcludeWorkspaces, cfg.Workspaces, diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index 4679afdd..243cb02e 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -25,13 +25,16 @@ var _ connectorbuilder.StaticEntitlementSyncerV2 = (*metastoreBuilder)(nil) type metastoreBuilder struct { client *databricks.Client + // clientID is the applicationId of the service principal the connector + // authenticates as, which is what grant visibility is decided against. + clientID string // workspaces is the configured allowlist, empty when none was given. workspaces map[string]struct{} sync syncPrincipals } -func newMetastoreBuilder(client *databricks.Client, workspaces map[string]struct{}, sync syncPrincipals) *metastoreBuilder { - return &metastoreBuilder{client: client, workspaces: workspaces, sync: sync} +func newMetastoreBuilder(client *databricks.Client, clientID string, workspaces map[string]struct{}, sync syncPrincipals) *metastoreBuilder { + return &metastoreBuilder{client: client, clientID: clientID, workspaces: workspaces, sync: sync} } func (m *metastoreBuilder) ResourceType(_ context.Context) *v2.ResourceType { @@ -155,6 +158,10 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at } annos := annotations.Annotations{} + if err := m.ensureGrantsAreTrustworthy(ctx, resource, metastoreID, workspace, &annos); err != nil { + return nil, &rs.SyncOpResults{Annotations: annos}, err + } + pageToken := attr.PageToken.Token assignments, nextPageToken, rateLimit, err := m.client.ListPermissions(ctx, workspace, databricks.SecurableMetastore, metastoreID, "", pageToken) noteRateLimit(&annos, rateLimit) @@ -220,6 +227,7 @@ func (m *metastoreBuilder) resolveChange( principalID *v2.ResourceId, entitlement *v2.Entitlement, isGrant bool, + annos *annotations.Annotations, ) (metastoreChange, error) { if !isValidPrincipal(principalID) { return metastoreChange{}, status.Errorf(codes.InvalidArgument, @@ -254,6 +262,12 @@ func (m *metastoreBuilder) resolveChange( "databricks-connector: metastore %s has no workspace on its profile", metastoreID) } + // A privilege list the connector cannot fully read also cannot be safely + // written to: the PATCH would be built from the rows it is allowed to see. + if err := m.ensureGrantsAreTrustworthy(ctx, resource, metastoreID, workspace, annos); err != nil { + return metastoreChange{}, err + } + scimID, err := unityPrincipalScimID(principalID) if err != nil { return metastoreChange{}, fmt.Errorf("databricks-connector: %w", err) @@ -294,7 +308,7 @@ func (c metastoreChange) updateError(verb string, err error) error { func (m *metastoreBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { annos := annotations.Annotations{} - change, err := m.resolveChange(ctx, principal.GetId(), entitlement, true) + change, err := m.resolveChange(ctx, principal.GetId(), entitlement, true, &annos) if err != nil { return annos, err } @@ -334,7 +348,7 @@ func (m *metastoreBuilder) Grant(ctx context.Context, principal *v2.Resource, en func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annotations.Annotations, error) { annos := annotations.Annotations{} - change, err := m.resolveChange(ctx, revoked.GetPrincipal().GetId(), revoked.GetEntitlement(), false) + change, err := m.resolveChange(ctx, revoked.GetPrincipal().GetId(), revoked.GetEntitlement(), false, &annos) if err != nil { return annos, err } diff --git a/pkg/connector/metastore_helpers.go b/pkg/connector/metastore_helpers.go index 7f833f6e..dc5b25d1 100644 --- a/pkg/connector/metastore_helpers.go +++ b/pkg/connector/metastore_helpers.go @@ -14,6 +14,8 @@ import ( "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/types/grant" rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -21,6 +23,10 @@ import ( const ( securableTypeMetastore = "METASTORE" + // workspacePermissionAdmin is the workspace-level assignment that makes its + // holder a metastore admin for the workspace's Unity Catalog reads. + workspacePermissionAdmin = "ADMIN" + // Statuses the account API documents for a workspace. PROVISIONING, // NOT_PROVISIONED and CANCELLING can still change; FAILED and BANNED cannot. workspaceStatusRunning = "RUNNING" @@ -258,6 +264,173 @@ func (m *metastoreBuilder) routeMetastore( return nil, inScope, nil } +// metastoreGrantsAreTrustworthy reports whether the connector reads every +// privilege assignment on a metastore. Databricks answers an under-permissioned +// read of the permissions endpoint with 200 and only the rows the caller may +// see, which is byte-identical to a securable that has no grants. Reporting +// that subset would make ConductorOne revoke every privilege it cannot see, so +// the sync refuses to report grants it cannot fully read. +// +// MANAGE and ALL_PRIVILEGES are rejected at the metastore level, so a privilege +// read cannot settle this. The two authorities are owning the metastore and +// being assigned Admin directly on the workspace the read goes through. +// https://docs.databricks.com/aws/en/data-governance/unity-catalog/manage-privileges/admin-privileges +// https://docs.databricks.com/aws/en/data-governance/unity-catalog/manage-privileges/ +func metastoreGrantsAreTrustworthy( + ctx context.Context, + client *databricks.Client, + ownPrincipal, metastoreOwner, workspaceID string, + annos *annotations.Annotations, +) (bool, error) { + // An owner holds every privilege implicitly, which is not reported as a grant. + if ownsPrincipal(ownPrincipal, metastoreOwner) { + return true, nil + } + + return isWorkspaceAdmin(ctx, client, ownPrincipal, workspaceID, annos) +} + +// ownsPrincipal reports whether name is the service principal the connector +// authenticates as. Databricks names a service principal by its applicationId +// in an owner field, and does not normalise its case. +func ownsPrincipal(ownPrincipal, name string) bool { + return ownPrincipal != "" && strings.EqualFold(name, ownPrincipal) +} + +// isWorkspaceAdmin lists the workspace's permission assignments on every call. +// An assignment list the connector may not read leaves Admin unproven, which is +// the same answer as not holding it. +func isWorkspaceAdmin( + ctx context.Context, + client *databricks.Client, + ownPrincipal, workspaceID string, + annos *annotations.Annotations, +) (bool, error) { + if ownPrincipal == "" || workspaceID == "" { + return false, nil + } + + assignments, rateLimit, err := client.ListWorkspaceMembers(ctx, workspaceID) + noteRateLimit(annos, rateLimit) + if err != nil { + if !deniesTrust(err) { + return false, fmt.Errorf("databricks-connector: failed to list the permission assignments of workspace %s: %w", workspaceID, err) + } + ctxzap.Extract(ctx).Debug("databricks-connector: the permission assignments of the workspace cannot be read, so Admin stays unproven", + zap.String("workspace_id", workspaceID), + zap.Error(err)) + + return false, nil + } + + return holdsWorkspaceAdmin(ctx, client, ownPrincipal, assignments, annos) +} + +// holdsWorkspaceAdmin matches the connector's own principal against a +// workspace's ADMIN assignments. A service principal is named by its +// applicationId, so most accounts settle without the SCIM lookup. +// Admin inherited only through a group is ignored: the connector principal must +// hold ADMIN itself. Walking every admin group's membership on every call is +// the cost that would close this gap. +func holdsWorkspaceAdmin( + ctx context.Context, + client *databricks.Client, + ownPrincipal string, + assignments []databricks.WorkspaceAssignment, + annos *annotations.Annotations, +) (bool, error) { + var adminIDs []string + for _, assignment := range assignments { + principal := assignment.Principal + if principal == nil || !slices.Contains(assignment.Permissions, workspacePermissionAdmin) { + continue + } + if ownsPrincipal(ownPrincipal, principal.ServicePrincipalAppID) { + return true, nil + } + if principal.ID != 0 { + adminIDs = append(adminIDs, strconv.Itoa(principal.ID)) + } + } + + if len(adminIDs) == 0 { + return false, nil + } + + ownID, err := ownPrincipalScimID(ctx, client, ownPrincipal, annos) + if err != nil { + return false, err + } + + return ownID != "" && slices.Contains(adminIDs, ownID), nil +} + +// ownPrincipalScimID resolves the connector's own service principal. An +// applicationId filter on the service principal endpoint can only match a +// service principal, which is why this asks for the one record instead of +// walking account SCIM, where the same name field is shared by three namespaces. +func ownPrincipalScimID( + ctx context.Context, + client *databricks.Client, + ownPrincipal string, + annos *annotations.Annotations, +) (string, error) { + id, rateLimit, err := client.FindServicePrincipalID(ctx, "", ownPrincipal) + noteRateLimit(annos, rateLimit) + if err != nil { + return "", fmt.Errorf("databricks-connector: failed to resolve the connector's own service principal: %w", err) + } + + return id, nil +} + +// deniesTrust is an answer that withholds what the connector asked for, as +// opposed to one that says the account plane is unavailable. +func deniesTrust(err error) bool { + switch apiStatus(err) { + case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: + return true + default: + return false + } +} + +// ensureGrantsAreTrustworthy settles whether the privilege list this call is +// about to read, or write to, is the whole one. It runs per call and keeps no +// answer: ConductorOne can hand back a profile from an earlier sync, so a +// remembered verdict would outlive the access that earned it. The probe costs +// one account call, and none at all when the connector owns the metastore. +func (m *metastoreBuilder) ensureGrantsAreTrustworthy( + ctx context.Context, + resource *v2.Resource, + metastoreID, workspace string, + annos *annotations.Annotations, +) error { + profile := rs.GetProfile(resource) + owner, _ := rs.GetProfileStringValue(profile, profileKeyOwner) + workspaceID, _ := rs.GetProfileStringValue(profile, profileKeyWorkspaceID) + + trustworthy, err := metastoreGrantsAreTrustworthy(ctx, m.client, m.clientID, owner, workspaceID, annos) + if err != nil { + return err + } + if !trustworthy { + return untrustedMetastoreError(metastoreID, workspace) + } + + return nil +} + +// untrustedMetastoreError names the metastore, the workspace the read goes +// through and what an operator has to change. +func untrustedMetastoreError(metastoreID, workspace string) error { + return status.Errorf(codes.PermissionDenied, + "databricks-connector: cannot read every privilege on metastore %s through workspace %s, and Databricks answers a partial read with 200, "+ + "so reporting it would revoke the access ConductorOne cannot see; "+ + "the connector's service principal must own the metastore or be assigned Admin directly on the workspace", + metastoreID, workspace) +} + // principalResolver maps a privilege assignment's principal to a C1 resource id. // One field carries a userName, a displayName or an applicationId with no type, // so lookups go through account SCIM in a fixed order. Results are cached for diff --git a/pkg/connector/metastore_test.go b/pkg/connector/metastore_test.go index 0f729f26..fdfa6810 100644 --- a/pkg/connector/metastore_test.go +++ b/pkg/connector/metastore_test.go @@ -67,9 +67,14 @@ type fakeAccount struct { groups map[string]string // id -> displayName sps map[string]string // id -> applicationId - permHosts []string - patches []map[string]any - scimGets map[string]int + // workspaceMembers maps a workspace id to its permission assignments, or to + // the HTTP status the account API answers the list with. + workspaceMembers map[string]any + + permHosts []string + patches []map[string]any + scimGets map[string]int + memberLists []string } func defaultFakeAccount() *fakeAccount { @@ -115,6 +120,16 @@ func (f *fakeAccount) handler(t *testing.T) http.HandlerFunc { default: encode(map[string]any{"workspace_ids": v}) } + case strings.HasSuffix(path, "/permissionassignments"): + parts := strings.Split(path, "/") + workspaceID := parts[len(parts)-2] + f.memberLists = append(f.memberLists, workspaceID) + switch v := f.workspaceMembers[workspaceID].(type) { + case int: + http.Error(w, `{"message":"denied"}`, v) + default: + encode(map[string]any{"permission_assignments": v}) + } case strings.HasSuffix(path, "/workspaces"): encode(f.workspaces) case strings.Contains(path, "/unity-catalog/permissions/metastore/"): @@ -185,11 +200,20 @@ func (f *fakeAccount) apply(change map[string]any) { } } +// testOwnPrincipal is the applicationId the connector authenticates as. It is +// the owner of the metastore in defaultFakeAccount, which is the live shape: +// ownership settles grant visibility without any probe call. +const testOwnPrincipal = "eng" + func newTestMetastoreBuilder(t *testing.T, f *fakeAccount, allowlist []string, sync syncPrincipals) *metastoreBuilder { + return newTestMetastoreBuilderAs(t, f, allowlist, sync, testOwnPrincipal) +} + +func newTestMetastoreBuilderAs(t *testing.T, f *fakeAccount, allowlist []string, sync syncPrincipals, clientID string) *metastoreBuilder { t.Setenv("BATON_HTTP_CACHE_TTL", "0") client, _ := newWorkspaceRoutedClient(t, f.handler(t)) - return newMetastoreBuilder(client, configuredWorkspaceSet(allowlist), sync) + return newMetastoreBuilder(client, clientID, configuredWorkspaceSet(allowlist), sync) } var testAccountResourceID = &v2.ResourceId{ResourceType: "account", Resource: testAccountId} @@ -439,6 +463,69 @@ func TestMetastoreGrantsFailOnErrors(t *testing.T) { } } +// Databricks answers an under-permissioned read of the permissions endpoint +// with 200 and only the privileges the caller may see, which is indistinguishable +// from a metastore with no grants. The sync therefore reports grants only when +// it can prove it reads all of them. +func TestMetastoreGrantsRequireFullVisibility(t *testing.T) { + adminByAppID := []map[string]any{ + {"principal": map[string]any{"service_principal_name": "app-9"}, "permissions": []string{"ADMIN"}}, + } + adminByScimID := []map[string]any{ + {"principal": map[string]any{"principal_id": 3}, "permissions": []string{"ADMIN"}}, + } + notAdmin := []map[string]any{ + {"principal": map[string]any{"service_principal_name": "app-9"}, "permissions": []string{"USER"}}, + {"principal": map[string]any{"group_name": "admins"}, "permissions": []string{"ADMIN"}}, + } + + tests := []struct { + name string + clientID string + members any + wantGrants bool + wantProbes int + }{ + {"owner of the metastore", testOwnPrincipal, nil, true, 0}, + {"direct workspace admin", "app-9", adminByAppID, true, 1}, + {"direct workspace admin matched by scim id", "app-9", adminByScimID, true, 1}, + {"admin held only through a group", "app-9", notAdmin, false, 1}, + {"assignment list denied", "app-9", http.StatusForbidden, false, 1}, + {"no principal to compare", "", adminByAppID, false, 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := defaultFakeAccount() + f.workspaceMembers = map[string]any{"1": test.members} + f.permissions = []map[string]any{ + {"principal": "alice@example.com", "principal_id": 1, "privileges": []string{"CREATE_CATALOG"}}, + } + m := newTestMetastoreBuilderAs(t, f, nil, syncPrincipals{}, test.clientID) + + grants, _, err := m.Grants(context.Background(), testMetastore(t, "wsa"), rs.SyncOpAttrs{}) + switch { + case test.wantGrants && err != nil: + t.Fatalf("Grants: %v", err) + case test.wantGrants && len(grants) == 0: + t.Fatal("Grants returned nothing, want the privileges it can read") + case !test.wantGrants: + if status.Code(err) != codes.PermissionDenied { + t.Fatalf("Grants = %v, want PermissionDenied", err) + } + if len(grants) != 0 { + t.Errorf("grants = %v, want none", grantKeys(grants)) + } + if !strings.Contains(err.Error(), "ms-a") { + t.Errorf("error %q does not name the metastore", err) + } + } + if len(f.memberLists) != test.wantProbes { + t.Errorf("workspace assignments listed %d times, want %d", len(f.memberLists), test.wantProbes) + } + }) + } +} + var alice = &v2.Resource{Id: &v2.ResourceId{ResourceType: "user", Resource: "1"}} func TestMetastoreGrant(t *testing.T) { @@ -513,6 +600,34 @@ func TestMetastoreRevoke(t *testing.T) { } } +// A privilege list the connector cannot fully read cannot be written to either: +// the change would be decided from the subset Databricks chose to show. +func TestMetastoreProvisioningRefusesAPartialPrivilegeList(t *testing.T) { + for _, verb := range []string{"grant", "revoke"} { + t.Run(verb, func(t *testing.T) { + f := defaultFakeAccount() + f.permissions = []map[string]any{ + {"principal": "alice@example.com", "principal_id": 1, "privileges": []string{"CREATE_CATALOG"}}, + } + m := newTestMetastoreBuilderAs(t, f, nil, syncPrincipals{}, "app-9") + g := grant.NewGrant(testMetastore(t, "wsa"), "CREATE_CATALOG", alice.GetId()) + + var err error + if verb == "grant" { + _, err = m.Grant(context.Background(), alice, g.GetEntitlement()) + } else { + _, err = m.Revoke(context.Background(), g) + } + if status.Code(err) != codes.PermissionDenied { + t.Fatalf("%s = %v, want PermissionDenied", verb, err) + } + if len(f.patches) != 0 { + t.Errorf("patches = %v, want none", f.patches) + } + }) + } +} + // Every workspace answers /api/2.1/unity-catalog/permissions with the same path // and query, so a cache shared between hosts would misattribute privileges. func TestPermissionsListingIsNotAnsweredByAnotherWorkspacesCachedPage(t *testing.T) { From 6b52954474b0da689c247c8dc4661eda08204eda Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Thu, 22 Oct 2026 17:12:54 -0300 Subject: [PATCH 21/36] refactor(databricks): drop the redundant clientID comment Co-authored-by: Cursor --- pkg/connector/connector.go | 2 -- pkg/connector/metastore.go | 2 -- 2 files changed, 4 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 9d0feb3c..d8048d6c 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -18,8 +18,6 @@ import ( type Databricks struct { client *databricks.Client - // clientID is the applicationId of the service principal the connector - // authenticates as. Grant visibility is decided against it. clientID string workspaces []string opts *cli.ConnectorOpts diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index 243cb02e..b812f077 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -25,8 +25,6 @@ var _ connectorbuilder.StaticEntitlementSyncerV2 = (*metastoreBuilder)(nil) type metastoreBuilder struct { client *databricks.Client - // clientID is the applicationId of the service principal the connector - // authenticates as, which is what grant visibility is decided against. clientID string // workspaces is the configured allowlist, empty when none was given. workspaces map[string]struct{} From 24b901e8f1602f71eb815cb9dcd7086654904dc9 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Thu, 22 Oct 2026 17:16:54 -0300 Subject: [PATCH 22/36] docs(databricks): state the limits the sync actually enforces Co-authored-by: Cursor --- README.md | 9 ++++----- docs/connector.mdx | 8 ++------ docs/docs-info.md | 40 +++++++--------------------------------- 3 files changed, 13 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 413b7499..189918cf 100644 --- a/README.md +++ b/README.md @@ -181,11 +181,10 @@ fails with an `InvalidArgument` error. ## Unity Catalog permissions The service principal must own the metastore, or be assigned **Admin** directly -on a workspace the metastore is attached to. Admin inherited only through a -group is not enough. Databricks answers an under-permissioned privilege read -with HTTP 200 and the subset the caller may see, which is indistinguishable -from an object with no grants, so a principal without that standing makes -ConductorOne read the privileges it cannot see as revoked access. +on the workspace the metastore is read through. Admin inherited only through a +group is not enough. Databricks answers an under-permissioned read with HTTP 200 +and only the rows the caller may see. The connector refuses that read, so the +sync fails and ConductorOne keeps the grants it already has. The connector requests the `all-apis` OAuth scope. Each resource type declares the scopes, roles and privileges it needs, so `./baton-databricks capabilities` diff --git a/docs/connector.mdx b/docs/connector.mdx index f2a20299..2a651bd3 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -111,7 +111,7 @@ There is no scope to pick in the console. The connector requests the `all-apis` -Without this, the metastore sync fails with a permission error naming the metastore. Databricks returns a partial privilege list rather than an error, and C1 would treat the missing grants as revoked. +Without this, the metastore sync fails with a permission error naming the metastore, and ConductorOne keeps the grants it already has. Databricks would otherwise return HTTP 200 with only the privileges the caller can see. **Done.** Here's the set of credentials you'll need when setting up the connector: @@ -333,8 +333,4 @@ spec: **Done.** Your Databricks connector is now pulling access data into C1. - - - -Stable binaries and container images for the Databricks connector are available from the [official download center](https://dist.conductorone.com/ConductorOne/baton-databricks). - \ No newline at end of file + \ No newline at end of file diff --git a/docs/docs-info.md b/docs/docs-info.md index 0356cc66..12de5eee 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -52,36 +52,10 @@ While developing the connector, please fill out this form. This information is n ## Limitations -- **Direct grants only.** Unity Catalog privileges inherit downward — - `READ_METADATA` granted on a metastore inherits to every object beneath it — - but the connector reports the privilege assignments recorded on the object - itself. The endpoint that exposes inheritance does not return principal IDs, - which are what the connector needs to resolve a principal unambiguously. -- **Workspace Admin must be assigned directly.** Admin inherited only through a - group does not let Databricks return every privilege assignment on a - metastore, so the sync would report the subset the service principal can see - as the complete set. -- **Ownership is read-only.** The owner of a metastore is shown but cannot be - provisioned. An owner that is not an account identity — `System user`, or a - workspace admins group such as `_workspace_admins_` — does not - resolve, and no owner grant is emitted for it. -- **`MANAGE` and `ALL_PRIVILEGES` do not exist at the metastore level** — - Databricks rejects them there, so neither is offered as an entitlement. -- **A metastore needs a running workspace.** Its privileges are not served on - the account plane, only through a running workspace attached to it. A - metastore that no running workspace in the sync reaches is not synced, and - ConductorOne removes it and its grants. Granting or revoking a privilege on - such a metastore fails and changes nothing in Databricks. -- **Narrowing `workspaces` narrows the metastores.** Only a metastore attached - to a listed workspace is synced. A list that names no workspace on the account - is rejected when the connector validates its credentials, before any sync - runs. -- **The workspace shown on a metastore is the access path.** `List` records the - first workspace in the sync that the metastore is assigned to, and every - privilege read, grant and revoke goes through it. Admin on that workspace is - what the service principal needs. -- **Other securables are not synced yet:** catalogs, schemas, tables, volumes, - functions, registered models, external locations, storage credentials, - connections, shares and Delta Sharing recipients. -- **Tags are not synced**, including the `SYSTEM.TEAM` convention some customers - use to drive just-in-time access. +- **Direct grants only.** Databricks inherits privileges downward. The connector reports the assignments stored on the object. The inheritance endpoint does not return principal IDs. +- **Direct Admin, or the sync fails.** Admin inherited through a group does not count. Databricks then returns HTTP 200 with only the privileges the caller can see. The sync fails instead of reporting that list, so ConductorOne keeps the grants it already has. +- **Ownership is read-only.** It is shown and cannot be granted or revoked. An owner that is not an account identity (`System user`, `_workspace_admins_…`) is not shown. +- **No `MANAGE` or `ALL_PRIVILEGES` on a metastore.** Databricks rejects both, so they are not offered. +- **Read through one running workspace.** Privileges are not served on the account plane. With no running workspace in scope, the metastore is not synced and ConductorOne removes it. A workspace that is still provisioning fails the sync until it is `RUNNING`. An unreadable assignment list fails the sync and ConductorOne keeps the metastore. +- **`workspaces` scopes metastores.** An allowlist that names no workspace on the account fails at credential validation, before any sync. +- **Not synced yet:** catalogs, schemas, tables, volumes, functions, registered models, external locations, storage credentials, connections, shares, recipients, and tags. From d525ad6fe408040c0977b0f3f6d7b143b4dc0fce Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Thu, 22 Oct 2026 17:20:26 -0300 Subject: [PATCH 23/36] docs(databricks): say a missing standing fails the sync Co-authored-by: Cursor --- docs/docs-info.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs-info.md b/docs/docs-info.md index 12de5eee..2ed955b6 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -40,7 +40,7 @@ While developing the connector, please fill out this form. This information is n > > The OAuth token carries scopes on top of that. The connector requests `all-apis`, which covers the four it uses: `unity-catalog` (the securables and the permissions endpoint), `scim` (account principals), `access-management` (roles and workspace permission assignments) and `provisioning` (the account workspaces listing). > - > Unity Catalog adds a third layer on top of scopes and roles: standing on the securable itself. For each metastore being synced, the service principal must either own the metastore or be assigned Admin directly on a workspace it is assigned to. Without that standing Databricks answers the privilege read with the subset the caller may see, and ConductorOne reads the rest as revoked. + > Unity Catalog adds a third layer on top of scopes and roles: standing on the securable itself. For each metastore being synced, the service principal must either own the metastore or be assigned Admin directly on the workspace it is read through. Without that standing the sync fails, and ConductorOne keeps the grants it already has. * If applicable: Is the list of scopes or permissions different to sync (read) versus provision (read-write)? If so, list the difference here. From 24e11c8930f74be8b0b181508519c413aa829717 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Thu, 22 Oct 2026 17:52:57 -0300 Subject: [PATCH 24/36] fix(databricks): stop a repeated privilege page token Co-authored-by: Cursor --- pkg/connector/metastore.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index b812f077..d4775231 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -24,7 +24,7 @@ import ( var _ connectorbuilder.StaticEntitlementSyncerV2 = (*metastoreBuilder)(nil) type metastoreBuilder struct { - client *databricks.Client + client *databricks.Client clientID string // workspaces is the configured allowlist, empty when none was given. workspaces map[string]struct{} @@ -208,6 +208,11 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at } } + if nextPageToken != "" && nextPageToken == pageToken { + return nil, &rs.SyncOpResults{Annotations: annos}, status.Errorf(codes.Internal, + "databricks-connector: listing privileges on metastore %s repeated page token %q", metastoreID, nextPageToken) + } + return rv, &rs.SyncOpResults{Annotations: annos, NextPageToken: nextPageToken}, nil } From ff64c51eb16cbac09c3638b1fdb13145ce52d3a5 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Thu, 22 Oct 2026 18:00:06 -0300 Subject: [PATCH 25/36] docs(databricks): restore the metastore warning and the download tip Co-authored-by: Cursor --- docs/connector.mdx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/connector.mdx b/docs/connector.mdx index 2a651bd3..f2a20299 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -111,7 +111,7 @@ There is no scope to pick in the console. The connector requests the `all-apis` -Without this, the metastore sync fails with a permission error naming the metastore, and ConductorOne keeps the grants it already has. Databricks would otherwise return HTTP 200 with only the privileges the caller can see. +Without this, the metastore sync fails with a permission error naming the metastore. Databricks returns a partial privilege list rather than an error, and C1 would treat the missing grants as revoked. **Done.** Here's the set of credentials you'll need when setting up the connector: @@ -333,4 +333,8 @@ spec: **Done.** Your Databricks connector is now pulling access data into C1. - \ No newline at end of file + + + +Stable binaries and container images for the Databricks connector are available from the [official download center](https://dist.conductorone.com/ConductorOne/baton-databricks). + \ No newline at end of file From 08ec1898500185146f5e5200797249d17e02ae4b Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Thu, 22 Oct 2026 18:07:49 -0300 Subject: [PATCH 26/36] refactor(databricks): shorten comments that restated the trust rules Co-authored-by: Cursor --- pkg/connector/metastore.go | 6 +---- pkg/connector/metastore_helpers.go | 36 +++++------------------------- 2 files changed, 6 insertions(+), 36 deletions(-) diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index d4775231..d1ad1eca 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -93,11 +93,7 @@ func (m *metastoreBuilder) List(ctx context.Context, parentResourceID *v2.Resour continue } - // An assignment list that cannot be read, including a 404, leaves which - // workspaces serve the metastore unknown. Skipping it here would drop the - // metastore and make ConductorOne remove its grants, so the sync fails and - // keeps what it already holds. A metastore that is really gone stops being - // returned by ListMetastores instead. + // A 404 here does not mean the metastore is gone. Skipping it would make ConductorOne delete it. workspace, inScope, err := m.routeMetastore(ctx, metastore.MetastoreID, scoped, running, &annos) if err != nil { return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( diff --git a/pkg/connector/metastore_helpers.go b/pkg/connector/metastore_helpers.go index dc5b25d1..7bd8df27 100644 --- a/pkg/connector/metastore_helpers.go +++ b/pkg/connector/metastore_helpers.go @@ -264,18 +264,7 @@ func (m *metastoreBuilder) routeMetastore( return nil, inScope, nil } -// metastoreGrantsAreTrustworthy reports whether the connector reads every -// privilege assignment on a metastore. Databricks answers an under-permissioned -// read of the permissions endpoint with 200 and only the rows the caller may -// see, which is byte-identical to a securable that has no grants. Reporting -// that subset would make ConductorOne revoke every privilege it cannot see, so -// the sync refuses to report grants it cannot fully read. -// -// MANAGE and ALL_PRIVILEGES are rejected at the metastore level, so a privilege -// read cannot settle this. The two authorities are owning the metastore and -// being assigned Admin directly on the workspace the read goes through. -// https://docs.databricks.com/aws/en/data-governance/unity-catalog/manage-privileges/admin-privileges -// https://docs.databricks.com/aws/en/data-governance/unity-catalog/manage-privileges/ +// metastoreGrantsAreTrustworthy is true when the connector owns the metastore or is a direct Admin of the routed workspace. MANAGE is not a metastore privilege. func metastoreGrantsAreTrustworthy( ctx context.Context, client *databricks.Client, @@ -326,12 +315,7 @@ func isWorkspaceAdmin( return holdsWorkspaceAdmin(ctx, client, ownPrincipal, assignments, annos) } -// holdsWorkspaceAdmin matches the connector's own principal against a -// workspace's ADMIN assignments. A service principal is named by its -// applicationId, so most accounts settle without the SCIM lookup. -// Admin inherited only through a group is ignored: the connector principal must -// hold ADMIN itself. Walking every admin group's membership on every call is -// the cost that would close this gap. +// holdsWorkspaceAdmin is true only when the connector principal itself holds ADMIN. Admin inherited through a group does not count. func holdsWorkspaceAdmin( ctx context.Context, client *databricks.Client, @@ -365,10 +349,7 @@ func holdsWorkspaceAdmin( return ownID != "" && slices.Contains(adminIDs, ownID), nil } -// ownPrincipalScimID resolves the connector's own service principal. An -// applicationId filter on the service principal endpoint can only match a -// service principal, which is why this asks for the one record instead of -// walking account SCIM, where the same name field is shared by three namespaces. +// ownPrincipalScimID looks up the connector's service principal by application id. func ownPrincipalScimID( ctx context.Context, client *databricks.Client, @@ -395,11 +376,7 @@ func deniesTrust(err error) bool { } } -// ensureGrantsAreTrustworthy settles whether the privilege list this call is -// about to read, or write to, is the whole one. It runs per call and keeps no -// answer: ConductorOne can hand back a profile from an earlier sync, so a -// remembered verdict would outlive the access that earned it. The probe costs -// one account call, and none at all when the connector owns the metastore. +// ensureGrantsAreTrustworthy refuses the call unless the privilege list is complete. Databricks returns HTTP 200 with only the rows the caller may see, and reporting that subset would make ConductorOne revoke the rest. The check is not cached. func (m *metastoreBuilder) ensureGrantsAreTrustworthy( ctx context.Context, resource *v2.Resource, @@ -431,10 +408,7 @@ func untrustedMetastoreError(metastoreID, workspace string) error { metastoreID, workspace) } -// principalResolver maps a privilege assignment's principal to a C1 resource id. -// One field carries a userName, a displayName or an applicationId with no type, -// so lookups go through account SCIM in a fixed order. Results are cached for -// one Grants call. +// principalResolver maps a privilege assignment to a C1 resource id. The id is used when present; the name is only the fallback. type principalResolver struct { client *databricks.Client annos *annotations.Annotations From 8ef1c1494dabdfce46bfa64313c3dd59dcc4f1ea Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Thu, 22 Oct 2026 18:22:40 -0300 Subject: [PATCH 27/36] fix(databricks): wrap the trust comment under the line limit Co-authored-by: Cursor --- pkg/connector/metastore_helpers.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/connector/metastore_helpers.go b/pkg/connector/metastore_helpers.go index 7bd8df27..a56e333a 100644 --- a/pkg/connector/metastore_helpers.go +++ b/pkg/connector/metastore_helpers.go @@ -376,7 +376,10 @@ func deniesTrust(err error) bool { } } -// ensureGrantsAreTrustworthy refuses the call unless the privilege list is complete. Databricks returns HTTP 200 with only the rows the caller may see, and reporting that subset would make ConductorOne revoke the rest. The check is not cached. +// ensureGrantsAreTrustworthy refuses the call unless the privilege list is +// complete. Databricks returns HTTP 200 with only the rows the caller may see, +// and reporting that subset would make ConductorOne revoke the rest. +// The check is not cached. func (m *metastoreBuilder) ensureGrantsAreTrustworthy( ctx context.Context, resource *v2.Resource, From 0dae4fc916ffa1f0875a50990bb598fc238ec55a Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Fri, 23 Oct 2026 10:12:02 -0300 Subject: [PATCH 28/36] test(databricks): drop redundant metastore tests Co-authored-by: Cursor --- pkg/connector/metastore_test.go | 174 -------------------------------- 1 file changed, 174 deletions(-) diff --git a/pkg/connector/metastore_test.go b/pkg/connector/metastore_test.go index fdfa6810..7eba05e0 100644 --- a/pkg/connector/metastore_test.go +++ b/pkg/connector/metastore_test.go @@ -331,50 +331,6 @@ func TestMetastoreListPicksTheFirstUsableWorkspace(t *testing.T) { } } -func TestMetastoreListProfile(t *testing.T) { - m := newTestMetastoreBuilder(t, defaultFakeAccount(), nil, syncPrincipals{}) - resources, _, err := m.List(context.Background(), testAccountResourceID, rs.SyncOpAttrs{}) - if err != nil || len(resources) != 1 { - t.Fatalf("List = %v, %v", resources, err) - } - profile := rs.GetProfile(resources[0]) - for key, want := range map[string]string{ - profileKeyWorkspace: "wsa", profileKeyWorkspaceID: "1", profileKeyWorkspaceName: "prod", - profileKeyOwner: "eng", profileKeyMetastoreID: "ms-a", profileKeySecurableType: securableTypeMetastore, - } { - if got, _ := rs.GetProfileStringValue(profile, key); got != want { - t.Errorf("profile[%s] = %q, want %q", key, got, want) - } - } - if resources[0].GetDisplayName() != "main" { - t.Errorf("display name = %q, want main", resources[0].GetDisplayName()) - } -} - -func TestMetastoreListFailsWhenItCannotRoute(t *testing.T) { - tests := []struct { - name string - assignment any - }{ - {"assignment list answers 403", http.StatusForbidden}, - {"assignment list answers 500", http.StatusInternalServerError}, - {"only in-scope workspace is not running", []int{2}}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - f := defaultFakeAccount() - f.assignments["ms-a"] = test.assignment - f.workspaces = append(f.workspaces, - map[string]any{"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "PROVISIONING"}) - m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) - - if _, _, err := m.List(context.Background(), testAccountResourceID, rs.SyncOpAttrs{}); err == nil { - t.Fatal("List succeeded") - } - }) - } -} - func grantKeys(grants []*v2.Grant) []string { keys := make([]string, 0, len(grants)) for _, g := range grants { @@ -385,84 +341,6 @@ func grantKeys(grants []*v2.Grant) []string { return keys } -func TestMetastoreGrants(t *testing.T) { - f := defaultFakeAccount() - f.permissions = []map[string]any{ - {"principal": "alice@example.com", "principal_id": 1, "privileges": []string{"CREATE_CATALOG", "NOT_OFFERED"}}, - {"principal": "alice@example.com", "principal_id": 1, "privileges": []string{"USE_SHARE"}}, - {"principal": "eng", "principal_id": 2, "privileges": []string{"READ_METADATA"}}, - {"principal": "app-9", "principal_id": 3, "privileges": []string{"CREATE_SHARE"}}, - {"principal": "", "principal_id": 99, "privileges": []string{"CREATE_SHARE"}}, - } - m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{groups: true}) - - grants, _, err := m.Grants(context.Background(), testMetastore(t, "wsa"), rs.SyncOpAttrs{}) - if err != nil { - t.Fatalf("Grants: %v", err) - } - group := "account/" + testAccountId + "/group/2" - want := []string{ - "1 metastore:ms-a:CREATE_CATALOG", - "1 metastore:ms-a:USE_SHARE", - group + " metastore:ms-a:READ_METADATA", - group + " metastore:ms-a:owner", - } - slices.Sort(want) - if got := grantKeys(grants); !slices.Equal(got, want) { - t.Fatalf("grants = %v, want %v", got, want) - } - if !slices.Equal(f.permHosts, []string{"wsa"}) { - t.Errorf("permissions read through %v, want [wsa]", f.permHosts) - } - if f.scimGets["Users/1"] != 1 { - t.Errorf("user 1 resolved %d times, want 1", f.scimGets["Users/1"]) - } - for _, g := range grants { - annos := g.GetAnnotations() - if !strings.HasSuffix(g.GetEntitlement().GetId(), ":owner") { - continue - } - if len(annos) != 2 { - t.Errorf("owner grant annotations = %v, want expandable and immutable", annos) - } - } -} - -func TestMetastoreGrantsReturnNothing(t *testing.T) { - tests := []struct { - name string - workspace string - permStatus int - }{ - {"no workspace on the profile", "", 0}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - f := defaultFakeAccount() - f.permStatus = test.permStatus - m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{groups: true}) - - grants, _, err := m.Grants(context.Background(), testMetastore(t, test.workspace), rs.SyncOpAttrs{}) - if err != nil || len(grants) != 0 { - t.Fatalf("Grants = %v, %v; want none and no error", grants, err) - } - }) - } -} - -func TestMetastoreGrantsFailOnErrors(t *testing.T) { - for _, code := range []int{http.StatusNotFound, http.StatusInternalServerError} { - f := defaultFakeAccount() - f.permStatus = code - m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) - - _, _, err := m.Grants(context.Background(), testMetastore(t, "wsa"), rs.SyncOpAttrs{}) - if status.Code(err) != codes.FailedPrecondition { - t.Fatalf("Grants on %d = %v, want FailedPrecondition", code, err) - } - } -} - // Databricks answers an under-permissioned read of the permissions endpoint // with 200 and only the privileges the caller may see, which is indistinguishable // from a metastore with no grants. The sync therefore reports grants only when @@ -528,27 +406,6 @@ func TestMetastoreGrantsRequireFullVisibility(t *testing.T) { var alice = &v2.Resource{Id: &v2.ResourceId{ResourceType: "user", Resource: "1"}} -func TestMetastoreGrant(t *testing.T) { - f := defaultFakeAccount() - m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) - g := grant.NewGrant(testMetastore(t, "wsa"), "CREATE_CATALOG", alice.GetId()) - - if _, err := m.Grant(context.Background(), alice, g.GetEntitlement()); err != nil { - t.Fatalf("Grant: %v", err) - } - if len(f.patches) != 1 || f.patches[0]["principal"] != "alice@example.com" { - t.Fatalf("patches = %v, want one add for alice@example.com", f.patches) - } - - annos, err := m.Grant(context.Background(), alice, g.GetEntitlement()) - if err != nil || !annos.Contains(&v2.GrantAlreadyExists{}) { - t.Fatalf("second Grant = %v, %v; want GrantAlreadyExists", annos, err) - } - if len(f.patches) != 1 { - t.Errorf("a held privilege was patched again: %v", f.patches) - } -} - func TestMetastoreGrantRejections(t *testing.T) { tests := []struct { name string @@ -627,34 +484,3 @@ func TestMetastoreProvisioningRefusesAPartialPrivilegeList(t *testing.T) { }) } } - -// Every workspace answers /api/2.1/unity-catalog/permissions with the same path -// and query, so a cache shared between hosts would misattribute privileges. -func TestPermissionsListingIsNotAnsweredByAnotherWorkspacesCachedPage(t *testing.T) { - held := map[string]string{"wsa": "alice", "wsb": "bob"} - - client, router := newWorkspaceRoutedClient(t, func(w http.ResponseWriter, r *http.Request) { - workspace := r.Header.Get("X-Test-Workspace") - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "privilege_assignments": []map[string]any{{ - "principal": held[workspace], - "privileges": []string{"CREATE_CATALOG"}, - }}, - }) - }) - - ctx := context.Background() - for _, workspace := range []string{"wsa", "wsb"} { - assignments, _, _, err := client.ListPermissions(ctx, workspace, databricks.SecurableMetastore, "ms-1", "", "") - if err != nil { - t.Fatalf("ListPermissions(%s): %v", workspace, err) - } - if router.hits[workspace] == 0 { - t.Errorf("workspace %s was never asked for its permissions; hits = %v", workspace, router.hits) - } - if len(assignments) != 1 || assignments[0].Principal != held[workspace] { - t.Errorf("workspace %s answered %+v, want %s", workspace, assignments, held[workspace]) - } - } -} From bfe443bc02e64700895d4f3d902b4ec782baf0cb Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Fri, 23 Oct 2026 13:55:43 -0300 Subject: [PATCH 29/36] merge(databricks): merge main into the unity catalog foundation branch Both sides fixed the uhttp response cache for different reasons: this branch keys it on Host so two workspace deployments answering the same path keep separate entries, and main keys it on Cache-Control so a read-before-write no-cache read does not overwrite the shared sync entry. WithCacheKeyHeaders is variadic, so the merged client keys on both. main added getNoCache and doRequestWithOptions to reach uhttp.WithNoCache(). This branch already routes request options through doRequest and exposes GetUncached, so the merge keeps that shape and points the workspace assignment read at GetUncached. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/ci-review.md | 65 +- .../skills/connector/build-provisioning.md | 7 + .../connector/patterns-entity-sources.md | 89 +- .../connector/patterns-error-handling.md | 87 +- .../skills/connector/patterns-http-safety.md | 26 +- .claude/skills/connector/review-checklist.md | 15 +- .../skills/connector/review-common-bugs.md | 7 +- .claude/skills/connector/review-connector.md | 5 +- .golangci.yml | 2 - .versions.yaml | 4 +- BUGBOT.md | 10 +- CHANGE_TYPES.md | 6 +- CLAUDE.md | 6 +- README.md | 4 + docs/connector.mdx | 2 + go.mod | 16 +- go.sum | 36 +- pkg/connector/users.go | 94 +- pkg/connector/users_test.go | 525 +++++ pkg/connector/workspaces.go | 14 +- pkg/connector/workspaces_provisioning_test.go | 215 ++ pkg/databricks/client.go | 196 +- pkg/databricks/client_test.go | 107 + pkg/databricks/models.go | 8 + pkg/databricks/workspace_members_test.go | 530 +++++ .../baton-sdk/internal/connector/connector.go | 43 +- .../pb/c1/connector/v2/event_feed.pb.go | 503 ++++- .../c1/connector/v2/event_feed.pb.validate.go | 376 ++++ .../connector/v2/event_feed_protoopaque.pb.go | 491 ++++- .../baton-sdk/pkg/dotc1z/c1zstore/ledger.go | 8 + .../pkg/dotc1z/engine/pebble/adapter_page.go | 4 + .../dotc1z/engine/pebble/deferred_index.go | 72 +- .../pkg/dotc1z/engine/pebble/grants.go | 15 +- .../engine/pebble/internal/rawdb/keyspace.go | 8 + .../engine/pebble/internal/rawdb/rawdb.go | 68 +- .../engine/pebble/internal/rawdb/records.go | 9 + .../pkg/dotc1z/engine/pebble/page_unit.go | 29 +- .../engine/pebble/rollback_expansion.go | 229 ++ .../dotc1z/engine/pebble/trusted_import.go | 108 + .../baton-sdk/pkg/dotc1z/pebble_store.go | 38 + .../pkg/dotc1z/rollback_expansion.go | 17 +- .../baton-sdk/pkg/lambda/grpc/util.go | 6 + .../conductorone/baton-sdk/pkg/sdk/version.go | 2 +- .../baton-sdk/pkg/session/session_server.go | 129 +- .../conductorone/baton-sdk/pkg/sync/config.go | 2 +- .../baton-sdk/pkg/sync/ledger_sync.go | 3 + .../baton-sdk/pkg/sync/session_binding.go | 55 + .../conductorone/baton-sdk/pkg/sync/syncer.go | 33 +- .../baton-sdk/pkg/tasks/c1api/full_sync.go | 6 +- .../baton-sdk/pkg/tasks/local/syncer.go | 11 +- .../baton-sdk/pkg/types/sessions/sessions.go | 10 +- .../pkg/ugrpc/c1_credential_provider.go | 29 +- .../baton-sdk/pkg/uhttp/client.go | 2 +- .../baton-sdk/pkg/uhttp/dbcache.go | 480 ---- .../baton-sdk/pkg/uhttp/errors.go | 173 +- .../baton-sdk/pkg/uhttp/errors_other.go | 2 +- .../baton-sdk/pkg/uhttp/gocache.go | 11 - .../baton-sdk/pkg/uhttp/oauth2.go | 6 +- .../baton-sdk/pkg/uhttp/transport.go | 24 +- .../baton-sdk/pkg/uhttp/wrapper.go | 296 ++- .../baton-sdk/pkg/uotel/config.go | 31 +- vendor/golang.org/x/net/http2/ascii.go | 14 +- vendor/golang.org/x/net/http2/databuffer.go | 5 +- vendor/golang.org/x/net/http2/flow.go | 103 +- vendor/golang.org/x/net/http2/frame.go | 34 +- vendor/golang.org/x/net/http2/server.go | 110 +- .../golang.org/x/net/http2/server_common.go | 47 + vendor/golang.org/x/net/http2/transport.go | 144 +- .../x/net/http2/transport_common.go | 79 +- .../golang.org/x/net/http2/transport_wrap.go | 216 +- vendor/golang.org/x/net/http2/writesched.go | 7 +- .../x/net/internal/httpcommon/README.md | 7 + .../x/net/internal/httpcommon/gzip.go | 132 ++ .../x/net/internal/httpcommon/request.go | 34 +- vendor/golang.org/x/net/trace/histogram.go | 5 +- .../golang.org/x/sync/semaphore/semaphore.go | 9 +- vendor/golang.org/x/sys/cpu/cpu.go | 21 +- vendor/golang.org/x/sys/cpu/cpu_gc_riscv64.go | 11 + .../golang.org/x/sys/cpu/cpu_linux_ppc64x.go | 4 + .../golang.org/x/sys/cpu/cpu_linux_riscv64.go | 11 + .../golang.org/x/sys/cpu/cpu_netbsd_amd64.go | 47 + vendor/golang.org/x/sys/cpu/cpu_other_x86.go | 2 +- vendor/golang.org/x/sys/cpu/cpu_riscv64.s | 17 + vendor/golang.org/x/sys/cpu/cpu_sparc64.go | 12 + vendor/golang.org/x/sys/unix/ifreq_linux.go | 8 +- vendor/golang.org/x/sys/unix/ioctl_linux.go | 7 + vendor/golang.org/x/sys/unix/mkerrors.sh | 9 +- vendor/golang.org/x/sys/unix/syscall.go | 2 +- vendor/golang.org/x/sys/unix/syscall_bsd.go | 2 +- vendor/golang.org/x/sys/unix/syscall_linux.go | 2 +- vendor/golang.org/x/sys/unix/zerrors_linux.go | 2 + .../x/sys/unix/zerrors_linux_386.go | 1 - .../x/sys/unix/zerrors_linux_amd64.go | 1 - .../x/sys/unix/zerrors_linux_arm.go | 1 - .../x/sys/unix/zerrors_linux_arm64.go | 1 - .../x/sys/unix/zerrors_linux_loong64.go | 1 - .../x/sys/unix/zerrors_linux_mips.go | 1 - .../x/sys/unix/zerrors_linux_mips64.go | 1 - .../x/sys/unix/zerrors_linux_mips64le.go | 1 - .../x/sys/unix/zerrors_linux_mipsle.go | 1 - .../x/sys/unix/zerrors_linux_ppc.go | 1 - .../x/sys/unix/zerrors_linux_ppc64.go | 1 - .../x/sys/unix/zerrors_linux_ppc64le.go | 1 - .../x/sys/unix/zerrors_linux_riscv64.go | 1 - .../x/sys/unix/zerrors_linux_s390x.go | 1 - .../x/sys/unix/zerrors_linux_sparc64.go | 35 +- vendor/golang.org/x/sys/unix/ztypes_linux.go | 86 +- .../golang.org/x/sys/unix/ztypes_linux_386.go | 32 +- .../x/sys/unix/ztypes_linux_amd64.go | 32 +- .../golang.org/x/sys/unix/ztypes_linux_arm.go | 32 +- .../x/sys/unix/ztypes_linux_arm64.go | 32 +- .../x/sys/unix/ztypes_linux_loong64.go | 32 +- .../x/sys/unix/ztypes_linux_mips.go | 32 +- .../x/sys/unix/ztypes_linux_mips64.go | 32 +- .../x/sys/unix/ztypes_linux_mips64le.go | 32 +- .../x/sys/unix/ztypes_linux_mipsle.go | 32 +- .../golang.org/x/sys/unix/ztypes_linux_ppc.go | 32 +- .../x/sys/unix/ztypes_linux_ppc64.go | 32 +- .../x/sys/unix/ztypes_linux_ppc64le.go | 32 +- .../x/sys/unix/ztypes_linux_riscv64.go | 32 +- .../x/sys/unix/ztypes_linux_s390x.go | 32 +- .../x/sys/unix/ztypes_linux_sparc64.go | 32 +- .../golang.org/x/sys/windows/types_windows.go | 1 + vendor/golang.org/x/term/terminal.go | 24 +- vendor/golang.org/x/text/unicode/bidi/core.go | 5 +- .../x/text/unicode/norm/composition.go | 42 +- .../x/text/unicode/norm/forminfo.go | 24 +- .../x/text/unicode/norm/normalize.go | 5 +- .../x/text/unicode/norm/tables15.0.0.go | 1884 ++++++++-------- .../x/text/unicode/norm/tables17.0.0.go | 1924 ++++++++--------- .../x/text/unicode/norm/transform.go | 5 +- vendor/modules.txt | 26 +- 132 files changed, 7851 insertions(+), 3212 deletions(-) create mode 100644 pkg/connector/users_test.go create mode 100644 pkg/connector/workspaces_provisioning_test.go create mode 100644 pkg/databricks/workspace_members_test.go create mode 100644 vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/rollback_expansion.go create mode 100644 vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/trusted_import.go create mode 100644 vendor/github.com/conductorone/baton-sdk/pkg/sync/session_binding.go delete mode 100644 vendor/github.com/conductorone/baton-sdk/pkg/uhttp/dbcache.go create mode 100644 vendor/golang.org/x/net/internal/httpcommon/README.md create mode 100644 vendor/golang.org/x/net/internal/httpcommon/gzip.go create mode 100644 vendor/golang.org/x/sys/cpu/cpu_gc_riscv64.go create mode 100644 vendor/golang.org/x/sys/cpu/cpu_netbsd_amd64.go create mode 100644 vendor/golang.org/x/sys/cpu/cpu_riscv64.s create mode 100644 vendor/golang.org/x/sys/cpu/cpu_sparc64.go diff --git a/.claude/skills/ci-review.md b/.claude/skills/ci-review.md index ec1f5604..e4f79e15 100644 --- a/.claude/skills/ci-review.md +++ b/.claude/skills/ci-review.md @@ -38,7 +38,9 @@ of SDK behavior the connector does not control, not a rule to copy. - L1b: A 4xx the connector handles gracefully and the customer can neither act on nor care about → `Debug`, not `Warn`. Typical shapes: a 403 on an optional per-resource detail endpoint the connector falls back from, a 404 for a record deleted mid-sync, a - 429 the SDK retries for you. Not-actionable **and** per-resource is the expensive + 429 on a single call — one throttled request is nothing the customer can act on, whether + or not anything retries it. (Sustained throttling *is* actionable and goes to L1c.) + Not-actionable **and** per-resource is the expensive combination, and it is the one that belongs at Debug. Choosing `Debug` here is a decision about noise, never about whether to return the @@ -64,15 +66,15 @@ of SDK behavior the connector does not control, not a rule to copy. - L3: Nil/zero/missing-but-expected values and gracefully-handled unknown enum variants → `Debug`, not Warn (e.g. "access key last-used date is nil"). - L4: Skip-and-continue (per-item graceful degradation) is NOT error swallowing — do not - flag `log + continue` on a single skipped item under H4. Two limits on that suppression: - it covers `continue` inside a loop only, never `log + return nil` from the method itself - (that is the destructive shape section S forbids, and this carve-out never licenses it); - and it covers only a `NotFound` on the item being fetched. A `continue` past a 401, a 403 - or an exhausted 429 is not suppressed — that error might be about every item, and the - empty page it produces is S1 arriving by a slower route. See S5. It says nothing about *which* level - to log at: take that from L1/L1b/L1c, which for most per-item degradation means `Debug`, - not `Warn`. Two separate questions — "is dropping this item a bug?" and "does anyone - need to see the line?" — and L4 only answers the first. + flag `log + continue` on a single skipped item under H4. Two limits on that suppression: it + covers `continue` inside a loop only, never `log + return nil` from the method itself (that is + the destructive shape section S forbids, and this carve-out never licenses it); and it covers + only a `NotFound` on the item being fetched. A `continue` past a 401, a 403 or an exhausted + 429 is not suppressed — that error might be about every item, and the empty page it produces + is S1 arriving by a slower route. See S5. It says nothing about *which* level to log at: take + that from L1/L1b/L1c, which for most per-item degradation means `Debug`, not `Warn`. Two + separate questions — "is dropping this item a bug?" and "does anyone need to see the line?" — + and L4 only answers the first. - L5: Context cancellation (`context.Canceled` / `DeadlineExceeded`) → `Debug`. The customer cannot act on a shutdown or a timeout, so L1's question answers this one outright; do not leave it as an either/or. @@ -82,13 +84,13 @@ of SDK behavior the connector does not control, not a rule to copy. the customer can act on it. - L7: A warning that can fire per-resource or per-page MUST use logarithmic sampling (occurrences 1, 10, 100, then every 1000) with a `total_occurrences` field. This is not a - judgement call, and the two halves bind different people: **MUST** for the author writing - the line, **suggestion** severity for the reviewer flagging it. Flag every unsampled - recurrent warn, even when the level itself is correct per L1. It is a suggestion because - an unsampled warn breaks nothing — it only costs — so it should not block a merge, but it - should never be waved through silently either. Warn-level output is ingested and retained downstream while debug - is filtered, so an unsampled per-resource warn costs real money on a large sync for lines - nobody reads. Bounding the volume is what makes L1 affordable. A one-shot warn (once per + judgement call, and the two halves bind different people: **MUST** for the author writing the + line, **suggestion** severity for the reviewer flagging it. Flag every unsampled recurrent + warn, even when the level itself is correct per L1. It is a suggestion because an unsampled + warn breaks nothing — it only costs — so it should not block a merge, but it should never be + waved through silently either. Warn-level output is ingested and retained downstream while + debug is filtered, so an unsampled per-resource warn costs real money on a large sync for + lines nobody reads. Bounding the volume is what makes L1 affordable. A one-shot warn (once per sync, once at connector init) needs no sampling. The test before writing `l.Error`: is it a connector code bug? does the connector stop? @@ -106,8 +108,23 @@ and *which code*: failures ("HTTP 200 but body says failed"). If all requests use `uhttp.BaseHttpClient`, uhttp wraps automatically — do not require manual wrapping. - E2: `preferredCode` is a gRPC `codes.Code`, not an HTTP status. Expected mapping: - 401→`Unauthenticated`, 403→`PermissionDenied`, 404→`NotFound`, 429→`ResourceExhausted`, - 5xx→`Internal`. The SDK reads this code to decide retry vs surface. + 401→`Unauthenticated`, 403→`PermissionDenied`, 404→`NotFound`, **429→`Unavailable`, + 5xx→`Unavailable`** (501 is the exception: `Unimplemented`). Match what `uhttp` itself + produces: `GrpcCodeFromHTTPStatus` (`pkg/uhttp/wrapper.go`) maps 429, 502, 503, 504 and every + other 5xx to `Unavailable`, so a hand-wrapped error should land on the same code the same + status would get through `BaseHttpClient`. + + **Why this one matters more than tidiness.** `pkg/retry` waits and retries only `Unavailable` + and `DeadlineExceeded`, and it is what runs on the parallel syncer and on + `Grant`/`Revoke`/`Validate`/tickets. Labelling a rate limit `ResourceExhausted` or a 502 + `Internal` takes it out of that path wherever it applies. Note what it does **not** buy: the + sequential syncer is the default and never calls the retryer, so on a default sync a 429 is + returned either way. The platform retries the whole sync for these codes, so + nothing is lost — a sync gets re-run where a backoff would have done. Flag the two wrong + mappings. Both codes still have uses, just not these: `ResourceExhausted` for an exhaustion + the connector itself hit — the SDK's local rate limiter (`pkg/ratelimit/grpc.go`) produces + it, as would a quota or memory ceiling — and `Internal` for an invariant broken inside the + connector rather than a fault reported by the upstream. - E3: Provisioning errors (P4) should carry a gRPC status code so Grant/Revoke failures surface correctly. @@ -164,8 +181,12 @@ two highest-cost mistakes (entity-source confusion has caused 3 production rever file, not just the diff — entity-source correctness needs the whole flow. - PR2: Context (workspace/org/tenant) comes from `principal.ParentResourceId.Resource` (Revoke: `grant.Principal.ParentResourceId.Resource`), NEVER from - `entitlement.Resource.ParentResourceId`. Grep the diff for - `entitlement.Resource.ParentResourceId` in Grant/Revoke as a direct detector. + an unchecked `entitlement.Resource.ParentResourceId`. Grep the diff for + `entitlement.Resource.ParentResourceId` in Grant/Revoke as a direct detector, then read + each hit. Exception: target-owned scope (the target belongs to one fixed scope, e.g. a + GitHub team's org) with a nil check, parse/validate, and an org+team-scoped API call is + correct — do not block it. Routing such an operation through + `principal.ParentResourceId` is the mirror bug and IS blocking; grep will not find it. - PR3: P5 — when an API call takes multiple string params, verify argument order against the function signature; swapped IDs are easy to miss and silently grant the wrong thing. @@ -239,4 +260,4 @@ work by keeping C1 from expecting the resource type at all, not by marking a syn role — and there a single legitimate 404 is "100% of the page", so a ratio bound fails the sync on a record that is legitimately gone, which is L1b's own Debug-and-skip case. A counter carried on the builder across pages does not work either: in Lambda mode each page - is a separate invocation, so the state does not survive. \ No newline at end of file + is a separate invocation, so the state does not survive. diff --git a/.claude/skills/connector/build-provisioning.md b/.claude/skills/connector/build-provisioning.md index 897895ff..4e806b32 100644 --- a/.claude/skills/connector/build-provisioning.md +++ b/.claude/skills/connector/build-provisioning.md @@ -152,6 +152,13 @@ groupID := entitlement.Resource.Id.Resource workspaceID := entitlement.Resource.ParentResourceId.Resource // WRONG! ``` +**Exception - target-owned scope:** when the target resource itself belongs to one fixed +scope (a GitHub team belongs to one org), WHERE comes from the parent of the +entitlement's resource (set by this connector's sync), but only after a nil check, +parsing the ID, and a vendor call scoped to both scope and target (org ID + team ID). Routing such an operation through +`principal.ParentResourceId` is the mirror bug. See +`patterns-entity-sources.md#exception-target-owned-scope`. + **ALSO WRONG - missing ExternalId:** ```go // Using ResourceId instead of native ID may not work for API calls diff --git a/.claude/skills/connector/patterns-entity-sources.md b/.claude/skills/connector/patterns-entity-sources.md index 592e2faa..faa5cd2c 100644 --- a/.claude/skills/connector/patterns-entity-sources.md +++ b/.claude/skills/connector/patterns-entity-sources.md @@ -19,12 +19,13 @@ Confusion about which entity provides which data caused 3 production reverts and | Data Type | Source | Example | |-----------|--------|---------| -| Context (workspace, org, tenant) | **Principal** | `principal.ParentResourceId.Resource` | +| Context (workspace, org, tenant) | **Principal** (default) | `principal.ParentResourceId.Resource` | +| Context owned by the target resource itself | **Entitlement**, checked (see [Target-Owned Scope](#exception-target-owned-scope)) | `entitlement.Resource.ParentResourceId.Resource` after nil and parse checks | | User/account ID | **Principal** | `principal.Id.Resource` | | Permission/role being granted | **Entitlement** | `entitlement.Resource.Id.Resource` | | Group being added to | **Entitlement** | `entitlement.Resource.Id.Resource` | -**Principal = WHO. Entitlement = WHAT.** +**Principal = WHO. Entitlement = WHAT.** WHERE comes from whoever owns the scope — almost always the principal. --- @@ -98,6 +99,83 @@ func (g *groupBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotations --- +## Exception: Target-Owned Scope + +Ask who owns the scope: + +- **Principal-scoped** — the context is a property of the user and can differ per user + (the workspace a user belongs to). Take it from the principal. This is the default + and covers the bug above. +- **Target-owned** — the target resource itself belongs to exactly one fixed scope, + regardless of who the user is. A GitHub team belongs to one organization even when + the user has no parent or belongs to several orgs. Here the principal's parent is + not the right scope; the parent of the entitlement's resource (the team's org) is. + +For target-owned scope, reading `entitlement.Resource.ParentResourceId` is correct +**only with checked routing**: + +1. The parent was set by this connector's own sync (e.g. `List` sets every team's + parent to the org it was listed under), not taken from elsewhere. +2. Nil-check the parent and return an error if it is missing. +3. Parse/validate the ID and return the error if it fails. +4. Call a vendor API scoped to both the scope and the target (e.g. org ID + team ID), + so the call itself rejects a mismatched pair. + +No extra membership lookup is needed. If you switch on the parent's `ResourceType`, +fail loudly on an unsupported type instead of falling through with a zero-value ID. + +Simplified from baton-github `pkg/connector/team.go` at `d5fb8fdd` (Grant, lines 251-322): + +```go +func (o *teamResourceType) Grant(ctx context.Context, principal *v2.Resource, + entitlement *v2.Entitlement) (annotations.Annotations, error) { + + // WHAT: the team + teamID, err := strconv.ParseInt(entitlement.Resource.Id.Resource, 10, 64) + if err != nil { + return nil, err + } + + // WHERE: the team's own org. List() sets every team's parent to its org. + if entitlement.GetResource().GetParentResourceId() == nil { + return nil, fmt.Errorf("github-connectorv2: parent resource is required to grant team membership") + } + orgID, err := strconv.ParseInt(entitlement.Resource.ParentResourceId.Resource, 10, 64) + if err != nil { + return nil, err + } + + // WHO: still the principal + userID, err := strconv.ParseInt(principal.Id.Resource, 10, 64) + if err != nil { + return nil, err + } + user, resp, err := o.client.Users.GetByID(ctx, userID) + // ... + + // org + team scoped call: GitHub rejects a team that is not in orgID + _, resp, err = o.client.Teams.AddTeamMembershipByID(ctx, orgID, teamID, user.GetLogin(), opts) + // ... +} +``` + +Revoke (lines 324-368) does the same through `grant.Entitlement`: nil-check, parse the +parent as the org ID, then `RemoveTeamMembershipByID(ctx, orgID, teamID, login)`. + +The real Grant also switches on the parent's type, with a branch that reads the org ID +from the team's profile when the parent is a team. That is a leftover from before +baton-github flattened nested teams under their org (commit `ab59c2a`); it is not one +of the checks above. + +**Still wrong:** +- A bare, unchecked `entitlement.Resource.ParentResourceId.Resource` read — for any type. +- Entitlement-resource-parent context in a principal-scoped (workspace-style) operation. +- **The mirror bug:** routing a target-owned operation through `principal.ParentResourceId`. + A parentless or multi-org user gives no valid org for the team. This does not match + the grep below, so reviewers must catch it by reading the code. + +--- + ## API Argument Order (6+ PRs with this bug) Functions with multiple `string` parameters of the same type are easy to call incorrectly. @@ -153,12 +231,14 @@ err := client.AddMember(groupID, userID) ## Detection in Code Review **Red flags:** -1. `entitlement.Resource.ParentResourceId` - probably should be `principal.ParentResourceId` +1. `entitlement.Resource.ParentResourceId` - usually should be `principal.ParentResourceId`; acceptable only for checked [target-owned scope](#exception-target-owned-scope). Each hit needs a human read — grep cannot tell the two apart. 2. Multiple `string` parameters in API calls - verify order -3. Getting "context" (workspace/org/tenant) from entitlement +3. Getting "context" (workspace/org/tenant) from entitlement for a principal-scoped operation +4. Routing a target-owned operation (e.g. team membership) through the principal's parent **Questions to ask:** - "Where does the workspace ID come from?" +- "Does that scope belong to the user, or to the target resource?" - "Is this getting data from the right entity?" - "Does the argument order match the API signature?" @@ -171,6 +251,7 @@ Test these scenarios: 2. Arguments in correct order for API 3. Grant to correct user (not some other user) 4. Revoke from correct user +5. Target-owned scope: principal with no parent (or several orgs) still grants in the target's own org; missing or unparseable parent returns an error ```go func TestGrant_UsesCorrectWorkspace(t *testing.T) { diff --git a/.claude/skills/connector/patterns-error-handling.md b/.claude/skills/connector/patterns-error-handling.md index f58811b6..88b1b1fb 100644 --- a/.claude/skills/connector/patterns-error-handling.md +++ b/.claude/skills/connector/patterns-error-handling.md @@ -61,13 +61,25 @@ func WrapErrors(preferredCode codes.Code, statusMsg string, errs ...error) error | Auth failure (401) | `codes.Unauthenticated` | | Permission denied (403) | `codes.PermissionDenied` | | Not found (404) | `codes.NotFound` | -| Rate limited (429) | `codes.ResourceExhausted` | -| Server error (5xx) | `codes.Internal` | +| Rate limited (429) | `codes.Unavailable` | +| Server error (5xx) | `codes.Unavailable` — except 501, which is `codes.Unimplemented` | ```go -// SDK library error — wrap with appropriate gRPC code: +// SDK library error — map the vendor's HTTP status through the SDK so a 429 or a +// 5xx lands on Unavailable rather than being frozen as Internal. Internal is for +// an error you know is not transient. Fall back to Unknown when the SDK gives you +// no status to map. if err != nil { - return nil, uhttp.WrapErrors(codes.Internal, "baton-myservice: failed to list users", err) + code := codes.Unknown + // vendorHTTPStatus stands in for your SDK's own status accessor — typically an + // errors.As into the type that actually carries the status, e.g. + // *github.ErrorResponse (.Response.StatusCode) or, for aws-sdk-go-v2, + // *smithyhttp.ResponseError (.HTTPStatusCode()) — not *smithy.GenericAPIError, + // which has only Code, Message and Fault. + if st, ok := vendorHTTPStatus(err); ok { + code = uhttp.GrpcCodeFromHTTPStatus(st) + } + return nil, uhttp.WrapErrors(code, "baton-myservice: failed to list users", err) } // Developer-inferred error from response body or status code: @@ -88,9 +100,9 @@ if resp.StatusCode == http.StatusForbidden { | Error Type | Retryable? | Action | |------------|-----------|--------| -| Rate limit (429) | Yes | SDK retries automatically | -| Network timeout | Yes | SDK retries | -| Server error (5xx) | Yes | SDK retries | +| Rate limit (429) | Yes | Back off and retry — the SDK only does this for you on some paths, see "Do not assume a 429 gets retried for you" below | +| Network timeout | Yes | Same | +| Server error (5xx) | Yes | Same | | Bad request (400) | No | Return error with context | | Unauthorized (401) | No | Return error, check credentials | | Forbidden (403) | No | Return error, check permissions | @@ -99,7 +111,7 @@ if resp.StatusCode == http.StatusForbidden { **Log level is deliberately not in this table.** Retryability and log level are different questions, and answering them together is what produced the blanket "all 4xx → Warn" rule this guide used to carry. A 5xx is retryable *and* an `Error`; a 429 is retryable and belongs at -`Debug` because the SDK handles it and the customer cannot act on it. See +`Debug` because the customer cannot act on a single throttled call. See [Log Level Classification](#log-level-classification) — Rule 1 decides the level for logging you write, on whether the customer can act, and it is the only place in this file that does. @@ -299,8 +311,9 @@ the condition: `Warn`. These are one-shot in practice: the credential is bad for the whole run, so the line appears once. - **Not actionable** — a 403 on an optional per-item call the connector already falls back - from, a 404 for a record deleted mid-sync, a 429 the SDK retries for you. `Debug`. The - customer can do nothing with it and it fires once per resource. + from, a 404 for a record deleted mid-sync, a 429 on a single call. `Debug`. The customer + can do nothing with one throttled request — whether or not anything retries it — and it + fires once per resource. ```go // WRONG — alerts on a customer config issue @@ -469,7 +482,7 @@ return nil, fmt.Errorf("baton-myservice: failed getting metadata: %w", err) | Upstream 403 on an optional per-item call the connector falls back from | **Debug** | Customer cannot act; fires per resource | | Upstream 403 on a per-object ACL the customer can grant individually | **Warn, sampled** | Actionable *and* recurring — see sampling below | | Upstream 404 (record deleted mid-sync) | **Debug** | Nothing to act on | -| Upstream 429 (rate limit) | **Debug** | SDK retries it; the customer cannot do anything | +| Upstream 429 (rate limit) | **Debug** | The customer cannot act on one throttled call. Sustained throttling is a different finding | | Upstream 400 (bad request) | **Error** if the connector built the request, **Warn** if customer data caused it | A malformed request we constructed is our bug | | Upstream 5xx (server error) | **Error** | Genuine upstream failure | | OAuth token refresh failure | **Warn** | Customer credential issue, fires once | @@ -512,6 +525,21 @@ If your connector makes HTTP calls outside of `uhttp.BaseHttpClient` (e.g., usin // logError keeps server-class gRPC errors at Error. Client-class errors are never // Error, but the level is the caller's call: pass Warn when the customer can act on // it, Debug when they cannot (Rule 1). A helper cannot know which it is. +// +// `Unavailable` carries both a rate limit and a 5xx — GrpcCodeFromHTTPStatus +// produces it for either — and it is deliberately NOT in clientCodes below, so +// both land at Error. Right for the 5xx, noisy for the 429. +// +// Leaving it out is the safer half of the trade. One call site passes one level +// for whatever that call returns, so putting Unavailable in clientCodes would +// send a 5xx to whatever that site chose — Debug at most sites, which the +// default info level never emits. A 429 at Error is noise; a 5xx at Debug is a +// server failure nobody sees. +// +// To avoid the noise, narrow at the call site rather than in the helper: where +// you still hold the response use logStatus below, and where you only have an +// opaque error, errors.As into the vendor's rate-limit type and log that case +// yourself before falling through to logError. func logError(l *zap.Logger, err error, clientLevel zapcore.Level, msg string, fields ...zap.Field) { clientCodes := map[codes.Code]bool{ codes.InvalidArgument: true, @@ -523,7 +551,8 @@ func logError(l *zap.Logger, err error, clientLevel zapcore.Level, msg string, f codes.OutOfRange: true, codes.Unimplemented: true, codes.Canceled: true, - codes.ResourceExhausted: true, + codes.ResourceExhausted: true, // the SDK's local rate limiter, or a limit the + // connector itself enforces — never an HTTP status } fields = append(fields, zap.Error(err)) @@ -535,17 +564,19 @@ func logError(l *zap.Logger, err error, clientLevel zapcore.Level, msg string, f } // Caller decides: a record that vanished mid-sync is not actionable. -// (Not a rate limit — outside BaseHttpClient nothing retries it for you.) logError(l, err, zapcore.DebugLevel, "role not found, skipping") // ...but a credential the customer must rotate is. logError(l, err, zapcore.WarnLevel, "token refresh failed") + ``` For direct HTTP responses without gRPC wrapping, branch on status code: ```go -// Same rule as the helper: the status class decides Error vs not, the caller -// decides Warn vs Debug. Keep it a function so the level is a real parameter. +// Same split as the helper, but without its ambiguity: a 429 is simply not >= 500, +// so the status separates what the gRPC code cannot. The caller still picks Warn +// vs Debug. +// Keep it a function so the level is a real parameter. func logStatus(l *zap.Logger, statusCode int, clientLevel zapcore.Level, msg string, err error) { fields := []zap.Field{zap.Int("status", statusCode), zap.Error(err)} if statusCode >= 500 { @@ -556,21 +587,27 @@ func logStatus(l *zap.Logger, statusCode int, clientLevel zapcore.Level, msg str } // A record that vanished mid-sync is nothing the customer can act on — see Rule 1. -// (Not a 429: on this path there is no SDK retry to lean on.) logStatus(l, resp.StatusCode, zapcore.DebugLevel, "record not found, skipping", err) // A rejected credential is. logStatus(l, resp.StatusCode, zapcore.WarnLevel, "request unauthorized", err) ``` -**Both samples answer "is it Error?", not "is it Warn?".** A client-side status or code means -*not* `Error` — it does not mean the line is worth shipping. A 404 for a record that vanished -mid-sync is the usual case: client-class, and nothing the customer can act on. Note that this -section is for calls made *outside* `uhttp.BaseHttpClient`, so "the SDK retries it" — the reason -a 429 is `Debug` elsewhere in this file — does not apply here. That removes the reason, not the -answer: run a 429 on this path back through Rule 1 rather than through the retry. Sustained -throttling after your retries are exhausted *is* actionable — the operator can lower concurrency -or raise the vendor quota — so `Warn`, sampled if it recurs. If instead you return the error and -let the SDK log it, Rule 6 applies and the local line is `Debug`. That is why neither sample +**Both samples answer "is it Error?", not "is it Warn?".** A client-class code means *not* +`Error` — it does not mean the line is worth shipping. A 404 for a record that vanished +mid-sync is the usual case: client-class, and nothing the customer can act on. + +**Do not assume a 429 gets retried for you.** `pkg/retry` is not part of uhttp — it wraps the +connector's own methods — but the sequential syncer, which is the default (`pkg/sync/syncer.go` +has no reference to it; `workerCount == 1` means sequential), never calls it. Retry exists on +the parallel syncer, which is opt-in behind a tenant feature flag and a configured worker count, +and on `Grant`/`Revoke`, `Validate` and tickets via `connectorbuilder`. uhttp's transport does +not replay a 429 either. So on a default sync a rate limit is returned, not retried, whatever +code it carries. Wrapping it as `Unavailable` — see the wrapping table near the top of this file +— is still right: it is what uhttp produces and it is the code the retryer looks for where a +retryer exists — but it buys a retry only there. The customer-facing condition is the same +either way: sustained throttling is something the operator can act on, by lowering concurrency +or raising the vendor quota, so `Warn`, sampled if it recurs. If instead you return the error +and let the SDK log it, Rule 6 applies and the local line is `Debug`. That is why neither sample hardcodes `Warn`; both take the non-`Error` level from the caller, who is the only one who knows whether the customer can act. diff --git a/.claude/skills/connector/patterns-http-safety.md b/.claude/skills/connector/patterns-http-safety.md index 5979218a..94df64e5 100644 --- a/.claude/skills/connector/patterns-http-safety.md +++ b/.claude/skills/connector/patterns-http-safety.md @@ -133,16 +133,32 @@ func handleResponse(resp *http.Response) error { case http.StatusNotFound: return nil // Often not an error - resource doesn't exist case http.StatusUnauthorized: - return fmt.Errorf("baton-myservice: unauthorized (check credentials)") + return uhttp.WrapErrors(codes.Unauthenticated, "baton-myservice: unauthorized", + fmt.Errorf("check credentials (status %d)", resp.StatusCode)) case http.StatusForbidden: - return fmt.Errorf("baton-myservice: forbidden (check permissions)") + return uhttp.WrapErrors(codes.PermissionDenied, "baton-myservice: forbidden", + fmt.Errorf("check permissions (status %d)", resp.StatusCode)) case http.StatusTooManyRequests: - return fmt.Errorf("baton-myservice: rate limited") // SDK retries + // A bare fmt.Errorf carries no gRPC code, so status.Code() reads Unknown and + // no retryer will ever pick it up. Wrap rate limits and 5xx as Unavailable — + // what uhttp produces for them — see the wrapping table in + // patterns-error-handling.md, which ships to every connector kind. + // WrapErrorsWithRateLimitInfo keeps the response's rate-limit headers on the + // error, so a retryer can size its backoff from them instead of guessing. + return uhttp.WrapErrorsWithRateLimitInfo(codes.Unavailable, resp, + fmt.Errorf("baton-myservice: rate limited (status %d)", resp.StatusCode)) + case http.StatusNotImplemented: + // The one 5xx that is not transient: the vendor does not implement this + // endpoint, so retrying never helps. uhttp maps it to Unimplemented too. + return uhttp.WrapErrors(codes.Unimplemented, "baton-myservice: not implemented", + fmt.Errorf("status %d", resp.StatusCode)) default: if resp.StatusCode >= 500 { - return fmt.Errorf("baton-myservice: server error %d", resp.StatusCode) + return uhttp.WrapErrors(codes.Unavailable, "baton-myservice: server error", + fmt.Errorf("status %d", resp.StatusCode)) } - return fmt.Errorf("baton-myservice: unexpected status %d", resp.StatusCode) + return uhttp.WrapErrors(uhttp.GrpcCodeFromHTTPStatus(resp.StatusCode), + "baton-myservice: unexpected status", fmt.Errorf("status %d", resp.StatusCode)) } } ``` diff --git a/.claude/skills/connector/review-checklist.md b/.claude/skills/connector/review-checklist.md index 1887025e..1e53db3c 100644 --- a/.claude/skills/connector/review-checklist.md +++ b/.claude/skills/connector/review-checklist.md @@ -82,9 +82,11 @@ return resources, resp.NextPage, nil, nil **Entity sources (CRITICAL - #1 bug pattern):** - [ ] User/account ID comes from `principal.Id.Resource` -- [ ] Context (workspace/org) comes from `principal.ParentResourceId.Resource` +- [ ] Context (workspace/org) comes from `principal.ParentResourceId.Resource`, unless the target owns its scope (below) - [ ] Role/group ID comes from `entitlement.Resource.Id.Resource` -- [ ] NO use of `entitlement.Resource.ParentResourceId` for context +- [ ] NO unchecked use of `entitlement.Resource.ParentResourceId` for context +- [ ] Target-owned scope only (the target belongs to one fixed scope, e.g. a GitHub team's org): context from `entitlement.Resource.ParentResourceId` is OK when the parent was set by this connector's sync, is nil-checked and parsed, and the vendor call is scoped to both scope and target (org+team). See `patterns-entity-sources.md#exception-target-owned-scope` +- [ ] Target-owned operations are NOT routed through `principal.ParentResourceId` (the mirror bug: a parentless or multi-org user gives no valid scope) **Idempotency:** - [ ] Grant handles "already exists" as success @@ -93,7 +95,7 @@ return resources, resp.NextPage, nil, nil **Revoke specifically:** - [ ] Uses `grant.Principal` for principal info - [ ] Uses `grant.Entitlement` for entitlement info -- [ ] Context still from `grant.Principal.ParentResourceId` +- [ ] Context still from `grant.Principal.ParentResourceId` (target-owned scope: checked `grant.Entitlement.Resource.ParentResourceId`, as above) --- @@ -184,11 +186,16 @@ Ask yourself: ## Review Comment Templates -**For entity source bugs:** +**For entity source bugs (principal-scoped operations):** ``` This gets the workspace from `entitlement.Resource.ParentResourceId` but it should come from `principal.ParentResourceId`. The principal's context determines where the grant happens. ``` +**For the mirror bug (target-owned operations):** +``` +This routes the call through `principal.ParentResourceId`, but the target belongs to one fixed scope (e.g. the team's org), not the user's. A parentless or multi-org user gives no valid scope here. Read the scope from `entitlement.Resource.ParentResourceId` with a nil check and parse, and make the vendor call scoped to both scope and target (see patterns-entity-sources.md#exception-target-owned-scope). +``` + **For pagination bugs:** ``` This always returns an empty next token, which will stop pagination after the first page. The next token should come from the API response. diff --git a/.claude/skills/connector/review-common-bugs.md b/.claude/skills/connector/review-common-bugs.md index e6fde068..8b6bf238 100644 --- a/.claude/skills/connector/review-common-bugs.md +++ b/.claude/skills/connector/review-common-bugs.md @@ -30,7 +30,12 @@ workspaceID := principal.ParentResourceId.Resource // From principal **Why it happens:** Both principal and entitlement have `ParentResourceId`. Developers grab the wrong one. -**Detection:** Search for `entitlement.Resource.ParentResourceId` in Grant/Revoke methods. +**Not this bug:** checked target-owned scope — the target resource owns one fixed scope +(a GitHub team's org), and the parent (set by this connector's sync) is nil-checked, +parsed, and passed to an org+team-scoped API call. **Also a bug (mirror):** routing such an operation +through `principal.ParentResourceId`. See `patterns-entity-sources.md#exception-target-owned-scope`. + +**Detection:** Search for `entitlement.Resource.ParentResourceId` in Grant/Revoke methods, then read each hit: flag unchecked reads and principal-scoped uses. The mirror bug does not match this search. --- diff --git a/.claude/skills/connector/review-connector.md b/.claude/skills/connector/review-connector.md index 99d6180e..636130ef 100644 --- a/.claude/skills/connector/review-connector.md +++ b/.claude/skills/connector/review-connector.md @@ -186,12 +186,13 @@ CRITICAL CONTEXT — Entity Source Rules (caused 3 production reverts): - WHO (user/account ID): principal.Id.Resource - WHAT (group/role): entitlement.Resource.Id.Resource - WHERE (workspace/org): principal.ParentResourceId.Resource -- NEVER get context from entitlement.Resource.ParentResourceId +- NEVER get context from an unchecked entitlement.Resource.ParentResourceId +- Exception (target-owned scope): when the target itself belongs to one fixed scope (a GitHub team's org), WHERE may come from entitlement.Resource.ParentResourceId (set by this connector's sync) only with a nil check, parse/validate, and a vendor call scoped to both scope and target (org+team). Routing such an operation through principal.ParentResourceId is the mirror bug — flag it. See patterns-entity-sources.md. In Revoke: - Principal: grant.Principal.Id.Resource - Entitlement: grant.Entitlement.Resource.Id.Resource -- Context: grant.Principal.ParentResourceId.Resource +- Context: grant.Principal.ParentResourceId.Resource (target-owned: checked grant.Entitlement parent, as above) ## PROVISIONING CRITERIA (P1-P6) - P1: CRITICAL — entity source correctness per rules above diff --git a/.golangci.yml b/.golangci.yml index 57d8f0ae..03546057 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,5 @@ # This file is managed by baton-admin. DO NOT EDIT!!! version: "2" -run: - modules-download-mode: readonly linters: default: none enable: diff --git a/.versions.yaml b/.versions.yaml index 21b24ae4..8c9d4a4e 100644 --- a/.versions.yaml +++ b/.versions.yaml @@ -1,4 +1,4 @@ # This file is managed by baton-admin. DO NOT EDIT!!! -go-version: 1.27.1 +go-version: 1.27.3 dependencies: - baton-sdk: v0.40.0 \ No newline at end of file + baton-sdk: v0.42.3 \ No newline at end of file diff --git a/BUGBOT.md b/BUGBOT.md index 8dcb113d..d84efa3d 100644 --- a/BUGBOT.md +++ b/BUGBOT.md @@ -58,6 +58,12 @@ entitlement.Resource.ParentResourceId // WRONG for context principal.ParentResourceId // Context comes from principal ``` +**Exception - target-owned scope:** if the target resource itself belongs to one fixed +scope (e.g. a GitHub team's org), reading the parent of the entitlement's resource is +correct when it is nil-checked, parsed, and passed to an API call scoped to both scope +and target (org+team). Do not flag that. Do flag routing such an operation through +`principal.ParentResourceId` (the mirror bug). + ### 2. Pagination Termination (High Severity) Two failure modes - both cause data loss or infinite loops: @@ -358,7 +364,7 @@ id := strconv.FormatUint(uint64(numericID), 10) ### Search for potential issues: ``` -# Entity confusion +# Entity confusion (read each hit: OK only for checked target-owned scope) entitlement\.Resource\.ParentResourceId # Always-empty pagination @@ -396,7 +402,7 @@ Before approving: - [ ] No changes to resource ID derivation ### Entity Sources (CRITICAL) -- [ ] Context (workspace/org) comes from **principal**, not entitlement +- [ ] Context (workspace/org) comes from **principal**, not entitlement (unless checked target-owned scope) - [ ] In Grant: principal = WHO, entitlement = WHAT - [ ] API arguments in correct order (check API docs for multi-string params) diff --git a/CHANGE_TYPES.md b/CHANGE_TYPES.md index 2f5b78b3..42b89801 100644 --- a/CHANGE_TYPES.md +++ b/CHANGE_TYPES.md @@ -180,7 +180,7 @@ orgID := resource.ParentResourceId.Resource **The Entity Source Rule**: - **WHO** (principal): The user/service being granted access - **WHAT** (entitlement): The permission being granted -- **WHERE** (context): The org/workspace scope - **ALWAYS from principal** +- **WHERE** (context): The org/workspace scope - **from principal**, unless the target itself owns one fixed scope (e.g. a GitHub team's org); then from a checked `entitlement.Resource.ParentResourceId`. See `.claude/skills/connector/patterns-entity-sources.md#exception-target-owned-scope` ```go func (g *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, @@ -220,7 +220,7 @@ if isNotFoundError(err) { ``` **Checklist**: -- [ ] Context (workspace/org) comes from principal, not entitlement +- [ ] Context (workspace/org) comes from principal, not entitlement (unless checked target-owned scope) - [ ] ExternalId is used for API calls, not ResourceId - [ ] Already-exists handled as success - [ ] Not-found on revoke handled as success @@ -281,7 +281,7 @@ func (b *widgetBuilder) Grants(ctx context.Context, resource *v2.Resource, **Checklist**: - [ ] ExternalId is set during sync (required for provisioning to work) -- [ ] Context comes from principal +- [ ] Context comes from principal (unless checked target-owned scope) - [ ] Idempotency handled (already-exists, already-revoked) - [ ] capabilities.json updated if needed diff --git a/CLAUDE.md b/CLAUDE.md index eaa40819..69b42d91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,10 @@ func (g *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, } ``` +Exception: when the target itself belongs to one fixed scope (a GitHub team's org), the +scope comes from a checked `entitlement.Resource.ParentResourceId`. See +`.claude/skills/connector/patterns-entity-sources.md#exception-target-owned-scope`. + **Note on ExternalId:** During sync, set `WithExternalID()` with the native system identifier. During Grant/Revoke, retrieve it via `GetExternalId()` to make API calls. ConductorOne assigns its own resource IDs that differ from the target system's native IDs. ### HTTP Response Safety @@ -176,7 +180,7 @@ func (c *Connector) Close() error { ## Common Mistakes 1. **Pagination bugs** - Infinite loop (hardcoded token) or early termination (always empty token) -2. **Entity confusion** - Getting workspace from entitlement instead of principal +2. **Entity confusion** - Getting workspace from entitlement instead of principal (or the reverse, for a target that owns its scope) 3. **Swapped arguments** - Multiple string params in wrong order (check API docs!) 4. **Nil pointer panic** - Accessing resp.Body when resp is nil 5. **Error swallowing** - Logging but not returning errors. Inside `List`/`Entitlements`/`Grants`, `continue` past an item only when the error is a `NotFound` on that item **and this vendor uses 404 for absence, not for permission denial** (log it at Debug); any other error must be returned, because a systemic one skips every item and the empty result C1 reads as deletions. See `patterns-error-handling.md` diff --git a/README.md b/README.md index 189918cf..dbc8fbc6 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,10 @@ A workspace entitlement can only be granted to a principal that is already assigned to that workspace. Otherwise the grant fails with a `FailedPrecondition` error; grant the workspace membership first. +Revoking workspace membership deletes the principal's whole workspace +assignment, so a principal that is also a workspace admin loses admin access +to that workspace too. + ## Unity Catalog A metastore is the top of the Databricks Unity Catalog hierarchy. The resource diff --git a/docs/connector.mdx b/docs/connector.mdx index f2a20299..04b1d549 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -22,6 +22,8 @@ The Databricks connector supports [automatic account provisioning and deprovisio Workspace roles (such as **workspace-access** or **databricks-sql-access**) can only be granted to users, groups, and service principals that are already members of that workspace. If the request fails because the identity isn't a member, grant them membership in the workspace first, then request the role again. + +Revoking the workspace **member** entitlement removes the principal's entire workspace assignment. If the principal is also a workspace admin, their admin access to that workspace is removed too. [This connector syncs non-human identities](/product/admin/nhi) and displays them on the **Identities overview** dashboard. diff --git a/go.mod b/go.mod index 158ed749..6b707b5b 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ module github.com/conductorone/baton-databricks -go 1.27.1 +go 1.27.3 require ( - github.com/conductorone/baton-sdk v0.40.0 + github.com/conductorone/baton-sdk v0.42.3 github.com/ennyjfrick/ruleguard-logfatal v0.0.2 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/quasilyte/go-ruleguard/dsl v0.3.23 @@ -124,13 +124,13 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/ratelimit v0.3.1 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.56.0 // indirect + golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/net v0.58.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/term v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 8b040e4e..ac53c1f2 100644 --- a/go.sum +++ b/go.sum @@ -84,8 +84,8 @@ github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258 h1:IJ+uNItEm0qx9 github.com/cockroachdb/swiss v0.0.0-20260820225851-333444432258/go.mod h1:yBRu/cnL4ks9bgy4vAASdjIW+/xMlFwuHKqtmh3GZQg= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= -github.com/conductorone/baton-sdk v0.40.0 h1:gs6D6E7hb7KfmZfCjBNLNF70wqPA2jX7uMMx752oMj4= -github.com/conductorone/baton-sdk v0.40.0/go.mod h1:EJcj98rPL8NYoD5UTHeHNrJl7LuCevvNWQOzGazJPDU= +github.com/conductorone/baton-sdk v0.42.3 h1:o3TO1UbJQTX+PTNLa7oGKtTSqHtQqITNQCAFGIeoxrI= +github.com/conductorone/baton-sdk v0.42.3/go.mod h1:RZHyjHhpRVuboKzp5cMTyrPuPolPcSXaWWUEDtWDW08= github.com/conductorone/dpop v0.3.0 h1:j5fZk0VqepGKYo+/NDikCOMsZcgs4HO4i0k56wRel5g= github.com/conductorone/dpop v0.3.0/go.mod h1:gyo8TtzB9SCFCsjsICH4IaLZ7y64CcrDXMOPBwfq/3s= github.com/conductorone/dpop/integrations/dpop_grpc v0.3.0 h1:R2uxHBtStgUn7cxbAnT3mAj6/e1akfP2Pj/hEPFB0D8= @@ -346,8 +346,8 @@ golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= -golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= @@ -359,8 +359,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -369,8 +369,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -380,8 +380,8 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -392,14 +392,14 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -409,8 +409,8 @@ golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/connector/users.go b/pkg/connector/users.go index 00acb4bb..95081c53 100644 --- a/pkg/connector/users.go +++ b/pkg/connector/users.go @@ -3,12 +3,16 @@ package connector import ( "context" "fmt" + "strings" "github.com/conductorone/baton-databricks/pkg/databricks" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) type userBuilder struct { @@ -171,45 +175,85 @@ func (o *userBuilder) CreateAccount(ctx context.Context, accountInfo *v2.Account annotations.Annotations, error, ) { - pMap := accountInfo.Profile.AsMap() - body := &databricks.CreateUserBody{} - - // Use email as userName since Databricks API expects email in userName field - if email, ok := pMap["email"]; ok { - if email == "" { - return nil, nil, nil, fmt.Errorf("baton-databricks: email is required to create a user") + profile := accountInfo.GetProfile() + body := &databricks.CreateUserBody{Active: true} + + // Required fields must be present. Presence is checked on the struct because + // the SDK helper returns false for a missing value and a non-string one alike. + for _, key := range []string{"email", "displayName"} { + if _, ok := profile.GetFields()[key]; !ok { + return nil, nil, nil, uhttp.WrapErrors(codes.InvalidArgument, fmt.Sprintf("baton-databricks: %s field is required in profile", key)) } - body.UserName = email.(string) - } else { - return nil, nil, nil, fmt.Errorf("baton-databricks: email field is required in profile") } - if displayName, ok := pMap["displayName"]; ok { - if displayName == "" { - return nil, nil, nil, fmt.Errorf("baton-databricks: displayName is required to create a user") + // A present value of any other type, null included, is rejected before any + // field is read. + for _, key := range []string{"email", "displayName", "givenName", "familyName", "id"} { + if _, ok := profile.GetFields()[key]; !ok { + continue + } + if _, ok := rs.GetProfileStringValue(profile, key); !ok { + return nil, nil, nil, uhttp.WrapErrors(codes.InvalidArgument, fmt.Sprintf("baton-databricks: %s field must be a string", key)) } - body.DisplayName = displayName.(string) } - if name, ok := pMap["familyName"]; ok { - body.Name.FamilyName = name.(string) + // Use email as userName since Databricks API expects email in userName field + email, _ := rs.GetProfileStringValue(profile, "email") + if email == "" { + return nil, nil, nil, uhttp.WrapErrors(codes.InvalidArgument, "baton-databricks: email is required to create a user") } + body.UserName = email - if name, ok := pMap["givenName"]; ok { - body.Name.GivenName = name.(string) + displayName, _ := rs.GetProfileStringValue(profile, "displayName") + if displayName == "" { + return nil, nil, nil, uhttp.WrapErrors(codes.InvalidArgument, "baton-databricks: displayName is required to create a user") } + body.DisplayName = displayName - if id, ok := pMap["id"]; ok { - body.Id = id.(string) + body.Name.FamilyName, _ = rs.GetProfileStringValue(profile, "familyName") + body.Name.GivenName, _ = rs.GetProfileStringValue(profile, "givenName") + body.Id, _ = rs.GetProfileStringValue(profile, "id") + + if active, ok := profile.AsMap()["active"]; ok { + activeBool, ok := active.(bool) + if !ok { + return nil, nil, nil, uhttp.WrapErrors(codes.InvalidArgument, "baton-databricks: active field must be a boolean") + } + body.Active = activeBool } - if active, ok := pMap["active"]; ok { - body.Active = active.(bool) + parentResourceId, err := rs.NewResourceID(accountResourceType, o.client.GetAccountId()) + if err != nil { + return nil, nil, nil, fmt.Errorf("baton-databricks: failed to create resource ID for account: %w", err) } res, _, err := o.client.CreateUser(ctx, "", body) if err != nil { - return nil, nil, nil, fmt.Errorf("baton-databricks: failed to create user: %w", err) + createErr := fmt.Errorf("baton-databricks: failed to create user: %w", err) + // Databricks answers 409 for a userName that already exists. Any other + // 409 finds no user by that userName and keeps the original error. + if status.Code(err) != codes.AlreadyExists { + return nil, nil, nil, createErr + } + existing, _, lookupErr := o.client.GetUserByUsername(ctx, "", body.UserName) + // A failed lookup carries its own code, so a transient failure stays + // retryable; the create error is kept as text only. + if lookupErr != nil { + return nil, nil, nil, fmt.Errorf("baton-databricks: existing-user lookup failed: %w (after %s)", lookupErr, createErr.Error()) + } + // The filter is not escaped, so accept only the user whose userName + // matches; Databricks compares userNames case-insensitively. + if existing == nil || !strings.EqualFold(existing.UserName, body.UserName) { + return nil, nil, nil, createErr + } + resource, err := o.userResource(ctx, existing, parentResourceId) + if err != nil { + return nil, nil, nil, fmt.Errorf("baton-databricks: failed to create user resource: %w", err) + } + return &v2.CreateAccountResponse_AlreadyExistsResult{ + Resource: resource, + IsCreateAccountResult: true, + }, nil, nil, nil } user, _, err := o.client.GetUser(ctx, "", res.Id) @@ -217,10 +261,6 @@ func (o *userBuilder) CreateAccount(ctx context.Context, accountInfo *v2.Account return nil, nil, nil, fmt.Errorf("baton-databricks: failed to get user after creation: %w", err) } - parentResourceId, err := rs.NewResourceID(accountResourceType, o.client.GetAccountId()) - if err != nil { - return nil, nil, nil, fmt.Errorf("baton-databricks: failed to create resource ID for account: %w", err) - } resource, err := o.userResource(ctx, user, parentResourceId) if err != nil { return nil, nil, nil, fmt.Errorf("baton-databricks: failed to create user resource: %w", err) diff --git a/pkg/connector/users_test.go b/pkg/connector/users_test.go new file mode 100644 index 00000000..b6d0f8ef --- /dev/null +++ b/pkg/connector/users_test.go @@ -0,0 +1,525 @@ +package connector + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +// capturedCreate records the raw and decoded body of the CreateUser request +// sent by CreateAccount. +type capturedCreate struct { + raw []byte + body databricks.CreateUserBody +} + +// newCapturingUserBuilder backs a user builder with a test server that +// captures the CreateUser body and also answers the post-create GetUser fetch. +func newCapturingUserBuilder(t *testing.T, captured *capturedCreate) *userBuilder { + t.Helper() + return newTestUserBuilder(t, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read create request body: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + captured.raw = raw + if err := json.Unmarshal(raw, &captured.body); err != nil { + t.Errorf("decode create request body: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(databricks.CreateUserResponse{ + Id: "u1", + UserName: captured.body.UserName, + DisplayName: captured.body.DisplayName, + Active: captured.body.Active, + }) + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":"u1","userName":%q,"displayName":%q,"active":%t,"emails":[{"primary":true,"value":%q}]}`, + captured.body.UserName, captured.body.DisplayName, captured.body.Active, captured.body.UserName) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) +} + +// newTestUserBuilder backs a user builder with a test server running handler. +func newTestUserBuilder(t *testing.T, handler http.HandlerFunc) *userBuilder { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + target, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + + httpClient := &http.Client{Transport: &hostRewriter{target: target}} + c, err := databricks.NewClient(context.Background(), httpClient, testHostname, testAccountHost, testAccountId, "", &databricks.NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + return newUserBuilder(c) +} + +// CreateUserBody.Active has no `omitempty`, so a zero-valued body silently +// sends "active": false even when the profile never set it. +func TestCreateAccountActiveDefault(t *testing.T) { + tests := []struct { + name string + profile map[string]interface{} + wantActive bool + }{ + { + name: "no active field in profile defaults to active", + profile: map[string]interface{}{ + "email": "alice@example.com", + "displayName": "Alice", + }, + wantActive: true, + }, + { + name: "explicit active true overrides default", + profile: map[string]interface{}{ + "email": "alice@example.com", + "displayName": "Alice", + "active": true, + }, + wantActive: true, + }, + { + name: "explicit active false overrides default", + profile: map[string]interface{}{ + "email": "alice@example.com", + "displayName": "Alice", + "active": false, + }, + wantActive: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var captured capturedCreate + b := newCapturingUserBuilder(t, &captured) + + profile, err := structpb.NewStruct(tc.profile) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + + _, _, _, err = b.CreateAccount(context.Background(), &v2.AccountInfo{Profile: profile}, nil) + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + + if captured.body.Active != tc.wantActive { + t.Errorf("serialized request body active = %v, want %v", captured.body.Active, tc.wantActive) + } + + // No omitempty: the key must always be present on the wire. + var raw map[string]interface{} + if err := json.Unmarshal(captured.raw, &raw); err != nil { + t.Fatalf("unmarshal raw request body: %v", err) + } + if _, ok := raw["active"]; !ok { + t.Error("serialized request body is missing the active field") + } + }) + } +} + +// A non-boolean active profile value must error, not panic the type assertion. +func TestCreateAccountActiveNonBoolProfileValue(t *testing.T) { + var captured capturedCreate + b := newCapturingUserBuilder(t, &captured) + + profile, err := structpb.NewStruct(map[string]interface{}{ + "email": "alice@example.com", + "displayName": "Alice", + "active": "yes", + }) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + + _, _, _, err = b.CreateAccount(context.Background(), &v2.AccountInfo{Profile: profile}, nil) + if err == nil { + t.Fatal("CreateAccount succeeded with a non-bool active profile value, want error") + } + if got := status.Code(err); got != codes.InvalidArgument { + t.Errorf("status.Code(err) = %v, want %v", got, codes.InvalidArgument) + } +} + +// Non-string profile values, null included, must error naming the field +// before any request is sent, not panic the type assertion. +func TestCreateAccountNonStringProfileValues(t *testing.T) { + badValues := map[string]interface{}{ + "number": 42, + "bool": true, + "null": nil, + "object": map[string]interface{}{"value": "x"}, + } + + for _, field := range []string{"email", "displayName", "familyName", "givenName", "id"} { + for kind, value := range badValues { + t.Run(field+"/"+kind, func(t *testing.T) { + requests := 0 + b := newTestUserBuilder(t, func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + }) + + profileMap := map[string]interface{}{ + "email": "alice@example.com", + "displayName": "Alice", + } + profileMap[field] = value + profile, err := structpb.NewStruct(profileMap) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + + _, _, _, err = b.CreateAccount(context.Background(), &v2.AccountInfo{Profile: profile}, nil) + want := fmt.Sprintf("baton-databricks: %s field must be a string", field) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("CreateAccount error = %v, want %q", err, want) + } + if got := status.Code(err); got != codes.InvalidArgument { + t.Errorf("status.Code(err) = %v, want %v", got, codes.InvalidArgument) + } + if requests != 0 { + t.Errorf("sent %d requests, want 0", requests) + } + }) + } + } +} + +// A missing or empty email or displayName is rejected as invalid input before +// any request is sent. +func TestCreateAccountMissingRequiredProfileValues(t *testing.T) { + tests := []struct { + name string + profile map[string]interface{} + want string + }{ + { + name: "missing email", + profile: map[string]interface{}{"displayName": "Alice"}, + want: "baton-databricks: email field is required in profile", + }, + { + name: "empty email", + profile: map[string]interface{}{"email": "", "displayName": "Alice"}, + want: "baton-databricks: email is required to create a user", + }, + { + name: "displayName missing", + profile: map[string]interface{}{"email": "alice@example.com"}, + want: "baton-databricks: displayName field is required in profile", + }, + { + name: "empty displayName", + profile: map[string]interface{}{"email": "alice@example.com", "displayName": ""}, + want: "baton-databricks: displayName is required to create a user", + }, + { + name: "missing email is reported before a type error", + profile: map[string]interface{}{"displayName": "A", "givenName": 42}, + want: "baton-databricks: email field is required in profile", + }, + { + name: "present email that is not a string is a type error", + profile: map[string]interface{}{"email": 123, "displayName": "A"}, + want: "baton-databricks: email field must be a string", + }, + { + name: "empty profile", + profile: map[string]interface{}{}, + want: "baton-databricks: email field is required in profile", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + requests := 0 + b := newTestUserBuilder(t, func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusInternalServerError) + }) + + profile, err := structpb.NewStruct(tc.profile) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + + _, _, _, err = b.CreateAccount(context.Background(), &v2.AccountInfo{Profile: profile}, nil) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("CreateAccount error = %v, want %q", err, tc.want) + } + if got := status.Code(err); got != codes.InvalidArgument { + t.Errorf("status.Code(err) = %v, want %v", got, codes.InvalidArgument) + } + if requests != 0 { + t.Errorf("sent %d requests, want 0", requests) + } + }) + } +} + +// createConflictServer answers POST with createStatus and the filtered +// userName lookup with lookupBody, or with lookupStatus when it is set, +// counting both. +type createConflictServer struct { + createStatus int + lookupStatus int + lookupBody string + posts int + lookups int + lookupFilter string +} + +func (s *createConflictServer) handle(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodPost: + s.posts++ + w.WriteHeader(s.createStatus) + fmt.Fprintf(w, `{"detail":"error %d","status":"%d"}`, s.createStatus, s.createStatus) + case http.MethodGet: + s.lookups++ + s.lookupFilter = r.URL.Query().Get("filter") + if s.lookupStatus != 0 { + w.WriteHeader(s.lookupStatus) + fmt.Fprintf(w, `{"detail":"error %d","status":"%d"}`, s.lookupStatus, s.lookupStatus) + return + } + fmt.Fprint(w, s.lookupBody) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func createAliceAccount(t *testing.T, b *userBuilder) (connectorbuilder.CreateAccountResponse, error) { + t.Helper() + profile, err := structpb.NewStruct(map[string]interface{}{ + "email": "alice@example.com", + "displayName": "Alice", + }) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + res, _, _, err := b.CreateAccount(context.Background(), &v2.AccountInfo{Profile: profile}, nil) + return res, err +} + +// A 409 for an existing userName returns the existing user as already-exists. +func TestCreateAccountDuplicateEmailReturnsAlreadyExists(t *testing.T) { + srv := &createConflictServer{ + createStatus: http.StatusConflict, + lookupBody: `{"totalResults":1,"Resources":[{"id":"existing-1","userName":"alice@example.com",` + + `"displayName":"Alice","active":true,"emails":[{"primary":true,"value":"alice@example.com"}]}]}`, + } + b := newTestUserBuilder(t, srv.handle) + + res, err := createAliceAccount(t, b) + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + already, ok := res.(*v2.CreateAccountResponse_AlreadyExistsResult) + if !ok { + t.Fatalf("CreateAccount result = %T, want *v2.CreateAccountResponse_AlreadyExistsResult", res) + } + if got := already.GetResource().GetId().GetResource(); got != "existing-1" { + t.Errorf("resource id = %q, want %q", got, "existing-1") + } + if !already.GetIsCreateAccountResult() { + t.Error("IsCreateAccountResult = false, want true") + } + if srv.posts != 1 || srv.lookups != 1 { + t.Errorf("posts = %d, lookups = %d, want 1 and 1", srv.posts, srv.lookups) + } + if want := "userName eq 'alice@example.com'"; srv.lookupFilter != want { + t.Errorf("lookup filter = %q, want %q", srv.lookupFilter, want) + } +} + +// A 409 whose lookup finds no user keeps the original create error. +func TestCreateAccountConflictWithoutExistingUserErrors(t *testing.T) { + srv := &createConflictServer{ + createStatus: http.StatusConflict, + lookupBody: `{"totalResults":0,"Resources":[]}`, + } + b := newTestUserBuilder(t, srv.handle) + + _, err := createAliceAccount(t, b) + if err == nil || !strings.HasPrefix(err.Error(), "baton-databricks: failed to create user:") { + t.Fatalf("CreateAccount error = %v, want the wrapped create error", err) + } + if strings.Contains(err.Error(), "existing-user lookup failed") { + t.Errorf("CreateAccount error = %v, want no lookup failure note", err) + } + if srv.posts != 1 || srv.lookups != 1 { + t.Errorf("posts = %d, lookups = %d, want 1 and 1", srv.posts, srv.lookups) + } +} + +// A 409 whose lookup itself fails reports the lookup failure with the create +// error as text, and resolves to the lookup error's code so a transient +// failure stays retryable. +func TestCreateAccountConflictWithFailedLookupReportsLookupError(t *testing.T) { + tests := []struct { + name string + lookupStatus int + wantCode codes.Code + }{ + {name: "lookup 500", lookupStatus: http.StatusInternalServerError, wantCode: codes.Unavailable}, + {name: "lookup 429", lookupStatus: http.StatusTooManyRequests, wantCode: codes.Unavailable}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := &createConflictServer{ + createStatus: http.StatusConflict, + lookupStatus: tc.lookupStatus, + } + b := newTestUserBuilder(t, srv.handle) + + res, err := createAliceAccount(t, b) + if err == nil { + t.Fatalf("CreateAccount result = %T, want an error", res) + } + if res != nil { + t.Errorf("CreateAccount result = %T, want nil (no already-exists result)", res) + } + if !strings.Contains(err.Error(), "baton-databricks: failed to create user:") { + t.Errorf("CreateAccount error = %v, want the create error text", err) + } + if !strings.Contains(err.Error(), "existing-user lookup failed") { + t.Errorf("CreateAccount error = %v, want the lookup failure note", err) + } + if got := status.Code(err); got != tc.wantCode { + t.Errorf("status.Code(err) = %v, want %v", got, tc.wantCode) + } + if srv.posts != 1 || srv.lookups != 1 { + t.Errorf("posts = %d, lookups = %d, want 1 and 1", srv.posts, srv.lookups) + } + }) + } +} + +// A 409 whose lookup returns a different userName keeps the original create +// error rather than returning that other user as already-exists. +func TestCreateAccountConflictWithDifferentUserNameErrors(t *testing.T) { + srv := &createConflictServer{ + createStatus: http.StatusConflict, + lookupBody: `{"totalResults":1,"Resources":[{"id":"other-1","userName":"bob@example.com",` + + `"displayName":"Bob","active":true,"emails":[{"primary":true,"value":"bob@example.com"}]}]}`, + } + b := newTestUserBuilder(t, srv.handle) + + res, err := createAliceAccount(t, b) + if err == nil || !strings.HasPrefix(err.Error(), "baton-databricks: failed to create user:") { + t.Fatalf("CreateAccount error = %v, want the wrapped create error", err) + } + if res != nil { + t.Errorf("CreateAccount result = %T, want nil", res) + } + if strings.Contains(err.Error(), "existing-user lookup failed") { + t.Errorf("CreateAccount error = %v, want no lookup failure note", err) + } + if srv.posts != 1 || srv.lookups != 1 { + t.Errorf("posts = %d, lookups = %d, want 1 and 1", srv.posts, srv.lookups) + } +} + +// A 409 whose lookup returns the userName in different case is the same user. +func TestCreateAccountConflictCaseInsensitiveUserNameReturnsAlreadyExists(t *testing.T) { + srv := &createConflictServer{ + createStatus: http.StatusConflict, + lookupBody: `{"totalResults":1,"Resources":[{"id":"existing-1","userName":"Alice@Example.COM",` + + `"displayName":"Alice","active":true,"emails":[{"primary":true,"value":"Alice@Example.COM"}]}]}`, + } + b := newTestUserBuilder(t, srv.handle) + + res, err := createAliceAccount(t, b) + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + already, ok := res.(*v2.CreateAccountResponse_AlreadyExistsResult) + if !ok { + t.Fatalf("CreateAccount result = %T, want *v2.CreateAccountResponse_AlreadyExistsResult", res) + } + if got := already.GetResource().GetId().GetResource(); got != "existing-1" { + t.Errorf("resource id = %q, want %q", got, "existing-1") + } +} + +// Any other create error is returned wrapped, with no lookup. +func TestCreateAccountOtherErrorIsWrapped(t *testing.T) { + srv := &createConflictServer{createStatus: http.StatusBadRequest} + b := newTestUserBuilder(t, srv.handle) + + _, err := createAliceAccount(t, b) + if err == nil || !strings.HasPrefix(err.Error(), "baton-databricks: failed to create user:") { + t.Fatalf("CreateAccount error = %v, want the wrapped create error", err) + } + if srv.posts != 1 || srv.lookups != 0 { + t.Errorf("posts = %d, lookups = %d, want 1 and 0", srv.posts, srv.lookups) + } +} + +// A valid profile still creates the user and returns success. +func TestCreateAccountValidProfileSucceeds(t *testing.T) { + var captured capturedCreate + b := newCapturingUserBuilder(t, &captured) + + profile, err := structpb.NewStruct(map[string]interface{}{ + "email": "alice@example.com", + "displayName": "Alice", + "familyName": "Smith", + "givenName": "Alice", + "id": "ext-1", + }) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + + res, _, _, err := b.CreateAccount(context.Background(), &v2.AccountInfo{Profile: profile}, nil) + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + success, ok := res.(*v2.CreateAccountResponse_SuccessResult) + if !ok { + t.Fatalf("CreateAccount result = %T, want *v2.CreateAccountResponse_SuccessResult", res) + } + if got := success.GetResource().GetId().GetResource(); got != "u1" { + t.Errorf("resource id = %q, want %q", got, "u1") + } + body := captured.body + if body.UserName != "alice@example.com" || body.DisplayName != "Alice" || + body.Name.FamilyName != "Smith" || body.Name.GivenName != "Alice" || body.Id != "ext-1" || !body.Active { + t.Errorf("create body = %+v, want the profile values with active true", body) + } +} diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index 04148a04..7bfc1d43 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -260,9 +260,12 @@ func (w *workspaceBuilder) Grant(ctx context.Context, principal *v2.Resource, en } workspace := strconv.Itoa(int(workspaceID)) - _, err := w.client.CreateOrUpdateWorkspaceMember(ctx, workspace, principal.Id.Resource) + alreadyGranted, _, err := w.client.AddWorkspaceMember(ctx, workspace, principal.Id.Resource) if err != nil { - return nil, fmt.Errorf("databricks-connector: failed to create or update workspace member: %w", err) + return nil, fmt.Errorf("databricks-connector: failed to add workspace member: %w", err) + } + if alreadyGranted { + return annotations.New(&v2.GrantAlreadyExists{}), nil } return nil, nil @@ -292,9 +295,12 @@ func (w *workspaceBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotat } workspace := strconv.Itoa(int(workspaceID)) - _, err := w.client.RemoveWorkspaceMember(ctx, workspace, principal.Id.Resource) + alreadyRevoked, _, err := w.client.RemoveWorkspaceMember(ctx, workspace, principal.Id.Resource) if err != nil { - return nil, fmt.Errorf("databricks-connector: failed to create or update workspace member: %w", err) + return nil, fmt.Errorf("databricks-connector: failed to remove workspace member: %w", err) + } + if alreadyRevoked { + return annotations.New(&v2.GrantAlreadyRevoked{}), nil } return nil, nil diff --git a/pkg/connector/workspaces_provisioning_test.go b/pkg/connector/workspaces_provisioning_test.go new file mode 100644 index 00000000..a5d4b6de --- /dev/null +++ b/pkg/connector/workspaces_provisioning_test.go @@ -0,0 +1,215 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + ent "github.com/conductorone/baton-sdk/pkg/types/entitlement" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + testProvisioningWorkspaceID = 123 + testProvisioningPrincipalID = "100" + + testAssignmentsPath = "/api/2.0/accounts/" + testAccountId + "/workspaces/123/permissionassignments" + testMemberPath = testAssignmentsPath + "/principals/" + testProvisioningPrincipalID +) + +// workspaceAssignmentsServer is a minimal mock of the workspace permission +// assignments API that records every request it receives. +type workspaceAssignmentsServer struct { + mu sync.Mutex + assignments []map[string]any + requests []string // "METHOD path" + putBodies []map[string]any +} + +func (m *workspaceAssignmentsServer) snapshot() ([]string, []map[string]any) { + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.requests...), append([]map[string]any(nil), m.putBodies...) +} + +func newWorkspaceBuilderWithMock(t *testing.T, m *workspaceAssignmentsServer) *workspaceBuilder { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + defer m.mu.Unlock() + m.requests = append(m.requests, r.Method+" "+r.URL.Path) + switch { + case r.Method == http.MethodGet && r.URL.Path == testAssignmentsPath: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"permission_assignments": m.assignments}) + case r.Method == http.MethodPut && r.URL.Path == testMemberPath: + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + m.putBodies = append(m.putBodies, body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + case r.Method == http.MethodDelete && r.URL.Path == testMemberPath: + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })) + t.Cleanup(srv.Close) + + target, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + + httpClient := &http.Client{Transport: &hostRewriter{target: target}} + c, err := databricks.NewClient(context.Background(), httpClient, testHostname, testAccountHost, testAccountId, "", &databricks.NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + return newWorkspaceBuilder(c, nil) +} + +func existingUserAssignment() []map[string]any { + return []map[string]any{{ + "principal": map[string]any{"principal_id": 100, "user_name": "alice@example.com"}, + "permissions": []string{"USER"}, + }} +} + +func testWorkspaceMemberEntitlement(t *testing.T) *v2.Entitlement { + t.Helper() + r, err := workspaceResource(context.Background(), + &databricks.Workspace{ID: testProvisioningWorkspaceID, Name: "ws", DeploymentName: "ws-deploy"}, + &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId}, + ) + if err != nil { + t.Fatalf("workspaceResource: %v", err) + } + return ent.NewAssignmentEntitlement(r, workspaceMemberEntitlement) +} + +func testProvisioningUser(t *testing.T) *v2.Resource { + t.Helper() + r, err := rs.NewUserResource("alice@example.com", userResourceType, testProvisioningPrincipalID, nil) + if err != nil { + t.Fatalf("NewUserResource: %v", err) + } + return r +} + +func testProvisioningGroup(t *testing.T) *v2.Resource { + t.Helper() + // Group resource IDs are compound ("account//group/"). + r, err := groupResource(context.Background(), + &databricks.Group{ID: testProvisioningPrincipalID, DisplayName: "admins"}, + &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId}, + ) + if err != nil { + t.Fatalf("groupResource: %v", err) + } + return r +} + +func TestWorkspaceGrantNoAssignment(t *testing.T) { + m := &workspaceAssignmentsServer{} + w := newWorkspaceBuilderWithMock(t, m) + + annos, err := w.Grant(context.Background(), testProvisioningUser(t), testWorkspaceMemberEntitlement(t)) + if err != nil { + t.Fatalf("Grant: %v", err) + } + if len(annos) != 0 { + t.Errorf("annotations = %v, want none", annos) + } + + requests, putBodies := m.snapshot() + want := []string{"GET " + testAssignmentsPath, "PUT " + testMemberPath} + if len(requests) != len(want) || requests[0] != want[0] || requests[1] != want[1] { + t.Fatalf("requests = %v, want %v", requests, want) + } + perms, _ := putBodies[0]["permissions"].([]any) + if len(perms) != 1 || perms[0] != "USER" { + t.Errorf("PUT permissions = %v, want [USER]", perms) + } +} + +func TestWorkspaceGrantExistingAssignment(t *testing.T) { + m := &workspaceAssignmentsServer{assignments: existingUserAssignment()} + w := newWorkspaceBuilderWithMock(t, m) + + annos, err := w.Grant(context.Background(), testProvisioningUser(t), testWorkspaceMemberEntitlement(t)) + if err != nil { + t.Fatalf("Grant: %v", err) + } + if !annos.Contains(&v2.GrantAlreadyExists{}) { + t.Errorf("annotations = %v, want GrantAlreadyExists", annos) + } + + requests, _ := m.snapshot() + if len(requests) != 1 || requests[0] != "GET "+testAssignmentsPath { + t.Errorf("requests = %v, want only the read", requests) + } +} + +func TestWorkspaceRevokeWithAssignment(t *testing.T) { + m := &workspaceAssignmentsServer{assignments: existingUserAssignment()} + w := newWorkspaceBuilderWithMock(t, m) + + annos, err := w.Revoke(context.Background(), &v2.Grant{Principal: testProvisioningUser(t), Entitlement: testWorkspaceMemberEntitlement(t)}) + if err != nil { + t.Fatalf("Revoke: %v", err) + } + if len(annos) != 0 { + t.Errorf("annotations = %v, want none", annos) + } + + requests, _ := m.snapshot() + want := []string{"GET " + testAssignmentsPath, "DELETE " + testMemberPath} + if len(requests) != len(want) || requests[0] != want[0] || requests[1] != want[1] { + t.Fatalf("requests = %v, want %v", requests, want) + } +} + +func TestWorkspaceRevokeNoAssignment(t *testing.T) { + m := &workspaceAssignmentsServer{} + w := newWorkspaceBuilderWithMock(t, m) + + annos, err := w.Revoke(context.Background(), &v2.Grant{Principal: testProvisioningUser(t), Entitlement: testWorkspaceMemberEntitlement(t)}) + if err != nil { + t.Fatalf("Revoke: %v", err) + } + if !annos.Contains(&v2.GrantAlreadyRevoked{}) { + t.Errorf("annotations = %v, want GrantAlreadyRevoked", annos) + } + + requests, _ := m.snapshot() + if len(requests) != 1 || requests[0] != "GET "+testAssignmentsPath { + t.Errorf("requests = %v, want only the read", requests) + } +} + +func TestWorkspaceGrantGroupPrincipalRejected(t *testing.T) { + m := &workspaceAssignmentsServer{} + w := newWorkspaceBuilderWithMock(t, m) + + _, err := w.Grant(context.Background(), testProvisioningGroup(t), testWorkspaceMemberEntitlement(t)) + if err == nil { + t.Fatal("Grant: expected error for a group principal, got nil") + } + if code := status.Code(err); code != codes.InvalidArgument { + t.Errorf("status code = %v, want InvalidArgument (err: %v)", code, err) + } + if requests, _ := m.snapshot(); len(requests) != 0 { + t.Errorf("requests = %v, want none", requests) + } +} diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index c44653bd..0a0e3ca7 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -2,6 +2,7 @@ package databricks import ( "context" + "errors" "fmt" "net/http" "net/url" @@ -10,6 +11,7 @@ import ( "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" + "google.golang.org/grpc/codes" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/uhttp" @@ -33,8 +35,9 @@ const ( accountRolesEndpoint = "/api/2.0/preview/accounts/%s/access-control/assignable-roles" accountRuleSetsEndpoint = "/api/2.0/preview/accounts/%s/access-control/rule-sets" - accountWorkspacesEndpoint = "/api/2.0/accounts/%s/workspaces" - accountWorkspaceAssignmentsEndpoint = "/api/2.0/accounts/%s/workspaces/%s/permissionassignments" + accountWorkspacesEndpoint = "/api/2.0/accounts/%s/workspaces" + accountWorkspaceAssignmentsEndpoint = "/api/2.0/accounts/%s/workspaces/%s/permissionassignments" + accountWorkspacePrincipalAssignmentEndpoint = "/api/2.0/accounts/%s/workspaces/%s/permissionassignments/principals/%s" ) type Client struct { @@ -102,7 +105,10 @@ func NewClient(ctx context.Context, httpClient *http.Client, hostname, accountHo Host: accountHostname, } - cli, err := uhttp.NewBaseHttpClientWithContext(ctx, httpClient, uhttp.WithCacheKeyHeaders(cacheScopeHeader)) + // Key the cache on the scope header so two workspaces answering the same path keep + // separate entries, and on Cache-Control so a read-before-write no-cache read does + // not overwrite the shared sync entry. + cli, err := uhttp.NewBaseHttpClientWithContext(ctx, httpClient, uhttp.WithCacheKeyHeaders(cacheScopeHeader, "Cache-Control")) return &Client{ httpClient: cli, auth: auth, @@ -262,6 +268,34 @@ func (c *Client) FindUserID( return users[0].ID, ratelimitData, nil } +// GetUserByUsername returns the first user matching username, or nil; like FindUserID the filter is not escaped. +func (c *Client) GetUserByUsername( + ctx context.Context, + workspaceId string, + username string, +) ( + *User, + *v2.RateLimitDescription, + error, +) { + users, _, ratelimitData, err := c.ListUsers( + ctx, + workspaceId, + &PaginationVars{Count: 1}, + NewFilterVars(fmt.Sprintf("userName eq '%s'", username)), + NewUserAttrVars(), + ) + if err != nil { + return nil, ratelimitData, err + } + + if len(users) == 0 { + return nil, ratelimitData, nil + } + + return &users[0], ratelimitData, nil +} + func (c *Client) FindUsername( ctx context.Context, workspaceId string, @@ -611,13 +645,31 @@ func (c *Client) ListWorkspaceMembers( []WorkspaceAssignment, *v2.RateLimitDescription, error, +) { + return c.listWorkspaceAssignments(ctx, workspaceId, false) +} + +// listWorkspaceAssignments lists the workspace's assignments; noCache skips +// the uhttp response cache. +func (c *Client) listWorkspaceAssignments( + ctx context.Context, + workspaceId string, + noCache bool, +) ( + []WorkspaceAssignment, + *v2.RateLimitDescription, + error, ) { var res struct { Assignments []WorkspaceAssignment `json:"permission_assignments"` } u := c.accountBaseUrl.JoinPath(fmt.Sprintf(accountWorkspaceAssignmentsEndpoint, c.accountId, workspaceId)) - ratelimitData, err := c.Get(ctx, u, &res) + get := c.Get + if noCache { + get = c.GetUncached + } + ratelimitData, err := get(ctx, u, &res) if err != nil { return nil, ratelimitData, err } @@ -625,31 +677,143 @@ func (c *Client) ListWorkspaceMembers( return res.Assignments, ratelimitData, nil } -func (c *Client) CreateOrUpdateWorkspaceMember( +// validatePrincipalID rejects non-numeric IDs: the assignments API addresses +// principals by numeric ID, and a group's composite resource ID would never match. +func validatePrincipalID(principalID string) error { + if _, err := strconv.Atoi(principalID); err != nil { + return uhttp.WrapErrors( + codes.InvalidArgument, + fmt.Sprintf("principal ID must be numeric; compound group IDs are not supported here: %q", principalID), + ) + } + + return nil +} + +// workspacePrincipalAssignmentURL is the URL of a single principal's assignment +// on a workspace. +func (c *Client) workspacePrincipalAssignmentURL(workspaceId, principalId string) *url.URL { + return c.accountBaseUrl.JoinPath(fmt.Sprintf(accountWorkspacePrincipalAssignmentEndpoint, c.accountId, workspaceId, principalId)) +} + +// AddWorkspaceMember grants the "USER" workspace permission to a principal. +// +// The assignments endpoint is full-replacement, not PATCH. An existing +// assignment of any kind (USER and/or ADMIN) already grants workspace access, +// so nothing is written and an existing ADMIN is never touched. +// +// GET /api/2.0/accounts/{account_id}/workspaces/{workspace_id}/permissionassignments, then +// PUT /api/2.0/accounts/{account_id}/workspaces/{workspace_id}/permissionassignments/principals/{principal_id}. +func (c *Client) AddWorkspaceMember( ctx context.Context, workspaceId string, principalId string, -) ( - *v2.RateLimitDescription, - error, -) { - u := c.accountBaseUrl.JoinPath(fmt.Sprintf(accountWorkspaceAssignmentsEndpoint, c.accountId, workspaceId), "principals", principalId) +) (bool, *v2.RateLimitDescription, error) { + if err := validatePrincipalID(principalId); err != nil { + return false, nil, err + } + + hasAssignment, ratelimitData, err := c.currentWorkspacePermissions(ctx, workspaceId, principalId) + if err != nil { + return false, ratelimitData, fmt.Errorf("failed to read current workspace permission assignment: %w", err) + } + + if hasAssignment { + return true, ratelimitData, nil + } + payload := struct { - Permission []string `json:"permissions"` + Permissions []string `json:"permissions"` }{ - Permission: []string{"USER"}, + Permissions: []string{WorkspaceUserPermission}, } - return c.Put(ctx, u, payload, nil) + putRatelimitData, err := c.Put(ctx, c.workspacePrincipalAssignmentURL(workspaceId, principalId), payload, nil) + if putRatelimitData != nil { + ratelimitData = putRatelimitData + } + + return false, ratelimitData, err } +// RemoveWorkspaceMember removes a principal's workspace assignment. +// +// The whole assignment (USER and/or ADMIN) is deleted because C1 models a +// single "member" grant; this matches the previous behavior of wiping the +// assignment with an empty PUT. +// https://docs.databricks.com/api/access-management/v1/delete-workspace-permission-assignment func (c *Client) RemoveWorkspaceMember( ctx context.Context, workspaceId string, principalId string, -) (*v2.RateLimitDescription, error) { - u := c.accountBaseUrl.JoinPath(fmt.Sprintf(accountWorkspaceAssignmentsEndpoint, c.accountId, workspaceId), "principals", principalId) - return c.Put(ctx, u, nil, nil) +) (bool, *v2.RateLimitDescription, error) { + if err := validatePrincipalID(principalId); err != nil { + return false, nil, err + } + + hasAssignment, ratelimitData, err := c.currentWorkspacePermissions(ctx, workspaceId, principalId) + if err != nil { + return false, ratelimitData, fmt.Errorf("failed to read current workspace permission assignment: %w", err) + } + + if !hasAssignment { + return true, ratelimitData, nil + } + + deleteRatelimitData, err := c.Delete(ctx, c.workspacePrincipalAssignmentURL(workspaceId, principalId)) + if deleteRatelimitData != nil { + ratelimitData = deleteRatelimitData + } + + // Removed between the read and the DELETE: already revoked. + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return true, ratelimitData, nil + } + + return false, ratelimitData, err +} + +// currentWorkspacePermissions reports whether the principal holds any +// permission on the workspace. It lists every assignment and matches by +// principal ID: there's no API to fetch a single assignment directly. An entry +// for the principal carrying an error is returned as an error, so callers +// never write on top of an assignment that couldn't be read properly. +// +// GET /api/2.0/accounts/{account_id}/workspaces/{workspace_id}/permissionassignments. +func (c *Client) currentWorkspacePermissions( + ctx context.Context, + workspaceId string, + principalId string, +) ( + bool, + *v2.RateLimitDescription, + error, +) { + // Read-before-write: a cached list would miss earlier PUTs/DELETEs. + assignments, ratelimitData, err := c.listWorkspaceAssignments(ctx, workspaceId, true) + if err != nil { + return false, ratelimitData, err + } + + for _, assignment := range assignments { + if assignment.Principal == nil { + continue + } + if strconv.Itoa(assignment.Principal.ID) != principalId { + continue + } + if assignment.Error != "" { + return false, ratelimitData, uhttp.WrapErrors( + codes.Internal, + fmt.Sprintf("workspace %s assignment for principal %s has an error: %s", workspaceId, principalId, assignment.Error), + ) + } + + return len(assignment.Permissions) > 0, ratelimitData, nil + } + + return false, ratelimitData, nil } func (c *Client) ListRuleSets( diff --git a/pkg/databricks/client_test.go b/pkg/databricks/client_test.go index ece98a8e..6d01e384 100644 --- a/pkg/databricks/client_test.go +++ b/pkg/databricks/client_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" ) @@ -146,3 +147,109 @@ func TestIsWorkspaceExcludedReturnsEveryMatchedKey(t *testing.T) { } } } + +// serverRewriter sends every request to the test server. +type serverRewriter struct { + target *url.URL +} + +func (s *serverRewriter) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.URL.Scheme = s.target.Scheme + req.URL.Host = s.target.Host + return http.DefaultTransport.RoundTrip(req) +} + +// newServerClient backs a client with a test server running handler. +func newServerClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + target, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + + httpClient := &http.Client{Transport: &serverRewriter{target: target}} + c, err := NewClient(context.Background(), httpClient, "example.cloud.databricks.com", "accounts.cloud.databricks.com", "acc-1", "", &NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + return c +} + +func TestGetUserByUsername(t *testing.T) { + tests := []struct { + name string + status int + body string + wantUser string + wantErr bool + }{ + { + name: "match", + status: http.StatusOK, + body: `{"totalResults":1,"Resources":[{"id":"u1","userName":"alice@example.com","displayName":"Alice","active":true}]}`, + wantUser: "u1", + }, + { + name: "no match", + status: http.StatusOK, + body: `{"totalResults":0,"Resources":[]}`, + }, + { + name: "server error", + status: http.StatusInternalServerError, + body: `{"detail":"boom"}`, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var query url.Values + c := newServerClient(t, func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + }) + + user, _, err := c.GetUserByUsername(context.Background(), "", "alice@example.com") + if tc.wantErr { + if err == nil { + t.Fatal("GetUserByUsername succeeded, want error") + } + } else { + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + switch { + case tc.wantUser == "" && user != nil: + t.Errorf("GetUserByUsername = %+v, want nil", user) + case tc.wantUser != "" && (user == nil || user.ID != tc.wantUser): + t.Errorf("GetUserByUsername = %+v, want user %q", user, tc.wantUser) + } + } + + if got, want := query.Get("filter"), "userName eq 'alice@example.com'"; got != want { + t.Errorf("filter = %q, want %q", got, want) + } + if got, want := query.Get("count"), "1"; got != want { + t.Errorf("count = %q, want %q", got, want) + } + wantAttrs := NewUserAttrVars().Attrs + gotAttrs := query["attributes"] + if len(gotAttrs) != len(wantAttrs) { + t.Fatalf("attributes = %v, want %v", gotAttrs, wantAttrs) + } + for i := range wantAttrs { + if gotAttrs[i] != wantAttrs[i] { + t.Errorf("attributes = %v, want %v", gotAttrs, wantAttrs) + break + } + } + }) + } +} diff --git a/pkg/databricks/models.go b/pkg/databricks/models.go index 541b8ccb..424c585b 100644 --- a/pkg/databricks/models.go +++ b/pkg/databricks/models.go @@ -130,9 +130,17 @@ type WorkspacePrincipal struct { ID int `json:"principal_id"` } +// WorkspaceAdminPermission marks a principal as a workspace admin; WorkspaceUserPermission is basic access. +const ( + WorkspaceAdminPermission = "ADMIN" + WorkspaceUserPermission = "USER" +) + type WorkspaceAssignment struct { Principal *WorkspacePrincipal `json:"principal"` Permissions []string `json:"permissions"` + // Error is the error response associated with this assignment, if any. + Error string `json:"error"` } type Role struct { diff --git a/pkg/databricks/workspace_members_test.go b/pkg/databricks/workspace_members_test.go new file mode 100644 index 00000000..69ed13e6 --- /dev/null +++ b/pkg/databricks/workspace_members_test.go @@ -0,0 +1,530 @@ +package databricks + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// workspaceMembersHostRewriter sends every request to the test server, +// regardless of the host the client computed internally. +type workspaceMembersHostRewriter struct { + target *url.URL +} + +func (h *workspaceMembersHostRewriter) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.URL.Scheme = h.target.Scheme + req.URL.Host = h.target.Host + return http.DefaultTransport.RoundTrip(req) +} + +const ( + testAccountId = "acc-1" + testWorkspaceId = "ws1" + testHostnameForWorkspaceMembers = "example.cloud.databricks.com" + testAccountHostForWorkspaceMembers = "accounts.cloud.databricks.com" + testPrincipalId = "100" +) + +var testPrincipalPath = fmt.Sprintf(accountWorkspaceAssignmentsEndpoint, testAccountId, testWorkspaceId) + "/principals/" + testPrincipalId + +// recordedRequest captures what the client actually sent, so tests can assert +// on HTTP method, headers and request body rather than just the final return value. +type recordedRequest struct { + method string + path string + header http.Header + body map[string]interface{} +} + +// workspaceAssignmentsMock is a mutex-guarded mock of the assignments API. It +// records every list GET and every write, and answers DELETE with a +// configurable status. +type workspaceAssignmentsMock struct { + mu sync.Mutex + assignments []WorkspaceAssignment + deleteStatus int // 0 means 200 OK + listGets []http.Header + writes []recordedRequest +} + +func (m *workspaceAssignmentsMock) setAssignments(assignments []WorkspaceAssignment) { + m.mu.Lock() + defer m.mu.Unlock() + m.assignments = assignments +} + +func (m *workspaceAssignmentsMock) snapshot() ([]http.Header, []recordedRequest) { + m.mu.Lock() + defer m.mu.Unlock() + return append([]http.Header(nil), m.listGets...), append([]recordedRequest(nil), m.writes...) +} + +func (m *workspaceAssignmentsMock) writeMethods() []string { + _, writes := m.snapshot() + methods := make([]string, 0, len(writes)) + for _, w := range writes { + methods = append(methods, w.method) + } + return methods +} + +func newWorkspaceAssignmentsMockClient(t *testing.T, m *workspaceAssignmentsMock) *Client { + t.Helper() + + listPath := fmt.Sprintf(accountWorkspaceAssignmentsEndpoint, testAccountId, testWorkspaceId) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + defer m.mu.Unlock() + switch { + case r.Method == http.MethodGet && r.URL.Path == listPath: + m.listGets = append(m.listGets, r.Header.Clone()) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(struct { + Assignments []WorkspaceAssignment `json:"permission_assignments"` + }{Assignments: m.assignments}) + case r.Method == http.MethodPut && r.URL.Path == testPrincipalPath: + var reqBody map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&reqBody) + m.writes = append(m.writes, recordedRequest{method: r.Method, path: r.URL.Path, header: r.Header.Clone(), body: reqBody}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + case r.Method == http.MethodDelete && r.URL.Path == testPrincipalPath: + m.writes = append(m.writes, recordedRequest{method: r.Method, path: r.URL.Path, header: r.Header.Clone()}) + if m.deleteStatus != 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(m.deleteStatus) + _, _ = w.Write([]byte(`{}`)) + return + } + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })) + t.Cleanup(srv.Close) + + target, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + httpClient := &http.Client{Transport: &workspaceMembersHostRewriter{target: target}} + c, err := NewClient(context.Background(), httpClient, testHostnameForWorkspaceMembers, testAccountHostForWorkspaceMembers, testAccountId, "", &NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + return c +} + +// testAssignment returns a fresh assignment for the target principal (ID 100). +func testAssignment(permissions ...string) WorkspaceAssignment { + return WorkspaceAssignment{ + Principal: &WorkspacePrincipal{ID: 100, UserName: "user@example.com"}, + Permissions: permissions, + } +} + +// testUserAssignment returns a fresh list holding a USER assignment for the target principal. +func testUserAssignment() []WorkspaceAssignment { + return []WorkspaceAssignment{testAssignment("USER")} +} + +// otherPrincipalAssignmentWithError is an unreadable entry for a principal other than the target. +func otherPrincipalAssignmentWithError() WorkspaceAssignment { + return WorkspaceAssignment{ + Principal: &WorkspacePrincipal{ID: 200, UserName: "other@example.com"}, + Error: "failed to resolve principal 200", + } +} + +func permissionsFromBody(t *testing.T, body map[string]interface{}) []string { + t.Helper() + raw, ok := body["permissions"] + if !ok { + t.Fatalf("request body has no \"permissions\" field: %v", body) + } + rawSlice, ok := raw.([]interface{}) + if !ok { + t.Fatalf("\"permissions\" field is not an array: %v", raw) + } + out := make([]string, 0, len(rawSlice)) + for _, v := range rawSlice { + s, ok := v.(string) + if !ok { + t.Fatalf("permissions entry is not a string: %v", v) + } + out = append(out, s) + } + return out +} + +// assertNoRequests fails if the mock saw any read or write. +func assertNoRequests(t *testing.T, m *workspaceAssignmentsMock) { + t.Helper() + listGets, writes := m.snapshot() + if len(listGets) != 0 || len(writes) != 0 { + t.Fatalf("expected no requests, got %d list GETs and %d writes: %+v", len(listGets), len(writes), writes) + } +} + +// assertNoWrites fails if the mock saw any PUT or DELETE. +func assertNoWrites(t *testing.T, m *workspaceAssignmentsMock) { + t.Helper() + if _, writes := m.snapshot(); len(writes) != 0 { + t.Fatalf("expected no write requests, got %d: %+v", len(writes), writes) + } +} + +// TestAddWorkspaceMember_NoExistingAssignment: a principal with no +// assignment at all gets exactly one PUT with [USER]. +func TestAddWorkspaceMember_NoExistingAssignment(t *testing.T) { + m := &workspaceAssignmentsMock{} + c := newWorkspaceAssignmentsMockClient(t, m) + + alreadyGranted, _, err := c.AddWorkspaceMember(context.Background(), testWorkspaceId, testPrincipalId) + if err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + if alreadyGranted { + t.Errorf("alreadyGranted = true, want false (no prior assignment)") + } + + _, writes := m.snapshot() + if len(writes) != 1 { + t.Fatalf("expected exactly 1 write request, got %d: %+v", len(writes), writes) + } + got := writes[0] + if got.method != http.MethodPut { + t.Errorf("method = %s, want PUT", got.method) + } + if got.path != testPrincipalPath { + t.Errorf("path = %s, want %s", got.path, testPrincipalPath) + } + permissions := permissionsFromBody(t, got.body) + if len(permissions) != 1 || permissions[0] != "USER" { + t.Errorf("permissions sent = %v, want exactly [USER]", permissions) + } +} + +// TestAddWorkspaceMember_ExistingAssignment: any existing +// assignment already grants workspace access, so nothing is written and an +// existing ADMIN is left untouched. +func TestAddWorkspaceMember_ExistingAssignment(t *testing.T) { + tests := []struct { + name string + permissions []string + }{ + {"user only", []string{"USER"}}, + {"admin only", []string{"ADMIN"}}, + {"user and admin", []string{"USER", "ADMIN"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &workspaceAssignmentsMock{assignments: []WorkspaceAssignment{testAssignment(tt.permissions...)}} + c := newWorkspaceAssignmentsMockClient(t, m) + + alreadyGranted, _, err := c.AddWorkspaceMember(context.Background(), testWorkspaceId, testPrincipalId) + if err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + if !alreadyGranted { + t.Errorf("alreadyGranted = false, want true (principal already holds %v)", tt.permissions) + } + assertNoWrites(t, m) + }) + } +} + +// TestRemoveWorkspaceMember_ExistingAssignment: revoke deletes the whole +// assignment, whatever it holds. +func TestRemoveWorkspaceMember_ExistingAssignment(t *testing.T) { + tests := []struct { + name string + permissions []string + }{ + {"user only", []string{"USER"}}, + {"admin only", []string{"ADMIN"}}, + {"user and admin", []string{"USER", "ADMIN"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &workspaceAssignmentsMock{assignments: []WorkspaceAssignment{testAssignment(tt.permissions...)}} + c := newWorkspaceAssignmentsMockClient(t, m) + + alreadyRevoked, _, err := c.RemoveWorkspaceMember(context.Background(), testWorkspaceId, testPrincipalId) + if err != nil { + t.Fatalf("RemoveWorkspaceMember: %v", err) + } + if alreadyRevoked { + t.Errorf("alreadyRevoked = true, want false (a DELETE was needed)") + } + + _, writes := m.snapshot() + if len(writes) != 1 { + t.Fatalf("expected exactly 1 write request, got %d: %+v", len(writes), writes) + } + got := writes[0] + if got.method != http.MethodDelete { + t.Errorf("method = %s, want DELETE", got.method) + } + if got.path != testPrincipalPath { + t.Errorf("path = %s, want %s", got.path, testPrincipalPath) + } + }) + } +} + +// TestRemoveWorkspaceMember_AlreadyRevoked: a principal who holds nothing at +// all needs no request; the revoke reports GrantAlreadyRevoked upstream. +func TestRemoveWorkspaceMember_AlreadyRevoked(t *testing.T) { + m := &workspaceAssignmentsMock{} + c := newWorkspaceAssignmentsMockClient(t, m) + + alreadyRevoked, _, err := c.RemoveWorkspaceMember(context.Background(), testWorkspaceId, testPrincipalId) + if err != nil { + t.Fatalf("RemoveWorkspaceMember: %v", err) + } + if !alreadyRevoked { + t.Errorf("alreadyRevoked = false, want true (principal holds nothing)") + } + assertNoWrites(t, m) +} + +// TestWorkspaceMember_AssignmentError: an entry for the target principal that +// carries an error could not be read properly, so neither grant nor revoke may +// write on top of it. +func TestWorkspaceMember_AssignmentError(t *testing.T) { + errAssignment := func() []WorkspaceAssignment { + a := testAssignment("USER") + a.Error = "principal lookup failed" + return []WorkspaceAssignment{a} + } + tests := []struct { + name string + call func(c *Client) error + }{ + {"grant", func(c *Client) error { + _, _, err := c.AddWorkspaceMember(context.Background(), testWorkspaceId, testPrincipalId) + return err + }}, + {"revoke", func(c *Client) error { + _, _, err := c.RemoveWorkspaceMember(context.Background(), testWorkspaceId, testPrincipalId) + return err + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &workspaceAssignmentsMock{assignments: errAssignment()} + c := newWorkspaceAssignmentsMockClient(t, m) + + err := tt.call(c) + if err == nil { + t.Fatal("expected error, got nil") + } + for _, want := range []string{testWorkspaceId, testPrincipalId, "principal lookup failed"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } + assertNoWrites(t, m) + }) + } +} + +// TestWorkspaceMember_OtherPrincipalAssignmentErrorDoesNotBlock: an error on +// another principal's entry must not affect the target. +func TestWorkspaceMember_OtherPrincipalAssignmentErrorDoesNotBlock(t *testing.T) { + t.Run("grant", func(t *testing.T) { + m := &workspaceAssignmentsMock{assignments: []WorkspaceAssignment{otherPrincipalAssignmentWithError()}} + c := newWorkspaceAssignmentsMockClient(t, m) + + alreadyGranted, _, err := c.AddWorkspaceMember(context.Background(), testWorkspaceId, testPrincipalId) + if err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + if alreadyGranted { + t.Errorf("alreadyGranted = true, want false") + } + if got := m.writeMethods(); len(got) != 1 || got[0] != http.MethodPut { + t.Errorf("writes = %v, want [PUT]", got) + } + }) + t.Run("revoke", func(t *testing.T) { + m := &workspaceAssignmentsMock{assignments: append(testUserAssignment(), otherPrincipalAssignmentWithError())} + c := newWorkspaceAssignmentsMockClient(t, m) + + alreadyRevoked, _, err := c.RemoveWorkspaceMember(context.Background(), testWorkspaceId, testPrincipalId) + if err != nil { + t.Fatalf("RemoveWorkspaceMember: %v", err) + } + if alreadyRevoked { + t.Errorf("alreadyRevoked = true, want false") + } + if got := m.writeMethods(); len(got) != 1 || got[0] != http.MethodDelete { + t.Errorf("writes = %v, want [DELETE]", got) + } + }) +} + +// TestWorkspaceMember_ReadsAreNotCached: grant then revoke on the same client +// must each see the live assignment list, not a cached one, and each +// read-before-write GET must carry Cache-Control: no-cache. +func TestWorkspaceMember_ReadsAreNotCached(t *testing.T) { + m := &workspaceAssignmentsMock{} + c := newWorkspaceAssignmentsMockClient(t, m) + + alreadyGranted, _, err := c.AddWorkspaceMember(context.Background(), testWorkspaceId, testPrincipalId) + if err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + if alreadyGranted { + t.Errorf("alreadyGranted = true, want false (empty list)") + } + + m.setAssignments(testUserAssignment()) + + alreadyRevoked, _, err := c.RemoveWorkspaceMember(context.Background(), testWorkspaceId, testPrincipalId) + if err != nil { + t.Fatalf("RemoveWorkspaceMember: %v", err) + } + if alreadyRevoked { + t.Errorf("alreadyRevoked = true, want false (stale cached list)") + } + + listGets, _ := m.snapshot() + if len(listGets) != 2 { + t.Fatalf("list GETs = %d, want 2", len(listGets)) + } + for i, h := range listGets { + if got := h.Get("Cache-Control"); got != "no-cache" { + t.Errorf("list GET %d Cache-Control = %q, want \"no-cache\"", i, got) + } + } + if got := m.writeMethods(); len(got) != 2 || got[0] != http.MethodPut || got[1] != http.MethodDelete { + t.Errorf("writes = %v, want [PUT DELETE]", got) + } +} + +// TestRemoveWorkspaceMember_DeleteNotFound: an assignment removed between the +// read and the DELETE is already revoked, not an error. +func TestRemoveWorkspaceMember_DeleteNotFound(t *testing.T) { + m := &workspaceAssignmentsMock{assignments: testUserAssignment(), deleteStatus: http.StatusNotFound} + c := newWorkspaceAssignmentsMockClient(t, m) + + alreadyRevoked, _, err := c.RemoveWorkspaceMember(context.Background(), testWorkspaceId, testPrincipalId) + if err != nil { + t.Fatalf("RemoveWorkspaceMember: %v", err) + } + if !alreadyRevoked { + t.Errorf("alreadyRevoked = false, want true (DELETE returned 404)") + } + if got := m.writeMethods(); len(got) != 1 || got[0] != http.MethodDelete { + t.Errorf("writes = %v, want exactly [DELETE]", got) + } +} + +// TestRemoveWorkspaceMember_DeleteServerError: any other DELETE failure is +// returned, not swallowed. +func TestRemoveWorkspaceMember_DeleteServerError(t *testing.T) { + m := &workspaceAssignmentsMock{assignments: testUserAssignment(), deleteStatus: http.StatusInternalServerError} + c := newWorkspaceAssignmentsMockClient(t, m) + + alreadyRevoked, _, err := c.RemoveWorkspaceMember(context.Background(), testWorkspaceId, testPrincipalId) + if err == nil { + t.Fatal("RemoveWorkspaceMember: expected error, got nil") + } + if alreadyRevoked { + t.Errorf("alreadyRevoked = true, want false (DELETE failed)") + } +} + +// TestWorkspaceMember_NoCacheReadDoesNotOverwriteSyncCache: a read-before-write +// must see live state without replacing the cached entry that sync's +// ListWorkspaceMembers reads from. +func TestWorkspaceMember_NoCacheReadDoesNotOverwriteSyncCache(t *testing.T) { + m := &workspaceAssignmentsMock{} + c := newWorkspaceAssignmentsMockClient(t, m) + ctx := context.Background() + + // State A (empty), cached by sync. + first, _, err := c.ListWorkspaceMembers(ctx, testWorkspaceId) + if err != nil { + t.Fatalf("ListWorkspaceMembers: %v", err) + } + if len(first) != 0 { + t.Fatalf("first ListWorkspaceMembers = %v, want empty", first) + } + + // State B: the no-cache read must see it. + m.setAssignments(testUserAssignment()) + alreadyGranted, _, err := c.AddWorkspaceMember(ctx, testWorkspaceId, testPrincipalId) + if err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + if !alreadyGranted { + t.Errorf("alreadyGranted = false, want true (read-before-write saw stale state)") + } + + // Sync's cached entry must still be state A, served without a new GET. + second, _, err := c.ListWorkspaceMembers(ctx, testWorkspaceId) + if err != nil { + t.Fatalf("ListWorkspaceMembers: %v", err) + } + if len(second) != 0 { + t.Errorf("second ListWorkspaceMembers = %v, want cached empty list (cache entry overwritten)", second) + } + + listGets, writes := m.snapshot() + if len(listGets) != 2 { + t.Errorf("list GETs = %d, want 2 (cached sync read, no-cache read)", len(listGets)) + } + if len(writes) != 0 { + t.Errorf("writes = %v, want none", writes) + } +} + +// TestWorkspaceMember_RejectsCompositePrincipalID: a group's composite +// resource ID must never reach the permission-assignments API, on grant or +// revoke (so it can't be silently read as "already revoked"). +func TestWorkspaceMember_RejectsCompositePrincipalID(t *testing.T) { + tests := []struct { + name string + call func(c *Client) error + }{ + {"grant", func(c *Client) error { + _, _, err := c.AddWorkspaceMember(context.Background(), testWorkspaceId, "account/abc/group/100") + return err + }}, + {"revoke", func(c *Client) error { + _, _, err := c.RemoveWorkspaceMember(context.Background(), testWorkspaceId, "account/abc/group/100") + return err + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &workspaceAssignmentsMock{} + c := newWorkspaceAssignmentsMockClient(t, m) + + err := tt.call(c) + if err == nil { + t.Fatal("expected error, got nil") + } + if code := status.Code(err); code != codes.InvalidArgument { + t.Errorf("status code = %v, want InvalidArgument", code) + } + assertNoRequests(t, m) + }) + } +} diff --git a/vendor/github.com/conductorone/baton-sdk/internal/connector/connector.go b/vendor/github.com/conductorone/baton-sdk/internal/connector/connector.go index 04170efd..8be50089 100644 --- a/vendor/github.com/conductorone/baton-sdk/internal/connector/connector.go +++ b/vendor/github.com/conductorone/baton-sdk/internal/connector/connector.go @@ -54,37 +54,24 @@ type connectorClient struct { connectorV2.TicketsServiceClient connectorV2.ActionServiceClient - sessionStoreSetter sessions.SetSessionStore // this is the session store server + sessionServer sessions.SessionStoreBinder } -var _ sessions.SetSessionStore = (*connectorClient)(nil) -var _ SetSessionStoreSetter = (*connectorClient)(nil) +var _ sessions.SessionStoreBinder = (*connectorClient)(nil) -type SetSessionStoreSetter interface { - SetSessionStoreSetter(setsessionStoreSetter sessions.SetSessionStore) +func (c *connectorClient) SetSessionServer(sessionServer sessions.SessionStoreBinder) { + c.sessionServer = sessionServer } -func (c *connectorClient) SetSessionStoreSetter(sessionStoreSetter sessions.SetSessionStore) { - c.sessionStoreSetter = sessionStoreSetter -} - -func (c *connectorClient) SetSessionStore(ctx context.Context, store sessions.SessionStore) { - if c.sessionStoreSetter == nil { - // Demoted from Warn to Debug: this path is the normal case for - // any connector that didn't opt into session storage (i.e., - // wrapper.run never set cw.SessionServer to non-nil, so - // SetSessionStoreSetter received nil at startup). The syncer - // still calls SetSessionStore unconditionally on every sync, so - // at Warn level this fires once per sync per non-session-store - // connector — pure noise that masked real warnings downstream. - // Debug keeps the "you forgot to wire this" signal available for - // developers running at debug level without polluting prod logs. - // See https://github.com/ConductorOne/baton-sdk/issues/907. - l := ctxzap.Extract(ctx) - l.Debug("connectorClient's session store is nil — connector did not opt into session storage") - return - } - c.sessionStoreSetter.SetSessionStore(ctx, store) +func (c *connectorClient) BindSessionStore(ctx context.Context, syncID string, store sessions.SessionStore) (sessions.UnbindSessionStore, error) { + if c.sessionServer == nil { + // Debug, not Warn: every sync of a connector that did not opt into + // session storage reaches this. See + // https://github.com/ConductorOne/baton-sdk/issues/907. + ctxzap.Extract(ctx).Debug("connector client has no session server — connector did not opt into session storage") + return func(context.Context) error { return nil }, nil + } + return c.sessionServer.BindSessionStore(ctx, syncID, store) } var ErrConnectorNotImplemented = errors.New("client does not implement connector connectorV2") @@ -109,7 +96,7 @@ type wrapper struct { now func() time.Time - SessionServer sessions.SetSessionStore + SessionServer sessions.SessionStoreBinder } type Option func(ctx context.Context, w *wrapper) error @@ -439,7 +426,7 @@ func (cw *wrapper) C(ctx context.Context) (types.ConnectorClient, error) { cw.conn = conn client := NewConnectorClient(ctx, cw.conn) - client.SetSessionStoreSetter(cw.SessionServer) + client.SetSessionServer(cw.SessionServer) cw.client = client return client, nil diff --git a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/event_feed.pb.go b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/event_feed.pb.go index 96d5646d..0c81e77a 100644 --- a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/event_feed.pb.go +++ b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/event_feed.pb.go @@ -75,6 +75,56 @@ func (x EventType) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } +type OAuthUsage_Action int32 + +const ( + OAuthUsage_ACTION_UNSPECIFIED OAuthUsage_Action = 0 + OAuthUsage_ACTION_AUTHORIZE OAuthUsage_Action = 1 + OAuthUsage_ACTION_REVOKE OAuthUsage_Action = 2 + OAuthUsage_ACTION_DENY OAuthUsage_Action = 3 + OAuthUsage_ACTION_REQUEST OAuthUsage_Action = 4 +) + +// Enum value maps for OAuthUsage_Action. +var ( + OAuthUsage_Action_name = map[int32]string{ + 0: "ACTION_UNSPECIFIED", + 1: "ACTION_AUTHORIZE", + 2: "ACTION_REVOKE", + 3: "ACTION_DENY", + 4: "ACTION_REQUEST", + } + OAuthUsage_Action_value = map[string]int32{ + "ACTION_UNSPECIFIED": 0, + "ACTION_AUTHORIZE": 1, + "ACTION_REVOKE": 2, + "ACTION_DENY": 3, + "ACTION_REQUEST": 4, + } +) + +func (x OAuthUsage_Action) Enum() *OAuthUsage_Action { + p := new(OAuthUsage_Action) + *p = x + return p +} + +func (x OAuthUsage_Action) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (OAuthUsage_Action) Descriptor() protoreflect.EnumDescriptor { + return file_c1_connector_v2_event_feed_proto_enumTypes[1].Descriptor() +} + +func (OAuthUsage_Action) Type() protoreflect.EnumType { + return &file_c1_connector_v2_event_feed_proto_enumTypes[1] +} + +func (x OAuthUsage_Action) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + type ListEventsRequest struct { state protoimpl.MessageState `protogen:"hybrid.v1"` // Can function like a page token but also can be arbitrary to resume a feed at any point @@ -859,8 +909,12 @@ type UsageEvent struct { state protoimpl.MessageState `protogen:"hybrid.v1"` TargetResource *Resource `protobuf:"bytes,1,opt,name=target_resource,json=targetResource,proto3" json:"target_resource,omitempty"` ActorResource *Resource `protobuf:"bytes,2,opt,name=actor_resource,json=actorResource,proto3" json:"actor_resource,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Network origin of the actor. + Network *NetworkOrigin `protobuf:"bytes,3,opt,name=network,proto3" json:"network,omitempty"` + // Set when the event is OAuth consent activity. + Oauth *OAuthUsage `protobuf:"bytes,4,opt,name=oauth,proto3" json:"oauth,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UsageEvent) Reset() { @@ -902,6 +956,20 @@ func (x *UsageEvent) GetActorResource() *Resource { return nil } +func (x *UsageEvent) GetNetwork() *NetworkOrigin { + if x != nil { + return x.Network + } + return nil +} + +func (x *UsageEvent) GetOauth() *OAuthUsage { + if x != nil { + return x.Oauth + } + return nil +} + func (x *UsageEvent) SetTargetResource(v *Resource) { x.TargetResource = v } @@ -910,6 +978,14 @@ func (x *UsageEvent) SetActorResource(v *Resource) { x.ActorResource = v } +func (x *UsageEvent) SetNetwork(v *NetworkOrigin) { + x.Network = v +} + +func (x *UsageEvent) SetOauth(v *OAuthUsage) { + x.Oauth = v +} + func (x *UsageEvent) HasTargetResource() bool { if x == nil { return false @@ -924,6 +1000,20 @@ func (x *UsageEvent) HasActorResource() bool { return x.ActorResource != nil } +func (x *UsageEvent) HasNetwork() bool { + if x == nil { + return false + } + return x.Network != nil +} + +func (x *UsageEvent) HasOauth() bool { + if x == nil { + return false + } + return x.Oauth != nil +} + func (x *UsageEvent) ClearTargetResource() { x.TargetResource = nil } @@ -932,11 +1022,23 @@ func (x *UsageEvent) ClearActorResource() { x.ActorResource = nil } +func (x *UsageEvent) ClearNetwork() { + x.Network = nil +} + +func (x *UsageEvent) ClearOauth() { + x.Oauth = nil +} + type UsageEvent_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. TargetResource *Resource ActorResource *Resource + // Network origin of the actor. + Network *NetworkOrigin + // Set when the event is OAuth consent activity. + Oauth *OAuthUsage } func (b0 UsageEvent_builder) Build() *UsageEvent { @@ -945,6 +1047,226 @@ func (b0 UsageEvent_builder) Build() *UsageEvent { _, _ = b, x x.TargetResource = b.TargetResource x.ActorResource = b.ActorResource + x.Network = b.Network + x.Oauth = b.Oauth + return m0 +} + +// Producers leave unset any value that fails its validate rule rather than +// copying it from the source: Event validation rejects the whole event, not +// the one value. +type NetworkOrigin struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + IpAddress string `protobuf:"bytes,1,opt,name=ip_address,json=ipAddress,proto3" json:"ip_address,omitempty"` + // ISO 3166-1 alpha-2, e.g. "US". + CountryCode string `protobuf:"bytes,2,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"` + // ISO 3166-2, e.g. "US-OR". + SubdivisionCode string `protobuf:"bytes,3,opt,name=subdivision_code,json=subdivisionCode,proto3" json:"subdivision_code,omitempty"` + // Autonomous system numbers the source attributes to ip_address. + Asns []uint32 `protobuf:"varint,4,rep,packed,name=asns,proto3" json:"asns,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkOrigin) Reset() { + *x = NetworkOrigin{} + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkOrigin) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkOrigin) ProtoMessage() {} + +func (x *NetworkOrigin) ProtoReflect() protoreflect.Message { + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *NetworkOrigin) GetIpAddress() string { + if x != nil { + return x.IpAddress + } + return "" +} + +func (x *NetworkOrigin) GetCountryCode() string { + if x != nil { + return x.CountryCode + } + return "" +} + +func (x *NetworkOrigin) GetSubdivisionCode() string { + if x != nil { + return x.SubdivisionCode + } + return "" +} + +func (x *NetworkOrigin) GetAsns() []uint32 { + if x != nil { + return x.Asns + } + return nil +} + +func (x *NetworkOrigin) SetIpAddress(v string) { + x.IpAddress = v +} + +func (x *NetworkOrigin) SetCountryCode(v string) { + x.CountryCode = v +} + +func (x *NetworkOrigin) SetSubdivisionCode(v string) { + x.SubdivisionCode = v +} + +func (x *NetworkOrigin) SetAsns(v []uint32) { + x.Asns = v +} + +type NetworkOrigin_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + IpAddress string + // ISO 3166-1 alpha-2, e.g. "US". + CountryCode string + // ISO 3166-2, e.g. "US-OR". + SubdivisionCode string + // Autonomous system numbers the source attributes to ip_address. + Asns []uint32 +} + +func (b0 NetworkOrigin_builder) Build() *NetworkOrigin { + m0 := &NetworkOrigin{} + b, x := &b0, m0 + _, _ = b, x + x.IpAddress = b.IpAddress + x.CountryCode = b.CountryCode + x.SubdivisionCode = b.SubdivisionCode + x.Asns = b.Asns + return m0 +} + +// OAuth consent activity: a user authorizes, revokes, is denied, or requests +// an application's access to the user's data. +// Producers leave unset any value that fails its validate rule rather than +// copying it from the source: Event validation rejects the whole event, not +// the one value. +type OAuthUsage struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + // ACTION_UNSPECIFIED when source_event_name maps to no Action. + Action OAuthUsage_Action `protobuf:"varint,1,opt,name=action,proto3,enum=c1.connector.v2.OAuthUsage_Action" json:"action,omitempty"` + SourceEventName string `protobuf:"bytes,2,opt,name=source_event_name,json=sourceEventName,proto3" json:"source_event_name,omitempty"` + // Scopes the action applies to, as the source names them. + Scopes []string `protobuf:"bytes,3,rep,name=scopes,proto3" json:"scopes,omitempty"` + // The source's client classification, as the source names it. + ClientType string `protobuf:"bytes,4,opt,name=client_type,json=clientType,proto3" json:"client_type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OAuthUsage) Reset() { + *x = OAuthUsage{} + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OAuthUsage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OAuthUsage) ProtoMessage() {} + +func (x *OAuthUsage) ProtoReflect() protoreflect.Message { + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *OAuthUsage) GetAction() OAuthUsage_Action { + if x != nil { + return x.Action + } + return OAuthUsage_ACTION_UNSPECIFIED +} + +func (x *OAuthUsage) GetSourceEventName() string { + if x != nil { + return x.SourceEventName + } + return "" +} + +func (x *OAuthUsage) GetScopes() []string { + if x != nil { + return x.Scopes + } + return nil +} + +func (x *OAuthUsage) GetClientType() string { + if x != nil { + return x.ClientType + } + return "" +} + +func (x *OAuthUsage) SetAction(v OAuthUsage_Action) { + x.Action = v +} + +func (x *OAuthUsage) SetSourceEventName(v string) { + x.SourceEventName = v +} + +func (x *OAuthUsage) SetScopes(v []string) { + x.Scopes = v +} + +func (x *OAuthUsage) SetClientType(v string) { + x.ClientType = v +} + +type OAuthUsage_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + // ACTION_UNSPECIFIED when source_event_name maps to no Action. + Action OAuthUsage_Action + SourceEventName string + // Scopes the action applies to, as the source names them. + Scopes []string + // The source's client classification, as the source names it. + ClientType string +} + +func (b0 OAuthUsage_builder) Build() *OAuthUsage { + m0 := &OAuthUsage{} + b, x := &b0, m0 + _, _ = b, x + x.Action = b.Action + x.SourceEventName = b.SourceEventName + x.Scopes = b.Scopes + x.ClientType = b.ClientType return m0 } @@ -958,7 +1280,7 @@ type GrantEvent struct { func (x *GrantEvent) Reset() { *x = GrantEvent{} - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[6] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -970,7 +1292,7 @@ func (x *GrantEvent) String() string { func (*GrantEvent) ProtoMessage() {} func (x *GrantEvent) ProtoReflect() protoreflect.Message { - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[6] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1028,7 +1350,7 @@ type CreateGrantEvent struct { func (x *CreateGrantEvent) Reset() { *x = CreateGrantEvent{} - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[7] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1040,7 +1362,7 @@ func (x *CreateGrantEvent) String() string { func (*CreateGrantEvent) ProtoMessage() {} func (x *CreateGrantEvent) ProtoReflect() protoreflect.Message { - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[7] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1135,7 +1457,7 @@ type CreateRevokeEvent struct { func (x *CreateRevokeEvent) Reset() { *x = CreateRevokeEvent{} - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[8] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1147,7 +1469,7 @@ func (x *CreateRevokeEvent) String() string { func (*CreateRevokeEvent) ProtoMessage() {} func (x *CreateRevokeEvent) ProtoReflect() protoreflect.Message { - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[8] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1241,7 +1563,7 @@ type RevokeEvent struct { func (x *RevokeEvent) Reset() { *x = RevokeEvent{} - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[9] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1253,7 +1575,7 @@ func (x *RevokeEvent) String() string { func (*RevokeEvent) ProtoMessage() {} func (x *RevokeEvent) ProtoReflect() protoreflect.Message { - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[9] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1335,7 +1657,7 @@ type ResourceChangeEvent struct { func (x *ResourceChangeEvent) Reset() { *x = ResourceChangeEvent{} - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[10] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1347,7 +1669,7 @@ func (x *ResourceChangeEvent) String() string { func (*ResourceChangeEvent) ProtoMessage() {} func (x *ResourceChangeEvent) ProtoReflect() protoreflect.Message { - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[10] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1429,7 +1751,7 @@ type EventFeedMetadata struct { func (x *EventFeedMetadata) Reset() { *x = EventFeedMetadata{} - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[11] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1441,7 +1763,7 @@ func (x *EventFeedMetadata) String() string { func (*EventFeedMetadata) ProtoMessage() {} func (x *EventFeedMetadata) ProtoReflect() protoreflect.Message { - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[11] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1529,11 +1851,34 @@ const file_c1_connector_v2_event_feed_proto_rawDesc = "" + "\x12create_grant_event\x18h \x01(\v2!.c1.connector.v2.CreateGrantEventH\x00R\x10createGrantEvent\x12T\n" + "\x13create_revoke_event\x18i \x01(\v2\".c1.connector.v2.CreateRevokeEventH\x00R\x11createRevokeEvent\x126\n" + "\vannotations\x18\x03 \x03(\v2\x14.google.protobuf.AnyR\vannotationsB\a\n" + - "\x05event\"\x92\x01\n" + + "\x05event\"\xff\x01\n" + "\n" + "UsageEvent\x12B\n" + "\x0ftarget_resource\x18\x01 \x01(\v2\x19.c1.connector.v2.ResourceR\x0etargetResource\x12@\n" + - "\x0eactor_resource\x18\x02 \x01(\v2\x19.c1.connector.v2.ResourceR\ractorResource\":\n" + + "\x0eactor_resource\x18\x02 \x01(\v2\x19.c1.connector.v2.ResourceR\ractorResource\x128\n" + + "\anetwork\x18\x03 \x01(\v2\x1e.c1.connector.v2.NetworkOriginR\anetwork\x121\n" + + "\x05oauth\x18\x04 \x01(\v2\x1b.c1.connector.v2.OAuthUsageR\x05oauth\"\xe0\x01\n" + + "\rNetworkOrigin\x12)\n" + + "\n" + + "ip_address\x18\x01 \x01(\tB\n" + + "\xfaB\ar\x05\xd0\x01\x01p\x01R\tipAddress\x127\n" + + "\fcountry_code\x18\x02 \x01(\tB\x14\xfaB\x11r\x0f2\n" + + "^[A-Z]{2}$\xd0\x01\x01R\vcountryCode\x12M\n" + + "\x10subdivision_code\x18\x03 \x01(\tB\"\xfaB\x1fr\x1d2\x18^[A-Z]{2}-[A-Z0-9]{1,3}$\xd0\x01\x01R\x0fsubdivisionCode\x12\x1c\n" + + "\x04asns\x18\x04 \x03(\rB\b\xfaB\x05\x92\x01\x02\x10@R\x04asns\"\xcb\x02\n" + + "\n" + + "OAuthUsage\x12:\n" + + "\x06action\x18\x01 \x01(\x0e2\".c1.connector.v2.OAuthUsage.ActionR\x06action\x127\n" + + "\x11source_event_name\x18\x02 \x01(\tB\v\xfaB\br\x06(\x80\x02\xd0\x01\x01R\x0fsourceEventName\x12*\n" + + "\x06scopes\x18\x03 \x03(\tB\x12\xfaB\x0f\x92\x01\f\x10\x80\x02\"\ar\x05 \x01(\x80\x04R\x06scopes\x12,\n" + + "\vclient_type\x18\x04 \x01(\tB\v\xfaB\br\x06(\x80\x02\xd0\x01\x01R\n" + + "clientType\"n\n" + + "\x06Action\x12\x16\n" + + "\x12ACTION_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10ACTION_AUTHORIZE\x10\x01\x12\x11\n" + + "\rACTION_REVOKE\x10\x02\x12\x0f\n" + + "\vACTION_DENY\x10\x03\x12\x12\n" + + "\x0eACTION_REQUEST\x10\x04\":\n" + "\n" + "GrantEvent\x12,\n" + "\x05grant\x18\x01 \x01(\v2\x16.c1.connector.v2.GrantR\x05grant\"\xd7\x01\n" + @@ -1567,68 +1912,74 @@ const file_c1_connector_v2_event_feed_proto_rawDesc = "" + "ListEvents\x12\".c1.connector.v2.ListEventsRequest\x1a#.c1.connector.v2.ListEventsResponse\x12a\n" + "\x0eListEventFeeds\x12&.c1.connector.v2.ListEventFeedsRequest\x1a'.c1.connector.v2.ListEventFeedsResponseB6Z4github.com/conductorone/baton-sdk/pb/c1/connector/v2b\x06proto3" -var file_c1_connector_v2_event_feed_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_c1_connector_v2_event_feed_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_c1_connector_v2_event_feed_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_c1_connector_v2_event_feed_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_c1_connector_v2_event_feed_proto_goTypes = []any{ (EventType)(0), // 0: c1.connector.v2.EventType - (*ListEventsRequest)(nil), // 1: c1.connector.v2.ListEventsRequest - (*ListEventsResponse)(nil), // 2: c1.connector.v2.ListEventsResponse - (*ListEventFeedsRequest)(nil), // 3: c1.connector.v2.ListEventFeedsRequest - (*ListEventFeedsResponse)(nil), // 4: c1.connector.v2.ListEventFeedsResponse - (*Event)(nil), // 5: c1.connector.v2.Event - (*UsageEvent)(nil), // 6: c1.connector.v2.UsageEvent - (*GrantEvent)(nil), // 7: c1.connector.v2.GrantEvent - (*CreateGrantEvent)(nil), // 8: c1.connector.v2.CreateGrantEvent - (*CreateRevokeEvent)(nil), // 9: c1.connector.v2.CreateRevokeEvent - (*RevokeEvent)(nil), // 10: c1.connector.v2.RevokeEvent - (*ResourceChangeEvent)(nil), // 11: c1.connector.v2.ResourceChangeEvent - (*EventFeedMetadata)(nil), // 12: c1.connector.v2.EventFeedMetadata - (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp - (*anypb.Any)(nil), // 14: google.protobuf.Any - (*Resource)(nil), // 15: c1.connector.v2.Resource - (*Grant)(nil), // 16: c1.connector.v2.Grant - (*Entitlement)(nil), // 17: c1.connector.v2.Entitlement - (*ResourceId)(nil), // 18: c1.connector.v2.ResourceId + (OAuthUsage_Action)(0), // 1: c1.connector.v2.OAuthUsage.Action + (*ListEventsRequest)(nil), // 2: c1.connector.v2.ListEventsRequest + (*ListEventsResponse)(nil), // 3: c1.connector.v2.ListEventsResponse + (*ListEventFeedsRequest)(nil), // 4: c1.connector.v2.ListEventFeedsRequest + (*ListEventFeedsResponse)(nil), // 5: c1.connector.v2.ListEventFeedsResponse + (*Event)(nil), // 6: c1.connector.v2.Event + (*UsageEvent)(nil), // 7: c1.connector.v2.UsageEvent + (*NetworkOrigin)(nil), // 8: c1.connector.v2.NetworkOrigin + (*OAuthUsage)(nil), // 9: c1.connector.v2.OAuthUsage + (*GrantEvent)(nil), // 10: c1.connector.v2.GrantEvent + (*CreateGrantEvent)(nil), // 11: c1.connector.v2.CreateGrantEvent + (*CreateRevokeEvent)(nil), // 12: c1.connector.v2.CreateRevokeEvent + (*RevokeEvent)(nil), // 13: c1.connector.v2.RevokeEvent + (*ResourceChangeEvent)(nil), // 14: c1.connector.v2.ResourceChangeEvent + (*EventFeedMetadata)(nil), // 15: c1.connector.v2.EventFeedMetadata + (*timestamppb.Timestamp)(nil), // 16: google.protobuf.Timestamp + (*anypb.Any)(nil), // 17: google.protobuf.Any + (*Resource)(nil), // 18: c1.connector.v2.Resource + (*Grant)(nil), // 19: c1.connector.v2.Grant + (*Entitlement)(nil), // 20: c1.connector.v2.Entitlement + (*ResourceId)(nil), // 21: c1.connector.v2.ResourceId } var file_c1_connector_v2_event_feed_proto_depIdxs = []int32{ - 13, // 0: c1.connector.v2.ListEventsRequest.start_at:type_name -> google.protobuf.Timestamp - 14, // 1: c1.connector.v2.ListEventsRequest.annotations:type_name -> google.protobuf.Any - 5, // 2: c1.connector.v2.ListEventsResponse.events:type_name -> c1.connector.v2.Event - 14, // 3: c1.connector.v2.ListEventsResponse.annotations:type_name -> google.protobuf.Any - 14, // 4: c1.connector.v2.ListEventFeedsRequest.annotations:type_name -> google.protobuf.Any - 12, // 5: c1.connector.v2.ListEventFeedsResponse.list:type_name -> c1.connector.v2.EventFeedMetadata - 14, // 6: c1.connector.v2.ListEventFeedsResponse.annotations:type_name -> google.protobuf.Any - 13, // 7: c1.connector.v2.Event.occurred_at:type_name -> google.protobuf.Timestamp - 6, // 8: c1.connector.v2.Event.usage_event:type_name -> c1.connector.v2.UsageEvent - 7, // 9: c1.connector.v2.Event.grant_event:type_name -> c1.connector.v2.GrantEvent - 10, // 10: c1.connector.v2.Event.revoke_event:type_name -> c1.connector.v2.RevokeEvent - 11, // 11: c1.connector.v2.Event.resource_change_event:type_name -> c1.connector.v2.ResourceChangeEvent - 8, // 12: c1.connector.v2.Event.create_grant_event:type_name -> c1.connector.v2.CreateGrantEvent - 9, // 13: c1.connector.v2.Event.create_revoke_event:type_name -> c1.connector.v2.CreateRevokeEvent - 14, // 14: c1.connector.v2.Event.annotations:type_name -> google.protobuf.Any - 15, // 15: c1.connector.v2.UsageEvent.target_resource:type_name -> c1.connector.v2.Resource - 15, // 16: c1.connector.v2.UsageEvent.actor_resource:type_name -> c1.connector.v2.Resource - 16, // 17: c1.connector.v2.GrantEvent.grant:type_name -> c1.connector.v2.Grant - 17, // 18: c1.connector.v2.CreateGrantEvent.entitlement:type_name -> c1.connector.v2.Entitlement - 15, // 19: c1.connector.v2.CreateGrantEvent.principal:type_name -> c1.connector.v2.Resource - 14, // 20: c1.connector.v2.CreateGrantEvent.annotations:type_name -> google.protobuf.Any - 17, // 21: c1.connector.v2.CreateRevokeEvent.entitlement:type_name -> c1.connector.v2.Entitlement - 15, // 22: c1.connector.v2.CreateRevokeEvent.principal:type_name -> c1.connector.v2.Resource - 14, // 23: c1.connector.v2.CreateRevokeEvent.annotations:type_name -> google.protobuf.Any - 17, // 24: c1.connector.v2.RevokeEvent.entitlement:type_name -> c1.connector.v2.Entitlement - 15, // 25: c1.connector.v2.RevokeEvent.principal:type_name -> c1.connector.v2.Resource - 18, // 26: c1.connector.v2.ResourceChangeEvent.resource_id:type_name -> c1.connector.v2.ResourceId - 18, // 27: c1.connector.v2.ResourceChangeEvent.parent_resource_id:type_name -> c1.connector.v2.ResourceId - 0, // 28: c1.connector.v2.EventFeedMetadata.supported_event_types:type_name -> c1.connector.v2.EventType - 1, // 29: c1.connector.v2.EventService.ListEvents:input_type -> c1.connector.v2.ListEventsRequest - 3, // 30: c1.connector.v2.EventService.ListEventFeeds:input_type -> c1.connector.v2.ListEventFeedsRequest - 2, // 31: c1.connector.v2.EventService.ListEvents:output_type -> c1.connector.v2.ListEventsResponse - 4, // 32: c1.connector.v2.EventService.ListEventFeeds:output_type -> c1.connector.v2.ListEventFeedsResponse - 31, // [31:33] is the sub-list for method output_type - 29, // [29:31] is the sub-list for method input_type - 29, // [29:29] is the sub-list for extension type_name - 29, // [29:29] is the sub-list for extension extendee - 0, // [0:29] is the sub-list for field type_name + 16, // 0: c1.connector.v2.ListEventsRequest.start_at:type_name -> google.protobuf.Timestamp + 17, // 1: c1.connector.v2.ListEventsRequest.annotations:type_name -> google.protobuf.Any + 6, // 2: c1.connector.v2.ListEventsResponse.events:type_name -> c1.connector.v2.Event + 17, // 3: c1.connector.v2.ListEventsResponse.annotations:type_name -> google.protobuf.Any + 17, // 4: c1.connector.v2.ListEventFeedsRequest.annotations:type_name -> google.protobuf.Any + 15, // 5: c1.connector.v2.ListEventFeedsResponse.list:type_name -> c1.connector.v2.EventFeedMetadata + 17, // 6: c1.connector.v2.ListEventFeedsResponse.annotations:type_name -> google.protobuf.Any + 16, // 7: c1.connector.v2.Event.occurred_at:type_name -> google.protobuf.Timestamp + 7, // 8: c1.connector.v2.Event.usage_event:type_name -> c1.connector.v2.UsageEvent + 10, // 9: c1.connector.v2.Event.grant_event:type_name -> c1.connector.v2.GrantEvent + 13, // 10: c1.connector.v2.Event.revoke_event:type_name -> c1.connector.v2.RevokeEvent + 14, // 11: c1.connector.v2.Event.resource_change_event:type_name -> c1.connector.v2.ResourceChangeEvent + 11, // 12: c1.connector.v2.Event.create_grant_event:type_name -> c1.connector.v2.CreateGrantEvent + 12, // 13: c1.connector.v2.Event.create_revoke_event:type_name -> c1.connector.v2.CreateRevokeEvent + 17, // 14: c1.connector.v2.Event.annotations:type_name -> google.protobuf.Any + 18, // 15: c1.connector.v2.UsageEvent.target_resource:type_name -> c1.connector.v2.Resource + 18, // 16: c1.connector.v2.UsageEvent.actor_resource:type_name -> c1.connector.v2.Resource + 8, // 17: c1.connector.v2.UsageEvent.network:type_name -> c1.connector.v2.NetworkOrigin + 9, // 18: c1.connector.v2.UsageEvent.oauth:type_name -> c1.connector.v2.OAuthUsage + 1, // 19: c1.connector.v2.OAuthUsage.action:type_name -> c1.connector.v2.OAuthUsage.Action + 19, // 20: c1.connector.v2.GrantEvent.grant:type_name -> c1.connector.v2.Grant + 20, // 21: c1.connector.v2.CreateGrantEvent.entitlement:type_name -> c1.connector.v2.Entitlement + 18, // 22: c1.connector.v2.CreateGrantEvent.principal:type_name -> c1.connector.v2.Resource + 17, // 23: c1.connector.v2.CreateGrantEvent.annotations:type_name -> google.protobuf.Any + 20, // 24: c1.connector.v2.CreateRevokeEvent.entitlement:type_name -> c1.connector.v2.Entitlement + 18, // 25: c1.connector.v2.CreateRevokeEvent.principal:type_name -> c1.connector.v2.Resource + 17, // 26: c1.connector.v2.CreateRevokeEvent.annotations:type_name -> google.protobuf.Any + 20, // 27: c1.connector.v2.RevokeEvent.entitlement:type_name -> c1.connector.v2.Entitlement + 18, // 28: c1.connector.v2.RevokeEvent.principal:type_name -> c1.connector.v2.Resource + 21, // 29: c1.connector.v2.ResourceChangeEvent.resource_id:type_name -> c1.connector.v2.ResourceId + 21, // 30: c1.connector.v2.ResourceChangeEvent.parent_resource_id:type_name -> c1.connector.v2.ResourceId + 0, // 31: c1.connector.v2.EventFeedMetadata.supported_event_types:type_name -> c1.connector.v2.EventType + 2, // 32: c1.connector.v2.EventService.ListEvents:input_type -> c1.connector.v2.ListEventsRequest + 4, // 33: c1.connector.v2.EventService.ListEventFeeds:input_type -> c1.connector.v2.ListEventFeedsRequest + 3, // 34: c1.connector.v2.EventService.ListEvents:output_type -> c1.connector.v2.ListEventsResponse + 5, // 35: c1.connector.v2.EventService.ListEventFeeds:output_type -> c1.connector.v2.ListEventFeedsResponse + 34, // [34:36] is the sub-list for method output_type + 32, // [32:34] is the sub-list for method input_type + 32, // [32:32] is the sub-list for extension type_name + 32, // [32:32] is the sub-list for extension extendee + 0, // [0:32] is the sub-list for field type_name } func init() { file_c1_connector_v2_event_feed_proto_init() } @@ -1652,8 +2003,8 @@ func file_c1_connector_v2_event_feed_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_c1_connector_v2_event_feed_proto_rawDesc), len(file_c1_connector_v2_event_feed_proto_rawDesc)), - NumEnums: 1, - NumMessages: 12, + NumEnums: 2, + NumMessages: 14, NumExtensions: 0, NumServices: 1, }, diff --git a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/event_feed.pb.validate.go b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/event_feed.pb.validate.go index 6a1cd646..14171039 100644 --- a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/event_feed.pb.validate.go +++ b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/event_feed.pb.validate.go @@ -1232,6 +1232,64 @@ func (m *UsageEvent) validate(all bool) error { } } + if all { + switch v := interface{}(m.GetNetwork()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, UsageEventValidationError{ + field: "Network", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, UsageEventValidationError{ + field: "Network", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetNetwork()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return UsageEventValidationError{ + field: "Network", + reason: "embedded message failed validation", + cause: err, + } + } + } + + if all { + switch v := interface{}(m.GetOauth()).(type) { + case interface{ ValidateAll() error }: + if err := v.ValidateAll(); err != nil { + errors = append(errors, UsageEventValidationError{ + field: "Oauth", + reason: "embedded message failed validation", + cause: err, + }) + } + case interface{ Validate() error }: + if err := v.Validate(); err != nil { + errors = append(errors, UsageEventValidationError{ + field: "Oauth", + reason: "embedded message failed validation", + cause: err, + }) + } + } + } else if v, ok := interface{}(m.GetOauth()).(interface{ Validate() error }); ok { + if err := v.Validate(); err != nil { + return UsageEventValidationError{ + field: "Oauth", + reason: "embedded message failed validation", + cause: err, + } + } + } + if len(errors) > 0 { return UsageEventMultiError(errors) } @@ -1309,6 +1367,324 @@ var _ interface { ErrorName() string } = UsageEventValidationError{} +// Validate checks the field values on NetworkOrigin with the rules defined in +// the proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *NetworkOrigin) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on NetworkOrigin with the rules defined +// in the proto definition for this message. If any rules are violated, the +// result is a list of violation errors wrapped in NetworkOriginMultiError, or +// nil if none found. +func (m *NetworkOrigin) ValidateAll() error { + return m.validate(true) +} + +func (m *NetworkOrigin) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + if m.GetIpAddress() != "" { + + if ip := net.ParseIP(m.GetIpAddress()); ip == nil { + err := NetworkOriginValidationError{ + field: "IpAddress", + reason: "value must be a valid IP address", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + + if m.GetCountryCode() != "" { + + if !_NetworkOrigin_CountryCode_Pattern.MatchString(m.GetCountryCode()) { + err := NetworkOriginValidationError{ + field: "CountryCode", + reason: "value does not match regex pattern \"^[A-Z]{2}$\"", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + + if m.GetSubdivisionCode() != "" { + + if !_NetworkOrigin_SubdivisionCode_Pattern.MatchString(m.GetSubdivisionCode()) { + err := NetworkOriginValidationError{ + field: "SubdivisionCode", + reason: "value does not match regex pattern \"^[A-Z]{2}-[A-Z0-9]{1,3}$\"", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + + if len(m.GetAsns()) > 64 { + err := NetworkOriginValidationError{ + field: "Asns", + reason: "value must contain no more than 64 item(s)", + } + if !all { + return err + } + errors = append(errors, err) + } + + if len(errors) > 0 { + return NetworkOriginMultiError(errors) + } + + return nil +} + +// NetworkOriginMultiError is an error wrapping multiple validation errors +// returned by NetworkOrigin.ValidateAll() if the designated constraints +// aren't met. +type NetworkOriginMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m NetworkOriginMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m NetworkOriginMultiError) AllErrors() []error { return m } + +// NetworkOriginValidationError is the validation error returned by +// NetworkOrigin.Validate if the designated constraints aren't met. +type NetworkOriginValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e NetworkOriginValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e NetworkOriginValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e NetworkOriginValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e NetworkOriginValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e NetworkOriginValidationError) ErrorName() string { return "NetworkOriginValidationError" } + +// Error satisfies the builtin error interface +func (e NetworkOriginValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sNetworkOrigin.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = NetworkOriginValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = NetworkOriginValidationError{} + +var _NetworkOrigin_CountryCode_Pattern = regexp.MustCompile("^[A-Z]{2}$") + +var _NetworkOrigin_SubdivisionCode_Pattern = regexp.MustCompile("^[A-Z]{2}-[A-Z0-9]{1,3}$") + +// Validate checks the field values on OAuthUsage with the rules defined in the +// proto definition for this message. If any rules are violated, the first +// error encountered is returned, or nil if there are no violations. +func (m *OAuthUsage) Validate() error { + return m.validate(false) +} + +// ValidateAll checks the field values on OAuthUsage with the rules defined in +// the proto definition for this message. If any rules are violated, the +// result is a list of violation errors wrapped in OAuthUsageMultiError, or +// nil if none found. +func (m *OAuthUsage) ValidateAll() error { + return m.validate(true) +} + +func (m *OAuthUsage) validate(all bool) error { + if m == nil { + return nil + } + + var errors []error + + // no validation rules for Action + + if m.GetSourceEventName() != "" { + + if len(m.GetSourceEventName()) > 256 { + err := OAuthUsageValidationError{ + field: "SourceEventName", + reason: "value length must be at most 256 bytes", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + + if len(m.GetScopes()) > 256 { + err := OAuthUsageValidationError{ + field: "Scopes", + reason: "value must contain no more than 256 item(s)", + } + if !all { + return err + } + errors = append(errors, err) + } + + for idx, item := range m.GetScopes() { + _, _ = idx, item + + if l := len(item); l < 1 || l > 512 { + err := OAuthUsageValidationError{ + field: fmt.Sprintf("Scopes[%v]", idx), + reason: "value length must be between 1 and 512 bytes, inclusive", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + + if m.GetClientType() != "" { + + if len(m.GetClientType()) > 256 { + err := OAuthUsageValidationError{ + field: "ClientType", + reason: "value length must be at most 256 bytes", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + + if len(errors) > 0 { + return OAuthUsageMultiError(errors) + } + + return nil +} + +// OAuthUsageMultiError is an error wrapping multiple validation errors +// returned by OAuthUsage.ValidateAll() if the designated constraints aren't met. +type OAuthUsageMultiError []error + +// Error returns a concatenation of all the error messages it wraps. +func (m OAuthUsageMultiError) Error() string { + msgs := make([]string, 0, len(m)) + for _, err := range m { + msgs = append(msgs, err.Error()) + } + return strings.Join(msgs, "; ") +} + +// AllErrors returns a list of validation violation errors. +func (m OAuthUsageMultiError) AllErrors() []error { return m } + +// OAuthUsageValidationError is the validation error returned by +// OAuthUsage.Validate if the designated constraints aren't met. +type OAuthUsageValidationError struct { + field string + reason string + cause error + key bool +} + +// Field function returns field value. +func (e OAuthUsageValidationError) Field() string { return e.field } + +// Reason function returns reason value. +func (e OAuthUsageValidationError) Reason() string { return e.reason } + +// Cause function returns cause value. +func (e OAuthUsageValidationError) Cause() error { return e.cause } + +// Key function returns key value. +func (e OAuthUsageValidationError) Key() bool { return e.key } + +// ErrorName returns error name. +func (e OAuthUsageValidationError) ErrorName() string { return "OAuthUsageValidationError" } + +// Error satisfies the builtin error interface +func (e OAuthUsageValidationError) Error() string { + cause := "" + if e.cause != nil { + cause = fmt.Sprintf(" | caused by: %v", e.cause) + } + + key := "" + if e.key { + key = "key for " + } + + return fmt.Sprintf( + "invalid %sOAuthUsage.%s: %s%s", + key, + e.field, + e.reason, + cause) +} + +var _ error = OAuthUsageValidationError{} + +var _ interface { + Field() string + Reason() string + Key() bool + Cause() error + ErrorName() string +} = OAuthUsageValidationError{} + // Validate checks the field values on GrantEvent with the rules defined in the // proto definition for this message. If any rules are violated, the first // error encountered is returned, or nil if there are no violations. diff --git a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/event_feed_protoopaque.pb.go b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/event_feed_protoopaque.pb.go index 29720a6d..88361e23 100644 --- a/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/event_feed_protoopaque.pb.go +++ b/vendor/github.com/conductorone/baton-sdk/pb/c1/connector/v2/event_feed_protoopaque.pb.go @@ -75,6 +75,56 @@ func (x EventType) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } +type OAuthUsage_Action int32 + +const ( + OAuthUsage_ACTION_UNSPECIFIED OAuthUsage_Action = 0 + OAuthUsage_ACTION_AUTHORIZE OAuthUsage_Action = 1 + OAuthUsage_ACTION_REVOKE OAuthUsage_Action = 2 + OAuthUsage_ACTION_DENY OAuthUsage_Action = 3 + OAuthUsage_ACTION_REQUEST OAuthUsage_Action = 4 +) + +// Enum value maps for OAuthUsage_Action. +var ( + OAuthUsage_Action_name = map[int32]string{ + 0: "ACTION_UNSPECIFIED", + 1: "ACTION_AUTHORIZE", + 2: "ACTION_REVOKE", + 3: "ACTION_DENY", + 4: "ACTION_REQUEST", + } + OAuthUsage_Action_value = map[string]int32{ + "ACTION_UNSPECIFIED": 0, + "ACTION_AUTHORIZE": 1, + "ACTION_REVOKE": 2, + "ACTION_DENY": 3, + "ACTION_REQUEST": 4, + } +) + +func (x OAuthUsage_Action) Enum() *OAuthUsage_Action { + p := new(OAuthUsage_Action) + *p = x + return p +} + +func (x OAuthUsage_Action) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (OAuthUsage_Action) Descriptor() protoreflect.EnumDescriptor { + return file_c1_connector_v2_event_feed_proto_enumTypes[1].Descriptor() +} + +func (OAuthUsage_Action) Type() protoreflect.EnumType { + return &file_c1_connector_v2_event_feed_proto_enumTypes[1] +} + +func (x OAuthUsage_Action) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + type ListEventsRequest struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_Cursor string `protobuf:"bytes,1,opt,name=cursor,proto3"` @@ -851,6 +901,8 @@ type UsageEvent struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_TargetResource *Resource `protobuf:"bytes,1,opt,name=target_resource,json=targetResource,proto3"` xxx_hidden_ActorResource *Resource `protobuf:"bytes,2,opt,name=actor_resource,json=actorResource,proto3"` + xxx_hidden_Network *NetworkOrigin `protobuf:"bytes,3,opt,name=network,proto3"` + xxx_hidden_Oauth *OAuthUsage `protobuf:"bytes,4,opt,name=oauth,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -894,6 +946,20 @@ func (x *UsageEvent) GetActorResource() *Resource { return nil } +func (x *UsageEvent) GetNetwork() *NetworkOrigin { + if x != nil { + return x.xxx_hidden_Network + } + return nil +} + +func (x *UsageEvent) GetOauth() *OAuthUsage { + if x != nil { + return x.xxx_hidden_Oauth + } + return nil +} + func (x *UsageEvent) SetTargetResource(v *Resource) { x.xxx_hidden_TargetResource = v } @@ -902,6 +968,14 @@ func (x *UsageEvent) SetActorResource(v *Resource) { x.xxx_hidden_ActorResource = v } +func (x *UsageEvent) SetNetwork(v *NetworkOrigin) { + x.xxx_hidden_Network = v +} + +func (x *UsageEvent) SetOauth(v *OAuthUsage) { + x.xxx_hidden_Oauth = v +} + func (x *UsageEvent) HasTargetResource() bool { if x == nil { return false @@ -916,6 +990,20 @@ func (x *UsageEvent) HasActorResource() bool { return x.xxx_hidden_ActorResource != nil } +func (x *UsageEvent) HasNetwork() bool { + if x == nil { + return false + } + return x.xxx_hidden_Network != nil +} + +func (x *UsageEvent) HasOauth() bool { + if x == nil { + return false + } + return x.xxx_hidden_Oauth != nil +} + func (x *UsageEvent) ClearTargetResource() { x.xxx_hidden_TargetResource = nil } @@ -924,11 +1012,23 @@ func (x *UsageEvent) ClearActorResource() { x.xxx_hidden_ActorResource = nil } +func (x *UsageEvent) ClearNetwork() { + x.xxx_hidden_Network = nil +} + +func (x *UsageEvent) ClearOauth() { + x.xxx_hidden_Oauth = nil +} + type UsageEvent_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. TargetResource *Resource ActorResource *Resource + // Network origin of the actor. + Network *NetworkOrigin + // Set when the event is OAuth consent activity. + Oauth *OAuthUsage } func (b0 UsageEvent_builder) Build() *UsageEvent { @@ -937,6 +1037,220 @@ func (b0 UsageEvent_builder) Build() *UsageEvent { _, _ = b, x x.xxx_hidden_TargetResource = b.TargetResource x.xxx_hidden_ActorResource = b.ActorResource + x.xxx_hidden_Network = b.Network + x.xxx_hidden_Oauth = b.Oauth + return m0 +} + +// Producers leave unset any value that fails its validate rule rather than +// copying it from the source: Event validation rejects the whole event, not +// the one value. +type NetworkOrigin struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_IpAddress string `protobuf:"bytes,1,opt,name=ip_address,json=ipAddress,proto3"` + xxx_hidden_CountryCode string `protobuf:"bytes,2,opt,name=country_code,json=countryCode,proto3"` + xxx_hidden_SubdivisionCode string `protobuf:"bytes,3,opt,name=subdivision_code,json=subdivisionCode,proto3"` + xxx_hidden_Asns []uint32 `protobuf:"varint,4,rep,packed,name=asns,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NetworkOrigin) Reset() { + *x = NetworkOrigin{} + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NetworkOrigin) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NetworkOrigin) ProtoMessage() {} + +func (x *NetworkOrigin) ProtoReflect() protoreflect.Message { + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *NetworkOrigin) GetIpAddress() string { + if x != nil { + return x.xxx_hidden_IpAddress + } + return "" +} + +func (x *NetworkOrigin) GetCountryCode() string { + if x != nil { + return x.xxx_hidden_CountryCode + } + return "" +} + +func (x *NetworkOrigin) GetSubdivisionCode() string { + if x != nil { + return x.xxx_hidden_SubdivisionCode + } + return "" +} + +func (x *NetworkOrigin) GetAsns() []uint32 { + if x != nil { + return x.xxx_hidden_Asns + } + return nil +} + +func (x *NetworkOrigin) SetIpAddress(v string) { + x.xxx_hidden_IpAddress = v +} + +func (x *NetworkOrigin) SetCountryCode(v string) { + x.xxx_hidden_CountryCode = v +} + +func (x *NetworkOrigin) SetSubdivisionCode(v string) { + x.xxx_hidden_SubdivisionCode = v +} + +func (x *NetworkOrigin) SetAsns(v []uint32) { + x.xxx_hidden_Asns = v +} + +type NetworkOrigin_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + IpAddress string + // ISO 3166-1 alpha-2, e.g. "US". + CountryCode string + // ISO 3166-2, e.g. "US-OR". + SubdivisionCode string + // Autonomous system numbers the source attributes to ip_address. + Asns []uint32 +} + +func (b0 NetworkOrigin_builder) Build() *NetworkOrigin { + m0 := &NetworkOrigin{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_IpAddress = b.IpAddress + x.xxx_hidden_CountryCode = b.CountryCode + x.xxx_hidden_SubdivisionCode = b.SubdivisionCode + x.xxx_hidden_Asns = b.Asns + return m0 +} + +// OAuth consent activity: a user authorizes, revokes, is denied, or requests +// an application's access to the user's data. +// Producers leave unset any value that fails its validate rule rather than +// copying it from the source: Event validation rejects the whole event, not +// the one value. +type OAuthUsage struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Action OAuthUsage_Action `protobuf:"varint,1,opt,name=action,proto3,enum=c1.connector.v2.OAuthUsage_Action"` + xxx_hidden_SourceEventName string `protobuf:"bytes,2,opt,name=source_event_name,json=sourceEventName,proto3"` + xxx_hidden_Scopes []string `protobuf:"bytes,3,rep,name=scopes,proto3"` + xxx_hidden_ClientType string `protobuf:"bytes,4,opt,name=client_type,json=clientType,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OAuthUsage) Reset() { + *x = OAuthUsage{} + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OAuthUsage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OAuthUsage) ProtoMessage() {} + +func (x *OAuthUsage) ProtoReflect() protoreflect.Message { + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *OAuthUsage) GetAction() OAuthUsage_Action { + if x != nil { + return x.xxx_hidden_Action + } + return OAuthUsage_ACTION_UNSPECIFIED +} + +func (x *OAuthUsage) GetSourceEventName() string { + if x != nil { + return x.xxx_hidden_SourceEventName + } + return "" +} + +func (x *OAuthUsage) GetScopes() []string { + if x != nil { + return x.xxx_hidden_Scopes + } + return nil +} + +func (x *OAuthUsage) GetClientType() string { + if x != nil { + return x.xxx_hidden_ClientType + } + return "" +} + +func (x *OAuthUsage) SetAction(v OAuthUsage_Action) { + x.xxx_hidden_Action = v +} + +func (x *OAuthUsage) SetSourceEventName(v string) { + x.xxx_hidden_SourceEventName = v +} + +func (x *OAuthUsage) SetScopes(v []string) { + x.xxx_hidden_Scopes = v +} + +func (x *OAuthUsage) SetClientType(v string) { + x.xxx_hidden_ClientType = v +} + +type OAuthUsage_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + // ACTION_UNSPECIFIED when source_event_name maps to no Action. + Action OAuthUsage_Action + SourceEventName string + // Scopes the action applies to, as the source names them. + Scopes []string + // The source's client classification, as the source names it. + ClientType string +} + +func (b0 OAuthUsage_builder) Build() *OAuthUsage { + m0 := &OAuthUsage{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Action = b.Action + x.xxx_hidden_SourceEventName = b.SourceEventName + x.xxx_hidden_Scopes = b.Scopes + x.xxx_hidden_ClientType = b.ClientType return m0 } @@ -950,7 +1264,7 @@ type GrantEvent struct { func (x *GrantEvent) Reset() { *x = GrantEvent{} - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[6] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -962,7 +1276,7 @@ func (x *GrantEvent) String() string { func (*GrantEvent) ProtoMessage() {} func (x *GrantEvent) ProtoReflect() protoreflect.Message { - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[6] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1020,7 +1334,7 @@ type CreateGrantEvent struct { func (x *CreateGrantEvent) Reset() { *x = CreateGrantEvent{} - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[7] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1032,7 +1346,7 @@ func (x *CreateGrantEvent) String() string { func (*CreateGrantEvent) ProtoMessage() {} func (x *CreateGrantEvent) ProtoReflect() protoreflect.Message { - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[7] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1129,7 +1443,7 @@ type CreateRevokeEvent struct { func (x *CreateRevokeEvent) Reset() { *x = CreateRevokeEvent{} - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[8] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1141,7 +1455,7 @@ func (x *CreateRevokeEvent) String() string { func (*CreateRevokeEvent) ProtoMessage() {} func (x *CreateRevokeEvent) ProtoReflect() protoreflect.Message { - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[8] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1237,7 +1551,7 @@ type RevokeEvent struct { func (x *RevokeEvent) Reset() { *x = RevokeEvent{} - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[9] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1249,7 +1563,7 @@ func (x *RevokeEvent) String() string { func (*RevokeEvent) ProtoMessage() {} func (x *RevokeEvent) ProtoReflect() protoreflect.Message { - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[9] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1331,7 +1645,7 @@ type ResourceChangeEvent struct { func (x *ResourceChangeEvent) Reset() { *x = ResourceChangeEvent{} - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[10] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1343,7 +1657,7 @@ func (x *ResourceChangeEvent) String() string { func (*ResourceChangeEvent) ProtoMessage() {} func (x *ResourceChangeEvent) ProtoReflect() protoreflect.Message { - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[10] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1424,7 +1738,7 @@ type EventFeedMetadata struct { func (x *EventFeedMetadata) Reset() { *x = EventFeedMetadata{} - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[11] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1436,7 +1750,7 @@ func (x *EventFeedMetadata) String() string { func (*EventFeedMetadata) ProtoMessage() {} func (x *EventFeedMetadata) ProtoReflect() protoreflect.Message { - mi := &file_c1_connector_v2_event_feed_proto_msgTypes[11] + mi := &file_c1_connector_v2_event_feed_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1524,11 +1838,34 @@ const file_c1_connector_v2_event_feed_proto_rawDesc = "" + "\x12create_grant_event\x18h \x01(\v2!.c1.connector.v2.CreateGrantEventH\x00R\x10createGrantEvent\x12T\n" + "\x13create_revoke_event\x18i \x01(\v2\".c1.connector.v2.CreateRevokeEventH\x00R\x11createRevokeEvent\x126\n" + "\vannotations\x18\x03 \x03(\v2\x14.google.protobuf.AnyR\vannotationsB\a\n" + - "\x05event\"\x92\x01\n" + + "\x05event\"\xff\x01\n" + "\n" + "UsageEvent\x12B\n" + "\x0ftarget_resource\x18\x01 \x01(\v2\x19.c1.connector.v2.ResourceR\x0etargetResource\x12@\n" + - "\x0eactor_resource\x18\x02 \x01(\v2\x19.c1.connector.v2.ResourceR\ractorResource\":\n" + + "\x0eactor_resource\x18\x02 \x01(\v2\x19.c1.connector.v2.ResourceR\ractorResource\x128\n" + + "\anetwork\x18\x03 \x01(\v2\x1e.c1.connector.v2.NetworkOriginR\anetwork\x121\n" + + "\x05oauth\x18\x04 \x01(\v2\x1b.c1.connector.v2.OAuthUsageR\x05oauth\"\xe0\x01\n" + + "\rNetworkOrigin\x12)\n" + + "\n" + + "ip_address\x18\x01 \x01(\tB\n" + + "\xfaB\ar\x05\xd0\x01\x01p\x01R\tipAddress\x127\n" + + "\fcountry_code\x18\x02 \x01(\tB\x14\xfaB\x11r\x0f2\n" + + "^[A-Z]{2}$\xd0\x01\x01R\vcountryCode\x12M\n" + + "\x10subdivision_code\x18\x03 \x01(\tB\"\xfaB\x1fr\x1d2\x18^[A-Z]{2}-[A-Z0-9]{1,3}$\xd0\x01\x01R\x0fsubdivisionCode\x12\x1c\n" + + "\x04asns\x18\x04 \x03(\rB\b\xfaB\x05\x92\x01\x02\x10@R\x04asns\"\xcb\x02\n" + + "\n" + + "OAuthUsage\x12:\n" + + "\x06action\x18\x01 \x01(\x0e2\".c1.connector.v2.OAuthUsage.ActionR\x06action\x127\n" + + "\x11source_event_name\x18\x02 \x01(\tB\v\xfaB\br\x06(\x80\x02\xd0\x01\x01R\x0fsourceEventName\x12*\n" + + "\x06scopes\x18\x03 \x03(\tB\x12\xfaB\x0f\x92\x01\f\x10\x80\x02\"\ar\x05 \x01(\x80\x04R\x06scopes\x12,\n" + + "\vclient_type\x18\x04 \x01(\tB\v\xfaB\br\x06(\x80\x02\xd0\x01\x01R\n" + + "clientType\"n\n" + + "\x06Action\x12\x16\n" + + "\x12ACTION_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10ACTION_AUTHORIZE\x10\x01\x12\x11\n" + + "\rACTION_REVOKE\x10\x02\x12\x0f\n" + + "\vACTION_DENY\x10\x03\x12\x12\n" + + "\x0eACTION_REQUEST\x10\x04\":\n" + "\n" + "GrantEvent\x12,\n" + "\x05grant\x18\x01 \x01(\v2\x16.c1.connector.v2.GrantR\x05grant\"\xd7\x01\n" + @@ -1562,68 +1899,74 @@ const file_c1_connector_v2_event_feed_proto_rawDesc = "" + "ListEvents\x12\".c1.connector.v2.ListEventsRequest\x1a#.c1.connector.v2.ListEventsResponse\x12a\n" + "\x0eListEventFeeds\x12&.c1.connector.v2.ListEventFeedsRequest\x1a'.c1.connector.v2.ListEventFeedsResponseB6Z4github.com/conductorone/baton-sdk/pb/c1/connector/v2b\x06proto3" -var file_c1_connector_v2_event_feed_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_c1_connector_v2_event_feed_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_c1_connector_v2_event_feed_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_c1_connector_v2_event_feed_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_c1_connector_v2_event_feed_proto_goTypes = []any{ (EventType)(0), // 0: c1.connector.v2.EventType - (*ListEventsRequest)(nil), // 1: c1.connector.v2.ListEventsRequest - (*ListEventsResponse)(nil), // 2: c1.connector.v2.ListEventsResponse - (*ListEventFeedsRequest)(nil), // 3: c1.connector.v2.ListEventFeedsRequest - (*ListEventFeedsResponse)(nil), // 4: c1.connector.v2.ListEventFeedsResponse - (*Event)(nil), // 5: c1.connector.v2.Event - (*UsageEvent)(nil), // 6: c1.connector.v2.UsageEvent - (*GrantEvent)(nil), // 7: c1.connector.v2.GrantEvent - (*CreateGrantEvent)(nil), // 8: c1.connector.v2.CreateGrantEvent - (*CreateRevokeEvent)(nil), // 9: c1.connector.v2.CreateRevokeEvent - (*RevokeEvent)(nil), // 10: c1.connector.v2.RevokeEvent - (*ResourceChangeEvent)(nil), // 11: c1.connector.v2.ResourceChangeEvent - (*EventFeedMetadata)(nil), // 12: c1.connector.v2.EventFeedMetadata - (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp - (*anypb.Any)(nil), // 14: google.protobuf.Any - (*Resource)(nil), // 15: c1.connector.v2.Resource - (*Grant)(nil), // 16: c1.connector.v2.Grant - (*Entitlement)(nil), // 17: c1.connector.v2.Entitlement - (*ResourceId)(nil), // 18: c1.connector.v2.ResourceId + (OAuthUsage_Action)(0), // 1: c1.connector.v2.OAuthUsage.Action + (*ListEventsRequest)(nil), // 2: c1.connector.v2.ListEventsRequest + (*ListEventsResponse)(nil), // 3: c1.connector.v2.ListEventsResponse + (*ListEventFeedsRequest)(nil), // 4: c1.connector.v2.ListEventFeedsRequest + (*ListEventFeedsResponse)(nil), // 5: c1.connector.v2.ListEventFeedsResponse + (*Event)(nil), // 6: c1.connector.v2.Event + (*UsageEvent)(nil), // 7: c1.connector.v2.UsageEvent + (*NetworkOrigin)(nil), // 8: c1.connector.v2.NetworkOrigin + (*OAuthUsage)(nil), // 9: c1.connector.v2.OAuthUsage + (*GrantEvent)(nil), // 10: c1.connector.v2.GrantEvent + (*CreateGrantEvent)(nil), // 11: c1.connector.v2.CreateGrantEvent + (*CreateRevokeEvent)(nil), // 12: c1.connector.v2.CreateRevokeEvent + (*RevokeEvent)(nil), // 13: c1.connector.v2.RevokeEvent + (*ResourceChangeEvent)(nil), // 14: c1.connector.v2.ResourceChangeEvent + (*EventFeedMetadata)(nil), // 15: c1.connector.v2.EventFeedMetadata + (*timestamppb.Timestamp)(nil), // 16: google.protobuf.Timestamp + (*anypb.Any)(nil), // 17: google.protobuf.Any + (*Resource)(nil), // 18: c1.connector.v2.Resource + (*Grant)(nil), // 19: c1.connector.v2.Grant + (*Entitlement)(nil), // 20: c1.connector.v2.Entitlement + (*ResourceId)(nil), // 21: c1.connector.v2.ResourceId } var file_c1_connector_v2_event_feed_proto_depIdxs = []int32{ - 13, // 0: c1.connector.v2.ListEventsRequest.start_at:type_name -> google.protobuf.Timestamp - 14, // 1: c1.connector.v2.ListEventsRequest.annotations:type_name -> google.protobuf.Any - 5, // 2: c1.connector.v2.ListEventsResponse.events:type_name -> c1.connector.v2.Event - 14, // 3: c1.connector.v2.ListEventsResponse.annotations:type_name -> google.protobuf.Any - 14, // 4: c1.connector.v2.ListEventFeedsRequest.annotations:type_name -> google.protobuf.Any - 12, // 5: c1.connector.v2.ListEventFeedsResponse.list:type_name -> c1.connector.v2.EventFeedMetadata - 14, // 6: c1.connector.v2.ListEventFeedsResponse.annotations:type_name -> google.protobuf.Any - 13, // 7: c1.connector.v2.Event.occurred_at:type_name -> google.protobuf.Timestamp - 6, // 8: c1.connector.v2.Event.usage_event:type_name -> c1.connector.v2.UsageEvent - 7, // 9: c1.connector.v2.Event.grant_event:type_name -> c1.connector.v2.GrantEvent - 10, // 10: c1.connector.v2.Event.revoke_event:type_name -> c1.connector.v2.RevokeEvent - 11, // 11: c1.connector.v2.Event.resource_change_event:type_name -> c1.connector.v2.ResourceChangeEvent - 8, // 12: c1.connector.v2.Event.create_grant_event:type_name -> c1.connector.v2.CreateGrantEvent - 9, // 13: c1.connector.v2.Event.create_revoke_event:type_name -> c1.connector.v2.CreateRevokeEvent - 14, // 14: c1.connector.v2.Event.annotations:type_name -> google.protobuf.Any - 15, // 15: c1.connector.v2.UsageEvent.target_resource:type_name -> c1.connector.v2.Resource - 15, // 16: c1.connector.v2.UsageEvent.actor_resource:type_name -> c1.connector.v2.Resource - 16, // 17: c1.connector.v2.GrantEvent.grant:type_name -> c1.connector.v2.Grant - 17, // 18: c1.connector.v2.CreateGrantEvent.entitlement:type_name -> c1.connector.v2.Entitlement - 15, // 19: c1.connector.v2.CreateGrantEvent.principal:type_name -> c1.connector.v2.Resource - 14, // 20: c1.connector.v2.CreateGrantEvent.annotations:type_name -> google.protobuf.Any - 17, // 21: c1.connector.v2.CreateRevokeEvent.entitlement:type_name -> c1.connector.v2.Entitlement - 15, // 22: c1.connector.v2.CreateRevokeEvent.principal:type_name -> c1.connector.v2.Resource - 14, // 23: c1.connector.v2.CreateRevokeEvent.annotations:type_name -> google.protobuf.Any - 17, // 24: c1.connector.v2.RevokeEvent.entitlement:type_name -> c1.connector.v2.Entitlement - 15, // 25: c1.connector.v2.RevokeEvent.principal:type_name -> c1.connector.v2.Resource - 18, // 26: c1.connector.v2.ResourceChangeEvent.resource_id:type_name -> c1.connector.v2.ResourceId - 18, // 27: c1.connector.v2.ResourceChangeEvent.parent_resource_id:type_name -> c1.connector.v2.ResourceId - 0, // 28: c1.connector.v2.EventFeedMetadata.supported_event_types:type_name -> c1.connector.v2.EventType - 1, // 29: c1.connector.v2.EventService.ListEvents:input_type -> c1.connector.v2.ListEventsRequest - 3, // 30: c1.connector.v2.EventService.ListEventFeeds:input_type -> c1.connector.v2.ListEventFeedsRequest - 2, // 31: c1.connector.v2.EventService.ListEvents:output_type -> c1.connector.v2.ListEventsResponse - 4, // 32: c1.connector.v2.EventService.ListEventFeeds:output_type -> c1.connector.v2.ListEventFeedsResponse - 31, // [31:33] is the sub-list for method output_type - 29, // [29:31] is the sub-list for method input_type - 29, // [29:29] is the sub-list for extension type_name - 29, // [29:29] is the sub-list for extension extendee - 0, // [0:29] is the sub-list for field type_name + 16, // 0: c1.connector.v2.ListEventsRequest.start_at:type_name -> google.protobuf.Timestamp + 17, // 1: c1.connector.v2.ListEventsRequest.annotations:type_name -> google.protobuf.Any + 6, // 2: c1.connector.v2.ListEventsResponse.events:type_name -> c1.connector.v2.Event + 17, // 3: c1.connector.v2.ListEventsResponse.annotations:type_name -> google.protobuf.Any + 17, // 4: c1.connector.v2.ListEventFeedsRequest.annotations:type_name -> google.protobuf.Any + 15, // 5: c1.connector.v2.ListEventFeedsResponse.list:type_name -> c1.connector.v2.EventFeedMetadata + 17, // 6: c1.connector.v2.ListEventFeedsResponse.annotations:type_name -> google.protobuf.Any + 16, // 7: c1.connector.v2.Event.occurred_at:type_name -> google.protobuf.Timestamp + 7, // 8: c1.connector.v2.Event.usage_event:type_name -> c1.connector.v2.UsageEvent + 10, // 9: c1.connector.v2.Event.grant_event:type_name -> c1.connector.v2.GrantEvent + 13, // 10: c1.connector.v2.Event.revoke_event:type_name -> c1.connector.v2.RevokeEvent + 14, // 11: c1.connector.v2.Event.resource_change_event:type_name -> c1.connector.v2.ResourceChangeEvent + 11, // 12: c1.connector.v2.Event.create_grant_event:type_name -> c1.connector.v2.CreateGrantEvent + 12, // 13: c1.connector.v2.Event.create_revoke_event:type_name -> c1.connector.v2.CreateRevokeEvent + 17, // 14: c1.connector.v2.Event.annotations:type_name -> google.protobuf.Any + 18, // 15: c1.connector.v2.UsageEvent.target_resource:type_name -> c1.connector.v2.Resource + 18, // 16: c1.connector.v2.UsageEvent.actor_resource:type_name -> c1.connector.v2.Resource + 8, // 17: c1.connector.v2.UsageEvent.network:type_name -> c1.connector.v2.NetworkOrigin + 9, // 18: c1.connector.v2.UsageEvent.oauth:type_name -> c1.connector.v2.OAuthUsage + 1, // 19: c1.connector.v2.OAuthUsage.action:type_name -> c1.connector.v2.OAuthUsage.Action + 19, // 20: c1.connector.v2.GrantEvent.grant:type_name -> c1.connector.v2.Grant + 20, // 21: c1.connector.v2.CreateGrantEvent.entitlement:type_name -> c1.connector.v2.Entitlement + 18, // 22: c1.connector.v2.CreateGrantEvent.principal:type_name -> c1.connector.v2.Resource + 17, // 23: c1.connector.v2.CreateGrantEvent.annotations:type_name -> google.protobuf.Any + 20, // 24: c1.connector.v2.CreateRevokeEvent.entitlement:type_name -> c1.connector.v2.Entitlement + 18, // 25: c1.connector.v2.CreateRevokeEvent.principal:type_name -> c1.connector.v2.Resource + 17, // 26: c1.connector.v2.CreateRevokeEvent.annotations:type_name -> google.protobuf.Any + 20, // 27: c1.connector.v2.RevokeEvent.entitlement:type_name -> c1.connector.v2.Entitlement + 18, // 28: c1.connector.v2.RevokeEvent.principal:type_name -> c1.connector.v2.Resource + 21, // 29: c1.connector.v2.ResourceChangeEvent.resource_id:type_name -> c1.connector.v2.ResourceId + 21, // 30: c1.connector.v2.ResourceChangeEvent.parent_resource_id:type_name -> c1.connector.v2.ResourceId + 0, // 31: c1.connector.v2.EventFeedMetadata.supported_event_types:type_name -> c1.connector.v2.EventType + 2, // 32: c1.connector.v2.EventService.ListEvents:input_type -> c1.connector.v2.ListEventsRequest + 4, // 33: c1.connector.v2.EventService.ListEventFeeds:input_type -> c1.connector.v2.ListEventFeedsRequest + 3, // 34: c1.connector.v2.EventService.ListEvents:output_type -> c1.connector.v2.ListEventsResponse + 5, // 35: c1.connector.v2.EventService.ListEventFeeds:output_type -> c1.connector.v2.ListEventFeedsResponse + 34, // [34:36] is the sub-list for method output_type + 32, // [32:34] is the sub-list for method input_type + 32, // [32:32] is the sub-list for extension type_name + 32, // [32:32] is the sub-list for extension extendee + 0, // [0:32] is the sub-list for field type_name } func init() { file_c1_connector_v2_event_feed_proto_init() } @@ -1647,8 +1990,8 @@ func file_c1_connector_v2_event_feed_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_c1_connector_v2_event_feed_proto_rawDesc), len(file_c1_connector_v2_event_feed_proto_rawDesc)), - NumEnums: 1, - NumMessages: 12, + NumEnums: 2, + NumMessages: 14, NumExtensions: 0, NumServices: 1, }, diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/c1zstore/ledger.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/c1zstore/ledger.go index 833425b0..c3ca9cbf 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/c1zstore/ledger.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/c1zstore/ledger.go @@ -238,6 +238,14 @@ type PageWriter interface { Discard() } +type TrustedImportPageWriter interface { + PageWriter + // Each non-grant identity occurs at most once across the sync. Grants use + // last-write-wins and rebuild derived indexes at seal. Writes do not read + // destination records. + SetTrustedImport() error +} + // What state the bound sync's pass is in, in one read. type LedgerState struct { Phase LedgerQueuePhase diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/adapter_page.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/adapter_page.go index 8539bf38..4553ef2f 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/adapter_page.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/adapter_page.go @@ -39,6 +39,10 @@ func (w *pageWriter) requireSync() error { return nil } +func (w *pageWriter) SetTrustedImport() error { + return w.unit.StageTrustedImport() +} + func (w *pageWriter) PutResourceTypes(ctx context.Context, rts ...*v2.ResourceType) error { if err := w.requireSync(); err != nil { return err diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/deferred_index.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/deferred_index.go index a1c8f7f0..91c83470 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/deferred_index.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/deferred_index.go @@ -32,12 +32,10 @@ import ( // pairs them with a bounded spillArenaFreeList and a sort semaphore, and // a fresh arena's pages commit only as it fills. // -// Memory budget (revised for the second family): with the grant digest -// index enabled the scan feeds TWO sorter families — by_principal -// (key-only) and by_entitlement_principal_hash (8-byte values) — which -// share the sort semaphore, so the peak is two filling arenas plus up -// to `sorters` (≤4) arenas in background sorts: ≈ 6 × 128MiB = 768MiB, -// roughly double the single-family budget. That stays acceptable +// Trusted imports add the by_needs_expansion sorter; the grant digest index +// adds by_entitlement_principal_hash. They share the sort semaphore, so at +// most `sorters` arenas are in background sorts. Each active family also +// owns one filling arena. That stays acceptable // because the build runs alone at EndSync (the sync's write pipeline // and its arenas are already drained) and each family's merge width // stays ~50 for whale-scale inputs. Each family gets its own bounded @@ -244,11 +242,9 @@ func (t *grantRebuildTee) closeAndWait() { } } -// BuildDeferredGrantIndexes rebuilds the remaining scattered expansion index -// family, by_principal, from entitlement-first primary grant keys. The expansion -// write path can skip by_principal inline because expansion reads by entitlement -// only; this method rewrites the whole by_principal range as one sorted SST at -// EndSync. +// BuildDeferredGrantIndexes rebuilds by_principal and by_needs_expansion from +// entitlement-first primary grant keys. Deferred writers may skip those +// indexes inline; this method replaces both complete ranges at EndSync. // // The same scan also rebuilds the primary grant keyspace itself: every raw // (key, value) is teed into rolling SSTs which replace the whole grant range @@ -284,6 +280,29 @@ func (e *Engine) BuildDeferredGrantIndexes(ctx context.Context) error { }) } +func (e *Engine) replaceDeferredIndex( + ctx context.Context, + dir string, + sorter *spillSorter, + index byte, + lower []byte, + upper []byte, +) error { + chunks, err := sorter.finalize() + if err != nil { + return err + } + span := pebble.KeyRange{Start: lower, End: upper} + if len(chunks) == 0 { + return e.db.ExciseRange(ctx, span) + } + path := filepath.Join(dir, fmt.Sprintf("index-%02x.sst", index)) + if err := mergeSortedSpillChunksToSST(ctx, e.fs(), path, fmt.Sprintf("index-%02x", index), chunks); err != nil { + return err + } + return e.db.ReplaceRangeWithSSTs(ctx, []string{path}, span) +} + func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { // The scan below only polls ctx periodically; don't start an O(grants) // pass (or its destructive IngestAndExcise) on an already-dead context. @@ -308,8 +327,14 @@ func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { // RemoveAll defer so LIFO ordering runs the wait first; abort is a // no-op once finalize has drained the sorter. defer principal.abort() + var needsExpansion *spillSorter + if e.db.DeferredNeedsExpansionPending() { + needsExpansion = newSpillSorter(dir, fmt.Sprintf("index-%02x", idxGrantByNeedsExpansion), sem, deferredIndexSpillChunkBytes) + needsExpansion.free = newSpillArenaFreeList(deferredIndexSpillChunkBytes, sorters+1) + defer needsExpansion.abort() + } - // Second sorter family: the by_entitlement_principal_hash rows the + // Digest sorter family: the by_entitlement_principal_hash rows the // grant digests fold over (valued entries — the 8-byte content // hash). Shares the sort semaphore with by_principal; see the // deferredIndexSpillChunkBytes budget note. Nil when the digest @@ -355,6 +380,7 @@ func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { var scanned, droppedMalformedKeys int64 var idxKeyScratch []byte + var expansionKeyScratch []byte // Hash-index row emission is non-fatal, mirroring the rebuild tee: // remember the first error, stop emitting, keep scanning — // by_principal and the stats stash don't depend on the digests, and @@ -399,6 +425,22 @@ func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { if err := principal.add(idxKey, nil); err != nil { return err } + if needsExpansion != nil { + expandable, err := scanGrantNeedsExpansionRaw(iter.Value()) + if err != nil { + return err + } + if expandable { + key, ok := rawdb.AppendGrantByNeedsExpansionKeyFromPrimary(expansionKeyScratch[:0], iter.Key()) + expansionKeyScratch = key + if !ok { + return errors.New("deferred grant index build: grant key cannot produce needs-expansion key") + } + if err := needsExpansion.add(key, nil); err != nil { + return err + } + } + } if hashIdx != nil && hashScanErr == nil { if err := appendGrantHashIndexRow(hashIdx, iter.Key(), iter.Value(), &hashScratch); err != nil { hashScanErr = err @@ -540,6 +582,12 @@ func (e *Engine) buildDeferredGrantIndexesLocked(ctx context.Context) error { ) } + if needsExpansion != nil { + if err := e.replaceDeferredIndex(ctx, dir, needsExpansion, idxGrantByNeedsExpansion, GrantByNeedsExpansionLowerBound(), GrantByNeedsExpansionUpperBound()); err != nil { + return err + } + } + // Grant digests + hash index, fused onto the same scan. Non-fatal, // like the stats sidecar's treatment of seal-time extras: on ANY // failure — a row emission error remembered mid-scan, a merge or diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/grants.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/grants.go index 072766a4..3c886bbe 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/grants.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/grants.go @@ -1126,22 +1126,13 @@ func grantIndexKeys(r *v3.GrantRecord) [][]byte { // marker through the Open-wired RecordDerivers — a caller can no // longer stage a grant row and forget what it owes. -// The deferred-marker arm/clear contract (CAS + durable key agreement -// on both edges) lives on rawdb (ArmDeferredGrantIndex / -// ClearDeferredGrantIndexMarker): the marker is write-side crash -// state the typed record ops consume directly. - -// clearDeferredIdxPending drops both forms of the marker after a -// successful rebuild, under the engine write barrier so the clear -// can't interleave with a concurrent writer's arm (which would leave -// the atomic flag armed but the durable marker deleted, or vice -// versa). Ordering and failure semantics live in rawdb's -// ClearDeferredGrantIndexMarker. +// clearDeferredIdxPending clears the durable deferred-index obligations after +// a successful rebuild while the engine remains sealed. func (e *Engine) clearDeferredIdxPending() error { // AllowSealed: runs inside EndSync's sealed finalize window, right // after the deferred build (see Adapter.EndSync). return e.withWriteAllowSealed(func() error { - return e.db.ClearDeferredGrantIndexMarker() + return e.db.ClearDeferredGrantIndexMarkers() }) } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb/keyspace.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb/keyspace.go index 2bafdba4..44cd3fec 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb/keyspace.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb/keyspace.go @@ -567,6 +567,14 @@ func DeferredIdxPendingKey() []byte { return codec.AppendTupleStrings(buf, "deferred_grant_idx_pending") } +// DeferredNeedsExpansionPendingKey marks trusted grant writes whose +// by_needs_expansion entries require a seal-time rebuild. +func DeferredNeedsExpansionPendingKey() []byte { + buf := make([]byte, 0, 2+len("deferred_grant_needs_expansion_pending")) + buf = append(buf, VersionV3, TypeEngineMeta) + return codec.AppendTupleStrings(buf, "deferred_grant_needs_expansion_pending") +} + // GrantDigestABIStampKey is the durable record of which grant-digest // hash ABI (the engine's GrantDigestABIVersion) this file's digest // state — hash-index values and digest nodes — was computed under. diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb/rawdb.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb/rawdb.go index 17041497..32313b40 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb/rawdb.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb/rawdb.go @@ -54,10 +54,14 @@ type DB struct { // means an in-process EndSync rebuilds while a crash+resume // silently skips the rebuild; cleared flag + present key forces a // spurious rebuild at the next open. ArmDeferredGrantIndex and - // ClearDeferredGrantIndexMarker maintain the agreement on both + // ClearDeferredGrantIndexMarkers maintain the agreement on both // edges (durable half first; rollback/abort on failure), and // RestoreDeferredIdxPending re-arms from the key at Open. deferredIdxPending atomic.Bool + // deferredNeedsExpansionPending is armed only by trusted grant imports, + // which cannot clean a prior page's by_needs_expansion entry without a + // destination read. + deferredNeedsExpansionPending atomic.Bool // grantDigestsPresent reports whether the digest keyspace holds // any nodes — the gate for the record ops' digest-invalidation @@ -212,6 +216,12 @@ func (d *DB) UnsafeForTesting() *pebble.DB { // is owed (see the field doc). func (d *DB) DeferredIdxPending() bool { return d.deferredIdxPending.Load() } +// DeferredNeedsExpansionPending reports whether trusted imports require a +// by_needs_expansion rebuild. +func (d *DB) DeferredNeedsExpansionPending() bool { + return d.deferredNeedsExpansionPending.Load() +} + // ArmDeferredGrantIndex durably arms the deferred-index rebuild // marker: CAS on the in-memory flag (repeat calls are one atomic // load — the deferred write paths call this per record), then the @@ -240,15 +250,40 @@ func (d *DB) armDeferredMarkerDurably() error { return d.set(DeferredIdxPendingKey(), nil, pebble.Sync) } -// ClearDeferredGrantIndexMarker drops both halves of the marker after -// a successful rebuild. Durable delete FIRST, flag second — the same -// agreement contract as the arm side: a failed delete leaves BOTH -// armed, so the retried EndSync re-runs the (idempotent) rebuild and -// retries the clear, never the flag-cleared/key-present split that -// skipped the retry's rebuild and left a stale key forcing a spurious -// rebuild at the next open. The caller owns the write barrier (the -// engine runs this inside EndSync's sealed finalize window). -func (d *DB) ClearDeferredGrantIndexMarker() error { +// ArmDeferredNeedsExpansionIndex durably records the additional index +// obligation created by trusted grant imports. +func (d *DB) ArmDeferredNeedsExpansionIndex() error { + if !d.deferredNeedsExpansionPending.CompareAndSwap(false, true) { + return nil + } + if d.testArmDeferredMarkerHook != nil { + if err := d.testArmDeferredMarkerHook(); err != nil { + d.deferredNeedsExpansionPending.Store(false) + return err + } + } + if err := d.set(DeferredNeedsExpansionPendingKey(), nil, pebble.Sync); err != nil { + d.deferredNeedsExpansionPending.Store(false) + return err + } + return nil +} + +// ClearDeferredGrantIndexMarkers clears each durable marker before its +// in-memory flag. The needs-expansion marker is cleared first, so a partial +// failure can only leave the broader by_principal rebuild pending. +func (d *DB) ClearDeferredGrantIndexMarkers() error { + if d.deferredNeedsExpansionPending.Load() { + if d.testClearDeferredMarkerHook != nil { + if err := d.testClearDeferredMarkerHook(); err != nil { + return err + } + } + if err := d.delete(DeferredNeedsExpansionPendingKey(), pebble.Sync); err != nil { + return err + } + d.deferredNeedsExpansionPending.Store(false) + } if d.testClearDeferredMarkerHook != nil { if err := d.testClearDeferredMarkerHook(); err != nil { return err @@ -271,13 +306,22 @@ func (d *DB) RestoreDeferredIdxPending() error { case err == nil: closer.Close() d.deferredIdxPending.Store(true) - return nil case errors.Is(err, pebble.ErrNotFound): d.deferredIdxPending.Store(false) - return nil default: return err } + _, closer, err = d.db.Get(DeferredNeedsExpansionPendingKey()) + switch { + case err == nil: + closer.Close() + d.deferredNeedsExpansionPending.Store(true) + case errors.Is(err, pebble.ErrNotFound): + d.deferredNeedsExpansionPending.Store(false) + default: + return err + } + return nil } // GrantDigestsPresent reports whether digest state exists (the record diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb/records.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb/records.go index 33013424..5984c617 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb/records.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb/records.go @@ -273,6 +273,15 @@ func (rb *RecordBatch) StageGrantPutDeferred(key, val, oldVal []byte, needsExpan return rb.stageGrantDigestInvalidation(key, sep4) } +// StageGrantPutTrusted stages a blind grant overwrite and arms the additional +// by_needs_expansion rebuild required when no prior value was read. +func (rb *RecordBatch) StageGrantPutTrusted(key, val []byte, needsExpansion bool) error { + if err := rb.StageGrantPutDeferred(key, val, nil, needsExpansion); err != nil { + return err + } + return rb.db.ArmDeferredNeedsExpansionIndex() +} + // StageGrantOrphanIndexHeal stages the removal of ONE orphan // by_principal index entry — an index key whose grant primary row does // not exist (a stranded write, never legitimate state). It takes the diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/page_unit.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/page_unit.go index a991c329..f9d64a7a 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/page_unit.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/page_unit.go @@ -45,6 +45,7 @@ type pageUnit struct { work *c1zstore.LedgerWork childWorkKeys []string + trustedImport bool sealing bool done bool } @@ -61,6 +62,14 @@ func (u *pageUnit) StageQueueSealing() error { return nil } +func (u *pageUnit) StageTrustedImport() error { + if u.done { + return ErrPageUnitCommitted + } + u.trustedImport = true + return nil +} + // Under the write lock, before anything is staged. Every page checks the // phase; the terminal page also checks the queue and its own row. func (u *pageUnit) stagePhaseLocked(batch *rawdb.RecordBatch, row *v3.LedgerRow) error { @@ -515,15 +524,28 @@ func (u *pageUnit) Commit(ctx context.Context, id c1zstore.LedgerActionIdentity, if err != nil { return err } - resources, err := l.e.stageResourceRecords(batch, u.resources) + stageResources := l.e.stageResourceRecords + stageEntitlements := l.e.stageEntitlementRecords + stageGrants := l.e.stageGrantRecords + if u.trustedImport { + stageResources = stageTrustedResourceRecords + stageEntitlements = stageTrustedEntitlementRecords + stageGrants = stageTrustedGrantRecords + // Same contract as bulk import: later plain stagers in this sync + // must read before writing so overwrites clean up index entries. + _ = l.e.takeFreshResourcesEmpty() + _ = l.e.takeFreshEntitlementsEmpty() + _ = l.e.takeFreshGrantsEmpty() + } + resources, err := stageResources(batch, u.resources) if err != nil { return err } - entitlements, err := l.e.stageEntitlementRecords(batch, u.entitlements) + entitlements, err := stageEntitlements(batch, u.entitlements) if err != nil { return err } - grants, err := l.e.stageGrantRecords(batch, u.grants) + grants, err := stageGrants(batch, u.grants) if err != nil { return err } @@ -602,6 +624,7 @@ func (u *pageUnit) release() { u.facts = nil u.bucketKey, u.bucketValue = nil, nil u.work, u.childWorkKeys = nil, nil + u.trustedImport = false u.sealing = false } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/rollback_expansion.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/rollback_expansion.go new file mode 100644 index 00000000..2ee66bc7 --- /dev/null +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/rollback_expansion.go @@ -0,0 +1,229 @@ +package pebble + +import ( + "context" + "errors" + "fmt" + + "github.com/cockroachdb/pebble/v2" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/dotc1z/c1zstore" + "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb" +) + +// ErrNothingToRollBack is returned when the sync has grants but none carries +// Sources, so expansion left nothing to remove. +var ErrNothingToRollBack = errors.New("pebble: no grant carries expansion sources; nothing to roll back") + +// ErrRollbackIncompleteCollection is returned for an unfinished sync whose +// collection is not known to be complete. Rolling it back and sealing would +// stamp ended_at on partial data. +var ErrRollbackIncompleteCollection = errors.New("pebble: refusing to roll back expansion before collection completes") + +const rollbackExpansionRoundRows = 10_000 + +// RollbackExpansionResult counts what RollbackExpansion changed, or would +// change on a dry run. SuspectConnectorSourced counts grants with Sources, no +// self-source, and no GrantImmutable; they are in GrantsDeleted unless +// preserved, in which case they are in SuspectPreserved. +type RollbackExpansionResult struct { + GrantsDeleted int + SourcesCleared int + SuspectConnectorSourced int + SuspectPreserved int +} + +// RollbackExpansion restores syncID's grants to their pre-expansion shape, +// classifying each by its Sources the way dotc1z's SQLite RollbackExpansion +// does: a grant whose Sources lack a self-source is deleted, and a grant whose +// Sources include one has them cleared. A pending pass in Expanding or +// Sealing, or any pending pass on a finished sync, is dropped so a replay +// starts a fresh pass. The caller seals: grant indexes, digests, and stats are +// stale until the next EndSync. +func (e *Engine) RollbackExpansion(ctx context.Context, syncID string, dryRun, preserveSuspect bool) (*RollbackExpansionResult, error) { + record, err := e.GetSyncRunRecord(ctx, syncID) + if err != nil { + return nil, fmt.Errorf("pebble: rollback could not load sync %q: %w", syncID, err) + } + finished := record.GetEndedAt() != nil + if !finished && record.GetSyncToken() != "" { + return nil, fmt.Errorf("%w: sync %q holds a legacy checkpoint", ErrRollbackIncompleteCollection, syncID) + } + _, phase, err := e.ledger.PendingWork(ctx, 0, 1) + if err != nil { + return nil, fmt.Errorf("pebble: rollback could not read pending work: %w", err) + } + if !finished && (phase == c1zstore.LedgerQueueAbsent || phase == c1zstore.LedgerQueueCollecting) { + return nil, fmt.Errorf("%w: sync %q is unfinished with pending work %s", ErrRollbackIncompleteCollection, syncID, phase) + } + if phase != c1zstore.LedgerQueueAbsent { + ctxzap.Extract(ctx).Warn("pebble: rollback found a pending pass; a write drops it and a replay starts a new one", + zap.String("sync_id", syncID), + zap.Stringer("phase", phase), + zap.Bool("finished", finished), + zap.Bool("dry_run", dryRun), + ) + } + + res := &RollbackExpansionResult{} + var scanned int + if dryRun { + scanned, err = e.rollbackExpansionRounds(ctx, false, preserveSuspect, res) + } else { + if err := e.SetCurrentSync(ctx, syncID); err != nil { + return nil, err + } + if phase != c1zstore.LedgerQueueAbsent { + if err := e.ledger.Drop(ctx); err != nil { + return nil, fmt.Errorf("pebble: rollback could not drop pending pass: %w", err) + } + } + scanned, err = e.rollbackExpansionRounds(ctx, true, preserveSuspect, res) + } + if err != nil { + return nil, fmt.Errorf("pebble: rollback failed: %w", err) + } + if scanned > 0 && res.GrantsDeleted+res.SourcesCleared+res.SuspectPreserved == 0 { + return nil, fmt.Errorf("%w: sync %q", ErrNothingToRollBack, syncID) + } + return res, nil +} + +// rollbackExpansionRounds commits each round's deletes and clears when write +// is set, and returns the number of grants scanned. +func (e *Engine) rollbackExpansionRounds(ctx context.Context, write, preserveSuspect bool, res *RollbackExpansionResult) (int, error) { + lower := encodeGrantPrefix() + upper := upperBoundOf(lower) + scanned := 0 + for lower != nil { + if err := ctx.Err(); err != nil { + return scanned, err + } + var next []byte + var n int + var err error + if !write { + next, n, err = e.rollbackExpansionRound(lower, upper, nil, preserveSuspect, res) + } else { + err = e.withWrite(func() error { + if err := e.requireCurrentSync(); err != nil { + return err + } + batch := e.db.NewRecordBatch() + defer batch.Close() + var roundErr error + next, n, roundErr = e.rollbackExpansionRound(lower, upper, batch, preserveSuspect, res) + if roundErr != nil { + return roundErr + } + if batch.Empty() { + return nil + } + return batch.Commit(writeOpts(e.opts.durability)) + }) + } + if err != nil { + return scanned, err + } + scanned += n + lower = next + } + return scanned, nil +} + +// rollbackExpansionRound classifies up to rollbackExpansionRoundRows grants +// in [lower, upper), staging each delete or clear into batch when batch is +// non-nil. The iterator is closed on return, before the caller commits. +// Returns the next round's lower bound, or nil when the range is exhausted. +func (e *Engine) rollbackExpansionRound( + lower, upper []byte, + batch *rawdb.RecordBatch, + preserveSuspect bool, + res *RollbackExpansionResult, +) ([]byte, int, error) { + iter, err := e.db.NewIter(&pebble.IterOptions{LowerBound: lower, UpperBound: upper}) + if err != nil { + return nil, 0, err + } + next, n, err := rollbackExpansionRows(iter, batch, preserveSuspect, res) + return next, n, errors.Join(err, iter.Close()) +} + +func rollbackExpansionRows( + iter *pebble.Iterator, + batch *rawdb.RecordBatch, + preserveSuspect bool, + res *RollbackExpansionResult, +) ([]byte, int, error) { + var n int + var err error + var facts []grantSourceFact + var record v3.GrantRecord + var valScratch []byte + for valid := iter.First(); valid; valid = iter.Next() { + if n == rollbackExpansionRoundRows { + return append([]byte(nil), iter.Key()...), n, nil + } + n++ + key, val := iter.Key(), iter.Value() + var isImmutable bool + isImmutable, facts, err = scanGrantContentFactsRawBytes(val, facts[:0]) + if err != nil { + return nil, n, fmt.Errorf("grant %x: %w", key, err) + } + if len(facts) == 0 { + continue + } + var ownEntitlementID string + _, _, ownEntitlementID, _, _, _, err = scanGrantIndexFieldsRaw(val) + if err != nil { + return nil, n, fmt.Errorf("grant %x: %w", key, err) + } + hasSelf := false + for _, f := range facts { + if string(f.key) == ownEntitlementID { + hasSelf = true + break + } + } + + if hasSelf { + res.SourcesCleared++ + if batch == nil { + continue + } + record.Reset() + if err := unmarshalRecord(val, &record); err != nil { + return nil, n, fmt.Errorf("grant %x: %w", key, err) + } + record.SetSources(nil) + valScratch, err = marshalRecordAppend(valScratch[:0], &record) + if err != nil { + return nil, n, fmt.Errorf("grant %x: %w", key, err) + } + if err := batch.StageGrantPutDeferred(key, valScratch, val, record.GetNeedsExpansion()); err != nil { + return nil, n, err + } + continue + } + + suspect := !isImmutable + if suspect { + res.SuspectConnectorSourced++ + if preserveSuspect { + res.SuspectPreserved++ + continue + } + } + res.GrantsDeleted++ + if batch != nil { + if err := batch.StageGrantDelete(key, val); err != nil { + return nil, n, err + } + } + } + return nil, n, iter.Error() +} diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/trusted_import.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/trusted_import.go new file mode 100644 index 00000000..b4cc91a8 --- /dev/null +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/trusted_import.go @@ -0,0 +1,108 @@ +package pebble + +import ( + v3 "github.com/conductorone/baton-sdk/pb/c1/storage/v3" + "github.com/conductorone/baton-sdk/pkg/dotc1z/engine/pebble/internal/rawdb" +) + +func stageTrustedResourceRecords(batch *rawdb.RecordBatch, records []*v3.ResourceRecord) (uint64, error) { + last := make(map[resourceBufKey]int, len(records)) + for i, record := range records { + if record != nil { + last[resourceBufKey{record.GetResourceTypeId(), record.GetResourceId()}] = i + } + } + var staged uint64 + for i, record := range records { + if record == nil || last[resourceBufKey{record.GetResourceTypeId(), record.GetResourceId()}] != i { + continue + } + value, err := marshalRecord(record) + if err != nil { + return 0, err + } + if err := batch.StageResourcePut( + encodeResourceKey(record.GetResourceTypeId(), record.GetResourceId()), + value, + nil, + record.GetResourceTypeId(), + record.GetResourceId(), + ); err != nil { + return 0, err + } + staged++ + } + return staged, nil +} + +func stageTrustedEntitlementRecords(batch *rawdb.RecordBatch, records []*v3.EntitlementRecord) (uint64, error) { + last := make(map[entitlementIdentity]int, len(records)) + for i, record := range records { + if record == nil { + continue + } + identity, err := entitlementIdentityFromRecord(record) + if err != nil { + return 0, err + } + last[identity] = i + } + var staged uint64 + for i, record := range records { + if record == nil { + continue + } + identity, err := entitlementIdentityFromRecord(record) + if err != nil { + return 0, err + } + if last[identity] != i { + continue + } + value, err := marshalRecord(record) + if err != nil { + return 0, err + } + if err := batch.StageEntitlementPut(encodeEntitlementIdentityKey(identity), value, nil); err != nil { + return 0, err + } + staged++ + } + return staged, nil +} + +func stageTrustedGrantRecords(batch *rawdb.RecordBatch, records []*v3.GrantRecord) (uint64, error) { + last := make(map[grantIdentity]int, len(records)) + for i, record := range records { + if record == nil { + continue + } + identity, err := grantIdentityFromRecord(record) + if err != nil { + return 0, err + } + last[identity] = i + } + var staged uint64 + for i, record := range records { + if record == nil { + continue + } + identity, err := grantIdentityFromRecord(record) + if err != nil { + return 0, err + } + if last[identity] != i { + continue + } + value, err := marshalRecord(record) + if err != nil { + return 0, err + } + if err := batch.StageGrantPutTrusted(encodeGrantIdentityKey(identity), value, record.GetNeedsExpansion()); err != nil { + return 0, err + } + staged++ + } + return staged, nil +} diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/pebble_store.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/pebble_store.go index 8cf28a40..b2d21b4e 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/pebble_store.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/pebble_store.go @@ -549,6 +549,36 @@ func (s *pebbleStore) SetSupportsDiff(ctx context.Context, syncID string) error return s.markDirty(s.SyncMeta().MarkSyncSupportsDiff(ctx, syncID)) } +// RollbackExpansion is the Pebble form of (*C1File).RollbackExpansion. +// WithSyncTokenRewrite is ignored: a replay re-expands a finished ledgered +// sync through a new ledger pass, not through its token. +func (s *pebbleStore) RollbackExpansion(ctx context.Context, syncID string, dryRun bool, opts ...RollbackOption) (*RollbackResult, error) { + cfg := &rollbackConfig{} + for _, o := range opts { + o(cfg) + } + res, err := s.Engine.RollbackExpansion(ctx, syncID, dryRun, cfg.preserveSuspect) + switch { + case errors.Is(err, pebble.ErrNothingToRollBack): + return nil, fmt.Errorf("%w: %w", ErrSyncNotExpanded, err) + case errors.Is(err, pebble.ErrRollbackIncompleteCollection): + return nil, fmt.Errorf("%w: %w", ErrSyncNotFinished, err) + case err != nil: + return nil, err + } + if !dryRun { + s.MarkDirty() + } + return &RollbackResult{ + SyncID: syncID, + GrantsDeleted: res.GrantsDeleted, + SourcesCleared: res.SourcesCleared, + SuspectConnectorSourced: res.SuspectConnectorSourced, + SuspectPreserved: res.SuspectPreserved, + DryRun: dryRun, + }, nil +} + func (s *pebbleStore) PutGrants(ctx context.Context, grants ...*v2.Grant) error { if err := s.writeHook(ctx, "PutGrants"); err != nil { return err @@ -629,6 +659,14 @@ type dirtyPageWriter struct { store *pebbleStore } +func (w *dirtyPageWriter) SetTrustedImport() error { + writer, ok := w.PageWriter.(c1zstore.TrustedImportPageWriter) + if !ok { + return errors.New("pebble page writer does not support trusted import") + } + return writer.SetTrustedImport() +} + func (w *dirtyPageWriter) Commit(ctx context.Context, id c1zstore.LedgerActionIdentity, row *c1zstore.LedgerRow) error { return w.store.markDirty(w.PageWriter.Commit(ctx, id, row)) } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/rollback_expansion.go b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/rollback_expansion.go index 0fc80f6c..27d4da13 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/rollback_expansion.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/dotc1z/rollback_expansion.go @@ -22,15 +22,16 @@ import ( // the entitlement graph persisted in the sync token, not in the grant // rows. Deleting expansion output from such a sync would strand that // graph: a later resume would treat the affected edges as already -// expanded and never regenerate the deleted grants. Rollback only -// operates on finished syncs. +// expanded and never regenerate the deleted grants. SQLite rollback only +// operates on finished syncs. Pebble wraps pebble.ErrRollbackIncompleteCollection +// in it, and accepts an unfinished sync whose collection completed. var ErrSyncNotFinished = errors.New("c1z: refusing to roll back expansion on an unfinished sync") -// ErrSyncNotExpanded is returned when a rollback targets a finished sync -// that never ran grant expansion (no supports_diff marker). There is -// nothing to roll back, and a missing marker also means a resumable -// graph was never produced. -var ErrSyncNotExpanded = errors.New("c1z: sync did not run grant expansion (no supports_diff marker); nothing to roll back") +// ErrSyncNotExpanded is returned when a rollback targets a sync that never +// ran grant expansion. SQLite decides this by the supports_diff marker, whose +// absence also means a resumable graph was never produced; Pebble decides it +// by finding no grant with Sources. +var ErrSyncNotExpanded = errors.New("c1z: sync did not run grant expansion; nothing to roll back") // rollbackPageSize bounds how many grant rows are read per round, and — since // each page's mutations commit in their own transaction — also bounds the @@ -151,7 +152,7 @@ func (c *C1File) RollbackExpansion(ctx context.Context, syncID string, dryRun bo return nil, fmt.Errorf("%w: %q", ErrSyncNotFinished, syncID) } if !sr.SupportsDiff { - return nil, fmt.Errorf("%w: %q", ErrSyncNotExpanded, syncID) + return nil, fmt.Errorf("%w: %q has no supports_diff marker", ErrSyncNotExpanded, syncID) } if err := c.SetCurrentSync(ctx, syncID); err != nil { diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/lambda/grpc/util.go b/vendor/github.com/conductorone/baton-sdk/pkg/lambda/grpc/util.go index ce75a7b5..212967b1 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/lambda/grpc/util.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/lambda/grpc/util.go @@ -2,6 +2,7 @@ package grpc import ( "context" + "crypto/tls" "errors" "fmt" "io" @@ -233,6 +234,11 @@ func statusForApplicationError(err error) *status.Status { return status.Newf(codes.Unavailable, "connection reset: %s", err) } + var certErr *tls.CertificateVerificationError + if errors.As(err, &certErr) { + return status.Newf(codes.FailedPrecondition, "tls certificate verification failed: %s", certErr) + } + var urlErr *url.Error if errors.As(err, &urlErr) { if urlErr.Timeout() { diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/sdk/version.go b/vendor/github.com/conductorone/baton-sdk/pkg/sdk/version.go index 8c9a5ad7..447525f1 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/sdk/version.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/sdk/version.go @@ -1,3 +1,3 @@ package sdk -const Version = "v0.36.0" +const Version = "v0.42.2" diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/session/session_server.go b/vendor/github.com/conductorone/baton-sdk/pkg/session/session_server.go index 622ec1ee..c49f2743 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/session/session_server.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/session/session_server.go @@ -2,49 +2,104 @@ package session import ( "context" + "errors" "fmt" "log" "net" + "sync" v1 "github.com/conductorone/baton-sdk/pb/c1/connectorapi/baton/v1" "github.com/conductorone/baton-sdk/pkg/types/sessions" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) var _ v1.BatonSessionServiceServer = (*GRPCSessionServer)(nil) +var _ sessions.SessionStoreBinder = (*GRPCSessionServer)(nil) type GRPCSessionServer struct { - // v1.UnimplementedBatonSessionServiceServer - store sessions.SessionStore + mu sync.RWMutex + bindings map[string]*boundSessionStore +} + +type boundSessionStore struct { + store sessions.SessionStore + inflight sync.WaitGroup } func NewGRPCSessionServer() *GRPCSessionServer { return &GRPCSessionServer{} } -type SetSessionStore interface { - SetSessionStore(ctx context.Context, store sessions.SessionStore) +func (s *GRPCSessionServer) BindSessionStore(ctx context.Context, syncID string, store sessions.SessionStore) (sessions.UnbindSessionStore, error) { + if syncID == "" { + return nil, errors.New("session store binding requires a sync id") + } + if store == nil { + return nil, fmt.Errorf("session store binding for sync %s has no store", syncID) + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.bindings[syncID]; ok { + return nil, fmt.Errorf("session store already bound for sync %s", syncID) + } + if s.bindings == nil { + s.bindings = make(map[string]*boundSessionStore) + } + b := &boundSessionStore{store: store} + s.bindings[syncID] = b + var once sync.Once + return func(ctx context.Context) error { + var err error + once.Do(func() { err = s.unbind(ctx, syncID, b) }) + return err + }, nil } -func (s *GRPCSessionServer) SetSessionStore(ctx context.Context, store sessions.SessionStore) { - s.store = store -} +func (s *GRPCSessionServer) unbind(ctx context.Context, syncID string, b *boundSessionStore) error { + s.mu.Lock() + delete(s.bindings, syncID) + s.mu.Unlock() -func (s *GRPCSessionServer) Validate() error { - if s.store == nil { - return fmt.Errorf("session store is not set") + drained := make(chan struct{}) + go func() { + b.inflight.Wait() + close(drained) + }() + select { + case <-drained: + return nil + case <-ctx.Done(): + return fmt.Errorf("session store for sync %s still has requests in flight: %w", syncID, context.Cause(ctx)) } +} - return nil +// The caller runs the returned func when the request is done; unbind waits on it. +func (s *GRPCSessionServer) acquire(syncID string) (sessions.SessionStore, func(), error) { + if syncID == "" { + return nil, nil, status.Error(codes.InvalidArgument, "session request has no sync id") + } + s.mu.RLock() + defer s.mu.RUnlock() + b, ok := s.bindings[syncID] + if !ok { + return nil, nil, status.Errorf(codes.FailedPrecondition, "no session store bound for sync %s", syncID) + } + b.inflight.Add(1) + return b.store, b.inflight.Done, nil } func (s *GRPCSessionServer) Get(ctx context.Context, req *v1.GetRequest) (*v1.GetResponse, error) { - if err := s.Validate(); err != nil { + store, release, err := s.acquire(req.GetSyncId()) + if err != nil { return nil, err } + defer release() - value, found, err := s.store.Get(ctx, req.GetKey(), sessions.WithSyncID(req.GetSyncId()), sessions.WithPrefix(req.GetPrefix())) + value, found, err := store.Get(ctx, req.GetKey(), sessions.WithSyncID(req.GetSyncId()), sessions.WithPrefix(req.GetPrefix())) if err != nil { return nil, fmt.Errorf("failed to get value from cache: %w", err) } @@ -56,11 +111,13 @@ func (s *GRPCSessionServer) Get(ctx context.Context, req *v1.GetRequest) (*v1.Ge } func (s *GRPCSessionServer) GetMany(ctx context.Context, req *v1.GetManyRequest) (*v1.GetManyResponse, error) { - if err := s.Validate(); err != nil { + store, release, err := s.acquire(req.GetSyncId()) + if err != nil { return nil, err } + defer release() - values, unprocessedKeys, err := s.store.GetMany( + values, unprocessedKeys, err := store.GetMany( ctx, req.GetKeys(), sessions.WithSyncID(req.GetSyncId()), @@ -86,11 +143,13 @@ func (s *GRPCSessionServer) GetMany(ctx context.Context, req *v1.GetManyRequest) } func (s *GRPCSessionServer) Set(ctx context.Context, req *v1.SetRequest) (*v1.SetResponse, error) { - if err := s.Validate(); err != nil { + store, release, err := s.acquire(req.GetSyncId()) + if err != nil { return nil, err } + defer release() - err := s.store.Set(ctx, req.GetKey(), req.GetValue(), sessions.WithSyncID(req.GetSyncId()), sessions.WithPrefix(req.GetPrefix())) + err = store.Set(ctx, req.GetKey(), req.GetValue(), sessions.WithSyncID(req.GetSyncId()), sessions.WithPrefix(req.GetPrefix())) if err != nil { return nil, fmt.Errorf("failed to set value in cache: %w", err) } @@ -99,11 +158,13 @@ func (s *GRPCSessionServer) Set(ctx context.Context, req *v1.SetRequest) (*v1.Se } func (s *GRPCSessionServer) SetMany(ctx context.Context, req *v1.SetManyRequest) (*v1.SetManyResponse, error) { - if err := s.Validate(); err != nil { + store, release, err := s.acquire(req.GetSyncId()) + if err != nil { return nil, err } + defer release() - err := s.store.SetMany(ctx, req.GetValues(), sessions.WithSyncID(req.GetSyncId()), sessions.WithPrefix(req.GetPrefix())) + err = store.SetMany(ctx, req.GetValues(), sessions.WithSyncID(req.GetSyncId()), sessions.WithPrefix(req.GetPrefix())) if err != nil { return nil, fmt.Errorf("failed to set many values in cache: %w", err) } @@ -112,11 +173,13 @@ func (s *GRPCSessionServer) SetMany(ctx context.Context, req *v1.SetManyRequest) } func (s *GRPCSessionServer) Delete(ctx context.Context, req *v1.DeleteRequest) (*v1.DeleteResponse, error) { - if err := s.Validate(); err != nil { + store, release, err := s.acquire(req.GetSyncId()) + if err != nil { return nil, err } + defer release() - err := s.store.Delete(ctx, req.GetKey(), sessions.WithSyncID(req.GetSyncId()), sessions.WithPrefix(req.GetPrefix())) + err = store.Delete(ctx, req.GetKey(), sessions.WithSyncID(req.GetSyncId()), sessions.WithPrefix(req.GetPrefix())) if err != nil { return nil, fmt.Errorf("failed to delete value from cache: %w", err) } @@ -125,12 +188,14 @@ func (s *GRPCSessionServer) Delete(ctx context.Context, req *v1.DeleteRequest) ( } func (s *GRPCSessionServer) DeleteMany(ctx context.Context, req *v1.DeleteManyRequest) (*v1.DeleteManyResponse, error) { - if err := s.Validate(); err != nil { + store, release, err := s.acquire(req.GetSyncId()) + if err != nil { return nil, err } + defer release() for _, key := range req.GetKeys() { - err := s.store.Delete( + err := store.Delete( ctx, key, sessions.WithSyncID(req.GetSyncId()), @@ -145,13 +210,17 @@ func (s *GRPCSessionServer) DeleteMany(ctx context.Context, req *v1.DeleteManyRe } func (s *GRPCSessionServer) Clear(ctx context.Context, req *v1.ClearRequest) (*v1.ClearResponse, error) { - if s.store == nil { - // we sometimes clean up the session store after the connector is done - ctxzap.Extract(ctx).Warn("session store is not set") + store, release, err := s.acquire(req.GetSyncId()) + if status.Code(err) == codes.FailedPrecondition { + ctxzap.Extract(ctx).Warn("no session store bound to clear", zap.String("sync_id", req.GetSyncId())) return &v1.ClearResponse{}, nil } + if err != nil { + return nil, err + } + defer release() - err := s.store.Clear(ctx, sessions.WithSyncID(req.GetSyncId()), sessions.WithPrefix(req.GetPrefix())) + err = store.Clear(ctx, sessions.WithSyncID(req.GetSyncId()), sessions.WithPrefix(req.GetPrefix())) if err != nil { return nil, fmt.Errorf("failed to clear cache: %w", err) } @@ -160,11 +229,13 @@ func (s *GRPCSessionServer) Clear(ctx context.Context, req *v1.ClearRequest) (*v } func (s *GRPCSessionServer) GetAll(ctx context.Context, req *v1.GetAllRequest) (*v1.GetAllResponse, error) { - if err := s.Validate(); err != nil { + store, release, err := s.acquire(req.GetSyncId()) + if err != nil { return nil, err } + defer release() - values, nextPageToken, err := s.store.GetAll( + values, nextPageToken, err := store.GetAll( ctx, req.PageToken, sessions.WithSyncID(req.GetSyncId()), diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/sync/config.go b/vendor/github.com/conductorone/baton-sdk/pkg/sync/config.go index c5ea9b68..dbd8d50e 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/sync/config.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/sync/config.go @@ -66,7 +66,7 @@ type syncConfig struct { skipEntitlementsAndGrants bool skipGrants bool syncType connectorstore.SyncType - setSessionStore sessions.SetSessionStore + sessionStoreBinder sessions.SessionStoreBinder syncResourceTypes []string workerCount int // If 1, sync is sequential (default). If > 1, sync operations are done in parallel. metricsHandler metrics.Handler diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/sync/ledger_sync.go b/vendor/github.com/conductorone/baton-sdk/pkg/sync/ledger_sync.go index cffd77ce..477273c8 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/sync/ledger_sync.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/sync/ledger_sync.go @@ -156,6 +156,9 @@ func (s *syncer) skipLedgerSync(ctx context.Context) error { return err } s.syncID = syncID + if err := s.sessionBinding.bind(ctx, s.cfg.sessionStoreBinder, syncID, s.store, s.ledgered, s.recordSessionOp); err != nil { + return err + } s.caps.pageLedger.SetRetainLedgerTokens(s.cfg.retainLedgerTokens) seed := c1zstore.LedgerWork{Action: c1zstore.LedgerChild{Identity: c1zstore.LedgerActionIdentity{Op: InitOp.String()}}} if err := s.caps.pageLedger.BeginCollecting(ctx, []c1zstore.LedgerWork{seed}, nil); err != nil { diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/sync/session_binding.go b/vendor/github.com/conductorone/baton-sdk/pkg/sync/session_binding.go new file mode 100644 index 00000000..bfb384f1 --- /dev/null +++ b/vendor/github.com/conductorone/baton-sdk/pkg/sync/session_binding.go @@ -0,0 +1,55 @@ +package sync + +import ( + "context" + "fmt" + "time" + + "github.com/conductorone/baton-sdk/pkg/dotc1z/c1zstore" + "github.com/conductorone/baton-sdk/pkg/session" + "github.com/conductorone/baton-sdk/pkg/types/sessions" +) + +const sessionDrainTimeout = 30 * time.Second + +type sessionBinding struct { + unbind sessions.UnbindSessionStore +} + +func (b *sessionBinding) bind( + ctx context.Context, + binder sessions.SessionStoreBinder, + syncID string, + store c1zstore.Store, + ledgered bool, + onOp session.SessionOpObserver, +) error { + if binder == nil { + return nil + } + if err := b.release(ctx); err != nil { + return err + } + sessionStore := store.SessionStore() + if ledgered { + sessionStore = ledgerSessionStore{SessionStore: sessionStore} + } + kind := "c1z_" + store.Metadata().Engine + unbind, err := binder.BindSessionStore(ctx, syncID, session.NewInstrumentedSessionStore(sessionStore, kind, "", onOp)) + if err != nil { + return fmt.Errorf("binding session store for sync %s: %w", syncID, err) + } + b.unbind = unbind + return nil +} + +func (b *sessionBinding) release(ctx context.Context) error { + if b.unbind == nil { + return nil + } + unbind := b.unbind + b.unbind = nil + ctx, cancel := context.WithTimeout(ctx, sessionDrainTimeout) + defer cancel() + return unbind(ctx) +} diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/sync/syncer.go b/vendor/github.com/conductorone/baton-sdk/pkg/sync/syncer.go index e45fe98d..392e7d0b 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/sync/syncer.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/sync/syncer.go @@ -192,6 +192,7 @@ type syncer struct { lastCheckPointTime time.Time counts *progresslog.ProgressLog syncID string + sessionBinding sessionBinding skipEGForResourceType syncMap[string, bool] skipEntitlementsForResourceType syncMap[string, bool] skipGrantsForResourceType syncMap[string, bool] @@ -639,8 +640,7 @@ func (s *syncer) syncSummaryFields(span trace.Span) []zap.Field { // recordSessionOp feeds store-side session observations into the stats // token under store.-prefixed ops (the c1z view of session traffic). // Session traffic is driven by connector RPCs the syncer initiated, so -// s.stats is set before any op arrives; the nil guard covers the Validate -// window between store load and the decode in Sync that sets it. +// s.stats is set before any op arrives. func (s *syncer) recordSessionOp(op string, elapsed time.Duration, opErr error) { if !s.recordStats { return @@ -1009,6 +1009,9 @@ func (s *syncer) Sync(ctx context.Context) error { l.Error("no syncID found after starting or resuming sync", zap.Error(err)) return err } + if err = s.sessionBinding.bind(ctx, s.cfg.sessionStoreBinder, syncID, s.store, s.ledgered, s.recordSessionOp); err != nil { + return err + } span.SetAttributes(attribute.String("sync_id", syncID)) @@ -3896,18 +3899,6 @@ func (s *syncer) loadStore(ctx context.Context) error { _ = store.Close(ctx) return s.storeAttachErr } - if s.cfg.setSessionStore != nil { - // Instrumented so session-store cost is attributable in the sync - // stats instead of vanishing into inflated connector-call latency - // (e.g. a broken backend whose every request times out before the - // connector falls back to real work). - kind := "c1z_" + store.Metadata().Engine - sessionStore := store.SessionStore() - if s.ledgered { - sessionStore = ledgerSessionStore{SessionStore: sessionStore} - } - s.cfg.setSessionStore.SetSessionStore(ctx, session.NewInstrumentedSessionStore(sessionStore, kind, "", s.recordSessionOp)) - } // Now that s.store is populated, wire the expand progress log's size // provider. NewSyncer could not do this when the caller used @@ -3993,10 +3984,11 @@ func (s *syncer) Close(ctx context.Context) error { var errs []error - var storeCloseErr error + if unbindErr := s.sessionBinding.release(finalizeCtx); unbindErr != nil { + ctxzap.Extract(ctx).Warn("closing store with session requests still in flight", zap.Error(unbindErr)) + } if s.store != nil { - storeCloseErr = s.store.Close(finalizeCtx) - if storeCloseErr != nil { + if storeCloseErr := s.store.Close(finalizeCtx); storeCloseErr != nil { errs = append(errs, fmt.Errorf("error closing store: %w", storeCloseErr)) } } @@ -4191,9 +4183,12 @@ func WithTargetedSyncResources(resources []*v2.Resource) SyncOpt { } } -func WithSessionStore(sessionStore sessions.SetSessionStore) SyncOpt { +// WithSessionStoreBinder binds the sync's store under its sync ID from the +// moment the ID is known until Close, so the connector's session requests for +// this sync reach it. +func WithSessionStoreBinder(binder sessions.SessionStoreBinder) SyncOpt { return func(s *syncer) { - s.cfg.setSessionStore = sessionStore + s.cfg.sessionStoreBinder = binder } } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/tasks/c1api/full_sync.go b/vendor/github.com/conductorone/baton-sdk/pkg/tasks/c1api/full_sync.go index 21e2e32f..6ff22cca 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/tasks/c1api/full_sync.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/tasks/c1api/full_sync.go @@ -18,10 +18,10 @@ import ( "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/dotc1z/c1zstore" formatv3 "github.com/conductorone/baton-sdk/pkg/dotc1z/format/v3" - "github.com/conductorone/baton-sdk/pkg/session" sdkSync "github.com/conductorone/baton-sdk/pkg/sync" "github.com/conductorone/baton-sdk/pkg/tasks" "github.com/conductorone/baton-sdk/pkg/types" + "github.com/conductorone/baton-sdk/pkg/types/sessions" "github.com/conductorone/baton-sdk/pkg/uotel" ) @@ -250,8 +250,8 @@ func (c *fullSyncTaskHandler) sync(ctx context.Context, c1zPath string) error { syncOpts = append(syncOpts, sdkSync.WithSyncResourceTypes(syncResourceTypeIDs)) } - if setSessionStore, ok := cc.(session.SetSessionStore); ok { - syncOpts = append(syncOpts, sdkSync.WithSessionStore(setSessionStore)) + if binder, ok := cc.(sessions.SessionStoreBinder); ok { + syncOpts = append(syncOpts, sdkSync.WithSessionStoreBinder(binder)) } syncer, err := sdkSync.NewSyncer(ctx, c.helpers.ConnectorClient(), syncOpts...) diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/tasks/local/syncer.go b/vendor/github.com/conductorone/baton-sdk/pkg/tasks/local/syncer.go index 4385c384..ab5291fd 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/tasks/local/syncer.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/tasks/local/syncer.go @@ -11,10 +11,10 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" v1 "github.com/conductorone/baton-sdk/pb/c1/connectorapi/baton/v1" "github.com/conductorone/baton-sdk/pkg/dotc1z/c1zstore" - "github.com/conductorone/baton-sdk/pkg/session" sdkSync "github.com/conductorone/baton-sdk/pkg/sync" "github.com/conductorone/baton-sdk/pkg/tasks" "github.com/conductorone/baton-sdk/pkg/types" + "github.com/conductorone/baton-sdk/pkg/types/sessions" "github.com/conductorone/baton-sdk/pkg/uotel" "github.com/conductorone/baton-sdk/pkg/uotel/uotelzap" ) @@ -120,11 +120,6 @@ func (m *localSyncer) Process(ctx context.Context, task *v1.Task, cc types.Conne var err error defer func() { uotel.EndSpanWithError(span, err) }() - var setSessionStore session.SetSessionStore - if ssetSessionStore, ok := cc.(session.SetSessionStore); ok { - setSessionStore = ssetSessionStore - } - syncOpts := []sdkSync.SyncOpt{ sdkSync.WithC1ZPath(m.dbPath), sdkSync.WithTmpDir(m.tmpDir), @@ -134,10 +129,12 @@ func (m *localSyncer) Process(ctx context.Context, task *v1.Task, cc types.Conne sdkSync.WithTargetedSyncResources(m.targetedSyncResources), sdkSync.WithSkipEntitlementsAndGrants(m.skipEntitlementsAndGrants), sdkSync.WithSkipGrants(m.skipGrants), - sdkSync.WithSessionStore(setSessionStore), sdkSync.WithSyncResourceTypes(m.syncResourceTypeIDs), sdkSync.WithWorkerCount(m.workerCount), } + if binder, ok := cc.(sessions.SessionStoreBinder); ok { + syncOpts = append(syncOpts, sdkSync.WithSessionStoreBinder(binder)) + } if m.storageEngine != "" { syncOpts = append(syncOpts, sdkSync.WithStorageEngine(m.storageEngine)) } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/types/sessions/sessions.go b/vendor/github.com/conductorone/baton-sdk/pkg/types/sessions/sessions.go index 841de45f..9aece44a 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/types/sessions/sessions.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/types/sessions/sessions.go @@ -72,6 +72,12 @@ func SetSyncIDInContext(ctx context.Context, syncID string) context.Context { return context.WithValue(ctx, SyncIDKey{}, syncID) } -type SetSessionStore interface { - SetSessionStore(ctx context.Context, store SessionStore) +// UnbindSessionStore removes the binding, then waits until requests already +// using the store finish or ctx ends. +type UnbindSessionStore func(ctx context.Context) error + +// SessionStoreBinder routes session requests that carry syncID to store until +// the returned UnbindSessionStore runs. +type SessionStoreBinder interface { + BindSessionStore(ctx context.Context, syncID string, store SessionStore) (UnbindSessionStore, error) } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/ugrpc/c1_credential_provider.go b/vendor/github.com/conductorone/baton-sdk/pkg/ugrpc/c1_credential_provider.go index 29332545..3404f850 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/ugrpc/c1_credential_provider.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/ugrpc/c1_credential_provider.go @@ -184,7 +184,7 @@ type c1CredentialProvider struct { func (c *c1CredentialProvider) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) { token, err := c.tokenSource.Token() if err != nil { - return nil, err + return nil, credsStatus(err) } ri, _ := credentials.RequestInfoFromContext(ctx) @@ -202,6 +202,33 @@ func (c *c1CredentialProvider) RequireTransportSecurity() bool { return true } +// credsStatus returns err with a code PerRPCCredentials may carry. +// grpc rewrites InvalidArgument, NotFound, AlreadyExists, FailedPrecondition, +// Aborted, OutOfRange, and DataLoss to Internal. Bootstrap retries Internal +// with no attempt limit. A certificate failure will not succeed on a retry, +// so it becomes Unauthenticated, which Bootstrap does not retry. The other +// restricted codes stay retryable as Unavailable. +func credsStatus(err error) error { + st, ok := status.FromError(err) + if !ok || !restrictedControlPlaneCode(st.Code()) { + return err + } + if st.Code() == codes.FailedPrecondition { + return status.Error(codes.Unauthenticated, st.Message()) + } + return status.Error(codes.Unavailable, st.Message()) +} + +func restrictedControlPlaneCode(code codes.Code) bool { + switch code { + case codes.InvalidArgument, codes.NotFound, codes.AlreadyExists, + codes.FailedPrecondition, codes.Aborted, codes.OutOfRange, codes.DataLoss: + return true + default: + return false + } +} + func NewC1CredentialProvider(ctx context.Context, clientID string, clientSecret string) (credentials.PerRPCCredentials, string, string, error) { tokenSource, clientName, tokenHost, err := newC1TokenSource(ctx, clientID, clientSecret) if err != nil { diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/client.go b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/client.go index 3237ebcf..4366a4a6 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/client.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/client.go @@ -129,7 +129,7 @@ func (o cacheKeyHeadersOption) applyCache(c *cacheKeyConfig) { } // CacheKeyHeaders returns a CacheOption that folds the named headers into -// the cache key computed by CreateCacheKey (and by GoCache/DBCache's +// the cache key computed by CreateCacheKey (and by GoCache's // Get/Set), beyond the default set (Accept, Content-Type, Cookie, Range). // The value folded in is always read from req.Header at key-computation // time, so the key can never describe a value other than the one actually diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/dbcache.go b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/dbcache.go deleted file mode 100644 index 860af5cf..00000000 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/dbcache.go +++ /dev/null @@ -1,480 +0,0 @@ -package uhttp - -import ( - "bufio" - "bytes" - "context" - "database/sql" - "errors" - "fmt" - "net/http" - "net/http/httputil" - "os" - "path/filepath" - "time" - - "github.com/doug-martin/goqu/v9" - // NOTE: required to register the dialect for goqu. - // - // If you remove this import, goqu.Dialect("sqlite3") will - // return a copy of the default dialect, which is not what we want, - // and allocates a ton of memory. - _ "github.com/doug-martin/goqu/v9/dialect/sqlite3" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" - "go.uber.org/zap" - _ "modernc.org/sqlite" -) - -type DBCache struct { - rawDb *sql.DB - db *goqu.Database - // Cleanup interval, close and remove db - waitDuration time.Duration - // Cache duration for removing expired keys - expirationTime time.Duration - // Database path - location string - // Enable statistics(hits, misses) - stats bool -} - -type CacheRow struct { - Key string - Value []byte - Expires time.Time - LastAccess time.Time - Url string -} - -const ( - failStartTransaction = "Failed to start a transaction" - errQueryingTable = "Error querying cache table" - failRollback = "Failed to rollback transaction" - failInsert = "Failed to insert response data into cache table" - failScanResponse = "Failed to scan rows for cached response" - cacheTTLThreshold = time.Duration(60) * time.Second - cacheTTLMultiplier uint64 = 5 -) - -var errNilConnection = errors.New("database connection is nil") - -var defaultWaitDuration = cacheTTLThreshold // Default Cleanup interval, 60 seconds - -const tableName = "http_cache" - -// TODO (ggreer): obey c1z-temp-dir CLI arg or environment variable -func NewDBCache(ctx context.Context, cfg CacheConfig) (*DBCache, error) { - var ( - err error - dc = &DBCache{ - waitDuration: defaultWaitDuration, // Default Cleanup interval, 60 seconds - stats: true, - expirationTime: cfg.TTL, - } - ) - l := ctxzap.Extract(ctx) - dc, err = dc.load(ctx) - if err != nil { - l.Warn("Failed to open database", zap.Error(err)) - return nil, err - } - - // Create cache table and index - _, err = dc.db.ExecContext(ctx, ` - CREATE TABLE IF NOT EXISTS http_cache( - key TEXT PRIMARY KEY, - value BLOB, - expires INT, - lastAccess TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, - url TEXT - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_cache_key ON http_cache (key); - CREATE INDEX IF NOT EXISTS expires ON http_cache (expires); - CREATE INDEX IF NOT EXISTS lastAccess ON http_cache (lastAccess); - CREATE TABLE IF NOT EXISTS http_stats( - id INTEGER PRIMARY KEY, - key TEXT, - hits INT DEFAULT 0, - misses INT DEFAULT 0 - ); - DELETE FROM http_cache; - DELETE FROM http_stats;`) - if err != nil { - l.Warn("Failed to create cache table in database", zap.Error(err)) - return nil, err - } - - if cfg.TTL <= 0 { - l.Debug("Cache TTL is 0. Disabling cache.") - return nil, nil - } - - if cfg.TTL > cacheTTLThreshold { - dc.waitDuration = cfg.TTL * time.Duration(cacheTTLMultiplier) // set as a fraction of the Cache TTL - } - - go func(waitDuration, expirationTime time.Duration) { - ctxWithTimeout, cancel := context.WithTimeout( - ctx, - waitDuration, - ) - defer cancel() - // TODO: I think this should be wait duration - ticker := time.NewTicker(expirationTime) - defer ticker.Stop() - for { - select { - case <-ctxWithTimeout.Done(): - // ctx done, shutting down cache cleanup routine - ticker.Stop() - err := dc.cleanup(ctx) - if err != nil { - l.Warn("shutting down cache failed", zap.Error(err)) - } - return - case <-ticker.C: - err := dc.deleteExpired(ctx) - if err != nil { - l.Warn("Failed to delete expired cache entries", zap.Error(err)) - } - } - } - }(dc.waitDuration, dc.expirationTime) - - return dc, nil -} - -func (d *DBCache) load(ctx context.Context) (*DBCache, error) { - l := ctxzap.Extract(ctx) - cacheDir, err := os.UserCacheDir() - if err != nil { - l.Warn("Failed to read user cache directory", zap.Error(err)) - return nil, err - } - - file := filepath.Join(cacheDir, "lcache.db") - - l.Debug("Opening database", zap.String("location", file)) - rawDB, err := sql.Open("sqlite", file) - if err != nil { - return nil, err - } - l.Debug("Opened database", zap.String("location", file)) - - // Commit all derived state together so a sql.Open failure can't leave the - // receiver half-initialised (location set, db nil). - d.location = file - d.db = goqu.New("sqlite3", rawDB) - d.rawDb = rawDB - return d, nil -} - -func (d *DBCache) removeDB(ctx context.Context) error { - _, err := os.Stat(d.location) - if errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("file not found %s", d.location) - } - - err = d.close(ctx) - if err != nil { - return err - } - - err = os.Remove(d.location) - if err != nil { - ctxzap.Extract(ctx).Warn("error removing cache database", zap.Error(err)) - return err - } - - return nil -} - -// Get returns cached response (if exists). -func (d *DBCache) Get(req *http.Request, opts ...CacheOption) (*http.Response, error) { - var ( - isFound = false - resp *http.Response - ) - key, err := CreateCacheKey(req, opts...) - if err != nil { - return nil, err - } - ctx := req.Context() - l := ctxzap.Extract(ctx) - - entry, err := d.pick(ctx, key) - if err == nil && len(entry) > 0 { - r := bufio.NewReader(bytes.NewReader(entry)) - resp, err = http.ReadResponse(r, nil) - if err != nil { - return nil, err - } - - isFound = true - } - - field := "misses" - if isFound { - field = "hits" - } - err = d.updateStats(ctx, field, key) - if err != nil { - l.Warn("Failed to update cache stats", zap.Error(err), zap.String("field", field)) - } - - return resp, nil -} - -func (d *DBCache) pick(ctx context.Context, key string) ([]byte, error) { - if d.db == nil { - return nil, errNilConnection - } - - l := ctxzap.Extract(ctx) - ds := goqu.From(tableName).Select("value").Where(goqu.Ex{"key": key}) - query, args, err := ds.ToSQL() - if err != nil { - l.Warn("Failed to create select statement", zap.Error(err)) - return nil, err - } - - var value []byte - row := d.db.QueryRowContext(ctx, query, args...) - err = row.Scan(&value) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - l.Warn(errQueryingTable, zap.Error(err), zap.String("sql", query), zap.Any("args", args)) - return nil, err - } - - return value, nil -} - -// Set stores and save response in the db. -func (d *DBCache) Set(req *http.Request, value *http.Response, opts ...CacheOption) error { - key, err := CreateCacheKey(req, opts...) - if err != nil { - return err - } - cacheableResponse, err := httputil.DumpResponse(value, true) - if err != nil { - return err - } - - url := req.URL.String() - - err = d.insert(req.Context(), - key, - cacheableResponse, - url, - ) - if err != nil { - return err - } - - return nil -} - -func (d *DBCache) cleanup(ctx context.Context) error { - l := ctxzap.Extract(ctx) - stats, err := d.getStats(ctx) - if err != nil { - l.Warn("error getting stats", zap.Error(err)) - return err - } - - l.Debug("summary and stats", zap.Any("stats", stats)) - return d.removeDB(ctx) -} - -// Insert data into the cache table. -func (d *DBCache) insert(ctx context.Context, key string, bytes []byte, url string) error { - if d.db == nil { - return errNilConnection - } - - l := ctxzap.Extract(ctx) - - tx, err := d.db.Begin() - if err != nil { - l.Warn(failStartTransaction, zap.Error(err)) - return err - } - - ds := goqu.Insert(tableName).Rows( - CacheRow{ - Key: key, - Value: bytes, - Expires: time.Now().Add(d.expirationTime), - Url: url, - }, - ) - ds = ds.OnConflict(goqu.DoUpdate("key", CacheRow{ - Key: key, - Value: bytes, - Expires: time.Now().Add(d.expirationTime), - Url: url, - })) - insertSQL, args, err := ds.ToSQL() - if err != nil { - l.Warn("Failed to create insert statement", zap.Error(err)) - return err - } - _, err = tx.ExecContext(ctx, insertSQL, args...) - if err != nil { - if errtx := tx.Rollback(); errtx != nil { - l.Warn(failRollback, zap.Error(errtx)) - } - - l.Warn(failInsert, zap.Error(err), zap.String("sql", insertSQL)) - return err - } - - err = tx.Commit() - if err != nil { - if errtx := tx.Rollback(); errtx != nil { - l.Warn(failRollback, zap.Error(errtx)) - } - - l.Warn(failInsert, zap.Error(err), zap.String("sql", insertSQL)) - return err - } - - return nil -} - -// Delete all expired items from the cache. -func (d *DBCache) deleteExpired(ctx context.Context) error { - if d.db == nil { - return errNilConnection - } - - l := ctxzap.Extract(ctx) - tx, err := d.db.Begin() - if err != nil { - l.Warn(failStartTransaction, zap.Error(err)) - return err - } - - _, err = d.db.ExecContext(ctx, "DELETE FROM http_cache WHERE expires < ?", time.Now().UnixNano()) - if err != nil { - if errtx := tx.Rollback(); errtx != nil { - l.Warn(failRollback, zap.Error(errtx)) - } - - l.Warn("Failed to delete expired cache entries", zap.Error(err)) - return err - } - - err = tx.Commit() - if err != nil { - if errtx := tx.Rollback(); errtx != nil { - l.Warn(failRollback, zap.Error(errtx)) - } - l.Warn("Failed to commit after deleting expired cache entries", zap.Error(err)) - } - - return err -} - -func (d *DBCache) close(ctx context.Context) error { - if d.rawDb == nil { - return errNilConnection - } - - err := d.rawDb.Close() - if err != nil { - ctxzap.Extract(ctx).Warn("Failed to close database connection", zap.Error(err)) - } - - return err -} - -func (d *DBCache) updateStats(ctx context.Context, field, key string) error { - if !d.stats { - return nil - } - - if d.db == nil { - return errNilConnection - } - l := ctxzap.Extract(ctx) - tx, err := d.db.Begin() - if err != nil { - l.Warn(failStartTransaction, zap.Error(err)) - return err - } - - _, err = d.db.ExecContext(ctx, fmt.Sprintf("INSERT INTO http_stats(key, %s) values(?, 1)", field), key) - if err != nil { - if errtx := tx.Rollback(); errtx != nil { - l.Warn(failRollback, zap.Error(errtx)) - } - - l.Warn("error updating "+field, zap.Error(err)) - return err - } - - err = tx.Commit() - if err != nil { - if errtx := tx.Rollback(); errtx != nil { - l.Warn(failRollback, zap.Error(errtx)) - } - - l.Warn("Failed to update "+field, zap.Error(err)) - return err - } - - return nil -} - -func (d *DBCache) getStats(ctx context.Context) (CacheStats, error) { - var ( - hits uint64 - misses uint64 - ) - if d.db == nil { - return CacheStats{}, errNilConnection - } - - l := ctxzap.Extract(ctx) - rows, err := d.db.QueryContext(ctx, ` - SELECT - sum(hits) total_hits, - sum(misses) total_misses - FROM http_stats - `) - if err != nil { - l.Warn(errQueryingTable, zap.Error(err)) - return CacheStats{}, err - } - - defer rows.Close() - for rows.Next() { - err = rows.Scan(&hits, &misses) - if err != nil { - l.Warn(failScanResponse, zap.Error(err)) - return CacheStats{}, err - } - } - if rows.Err() != nil { - return CacheStats{}, rows.Err() - } - - return CacheStats{ - Hits: hits, - Misses: misses, - }, nil -} - -func (d *DBCache) Stats(ctx context.Context) CacheStats { - stats, err := d.getStats(ctx) - if err != nil { - return CacheStats{} - } - return stats -} - -func (d *DBCache) Clear(ctx context.Context) error { - // TODO: Implement - return nil -} diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/errors.go b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/errors.go index 8a3b249e..20bd5d0b 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/errors.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/errors.go @@ -2,8 +2,8 @@ package uhttp import ( "context" + "crypto/tls" "errors" - "fmt" "io" "net" "net/http" @@ -15,12 +15,14 @@ import ( "google.golang.org/grpc/status" ) -// wrapTransientNetworkError mirrors Baton HTTP retry classification for callers -// that use the transport directly, such as oauth2-backed SDK clients. -func wrapTransientNetworkError(err error) error { +// classifyTransportFailure classifies err. endpoint is the request the caller +// has in hand: Transport.RoundTrip classifies the dial error before net/http +// wraps it in a *url.Error, so the URL is not in err yet. Pass "" when it is. +func classifyTransportFailure(err error, endpoint string) error { if err == nil { return nil } + endpoint = failedEndpoint(err, endpoint) // A rejected token request is classified in oauth2.go, from the RFC 6749 // error param rather than the HTTP status alone. This branch is total @@ -32,77 +34,194 @@ func wrapTransientNetworkError(err error) error { } if errors.Is(err, io.ErrUnexpectedEOF) { - return WrapErrors(codes.Unavailable, "unexpected EOF", err) + return WrapErrors(codes.Unavailable, withEndpoint("unexpected EOF", endpoint), err) } // A bare EOF reaching the caller means the peer closed the connection // before any response headers arrived, usually a pooled connection it // had already torn down. if errors.Is(err, io.EOF) { - return WrapErrors(codes.Unavailable, "connection closed before response", err) + return WrapErrors(codes.Unavailable, withEndpoint("connection closed before response", endpoint), err) } if isConnectionReset(err) { - return wrapSocketClass(socketReset, err) + return wrapSocketClass(socketReset, err, endpoint) } if isConnectionRefused(err) { - return wrapSocketClass(socketRefused, err) + return wrapSocketClass(socketRefused, err, endpoint) } if isBrokenPipe(err) { - return wrapSocketClass(socketBrokenPipe, err) + return wrapSocketClass(socketBrokenPipe, err, endpoint) } if isNetworkUnreachable(err) { - return wrapSocketClass(socketNetworkUnreachable, err) + return wrapSocketClass(socketNetworkUnreachable, err, endpoint) } var dnsErr *net.DNSError if errors.As(err, &dnsErr) { switch { case dnsErr.IsTimeout: - return WrapErrors(codes.DeadlineExceeded, "dns lookup timeout", err) + return WrapErrors(codes.DeadlineExceeded, withEndpoint("dns lookup timeout", endpoint), err) case dnsErr.IsTemporary: - return WrapErrors(codes.Unavailable, "temporary dns lookup failure", err) + return WrapErrors(codes.Unavailable, withEndpoint("temporary dns lookup failure", endpoint), err) case dnsErr.IsNotFound: - return WrapErrors(codes.InvalidArgument, "dns lookup failed: NXDOMAIN", err) + return WrapErrors(codes.InvalidArgument, withEndpoint("dns lookup failed: NXDOMAIN", endpoint), err) default: - return WrapErrors(codes.Unavailable, "dns lookup failed", err) + return WrapErrors(codes.Unavailable, withEndpoint("dns lookup failed", endpoint), err) } } if isHTTP2ClientConnectionLost(err) { - return WrapErrors(codes.Unavailable, "http2 client connection lost", err) + return WrapErrors(codes.Unavailable, withEndpoint("http2 client connection lost", endpoint), err) + } + + // A certificate the client does not trust will not become trusted on a + // retry, so this is terminal: the fix is in the deployment's trust store + // or the server's certificate. + var certErr *tls.CertificateVerificationError + if errors.As(err, &certErr) { + return WrapErrors(codes.FailedPrecondition, withEndpoint("tls certificate verification failed", endpoint), err) } var urlErr *url.Error if errors.As(err, &urlErr) { if urlErr.Timeout() { - return WrapErrors(codes.DeadlineExceeded, fmt.Sprintf("request timeout: %v", urlErr.URL), urlErr) + return WrapErrors(codes.DeadlineExceeded, withEndpoint("request timeout", endpoint), err) } if urlErr.Temporary() { - return WrapErrors(codes.Unavailable, fmt.Sprintf("temporary error: %v", urlErr.URL), urlErr) + return WrapErrors(codes.Unavailable, withEndpoint("temporary error", endpoint), err) } } + // Ahead of the net.Error check, which context.DeadlineExceeded also + // satisfies, so the caller's deadline reads the same at the RoundTrip + // level as it does wrapped in a *url.Error. + if errors.Is(err, context.DeadlineExceeded) { + return WrapErrors(codes.DeadlineExceeded, withEndpoint("request timeout", endpoint), err) + } + if errors.Is(err, context.Canceled) { + return WrapErrors(codes.Canceled, withEndpoint("request canceled", endpoint), err) + } + // Catches net.Error timeout types not wrapped in url.Error // (e.g. tls.handshakeTimeoutError at the RoundTrip level). var netErr net.Error if errors.As(err, &netErr) && netErr.Timeout() { - return WrapErrors(codes.DeadlineExceeded, fmt.Sprintf("network timeout: %v", err), err) + return wrapSocketClass(socketTimeout, err, endpoint) } // Winsock timeouts do not satisfy the check above, so they need their own. if isSocketTimeout(err) { - return WrapErrors(codes.DeadlineExceeded, fmt.Sprintf("network timeout: %v", err), err) + return wrapSocketClass(socketTimeout, err, endpoint) } - if errors.Is(err, context.DeadlineExceeded) { - return status.Error(codes.DeadlineExceeded, "request timeout") + return err +} + +// wrapRequestError classifies a request that got no response. +func wrapRequestError(err error) error { + if err == nil { + return nil + } + //nolint:errorlint // http.Client.Do returns this wrapper, and its text is what a reader sees first. + if urlErr, ok := err.(*url.Error); ok && urlErr.Err != nil { + err = withoutRequestURL(urlErr) } + // Transport.RoundTrip classified this one: one request states one status. + if status.Code(err) != codes.Unknown { + return err + } + return classifyTransportFailure(err, "") +} - return err +// withoutRequestURL is urlErr stating only what it wraps. http.Client.Do +// prints the request URL ahead of that, query string included, and +// status.FromError copies the whole text into the status message of a wrapped +// status. A connector is free to send a credential as a query parameter. What +// names the call instead is the description classifyTransportFailure writes +// for the class it recognizes, and the connector's own wrapper otherwise, as +// it is for an HTTP error status. The *url.Error stays reachable for +// errors.Is and errors.As. +func withoutRequestURL(urlErr *url.Error) error { + return &maskedError{text: urlErr.Err.Error(), err: urlErr} +} + +// maskedError states text in place of what err prints. errors.Is and +// errors.As still reach err. +type maskedError struct { + text string + err error +} + +func (e *maskedError) Error() string { return e.text } +func (e *maskedError) Unwrap() error { return e.err } + +// requestURL is u without its query string, which is where a connector may +// send a credential. The scheme stays, so the description states a URL and a +// request that is not https is visible in it. The path stays percent-encoded: +// a decoded '%0A' would add a line to the description that reads as a status +// of its own. +func requestURL(u *url.URL) string { + if u.Scheme == "" { + return u.Host + u.EscapedPath() + } + return u.Scheme + "://" + u.Host + u.EscapedPath() +} + +// endpointOf is the request a *url.Error in err was for. +func endpointOf(err error) string { + var urlErr *url.Error + if err == nil || !errors.As(err, &urlErr) { + return "" + } + u, parseErr := url.Parse(urlErr.URL) + if parseErr != nil { + return "" + } + return requestURL(u) +} + +// requestEndpoint is the request RoundTrip was given. +func requestEndpoint(req *http.Request) string { + if req == nil || req.URL == nil { + return "" + } + return requestURL(req.URL) +} + +// failedEndpoint is what the request failed to reach. An empty endpoint falls +// back to a *url.Error already in err, which is what a caller downstream of +// RoundTrip has. A proxyconnect error is raised before the target host sees +// the request, so naming the request's own endpoint there reports the target +// as the host that refused. +func failedEndpoint(err error, endpoint string) string { + if endpoint == "" { + endpoint = endpointOf(err) + } + var opErr *net.OpError + if !errors.As(err, &opErr) || opErr.Op != "proxyconnect" { + return endpoint + } + proxy := "proxy" + var dialErr *net.OpError + if errors.As(opErr.Err, &dialErr) && dialErr.Addr != nil { + proxy += " " + dialErr.Addr.String() + } + if endpoint == "" { + return proxy + } + return proxy + " for " + endpoint +} + +// withEndpoint appends endpoint to msg. An empty endpoint keeps msg as it is. +func withEndpoint(msg, endpoint string) string { + if endpoint == "" { + return msg + } + return msg + ": " + endpoint } // socketClass groups the socket failures the platform predicates in // errors_other.go and errors_windows.go match. Each platform lists the // errnos it spells a class with in transientSocketConditions, so the -// predicates, the messages wrapTransientNetworkError attaches, and the +// predicates, the messages classifyTransportFailure attaches, and the // text-only table in oauth2.go all derive from one list and cannot drift // apart. type socketClass int @@ -122,10 +241,6 @@ type socketCondition struct { } // socketClassifications is the classification each class receives. -// socketTimeout's message is reached only from oauth2.go's text-only table: -// on the typed path a timeout is caught by the net.Error branch below (or by -// isSocketTimeout on Windows), which keeps the underlying error in the -// message. var socketClassifications = map[socketClass]struct { code codes.Code msg string @@ -147,9 +262,9 @@ func hasSocketClass(err error, class socketClass) bool { return false } -func wrapSocketClass(class socketClass, err error) error { +func wrapSocketClass(class socketClass, err error, endpoint string) error { classification := socketClassifications[class] - return WrapErrors(classification.code, classification.msg, err) + return WrapErrors(classification.code, withEndpoint(classification.msg, endpoint), err) } func isHTTP2ClientConnectionLost(err error) bool { diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/errors_other.go b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/errors_other.go index f3d06f48..2cdaa5e7 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/errors_other.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/errors_other.go @@ -39,7 +39,7 @@ func isNetworkUnreachable(err error) bool { // isSocketTimeout is always false here: syscall.Errno.Timeout() already reports // true for ETIMEDOUT, so the net.Error timeout branch in -// wrapTransientNetworkError catches it. ETIMEDOUT is still listed above so +// classifyTransportFailure catches it. ETIMEDOUT is still listed above so // oauth2.go's text-only classifier knows the spelling this platform prints. func isSocketTimeout(error) bool { return false diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/gocache.go b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/gocache.go index 718e63c7..054e94d1 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/gocache.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/gocache.go @@ -27,7 +27,6 @@ const ( type CacheBackend string const ( - CacheBackendDB CacheBackend = "db" CacheBackendMemory CacheBackend = "memory" CacheBackendNoop CacheBackend = "noop" ) @@ -107,8 +106,6 @@ func NewCacheConfigFromEnv() *CacheConfig { cacheBackend := os.Getenv("BATON_HTTP_CACHE_BACKEND") switch cacheBackend { - case "db": - config.Backend = CacheBackendDB case "memory": config.Backend = CacheBackendMemory case "noop": @@ -164,14 +161,6 @@ func NewHttpCache(ctx context.Context, config *CacheConfig) (icache, error) { return nil, err } return memCache, nil - case CacheBackendDB: - l.Debug("Using db cache") - dbCache, err := NewDBCache(ctx, *config) - if err != nil { - l.Error("error creating http cache (db-cache)", zap.Error(err), zap.Any("config", *config)) - return nil, err - } - return dbCache, nil } return NewNoopCache(ctx), nil diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/oauth2.go b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/oauth2.go index b243e0d8..abbd88ac 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/oauth2.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/oauth2.go @@ -64,7 +64,7 @@ func ClassifyOAuth2TokenError(err error) error { return classified } - return wrapTransientNetworkError(err) + return classifyTransportFailure(err, "") } // NewClassifyingTokenSource returns a TokenSource that runs every error from @@ -95,7 +95,7 @@ func (c *classifyingTokenSource) Token() (*oauth2.Token, error) { // classifyOAuth2RetrieveError maps a token endpoint's rejection to a grpc // code. It is reached from ClassifyOAuth2TokenError, which x/oauth2's token -// sources feed, and from wrapTransientNetworkError, for the callers that +// sources feed, and from classifyTransportFailure, for the callers that // drive a token endpoint through this package's transport directly. func classifyOAuth2RetrieveError(retrieveErr *oauth2.RetrieveError, err error) error { tokenErr := oauth2TokenErrorFrom(retrieveErr) @@ -330,7 +330,9 @@ func buildFlattenedTokenFailures() []flattenedTokenFailure { failures := []flattenedTokenFailure{ {substr: "no such host", code: codes.InvalidArgument, msg: "dns lookup failed: NXDOMAIN"}, {substr: "server misbehaving", code: codes.Unavailable, msg: "temporary dns lookup failure"}, + {substr: "tls: failed to verify certificate", code: codes.FailedPrecondition, msg: "tls certificate verification failed"}, {substr: "context deadline exceeded", code: codes.DeadlineExceeded, msg: "request timeout"}, + {substr: "context canceled", code: codes.Canceled, msg: "request canceled"}, {substr: "Client.Timeout exceeded", code: codes.DeadlineExceeded, msg: "request timeout"}, {substr: "TLS handshake timeout", code: codes.DeadlineExceeded, msg: "request timeout"}, {substr: "i/o timeout", code: codes.DeadlineExceeded, msg: "request timeout"}, diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/transport.go b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/transport.go index 42b29dce..e8ccd3a2 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/transport.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/transport.go @@ -13,7 +13,6 @@ import ( "github.com/conductorone/baton-sdk/pkg/sdk" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" - "golang.org/x/net/http2" ) // staleConnectionGap is how long the transport can go unused before its @@ -163,16 +162,15 @@ func (t *Transport) make(_ context.Context) (http.RoundTripper, error) { ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 60 * time.Second, TLSClientConfig: t.tlsClientConfig, + // PING-frame keepalive: keeps streams alive across intermediaries + // (e.g. NLB) that RST idle tunnels. TCP keepalive doesn't cover + // sockets with an outstanding write — the state during a slow + // response. + HTTP2: &http.HTTP2Config{ + SendPingTimeout: 15 * time.Second, + PingTimeout: 15 * time.Second, + }, } - // PING-frame keepalive: keeps streams alive across intermediaries (e.g. - // NLB) that RST idle tunnels. TCP keepalive doesn't cover sockets with - // an outstanding write — the state during a slow response. - h2, err := http2.ConfigureTransports(baseTransport) - if err != nil { - return nil, err - } - h2.ReadIdleTimeout = 15 * time.Second - h2.PingTimeout = 15 * time.Second var rv http.RoundTripper = baseTransport rv = &userAgentTripper{next: rv, userAgent: t.userAgent} t.baseTransport = baseTransport @@ -279,7 +277,6 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { zap.String("http.method", req.Method), zap.String("http.url_details.host", req.URL.Host), zap.String("http.url_details.path", req.URL.Path), - zap.String("http.url_details.query", req.URL.RawQuery), zap.Duration("duration", duration), zap.Any("panic", r), ) @@ -313,14 +310,15 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { resp, err = rt.RoundTrip(retryReq) } } - err = wrapTransientNetworkError(err) + if err != nil { + err = classifyTransportFailure(err, requestEndpoint(req)) + } if t.log { duration := time.Since(start) fields := []zap.Field{ zap.String("http.method", req.Method), zap.String("http.url_details.host", req.URL.Host), zap.String("http.url_details.path", req.URL.Path), - zap.String("http.url_details.query", req.URL.RawQuery), zap.Duration("duration", duration), } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/wrapper.go b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/wrapper.go index a12ff18d..77876003 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/wrapper.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/uhttp/wrapper.go @@ -11,15 +11,19 @@ import ( "net/http" "net/url" "os" + "regexp" "strings" "sync" "time" + "unicode" + "unicode/utf8" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" uRateLimit "go.uber.org/ratelimit" "go.uber.org/zap" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/metrics" @@ -185,6 +189,39 @@ func NewBaseHttpClientWithContext(ctx context.Context, httpClient *http.Client, return cli, nil } +// decodeError is a success decoder failing to read a body into its target. +// Do leaves it out of the error for a non-2xx response: that body was never +// the success shape, and the decoder's complaint would read as the failure. +type decodeError struct { + err error +} + +func (e *decodeError) Error() string { + return e.err.Error() +} + +func (e *decodeError) Unwrap() error { + return e.err +} + +func decodeFailure(err error) error { + if err == nil { + return nil + } + return &decodeError{err: err} +} + +func withoutDecodeErrors(errs []error) []error { + kept := make([]error, 0, len(errs)) + for _, err := range errs { + var decodeErr *decodeError + if !errors.As(err, &decodeErr) { + kept = append(kept, err) + } + } + return kept +} + // WithJSONResponse is a wrapper that marshals the returned response body into // the provided shape. If the API should return an empty JSON body (i.e. HTTP // status code 204 No Content), then pass a `nil` to `response`. @@ -193,11 +230,8 @@ func WithJSONResponse(response any) DoOption { contentHeader := resp.Header.Get(ContentType) if !IsJSONContentType(contentHeader) { - if len(resp.Body) != 0 { - // to print the response, set the envvar BATON_DEBUG_PRINT_RESPONSE_BODY as non-empty, instead - return fmt.Errorf("unexpected content type for JSON response: %s. status code: %d", contentHeader, resp.StatusCode) - } - return fmt.Errorf("unexpected content type for JSON response: %s. status code: %d", contentHeader, resp.StatusCode) + // to print the response, set the envvar BATON_DEBUG_PRINT_RESPONSE_BODY as non-empty, instead + return decodeFailure(fmt.Errorf("unexpected content type for JSON response: %s. status code: %d", contentHeader, resp.StatusCode)) } if response == nil && len(resp.Body) == 0 { return nil @@ -205,7 +239,7 @@ func WithJSONResponse(response any) DoOption { err := json.Unmarshal(resp.Body, response) if err != nil { // to print the response, set the envvar BATON_DEBUG_PRINT_RESPONSE_BODY as non-empty, instead - return fmt.Errorf("failed to unmarshal json response: %w. status code: %d", err, resp.StatusCode) + return decodeFailure(fmt.Errorf("failed to unmarshal json response: %w. status code: %d", err, resp.StatusCode)) } return nil } @@ -220,7 +254,7 @@ func WithAlwaysJSONResponse(response any) DoOption { err := json.Unmarshal(resp.Body, response) if err != nil { // to print the response, set the envvar BATON_DEBUG_PRINT_RESPONSE_BODY as non-empty, instead - return fmt.Errorf("failed to unmarshal json response: %w. status code: %d", err, resp.StatusCode) + return decodeFailure(fmt.Errorf("failed to unmarshal json response: %w. status code: %d", err, resp.StatusCode)) } return nil } @@ -229,14 +263,14 @@ func WithAlwaysJSONResponse(response any) DoOption { func WithXMLResponse(response any) DoOption { return func(resp *WrapperResponse) error { if !IsXMLContentType(resp.Header.Get(ContentType)) { - return fmt.Errorf("unexpected content type for xml response: %s", resp.Header.Get(ContentType)) + return decodeFailure(fmt.Errorf("unexpected content type for xml response: %s", resp.Header.Get(ContentType))) } if response == nil && len(resp.Body) == 0 { return nil } err := xml.Unmarshal(resp.Body, response) if err != nil { - return fmt.Errorf("failed to unmarshal xml response: %w. status code: %d", err, resp.StatusCode) + return decodeFailure(fmt.Errorf("failed to unmarshal xml response: %w. status code: %d", err, resp.StatusCode)) } return nil } @@ -262,11 +296,11 @@ func WithAlwaysXMLResponse(response any) DoOption { if isSuccessStatusCode(resp.StatusCode) && len(resp.Body) == 0 { return nil } - return unmarshalXMLToMap(genericResponse, resp) + return decodeFailure(unmarshalXMLToMap(genericResponse, resp)) } err := xml.Unmarshal(resp.Body, response) if err != nil { - return fmt.Errorf("failed to unmarshal xml response: %w. status code: %d", err, resp.StatusCode) + return decodeFailure(fmt.Errorf("failed to unmarshal xml response: %w. status code: %d", err, resp.StatusCode)) } return nil } @@ -317,6 +351,146 @@ type ErrorResponse interface { Message() string } +// maxQuotedBodyLength bounds the text quotedBody puts into an error message. +const maxQuotedBodyLength = 256 + +// quotedBody renders an error body WithErrorResponse could not decode, as a +// suffix for its message. Only a message is quoted: a record is where a +// vendor states a user's login or email, whether it declares one or sends +// one under a text type. +func quotedBody(contentType string, body []byte) string { + if len(bytes.TrimSpace(body)) == 0 { + return "" + } + message := quotableMessage(contentType, body) + if message == "" { + return fmt.Sprintf(". body omitted (%d bytes)", len(body)) + } + return ". body: " + message +} + +// quotableMessage is the message body states, as a single line of at most +// maxQuotedBodyLength runes and the mark that says more follows. It is "" +// when there is nothing to quote: body states a record, or markup, or +// nothing at all. +func quotableMessage(contentType string, body []byte) string { + if !quotableContentType(contentType) { + return "" + } + // Scan no more than a message can quote; n runes hold at most n*UTFMax + // bytes. + head, dropped := body, false + if len(head) > maxQuotedBodyLength*utf8.UTFMax { + head, dropped = head[:maxQuotedBodyLength*utf8.UTFMax], true + // The cut can land inside a rune. Drop the part of it that is left, + // so the quote states no replacement character. + for len(head) > 0 { + if r, size := utf8.DecodeLastRune(head); r != utf8.RuneError || size > 1 { + break + } + head = head[:len(head)-1] + } + } + + prefix, markup := plainPrefix(strings.TrimSpace(strings.TrimPrefix(string(head), "\ufeff"))) + message := oneLine(prefix) + if message == "" || jsonRecord.MatchString(message) || emailAddress.MatchString(message) { + return "" + } + // The mark states that the message goes on. The cap and the scan each + // drop message text; markup ends the message, so what follows it is not + // more of one. + if runes := []rune(message); len(runes) > maxQuotedBodyLength { + return string(runes[:maxQuotedBodyLength]) + "..." + } + if dropped { + // The same cut can halve an address, which emailAddress no longer + // matches. Markup ends the message, so what follows it is not more + // of one and takes no mark. + if strings.ContainsRune(message, '@') { + return "" + } + if !markup { + return message + "..." + } + } + return message +} + +// quotableContentType reports whether contentType declares text a message is +// written in. An absent type counts as one, since a vendor answering in plain +// text often declares nothing. Every other type states a record, text/csv and +// text/xml included. +func quotableContentType(contentType string) bool { + normalized := strings.TrimSpace(strings.ToLower(contentType)) + return normalized == "" || + strings.HasPrefix(normalized, "text/plain") || + strings.HasPrefix(normalized, "text/html") +} + +// jsonRecord matches an object or an array that quotes a string or separates +// two single-quoted ones, which is what a record does and a message does not, +// and one that opens after a prefix such as an XSSI guard. "[ERROR] upstream: +// timeout" and "[upstream] can't connect" match neither, however much +// punctuation they hold. +var jsonRecord = regexp.MustCompile(`^[{\[].*(?:"|'\s*[:,])|[{\[]\s*"`) + +// emailAddress matches an address down to a letters-only top-level domain, +// so a version string with an '@' in it is not one. +var emailAddress = regexp.MustCompile(`[^\s@]+@[^\s@]+\.[A-Za-z]{2,}`) + +// plainPrefix is text up to the first markup tag, and whether a tag ended it. +// A '<' that opens no tag, as in "quota must be < 5", is part of the message. +func plainPrefix(text string) (string, bool) { + for i := range len(text) { + if text[i] != '<' { + continue + } + if i+1 < len(text) && opensTag(text[i+1]) { + return strings.TrimSpace(text[:i]), true + } + } + return text, false +} + +func opensTag(c byte) bool { + switch { + case c == '!', c == '?', c == '/': + return true + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z': + return true + } + return false +} + +// oneLine collapses the whitespace in text and drops what does not print, so +// a body cannot add a line to the message. +func oneLine(text string) string { + printable := strings.Map(func(r rune) rune { + if unicode.IsPrint(r) { + return r + } + return ' ' + }, text) + return strings.Join(strings.Fields(printable), " ") +} + +// requestLogFields identify the request a log line is about. These fields omit +// the query string. +func requestLogFields(req *http.Request) []zap.Field { + if req == nil { + return nil + } + if req.URL == nil { + return []zap.Field{zap.String("http.method", req.Method)} + } + return []zap.Field{ + zap.String("http.method", req.Method), + zap.String("http.url_details.host", req.URL.Host), + zap.String("http.url_details.path", req.URL.Path), + } +} + // isSuccessStatusCode reports whether code is in the 2xx success class. // http.StatusOK (200) is the inclusive lower bound and // http.StatusMultipleChoices (300) the exclusive upper bound. @@ -365,18 +539,16 @@ func WithErrorResponse(resource ErrorResponse) DoOption { grpcCode := GrpcCodeFromHTTPStatus(resp.StatusCode) if !IsJSONContentType(contentHeader) { - // to print the response, set the envvar BATON_DEBUG_PRINT_RESPONSE_BODY as non-empty, instead return status.Errorf(grpcCode, - "unexpected content type for JSON error response: %s. status code: %d. body: %s", - contentHeader, resp.StatusCode, string(resp.Body)) + "unexpected content type for JSON error response: %s. status code: %d%s", + contentHeader, resp.StatusCode, quotedBody(contentHeader, resp.Body)) } // Decode the JSON response body into the ErrorResponse if err := json.Unmarshal(resp.Body, &resource); err != nil { - // to print the response, set the envvar BATON_DEBUG_PRINT_RESPONSE_BODY as non-empty, instead return status.Errorf(grpcCode, - "failed to unmarshal JSON error response: %v. status code: %d. body: %s", - err, resp.StatusCode, string(resp.Body)) + "failed to unmarshal JSON error response: %v. status code: %d%s", + err, resp.StatusCode, quotedBody(contentHeader, resp.Body)) } // Construct a more detailed error message @@ -414,7 +586,7 @@ func WithResponse(response any) DoOption { return WithXMLResponse(response)(resp) } - return status.Error(codes.Unknown, "unsupported content type") + return decodeFailure(status.Error(codes.Unknown, "unsupported content type")) } } @@ -458,16 +630,16 @@ func WithGenericResponse(response *map[string]any) DoOption { // JSON response is literally `null`. (*response) = map[string]any{"value": nil} } else { - return status.Errorf(codes.Internal, "unsupported value type: %T", v) + return decodeFailure(status.Errorf(codes.Internal, "unsupported value type: %T", v)) } return nil } if IsXMLContentType(resp.Header.Get(ContentType)) { - return unmarshalXMLToMap(response, resp) + return decodeFailure(unmarshalXMLToMap(response, resp)) } - return status.Error(codes.Unknown, fmt.Sprintf("unsupported content type: %s", resp.Header.Get(ContentType))) + return decodeFailure(status.Error(codes.Unknown, fmt.Sprintf("unsupported content type: %s", resp.Header.Get(ContentType)))) } } @@ -477,9 +649,7 @@ func WrapErrors(preferredCode codes.Code, statusMsg string, errs ...error) error if len(errs) == 0 { return st.Err() } - - allErrs := append([]error{st.Err()}, errs...) - return errors.Join(allErrs...) + return joinWithStatus(st, errs) } func WrapErrorsWithRateLimitInfo(preferredCode codes.Code, resp *http.Response, errs ...error) error { @@ -494,9 +664,68 @@ func WrapErrorsWithRateLimitInfo(preferredCode codes.Code, resp *http.Response, if len(errs) == 0 { return st.Err() } + return joinWithStatus(st, errs) +} - allErrs := append([]error{st.Err()}, errs...) - return errors.Join(allErrs...) +// joinWithStatus joins errs under st. st is left off only when the first +// status in the chain already states it: status.Code reads that one. A later +// match does not count, or an earlier status of another code would win. A +// non-2xx Do error passed back through WrapErrors would otherwise print its +// status line twice. +func joinWithStatus(st *status.Status, errs []error) error { + joined := errors.Join(errs...) + if statusStates(st, joined) { + return joined + } + return errors.Join(append([]error{st.Err()}, errs...)...) +} + +// statusStates reports whether the status status.Code would read from err +// already has st's code, message, and details. +func statusStates(st *status.Status, err error) bool { + var carrier interface{ GRPCStatus() *status.Status } + if !errors.As(err, &carrier) { + return false + } + carried := carrier.GRPCStatus() + return carried.Code() == st.Code() && carried.Message() == st.Message() && + detailsCover(carried.Details(), st.Details()) +} + +// detailsCover reports whether every detail in want is already in have. +func detailsCover(have, want []any) bool { + for _, detail := range want { + wantMsg, ok := detail.(proto.Message) + if !ok || !hasDetail(have, wantMsg) { + return false + } + } + return true +} + +func hasDetail(have []any, want proto.Message) bool { + for _, detail := range have { + got, ok := detail.(proto.Message) + if ok && detailMatches(got, want) { + return true + } + } + return false +} + +// detailMatches compares details. A rate-limit detail matches on status, +// limit, and remaining. ResetAt is time.Now plus a relative header, so two +// reads of the same header are not proto.Equal. +func detailMatches(got, want proto.Message) bool { + gotRL, gotOK := got.(*v2.RateLimitDescription) + wantRL, wantOK := want.(*v2.RateLimitDescription) + if gotOK || wantOK { + return gotOK && wantOK && + gotRL.GetStatus() == wantRL.GetStatus() && + gotRL.GetLimit() == wantRL.GetLimit() && + gotRL.GetRemaining() == wantRL.GetRemaining() + } + return proto.Equal(got, want) } func (c *BaseHttpClient) recordCacheHit(ctx context.Context) { @@ -542,8 +771,9 @@ func (c *BaseHttpClient) Do(req *http.Request, options ...DoOption) (*http.Respo if resp == nil { resp, err = c.HttpClient.Do(req) // #nosec G704 -- this HTTP wrapper intentionally supports arbitrary connector-defined endpoints. if err != nil { - l.Error("base-http-client: HTTP error response", zap.Error(err)) - return resp, wrapTransientNetworkError(err) + err = wrapRequestError(err) + l.Error("base-http-client: HTTP error response", append(requestLogFields(req), zap.Error(err))...) + return resp, err } } @@ -553,7 +783,7 @@ func (c *BaseHttpClient) Do(req *http.Request, options ...DoOption) (*http.Respo if len(body) > 0 { resp.Body = io.NopCloser(bytes.NewBuffer(body)) } - return resp, wrapTransientNetworkError(err) + return resp, wrapRequestError(err) } // Replace resp.Body with a no-op closer so nobody has to worry about closing the reader. @@ -582,11 +812,11 @@ func (c *BaseHttpClient) Do(req *http.Request, options ...DoOption) (*http.Respo // Log response headers directly for certain errors if resp.StatusCode >= 400 { redactedHeaders := RedactSensitiveHeaders(resp.Header) - logFields := []zap.Field{ + logFields := append(requestLogFields(req), zap.Int("status_code", resp.StatusCode), zap.String("status", resp.Status), zap.Any("headers", redactedHeaders), - } + ) if resp.StatusCode >= 500 { l.Error("base-http-client: HTTP error status", logFields...) } else { @@ -596,13 +826,13 @@ func (c *BaseHttpClient) Do(req *http.Request, options ...DoOption) (*http.Respo if !isSuccessStatusCode(resp.StatusCode) { grpcCode := GrpcCodeFromHTTPStatus(resp.StatusCode) - return resp, WrapErrorsWithRateLimitInfo(grpcCode, resp, optErrs...) + return resp, WrapErrorsWithRateLimitInfo(grpcCode, resp, withoutDecodeErrors(optErrs)...) } if req.Method == http.MethodGet && resp.StatusCode == http.StatusOK { cacheErr := c.baseHttpCache.Set(req, resp, c.cacheOptions...) if cacheErr != nil { - l.Warn("error setting cache", zap.String("url", req.URL.String()), zap.Error(cacheErr)) + l.Warn("error setting cache", append(requestLogFields(req), zap.Error(cacheErr))...) } } diff --git a/vendor/github.com/conductorone/baton-sdk/pkg/uotel/config.go b/vendor/github.com/conductorone/baton-sdk/pkg/uotel/config.go index 18b212a2..45d0fdfc 100644 --- a/vendor/github.com/conductorone/baton-sdk/pkg/uotel/config.go +++ b/vendor/github.com/conductorone/baton-sdk/pkg/uotel/config.go @@ -134,14 +134,18 @@ func (c *otelConfig) init(ctx context.Context) (context.Context, error) { return ctx, nil } - ctx, err = c.initLogging(ctx, cc) - if err != nil { - return nil, fmt.Errorf("otel: failed to initialize logging: %w", err) + if !c.loggingDisabled { + ctx, err = c.initLogging(ctx, cc) + if err != nil { + return nil, fmt.Errorf("otel: failed to initialize logging: %w", err) + } } - ctx, err = c.initTracing(ctx, cc) - if err != nil { - return nil, fmt.Errorf("otel: failed to initialize tracing: %w", err) + if !c.tracingDisabled { + ctx, err = c.initTracing(ctx, cc) + if err != nil { + return nil, fmt.Errorf("otel: failed to initialize tracing: %w", err) + } } return ctx, nil } @@ -295,25 +299,26 @@ func (c *otelConfig) initLogging(ctx context.Context, cc *grpc.ClientConn) (cont return ctxzap.ToContext(ctx, l), nil } -// Close closes all connections managed by the config. +// Close shuts down the tracer and log providers, which flushes their queued +// batches, and then closes the connections they export over. func (c *otelConfig) Close(ctx context.Context) error { c.mtx.Lock() defer c.mtx.Unlock() var errs []error - for _, conn := range c.c { - if err := conn.Close(); err != nil { + for _, shutdown := range c.shutdown { + if err := shutdown(ctx); err != nil { errs = append(errs, err) } } - c.c = make(map[string]*grpc.ClientConn) + c.shutdown = nil - for _, shutdown := range c.shutdown { - if err := shutdown(ctx); err != nil { + for _, conn := range c.c { + if err := conn.Close(); err != nil { errs = append(errs, err) } } - c.shutdown = nil + c.c = make(map[string]*grpc.ClientConn) err := errors.Join(errs...) if err != nil { diff --git a/vendor/golang.org/x/net/http2/ascii.go b/vendor/golang.org/x/net/http2/ascii.go index 17caa205..3a7aed12 100644 --- a/vendor/golang.org/x/net/http2/ascii.go +++ b/vendor/golang.org/x/net/http2/ascii.go @@ -4,7 +4,9 @@ package http2 -import "strings" +import ( + "strings" +) // The HTTP protocols are defined in terms of ASCII, not Unicode. This file // contains helper functions which may use Unicode-aware functions which would @@ -43,6 +45,16 @@ func isASCIIPrint(s string) bool { return true } +// isASCII returns whether s is ASCII. +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] > 0x7f { + return false + } + } + return true +} + // asciiToLower returns the lowercase version of s if s is ASCII and printable, // and whether or not it was. func asciiToLower(s string) (lower string, ok bool) { diff --git a/vendor/golang.org/x/net/http2/databuffer.go b/vendor/golang.org/x/net/http2/databuffer.go index e6f55cbd..9763ab13 100644 --- a/vendor/golang.org/x/net/http2/databuffer.go +++ b/vendor/golang.org/x/net/http2/databuffer.go @@ -121,10 +121,7 @@ func (b *dataBuffer) Write(p []byte) (int, error) { // If the last chunk is empty, allocate a new chunk. Try to allocate // enough to fully copy p plus any additional bytes we expect to // receive. However, this may allocate less than len(p). - want := int64(len(p)) - if b.expected > want { - want = b.expected - } + want := max(int64(len(p)), b.expected) chunk := b.lastChunkOrAlloc(want) n := copy(chunk[b.w:], p) p = p[n:] diff --git a/vendor/golang.org/x/net/http2/flow.go b/vendor/golang.org/x/net/http2/flow.go index b7dbd186..5066a399 100644 --- a/vendor/golang.org/x/net/http2/flow.go +++ b/vendor/golang.org/x/net/http2/flow.go @@ -10,6 +10,9 @@ package http2 // flow control window update. const inflowMinRefresh = 4 << 10 +// maxFlowWindow is the maximum size of a flow control window. +const maxFlowWindow = (1 << 31) - 1 + // inflow accounts for an inbound flow control window. // It tracks both the latest window sent to the peer (used for enforcement) // and the accumulated unsent window. @@ -74,47 +77,99 @@ func takeInflows(f1, f2 *inflow, n uint32) bool { return true } -// outflow is the outbound flow control window's size. +// connOutflow is connection-level outbound flow control. +type connOutflow struct { + initial int32 // SETTINGS_INITIAL_WINDOW_SIZE, changes with settings updates + n int32 // connection-level flow control window + flowErr bool // set when a flow control error is encountered +} + +func (f *connOutflow) init() { + f.initial = initialWindowSize // initial stream window size + f.n = initialWindowSize // current connection window size +} + +func (f *connOutflow) changeInitialWindowSize(size int64) bool { + if size > maxFlowWindow { + f.flowErr = true + return false + } + f.initial = int32(size) + return true +} + +func (f *connOutflow) add(n int32) bool { + sum := int64(f.n) + int64(n) + if sum > maxFlowWindow { + f.flowErr = true + return false + } + f.n += n + return true +} + +// outflow is the stream-level outbound flow control window's size. type outflow struct { _ incomparable - // n is the number of DATA bytes we're allowed to send. - // An outflow is kept both on a conn and a per-stream. - n int32 + // delta is the difference between the stream's flow control window and + // the connection's initial window size (conn.initial). + // + // Another view is that delta is the number of flow control bytes provided to this + // stream in WINDOW_UPDATE frames, less the number of bytes sent on the stream. + delta int32 // conn points to the shared connection-level outflow that is - // shared by all streams on that conn. It is nil for the outflow - // that's on the conn directly. - conn *outflow + // shared by all streams on that conn. + conn *connOutflow } -func (f *outflow) setConnFlow(cf *outflow) { f.conn = cf } - -func (f *outflow) available() int32 { - n := f.n - if f.conn != nil && f.conn.n < n { - n = f.conn.n +func (f *outflow) available() (int32, bool) { + if f.conn == nil { + return maxFlowWindow, true // only happens in tests } - return n + if f.conn.flowErr { + // Block all sending once any stream observes a flow control error. + return 0, false + } + n := int64(f.conn.initial) + int64(f.delta) + if n > maxFlowWindow { + f.conn.flowErr = true + return 0, false + } + return min(int32(n), f.conn.n), true } func (f *outflow) take(n int32) { - if n > f.available() { - panic("internal error: took too much") + if f.conn == nil { + return // only happens in tests } - f.n -= n - if f.conn != nil { - f.conn.n -= n + avail, _ := f.available() + if n > avail { + panic("internal error: took too much") } + f.delta -= n + f.conn.n -= n } // add adds n bytes (positive or negative) to the flow control window. // It returns false if the sum would exceed 2^31-1. func (f *outflow) add(n int32) bool { - sum := f.n + n - if (sum > n) == (f.n > 0) { - f.n = sum - return true + if f.conn == nil { + return true // only happens in tests + } + avail := int64(f.conn.initial) + int64(f.delta) + if avail > maxFlowWindow { + // An earlier change to the initial window pushed this stream over the limit. + // This is a connection-level flow control error. + f.conn.flowErr = true + return false } - return false + if avail+int64(n) > maxFlowWindow { + // This update would push the stream over the limit. + // This is a stream-level flow control error. + return false + } + f.delta += n + return true } diff --git a/vendor/golang.org/x/net/http2/frame.go b/vendor/golang.org/x/net/http2/frame.go index be75badc..3363bbaf 100644 --- a/vendor/golang.org/x/net/http2/frame.go +++ b/vendor/golang.org/x/net/http2/frame.go @@ -1711,7 +1711,8 @@ func (fr *Framer) readMetaFrame(hf *HeadersFrame) (Frame, error) { mh := &MetaHeadersFrame{ HeadersFrame: hf, } - var remainSize = fr.maxHeaderListSize() + var headersRemainSize = fr.maxHeaderListSize() + var trailersRemainSize = fr.maxHeaderListSize() var sawRegular bool var invalid error // pseudo header field errors @@ -1743,14 +1744,29 @@ func (fr *Framer) readMetaFrame(hf *HeadersFrame) (Frame, error) { return } - size := hf.Size() - if size > remainSize { + var remainSize *uint32 + var size uint32 + if hf.Name == "trailer" { + remainSize = &trailersRemainSize + fieldCount := strings.Count(hf.Value, ",") + 1 + // Rather than actually constructing hpack.HeaderField for each + // trailer field and cumulatively adding its Size, just do the math + // manually to avoid unnecessary work. This does make it so + // whitespaces after comma are counted against the budget, but that + // should be innocuous. + size = uint32(len(hf.Value)-fieldCount+1) + uint32(fieldCount)*hpack.HeaderField{}.Size() + } else { + remainSize = &headersRemainSize + size = hf.Size() + } + if size > *remainSize { hdec.SetEmitEnabled(false) mh.Truncated = true - remainSize = 0 + headersRemainSize = 0 + trailersRemainSize = 0 return } - remainSize -= size + *remainSize -= size mh.Fields = append(mh.Fields, hf) }) @@ -1766,10 +1782,10 @@ func (fr *Framer) readMetaFrame(hf *HeadersFrame) (Frame, error) { // skip parsing the fragment and close the connection. // // "Too much" is either any CONTINUATION frame after we've already - // exceeded the max header list size (in which case remainSize is 0), - // or a frame whose encoded size is more than twice the remaining - // header list bytes we're willing to accept. - if int64(len(frag)) > int64(2*remainSize) { + // exceeded the max header list size (if so, both budgets are 0), or a + // frame whose encoded size is more than twice the remaining header + // list bytes we're willing to accept. + if int64(len(frag)) > 2*int64(headersRemainSize+trailersRemainSize) { if VerboseLogs { log.Printf("http2: header list too large") } diff --git a/vendor/golang.org/x/net/http2/server.go b/vendor/golang.org/x/net/http2/server.go index a7d2053b..a6cd6622 100644 --- a/vendor/golang.org/x/net/http2/server.go +++ b/vendor/golang.org/x/net/http2/server.go @@ -280,7 +280,6 @@ func (s *Server) serveConn(c net.Conn, opts *ServeConnOpts, newf func(*serverCon doneServing: make(chan struct{}), clientMaxStreams: math.MaxUint32, // Section 6.5.2: "Initially, there is no limit to this value" advMaxStreams: conf.MaxConcurrentStreams, - initialStreamSendWindowSize: initialWindowSize, initialStreamRecvWindowSize: conf.MaxUploadBufferPerStream, maxFrameSize: initialMaxFrameSize, pingTimeout: conf.PingTimeout, @@ -320,7 +319,7 @@ func (s *Server) serveConn(c net.Conn, opts *ServeConnOpts, newf func(*serverCon // These start at the RFC-specified defaults. If there is a higher // configured value for inflow, that will be updated when we send a // WINDOW_UPDATE shortly after sending SETTINGS. - sc.flow.add(initialWindowSize) + sc.flow.init() sc.inflow.init(initialWindowSize) sc.hpackEncoder = hpack.NewEncoder(&sc.headerWriteBuf) sc.hpackEncoder.SetMaxDynamicTableSizeLimit(conf.MaxEncoderHeaderTableSize) @@ -427,7 +426,7 @@ type serverConn struct { wroteFrameCh chan frameWriteResult // from writeFrameAsync -> serve, tickles more frame writes bodyReadCh chan bodyReadMsg // from handlers -> serve serveMsgCh chan interface{} // misc messages & code to send to / run on the serve loop - flow outflow // conn-wide (not stream-specific) outbound flow control + flow connOutflow // conn-wide (not stream-specific) outbound flow control inflow inflow // conn-wide inbound flow control tlsState *tls.ConnectionState // shared by all handlers, like net/http remoteAddrStr string @@ -441,6 +440,9 @@ type serverConn struct { sawFirstSettings bool // got the initial SETTINGS frame after the preface needToSendSettingsAck bool unackedSettings int // how many SETTINGS have we sent without ACKs? + pendingEncoderTableSize bool // peer changed SETTINGS_HEADER_TABLE_SIZE; apply to hpackEncoder before the next frame write + encoderTableSizeMin uint32 // smallest SETTINGS_HEADER_TABLE_SIZE since the last apply + encoderTableSize uint32 // latest SETTINGS_HEADER_TABLE_SIZE queuedControlFrames int // control frames in the writeSched queue clientMaxStreams uint32 // SETTINGS_MAX_CONCURRENT_STREAMS from client (our PUSH_PROMISE limit) advMaxStreams uint32 // our SETTINGS_MAX_CONCURRENT_STREAMS advertised the client @@ -451,7 +453,6 @@ type serverConn struct { maxPushPromiseID uint32 // ID of the last push promise (even), or 0 if there have been no pushes streams map[uint32]*stream unstartedHandlers []unstartedHandler - initialStreamSendWindowSize int32 initialStreamRecvWindowSize int32 maxFrameSize int32 peerMaxHeaderListSize uint32 // zero means unknown (default) @@ -521,7 +522,8 @@ type stream struct { // immutable: sc *serverConn id uint32 - body *pipe // non-nil if expecting DATA frames + body *pipe // non-nil if expecting DATA frames + reqBody *requestBody cw closeWaiter // closed wait stream transitions to closed state ctx context.Context cancelCtx func() @@ -1172,6 +1174,16 @@ func (sc *serverConn) startFrameWrite(wr FrameWriteRequest) { sc.writingFrame = true sc.needsFrameFlush = true + if sc.pendingEncoderTableSize { + // hpackEncoder may be in use by writeFrameAsync, so SETTINGS + // changes to it are deferred until no frame is being written. + // Replaying the smallest size before the latest one keeps the + // encoder's view identical to having applied every change + // (RFC 7541, Section 4.2). + sc.pendingEncoderTableSize = false + sc.hpackEncoder.SetMaxDynamicTableSize(sc.encoderTableSizeMin) + sc.hpackEncoder.SetMaxDynamicTableSize(sc.encoderTableSize) + } if wr.write.staysWithinBuffer(sc.bw.Available()) { sc.writingFrameAsync = false err := wr.write.writeFrame(sc) @@ -1271,6 +1283,11 @@ func (sc *serverConn) scheduleFrameWrite() { } sc.inFrameScheduleLoop = true for !sc.writingFrameAsync { + if sc.flow.flowErr && (!sc.inGoAway || sc.goAwayCode == ErrCodeNo) { + sc.inGoAway = true + sc.needToSendGoAway = true + sc.goAwayCode = ErrCodeFlowControl + } if sc.needToSendGoAway { sc.needToSendGoAway = false sc.startFrameWrite(FrameWriteRequest{ @@ -1294,6 +1311,9 @@ func (sc *serverConn) scheduleFrameWrite() { sc.startFrameWrite(wr) continue } + if sc.flow.flowErr { + continue + } } if sc.needsFrameFlush { sc.startFrameWrite(FrameWriteRequest{write: flushFrameWriter{}}) @@ -1526,6 +1546,10 @@ func (sc *serverConn) processWindowUpdate(f *WindowUpdateFrame) error { return nil } if !st.flow.add(int32(f.Increment)) { + if st.flow.conn.flowErr { + // This is a lazily-detected connection-level flow control error. + return sc.countError("bad_flow", ConnectionError(ErrCodeFlowControl)) + } return sc.countError("bad_flow", streamError(f.StreamID, ErrCodeFlowControl)) } default: // connection-level flow control @@ -1584,10 +1608,6 @@ func (sc *serverConn) closeStream(st *stream, err error) { } } if p := st.body; p != nil { - // Return any buffered unread bytes worth of conn-level flow control. - // See golang.org/issue/16481 - sc.sendWindowUpdate(nil, p.Len()) - p.CloseWithError(err) } if e, ok := err.(StreamError); ok { @@ -1641,7 +1661,12 @@ func (sc *serverConn) processSetting(s Setting) error { } switch s.ID { case SettingHeaderTableSize: - sc.hpackEncoder.SetMaxDynamicTableSize(s.Val) + // Applied by startFrameWrite; see comment there. + if !sc.pendingEncoderTableSize || s.Val < sc.encoderTableSizeMin { + sc.encoderTableSizeMin = s.Val + } + sc.encoderTableSize = s.Val + sc.pendingEncoderTableSize = true case SettingEnablePush: sc.pushEnabled = s.Val != 0 case SettingMaxConcurrentStreams: @@ -1672,28 +1697,14 @@ func (sc *serverConn) processSetting(s Setting) error { func (sc *serverConn) processSettingInitialWindowSize(val uint32) error { sc.serveG.check() - // Note: val already validated to be within range by - // processSetting's Valid call. - - // "A SETTINGS frame can alter the initial flow control window - // size for all current streams. When the value of - // SETTINGS_INITIAL_WINDOW_SIZE changes, a receiver MUST - // adjust the size of all stream flow control windows that it - // maintains by the difference between the new value and the - // old value." - old := sc.initialStreamSendWindowSize - sc.initialStreamSendWindowSize = int32(val) - growth := int32(val) - old // may be negative - for _, st := range sc.streams { - if !st.flow.add(growth) { - // 6.9.2 Initial Flow Control Window Size - // "An endpoint MUST treat a change to - // SETTINGS_INITIAL_WINDOW_SIZE that causes any flow - // control window to exceed the maximum size as a - // connection error (Section 5.4.1) of type - // FLOW_CONTROL_ERROR." - return sc.countError("setting_win_size", ConnectionError(ErrCodeFlowControl)) - } + if !sc.flow.changeInitialWindowSize(int64(val)) { + // 6.9.2 Initial Flow Control Window Size + // "An endpoint MUST treat a change to + // SETTINGS_INITIAL_WINDOW_SIZE that causes any flow + // control window to exceed the maximum size as a + // connection error (Section 5.4.1) of type + // FLOW_CONTROL_ERROR." + return sc.countError("setting_win_size", ConnectionError(ErrCodeFlowControl)) } return nil } @@ -1966,7 +1977,7 @@ func (sc *serverConn) processHeaders(f *MetaHeadersFrame) error { if st.reqTrailer != nil { st.trailer = make(http.Header) } - st.body = req.Body.(*requestBody).pipe // may be nil + st.body = st.reqBody.pipe // may be nil st.declBodyBytes = req.ContentLength handler := sc.handler.ServeHTTP @@ -1989,7 +2000,7 @@ func (sc *serverConn) processHeaders(f *MetaHeadersFrame) error { st.readDeadline = time.AfterFunc(sc.hs.ReadTimeout, st.onReadTimeout) } - return sc.scheduleHandler(id, rw, req, handler) + return sc.scheduleHandler(st, rw, req, handler) } func (sc *serverConn) upgradeRequest(req *http.Request) { @@ -2102,7 +2113,6 @@ func (sc *serverConn) newStream(id, pusherID uint32, state streamState, priority } st.cw.Init() st.flow.conn = &sc.flow // link to conn-level counter - st.flow.add(sc.initialStreamSendWindowSize) st.inflow.init(sc.initialStreamRecvWindowSize) if sc.hs.WriteTimeout > 0 { st.writeDeadline = time.AfterFunc(sc.hs.WriteTimeout, st.onWriteTimeout) @@ -2184,7 +2194,7 @@ func (sc *serverConn) newWriterAndRequest(st *stream, f *MetaHeadersFrame) (*res } else { req.ContentLength = -1 } - req.Body.(*requestBody).pipe = &pipe{ + st.reqBody.pipe = &pipe{ b: &dataBuffer{expected: req.ContentLength}, } } @@ -2204,7 +2214,7 @@ func (sc *serverConn) newWriterAndRequestNoBody(st *stream, rp httpcommon.Server return nil, nil, sc.countError(res.InvalidReason, streamError(st.id, ErrCodeProtocol)) } - body := &requestBody{ + st.reqBody = &requestBody{ conn: sc, stream: st, needsContinue: res.NeedsContinue, @@ -2220,7 +2230,7 @@ func (sc *serverConn) newWriterAndRequestNoBody(st *stream, rp httpcommon.Server ProtoMinor: 0, TLS: tlsState, Host: rp.Authority, - Body: body, + Body: st.reqBody, Trailer: res.Trailer, }).WithContext(st.ctx) rw := sc.newResponseWriter(st, req) @@ -2244,11 +2254,12 @@ type unstartedHandler struct { rw *responseWriter req *http.Request handler func(http.ResponseWriter, *http.Request) + body *pipe } // scheduleHandler starts a handler goroutine, // or schedules one to start as soon as an existing handler finishes. -func (sc *serverConn) scheduleHandler(streamID uint32, rw *responseWriter, req *http.Request, handler func(http.ResponseWriter, *http.Request)) error { +func (sc *serverConn) scheduleHandler(st *stream, rw *responseWriter, req *http.Request, handler func(http.ResponseWriter, *http.Request)) error { sc.serveG.check() maxHandlers := sc.advMaxStreams if sc.curHandlers < maxHandlers { @@ -2260,10 +2271,11 @@ func (sc *serverConn) scheduleHandler(streamID uint32, rw *responseWriter, req * return sc.countError("too_many_early_resets", ConnectionError(ErrCodeEnhanceYourCalm)) } sc.unstartedHandlers = append(sc.unstartedHandlers, unstartedHandler{ - streamID: streamID, + streamID: st.id, rw: rw, req: req, handler: handler, + body: st.body, }) return nil } @@ -2277,6 +2289,10 @@ func (sc *serverConn) handlerDone() { u := sc.unstartedHandlers[i] if sc.streams[u.streamID] == nil { // This stream was reset before its goroutine had a chance to start. + if u.body != nil { + u.body.BreakWithError(errClosedBody) + sc.sendWindowUpdate(nil, u.body.Len()) + } continue } if sc.curHandlers >= maxHandlers { @@ -2298,6 +2314,12 @@ func (sc *serverConn) runHandler(rw *responseWriter, req *http.Request, handler didPanic := true defer func() { rw.rws.stream.cancelCtx() + if b := rw.rws.stream.reqBody; b != nil { + // Closing the body refunds flow control credit for any unconsumed data. + // (reqBody is nil for Upgrade: h2c requests, but those do not use flow + // control for the request body.) + b.Close() + } if req.MultipartForm != nil { req.MultipartForm.RemoveAll() } @@ -2396,7 +2418,7 @@ func (sc *serverConn) noteBodyReadFromHandler(st *stream, n int, err error) { func (sc *serverConn) noteBodyRead(st *stream, n int) { sc.serveG.check() sc.sendWindowUpdate(nil, n) // conn-level - if st.state != stateHalfClosedRemote && st.state != stateClosed { + if st != nil && st.state != stateHalfClosedRemote && st.state != stateClosed { // Don't send this WINDOW_UPDATE if the stream is closed // remotely. sc.sendWindowUpdate(st, n) @@ -2444,6 +2466,9 @@ func (b *requestBody) Close() error { b.closeOnce.Do(func() { if b.pipe != nil { b.pipe.BreakWithError(errClosedBody) + if unread := b.pipe.Len(); unread > 0 { + b.conn.noteBodyReadFromHandler(nil, unread, errClosedBody) + } } }) return nil @@ -2461,9 +2486,6 @@ func (b *requestBody) Read(p []byte) (n int, err error) { if err == io.EOF { b.sawEOF = true } - if b.conn == nil { - return - } b.conn.noteBodyReadFromHandler(b.stream, n, err) return } diff --git a/vendor/golang.org/x/net/http2/server_common.go b/vendor/golang.org/x/net/http2/server_common.go index 449538c8..fec36909 100644 --- a/vendor/golang.org/x/net/http2/server_common.go +++ b/vendor/golang.org/x/net/http2/server_common.go @@ -25,6 +25,8 @@ import ( // // https://golang.org/pkg/net/http/#ResponseWriter // https://golang.org/pkg/net/http/#example_ResponseWriter_trailers +// +// Deprecated: Use [http.TrailerPrefix] instead. const TrailerPrefix = "Trailer:" // Push errors. @@ -38,16 +40,22 @@ var ( // The configuration conf may be nil. // // ConfigureServer must be called before s begins serving. +// +// Deprecated: Set [http.Server.Protocols] instead. func ConfigureServer(s *http.Server, conf *Server) error { return configureServer(s, conf) } // Server is an HTTP/2 server. +// +// Deprecated: Use [http.Server] instead. type Server struct { // MaxHandlers limits the number of http.Handler ServeHTTP goroutines // which may run at a time over all connections. // Negative or zero no limit. // TODO: implement + // + // Deprecated: This field has never had any effect. MaxHandlers int // MaxConcurrentStreams optionally specifies the number of @@ -56,6 +64,9 @@ type Server struct { // which may be active globally, which is MaxHandlers. // If zero, MaxConcurrentStreams defaults to at least 100, per // the HTTP/2 spec's recommendations. + // + // Deprecated: Use [http.Server.HTTP2] and + // [http.HTTP2Config.MaxConcurrentStreams] instead. MaxConcurrentStreams uint32 // MaxDecoderHeaderTableSize optionally specifies the http2 @@ -63,44 +74,67 @@ type Server struct { // informs the remote endpoint of the maximum size of the header compression // table used to decode header blocks, in octets. If zero, the default value // of 4096 is used. + // + // Deprecated: Use [http.Server.HTTP2] and + // [http.HTTP2Config.MaxDecoderHeaderTableSize] instead. MaxDecoderHeaderTableSize uint32 // MaxEncoderHeaderTableSize optionally specifies an upper limit for the // header compression table used for encoding request headers. Received // SETTINGS_HEADER_TABLE_SIZE settings are capped at this limit. If zero, // the default value of 4096 is used. + // + // Deprecated: Use [http.Server.HTTP2] and + // [http.HTTP2Config.MaxEncoderHeaderTableSize] instead. MaxEncoderHeaderTableSize uint32 // MaxReadFrameSize optionally specifies the largest frame // this server is willing to read. A valid value is between // 16k and 16M, inclusive. If zero or otherwise invalid, a // default value is used. + // + // Deprecated: Use [http.Server.HTTP2] and + // [http.HTTP2Config.MaxReadFrameSize] instead. MaxReadFrameSize uint32 // PermitProhibitedCipherSuites, if true, permits the use of // cipher suites prohibited by the HTTP/2 spec. + // + // Deprecated: Use [http.Server.HTTP2] and + // [http.HTTP2Config.PermitProhibitedCipherSuites] instead. PermitProhibitedCipherSuites bool // IdleTimeout specifies how long until idle clients should be // closed with a GOAWAY frame. PING frames are not considered // activity for the purposes of IdleTimeout. // If zero or negative, there is no timeout. + // + // Deprecated: Use [http.Server.IdleTimeout] instead. IdleTimeout time.Duration // ReadIdleTimeout is the timeout after which a health check using a ping // frame will be carried out if no frame is received on the connection. // If zero, no health check is performed. + // + // Deprecated: Use [http.Server.HTTP2] and + // [http.HTTP2Config.SendPingTimeout] instead. ReadIdleTimeout time.Duration // PingTimeout is the timeout after which the connection will be closed // if a response to a ping is not received. // If zero, a default of 15 seconds is used. + // + // Deprecated: Use [http.Server.HTTP2] and + // [http.HTTP2Config.PingTimeout] instead. PingTimeout time.Duration // WriteByteTimeout is the timeout after which a connection will be // closed if no data can be written to it. The timeout begins when data is // available to write, and is extended whenever any bytes are written. // If zero or negative, there is no timeout. + // + // Deprecated: Use [http.Server.HTTP2] and + // [http.HTTP2Config.WriteByteTimeout] instead. WriteByteTimeout time.Duration // MaxUploadBufferPerConnection is the size of the initial flow @@ -108,12 +142,18 @@ type Server struct { // allow this to be smaller than 65535 or larger than 2^32-1. // If the value is outside this range, a default value will be // used instead. + // + // Deprecated: Use [http.Server.HTTP2] and + // [http.HTTP2Config.MaxReceiveBufferPerConnection] instead. MaxUploadBufferPerConnection int32 // MaxUploadBufferPerStream is the size of the initial flow control // window for each stream. The HTTP/2 spec does not allow this to // be larger than 2^32-1. If the value is zero or larger than the // maximum, a default value will be used instead. + // + // Deprecated: Use [http.Server.HTTP2] and + // [http.HTTP2Config.MaxReceiveBufferPerStream] instead. MaxUploadBufferPerStream int32 // NewWriteScheduler constructs a write scheduler for a connection. @@ -126,6 +166,9 @@ type Server struct { // It's intended to increment a metric for monitoring, such // as an expvar or Prometheus metric. // The errType consists of only ASCII word characters. + // + // Deprecated: Use [http.Server.HTTP2] and + // [http.HTTP2Config.CountError] instead. CountError func(errType string) // Internal state. This is a pointer (rather than embedded directly) @@ -135,6 +178,8 @@ type Server struct { } // ServeConnOpts are options for the Server.ServeConn method. +// +// Deprecated: ServeConnOpts is deprecated. type ServeConnOpts struct { // Context is the base context to use. // If nil, context.Background is used. @@ -178,6 +223,8 @@ type ServeConnOpts struct { // implemented in terms of providing a suitably-behaving net.Conn. // // The opts parameter is optional. If nil, default values are used. +// +// Deprecated: Use [http.Server.Serve] or [http.Server.ServeTLS] instead. func (s *Server) ServeConn(c net.Conn, opts *ServeConnOpts) { if opts == nil { opts = &ServeConnOpts{} diff --git a/vendor/golang.org/x/net/http2/transport.go b/vendor/golang.org/x/net/http2/transport.go index 08ac409b..d894c601 100644 --- a/vendor/golang.org/x/net/http2/transport.go +++ b/vendor/golang.org/x/net/http2/transport.go @@ -27,6 +27,7 @@ import ( "net/http" "net/http/httptrace" "net/textproto" + "slices" "strconv" "sync" "sync/atomic" @@ -184,6 +185,8 @@ func (t *Transport) initConnPool() { // ClientConn is the state of a single HTTP/2 client connection to an // HTTP/2 server. +// +// Deprecated: Use [http.ClientConn] instead. type ClientConn struct { t *Transport tconn net.Conn // usually *tls.Conn, except specialized impls @@ -201,7 +204,7 @@ type ClientConn struct { mu sync.Mutex // guards following cond *sync.Cond // hold mu; broadcast on flow/closed changes - flow outflow // our conn-level flow control quota (cs.outflow is per stream) + flow connOutflow // our conn-level flow control quota (cs.outflow is per stream) inflow inflow // peer's conn-level flow control doNotReuse bool // whether conn is marked to not be reused for any future requests closing bool @@ -225,7 +228,6 @@ type ClientConn struct { maxConcurrentStreams uint32 peerMaxHeaderListSize uint64 peerMaxHeaderTableSize uint32 - initialWindowSize uint32 initialStreamRecvWindowSize int32 readIdleTimeout time.Duration pingTimeout time.Duration @@ -470,7 +472,6 @@ func (t *Transport) newClientConn(c net.Conn, singleUse bool, internalStateHook readerDone: make(chan struct{}), nextStreamID: 1, maxFrameSize: 16 << 10, // spec default - initialWindowSize: 65535, // spec default initialStreamRecvWindowSize: conf.MaxUploadBufferPerStream, maxConcurrentStreams: initialMaxConcurrentStreams, // "infinite", per spec. Use a smaller value until we have received server settings. strictMaxConcurrentStreams: conf.StrictMaxConcurrentRequests, @@ -495,7 +496,7 @@ func (t *Transport) newClientConn(c net.Conn, singleUse bool, internalStateHook } cc.cond = sync.NewCond(&cc.mu) - cc.flow.add(int32(initialWindowSize)) + cc.flow.init() // TODO: adjust this writer size to account for frame size + // MTU + crypto/tls record padding. @@ -874,6 +875,33 @@ func (cc *ClientConn) sendGoAway() error { return nil } +func (cc *ClientConn) goAwayAndClose(code ErrCode) { + cc.mu.Lock() + closed := cc.closed + cc.closing = true + cc.closed = true + cc.mu.Unlock() + if closed { + return + } + if f := cc.fr.countError; f != nil { + f(fmt.Sprintf("conn_close_error_%s", code.stringToken())) + } + done := make(chan struct{}) + go func() { + defer close(done) + cc.wmu.Lock() + cc.fr.WriteGoAway(0, code, nil) + cc.bw.Flush() + cc.wmu.Unlock() + }() + select { + case <-done: + case <-time.After(250 * time.Millisecond): + } + cc.closeForError(fmt.Errorf("http2: closing connection with %v", code)) +} + // closes the client connection immediately. In-flight requests are interrupted. // err is sent to streams. func (cc *ClientConn) closeForError(err error) { @@ -1317,7 +1345,9 @@ func (cs *clientStream) cleanupWriteRequest(err error) { } if err != nil { cs.abortStream(err) // possibly redundant, but harmless - if cs.sentHeaders { + if ce, ok := err.(ConnectionError); ok { + cc.goAwayAndClose(ErrCode(ce)) + } else if cs.sentHeaders { if se, ok := err.(StreamError); ok { if se.Cause != errFromPeer { cc.writeStreamReset(cs.ID, se.Code, false, err) @@ -1646,8 +1676,12 @@ func (cs *clientStream) awaitFlowControl(maxBytes int) (taken int32, err error) return 0, errRequestCanceled default: } - if a := cs.flow.available(); a > 0 { - take := a + avail, ok := cs.flow.available() + if !ok { + return 0, ConnectionError(ErrCodeFlowControl) + } + if avail > 0 { + take := avail if int(take) > maxBytes { take = int32(maxBytes) // can't truncate int; take is int32 @@ -1708,8 +1742,7 @@ type resAndError struct { // requires cc.mu be held. func (cc *ClientConn) addStreamLocked(cs *clientStream) { - cs.flow.add(int32(cc.initialWindowSize)) - cs.flow.setConnFlow(&cc.flow) + cs.flow.conn = &cc.flow cs.inflow.init(cc.initialStreamRecvWindowSize) cs.ID = cc.nextStreamID cc.nextStreamID += 2 @@ -2093,18 +2126,42 @@ func (rl *clientConnReadLoop) handleResponse(cs *clientStream, f *MetaHeadersFra return nil, nil } + // Delete various headers that might mess up framing for HTTP/1. This is + // not a problem for HTTP/2, but someone might use HTTP/2 transport as a + // reverse proxy which forwards the response to an HTTP/1 client. Our + // HTTP/1 client transport will properly reject improper headers such as + // multiple conflicting Content-Length headers, but other implementations + // might not. + // TODO: just reject such responses? We deleted them for compatibility + // since this was done in a security fix (go.dev/issue/81115). However, + // rejecting them seems entirely reasonable and relatively safe. + + // Connection-specific header fields must not appear in an HTTP/2 message, + // and any message containing them is malformed. RFC 9113, Section 8.2.2. + for _, k := range connHeaders { + delete(res.Header, k) + } res.ContentLength = -1 - if clens := res.Header["Content-Length"]; len(clens) == 1 { - if cl, err := strconv.ParseUint(clens[0], 10, 63); err == nil { - res.ContentLength = int64(cl) + if clens, ok := res.Header["Content-Length"]; ok { + // Repeated Content-Length values may be collapsed into one only if + // they are identical per RFC 9110 Section 8.6. + // No need to trim whitespace, HTTP/2 header values must not have + // extraneous whitespace per RFC 9113 Section 8.2.1. + // Non-canonical headers are already rejected by our framer at this + // point. + conflicting := slices.ContainsFunc(clens[1:], func(clen string) bool { + return clen != clens[0] + }) + cl, err := strconv.ParseUint(clens[0], 10, 63) + if conflicting || err != nil { + delete(res.Header, "Content-Length") } else { - // TODO: care? unlike http/1, it won't mess up our framing, so it's - // more safe smuggling-wise to ignore. + res.Header["Content-Length"] = clens[:1] + res.ContentLength = int64(cl) } - } else if len(clens) > 1 { - // TODO: care? unlike http/1, it won't mess up our framing, so it's - // more safe smuggling-wise to ignore. - } else if f.StreamEnded() && !cs.isHead { + } + + if res.ContentLength < 0 && f.StreamEnded() && !cs.isHead { res.ContentLength = 0 } @@ -2415,6 +2472,10 @@ const ( func (rl *clientConnReadLoop) streamByID(id uint32, headerOrData bool) *clientStream { rl.cc.mu.Lock() defer rl.cc.mu.Unlock() + return rl.streamByIDLocked(id, headerOrData) +} + +func (rl *clientConnReadLoop) streamByIDLocked(id uint32, headerOrData bool) *clientStream { if headerOrData { // Work around an unfortunate gRPC behavior. // See comment on ClientConn.rstStreamPingsBlocked for details. @@ -2497,24 +2558,10 @@ func (rl *clientConnReadLoop) processSettingsNoWrite(f *SettingsFrame) error { case SettingMaxHeaderListSize: cc.peerMaxHeaderListSize = uint64(s.Val) case SettingInitialWindowSize: - // Values above the maximum flow-control - // window size of 2^31-1 MUST be treated as a - // connection error (Section 5.4.1) of type - // FLOW_CONTROL_ERROR. - if s.Val > math.MaxInt32 { + if !cc.flow.changeInitialWindowSize(int64(s.Val)) { return ConnectionError(ErrCodeFlowControl) } - - // Adjust flow control of currently-open - // frames by the difference of the old initial - // window size and this one. - delta := int32(s.Val) - int32(cc.initialWindowSize) - for _, cs := range cc.streams { - cs.flow.add(delta) - } cc.cond.Broadcast() - - cc.initialWindowSize = s.Val case SettingHeaderTableSize: cc.henc.SetMaxDynamicTableSize(s.Val) cc.peerMaxHeaderTableSize = s.Val @@ -2556,29 +2603,30 @@ func (rl *clientConnReadLoop) processSettingsNoWrite(f *SettingsFrame) error { func (rl *clientConnReadLoop) processWindowUpdate(f *WindowUpdateFrame) error { cc := rl.cc - cs := rl.streamByID(f.StreamID, notHeaderOrDataFrame) - if f.StreamID != 0 && cs == nil { - return nil - } - cc.mu.Lock() defer cc.mu.Unlock() - - fl := &cc.flow - if cs != nil { - fl = &cs.flow - } - if !fl.add(int32(f.Increment)) { - // For stream, the sender sends RST_STREAM with an error code of FLOW_CONTROL_ERROR - if cs != nil { + if f.StreamID == 0 { + if !cc.flow.add(int32(f.Increment)) { + return ConnectionError(ErrCodeFlowControl) + } + } else { + cs := rl.streamByIDLocked(f.StreamID, notHeaderOrDataFrame) + if cs == nil { + return nil + } + if !cs.flow.add(int32(f.Increment)) { + if cs.flow.conn.flowErr { + // This is a lazily-detected connection-level flow control error. + return ConnectionError(ErrCodeFlowControl) + } + // For stream, the sender sends RST_STREAM with + // an error code of FLOW_CONTROL_ERROR. rl.endStreamErrorLocked(cs, StreamError{ StreamID: f.StreamID, Code: ErrCodeFlowControl, }) return nil } - - return ConnectionError(ErrCodeFlowControl) } cc.cond.Broadcast() return nil diff --git a/vendor/golang.org/x/net/http2/transport_common.go b/vendor/golang.org/x/net/http2/transport_common.go index b9f52932..4f0333a7 100644 --- a/vendor/golang.org/x/net/http2/transport_common.go +++ b/vendor/golang.org/x/net/http2/transport_common.go @@ -24,6 +24,8 @@ import ( // It returns an error if t1 has already been HTTP/2-enabled. // // Use ConfigureTransports instead to configure the HTTP/2 Transport. +// +// Deprecated: Set [http.Transport.Protocols] instead. func ConfigureTransport(t1 *http.Transport) error { return configureTransport(t1) } @@ -31,6 +33,8 @@ func ConfigureTransport(t1 *http.Transport) error { // ConfigureTransports configures a net/http HTTP/1 Transport to use HTTP/2. // It returns a new HTTP/2 Transport for further configuration. // It returns an error if t1 has already been HTTP/2-enabled. +// +// Deprecated: Set [http.Transport.Protocols] instead. func ConfigureTransports(t1 *http.Transport) (*Transport, error) { return configureTransports(t1) } @@ -39,6 +43,8 @@ func ConfigureTransports(t1 *http.Transport) (*Transport, error) { // // A Transport internally caches connections to servers. It is safe // for concurrent use by multiple goroutines. +// +// Deprecated: Use [http.Transport] instead. type Transport struct { // DialTLSContext specifies an optional dial function with context for // creating TLS connections for requests. @@ -47,6 +53,8 @@ type Transport struct { // // If the returned net.Conn has a ConnectionState method like tls.Conn, // it will be used to set http.Response.TLS. + // + // Deprecated: Use [http.Transport.DialTLSContext] instead. DialTLSContext func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) // DialTLS specifies an optional dial function for creating @@ -54,17 +62,21 @@ type Transport struct { // // If DialTLSContext and DialTLS is nil, tls.Dial is used. // - // Deprecated: Use DialTLSContext instead, which allows the transport - // to cancel dials as soon as they are no longer needed. - // If both are set, DialTLSContext takes priority. + // Deprecated: Use [http.Transport.DialTLSContext] instead. DialTLS func(network, addr string, cfg *tls.Config) (net.Conn, error) // TLSClientConfig specifies the TLS configuration to use with // tls.Client. If nil, the default configuration is used. + // + // Deprecated: Use [http.Transport.TLSClientConfig] instead. TLSClientConfig *tls.Config // ConnPool optionally specifies an alternate connection pool to use. // If nil, the default is used. + // + // Deprecated: To create a custom connection pool, implement + // [http.RoundTripper]. Use [http.Transport.NewClientConn] + // to create connections for the pool. ConnPool ClientConnPool // DisableCompression, if true, prevents the Transport from @@ -75,10 +87,14 @@ type Transport struct { // decoded in the Response.Body. However, if the user // explicitly requested gzip it is not automatically // uncompressed. + // + // Deprecated: Use [http.Transport.DisableCompression] instead. DisableCompression bool // AllowHTTP, if true, permits HTTP/2 requests using the insecure, // plain-text "http" scheme. Note that this does not enable h2c support. + // + // Deprecated: Use [http.Transport.Protocols] instead. AllowHTTP bool // MaxHeaderListSize is the http2 SETTINGS_MAX_HEADER_LIST_SIZE to @@ -88,6 +104,8 @@ type Transport struct { // want to advertise an unlimited value to the peer, Transport // interprets the highest possible value here (0xffffffff or 1<<32-1) // to mean no limit. + // + // Deprecated: Use [http.Transport.MaxResponseHeaderBytes] instead. MaxHeaderListSize uint32 // MaxReadFrameSize is the http2 SETTINGS_MAX_FRAME_SIZE to send in the @@ -97,6 +115,9 @@ type Transport struct { // according to the spec: // https://datatracker.ietf.org/doc/html/rfc7540#section-6.5.2. // Values are bounded in the range 16k to 16M. + // + // Deprecated: Use [http.Transport.HTTP2] and + // [http.HTTP2Config.MaxReadFrameSize] instead. MaxReadFrameSize uint32 // MaxDecoderHeaderTableSize optionally specifies the http2 @@ -104,12 +125,18 @@ type Transport struct { // informs the remote endpoint of the maximum size of the header compression // table used to decode header blocks, in octets. If zero, the default value // of 4096 is used. + // + // Deprecated: Use [http.Transport.HTTP2] and + // [http.HTTP2Config.MaxDecoderHeaderTableSize] instead. MaxDecoderHeaderTableSize uint32 // MaxEncoderHeaderTableSize optionally specifies an upper limit for the // header compression table used for encoding request headers. Received // SETTINGS_HEADER_TABLE_SIZE settings are capped at this limit. If zero, // the default value of 4096 is used. + // + // Deprecated: Use [http.Transport.HTTP2] and + // [http.HTTP2Config.MaxEncoderHeaderTableSize] instead. MaxEncoderHeaderTableSize uint32 // StrictMaxConcurrentStreams controls whether the server's @@ -120,12 +147,17 @@ type Transport struct { // server's SETTINGS_MAX_CONCURRENT_STREAMS is interpreted as // a global limit and callers of RoundTrip block when needed, // waiting for their turn. + // + // Deprecated: Use [http.Transport.HTTP2] and + // [http.HTTP2Config.StrictMaxConcurrentRequests] instead. StrictMaxConcurrentStreams bool // IdleConnTimeout is the maximum amount of time an idle // (keep-alive) connection will remain idle before closing // itself. // Zero means no limit. + // + // Deprecated: Use [http.Transport.IdleConnTimeout] instead. IdleConnTimeout time.Duration // ReadIdleTimeout is the timeout after which a health check using ping @@ -134,22 +166,34 @@ type Transport struct { // there is no other traffic on the connection, the health check will // be performed every ReadIdleTimeout interval. // If zero, no health check is performed. + // + // Deprecated: Use [http.Transport.HTTP2] and + // [http.HTTP2Config.SendPingTimeout] instead. ReadIdleTimeout time.Duration // PingTimeout is the timeout after which the connection will be closed // if a response to Ping is not received. // Defaults to 15s. + // + // Deprecated: Use [http.Transport.HTTP2] and + // [http.HTTP2Config.PingTimeout] instead. PingTimeout time.Duration // WriteByteTimeout is the timeout after which the connection will be // closed no data can be written to it. The timeout begins when data is // available to write, and is extended whenever any bytes are written. + // + // Deprecated: Use [http.Transport.HTTP2] and + // [http.HTTP2Config.WriteByteTimeout] instead. WriteByteTimeout time.Duration // CountError, if non-nil, is called on HTTP/2 transport errors. // It's intended to increment a metric for monitoring, such // as an expvar or Prometheus metric. // The errType consists of only ASCII word characters. + // + // Deprecated: Use [http.Transport.HTTP2] and + // [http.HTTP2Config.CountError] instead. CountError func(errType string) // Internal state, differs between wrapped and non-wrapped implementations. @@ -165,6 +209,10 @@ var ( ) // ClientConnPool manages a pool of HTTP/2 client connections. +// +// Deprecated: To create a custom connection pool, implement +// [http.RoundTripper]. Use [http.Transport.NewClientConn] +// to create connections for the pool. type ClientConnPool interface { // GetClientConn returns a specific HTTP/2 connection (usually // a TLS-TCP connection) to an HTTP/2 server. On success, the @@ -177,6 +225,8 @@ type ClientConnPool interface { } // ClientConnState describes the state of a ClientConn. +// +// Deprecated: Use [http.ClientConn] instead. type ClientConnState struct { // Closed is whether the connection is closed. Closed bool @@ -210,6 +260,8 @@ type ClientConnState struct { } // RoundTripOpt are options for the Transport.RoundTripOpt method. +// +// Deprecated: There are no options to set. type RoundTripOpt struct { // OnlyCachedConn controls whether RoundTripOpt may // create a new TCP connection. If set true and @@ -222,11 +274,14 @@ type RoundTripOpt struct { allowHTTP bool // allow http:// URLs } +// Deprecated: Use [http.Transport.RoundTrip] instead. func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { return t.RoundTripOpt(req, RoundTripOpt{}) } // RoundTripOpt is like RoundTrip, but takes options. +// +// Deprecated: Use [http.Transport.RoundTrip] instead. func (t *Transport) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) { return t.roundTripOpt(req, opt) } @@ -234,10 +289,15 @@ func (t *Transport) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Res // CloseIdleConnections closes any connections which were previously // connected from previous requests but are now sitting idle. // It does not interrupt any connections currently in use. +// +// Deprecated: Use [http.Transport.CloseIdleConnections] instead. func (t *Transport) CloseIdleConnections() { t.closeIdleConnections() } +// NewClientConn is deprecated. +// +// Deprecated: Use [http.Transport.NewClientConn] instead. func (t *Transport) NewClientConn(c net.Conn) (*ClientConn, error) { return t.newUserClientConn(c) } @@ -256,8 +316,15 @@ func authorityAddr(scheme string, authority string) (addr string) { port = "80" } } - if a, err := idna.ToASCII(host); err == nil { - host = a + // Skip IDNA processing on hosts which are already ASCII. + // This is consistent with net/http and the WHATWG URL Specification. + // (We currently don't follow all of WHATWG, but we're aligned on + // permitting all-ASCII hostnames which fail IDNA validation. + // There are existing, valid domain names which TR #46 processing rejects.) + if !isASCII(host) { + if a, err := idna.Lookup.ToASCII(host); err == nil && a != "" { + host = a + } } // IPv6 address literal, without a port: if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { @@ -414,6 +481,8 @@ func (t *Transport) dialTLSWithContext(ctx context.Context, network, addr string // GoAwayError is returned by the Transport when the server closes the // TCP connection after sending a GOAWAY frame. +// +// Deprecated: GoAwayError is deprecated. type GoAwayError struct { LastStreamID uint32 ErrCode ErrCode diff --git a/vendor/golang.org/x/net/http2/transport_wrap.go b/vendor/golang.org/x/net/http2/transport_wrap.go index 741fb970..eeaa95f0 100644 --- a/vendor/golang.org/x/net/http2/transport_wrap.go +++ b/vendor/golang.org/x/net/http2/transport_wrap.go @@ -119,33 +119,27 @@ type http2TransportContextKey struct{} // DialFromContext dials a new connection using the http2.Transport's DialTLS/DialTLSContext. func (t transportConfig) DialFromContext(ctx context.Context, network, address string) (net.Conn, error) { - if ctx.Value(http2TransportContextKey{}) == nil { + dial, _ := ctx.Value(http2TransportContextKey{}).(*transportRoundTripState) + if dial == nil { // We're being called from a RoundTrip that did not start with an http2.Transport. // Use the http.Transport's dialer. return nil, errors.ErrUnsupported } - - tlsConf := t.t.TLSClientConfig - if tlsConf == nil { - tlsConf = &tls.Config{} - } else { - tlsConf = tlsConf.Clone() - } - if !slices.Contains(tlsConf.NextProtos, "h2") { - tlsConf.NextProtos = append([]string{"h2"}, tlsConf.NextProtos...) - } - if tlsConf.ServerName == "" { - host, _, err := net.SplitHostPort(address) - if err == nil { - tlsConf.ServerName = host - } - } - return t.t.dialTLS(ctx, network, address, tlsConf) + return t.t.dialForRoundTrip(ctx, dial, network, address) } type transportInternal struct { initOnce sync.Once lazyt1 *http.Transport + + dialMu sync.Mutex + dials map[string]*transportDialState +} + +type transportDialState struct { + dialc chan struct{} // close when dial returns + dialErr error // dial result, set before dialc is closed + rtdonec chan struct{} // closed when RoundTrip initiating the dial returns } func (t *Transport) init() *http.Transport { @@ -167,6 +161,52 @@ func (t *Transport) configure(t1 *http.Transport) { } } +// transportRoundTripState is the state of the dial for an http2.Transport.RoundTrip. +type transportRoundTripState struct { + mu sync.Mutex + rtdone bool // set when RoundTrip returns + rtdonec chan struct{} // closed when RoundTrip returns + gotconnc chan struct{} // closed when GotConn hook is called +} + +// startDial is called when a dial starts. +// +// It returns done=true if the RoundTrip has already returned, +// in which case we should skip dialing. +func (dial *transportRoundTripState) startDial() (gotconnc chan struct{}, done bool) { + dial.mu.Lock() + defer dial.mu.Unlock() + if dial.rtdone { + return nil, true + } + if dial.rtdonec == nil { + dial.rtdonec = make(chan struct{}) + } + dial.gotconnc = make(chan struct{}) + return dial.gotconnc, false +} + +// gotConn is called when RoundTrip gets a connection. +// This may happen multiple times per RoundTrip, if a request is retried. +func (dial *transportRoundTripState) gotConn() { + dial.mu.Lock() + defer dial.mu.Unlock() + if dial.gotconnc != nil { + close(dial.gotconnc) + dial.gotconnc = nil + } +} + +// roundTripDone is called when RoundTrip returns. +func (dial *transportRoundTripState) roundTripDone() { + dial.mu.Lock() + defer dial.mu.Unlock() + dial.rtdone = true + if dial.rtdonec != nil { + close(dial.rtdonec) + } +} + func (t *Transport) roundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) { t1 := t.init() @@ -186,12 +226,146 @@ func (t *Transport) roundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Res // Both http.Transport and http2.Transport allow the user to provide a custom // dial function, and historically you only get the dial function from the // Transport you're calling RoundTrip on. - ctx := context.WithValue(req.Context(), http2TransportContextKey{}, t) + // + // In addition, http2.Transport coalesces dials, which http.Transport historically + // has not. + dial := &transportRoundTripState{} + defer dial.roundTripDone() + ctx := context.WithValue(req.Context(), http2TransportContextKey{}, dial) + ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{ + GotConn: func(httptrace.GotConnInfo) { + // Tell dialForRoundTrip that we have received a connection. + dial.gotConn() + }, + }) req = req.WithContext(ctx) - return t1.RoundTrip(req) } +var errCoalescedDialAbandoned = errors.New("http2: abandoned coalesced dial") + +const coalescedDialRetryTimeout = 50 * time.Millisecond + +// dialForRoundTrip is called (indirectly) by net/http when dialing a new connection +// for a request call which originated as an http2.Transport.RoundTrip call. +// +// It uses the http2.Transport's Dial hooks and coalesces dials. +func (t *Transport) dialForRoundTrip(ctx context.Context, dial *transportRoundTripState, network, address string) (net.Conn, error) { + gotconnc, rtdone := dial.startDial() + if rtdone { + // RoundTrip returned, no need for this dial to proceed. + return nil, errCoalescedDialAbandoned + } + + var state *transportDialState + for { + // The first dial to an address registers itself with t.dials. + // When the dial finishes, it records the outcome and removes itself from t.dials + // so future dials will start a new round of coalescing. + // + // Subsequent dials observe that a dial is in progress, and skip dialing. + t.dialMu.Lock() + if t.dials == nil { + t.dials = make(map[string]*transportDialState) + } + leader := t.dials[address] + if leader == nil { + // No entry in t.dials to coalesce with. Add ourselves as leader. + state = &transportDialState{ + dialc: make(chan struct{}), + rtdonec: dial.rtdonec, + } + t.dials[address] = state + } + t.dialMu.Unlock() + if leader == nil { + // We are the leader, so we should dial for real. + break + } + + // Coalesce with a previous dial. + var ( + done = false + leaderdialc = leader.dialc + leaderrtdonec = leader.rtdonec + timerc <-chan time.Time + ) + for !done { + if leaderdialc == nil && leaderrtdonec == nil && timerc == nil { + // The leader finished dialing, and its RoundTrip finished. + // + // There are several possibilities, which reduce to: + // - The RoundTrip we are dialing for is about to receive + // a connection (possibly the one the leader just dialed), + // but we haven't observed it yet. + // - There's something wrong with the leader's connection, + // such as a TLS handshake failure. + // + // We have no good way to distinguish between these cases. + // + // Wait a short time for the leader's connection (or some + // other usable connection) to be delivered to our RoundTrip. + // If it is not, start a dial of our own. + timerc = time.After(coalescedDialRetryTimeout) + } + select { + case <-leaderdialc: + // The leader's dial completed. + if leader.dialErr != nil { + return nil, leader.dialErr + } + leaderdialc = nil + case <-leaderrtdonec: + // The leader's RoundTrip completed. + leaderrtdonec = nil + case <-timerc: + // The leader's dial and RoundTrip completed, and some time + // has passed. Assume we're not getting a connection and + // restart the dial process. + done = true + case <-dial.rtdonec: + // Our RoundTrip returned. + return nil, errCoalescedDialAbandoned + case <-gotconnc: + // Our RoundTrip got a connection. + return nil, errCoalescedDialAbandoned + case <-ctx.Done(): + // net/http abandoned the dial. + return nil, ctx.Err() + } + } + } + + // Dial for real. + tlsConf := t.TLSClientConfig + if tlsConf == nil { + tlsConf = &tls.Config{} + } else { + tlsConf = tlsConf.Clone() + } + if !slices.Contains(tlsConf.NextProtos, "h2") { + tlsConf.NextProtos = append([]string{"h2"}, tlsConf.NextProtos...) + } + if tlsConf.ServerName == "" { + host, _, err := net.SplitHostPort(address) + if err == nil { + tlsConf.ServerName = host + } + } + nc, err := t.dialTLS(ctx, network, address, tlsConf) + + // Remove this dial from t.dials, so future dial attempts will not coalesce with it. + t.dialMu.Lock() + delete(t.dials, address) + t.dialMu.Unlock() + + // Notify any dial coalesced with this one that we are done. + state.dialErr = err + close(state.dialc) + + return nc, err +} + func (t *Transport) closeIdleConnections() { t1 := t.init() t1.CloseIdleConnections() @@ -216,6 +390,8 @@ func (t *Transport) newUserClientConn(c net.Conn) (*ClientConn, error) { // ClientConn is the state of a single HTTP/2 client connection to an // HTTP/2 server. +// +// Deprecated: Use [http.ClientConn] instead. type ClientConn struct { cc *http.ClientConn tconn net.Conn diff --git a/vendor/golang.org/x/net/http2/writesched.go b/vendor/golang.org/x/net/http2/writesched.go index 36ad5e32..f1903a8c 100644 --- a/vendor/golang.org/x/net/http2/writesched.go +++ b/vendor/golang.org/x/net/http2/writesched.go @@ -79,10 +79,11 @@ func (wr FrameWriteRequest) Consume(n int32) (FrameWriteRequest, FrameWriteReque } // Might need to split after applying limits. - allowed := wr.stream.flow.available() - if n < allowed { - allowed = n + avail, ok := wr.stream.flow.available() + if !ok { + return empty, empty, 0 } + allowed := min(n, avail) if wr.stream.sc.maxFrameSize < allowed { allowed = wr.stream.sc.maxFrameSize } diff --git a/vendor/golang.org/x/net/internal/httpcommon/README.md b/vendor/golang.org/x/net/internal/httpcommon/README.md new file mode 100644 index 00000000..951c346c --- /dev/null +++ b/vendor/golang.org/x/net/internal/httpcommon/README.md @@ -0,0 +1,7 @@ +These two packages are identical: + +- net/http/internal/httpcommon +- golang.org/x/net/internal/httpcommon + +The two are kept in sync manually. +Any change to one should be made in the other. diff --git a/vendor/golang.org/x/net/internal/httpcommon/gzip.go b/vendor/golang.org/x/net/internal/httpcommon/gzip.go new file mode 100644 index 00000000..819dd650 --- /dev/null +++ b/vendor/golang.org/x/net/internal/httpcommon/gzip.go @@ -0,0 +1,132 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package httpcommon + +import ( + "compress/flate" + "compress/gzip" + "errors" + "io" + "io/fs" + "sync" +) + +var errConcurrentRead = errors.New("http: concurrent read on response body") + +// incomparable is a zero-width, non-comparable type. Adding it to a struct +// makes that struct also non-comparable, and generally doesn't add +// any size (as long as it's first). +type incomparable [0]func() + +// GzipReader wraps a response body so it can lazily +// get gzip.Reader from the pool on the first call to Read. +// After Close is called it puts gzip.Reader to the pool immediately +// if there is no Read in progress or later when Read completes. +type GzipReader struct { + _ incomparable + Body io.ReadCloser // underlying Response.Body + mu sync.Mutex // guards zr and zerr + zr *gzip.Reader // stores gzip reader from the pool between reads + zerr error // sticky gzip reader init error or sentinel value to detect concurrent read and read after close +} + +type eofReader struct{} + +func (eofReader) Read([]byte) (int, error) { return 0, io.EOF } +func (eofReader) ReadByte() (byte, error) { return 0, io.EOF } + +var gzipPool = sync.Pool{New: func() any { return new(gzip.Reader) }} + +// gzipPoolGet gets a gzip.Reader from the pool and resets it to read from r. +func gzipPoolGet(r io.Reader) (*gzip.Reader, error) { + zr := gzipPool.Get().(*gzip.Reader) + if err := zr.Reset(r); err != nil { + gzipPoolPut(zr) + return nil, err + } + return zr, nil +} + +// gzipPoolPut puts a gzip.Reader back into the pool. +func gzipPoolPut(zr *gzip.Reader) { + // Reset will allocate bufio.Reader if we pass it anything + // other than a flate.Reader, so ensure that it's getting one. + var r flate.Reader = eofReader{} + zr.Reset(r) + gzipPool.Put(zr) +} + +// acquire returns a gzip.Reader for reading response body. +// The reader must be released after use. +func (gz *GzipReader) acquire() (*gzip.Reader, error) { + gz.mu.Lock() + defer gz.mu.Unlock() + if gz.zerr != nil { + return nil, gz.zerr + } + if gz.zr == nil { + // gzipPoolGet might block indefinitely since it reads the gzip header. + // Therefore, drop mu temporarily when using gzipPoolGet. + // We set zerr to errConcurrentRead to prevent concurrent read + // even when mu is temporarily dropped. + gz.zerr = errConcurrentRead + gz.mu.Unlock() + zr, err := gzipPoolGet(gz.Body) + gz.mu.Lock() + // Guard against Close being called while gzipPoolGet is running. + if gz.zerr != errConcurrentRead { + if zr != nil { + gzipPoolPut(zr) + } + return nil, gz.zerr + } + gz.zr, gz.zerr = zr, err + if gz.zerr != nil { + return nil, gz.zerr + } + } + ret := gz.zr + gz.zr, gz.zerr = nil, errConcurrentRead + return ret, nil +} + +// release returns the gzip.Reader to the pool if Close was called during Read. +func (gz *GzipReader) release(zr *gzip.Reader) { + gz.mu.Lock() + defer gz.mu.Unlock() + if gz.zerr == errConcurrentRead { + gz.zr, gz.zerr = zr, nil + } else { // fs.ErrClosed + gzipPoolPut(zr) + } +} + +// close returns the gzip.Reader to the pool immediately or +// signals release to do so after Read completes. +func (gz *GzipReader) close() { + gz.mu.Lock() + defer gz.mu.Unlock() + if gz.zerr == nil && gz.zr != nil { + gzipPoolPut(gz.zr) + gz.zr = nil + } + gz.zerr = fs.ErrClosed +} + +func (gz *GzipReader) Read(p []byte) (n int, err error) { + zr, err := gz.acquire() + if err != nil { + return 0, err + } + defer gz.release(zr) + + return zr.Read(p) +} + +func (gz *GzipReader) Close() error { + gz.close() + + return gz.Body.Close() +} diff --git a/vendor/golang.org/x/net/internal/httpcommon/request.go b/vendor/golang.org/x/net/internal/httpcommon/request.go index d94c8742..5aaca598 100644 --- a/vendor/golang.org/x/net/internal/httpcommon/request.go +++ b/vendor/golang.org/x/net/internal/httpcommon/request.go @@ -391,6 +391,7 @@ type ServerRequestParam struct { // ServerRequestResult is the result of NewServerRequest. type ServerRequestResult struct { // Various http.Request fields. + Host string URL *url.URL RequestURI string Trailer map[string][]string @@ -433,20 +434,46 @@ func NewServerRequest(rp ServerRequestParam) ServerRequestResult { } delete(rp.Header, "Trailer") + authority := rp.Authority + if host := rp.Header["Host"]; len(host) == 1 { + // HTTP/2 and HTTP/3 permit the Host header to be present, + // but it must match the :authority pseudo-header. + if authority == "" { + authority = host[0] + } else if host[0] != authority { + return ServerRequestResult{ + InvalidReason: "authority_host_mismatch", + } + } + delete(rp.Header, "Host") + } else if len(host) > 1 { + // HTTP/1.1 rejects any request containing more than one Host header. + // HTTP/2 and HTTP/3 don't use the Host header, but reject multiple anyway. + return ServerRequestResult{ + InvalidReason: "multiple_host_headers", + } + } + // "':authority' MUST NOT include the deprecated userinfo subcomponent // for "http" or "https" schemed URIs." // https://www.rfc-editor.org/rfc/rfc9113.html#section-8.3.1-2.3.8 - if strings.IndexByte(rp.Authority, '@') != -1 && (rp.Scheme == "http" || rp.Scheme == "https") { + if strings.IndexByte(authority, '@') != -1 && (rp.Scheme == "http" || rp.Scheme == "https") { return ServerRequestResult{ InvalidReason: "userinfo_in_authority", } } + if authority != "" && !httpguts.ValidHostHeader(authority) { + return ServerRequestResult{ + InvalidReason: "invalid_authority", + } + } + var url_ *url.URL var requestURI string if rp.Method == "CONNECT" && rp.Protocol == "" { - url_ = &url.URL{Host: rp.Authority} - requestURI = rp.Authority // mimic HTTP/1 server behavior + url_ = &url.URL{Host: authority} + requestURI = authority // mimic HTTP/1 server behavior } else { // "[The :path] pseudo-header field MUST NOT be empty [...]" // https://www.rfc-editor.org/rfc/rfc9113.html#section-8.3.1-2.4.2 @@ -467,6 +494,7 @@ func NewServerRequest(rp ServerRequestParam) ServerRequestResult { } return ServerRequestResult{ + Host: authority, URL: url_, NeedsContinue: needsContinue, RequestURI: requestURI, diff --git a/vendor/golang.org/x/net/trace/histogram.go b/vendor/golang.org/x/net/trace/histogram.go index d6c71101..2f786831 100644 --- a/vendor/golang.org/x/net/trace/histogram.go +++ b/vendor/golang.org/x/net/trace/histogram.go @@ -70,10 +70,7 @@ func log2(i int64) int { } func getBucket(i int64) (index int) { - index = log2(i) - 1 - if index < 0 { - index = 0 - } + index = max(log2(i)-1, 0) if index >= bucketCount { index = bucketCount - 1 } diff --git a/vendor/golang.org/x/sync/semaphore/semaphore.go b/vendor/golang.org/x/sync/semaphore/semaphore.go index 96a035ae..f2a7d3ff 100644 --- a/vendor/golang.org/x/sync/semaphore/semaphore.go +++ b/vendor/golang.org/x/sync/semaphore/semaphore.go @@ -17,10 +17,13 @@ type waiter struct { } // NewWeighted creates a new weighted semaphore with the given -// maximum combined weight for concurrent access. +// maximum combined weight for concurrent access. NewWeighted panics if n is +// negative. func NewWeighted(n int64) *Weighted { - w := &Weighted{size: n} - return w + if n < 0 { + panic("semaphore: size < 0") + } + return &Weighted{size: n} } // Weighted provides a way to bound concurrent access to a resource. diff --git a/vendor/golang.org/x/sys/cpu/cpu.go b/vendor/golang.org/x/sys/cpu/cpu.go index f1ce515d..df8dbd1d 100644 --- a/vendor/golang.org/x/sys/cpu/cpu.go +++ b/vendor/golang.org/x/sys/cpu/cpu.go @@ -182,12 +182,13 @@ var MIPS64X struct { // require kernel support to work (DARN, SCV), so there are feature bits for // those as well. The struct is padded to avoid false sharing. var PPC64 struct { - _ CacheLinePad - HasDARN bool // Hardware random number generator (requires kernel enablement) - HasSCV bool // Syscall vectored (requires kernel enablement) - IsPOWER8 bool // ISA v2.07 (POWER8) - IsPOWER9 bool // ISA v3.00 (POWER9), implies IsPOWER8 - _ CacheLinePad + _ CacheLinePad + HasDARN bool // Hardware random number generator (requires kernel enablement) + HasSCV bool // Syscall vectored (requires kernel enablement) + IsPOWER8 bool // ISA v2.07 (POWER8) + IsPOWER9 bool // ISA v3.00 (POWER9), implies IsPOWER8 + IsPOWER10 bool // ISA v3.1 (POWER10 and POWER11; POWER11 did not add a new architected level), implies IsPOWER9 + _ CacheLinePad } // S390X contains the supported CPU features of the current IBM Z @@ -248,13 +249,21 @@ var RISCV64 struct { HasZvks bool // ShangMi Algorithm Suite HasZvksc bool // ShangMi Algorithm Suite with carryless multiplication HasZvksg bool // ShangMi Algorithm Suite with GCM + VLENB uint // Vector register length in bytes, 0 if undetected _ CacheLinePad } +// doDerived, if non-nil, is called after processing GODEBUG to set "derived" +// feature flags. +var doDerived func() + func init() { archInit() initOptions() processOptions() + if doDerived != nil { + doDerived() + } } // options contains the cpu debug options that can be used in GODEBUG. diff --git a/vendor/golang.org/x/sys/cpu/cpu_gc_riscv64.go b/vendor/golang.org/x/sys/cpu/cpu_gc_riscv64.go new file mode 100644 index 00000000..3d660dd4 --- /dev/null +++ b/vendor/golang.org/x/sys/cpu/cpu_gc_riscv64.go @@ -0,0 +1,11 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build gc + +package cpu + +// Can only be called when the vector extension is present. +// Implemented in cpu_riscv64.s. +func readVLENB() uint diff --git a/vendor/golang.org/x/sys/cpu/cpu_linux_ppc64x.go b/vendor/golang.org/x/sys/cpu/cpu_linux_ppc64x.go index 197188e6..70b3dc7d 100644 --- a/vendor/golang.org/x/sys/cpu/cpu_linux_ppc64x.go +++ b/vendor/golang.org/x/sys/cpu/cpu_linux_ppc64x.go @@ -11,6 +11,7 @@ const ( // ISA Level _PPC_FEATURE2_ARCH_2_07 = 0x80000000 _PPC_FEATURE2_ARCH_3_00 = 0x00800000 + _PPC_FEATURE2_ARCH_3_1 = 0x00040000 // CPU features _PPC_FEATURE2_DARN = 0x00200000 @@ -21,6 +22,9 @@ func doinit() { // HWCAP2 feature bits PPC64.IsPOWER8 = isSet(hwCap2, _PPC_FEATURE2_ARCH_2_07) PPC64.IsPOWER9 = isSet(hwCap2, _PPC_FEATURE2_ARCH_3_00) + // ISA 3.1 covers both POWER10 and POWER11: POWER11 did not introduce a + // new architected HWCAP level, so there is no separate IsPOWER11. + PPC64.IsPOWER10 = isSet(hwCap2, _PPC_FEATURE2_ARCH_3_1) PPC64.HasDARN = isSet(hwCap2, _PPC_FEATURE2_DARN) PPC64.HasSCV = isSet(hwCap2, _PPC_FEATURE2_SCV) } diff --git a/vendor/golang.org/x/sys/cpu/cpu_linux_riscv64.go b/vendor/golang.org/x/sys/cpu/cpu_linux_riscv64.go index f4fb52ee..53e90911 100644 --- a/vendor/golang.org/x/sys/cpu/cpu_linux_riscv64.go +++ b/vendor/golang.org/x/sys/cpu/cpu_linux_riscv64.go @@ -130,6 +130,9 @@ func doinit() { RISCV64.HasFastMisaligned = v == riscv_HWPROBE_MISALIGNED_FAST } } + if RISCV64.HasV { + RISCV64.VLENB = readVLENB() + } // Let's double check with HWCAP if the C extension does not appear to be supported. // This may happen if we're running on a kernel older than 6.4. @@ -137,6 +140,14 @@ func doinit() { if !RISCV64.HasC { RISCV64.HasC = isSet(hwCap, hwcap_RISCV_ISA_C) } + + doDerived = func() { + // If the vector extension is disabled by GODEBUG, then the VLENB is zero. + if !RISCV64.HasV { + RISCV64.VLENB = 0 + } + } + } func isSet(hwc uint, value uint) bool { diff --git a/vendor/golang.org/x/sys/cpu/cpu_netbsd_amd64.go b/vendor/golang.org/x/sys/cpu/cpu_netbsd_amd64.go new file mode 100644 index 00000000..4f981b1c --- /dev/null +++ b/vendor/golang.org/x/sys/cpu/cpu_netbsd_amd64.go @@ -0,0 +1,47 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build netbsd && amd64 && gc + +package cpu + +func doinit() { + // NetBSD corrupts avx registers when receiving signals. + // See issue 80285. + // TODO: when NetBSD fixes the bug, add a version + // check and skip here. + X86.HasAVX = false + X86.HasAVX2 = false + // Set these also, just to be safe + X86.HasAVXVNNI = false + X86.HasAVX512 = false + X86.HasAVX512F = false + X86.HasAVX512CD = false + X86.HasAVX512CD = false + X86.HasAVX512ER = false + X86.HasAVX512PF = false + X86.HasAVX512VL = false + X86.HasAVX512BW = false + X86.HasAVX512DQ = false + X86.HasAVX512IFMA = false + X86.HasAVX512VBMI = false + X86.HasAVX5124VNNIW = false + X86.HasAVX5124FMAPS = false + X86.HasAVX512VPOPCNTDQ = false + X86.HasAVX512VPCLMULQDQ = false + X86.HasAVX512VNNI = false + X86.HasAVX512GFNI = false + X86.HasAVX512VAES = false + X86.HasAVX512VBMI2 = false + X86.HasAVX512BITALG = false + X86.HasAVX512BF16 = false + X86.HasAVXIFMA = false + X86.HasAVXVNNI = false + X86.HasAVXVNNIInt8 = false + +} + +func darwinSupportsAVX512() bool { + panic("only implemented for gc && amd64 && darwin") +} diff --git a/vendor/golang.org/x/sys/cpu/cpu_other_x86.go b/vendor/golang.org/x/sys/cpu/cpu_other_x86.go index a0fd7e2f..82b7580d 100644 --- a/vendor/golang.org/x/sys/cpu/cpu_other_x86.go +++ b/vendor/golang.org/x/sys/cpu/cpu_other_x86.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -//go:build 386 || amd64p32 || (amd64 && (!darwin || !gc)) +//go:build 386 || amd64p32 || (amd64 && ((!darwin && !netbsd) || !gc)) package cpu diff --git a/vendor/golang.org/x/sys/cpu/cpu_riscv64.s b/vendor/golang.org/x/sys/cpu/cpu_riscv64.s new file mode 100644 index 00000000..b092fb26 --- /dev/null +++ b/vendor/golang.org/x/sys/cpu/cpu_riscv64.s @@ -0,0 +1,17 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build gc + +#include "textflag.h" + +// Read the vector register length in bytes from the vlenb CSR. +// May only be called when the vector extension is present. +// func readVLENB() uint +TEXT ·readVLENB(SB), NOSPLIT|NOFRAME, $0-8 + // Go 1.25's assembler does not recognize VLENB, so use its raw CSRR encoding. + // Replace WORD with CSRR VLENB, X10 once go.mod requires Go 1.27. + WORD $0xc2202573 + MOV X10, ret+0(FP) + RET diff --git a/vendor/golang.org/x/sys/cpu/cpu_sparc64.go b/vendor/golang.org/x/sys/cpu/cpu_sparc64.go new file mode 100644 index 00000000..0199ef19 --- /dev/null +++ b/vendor/golang.org/x/sys/cpu/cpu_sparc64.go @@ -0,0 +1,12 @@ +// Copyright 2026 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build sparc64 + +package cpu + +// The L1 line is 32 bytes; false sharing is governed by the 64-byte L2 line. +const cacheLineSize = 64 + +func initOptions() {} diff --git a/vendor/golang.org/x/sys/unix/ifreq_linux.go b/vendor/golang.org/x/sys/unix/ifreq_linux.go index 309f5a2b..3f7fa766 100644 --- a/vendor/golang.org/x/sys/unix/ifreq_linux.go +++ b/vendor/golang.org/x/sys/unix/ifreq_linux.go @@ -22,7 +22,13 @@ import ( // fields can be get and set using the following methods: // - Uint16/SetUint16: flags // - Uint32/SetUint32: ifindex, metric, mtu -type Ifreq struct{ raw ifreq } +type Ifreq struct { + // Aligns the union for the accessors below, which cast it in place; + // the generated ifreq is all byte arrays, so its alignment is one. + _ [0]int64 + + raw ifreq +} // NewIfreq creates an Ifreq with the input network interface name after // validating the name does not exceed IFNAMSIZ-1 (trailing NULL required) diff --git a/vendor/golang.org/x/sys/unix/ioctl_linux.go b/vendor/golang.org/x/sys/unix/ioctl_linux.go index 7ca4fa12..5589d4f6 100644 --- a/vendor/golang.org/x/sys/unix/ioctl_linux.go +++ b/vendor/golang.org/x/sys/unix/ioctl_linux.go @@ -332,3 +332,10 @@ func IoctlLoopSetStatus64(fd int, value *LoopInfo64) error { func IoctlLoopConfigure(fd int, value *LoopConfig) error { return ioctlPtr(fd, LOOP_CONFIGURE, unsafe.Pointer(value)) } + +// IoctlPidfdInfo fetches information about a pidfd. +// The Mask field of the info argument indicates which +// values to fetch. +func IoctlPidfdInfo(fd int, info *PidfdInfo) error { + return ioctlPtr(fd, PIDFD_GET_INFO, unsafe.Pointer(info)) +} diff --git a/vendor/golang.org/x/sys/unix/mkerrors.sh b/vendor/golang.org/x/sys/unix/mkerrors.sh index fa74cfe9..9e907ac2 100644 --- a/vendor/golang.org/x/sys/unix/mkerrors.sh +++ b/vendor/golang.org/x/sys/unix/mkerrors.sh @@ -290,14 +290,7 @@ struct ltchars { #include #include -#if defined(__sparc__) -// On sparc{,64}, the kernel defines struct termios2 itself which clashes with the -// definition in glibc. As only the error constants are needed here, include the -// generic termibits.h (which is included by termbits.h on sparc). -#include -#else #include -#endif #ifndef PTRACE_GETREGS #define PTRACE_GETREGS 0xc @@ -544,7 +537,7 @@ ccflags="$@" $2 ~ /^LO_(KEY|NAME)_SIZE$/ || $2 ~ /^LOOP_(CLR|CTL|GET|SET)_/ || $2 == "LOOP_CONFIGURE" || - $2 ~ /^(AF|SOCK|SO|SOL|IPPROTO|IP|IPV6|TCP|MCAST|EVFILT|NOTE|SHUT|PROT|MAP|MREMAP|MFD|T?PACKET|MSG|SCM|MCL|DT|MADV|PR|LOCAL|TCPOPT|UDP)_/ || + $2 ~ /^(AF|SOCK|SO|SOL|IPPROTO|IP|IPV6|TCP|MCAST|EVFILT|NOTE|SHUT|PROT|MAP|MREMAP|MFD|MLOCK|T?PACKET|MSG|SCM|MCL|DT|MADV|PR|LOCAL|TCPOPT|UDP)_/ || $2 ~ /^NFC_(GENL|PROTO|COMM|RF|SE|DIRECTION|LLCP|SOCKPROTO)_/ || $2 ~ /^NFC_.*_(MAX)?SIZE$/ || $2 ~ /^PTP_/ || diff --git a/vendor/golang.org/x/sys/unix/syscall.go b/vendor/golang.org/x/sys/unix/syscall.go index 5ea74da9..8f27e7ec 100644 --- a/vendor/golang.org/x/sys/unix/syscall.go +++ b/vendor/golang.org/x/sys/unix/syscall.go @@ -76,7 +76,7 @@ func BytePtrToString(p *byte) string { // Find NUL terminator. n := 0 for ptr := unsafe.Pointer(p); *(*byte)(ptr) != 0; n++ { - ptr = unsafe.Pointer(uintptr(ptr) + 1) + ptr = unsafe.Add(ptr, 1) } return string(unsafe.Slice(p, n)) diff --git a/vendor/golang.org/x/sys/unix/syscall_bsd.go b/vendor/golang.org/x/sys/unix/syscall_bsd.go index a00c3e54..035c2cd9 100644 --- a/vendor/golang.org/x/sys/unix/syscall_bsd.go +++ b/vendor/golang.org/x/sys/unix/syscall_bsd.go @@ -188,7 +188,7 @@ func (sa *SockaddrUnix) sockaddr() (unsafe.Pointer, _Socklen, error) { } sa.raw.Len = byte(3 + n) // 2 for Family, Len; 1 for NUL sa.raw.Family = AF_UNIX - for i := 0; i < n; i++ { + for i := range n { sa.raw.Path[i] = int8(name[i]) } return unsafe.Pointer(&sa.raw), _Socklen(sa.raw.Len), nil diff --git a/vendor/golang.org/x/sys/unix/syscall_linux.go b/vendor/golang.org/x/sys/unix/syscall_linux.go index 21e2bfa3..6926057e 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux.go @@ -2363,7 +2363,7 @@ func (fh *FileHandle) Bytes() []byte { if n == 0 { return nil } - return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(&fh.fileHandle.Type))+4)), n) + return unsafe.Slice((*byte)(unsafe.Add(unsafe.Pointer(&fh.fileHandle.Type), 4)), n) } // NameToHandleAt wraps the name_to_handle_at system call; it obtains diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux.go b/vendor/golang.org/x/sys/unix/zerrors_linux.go index 5bb51d7a..45feb124 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux.go @@ -2048,6 +2048,7 @@ const ( MINIX3_SUPER_MAGIC = 0x4d5a MINIX_SUPER_MAGIC = 0x137f MINIX_SUPER_MAGIC2 = 0x138f + MLOCK_ONFAULT = 0x1 MNT_DETACH = 0x2 MNT_EXPIRE = 0x4 MNT_FORCE = 0x1 @@ -3862,6 +3863,7 @@ const ( TIOCPKT_NOSTOP = 0x10 TIOCPKT_START = 0x8 TIOCPKT_STOP = 0x4 + TIOCSER_TEMT = 0x1 TIPC_ADDR_ID = 0x3 TIPC_ADDR_MCAST = 0x1 TIPC_ADDR_NAME = 0x2 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_386.go b/vendor/golang.org/x/sys/unix/zerrors_linux_386.go index c0a8ea1d..6f74d4a0 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_386.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_386.go @@ -483,7 +483,6 @@ const ( TIOCSERGWILD = 0x5454 TIOCSERSETMULTI = 0x545b TIOCSERSWILD = 0x5455 - TIOCSER_TEMT = 0x1 TIOCSETD = 0x5423 TIOCSIG = 0x40045436 TIOCSISO7816 = 0xc0285443 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_amd64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_amd64.go index ff927c83..36c1f0b6 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_amd64.go @@ -484,7 +484,6 @@ const ( TIOCSERGWILD = 0x5454 TIOCSERSETMULTI = 0x545b TIOCSERSWILD = 0x5455 - TIOCSER_TEMT = 0x1 TIOCSETD = 0x5423 TIOCSIG = 0x40045436 TIOCSISO7816 = 0xc0285443 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_arm.go b/vendor/golang.org/x/sys/unix/zerrors_linux_arm.go index 55294eda..c5512dbe 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_arm.go @@ -489,7 +489,6 @@ const ( TIOCSERGWILD = 0x5454 TIOCSERSETMULTI = 0x545b TIOCSERSWILD = 0x5455 - TIOCSER_TEMT = 0x1 TIOCSETD = 0x5423 TIOCSIG = 0x40045436 TIOCSISO7816 = 0xc0285443 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_arm64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_arm64.go index 5dac54c3..4dabfae0 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_arm64.go @@ -483,7 +483,6 @@ const ( TIOCSERGWILD = 0x5454 TIOCSERSETMULTI = 0x545b TIOCSERSWILD = 0x5455 - TIOCSER_TEMT = 0x1 TIOCSETD = 0x5423 TIOCSIG = 0x40045436 TIOCSISO7816 = 0xc0285443 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_loong64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_loong64.go index 46ac1fcb..ea97930f 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_loong64.go @@ -476,7 +476,6 @@ const ( TIOCSERGWILD = 0x5454 TIOCSERSETMULTI = 0x545b TIOCSERSWILD = 0x5455 - TIOCSER_TEMT = 0x1 TIOCSETD = 0x5423 TIOCSIG = 0x40045436 TIOCSISO7816 = 0xc0285443 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_mips.go b/vendor/golang.org/x/sys/unix/zerrors_linux_mips.go index b55483e8..0826a929 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_mips.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_mips.go @@ -481,7 +481,6 @@ const ( TIOCSERGWILD = 0x5489 TIOCSERSETMULTI = 0x5490 TIOCSERSWILD = 0x548a - TIOCSER_TEMT = 0x1 TIOCSETD = 0x7401 TIOCSETN = 0x740a TIOCSETP = 0x7409 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_mips64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_mips64.go index 71890c98..139deedd 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_mips64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_mips64.go @@ -481,7 +481,6 @@ const ( TIOCSERGWILD = 0x5489 TIOCSERSETMULTI = 0x5490 TIOCSERSWILD = 0x548a - TIOCSER_TEMT = 0x1 TIOCSETD = 0x7401 TIOCSETN = 0x740a TIOCSETP = 0x7409 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_mips64le.go b/vendor/golang.org/x/sys/unix/zerrors_linux_mips64le.go index a78b6cc1..b8018af3 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_mips64le.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_mips64le.go @@ -481,7 +481,6 @@ const ( TIOCSERGWILD = 0x5489 TIOCSERSETMULTI = 0x5490 TIOCSERSWILD = 0x548a - TIOCSER_TEMT = 0x1 TIOCSETD = 0x7401 TIOCSETN = 0x740a TIOCSETP = 0x7409 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_mipsle.go b/vendor/golang.org/x/sys/unix/zerrors_linux_mipsle.go index d0e38ca7..deb5c2e6 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_mipsle.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_mipsle.go @@ -481,7 +481,6 @@ const ( TIOCSERGWILD = 0x5489 TIOCSERSETMULTI = 0x5490 TIOCSERSWILD = 0x548a - TIOCSER_TEMT = 0x1 TIOCSETD = 0x7401 TIOCSETN = 0x740a TIOCSETP = 0x7409 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_ppc.go b/vendor/golang.org/x/sys/unix/zerrors_linux_ppc.go index c883e14c..0603bf3a 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_ppc.go @@ -535,7 +535,6 @@ const ( TIOCSERGWILD = 0x5454 TIOCSERSETMULTI = 0x545b TIOCSERSWILD = 0x5455 - TIOCSER_TEMT = 0x1 TIOCSETC = 0x80067411 TIOCSETD = 0x5423 TIOCSETN = 0x8006740a diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64.go index 1834273d..5fa8a5d3 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64.go @@ -539,7 +539,6 @@ const ( TIOCSERGWILD = 0x5454 TIOCSERSETMULTI = 0x545b TIOCSERSWILD = 0x5455 - TIOCSER_TEMT = 0x1 TIOCSETC = 0x80067411 TIOCSETD = 0x5423 TIOCSETN = 0x8006740a diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64le.go b/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64le.go index 39945dd9..2733e999 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64le.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64le.go @@ -539,7 +539,6 @@ const ( TIOCSERGWILD = 0x5454 TIOCSERSETMULTI = 0x545b TIOCSERSWILD = 0x5455 - TIOCSER_TEMT = 0x1 TIOCSETC = 0x80067411 TIOCSETD = 0x5423 TIOCSETN = 0x8006740a diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_riscv64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_riscv64.go index bc0f3724..1470172f 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_riscv64.go @@ -486,7 +486,6 @@ const ( TIOCSERGWILD = 0x5454 TIOCSERSETMULTI = 0x545b TIOCSERSWILD = 0x5455 - TIOCSER_TEMT = 0x1 TIOCSETD = 0x5423 TIOCSIG = 0x40045436 TIOCSISO7816 = 0xc0285443 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_s390x.go b/vendor/golang.org/x/sys/unix/zerrors_linux_s390x.go index 6e87bd65..1871579c 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_s390x.go @@ -545,7 +545,6 @@ const ( TIOCSERGWILD = 0x5454 TIOCSERSETMULTI = 0x545b TIOCSERSWILD = 0x5455 - TIOCSER_TEMT = 0x1 TIOCSETD = 0x5423 TIOCSIG = 0x40045436 TIOCSISO7816 = 0xc0285443 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_sparc64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_sparc64.go index 7e2b2e8a..10881cf6 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_sparc64.go @@ -14,21 +14,21 @@ const ( ASI_LEON_DFLUSH = 0x11 ASI_LEON_IFLUSH = 0x10 ASI_LEON_MMUFLUSH = 0x18 - B1000000 = 0x1008 + B1000000 = 0x100c B115200 = 0x1002 - B1152000 = 0x1009 - B1500000 = 0x100a - B2000000 = 0x100b + B1152000 = 0x100d + B1500000 = 0x100e + B153600 = 0x1006 + B2000000 = 0x100f B230400 = 0x1003 - B2500000 = 0x100c - B3000000 = 0x100d - B3500000 = 0x100e - B4000000 = 0x100f + B307200 = 0x1007 B460800 = 0x1004 - B500000 = 0x1005 + B500000 = 0x100a B57600 = 0x1001 - B576000 = 0x1006 - B921600 = 0x1007 + B576000 = 0x100b + B614400 = 0x1008 + B76800 = 0x1005 + B921600 = 0x1009 BLKALIGNOFF = 0x2000127a BLKBSZGET = 0x40081270 BLKBSZSET = 0x80081271 @@ -91,7 +91,7 @@ const ( FFDLY = 0x8000 FICLONE = 0x80049409 FICLONERANGE = 0x8020940d - FLUSHO = 0x1000 + FLUSHO = 0x2000 FS_IOC_ENABLE_VERITY = 0x80806685 FS_IOC_GETFLAGS = 0x40086601 FS_IOC_GET_ENCRYPTION_NONCE = 0x4010661b @@ -563,6 +563,9 @@ const ( TIOCM_CD = 0x40 TIOCM_CTS = 0x20 TIOCM_DSR = 0x100 + TIOCM_LOOP = 0x8000 + TIOCM_OUT1 = 0x2000 + TIOCM_OUT2 = 0x4000 TIOCM_RI = 0x80 TIOCM_RNG = 0x80 TIOCM_SR = 0x10 @@ -641,10 +644,11 @@ const ( UBI_IOCVOLRMBLK = 0x20004f08 UBI_IOCVOLUP = 0x80084f00 VDISCARD = 0xd + VDSUSP = 0xb VEOF = 0x4 - VEOL = 0xb - VEOL2 = 0x10 - VMIN = 0x6 + VEOL = 0x5 + VEOL2 = 0x6 + VMIN = 0x4 VREPRINT = 0xc VSTART = 0x8 VSTOP = 0x9 @@ -664,6 +668,7 @@ const ( WDIOC_KEEPALIVE = 0x40045705 WDIOC_SETOPTIONS = 0x40045704 WORDSIZE = 0x40 + WRAP = 0x20000 XCASE = 0x4 XTABS = 0x1800 _HIDIOCGRAWNAME = 0x40804804 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux.go b/vendor/golang.org/x/sys/unix/ztypes_linux.go index 526a0d5f..538a18f2 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux.go @@ -4264,19 +4264,29 @@ type HwTstampConfig struct { } const ( - HWTSTAMP_FILTER_NONE = 0x0 - HWTSTAMP_FILTER_ALL = 0x1 - HWTSTAMP_FILTER_SOME = 0x2 - HWTSTAMP_FILTER_PTP_V1_L4_EVENT = 0x3 - HWTSTAMP_FILTER_PTP_V2_L4_EVENT = 0x6 - HWTSTAMP_FILTER_PTP_V2_L2_EVENT = 0x9 - HWTSTAMP_FILTER_PTP_V2_EVENT = 0xc + HWTSTAMP_FILTER_NONE = 0x0 + HWTSTAMP_FILTER_ALL = 0x1 + HWTSTAMP_FILTER_SOME = 0x2 + HWTSTAMP_FILTER_PTP_V1_L4_EVENT = 0x3 + HWTSTAMP_FILTER_PTP_V1_L4_SYNC = 0x4 + HWTSTAMP_FILTER_PTP_V1_L4_DELAY_REQ = 0x5 + HWTSTAMP_FILTER_PTP_V2_L4_EVENT = 0x6 + HWTSTAMP_FILTER_PTP_V2_L4_SYNC = 0x7 + HWTSTAMP_FILTER_PTP_V2_L4_DELAY_REQ = 0x8 + HWTSTAMP_FILTER_PTP_V2_L2_EVENT = 0x9 + HWTSTAMP_FILTER_PTP_V2_L2_SYNC = 0xa + HWTSTAMP_FILTER_PTP_V2_L2_DELAY_REQ = 0xb + HWTSTAMP_FILTER_PTP_V2_EVENT = 0xc + HWTSTAMP_FILTER_PTP_V2_SYNC = 0xd + HWTSTAMP_FILTER_PTP_V2_DELAY_REQ = 0xe + HWTSTAMP_FILTER_NTP_ALL = 0xf ) const ( HWTSTAMP_TX_OFF = 0x0 HWTSTAMP_TX_ON = 0x1 HWTSTAMP_TX_ONESTEP_SYNC = 0x2 + HWTSTAMP_TX_ONESTEP_P2P = 0x3 ) type ( @@ -4541,6 +4551,48 @@ const ( LANDLOCK_RULE_PATH_BENEATH = 0x1 ) +const ( + PIDFD_SIGNAL_THREAD = 0x1 + PIDFD_SIGNAL_THREAD_GROUP = 0x2 + PIDFD_SIGNAL_PROCESS_GROUP = 0x4 + PIDFD_SELF_THREAD = -0x2710 + PIDFD_SELF_THREAD_GROUP = -0x2711 + PIDFD_SELF = -0x2710 + PIDFD_SELF_PROCESS = -0x2711 + PIDFD_INFO_PID = 0x1 + PIDFD_INFO_CREDS = 0x2 + PIDFD_INFO_CGROUPID = 0x4 + PIDFD_INFO_EXIT = 0x8 + PIDFD_INFO_COREDUMP = 0x10 + PIDFD_COREDUMPED = 0x1 + PIDFD_COREDUMP_SKIP = 0x2 + PIDFD_COREDUMP_USER = 0x4 + PIDFD_COREDUMP_ROOT = 0x8 + PIDFD_INFO_SIZE_VER0 = 0x40 + PIDFD_GET_INFO = 0xc048ff0b +) + +const SizeofPidfdInfo = 0x48 + +type PidfdInfo struct { + Mask uint64 + Cgroupid uint64 + Pid uint32 + Tgid uint32 + Ppid uint32 + Ruid uint32 + Rgid uint32 + Euid uint32 + Egid uint32 + Suid uint32 + Sgid uint32 + Fsuid uint32 + Fsgid uint32 + Exit_code int32 + Coredump_mask uint32 + _ uint32 +} + const ( IPC_CREAT = 0x200 IPC_EXCL = 0x400 @@ -6473,3 +6525,23 @@ type GPIOV2LineEvent struct { Line_seqno uint32 _ [6]uint32 } + +const ( + IPMI_MAX_ADDR_SIZE = 0x20 + IPMI_SYSTEM_INTERFACE_ADDR_TYPE = 0xc + IPMI_BMC_CHANNEL = 0xf + IPMI_RESPONSE_RECV_TYPE = 0x1 +) + +type IPMISystemInterfaceAddr struct { + Type int32 + Channel int16 + Lun uint8 + _ [1]byte +} +type IPMIMsg struct { + Netfn uint8 + Cmd uint8 + Len uint16 + Data *uint8 +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_386.go b/vendor/golang.org/x/sys/unix/ztypes_linux_386.go index aede1de7..38658a34 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_386.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_386.go @@ -680,7 +680,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x800 + PIDFD_NONBLOCK = 0x800 + PIDFD_THREAD = 0x80 + PIDFD_GET_CGROUP_NAMESPACE = 0xff01 + PIDFD_GET_IPC_NAMESPACE = 0xff02 + PIDFD_GET_MNT_NAMESPACE = 0xff03 + PIDFD_GET_NET_NAMESPACE = 0xff04 + PIDFD_GET_PID_NAMESPACE = 0xff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0xff06 + PIDFD_GET_TIME_NAMESPACE = 0xff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0xff08 + PIDFD_GET_USER_NAMESPACE = 0xff09 + PIDFD_GET_UTS_NAMESPACE = 0xff0a ) type SysvIpcPerm struct { @@ -715,3 +726,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x8014690d + IPMICTL_RECEIVE_MSG = 0xc018690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int32 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int32 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go index bb3bc4dc..267ffe05 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go @@ -697,7 +697,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x800 + PIDFD_NONBLOCK = 0x800 + PIDFD_THREAD = 0x80 + PIDFD_GET_CGROUP_NAMESPACE = 0xff01 + PIDFD_GET_IPC_NAMESPACE = 0xff02 + PIDFD_GET_MNT_NAMESPACE = 0xff03 + PIDFD_GET_NET_NAMESPACE = 0xff04 + PIDFD_GET_PID_NAMESPACE = 0xff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0xff06 + PIDFD_GET_TIME_NAMESPACE = 0xff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0xff08 + PIDFD_GET_USER_NAMESPACE = 0xff09 + PIDFD_GET_UTS_NAMESPACE = 0xff0a ) type SysvIpcPerm struct { @@ -729,3 +740,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x8028690d + IPMICTL_RECEIVE_MSG = 0xc030690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int64 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int64 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go b/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go index 1fdf4c51..ec459d5f 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go @@ -674,7 +674,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x800 + PIDFD_NONBLOCK = 0x800 + PIDFD_THREAD = 0x80 + PIDFD_GET_CGROUP_NAMESPACE = 0xff01 + PIDFD_GET_IPC_NAMESPACE = 0xff02 + PIDFD_GET_MNT_NAMESPACE = 0xff03 + PIDFD_GET_NET_NAMESPACE = 0xff04 + PIDFD_GET_PID_NAMESPACE = 0xff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0xff06 + PIDFD_GET_TIME_NAMESPACE = 0xff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0xff08 + PIDFD_GET_USER_NAMESPACE = 0xff09 + PIDFD_GET_UTS_NAMESPACE = 0xff0a ) type SysvIpcPerm struct { @@ -709,3 +720,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x8014690d + IPMICTL_RECEIVE_MSG = 0xc018690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int32 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int32 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go index 063e6f0b..fa45ab7d 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go @@ -676,7 +676,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x800 + PIDFD_NONBLOCK = 0x800 + PIDFD_THREAD = 0x80 + PIDFD_GET_CGROUP_NAMESPACE = 0xff01 + PIDFD_GET_IPC_NAMESPACE = 0xff02 + PIDFD_GET_MNT_NAMESPACE = 0xff03 + PIDFD_GET_NET_NAMESPACE = 0xff04 + PIDFD_GET_PID_NAMESPACE = 0xff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0xff06 + PIDFD_GET_TIME_NAMESPACE = 0xff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0xff08 + PIDFD_GET_USER_NAMESPACE = 0xff09 + PIDFD_GET_UTS_NAMESPACE = 0xff0a ) type SysvIpcPerm struct { @@ -708,3 +719,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x8028690d + IPMICTL_RECEIVE_MSG = 0xc030690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int64 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int64 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go index 9cf836c7..3da72ccd 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go @@ -677,7 +677,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x800 + PIDFD_NONBLOCK = 0x800 + PIDFD_THREAD = 0x80 + PIDFD_GET_CGROUP_NAMESPACE = 0xff01 + PIDFD_GET_IPC_NAMESPACE = 0xff02 + PIDFD_GET_MNT_NAMESPACE = 0xff03 + PIDFD_GET_NET_NAMESPACE = 0xff04 + PIDFD_GET_PID_NAMESPACE = 0xff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0xff06 + PIDFD_GET_TIME_NAMESPACE = 0xff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0xff08 + PIDFD_GET_USER_NAMESPACE = 0xff09 + PIDFD_GET_UTS_NAMESPACE = 0xff0a ) type SysvIpcPerm struct { @@ -709,3 +720,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x8028690d + IPMICTL_RECEIVE_MSG = 0xc030690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int64 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int64 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go index 1d222fcb..78f8c6d5 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go @@ -680,7 +680,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x80 + PIDFD_NONBLOCK = 0x80 + PIDFD_THREAD = 0x400 + PIDFD_GET_CGROUP_NAMESPACE = 0x2000ff01 + PIDFD_GET_IPC_NAMESPACE = 0x2000ff02 + PIDFD_GET_MNT_NAMESPACE = 0x2000ff03 + PIDFD_GET_NET_NAMESPACE = 0x2000ff04 + PIDFD_GET_PID_NAMESPACE = 0x2000ff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0x2000ff06 + PIDFD_GET_TIME_NAMESPACE = 0x2000ff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0x2000ff08 + PIDFD_GET_USER_NAMESPACE = 0x2000ff09 + PIDFD_GET_UTS_NAMESPACE = 0x2000ff0a ) type SysvIpcPerm struct { @@ -714,3 +725,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x4014690d + IPMICTL_RECEIVE_MSG = 0xc018690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int32 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int32 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go index 912cc4ab..bbd4c6c2 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go @@ -679,7 +679,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x80 + PIDFD_NONBLOCK = 0x80 + PIDFD_THREAD = 0x400 + PIDFD_GET_CGROUP_NAMESPACE = 0x2000ff01 + PIDFD_GET_IPC_NAMESPACE = 0x2000ff02 + PIDFD_GET_MNT_NAMESPACE = 0x2000ff03 + PIDFD_GET_NET_NAMESPACE = 0x2000ff04 + PIDFD_GET_PID_NAMESPACE = 0x2000ff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0x2000ff06 + PIDFD_GET_TIME_NAMESPACE = 0x2000ff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0x2000ff08 + PIDFD_GET_USER_NAMESPACE = 0x2000ff09 + PIDFD_GET_UTS_NAMESPACE = 0x2000ff0a ) type SysvIpcPerm struct { @@ -711,3 +722,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x4028690d + IPMICTL_RECEIVE_MSG = 0xc030690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int64 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int64 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go index 1e358ef3..644d31d6 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go @@ -679,7 +679,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x80 + PIDFD_NONBLOCK = 0x80 + PIDFD_THREAD = 0x400 + PIDFD_GET_CGROUP_NAMESPACE = 0x2000ff01 + PIDFD_GET_IPC_NAMESPACE = 0x2000ff02 + PIDFD_GET_MNT_NAMESPACE = 0x2000ff03 + PIDFD_GET_NET_NAMESPACE = 0x2000ff04 + PIDFD_GET_PID_NAMESPACE = 0x2000ff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0x2000ff06 + PIDFD_GET_TIME_NAMESPACE = 0x2000ff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0x2000ff08 + PIDFD_GET_USER_NAMESPACE = 0x2000ff09 + PIDFD_GET_UTS_NAMESPACE = 0x2000ff0a ) type SysvIpcPerm struct { @@ -711,3 +722,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x4028690d + IPMICTL_RECEIVE_MSG = 0xc030690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int64 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int64 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go index df59f32f..a976f626 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go @@ -680,7 +680,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x80 + PIDFD_NONBLOCK = 0x80 + PIDFD_THREAD = 0x400 + PIDFD_GET_CGROUP_NAMESPACE = 0x2000ff01 + PIDFD_GET_IPC_NAMESPACE = 0x2000ff02 + PIDFD_GET_MNT_NAMESPACE = 0x2000ff03 + PIDFD_GET_NET_NAMESPACE = 0x2000ff04 + PIDFD_GET_PID_NAMESPACE = 0x2000ff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0x2000ff06 + PIDFD_GET_TIME_NAMESPACE = 0x2000ff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0x2000ff08 + PIDFD_GET_USER_NAMESPACE = 0x2000ff09 + PIDFD_GET_UTS_NAMESPACE = 0x2000ff0a ) type SysvIpcPerm struct { @@ -714,3 +725,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x4014690d + IPMICTL_RECEIVE_MSG = 0xc018690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int32 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int32 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go index 29355aa0..46324695 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go @@ -686,7 +686,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x800 + PIDFD_NONBLOCK = 0x800 + PIDFD_THREAD = 0x80 + PIDFD_GET_CGROUP_NAMESPACE = 0x2000ff01 + PIDFD_GET_IPC_NAMESPACE = 0x2000ff02 + PIDFD_GET_MNT_NAMESPACE = 0x2000ff03 + PIDFD_GET_NET_NAMESPACE = 0x2000ff04 + PIDFD_GET_PID_NAMESPACE = 0x2000ff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0x2000ff06 + PIDFD_GET_TIME_NAMESPACE = 0x2000ff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0x2000ff08 + PIDFD_GET_USER_NAMESPACE = 0x2000ff09 + PIDFD_GET_UTS_NAMESPACE = 0x2000ff0a ) type SysvIpcPerm struct { @@ -722,3 +733,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x4014690d + IPMICTL_RECEIVE_MSG = 0xc018690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int32 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int32 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go index c6083a15..cbc30397 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go @@ -686,7 +686,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x800 + PIDFD_NONBLOCK = 0x800 + PIDFD_THREAD = 0x80 + PIDFD_GET_CGROUP_NAMESPACE = 0x2000ff01 + PIDFD_GET_IPC_NAMESPACE = 0x2000ff02 + PIDFD_GET_MNT_NAMESPACE = 0x2000ff03 + PIDFD_GET_NET_NAMESPACE = 0x2000ff04 + PIDFD_GET_PID_NAMESPACE = 0x2000ff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0x2000ff06 + PIDFD_GET_TIME_NAMESPACE = 0x2000ff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0x2000ff08 + PIDFD_GET_USER_NAMESPACE = 0x2000ff09 + PIDFD_GET_UTS_NAMESPACE = 0x2000ff0a ) type SysvIpcPerm struct { @@ -717,3 +728,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x4028690d + IPMICTL_RECEIVE_MSG = 0xc030690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int64 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int64 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go index 6321cc76..b47f7b49 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go @@ -686,7 +686,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x800 + PIDFD_NONBLOCK = 0x800 + PIDFD_THREAD = 0x80 + PIDFD_GET_CGROUP_NAMESPACE = 0x2000ff01 + PIDFD_GET_IPC_NAMESPACE = 0x2000ff02 + PIDFD_GET_MNT_NAMESPACE = 0x2000ff03 + PIDFD_GET_NET_NAMESPACE = 0x2000ff04 + PIDFD_GET_PID_NAMESPACE = 0x2000ff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0x2000ff06 + PIDFD_GET_TIME_NAMESPACE = 0x2000ff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0x2000ff08 + PIDFD_GET_USER_NAMESPACE = 0x2000ff09 + PIDFD_GET_UTS_NAMESPACE = 0x2000ff0a ) type SysvIpcPerm struct { @@ -717,3 +728,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x4028690d + IPMICTL_RECEIVE_MSG = 0xc030690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int64 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int64 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go index b44f402f..cb16add5 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go @@ -704,7 +704,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x800 + PIDFD_NONBLOCK = 0x800 + PIDFD_THREAD = 0x80 + PIDFD_GET_CGROUP_NAMESPACE = 0xff01 + PIDFD_GET_IPC_NAMESPACE = 0xff02 + PIDFD_GET_MNT_NAMESPACE = 0xff03 + PIDFD_GET_NET_NAMESPACE = 0xff04 + PIDFD_GET_PID_NAMESPACE = 0xff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0xff06 + PIDFD_GET_TIME_NAMESPACE = 0xff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0xff08 + PIDFD_GET_USER_NAMESPACE = 0xff09 + PIDFD_GET_UTS_NAMESPACE = 0xff0a ) type SysvIpcPerm struct { @@ -796,3 +807,22 @@ const ( const ( GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x8028690d + IPMICTL_RECEIVE_MSG = 0xc030690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int64 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int64 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go b/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go index b22c795a..a57ab8fa 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go @@ -700,7 +700,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x800 + PIDFD_NONBLOCK = 0x800 + PIDFD_THREAD = 0x80 + PIDFD_GET_CGROUP_NAMESPACE = 0xff01 + PIDFD_GET_IPC_NAMESPACE = 0xff02 + PIDFD_GET_MNT_NAMESPACE = 0xff03 + PIDFD_GET_NET_NAMESPACE = 0xff04 + PIDFD_GET_PID_NAMESPACE = 0xff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0xff06 + PIDFD_GET_TIME_NAMESPACE = 0xff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0xff08 + PIDFD_GET_USER_NAMESPACE = 0xff09 + PIDFD_GET_UTS_NAMESPACE = 0xff0a ) type SysvIpcPerm struct { @@ -731,3 +742,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x8028690d + IPMICTL_RECEIVE_MSG = 0xc030690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int64 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int64 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go index 0b18075b..bf99635d 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go @@ -681,7 +681,18 @@ const ( ) const ( - PIDFD_NONBLOCK = 0x4000 + PIDFD_NONBLOCK = 0x4000 + PIDFD_THREAD = 0x800 + PIDFD_GET_CGROUP_NAMESPACE = 0x2000ff01 + PIDFD_GET_IPC_NAMESPACE = 0x2000ff02 + PIDFD_GET_MNT_NAMESPACE = 0x2000ff03 + PIDFD_GET_NET_NAMESPACE = 0x2000ff04 + PIDFD_GET_PID_NAMESPACE = 0x2000ff05 + PIDFD_GET_PID_FOR_CHILDREN_NAMESPACE = 0x2000ff06 + PIDFD_GET_TIME_NAMESPACE = 0x2000ff07 + PIDFD_GET_TIME_FOR_CHILDREN_NAMESPACE = 0x2000ff08 + PIDFD_GET_USER_NAMESPACE = 0x2000ff09 + PIDFD_GET_UTS_NAMESPACE = 0x2000ff0a ) type SysvIpcPerm struct { @@ -712,3 +723,22 @@ type SysvShmDesc struct { const ( GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 ) + +const ( + IPMICTL_SEND_COMMAND = 0x4028690d + IPMICTL_RECEIVE_MSG = 0xc030690c +) + +type IPMIReq struct { + Addr *uint8 + Len uint32 + Msgid int64 + Msg IPMIMsg +} +type IPMIRecv struct { + Recv_type int32 + Addr *uint8 + Addr_len uint32 + Msgid int64 + Msg IPMIMsg +} diff --git a/vendor/golang.org/x/sys/windows/types_windows.go b/vendor/golang.org/x/sys/windows/types_windows.go index 75a50b31..643737d4 100644 --- a/vendor/golang.org/x/sys/windows/types_windows.go +++ b/vendor/golang.org/x/sys/windows/types_windows.go @@ -1065,6 +1065,7 @@ const ( SO_BROADCAST = 32 SO_LINGER = 128 SO_RCVBUF = 0x1002 + SO_SNDTIMEO = 0x1005 SO_RCVTIMEO = 0x1006 SO_SNDBUF = 0x1001 SO_UPDATE_ACCEPT_CONTEXT = 0x700b diff --git a/vendor/golang.org/x/term/terminal.go b/vendor/golang.org/x/term/terminal.go index 6ec537cd..e8327c83 100644 --- a/vendor/golang.org/x/term/terminal.go +++ b/vendor/golang.org/x/term/terminal.go @@ -106,6 +106,9 @@ type Terminal struct { // a read. It aliases into inBuf. remainder []byte inBuf [256]byte + // readErr is an error returned by a read that also returned data. It is + // reported after all of that data has been processed. + readErr error // History records and retrieves lines of input read by [ReadLine] which // a user can retrieve and navigate using the up and down arrow keys. @@ -788,7 +791,11 @@ func (t *Terminal) ReadPassword(prompt string) (line string, err error) { return } -// ReadLine returns a line of input from the terminal. +// ReadLine returns a line of input from the terminal, excluding the +// trailing newline. It may return partial data along with an error. +// An [io.EOF] error indicates the end of the stream. For other errors, +// such as [ErrPasteIndicator] in bracketed paste mode, a subsequent +// call may return more data. func (t *Terminal) ReadLine() (line string, err error) { t.lock.Lock() defer t.lock.Unlock() @@ -863,21 +870,24 @@ func (t *Terminal) readLine() (line string, err error) { } return } + if t.readErr != nil { + err = t.readErr + t.readErr = nil + return + } // t.remainder is a slice at the beginning of t.inBuf // containing a partial key sequence readBuf := t.inBuf[len(t.remainder):] - var n int t.lock.Unlock() - n, err = t.c.Read(readBuf) + n, readErr := t.c.Read(readBuf) t.lock.Lock() - if err != nil { - return - } - t.remainder = t.inBuf[:n+len(t.remainder)] + if readErr != nil { + t.readErr = readErr + } } } diff --git a/vendor/golang.org/x/text/unicode/bidi/core.go b/vendor/golang.org/x/text/unicode/bidi/core.go index fb827323..e28ac8c8 100644 --- a/vendor/golang.org/x/text/unicode/bidi/core.go +++ b/vendor/golang.org/x/text/unicode/bidi/core.go @@ -6,7 +6,6 @@ package bidi import ( "fmt" - "log" ) // This implementation is a port based on the reference implementation found at: @@ -248,7 +247,7 @@ func (p *paragraph) determineParagraphEmbeddingLevel(start, end int) level { } else if t.in(FSI, LRI, RLI) { i = p.matchingPDI[i] // skip over to the matching PDI if i > end { - log.Panic("assert (i <= end)") + panic("assert (i <= end)") } } } @@ -729,7 +728,7 @@ loop: continue loop } } - log.Panicf("invalid bidi code %v present in assertOnly at position %d", t, s.indexes[i]) + panic(fmt.Errorf("invalid bidi code %v present in assertOnly at position %d", t, s.indexes[i])) } } diff --git a/vendor/golang.org/x/text/unicode/norm/composition.go b/vendor/golang.org/x/text/unicode/norm/composition.go index e2087bce..e024e74b 100644 --- a/vendor/golang.org/x/text/unicode/norm/composition.go +++ b/vendor/golang.org/x/text/unicode/norm/composition.go @@ -449,6 +449,16 @@ func (rb *reorderBuffer) combineHangul(s, i, k int) { // ACxx plus 11Ax to LVT rb.assignRune(s, l+v-jamoTBase) default: + // Not a Hangul composition. The segment may still + // contain regular canonical compositions, such as + // combining marks following a Hangul syllable, so + // fall back to the composition table. + if b[i].combinesBackward() { + if combined := combine(l, v); combined != 0 { + rb.assignRune(s, combined) + continue + } + } b[k] = b[i] k++ } @@ -484,25 +494,27 @@ func (rb *reorderBuffer) compose() { return } ii := b[i] + // Track the last starter unconditionally: b[i] must be blocked by + // any starter between it and s, even one that b[i] itself cannot + // combine with, and even if the runes in between never enter the + // combinesBackward branch below. + cccB := b[k-1].ccc + cccC := ii.ccc + blocked := false // b[i] blocked by starter or greater or equal CCC? + if cccB == 0 { + s = k - 1 + } else { + blocked = s != k-1 && cccB >= cccC + } // We can only use combineForward as a filter if we later // get the info for the combined character. This is more // expensive than using the filter. Using combinesBackward() // is safe. - if ii.combinesBackward() { - cccB := b[k-1].ccc - cccC := ii.ccc - blocked := false // b[i] blocked by starter or greater or equal CCC? - if cccB == 0 { - s = k - 1 - } else { - blocked = s != k-1 && cccB >= cccC - } - if !blocked { - combined := combine(rb.runeAt(s), rb.runeAt(i)) - if combined != 0 { - rb.assignRune(s, combined) - continue - } + if ii.combinesBackward() && !blocked { + combined := combine(rb.runeAt(s), rb.runeAt(i)) + if combined != 0 { + rb.assignRune(s, combined) + continue } } b[k] = b[i] diff --git a/vendor/golang.org/x/text/unicode/norm/forminfo.go b/vendor/golang.org/x/text/unicode/norm/forminfo.go index b3cf5d9b..956708ee 100644 --- a/vendor/golang.org/x/text/unicode/norm/forminfo.go +++ b/vendor/golang.org/x/text/unicode/norm/forminfo.go @@ -191,30 +191,30 @@ func (p Properties) TrailCCC() uint8 { return ccc[p.tccc] } +// Recomposition +// Each entry of recompMapPacked holds the two runes of the key and the +// composed rune of the value packed into a big-endian uint64 as three +// 21-bit fields, which is wide enough for any rune. +// Note that the recomposition map for NFC and NFKC are identical. + +const recompShift = 21 + func buildRecompMap() { - recompMap = make(map[uint32]rune, len(recompMapPacked)/8) + recompMap = make(map[uint64]rune, len(recompMapPacked)/8) var buf [8]byte for i := 0; i < len(recompMapPacked); i += 8 { copy(buf[:], recompMapPacked[i:i+8]) - key := binary.BigEndian.Uint32(buf[:4]) - val := binary.BigEndian.Uint32(buf[4:]) - recompMap[key] = rune(val) + e := binary.BigEndian.Uint64(buf[:]) + recompMap[e>>recompShift] = rune(e & (1<" + // 0x006C030C: 0x0000013E - "\x00N\x03\x01\x00\x00\x01C" + // 0x004E0301: 0x00000143 - "\x00n\x03\x01\x00\x00\x01D" + // 0x006E0301: 0x00000144 - "\x00N\x03'\x00\x00\x01E" + // 0x004E0327: 0x00000145 - "\x00n\x03'\x00\x00\x01F" + // 0x006E0327: 0x00000146 - "\x00N\x03\f\x00\x00\x01G" + // 0x004E030C: 0x00000147 - "\x00n\x03\f\x00\x00\x01H" + // 0x006E030C: 0x00000148 - "\x00O\x03\x04\x00\x00\x01L" + // 0x004F0304: 0x0000014C - "\x00o\x03\x04\x00\x00\x01M" + // 0x006F0304: 0x0000014D - "\x00O\x03\x06\x00\x00\x01N" + // 0x004F0306: 0x0000014E - "\x00o\x03\x06\x00\x00\x01O" + // 0x006F0306: 0x0000014F - "\x00O\x03\v\x00\x00\x01P" + // 0x004F030B: 0x00000150 - "\x00o\x03\v\x00\x00\x01Q" + // 0x006F030B: 0x00000151 - "\x00R\x03\x01\x00\x00\x01T" + // 0x00520301: 0x00000154 - "\x00r\x03\x01\x00\x00\x01U" + // 0x00720301: 0x00000155 - "\x00R\x03'\x00\x00\x01V" + // 0x00520327: 0x00000156 - "\x00r\x03'\x00\x00\x01W" + // 0x00720327: 0x00000157 - "\x00R\x03\f\x00\x00\x01X" + // 0x0052030C: 0x00000158 - "\x00r\x03\f\x00\x00\x01Y" + // 0x0072030C: 0x00000159 - "\x00S\x03\x01\x00\x00\x01Z" + // 0x00530301: 0x0000015A - "\x00s\x03\x01\x00\x00\x01[" + // 0x00730301: 0x0000015B - "\x00S\x03\x02\x00\x00\x01\\" + // 0x00530302: 0x0000015C - "\x00s\x03\x02\x00\x00\x01]" + // 0x00730302: 0x0000015D - "\x00S\x03'\x00\x00\x01^" + // 0x00530327: 0x0000015E - "\x00s\x03'\x00\x00\x01_" + // 0x00730327: 0x0000015F - "\x00S\x03\f\x00\x00\x01`" + // 0x0053030C: 0x00000160 - "\x00s\x03\f\x00\x00\x01a" + // 0x0073030C: 0x00000161 - "\x00T\x03'\x00\x00\x01b" + // 0x00540327: 0x00000162 - "\x00t\x03'\x00\x00\x01c" + // 0x00740327: 0x00000163 - "\x00T\x03\f\x00\x00\x01d" + // 0x0054030C: 0x00000164 - "\x00t\x03\f\x00\x00\x01e" + // 0x0074030C: 0x00000165 - "\x00U\x03\x03\x00\x00\x01h" + // 0x00550303: 0x00000168 - "\x00u\x03\x03\x00\x00\x01i" + // 0x00750303: 0x00000169 - "\x00U\x03\x04\x00\x00\x01j" + // 0x00550304: 0x0000016A - "\x00u\x03\x04\x00\x00\x01k" + // 0x00750304: 0x0000016B - "\x00U\x03\x06\x00\x00\x01l" + // 0x00550306: 0x0000016C - "\x00u\x03\x06\x00\x00\x01m" + // 0x00750306: 0x0000016D - "\x00U\x03\n\x00\x00\x01n" + // 0x0055030A: 0x0000016E - "\x00u\x03\n\x00\x00\x01o" + // 0x0075030A: 0x0000016F - "\x00U\x03\v\x00\x00\x01p" + // 0x0055030B: 0x00000170 - "\x00u\x03\v\x00\x00\x01q" + // 0x0075030B: 0x00000171 - "\x00U\x03(\x00\x00\x01r" + // 0x00550328: 0x00000172 - "\x00u\x03(\x00\x00\x01s" + // 0x00750328: 0x00000173 - "\x00W\x03\x02\x00\x00\x01t" + // 0x00570302: 0x00000174 - "\x00w\x03\x02\x00\x00\x01u" + // 0x00770302: 0x00000175 - "\x00Y\x03\x02\x00\x00\x01v" + // 0x00590302: 0x00000176 - "\x00y\x03\x02\x00\x00\x01w" + // 0x00790302: 0x00000177 - "\x00Y\x03\b\x00\x00\x01x" + // 0x00590308: 0x00000178 - "\x00Z\x03\x01\x00\x00\x01y" + // 0x005A0301: 0x00000179 - "\x00z\x03\x01\x00\x00\x01z" + // 0x007A0301: 0x0000017A - "\x00Z\x03\a\x00\x00\x01{" + // 0x005A0307: 0x0000017B - "\x00z\x03\a\x00\x00\x01|" + // 0x007A0307: 0x0000017C - "\x00Z\x03\f\x00\x00\x01}" + // 0x005A030C: 0x0000017D - "\x00z\x03\f\x00\x00\x01~" + // 0x007A030C: 0x0000017E - "\x00O\x03\x1b\x00\x00\x01\xa0" + // 0x004F031B: 0x000001A0 - "\x00o\x03\x1b\x00\x00\x01\xa1" + // 0x006F031B: 0x000001A1 - "\x00U\x03\x1b\x00\x00\x01\xaf" + // 0x0055031B: 0x000001AF - "\x00u\x03\x1b\x00\x00\x01\xb0" + // 0x0075031B: 0x000001B0 - "\x00A\x03\f\x00\x00\x01\xcd" + // 0x0041030C: 0x000001CD - "\x00a\x03\f\x00\x00\x01\xce" + // 0x0061030C: 0x000001CE - "\x00I\x03\f\x00\x00\x01\xcf" + // 0x0049030C: 0x000001CF - "\x00i\x03\f\x00\x00\x01\xd0" + // 0x0069030C: 0x000001D0 - "\x00O\x03\f\x00\x00\x01\xd1" + // 0x004F030C: 0x000001D1 - "\x00o\x03\f\x00\x00\x01\xd2" + // 0x006F030C: 0x000001D2 - "\x00U\x03\f\x00\x00\x01\xd3" + // 0x0055030C: 0x000001D3 - "\x00u\x03\f\x00\x00\x01\xd4" + // 0x0075030C: 0x000001D4 - "\x00\xdc\x03\x04\x00\x00\x01\xd5" + // 0x00DC0304: 0x000001D5 - "\x00\xfc\x03\x04\x00\x00\x01\xd6" + // 0x00FC0304: 0x000001D6 - "\x00\xdc\x03\x01\x00\x00\x01\xd7" + // 0x00DC0301: 0x000001D7 - "\x00\xfc\x03\x01\x00\x00\x01\xd8" + // 0x00FC0301: 0x000001D8 - "\x00\xdc\x03\f\x00\x00\x01\xd9" + // 0x00DC030C: 0x000001D9 - "\x00\xfc\x03\f\x00\x00\x01\xda" + // 0x00FC030C: 0x000001DA - "\x00\xdc\x03\x00\x00\x00\x01\xdb" + // 0x00DC0300: 0x000001DB - "\x00\xfc\x03\x00\x00\x00\x01\xdc" + // 0x00FC0300: 0x000001DC - "\x00\xc4\x03\x04\x00\x00\x01\xde" + // 0x00C40304: 0x000001DE - "\x00\xe4\x03\x04\x00\x00\x01\xdf" + // 0x00E40304: 0x000001DF - "\x02&\x03\x04\x00\x00\x01\xe0" + // 0x02260304: 0x000001E0 - "\x02'\x03\x04\x00\x00\x01\xe1" + // 0x02270304: 0x000001E1 - "\x00\xc6\x03\x04\x00\x00\x01\xe2" + // 0x00C60304: 0x000001E2 - "\x00\xe6\x03\x04\x00\x00\x01\xe3" + // 0x00E60304: 0x000001E3 - "\x00G\x03\f\x00\x00\x01\xe6" + // 0x0047030C: 0x000001E6 - "\x00g\x03\f\x00\x00\x01\xe7" + // 0x0067030C: 0x000001E7 - "\x00K\x03\f\x00\x00\x01\xe8" + // 0x004B030C: 0x000001E8 - "\x00k\x03\f\x00\x00\x01\xe9" + // 0x006B030C: 0x000001E9 - "\x00O\x03(\x00\x00\x01\xea" + // 0x004F0328: 0x000001EA - "\x00o\x03(\x00\x00\x01\xeb" + // 0x006F0328: 0x000001EB - "\x01\xea\x03\x04\x00\x00\x01\xec" + // 0x01EA0304: 0x000001EC - "\x01\xeb\x03\x04\x00\x00\x01\xed" + // 0x01EB0304: 0x000001ED - "\x01\xb7\x03\f\x00\x00\x01\xee" + // 0x01B7030C: 0x000001EE - "\x02\x92\x03\f\x00\x00\x01\xef" + // 0x0292030C: 0x000001EF - "\x00j\x03\f\x00\x00\x01\xf0" + // 0x006A030C: 0x000001F0 - "\x00G\x03\x01\x00\x00\x01\xf4" + // 0x00470301: 0x000001F4 - "\x00g\x03\x01\x00\x00\x01\xf5" + // 0x00670301: 0x000001F5 - "\x00N\x03\x00\x00\x00\x01\xf8" + // 0x004E0300: 0x000001F8 - "\x00n\x03\x00\x00\x00\x01\xf9" + // 0x006E0300: 0x000001F9 - "\x00\xc5\x03\x01\x00\x00\x01\xfa" + // 0x00C50301: 0x000001FA - "\x00\xe5\x03\x01\x00\x00\x01\xfb" + // 0x00E50301: 0x000001FB - "\x00\xc6\x03\x01\x00\x00\x01\xfc" + // 0x00C60301: 0x000001FC - "\x00\xe6\x03\x01\x00\x00\x01\xfd" + // 0x00E60301: 0x000001FD - "\x00\xd8\x03\x01\x00\x00\x01\xfe" + // 0x00D80301: 0x000001FE - "\x00\xf8\x03\x01\x00\x00\x01\xff" + // 0x00F80301: 0x000001FF - "\x00A\x03\x0f\x00\x00\x02\x00" + // 0x0041030F: 0x00000200 - "\x00a\x03\x0f\x00\x00\x02\x01" + // 0x0061030F: 0x00000201 - "\x00A\x03\x11\x00\x00\x02\x02" + // 0x00410311: 0x00000202 - "\x00a\x03\x11\x00\x00\x02\x03" + // 0x00610311: 0x00000203 - "\x00E\x03\x0f\x00\x00\x02\x04" + // 0x0045030F: 0x00000204 - "\x00e\x03\x0f\x00\x00\x02\x05" + // 0x0065030F: 0x00000205 - "\x00E\x03\x11\x00\x00\x02\x06" + // 0x00450311: 0x00000206 - "\x00e\x03\x11\x00\x00\x02\a" + // 0x00650311: 0x00000207 - "\x00I\x03\x0f\x00\x00\x02\b" + // 0x0049030F: 0x00000208 - "\x00i\x03\x0f\x00\x00\x02\t" + // 0x0069030F: 0x00000209 - "\x00I\x03\x11\x00\x00\x02\n" + // 0x00490311: 0x0000020A - "\x00i\x03\x11\x00\x00\x02\v" + // 0x00690311: 0x0000020B - "\x00O\x03\x0f\x00\x00\x02\f" + // 0x004F030F: 0x0000020C - "\x00o\x03\x0f\x00\x00\x02\r" + // 0x006F030F: 0x0000020D - "\x00O\x03\x11\x00\x00\x02\x0e" + // 0x004F0311: 0x0000020E - "\x00o\x03\x11\x00\x00\x02\x0f" + // 0x006F0311: 0x0000020F - "\x00R\x03\x0f\x00\x00\x02\x10" + // 0x0052030F: 0x00000210 - "\x00r\x03\x0f\x00\x00\x02\x11" + // 0x0072030F: 0x00000211 - "\x00R\x03\x11\x00\x00\x02\x12" + // 0x00520311: 0x00000212 - "\x00r\x03\x11\x00\x00\x02\x13" + // 0x00720311: 0x00000213 - "\x00U\x03\x0f\x00\x00\x02\x14" + // 0x0055030F: 0x00000214 - "\x00u\x03\x0f\x00\x00\x02\x15" + // 0x0075030F: 0x00000215 - "\x00U\x03\x11\x00\x00\x02\x16" + // 0x00550311: 0x00000216 - "\x00u\x03\x11\x00\x00\x02\x17" + // 0x00750311: 0x00000217 - "\x00S\x03&\x00\x00\x02\x18" + // 0x00530326: 0x00000218 - "\x00s\x03&\x00\x00\x02\x19" + // 0x00730326: 0x00000219 - "\x00T\x03&\x00\x00\x02\x1a" + // 0x00540326: 0x0000021A - "\x00t\x03&\x00\x00\x02\x1b" + // 0x00740326: 0x0000021B - "\x00H\x03\f\x00\x00\x02\x1e" + // 0x0048030C: 0x0000021E - "\x00h\x03\f\x00\x00\x02\x1f" + // 0x0068030C: 0x0000021F - "\x00A\x03\a\x00\x00\x02&" + // 0x00410307: 0x00000226 - "\x00a\x03\a\x00\x00\x02'" + // 0x00610307: 0x00000227 - "\x00E\x03'\x00\x00\x02(" + // 0x00450327: 0x00000228 - "\x00e\x03'\x00\x00\x02)" + // 0x00650327: 0x00000229 - "\x00\xd6\x03\x04\x00\x00\x02*" + // 0x00D60304: 0x0000022A - "\x00\xf6\x03\x04\x00\x00\x02+" + // 0x00F60304: 0x0000022B - "\x00\xd5\x03\x04\x00\x00\x02," + // 0x00D50304: 0x0000022C - "\x00\xf5\x03\x04\x00\x00\x02-" + // 0x00F50304: 0x0000022D - "\x00O\x03\a\x00\x00\x02." + // 0x004F0307: 0x0000022E - "\x00o\x03\a\x00\x00\x02/" + // 0x006F0307: 0x0000022F - "\x02.\x03\x04\x00\x00\x020" + // 0x022E0304: 0x00000230 - "\x02/\x03\x04\x00\x00\x021" + // 0x022F0304: 0x00000231 - "\x00Y\x03\x04\x00\x00\x022" + // 0x00590304: 0x00000232 - "\x00y\x03\x04\x00\x00\x023" + // 0x00790304: 0x00000233 - "\x00\xa8\x03\x01\x00\x00\x03\x85" + // 0x00A80301: 0x00000385 - "\x03\x91\x03\x01\x00\x00\x03\x86" + // 0x03910301: 0x00000386 - "\x03\x95\x03\x01\x00\x00\x03\x88" + // 0x03950301: 0x00000388 - "\x03\x97\x03\x01\x00\x00\x03\x89" + // 0x03970301: 0x00000389 - "\x03\x99\x03\x01\x00\x00\x03\x8a" + // 0x03990301: 0x0000038A - "\x03\x9f\x03\x01\x00\x00\x03\x8c" + // 0x039F0301: 0x0000038C - "\x03\xa5\x03\x01\x00\x00\x03\x8e" + // 0x03A50301: 0x0000038E - "\x03\xa9\x03\x01\x00\x00\x03\x8f" + // 0x03A90301: 0x0000038F - "\x03\xca\x03\x01\x00\x00\x03\x90" + // 0x03CA0301: 0x00000390 - "\x03\x99\x03\b\x00\x00\x03\xaa" + // 0x03990308: 0x000003AA - "\x03\xa5\x03\b\x00\x00\x03\xab" + // 0x03A50308: 0x000003AB - "\x03\xb1\x03\x01\x00\x00\x03\xac" + // 0x03B10301: 0x000003AC - "\x03\xb5\x03\x01\x00\x00\x03\xad" + // 0x03B50301: 0x000003AD - "\x03\xb7\x03\x01\x00\x00\x03\xae" + // 0x03B70301: 0x000003AE - "\x03\xb9\x03\x01\x00\x00\x03\xaf" + // 0x03B90301: 0x000003AF - "\x03\xcb\x03\x01\x00\x00\x03\xb0" + // 0x03CB0301: 0x000003B0 - "\x03\xb9\x03\b\x00\x00\x03\xca" + // 0x03B90308: 0x000003CA - "\x03\xc5\x03\b\x00\x00\x03\xcb" + // 0x03C50308: 0x000003CB - "\x03\xbf\x03\x01\x00\x00\x03\xcc" + // 0x03BF0301: 0x000003CC - "\x03\xc5\x03\x01\x00\x00\x03\xcd" + // 0x03C50301: 0x000003CD - "\x03\xc9\x03\x01\x00\x00\x03\xce" + // 0x03C90301: 0x000003CE - "\x03\xd2\x03\x01\x00\x00\x03\xd3" + // 0x03D20301: 0x000003D3 - "\x03\xd2\x03\b\x00\x00\x03\xd4" + // 0x03D20308: 0x000003D4 - "\x04\x15\x03\x00\x00\x00\x04\x00" + // 0x04150300: 0x00000400 - "\x04\x15\x03\b\x00\x00\x04\x01" + // 0x04150308: 0x00000401 - "\x04\x13\x03\x01\x00\x00\x04\x03" + // 0x04130301: 0x00000403 - "\x04\x06\x03\b\x00\x00\x04\a" + // 0x04060308: 0x00000407 - "\x04\x1a\x03\x01\x00\x00\x04\f" + // 0x041A0301: 0x0000040C - "\x04\x18\x03\x00\x00\x00\x04\r" + // 0x04180300: 0x0000040D - "\x04#\x03\x06\x00\x00\x04\x0e" + // 0x04230306: 0x0000040E - "\x04\x18\x03\x06\x00\x00\x04\x19" + // 0x04180306: 0x00000419 - "\x048\x03\x06\x00\x00\x049" + // 0x04380306: 0x00000439 - "\x045\x03\x00\x00\x00\x04P" + // 0x04350300: 0x00000450 - "\x045\x03\b\x00\x00\x04Q" + // 0x04350308: 0x00000451 - "\x043\x03\x01\x00\x00\x04S" + // 0x04330301: 0x00000453 - "\x04V\x03\b\x00\x00\x04W" + // 0x04560308: 0x00000457 - "\x04:\x03\x01\x00\x00\x04\\" + // 0x043A0301: 0x0000045C - "\x048\x03\x00\x00\x00\x04]" + // 0x04380300: 0x0000045D - "\x04C\x03\x06\x00\x00\x04^" + // 0x04430306: 0x0000045E - "\x04t\x03\x0f\x00\x00\x04v" + // 0x0474030F: 0x00000476 - "\x04u\x03\x0f\x00\x00\x04w" + // 0x0475030F: 0x00000477 - "\x04\x16\x03\x06\x00\x00\x04\xc1" + // 0x04160306: 0x000004C1 - "\x046\x03\x06\x00\x00\x04\xc2" + // 0x04360306: 0x000004C2 - "\x04\x10\x03\x06\x00\x00\x04\xd0" + // 0x04100306: 0x000004D0 - "\x040\x03\x06\x00\x00\x04\xd1" + // 0x04300306: 0x000004D1 - "\x04\x10\x03\b\x00\x00\x04\xd2" + // 0x04100308: 0x000004D2 - "\x040\x03\b\x00\x00\x04\xd3" + // 0x04300308: 0x000004D3 - "\x04\x15\x03\x06\x00\x00\x04\xd6" + // 0x04150306: 0x000004D6 - "\x045\x03\x06\x00\x00\x04\xd7" + // 0x04350306: 0x000004D7 - "\x04\xd8\x03\b\x00\x00\x04\xda" + // 0x04D80308: 0x000004DA - "\x04\xd9\x03\b\x00\x00\x04\xdb" + // 0x04D90308: 0x000004DB - "\x04\x16\x03\b\x00\x00\x04\xdc" + // 0x04160308: 0x000004DC - "\x046\x03\b\x00\x00\x04\xdd" + // 0x04360308: 0x000004DD - "\x04\x17\x03\b\x00\x00\x04\xde" + // 0x04170308: 0x000004DE - "\x047\x03\b\x00\x00\x04\xdf" + // 0x04370308: 0x000004DF - "\x04\x18\x03\x04\x00\x00\x04\xe2" + // 0x04180304: 0x000004E2 - "\x048\x03\x04\x00\x00\x04\xe3" + // 0x04380304: 0x000004E3 - "\x04\x18\x03\b\x00\x00\x04\xe4" + // 0x04180308: 0x000004E4 - "\x048\x03\b\x00\x00\x04\xe5" + // 0x04380308: 0x000004E5 - "\x04\x1e\x03\b\x00\x00\x04\xe6" + // 0x041E0308: 0x000004E6 - "\x04>\x03\b\x00\x00\x04\xe7" + // 0x043E0308: 0x000004E7 - "\x04\xe8\x03\b\x00\x00\x04\xea" + // 0x04E80308: 0x000004EA - "\x04\xe9\x03\b\x00\x00\x04\xeb" + // 0x04E90308: 0x000004EB - "\x04-\x03\b\x00\x00\x04\xec" + // 0x042D0308: 0x000004EC - "\x04M\x03\b\x00\x00\x04\xed" + // 0x044D0308: 0x000004ED - "\x04#\x03\x04\x00\x00\x04\xee" + // 0x04230304: 0x000004EE - "\x04C\x03\x04\x00\x00\x04\xef" + // 0x04430304: 0x000004EF - "\x04#\x03\b\x00\x00\x04\xf0" + // 0x04230308: 0x000004F0 - "\x04C\x03\b\x00\x00\x04\xf1" + // 0x04430308: 0x000004F1 - "\x04#\x03\v\x00\x00\x04\xf2" + // 0x0423030B: 0x000004F2 - "\x04C\x03\v\x00\x00\x04\xf3" + // 0x0443030B: 0x000004F3 - "\x04'\x03\b\x00\x00\x04\xf4" + // 0x04270308: 0x000004F4 - "\x04G\x03\b\x00\x00\x04\xf5" + // 0x04470308: 0x000004F5 - "\x04+\x03\b\x00\x00\x04\xf8" + // 0x042B0308: 0x000004F8 - "\x04K\x03\b\x00\x00\x04\xf9" + // 0x044B0308: 0x000004F9 - "\x06'\x06S\x00\x00\x06\"" + // 0x06270653: 0x00000622 - "\x06'\x06T\x00\x00\x06#" + // 0x06270654: 0x00000623 - "\x06H\x06T\x00\x00\x06$" + // 0x06480654: 0x00000624 - "\x06'\x06U\x00\x00\x06%" + // 0x06270655: 0x00000625 - "\x06J\x06T\x00\x00\x06&" + // 0x064A0654: 0x00000626 - "\x06\xd5\x06T\x00\x00\x06\xc0" + // 0x06D50654: 0x000006C0 - "\x06\xc1\x06T\x00\x00\x06\xc2" + // 0x06C10654: 0x000006C2 - "\x06\xd2\x06T\x00\x00\x06\xd3" + // 0x06D20654: 0x000006D3 - "\t(\t<\x00\x00\t)" + // 0x0928093C: 0x00000929 - "\t0\t<\x00\x00\t1" + // 0x0930093C: 0x00000931 - "\t3\t<\x00\x00\t4" + // 0x0933093C: 0x00000934 - "\t\xc7\t\xbe\x00\x00\t\xcb" + // 0x09C709BE: 0x000009CB - "\t\xc7\t\xd7\x00\x00\t\xcc" + // 0x09C709D7: 0x000009CC - "\vG\vV\x00\x00\vH" + // 0x0B470B56: 0x00000B48 - "\vG\v>\x00\x00\vK" + // 0x0B470B3E: 0x00000B4B - "\vG\vW\x00\x00\vL" + // 0x0B470B57: 0x00000B4C - "\v\x92\v\xd7\x00\x00\v\x94" + // 0x0B920BD7: 0x00000B94 - "\v\xc6\v\xbe\x00\x00\v\xca" + // 0x0BC60BBE: 0x00000BCA - "\v\xc7\v\xbe\x00\x00\v\xcb" + // 0x0BC70BBE: 0x00000BCB - "\v\xc6\v\xd7\x00\x00\v\xcc" + // 0x0BC60BD7: 0x00000BCC - "\fF\fV\x00\x00\fH" + // 0x0C460C56: 0x00000C48 - "\f\xbf\f\xd5\x00\x00\f\xc0" + // 0x0CBF0CD5: 0x00000CC0 - "\f\xc6\f\xd5\x00\x00\f\xc7" + // 0x0CC60CD5: 0x00000CC7 - "\f\xc6\f\xd6\x00\x00\f\xc8" + // 0x0CC60CD6: 0x00000CC8 - "\f\xc6\f\xc2\x00\x00\f\xca" + // 0x0CC60CC2: 0x00000CCA - "\f\xca\f\xd5\x00\x00\f\xcb" + // 0x0CCA0CD5: 0x00000CCB - "\rF\r>\x00\x00\rJ" + // 0x0D460D3E: 0x00000D4A - "\rG\r>\x00\x00\rK" + // 0x0D470D3E: 0x00000D4B - "\rF\rW\x00\x00\rL" + // 0x0D460D57: 0x00000D4C - "\r\xd9\r\xca\x00\x00\r\xda" + // 0x0DD90DCA: 0x00000DDA - "\r\xd9\r\xcf\x00\x00\r\xdc" + // 0x0DD90DCF: 0x00000DDC - "\r\xdc\r\xca\x00\x00\r\xdd" + // 0x0DDC0DCA: 0x00000DDD - "\r\xd9\r\xdf\x00\x00\r\xde" + // 0x0DD90DDF: 0x00000DDE - "\x10%\x10.\x00\x00\x10&" + // 0x1025102E: 0x00001026 - "\x1b\x05\x1b5\x00\x00\x1b\x06" + // 0x1B051B35: 0x00001B06 - "\x1b\a\x1b5\x00\x00\x1b\b" + // 0x1B071B35: 0x00001B08 - "\x1b\t\x1b5\x00\x00\x1b\n" + // 0x1B091B35: 0x00001B0A - "\x1b\v\x1b5\x00\x00\x1b\f" + // 0x1B0B1B35: 0x00001B0C - "\x1b\r\x1b5\x00\x00\x1b\x0e" + // 0x1B0D1B35: 0x00001B0E - "\x1b\x11\x1b5\x00\x00\x1b\x12" + // 0x1B111B35: 0x00001B12 - "\x1b:\x1b5\x00\x00\x1b;" + // 0x1B3A1B35: 0x00001B3B - "\x1b<\x1b5\x00\x00\x1b=" + // 0x1B3C1B35: 0x00001B3D - "\x1b>\x1b5\x00\x00\x1b@" + // 0x1B3E1B35: 0x00001B40 - "\x1b?\x1b5\x00\x00\x1bA" + // 0x1B3F1B35: 0x00001B41 - "\x1bB\x1b5\x00\x00\x1bC" + // 0x1B421B35: 0x00001B43 - "\x00A\x03%\x00\x00\x1e\x00" + // 0x00410325: 0x00001E00 - "\x00a\x03%\x00\x00\x1e\x01" + // 0x00610325: 0x00001E01 - "\x00B\x03\a\x00\x00\x1e\x02" + // 0x00420307: 0x00001E02 - "\x00b\x03\a\x00\x00\x1e\x03" + // 0x00620307: 0x00001E03 - "\x00B\x03#\x00\x00\x1e\x04" + // 0x00420323: 0x00001E04 - "\x00b\x03#\x00\x00\x1e\x05" + // 0x00620323: 0x00001E05 - "\x00B\x031\x00\x00\x1e\x06" + // 0x00420331: 0x00001E06 - "\x00b\x031\x00\x00\x1e\a" + // 0x00620331: 0x00001E07 - "\x00\xc7\x03\x01\x00\x00\x1e\b" + // 0x00C70301: 0x00001E08 - "\x00\xe7\x03\x01\x00\x00\x1e\t" + // 0x00E70301: 0x00001E09 - "\x00D\x03\a\x00\x00\x1e\n" + // 0x00440307: 0x00001E0A - "\x00d\x03\a\x00\x00\x1e\v" + // 0x00640307: 0x00001E0B - "\x00D\x03#\x00\x00\x1e\f" + // 0x00440323: 0x00001E0C - "\x00d\x03#\x00\x00\x1e\r" + // 0x00640323: 0x00001E0D - "\x00D\x031\x00\x00\x1e\x0e" + // 0x00440331: 0x00001E0E - "\x00d\x031\x00\x00\x1e\x0f" + // 0x00640331: 0x00001E0F - "\x00D\x03'\x00\x00\x1e\x10" + // 0x00440327: 0x00001E10 - "\x00d\x03'\x00\x00\x1e\x11" + // 0x00640327: 0x00001E11 - "\x00D\x03-\x00\x00\x1e\x12" + // 0x0044032D: 0x00001E12 - "\x00d\x03-\x00\x00\x1e\x13" + // 0x0064032D: 0x00001E13 - "\x01\x12\x03\x00\x00\x00\x1e\x14" + // 0x01120300: 0x00001E14 - "\x01\x13\x03\x00\x00\x00\x1e\x15" + // 0x01130300: 0x00001E15 - "\x01\x12\x03\x01\x00\x00\x1e\x16" + // 0x01120301: 0x00001E16 - "\x01\x13\x03\x01\x00\x00\x1e\x17" + // 0x01130301: 0x00001E17 - "\x00E\x03-\x00\x00\x1e\x18" + // 0x0045032D: 0x00001E18 - "\x00e\x03-\x00\x00\x1e\x19" + // 0x0065032D: 0x00001E19 - "\x00E\x030\x00\x00\x1e\x1a" + // 0x00450330: 0x00001E1A - "\x00e\x030\x00\x00\x1e\x1b" + // 0x00650330: 0x00001E1B - "\x02(\x03\x06\x00\x00\x1e\x1c" + // 0x02280306: 0x00001E1C - "\x02)\x03\x06\x00\x00\x1e\x1d" + // 0x02290306: 0x00001E1D - "\x00F\x03\a\x00\x00\x1e\x1e" + // 0x00460307: 0x00001E1E - "\x00f\x03\a\x00\x00\x1e\x1f" + // 0x00660307: 0x00001E1F - "\x00G\x03\x04\x00\x00\x1e " + // 0x00470304: 0x00001E20 - "\x00g\x03\x04\x00\x00\x1e!" + // 0x00670304: 0x00001E21 - "\x00H\x03\a\x00\x00\x1e\"" + // 0x00480307: 0x00001E22 - "\x00h\x03\a\x00\x00\x1e#" + // 0x00680307: 0x00001E23 - "\x00H\x03#\x00\x00\x1e$" + // 0x00480323: 0x00001E24 - "\x00h\x03#\x00\x00\x1e%" + // 0x00680323: 0x00001E25 - "\x00H\x03\b\x00\x00\x1e&" + // 0x00480308: 0x00001E26 - "\x00h\x03\b\x00\x00\x1e'" + // 0x00680308: 0x00001E27 - "\x00H\x03'\x00\x00\x1e(" + // 0x00480327: 0x00001E28 - "\x00h\x03'\x00\x00\x1e)" + // 0x00680327: 0x00001E29 - "\x00H\x03.\x00\x00\x1e*" + // 0x0048032E: 0x00001E2A - "\x00h\x03.\x00\x00\x1e+" + // 0x0068032E: 0x00001E2B - "\x00I\x030\x00\x00\x1e," + // 0x00490330: 0x00001E2C - "\x00i\x030\x00\x00\x1e-" + // 0x00690330: 0x00001E2D - "\x00\xcf\x03\x01\x00\x00\x1e." + // 0x00CF0301: 0x00001E2E - "\x00\xef\x03\x01\x00\x00\x1e/" + // 0x00EF0301: 0x00001E2F - "\x00K\x03\x01\x00\x00\x1e0" + // 0x004B0301: 0x00001E30 - "\x00k\x03\x01\x00\x00\x1e1" + // 0x006B0301: 0x00001E31 - "\x00K\x03#\x00\x00\x1e2" + // 0x004B0323: 0x00001E32 - "\x00k\x03#\x00\x00\x1e3" + // 0x006B0323: 0x00001E33 - "\x00K\x031\x00\x00\x1e4" + // 0x004B0331: 0x00001E34 - "\x00k\x031\x00\x00\x1e5" + // 0x006B0331: 0x00001E35 - "\x00L\x03#\x00\x00\x1e6" + // 0x004C0323: 0x00001E36 - "\x00l\x03#\x00\x00\x1e7" + // 0x006C0323: 0x00001E37 - "\x1e6\x03\x04\x00\x00\x1e8" + // 0x1E360304: 0x00001E38 - "\x1e7\x03\x04\x00\x00\x1e9" + // 0x1E370304: 0x00001E39 - "\x00L\x031\x00\x00\x1e:" + // 0x004C0331: 0x00001E3A - "\x00l\x031\x00\x00\x1e;" + // 0x006C0331: 0x00001E3B - "\x00L\x03-\x00\x00\x1e<" + // 0x004C032D: 0x00001E3C - "\x00l\x03-\x00\x00\x1e=" + // 0x006C032D: 0x00001E3D - "\x00M\x03\x01\x00\x00\x1e>" + // 0x004D0301: 0x00001E3E - "\x00m\x03\x01\x00\x00\x1e?" + // 0x006D0301: 0x00001E3F - "\x00M\x03\a\x00\x00\x1e@" + // 0x004D0307: 0x00001E40 - "\x00m\x03\a\x00\x00\x1eA" + // 0x006D0307: 0x00001E41 - "\x00M\x03#\x00\x00\x1eB" + // 0x004D0323: 0x00001E42 - "\x00m\x03#\x00\x00\x1eC" + // 0x006D0323: 0x00001E43 - "\x00N\x03\a\x00\x00\x1eD" + // 0x004E0307: 0x00001E44 - "\x00n\x03\a\x00\x00\x1eE" + // 0x006E0307: 0x00001E45 - "\x00N\x03#\x00\x00\x1eF" + // 0x004E0323: 0x00001E46 - "\x00n\x03#\x00\x00\x1eG" + // 0x006E0323: 0x00001E47 - "\x00N\x031\x00\x00\x1eH" + // 0x004E0331: 0x00001E48 - "\x00n\x031\x00\x00\x1eI" + // 0x006E0331: 0x00001E49 - "\x00N\x03-\x00\x00\x1eJ" + // 0x004E032D: 0x00001E4A - "\x00n\x03-\x00\x00\x1eK" + // 0x006E032D: 0x00001E4B - "\x00\xd5\x03\x01\x00\x00\x1eL" + // 0x00D50301: 0x00001E4C - "\x00\xf5\x03\x01\x00\x00\x1eM" + // 0x00F50301: 0x00001E4D - "\x00\xd5\x03\b\x00\x00\x1eN" + // 0x00D50308: 0x00001E4E - "\x00\xf5\x03\b\x00\x00\x1eO" + // 0x00F50308: 0x00001E4F - "\x01L\x03\x00\x00\x00\x1eP" + // 0x014C0300: 0x00001E50 - "\x01M\x03\x00\x00\x00\x1eQ" + // 0x014D0300: 0x00001E51 - "\x01L\x03\x01\x00\x00\x1eR" + // 0x014C0301: 0x00001E52 - "\x01M\x03\x01\x00\x00\x1eS" + // 0x014D0301: 0x00001E53 - "\x00P\x03\x01\x00\x00\x1eT" + // 0x00500301: 0x00001E54 - "\x00p\x03\x01\x00\x00\x1eU" + // 0x00700301: 0x00001E55 - "\x00P\x03\a\x00\x00\x1eV" + // 0x00500307: 0x00001E56 - "\x00p\x03\a\x00\x00\x1eW" + // 0x00700307: 0x00001E57 - "\x00R\x03\a\x00\x00\x1eX" + // 0x00520307: 0x00001E58 - "\x00r\x03\a\x00\x00\x1eY" + // 0x00720307: 0x00001E59 - "\x00R\x03#\x00\x00\x1eZ" + // 0x00520323: 0x00001E5A - "\x00r\x03#\x00\x00\x1e[" + // 0x00720323: 0x00001E5B - "\x1eZ\x03\x04\x00\x00\x1e\\" + // 0x1E5A0304: 0x00001E5C - "\x1e[\x03\x04\x00\x00\x1e]" + // 0x1E5B0304: 0x00001E5D - "\x00R\x031\x00\x00\x1e^" + // 0x00520331: 0x00001E5E - "\x00r\x031\x00\x00\x1e_" + // 0x00720331: 0x00001E5F - "\x00S\x03\a\x00\x00\x1e`" + // 0x00530307: 0x00001E60 - "\x00s\x03\a\x00\x00\x1ea" + // 0x00730307: 0x00001E61 - "\x00S\x03#\x00\x00\x1eb" + // 0x00530323: 0x00001E62 - "\x00s\x03#\x00\x00\x1ec" + // 0x00730323: 0x00001E63 - "\x01Z\x03\a\x00\x00\x1ed" + // 0x015A0307: 0x00001E64 - "\x01[\x03\a\x00\x00\x1ee" + // 0x015B0307: 0x00001E65 - "\x01`\x03\a\x00\x00\x1ef" + // 0x01600307: 0x00001E66 - "\x01a\x03\a\x00\x00\x1eg" + // 0x01610307: 0x00001E67 - "\x1eb\x03\a\x00\x00\x1eh" + // 0x1E620307: 0x00001E68 - "\x1ec\x03\a\x00\x00\x1ei" + // 0x1E630307: 0x00001E69 - "\x00T\x03\a\x00\x00\x1ej" + // 0x00540307: 0x00001E6A - "\x00t\x03\a\x00\x00\x1ek" + // 0x00740307: 0x00001E6B - "\x00T\x03#\x00\x00\x1el" + // 0x00540323: 0x00001E6C - "\x00t\x03#\x00\x00\x1em" + // 0x00740323: 0x00001E6D - "\x00T\x031\x00\x00\x1en" + // 0x00540331: 0x00001E6E - "\x00t\x031\x00\x00\x1eo" + // 0x00740331: 0x00001E6F - "\x00T\x03-\x00\x00\x1ep" + // 0x0054032D: 0x00001E70 - "\x00t\x03-\x00\x00\x1eq" + // 0x0074032D: 0x00001E71 - "\x00U\x03$\x00\x00\x1er" + // 0x00550324: 0x00001E72 - "\x00u\x03$\x00\x00\x1es" + // 0x00750324: 0x00001E73 - "\x00U\x030\x00\x00\x1et" + // 0x00550330: 0x00001E74 - "\x00u\x030\x00\x00\x1eu" + // 0x00750330: 0x00001E75 - "\x00U\x03-\x00\x00\x1ev" + // 0x0055032D: 0x00001E76 - "\x00u\x03-\x00\x00\x1ew" + // 0x0075032D: 0x00001E77 - "\x01h\x03\x01\x00\x00\x1ex" + // 0x01680301: 0x00001E78 - "\x01i\x03\x01\x00\x00\x1ey" + // 0x01690301: 0x00001E79 - "\x01j\x03\b\x00\x00\x1ez" + // 0x016A0308: 0x00001E7A - "\x01k\x03\b\x00\x00\x1e{" + // 0x016B0308: 0x00001E7B - "\x00V\x03\x03\x00\x00\x1e|" + // 0x00560303: 0x00001E7C - "\x00v\x03\x03\x00\x00\x1e}" + // 0x00760303: 0x00001E7D - "\x00V\x03#\x00\x00\x1e~" + // 0x00560323: 0x00001E7E - "\x00v\x03#\x00\x00\x1e\x7f" + // 0x00760323: 0x00001E7F - "\x00W\x03\x00\x00\x00\x1e\x80" + // 0x00570300: 0x00001E80 - "\x00w\x03\x00\x00\x00\x1e\x81" + // 0x00770300: 0x00001E81 - "\x00W\x03\x01\x00\x00\x1e\x82" + // 0x00570301: 0x00001E82 - "\x00w\x03\x01\x00\x00\x1e\x83" + // 0x00770301: 0x00001E83 - "\x00W\x03\b\x00\x00\x1e\x84" + // 0x00570308: 0x00001E84 - "\x00w\x03\b\x00\x00\x1e\x85" + // 0x00770308: 0x00001E85 - "\x00W\x03\a\x00\x00\x1e\x86" + // 0x00570307: 0x00001E86 - "\x00w\x03\a\x00\x00\x1e\x87" + // 0x00770307: 0x00001E87 - "\x00W\x03#\x00\x00\x1e\x88" + // 0x00570323: 0x00001E88 - "\x00w\x03#\x00\x00\x1e\x89" + // 0x00770323: 0x00001E89 - "\x00X\x03\a\x00\x00\x1e\x8a" + // 0x00580307: 0x00001E8A - "\x00x\x03\a\x00\x00\x1e\x8b" + // 0x00780307: 0x00001E8B - "\x00X\x03\b\x00\x00\x1e\x8c" + // 0x00580308: 0x00001E8C - "\x00x\x03\b\x00\x00\x1e\x8d" + // 0x00780308: 0x00001E8D - "\x00Y\x03\a\x00\x00\x1e\x8e" + // 0x00590307: 0x00001E8E - "\x00y\x03\a\x00\x00\x1e\x8f" + // 0x00790307: 0x00001E8F - "\x00Z\x03\x02\x00\x00\x1e\x90" + // 0x005A0302: 0x00001E90 - "\x00z\x03\x02\x00\x00\x1e\x91" + // 0x007A0302: 0x00001E91 - "\x00Z\x03#\x00\x00\x1e\x92" + // 0x005A0323: 0x00001E92 - "\x00z\x03#\x00\x00\x1e\x93" + // 0x007A0323: 0x00001E93 - "\x00Z\x031\x00\x00\x1e\x94" + // 0x005A0331: 0x00001E94 - "\x00z\x031\x00\x00\x1e\x95" + // 0x007A0331: 0x00001E95 - "\x00h\x031\x00\x00\x1e\x96" + // 0x00680331: 0x00001E96 - "\x00t\x03\b\x00\x00\x1e\x97" + // 0x00740308: 0x00001E97 - "\x00w\x03\n\x00\x00\x1e\x98" + // 0x0077030A: 0x00001E98 - "\x00y\x03\n\x00\x00\x1e\x99" + // 0x0079030A: 0x00001E99 - "\x01\x7f\x03\a\x00\x00\x1e\x9b" + // 0x017F0307: 0x00001E9B - "\x00A\x03#\x00\x00\x1e\xa0" + // 0x00410323: 0x00001EA0 - "\x00a\x03#\x00\x00\x1e\xa1" + // 0x00610323: 0x00001EA1 - "\x00A\x03\t\x00\x00\x1e\xa2" + // 0x00410309: 0x00001EA2 - "\x00a\x03\t\x00\x00\x1e\xa3" + // 0x00610309: 0x00001EA3 - "\x00\xc2\x03\x01\x00\x00\x1e\xa4" + // 0x00C20301: 0x00001EA4 - "\x00\xe2\x03\x01\x00\x00\x1e\xa5" + // 0x00E20301: 0x00001EA5 - "\x00\xc2\x03\x00\x00\x00\x1e\xa6" + // 0x00C20300: 0x00001EA6 - "\x00\xe2\x03\x00\x00\x00\x1e\xa7" + // 0x00E20300: 0x00001EA7 - "\x00\xc2\x03\t\x00\x00\x1e\xa8" + // 0x00C20309: 0x00001EA8 - "\x00\xe2\x03\t\x00\x00\x1e\xa9" + // 0x00E20309: 0x00001EA9 - "\x00\xc2\x03\x03\x00\x00\x1e\xaa" + // 0x00C20303: 0x00001EAA - "\x00\xe2\x03\x03\x00\x00\x1e\xab" + // 0x00E20303: 0x00001EAB - "\x1e\xa0\x03\x02\x00\x00\x1e\xac" + // 0x1EA00302: 0x00001EAC - "\x1e\xa1\x03\x02\x00\x00\x1e\xad" + // 0x1EA10302: 0x00001EAD - "\x01\x02\x03\x01\x00\x00\x1e\xae" + // 0x01020301: 0x00001EAE - "\x01\x03\x03\x01\x00\x00\x1e\xaf" + // 0x01030301: 0x00001EAF - "\x01\x02\x03\x00\x00\x00\x1e\xb0" + // 0x01020300: 0x00001EB0 - "\x01\x03\x03\x00\x00\x00\x1e\xb1" + // 0x01030300: 0x00001EB1 - "\x01\x02\x03\t\x00\x00\x1e\xb2" + // 0x01020309: 0x00001EB2 - "\x01\x03\x03\t\x00\x00\x1e\xb3" + // 0x01030309: 0x00001EB3 - "\x01\x02\x03\x03\x00\x00\x1e\xb4" + // 0x01020303: 0x00001EB4 - "\x01\x03\x03\x03\x00\x00\x1e\xb5" + // 0x01030303: 0x00001EB5 - "\x1e\xa0\x03\x06\x00\x00\x1e\xb6" + // 0x1EA00306: 0x00001EB6 - "\x1e\xa1\x03\x06\x00\x00\x1e\xb7" + // 0x1EA10306: 0x00001EB7 - "\x00E\x03#\x00\x00\x1e\xb8" + // 0x00450323: 0x00001EB8 - "\x00e\x03#\x00\x00\x1e\xb9" + // 0x00650323: 0x00001EB9 - "\x00E\x03\t\x00\x00\x1e\xba" + // 0x00450309: 0x00001EBA - "\x00e\x03\t\x00\x00\x1e\xbb" + // 0x00650309: 0x00001EBB - "\x00E\x03\x03\x00\x00\x1e\xbc" + // 0x00450303: 0x00001EBC - "\x00e\x03\x03\x00\x00\x1e\xbd" + // 0x00650303: 0x00001EBD - "\x00\xca\x03\x01\x00\x00\x1e\xbe" + // 0x00CA0301: 0x00001EBE - "\x00\xea\x03\x01\x00\x00\x1e\xbf" + // 0x00EA0301: 0x00001EBF - "\x00\xca\x03\x00\x00\x00\x1e\xc0" + // 0x00CA0300: 0x00001EC0 - "\x00\xea\x03\x00\x00\x00\x1e\xc1" + // 0x00EA0300: 0x00001EC1 - "\x00\xca\x03\t\x00\x00\x1e\xc2" + // 0x00CA0309: 0x00001EC2 - "\x00\xea\x03\t\x00\x00\x1e\xc3" + // 0x00EA0309: 0x00001EC3 - "\x00\xca\x03\x03\x00\x00\x1e\xc4" + // 0x00CA0303: 0x00001EC4 - "\x00\xea\x03\x03\x00\x00\x1e\xc5" + // 0x00EA0303: 0x00001EC5 - "\x1e\xb8\x03\x02\x00\x00\x1e\xc6" + // 0x1EB80302: 0x00001EC6 - "\x1e\xb9\x03\x02\x00\x00\x1e\xc7" + // 0x1EB90302: 0x00001EC7 - "\x00I\x03\t\x00\x00\x1e\xc8" + // 0x00490309: 0x00001EC8 - "\x00i\x03\t\x00\x00\x1e\xc9" + // 0x00690309: 0x00001EC9 - "\x00I\x03#\x00\x00\x1e\xca" + // 0x00490323: 0x00001ECA - "\x00i\x03#\x00\x00\x1e\xcb" + // 0x00690323: 0x00001ECB - "\x00O\x03#\x00\x00\x1e\xcc" + // 0x004F0323: 0x00001ECC - "\x00o\x03#\x00\x00\x1e\xcd" + // 0x006F0323: 0x00001ECD - "\x00O\x03\t\x00\x00\x1e\xce" + // 0x004F0309: 0x00001ECE - "\x00o\x03\t\x00\x00\x1e\xcf" + // 0x006F0309: 0x00001ECF - "\x00\xd4\x03\x01\x00\x00\x1e\xd0" + // 0x00D40301: 0x00001ED0 - "\x00\xf4\x03\x01\x00\x00\x1e\xd1" + // 0x00F40301: 0x00001ED1 - "\x00\xd4\x03\x00\x00\x00\x1e\xd2" + // 0x00D40300: 0x00001ED2 - "\x00\xf4\x03\x00\x00\x00\x1e\xd3" + // 0x00F40300: 0x00001ED3 - "\x00\xd4\x03\t\x00\x00\x1e\xd4" + // 0x00D40309: 0x00001ED4 - "\x00\xf4\x03\t\x00\x00\x1e\xd5" + // 0x00F40309: 0x00001ED5 - "\x00\xd4\x03\x03\x00\x00\x1e\xd6" + // 0x00D40303: 0x00001ED6 - "\x00\xf4\x03\x03\x00\x00\x1e\xd7" + // 0x00F40303: 0x00001ED7 - "\x1e\xcc\x03\x02\x00\x00\x1e\xd8" + // 0x1ECC0302: 0x00001ED8 - "\x1e\xcd\x03\x02\x00\x00\x1e\xd9" + // 0x1ECD0302: 0x00001ED9 - "\x01\xa0\x03\x01\x00\x00\x1e\xda" + // 0x01A00301: 0x00001EDA - "\x01\xa1\x03\x01\x00\x00\x1e\xdb" + // 0x01A10301: 0x00001EDB - "\x01\xa0\x03\x00\x00\x00\x1e\xdc" + // 0x01A00300: 0x00001EDC - "\x01\xa1\x03\x00\x00\x00\x1e\xdd" + // 0x01A10300: 0x00001EDD - "\x01\xa0\x03\t\x00\x00\x1e\xde" + // 0x01A00309: 0x00001EDE - "\x01\xa1\x03\t\x00\x00\x1e\xdf" + // 0x01A10309: 0x00001EDF - "\x01\xa0\x03\x03\x00\x00\x1e\xe0" + // 0x01A00303: 0x00001EE0 - "\x01\xa1\x03\x03\x00\x00\x1e\xe1" + // 0x01A10303: 0x00001EE1 - "\x01\xa0\x03#\x00\x00\x1e\xe2" + // 0x01A00323: 0x00001EE2 - "\x01\xa1\x03#\x00\x00\x1e\xe3" + // 0x01A10323: 0x00001EE3 - "\x00U\x03#\x00\x00\x1e\xe4" + // 0x00550323: 0x00001EE4 - "\x00u\x03#\x00\x00\x1e\xe5" + // 0x00750323: 0x00001EE5 - "\x00U\x03\t\x00\x00\x1e\xe6" + // 0x00550309: 0x00001EE6 - "\x00u\x03\t\x00\x00\x1e\xe7" + // 0x00750309: 0x00001EE7 - "\x01\xaf\x03\x01\x00\x00\x1e\xe8" + // 0x01AF0301: 0x00001EE8 - "\x01\xb0\x03\x01\x00\x00\x1e\xe9" + // 0x01B00301: 0x00001EE9 - "\x01\xaf\x03\x00\x00\x00\x1e\xea" + // 0x01AF0300: 0x00001EEA - "\x01\xb0\x03\x00\x00\x00\x1e\xeb" + // 0x01B00300: 0x00001EEB - "\x01\xaf\x03\t\x00\x00\x1e\xec" + // 0x01AF0309: 0x00001EEC - "\x01\xb0\x03\t\x00\x00\x1e\xed" + // 0x01B00309: 0x00001EED - "\x01\xaf\x03\x03\x00\x00\x1e\xee" + // 0x01AF0303: 0x00001EEE - "\x01\xb0\x03\x03\x00\x00\x1e\xef" + // 0x01B00303: 0x00001EEF - "\x01\xaf\x03#\x00\x00\x1e\xf0" + // 0x01AF0323: 0x00001EF0 - "\x01\xb0\x03#\x00\x00\x1e\xf1" + // 0x01B00323: 0x00001EF1 - "\x00Y\x03\x00\x00\x00\x1e\xf2" + // 0x00590300: 0x00001EF2 - "\x00y\x03\x00\x00\x00\x1e\xf3" + // 0x00790300: 0x00001EF3 - "\x00Y\x03#\x00\x00\x1e\xf4" + // 0x00590323: 0x00001EF4 - "\x00y\x03#\x00\x00\x1e\xf5" + // 0x00790323: 0x00001EF5 - "\x00Y\x03\t\x00\x00\x1e\xf6" + // 0x00590309: 0x00001EF6 - "\x00y\x03\t\x00\x00\x1e\xf7" + // 0x00790309: 0x00001EF7 - "\x00Y\x03\x03\x00\x00\x1e\xf8" + // 0x00590303: 0x00001EF8 - "\x00y\x03\x03\x00\x00\x1e\xf9" + // 0x00790303: 0x00001EF9 - "\x03\xb1\x03\x13\x00\x00\x1f\x00" + // 0x03B10313: 0x00001F00 - "\x03\xb1\x03\x14\x00\x00\x1f\x01" + // 0x03B10314: 0x00001F01 - "\x1f\x00\x03\x00\x00\x00\x1f\x02" + // 0x1F000300: 0x00001F02 - "\x1f\x01\x03\x00\x00\x00\x1f\x03" + // 0x1F010300: 0x00001F03 - "\x1f\x00\x03\x01\x00\x00\x1f\x04" + // 0x1F000301: 0x00001F04 - "\x1f\x01\x03\x01\x00\x00\x1f\x05" + // 0x1F010301: 0x00001F05 - "\x1f\x00\x03B\x00\x00\x1f\x06" + // 0x1F000342: 0x00001F06 - "\x1f\x01\x03B\x00\x00\x1f\a" + // 0x1F010342: 0x00001F07 - "\x03\x91\x03\x13\x00\x00\x1f\b" + // 0x03910313: 0x00001F08 - "\x03\x91\x03\x14\x00\x00\x1f\t" + // 0x03910314: 0x00001F09 - "\x1f\b\x03\x00\x00\x00\x1f\n" + // 0x1F080300: 0x00001F0A - "\x1f\t\x03\x00\x00\x00\x1f\v" + // 0x1F090300: 0x00001F0B - "\x1f\b\x03\x01\x00\x00\x1f\f" + // 0x1F080301: 0x00001F0C - "\x1f\t\x03\x01\x00\x00\x1f\r" + // 0x1F090301: 0x00001F0D - "\x1f\b\x03B\x00\x00\x1f\x0e" + // 0x1F080342: 0x00001F0E - "\x1f\t\x03B\x00\x00\x1f\x0f" + // 0x1F090342: 0x00001F0F - "\x03\xb5\x03\x13\x00\x00\x1f\x10" + // 0x03B50313: 0x00001F10 - "\x03\xb5\x03\x14\x00\x00\x1f\x11" + // 0x03B50314: 0x00001F11 - "\x1f\x10\x03\x00\x00\x00\x1f\x12" + // 0x1F100300: 0x00001F12 - "\x1f\x11\x03\x00\x00\x00\x1f\x13" + // 0x1F110300: 0x00001F13 - "\x1f\x10\x03\x01\x00\x00\x1f\x14" + // 0x1F100301: 0x00001F14 - "\x1f\x11\x03\x01\x00\x00\x1f\x15" + // 0x1F110301: 0x00001F15 - "\x03\x95\x03\x13\x00\x00\x1f\x18" + // 0x03950313: 0x00001F18 - "\x03\x95\x03\x14\x00\x00\x1f\x19" + // 0x03950314: 0x00001F19 - "\x1f\x18\x03\x00\x00\x00\x1f\x1a" + // 0x1F180300: 0x00001F1A - "\x1f\x19\x03\x00\x00\x00\x1f\x1b" + // 0x1F190300: 0x00001F1B - "\x1f\x18\x03\x01\x00\x00\x1f\x1c" + // 0x1F180301: 0x00001F1C - "\x1f\x19\x03\x01\x00\x00\x1f\x1d" + // 0x1F190301: 0x00001F1D - "\x03\xb7\x03\x13\x00\x00\x1f " + // 0x03B70313: 0x00001F20 - "\x03\xb7\x03\x14\x00\x00\x1f!" + // 0x03B70314: 0x00001F21 - "\x1f \x03\x00\x00\x00\x1f\"" + // 0x1F200300: 0x00001F22 - "\x1f!\x03\x00\x00\x00\x1f#" + // 0x1F210300: 0x00001F23 - "\x1f \x03\x01\x00\x00\x1f$" + // 0x1F200301: 0x00001F24 - "\x1f!\x03\x01\x00\x00\x1f%" + // 0x1F210301: 0x00001F25 - "\x1f \x03B\x00\x00\x1f&" + // 0x1F200342: 0x00001F26 - "\x1f!\x03B\x00\x00\x1f'" + // 0x1F210342: 0x00001F27 - "\x03\x97\x03\x13\x00\x00\x1f(" + // 0x03970313: 0x00001F28 - "\x03\x97\x03\x14\x00\x00\x1f)" + // 0x03970314: 0x00001F29 - "\x1f(\x03\x00\x00\x00\x1f*" + // 0x1F280300: 0x00001F2A - "\x1f)\x03\x00\x00\x00\x1f+" + // 0x1F290300: 0x00001F2B - "\x1f(\x03\x01\x00\x00\x1f," + // 0x1F280301: 0x00001F2C - "\x1f)\x03\x01\x00\x00\x1f-" + // 0x1F290301: 0x00001F2D - "\x1f(\x03B\x00\x00\x1f." + // 0x1F280342: 0x00001F2E - "\x1f)\x03B\x00\x00\x1f/" + // 0x1F290342: 0x00001F2F - "\x03\xb9\x03\x13\x00\x00\x1f0" + // 0x03B90313: 0x00001F30 - "\x03\xb9\x03\x14\x00\x00\x1f1" + // 0x03B90314: 0x00001F31 - "\x1f0\x03\x00\x00\x00\x1f2" + // 0x1F300300: 0x00001F32 - "\x1f1\x03\x00\x00\x00\x1f3" + // 0x1F310300: 0x00001F33 - "\x1f0\x03\x01\x00\x00\x1f4" + // 0x1F300301: 0x00001F34 - "\x1f1\x03\x01\x00\x00\x1f5" + // 0x1F310301: 0x00001F35 - "\x1f0\x03B\x00\x00\x1f6" + // 0x1F300342: 0x00001F36 - "\x1f1\x03B\x00\x00\x1f7" + // 0x1F310342: 0x00001F37 - "\x03\x99\x03\x13\x00\x00\x1f8" + // 0x03990313: 0x00001F38 - "\x03\x99\x03\x14\x00\x00\x1f9" + // 0x03990314: 0x00001F39 - "\x1f8\x03\x00\x00\x00\x1f:" + // 0x1F380300: 0x00001F3A - "\x1f9\x03\x00\x00\x00\x1f;" + // 0x1F390300: 0x00001F3B - "\x1f8\x03\x01\x00\x00\x1f<" + // 0x1F380301: 0x00001F3C - "\x1f9\x03\x01\x00\x00\x1f=" + // 0x1F390301: 0x00001F3D - "\x1f8\x03B\x00\x00\x1f>" + // 0x1F380342: 0x00001F3E - "\x1f9\x03B\x00\x00\x1f?" + // 0x1F390342: 0x00001F3F - "\x03\xbf\x03\x13\x00\x00\x1f@" + // 0x03BF0313: 0x00001F40 - "\x03\xbf\x03\x14\x00\x00\x1fA" + // 0x03BF0314: 0x00001F41 - "\x1f@\x03\x00\x00\x00\x1fB" + // 0x1F400300: 0x00001F42 - "\x1fA\x03\x00\x00\x00\x1fC" + // 0x1F410300: 0x00001F43 - "\x1f@\x03\x01\x00\x00\x1fD" + // 0x1F400301: 0x00001F44 - "\x1fA\x03\x01\x00\x00\x1fE" + // 0x1F410301: 0x00001F45 - "\x03\x9f\x03\x13\x00\x00\x1fH" + // 0x039F0313: 0x00001F48 - "\x03\x9f\x03\x14\x00\x00\x1fI" + // 0x039F0314: 0x00001F49 - "\x1fH\x03\x00\x00\x00\x1fJ" + // 0x1F480300: 0x00001F4A - "\x1fI\x03\x00\x00\x00\x1fK" + // 0x1F490300: 0x00001F4B - "\x1fH\x03\x01\x00\x00\x1fL" + // 0x1F480301: 0x00001F4C - "\x1fI\x03\x01\x00\x00\x1fM" + // 0x1F490301: 0x00001F4D - "\x03\xc5\x03\x13\x00\x00\x1fP" + // 0x03C50313: 0x00001F50 - "\x03\xc5\x03\x14\x00\x00\x1fQ" + // 0x03C50314: 0x00001F51 - "\x1fP\x03\x00\x00\x00\x1fR" + // 0x1F500300: 0x00001F52 - "\x1fQ\x03\x00\x00\x00\x1fS" + // 0x1F510300: 0x00001F53 - "\x1fP\x03\x01\x00\x00\x1fT" + // 0x1F500301: 0x00001F54 - "\x1fQ\x03\x01\x00\x00\x1fU" + // 0x1F510301: 0x00001F55 - "\x1fP\x03B\x00\x00\x1fV" + // 0x1F500342: 0x00001F56 - "\x1fQ\x03B\x00\x00\x1fW" + // 0x1F510342: 0x00001F57 - "\x03\xa5\x03\x14\x00\x00\x1fY" + // 0x03A50314: 0x00001F59 - "\x1fY\x03\x00\x00\x00\x1f[" + // 0x1F590300: 0x00001F5B - "\x1fY\x03\x01\x00\x00\x1f]" + // 0x1F590301: 0x00001F5D - "\x1fY\x03B\x00\x00\x1f_" + // 0x1F590342: 0x00001F5F - "\x03\xc9\x03\x13\x00\x00\x1f`" + // 0x03C90313: 0x00001F60 - "\x03\xc9\x03\x14\x00\x00\x1fa" + // 0x03C90314: 0x00001F61 - "\x1f`\x03\x00\x00\x00\x1fb" + // 0x1F600300: 0x00001F62 - "\x1fa\x03\x00\x00\x00\x1fc" + // 0x1F610300: 0x00001F63 - "\x1f`\x03\x01\x00\x00\x1fd" + // 0x1F600301: 0x00001F64 - "\x1fa\x03\x01\x00\x00\x1fe" + // 0x1F610301: 0x00001F65 - "\x1f`\x03B\x00\x00\x1ff" + // 0x1F600342: 0x00001F66 - "\x1fa\x03B\x00\x00\x1fg" + // 0x1F610342: 0x00001F67 - "\x03\xa9\x03\x13\x00\x00\x1fh" + // 0x03A90313: 0x00001F68 - "\x03\xa9\x03\x14\x00\x00\x1fi" + // 0x03A90314: 0x00001F69 - "\x1fh\x03\x00\x00\x00\x1fj" + // 0x1F680300: 0x00001F6A - "\x1fi\x03\x00\x00\x00\x1fk" + // 0x1F690300: 0x00001F6B - "\x1fh\x03\x01\x00\x00\x1fl" + // 0x1F680301: 0x00001F6C - "\x1fi\x03\x01\x00\x00\x1fm" + // 0x1F690301: 0x00001F6D - "\x1fh\x03B\x00\x00\x1fn" + // 0x1F680342: 0x00001F6E - "\x1fi\x03B\x00\x00\x1fo" + // 0x1F690342: 0x00001F6F - "\x03\xb1\x03\x00\x00\x00\x1fp" + // 0x03B10300: 0x00001F70 - "\x03\xb5\x03\x00\x00\x00\x1fr" + // 0x03B50300: 0x00001F72 - "\x03\xb7\x03\x00\x00\x00\x1ft" + // 0x03B70300: 0x00001F74 - "\x03\xb9\x03\x00\x00\x00\x1fv" + // 0x03B90300: 0x00001F76 - "\x03\xbf\x03\x00\x00\x00\x1fx" + // 0x03BF0300: 0x00001F78 - "\x03\xc5\x03\x00\x00\x00\x1fz" + // 0x03C50300: 0x00001F7A - "\x03\xc9\x03\x00\x00\x00\x1f|" + // 0x03C90300: 0x00001F7C - "\x1f\x00\x03E\x00\x00\x1f\x80" + // 0x1F000345: 0x00001F80 - "\x1f\x01\x03E\x00\x00\x1f\x81" + // 0x1F010345: 0x00001F81 - "\x1f\x02\x03E\x00\x00\x1f\x82" + // 0x1F020345: 0x00001F82 - "\x1f\x03\x03E\x00\x00\x1f\x83" + // 0x1F030345: 0x00001F83 - "\x1f\x04\x03E\x00\x00\x1f\x84" + // 0x1F040345: 0x00001F84 - "\x1f\x05\x03E\x00\x00\x1f\x85" + // 0x1F050345: 0x00001F85 - "\x1f\x06\x03E\x00\x00\x1f\x86" + // 0x1F060345: 0x00001F86 - "\x1f\a\x03E\x00\x00\x1f\x87" + // 0x1F070345: 0x00001F87 - "\x1f\b\x03E\x00\x00\x1f\x88" + // 0x1F080345: 0x00001F88 - "\x1f\t\x03E\x00\x00\x1f\x89" + // 0x1F090345: 0x00001F89 - "\x1f\n\x03E\x00\x00\x1f\x8a" + // 0x1F0A0345: 0x00001F8A - "\x1f\v\x03E\x00\x00\x1f\x8b" + // 0x1F0B0345: 0x00001F8B - "\x1f\f\x03E\x00\x00\x1f\x8c" + // 0x1F0C0345: 0x00001F8C - "\x1f\r\x03E\x00\x00\x1f\x8d" + // 0x1F0D0345: 0x00001F8D - "\x1f\x0e\x03E\x00\x00\x1f\x8e" + // 0x1F0E0345: 0x00001F8E - "\x1f\x0f\x03E\x00\x00\x1f\x8f" + // 0x1F0F0345: 0x00001F8F - "\x1f \x03E\x00\x00\x1f\x90" + // 0x1F200345: 0x00001F90 - "\x1f!\x03E\x00\x00\x1f\x91" + // 0x1F210345: 0x00001F91 - "\x1f\"\x03E\x00\x00\x1f\x92" + // 0x1F220345: 0x00001F92 - "\x1f#\x03E\x00\x00\x1f\x93" + // 0x1F230345: 0x00001F93 - "\x1f$\x03E\x00\x00\x1f\x94" + // 0x1F240345: 0x00001F94 - "\x1f%\x03E\x00\x00\x1f\x95" + // 0x1F250345: 0x00001F95 - "\x1f&\x03E\x00\x00\x1f\x96" + // 0x1F260345: 0x00001F96 - "\x1f'\x03E\x00\x00\x1f\x97" + // 0x1F270345: 0x00001F97 - "\x1f(\x03E\x00\x00\x1f\x98" + // 0x1F280345: 0x00001F98 - "\x1f)\x03E\x00\x00\x1f\x99" + // 0x1F290345: 0x00001F99 - "\x1f*\x03E\x00\x00\x1f\x9a" + // 0x1F2A0345: 0x00001F9A - "\x1f+\x03E\x00\x00\x1f\x9b" + // 0x1F2B0345: 0x00001F9B - "\x1f,\x03E\x00\x00\x1f\x9c" + // 0x1F2C0345: 0x00001F9C - "\x1f-\x03E\x00\x00\x1f\x9d" + // 0x1F2D0345: 0x00001F9D - "\x1f.\x03E\x00\x00\x1f\x9e" + // 0x1F2E0345: 0x00001F9E - "\x1f/\x03E\x00\x00\x1f\x9f" + // 0x1F2F0345: 0x00001F9F - "\x1f`\x03E\x00\x00\x1f\xa0" + // 0x1F600345: 0x00001FA0 - "\x1fa\x03E\x00\x00\x1f\xa1" + // 0x1F610345: 0x00001FA1 - "\x1fb\x03E\x00\x00\x1f\xa2" + // 0x1F620345: 0x00001FA2 - "\x1fc\x03E\x00\x00\x1f\xa3" + // 0x1F630345: 0x00001FA3 - "\x1fd\x03E\x00\x00\x1f\xa4" + // 0x1F640345: 0x00001FA4 - "\x1fe\x03E\x00\x00\x1f\xa5" + // 0x1F650345: 0x00001FA5 - "\x1ff\x03E\x00\x00\x1f\xa6" + // 0x1F660345: 0x00001FA6 - "\x1fg\x03E\x00\x00\x1f\xa7" + // 0x1F670345: 0x00001FA7 - "\x1fh\x03E\x00\x00\x1f\xa8" + // 0x1F680345: 0x00001FA8 - "\x1fi\x03E\x00\x00\x1f\xa9" + // 0x1F690345: 0x00001FA9 - "\x1fj\x03E\x00\x00\x1f\xaa" + // 0x1F6A0345: 0x00001FAA - "\x1fk\x03E\x00\x00\x1f\xab" + // 0x1F6B0345: 0x00001FAB - "\x1fl\x03E\x00\x00\x1f\xac" + // 0x1F6C0345: 0x00001FAC - "\x1fm\x03E\x00\x00\x1f\xad" + // 0x1F6D0345: 0x00001FAD - "\x1fn\x03E\x00\x00\x1f\xae" + // 0x1F6E0345: 0x00001FAE - "\x1fo\x03E\x00\x00\x1f\xaf" + // 0x1F6F0345: 0x00001FAF - "\x03\xb1\x03\x06\x00\x00\x1f\xb0" + // 0x03B10306: 0x00001FB0 - "\x03\xb1\x03\x04\x00\x00\x1f\xb1" + // 0x03B10304: 0x00001FB1 - "\x1fp\x03E\x00\x00\x1f\xb2" + // 0x1F700345: 0x00001FB2 - "\x03\xb1\x03E\x00\x00\x1f\xb3" + // 0x03B10345: 0x00001FB3 - "\x03\xac\x03E\x00\x00\x1f\xb4" + // 0x03AC0345: 0x00001FB4 - "\x03\xb1\x03B\x00\x00\x1f\xb6" + // 0x03B10342: 0x00001FB6 - "\x1f\xb6\x03E\x00\x00\x1f\xb7" + // 0x1FB60345: 0x00001FB7 - "\x03\x91\x03\x06\x00\x00\x1f\xb8" + // 0x03910306: 0x00001FB8 - "\x03\x91\x03\x04\x00\x00\x1f\xb9" + // 0x03910304: 0x00001FB9 - "\x03\x91\x03\x00\x00\x00\x1f\xba" + // 0x03910300: 0x00001FBA - "\x03\x91\x03E\x00\x00\x1f\xbc" + // 0x03910345: 0x00001FBC - "\x00\xa8\x03B\x00\x00\x1f\xc1" + // 0x00A80342: 0x00001FC1 - "\x1ft\x03E\x00\x00\x1f\xc2" + // 0x1F740345: 0x00001FC2 - "\x03\xb7\x03E\x00\x00\x1f\xc3" + // 0x03B70345: 0x00001FC3 - "\x03\xae\x03E\x00\x00\x1f\xc4" + // 0x03AE0345: 0x00001FC4 - "\x03\xb7\x03B\x00\x00\x1f\xc6" + // 0x03B70342: 0x00001FC6 - "\x1f\xc6\x03E\x00\x00\x1f\xc7" + // 0x1FC60345: 0x00001FC7 - "\x03\x95\x03\x00\x00\x00\x1f\xc8" + // 0x03950300: 0x00001FC8 - "\x03\x97\x03\x00\x00\x00\x1f\xca" + // 0x03970300: 0x00001FCA - "\x03\x97\x03E\x00\x00\x1f\xcc" + // 0x03970345: 0x00001FCC - "\x1f\xbf\x03\x00\x00\x00\x1f\xcd" + // 0x1FBF0300: 0x00001FCD - "\x1f\xbf\x03\x01\x00\x00\x1f\xce" + // 0x1FBF0301: 0x00001FCE - "\x1f\xbf\x03B\x00\x00\x1f\xcf" + // 0x1FBF0342: 0x00001FCF - "\x03\xb9\x03\x06\x00\x00\x1f\xd0" + // 0x03B90306: 0x00001FD0 - "\x03\xb9\x03\x04\x00\x00\x1f\xd1" + // 0x03B90304: 0x00001FD1 - "\x03\xca\x03\x00\x00\x00\x1f\xd2" + // 0x03CA0300: 0x00001FD2 - "\x03\xb9\x03B\x00\x00\x1f\xd6" + // 0x03B90342: 0x00001FD6 - "\x03\xca\x03B\x00\x00\x1f\xd7" + // 0x03CA0342: 0x00001FD7 - "\x03\x99\x03\x06\x00\x00\x1f\xd8" + // 0x03990306: 0x00001FD8 - "\x03\x99\x03\x04\x00\x00\x1f\xd9" + // 0x03990304: 0x00001FD9 - "\x03\x99\x03\x00\x00\x00\x1f\xda" + // 0x03990300: 0x00001FDA - "\x1f\xfe\x03\x00\x00\x00\x1f\xdd" + // 0x1FFE0300: 0x00001FDD - "\x1f\xfe\x03\x01\x00\x00\x1f\xde" + // 0x1FFE0301: 0x00001FDE - "\x1f\xfe\x03B\x00\x00\x1f\xdf" + // 0x1FFE0342: 0x00001FDF - "\x03\xc5\x03\x06\x00\x00\x1f\xe0" + // 0x03C50306: 0x00001FE0 - "\x03\xc5\x03\x04\x00\x00\x1f\xe1" + // 0x03C50304: 0x00001FE1 - "\x03\xcb\x03\x00\x00\x00\x1f\xe2" + // 0x03CB0300: 0x00001FE2 - "\x03\xc1\x03\x13\x00\x00\x1f\xe4" + // 0x03C10313: 0x00001FE4 - "\x03\xc1\x03\x14\x00\x00\x1f\xe5" + // 0x03C10314: 0x00001FE5 - "\x03\xc5\x03B\x00\x00\x1f\xe6" + // 0x03C50342: 0x00001FE6 - "\x03\xcb\x03B\x00\x00\x1f\xe7" + // 0x03CB0342: 0x00001FE7 - "\x03\xa5\x03\x06\x00\x00\x1f\xe8" + // 0x03A50306: 0x00001FE8 - "\x03\xa5\x03\x04\x00\x00\x1f\xe9" + // 0x03A50304: 0x00001FE9 - "\x03\xa5\x03\x00\x00\x00\x1f\xea" + // 0x03A50300: 0x00001FEA - "\x03\xa1\x03\x14\x00\x00\x1f\xec" + // 0x03A10314: 0x00001FEC - "\x00\xa8\x03\x00\x00\x00\x1f\xed" + // 0x00A80300: 0x00001FED - "\x1f|\x03E\x00\x00\x1f\xf2" + // 0x1F7C0345: 0x00001FF2 - "\x03\xc9\x03E\x00\x00\x1f\xf3" + // 0x03C90345: 0x00001FF3 - "\x03\xce\x03E\x00\x00\x1f\xf4" + // 0x03CE0345: 0x00001FF4 - "\x03\xc9\x03B\x00\x00\x1f\xf6" + // 0x03C90342: 0x00001FF6 - "\x1f\xf6\x03E\x00\x00\x1f\xf7" + // 0x1FF60345: 0x00001FF7 - "\x03\x9f\x03\x00\x00\x00\x1f\xf8" + // 0x039F0300: 0x00001FF8 - "\x03\xa9\x03\x00\x00\x00\x1f\xfa" + // 0x03A90300: 0x00001FFA - "\x03\xa9\x03E\x00\x00\x1f\xfc" + // 0x03A90345: 0x00001FFC - "!\x90\x038\x00\x00!\x9a" + // 0x21900338: 0x0000219A - "!\x92\x038\x00\x00!\x9b" + // 0x21920338: 0x0000219B - "!\x94\x038\x00\x00!\xae" + // 0x21940338: 0x000021AE - "!\xd0\x038\x00\x00!\xcd" + // 0x21D00338: 0x000021CD - "!\xd4\x038\x00\x00!\xce" + // 0x21D40338: 0x000021CE - "!\xd2\x038\x00\x00!\xcf" + // 0x21D20338: 0x000021CF - "\"\x03\x038\x00\x00\"\x04" + // 0x22030338: 0x00002204 - "\"\b\x038\x00\x00\"\t" + // 0x22080338: 0x00002209 - "\"\v\x038\x00\x00\"\f" + // 0x220B0338: 0x0000220C - "\"#\x038\x00\x00\"$" + // 0x22230338: 0x00002224 - "\"%\x038\x00\x00\"&" + // 0x22250338: 0x00002226 - "\"<\x038\x00\x00\"A" + // 0x223C0338: 0x00002241 - "\"C\x038\x00\x00\"D" + // 0x22430338: 0x00002244 - "\"E\x038\x00\x00\"G" + // 0x22450338: 0x00002247 - "\"H\x038\x00\x00\"I" + // 0x22480338: 0x00002249 - "\x00=\x038\x00\x00\"`" + // 0x003D0338: 0x00002260 - "\"a\x038\x00\x00\"b" + // 0x22610338: 0x00002262 - "\"M\x038\x00\x00\"m" + // 0x224D0338: 0x0000226D - "\x00<\x038\x00\x00\"n" + // 0x003C0338: 0x0000226E - "\x00>\x038\x00\x00\"o" + // 0x003E0338: 0x0000226F - "\"d\x038\x00\x00\"p" + // 0x22640338: 0x00002270 - "\"e\x038\x00\x00\"q" + // 0x22650338: 0x00002271 - "\"r\x038\x00\x00\"t" + // 0x22720338: 0x00002274 - "\"s\x038\x00\x00\"u" + // 0x22730338: 0x00002275 - "\"v\x038\x00\x00\"x" + // 0x22760338: 0x00002278 - "\"w\x038\x00\x00\"y" + // 0x22770338: 0x00002279 - "\"z\x038\x00\x00\"\x80" + // 0x227A0338: 0x00002280 - "\"{\x038\x00\x00\"\x81" + // 0x227B0338: 0x00002281 - "\"\x82\x038\x00\x00\"\x84" + // 0x22820338: 0x00002284 - "\"\x83\x038\x00\x00\"\x85" + // 0x22830338: 0x00002285 - "\"\x86\x038\x00\x00\"\x88" + // 0x22860338: 0x00002288 - "\"\x87\x038\x00\x00\"\x89" + // 0x22870338: 0x00002289 - "\"\xa2\x038\x00\x00\"\xac" + // 0x22A20338: 0x000022AC - "\"\xa8\x038\x00\x00\"\xad" + // 0x22A80338: 0x000022AD - "\"\xa9\x038\x00\x00\"\xae" + // 0x22A90338: 0x000022AE - "\"\xab\x038\x00\x00\"\xaf" + // 0x22AB0338: 0x000022AF - "\"|\x038\x00\x00\"\xe0" + // 0x227C0338: 0x000022E0 - "\"}\x038\x00\x00\"\xe1" + // 0x227D0338: 0x000022E1 - "\"\x91\x038\x00\x00\"\xe2" + // 0x22910338: 0x000022E2 - "\"\x92\x038\x00\x00\"\xe3" + // 0x22920338: 0x000022E3 - "\"\xb2\x038\x00\x00\"\xea" + // 0x22B20338: 0x000022EA - "\"\xb3\x038\x00\x00\"\xeb" + // 0x22B30338: 0x000022EB - "\"\xb4\x038\x00\x00\"\xec" + // 0x22B40338: 0x000022EC - "\"\xb5\x038\x00\x00\"\xed" + // 0x22B50338: 0x000022ED - "0K0\x99\x00\x000L" + // 0x304B3099: 0x0000304C - "0M0\x99\x00\x000N" + // 0x304D3099: 0x0000304E - "0O0\x99\x00\x000P" + // 0x304F3099: 0x00003050 - "0Q0\x99\x00\x000R" + // 0x30513099: 0x00003052 - "0S0\x99\x00\x000T" + // 0x30533099: 0x00003054 - "0U0\x99\x00\x000V" + // 0x30553099: 0x00003056 - "0W0\x99\x00\x000X" + // 0x30573099: 0x00003058 - "0Y0\x99\x00\x000Z" + // 0x30593099: 0x0000305A - "0[0\x99\x00\x000\\" + // 0x305B3099: 0x0000305C - "0]0\x99\x00\x000^" + // 0x305D3099: 0x0000305E - "0_0\x99\x00\x000`" + // 0x305F3099: 0x00003060 - "0a0\x99\x00\x000b" + // 0x30613099: 0x00003062 - "0d0\x99\x00\x000e" + // 0x30643099: 0x00003065 - "0f0\x99\x00\x000g" + // 0x30663099: 0x00003067 - "0h0\x99\x00\x000i" + // 0x30683099: 0x00003069 - "0o0\x99\x00\x000p" + // 0x306F3099: 0x00003070 - "0o0\x9a\x00\x000q" + // 0x306F309A: 0x00003071 - "0r0\x99\x00\x000s" + // 0x30723099: 0x00003073 - "0r0\x9a\x00\x000t" + // 0x3072309A: 0x00003074 - "0u0\x99\x00\x000v" + // 0x30753099: 0x00003076 - "0u0\x9a\x00\x000w" + // 0x3075309A: 0x00003077 - "0x0\x99\x00\x000y" + // 0x30783099: 0x00003079 - "0x0\x9a\x00\x000z" + // 0x3078309A: 0x0000307A - "0{0\x99\x00\x000|" + // 0x307B3099: 0x0000307C - "0{0\x9a\x00\x000}" + // 0x307B309A: 0x0000307D - "0F0\x99\x00\x000\x94" + // 0x30463099: 0x00003094 - "0\x9d0\x99\x00\x000\x9e" + // 0x309D3099: 0x0000309E - "0\xab0\x99\x00\x000\xac" + // 0x30AB3099: 0x000030AC - "0\xad0\x99\x00\x000\xae" + // 0x30AD3099: 0x000030AE - "0\xaf0\x99\x00\x000\xb0" + // 0x30AF3099: 0x000030B0 - "0\xb10\x99\x00\x000\xb2" + // 0x30B13099: 0x000030B2 - "0\xb30\x99\x00\x000\xb4" + // 0x30B33099: 0x000030B4 - "0\xb50\x99\x00\x000\xb6" + // 0x30B53099: 0x000030B6 - "0\xb70\x99\x00\x000\xb8" + // 0x30B73099: 0x000030B8 - "0\xb90\x99\x00\x000\xba" + // 0x30B93099: 0x000030BA - "0\xbb0\x99\x00\x000\xbc" + // 0x30BB3099: 0x000030BC - "0\xbd0\x99\x00\x000\xbe" + // 0x30BD3099: 0x000030BE - "0\xbf0\x99\x00\x000\xc0" + // 0x30BF3099: 0x000030C0 - "0\xc10\x99\x00\x000\xc2" + // 0x30C13099: 0x000030C2 - "0\xc40\x99\x00\x000\xc5" + // 0x30C43099: 0x000030C5 - "0\xc60\x99\x00\x000\xc7" + // 0x30C63099: 0x000030C7 - "0\xc80\x99\x00\x000\xc9" + // 0x30C83099: 0x000030C9 - "0\xcf0\x99\x00\x000\xd0" + // 0x30CF3099: 0x000030D0 - "0\xcf0\x9a\x00\x000\xd1" + // 0x30CF309A: 0x000030D1 - "0\xd20\x99\x00\x000\xd3" + // 0x30D23099: 0x000030D3 - "0\xd20\x9a\x00\x000\xd4" + // 0x30D2309A: 0x000030D4 - "0\xd50\x99\x00\x000\xd6" + // 0x30D53099: 0x000030D6 - "0\xd50\x9a\x00\x000\xd7" + // 0x30D5309A: 0x000030D7 - "0\xd80\x99\x00\x000\xd9" + // 0x30D83099: 0x000030D9 - "0\xd80\x9a\x00\x000\xda" + // 0x30D8309A: 0x000030DA - "0\xdb0\x99\x00\x000\xdc" + // 0x30DB3099: 0x000030DC - "0\xdb0\x9a\x00\x000\xdd" + // 0x30DB309A: 0x000030DD - "0\xa60\x99\x00\x000\xf4" + // 0x30A63099: 0x000030F4 - "0\xef0\x99\x00\x000\xf7" + // 0x30EF3099: 0x000030F7 - "0\xf00\x99\x00\x000\xf8" + // 0x30F03099: 0x000030F8 - "0\xf10\x99\x00\x000\xf9" + // 0x30F13099: 0x000030F9 - "0\xf20\x99\x00\x000\xfa" + // 0x30F23099: 0x000030FA - "0\xfd0\x99\x00\x000\xfe" + // 0x30FD3099: 0x000030FE - "\x10\x99\x10\xba\x00\x01\x10\x9a" + // 0x109910BA: 0x0001109A - "\x10\x9b\x10\xba\x00\x01\x10\x9c" + // 0x109B10BA: 0x0001109C - "\x10\xa5\x10\xba\x00\x01\x10\xab" + // 0x10A510BA: 0x000110AB - "\x111\x11'\x00\x01\x11." + // 0x11311127: 0x0001112E - "\x112\x11'\x00\x01\x11/" + // 0x11321127: 0x0001112F - "\x13G\x13>\x00\x01\x13K" + // 0x1347133E: 0x0001134B - "\x13G\x13W\x00\x01\x13L" + // 0x13471357: 0x0001134C - "\x14\xb9\x14\xba\x00\x01\x14\xbb" + // 0x14B914BA: 0x000114BB - "\x14\xb9\x14\xb0\x00\x01\x14\xbc" + // 0x14B914B0: 0x000114BC - "\x14\xb9\x14\xbd\x00\x01\x14\xbe" + // 0x14B914BD: 0x000114BE - "\x15\xb8\x15\xaf\x00\x01\x15\xba" + // 0x15B815AF: 0x000115BA - "\x15\xb9\x15\xaf\x00\x01\x15\xbb" + // 0x15B915AF: 0x000115BB - "\x195\x190\x00\x01\x198" + // 0x19351930: 0x00011938 + "\x00\x01\x04\x00`\x00\x00\xc0" + // 0x00041 0x00300: 0x000C0 + "\x00\x01\x04\x00` \x00\xc1" + // 0x00041 0x00301: 0x000C1 + "\x00\x01\x04\x00`@\x00\xc2" + // 0x00041 0x00302: 0x000C2 + "\x00\x01\x04\x00``\x00\xc3" + // 0x00041 0x00303: 0x000C3 + "\x00\x01\x04\x00a\x00\x00\xc4" + // 0x00041 0x00308: 0x000C4 + "\x00\x01\x04\x00a@\x00\xc5" + // 0x00041 0x0030A: 0x000C5 + "\x00\x01\f\x00d\xe0\x00\xc7" + // 0x00043 0x00327: 0x000C7 + "\x00\x01\x14\x00`\x00\x00\xc8" + // 0x00045 0x00300: 0x000C8 + "\x00\x01\x14\x00` \x00\xc9" + // 0x00045 0x00301: 0x000C9 + "\x00\x01\x14\x00`@\x00\xca" + // 0x00045 0x00302: 0x000CA + "\x00\x01\x14\x00a\x00\x00\xcb" + // 0x00045 0x00308: 0x000CB + "\x00\x01$\x00`\x00\x00\xcc" + // 0x00049 0x00300: 0x000CC + "\x00\x01$\x00` \x00\xcd" + // 0x00049 0x00301: 0x000CD + "\x00\x01$\x00`@\x00\xce" + // 0x00049 0x00302: 0x000CE + "\x00\x01$\x00a\x00\x00\xcf" + // 0x00049 0x00308: 0x000CF + "\x00\x018\x00``\x00\xd1" + // 0x0004E 0x00303: 0x000D1 + "\x00\x01<\x00`\x00\x00\xd2" + // 0x0004F 0x00300: 0x000D2 + "\x00\x01<\x00` \x00\xd3" + // 0x0004F 0x00301: 0x000D3 + "\x00\x01<\x00`@\x00\xd4" + // 0x0004F 0x00302: 0x000D4 + "\x00\x01<\x00``\x00\xd5" + // 0x0004F 0x00303: 0x000D5 + "\x00\x01<\x00a\x00\x00\xd6" + // 0x0004F 0x00308: 0x000D6 + "\x00\x01T\x00`\x00\x00\xd9" + // 0x00055 0x00300: 0x000D9 + "\x00\x01T\x00` \x00\xda" + // 0x00055 0x00301: 0x000DA + "\x00\x01T\x00`@\x00\xdb" + // 0x00055 0x00302: 0x000DB + "\x00\x01T\x00a\x00\x00\xdc" + // 0x00055 0x00308: 0x000DC + "\x00\x01d\x00` \x00\xdd" + // 0x00059 0x00301: 0x000DD + "\x00\x01\x84\x00`\x00\x00\xe0" + // 0x00061 0x00300: 0x000E0 + "\x00\x01\x84\x00` \x00\xe1" + // 0x00061 0x00301: 0x000E1 + "\x00\x01\x84\x00`@\x00\xe2" + // 0x00061 0x00302: 0x000E2 + "\x00\x01\x84\x00``\x00\xe3" + // 0x00061 0x00303: 0x000E3 + "\x00\x01\x84\x00a\x00\x00\xe4" + // 0x00061 0x00308: 0x000E4 + "\x00\x01\x84\x00a@\x00\xe5" + // 0x00061 0x0030A: 0x000E5 + "\x00\x01\x8c\x00d\xe0\x00\xe7" + // 0x00063 0x00327: 0x000E7 + "\x00\x01\x94\x00`\x00\x00\xe8" + // 0x00065 0x00300: 0x000E8 + "\x00\x01\x94\x00` \x00\xe9" + // 0x00065 0x00301: 0x000E9 + "\x00\x01\x94\x00`@\x00\xea" + // 0x00065 0x00302: 0x000EA + "\x00\x01\x94\x00a\x00\x00\xeb" + // 0x00065 0x00308: 0x000EB + "\x00\x01\xa4\x00`\x00\x00\xec" + // 0x00069 0x00300: 0x000EC + "\x00\x01\xa4\x00` \x00\xed" + // 0x00069 0x00301: 0x000ED + "\x00\x01\xa4\x00`@\x00\xee" + // 0x00069 0x00302: 0x000EE + "\x00\x01\xa4\x00a\x00\x00\xef" + // 0x00069 0x00308: 0x000EF + "\x00\x01\xb8\x00``\x00\xf1" + // 0x0006E 0x00303: 0x000F1 + "\x00\x01\xbc\x00`\x00\x00\xf2" + // 0x0006F 0x00300: 0x000F2 + "\x00\x01\xbc\x00` \x00\xf3" + // 0x0006F 0x00301: 0x000F3 + "\x00\x01\xbc\x00`@\x00\xf4" + // 0x0006F 0x00302: 0x000F4 + "\x00\x01\xbc\x00``\x00\xf5" + // 0x0006F 0x00303: 0x000F5 + "\x00\x01\xbc\x00a\x00\x00\xf6" + // 0x0006F 0x00308: 0x000F6 + "\x00\x01\xd4\x00`\x00\x00\xf9" + // 0x00075 0x00300: 0x000F9 + "\x00\x01\xd4\x00` \x00\xfa" + // 0x00075 0x00301: 0x000FA + "\x00\x01\xd4\x00`@\x00\xfb" + // 0x00075 0x00302: 0x000FB + "\x00\x01\xd4\x00a\x00\x00\xfc" + // 0x00075 0x00308: 0x000FC + "\x00\x01\xe4\x00` \x00\xfd" + // 0x00079 0x00301: 0x000FD + "\x00\x01\xe4\x00a\x00\x00\xff" + // 0x00079 0x00308: 0x000FF + "\x00\x01\x04\x00`\x80\x01\x00" + // 0x00041 0x00304: 0x00100 + "\x00\x01\x84\x00`\x80\x01\x01" + // 0x00061 0x00304: 0x00101 + "\x00\x01\x04\x00`\xc0\x01\x02" + // 0x00041 0x00306: 0x00102 + "\x00\x01\x84\x00`\xc0\x01\x03" + // 0x00061 0x00306: 0x00103 + "\x00\x01\x04\x00e\x00\x01\x04" + // 0x00041 0x00328: 0x00104 + "\x00\x01\x84\x00e\x00\x01\x05" + // 0x00061 0x00328: 0x00105 + "\x00\x01\f\x00` \x01\x06" + // 0x00043 0x00301: 0x00106 + "\x00\x01\x8c\x00` \x01\a" + // 0x00063 0x00301: 0x00107 + "\x00\x01\f\x00`@\x01\b" + // 0x00043 0x00302: 0x00108 + "\x00\x01\x8c\x00`@\x01\t" + // 0x00063 0x00302: 0x00109 + "\x00\x01\f\x00`\xe0\x01\n" + // 0x00043 0x00307: 0x0010A + "\x00\x01\x8c\x00`\xe0\x01\v" + // 0x00063 0x00307: 0x0010B + "\x00\x01\f\x00a\x80\x01\f" + // 0x00043 0x0030C: 0x0010C + "\x00\x01\x8c\x00a\x80\x01\r" + // 0x00063 0x0030C: 0x0010D + "\x00\x01\x10\x00a\x80\x01\x0e" + // 0x00044 0x0030C: 0x0010E + "\x00\x01\x90\x00a\x80\x01\x0f" + // 0x00064 0x0030C: 0x0010F + "\x00\x01\x14\x00`\x80\x01\x12" + // 0x00045 0x00304: 0x00112 + "\x00\x01\x94\x00`\x80\x01\x13" + // 0x00065 0x00304: 0x00113 + "\x00\x01\x14\x00`\xc0\x01\x14" + // 0x00045 0x00306: 0x00114 + "\x00\x01\x94\x00`\xc0\x01\x15" + // 0x00065 0x00306: 0x00115 + "\x00\x01\x14\x00`\xe0\x01\x16" + // 0x00045 0x00307: 0x00116 + "\x00\x01\x94\x00`\xe0\x01\x17" + // 0x00065 0x00307: 0x00117 + "\x00\x01\x14\x00e\x00\x01\x18" + // 0x00045 0x00328: 0x00118 + "\x00\x01\x94\x00e\x00\x01\x19" + // 0x00065 0x00328: 0x00119 + "\x00\x01\x14\x00a\x80\x01\x1a" + // 0x00045 0x0030C: 0x0011A + "\x00\x01\x94\x00a\x80\x01\x1b" + // 0x00065 0x0030C: 0x0011B + "\x00\x01\x1c\x00`@\x01\x1c" + // 0x00047 0x00302: 0x0011C + "\x00\x01\x9c\x00`@\x01\x1d" + // 0x00067 0x00302: 0x0011D + "\x00\x01\x1c\x00`\xc0\x01\x1e" + // 0x00047 0x00306: 0x0011E + "\x00\x01\x9c\x00`\xc0\x01\x1f" + // 0x00067 0x00306: 0x0011F + "\x00\x01\x1c\x00`\xe0\x01 " + // 0x00047 0x00307: 0x00120 + "\x00\x01\x9c\x00`\xe0\x01!" + // 0x00067 0x00307: 0x00121 + "\x00\x01\x1c\x00d\xe0\x01\"" + // 0x00047 0x00327: 0x00122 + "\x00\x01\x9c\x00d\xe0\x01#" + // 0x00067 0x00327: 0x00123 + "\x00\x01 \x00`@\x01$" + // 0x00048 0x00302: 0x00124 + "\x00\x01\xa0\x00`@\x01%" + // 0x00068 0x00302: 0x00125 + "\x00\x01$\x00``\x01(" + // 0x00049 0x00303: 0x00128 + "\x00\x01\xa4\x00``\x01)" + // 0x00069 0x00303: 0x00129 + "\x00\x01$\x00`\x80\x01*" + // 0x00049 0x00304: 0x0012A + "\x00\x01\xa4\x00`\x80\x01+" + // 0x00069 0x00304: 0x0012B + "\x00\x01$\x00`\xc0\x01," + // 0x00049 0x00306: 0x0012C + "\x00\x01\xa4\x00`\xc0\x01-" + // 0x00069 0x00306: 0x0012D + "\x00\x01$\x00e\x00\x01." + // 0x00049 0x00328: 0x0012E + "\x00\x01\xa4\x00e\x00\x01/" + // 0x00069 0x00328: 0x0012F + "\x00\x01$\x00`\xe0\x010" + // 0x00049 0x00307: 0x00130 + "\x00\x01(\x00`@\x014" + // 0x0004A 0x00302: 0x00134 + "\x00\x01\xa8\x00`@\x015" + // 0x0006A 0x00302: 0x00135 + "\x00\x01,\x00d\xe0\x016" + // 0x0004B 0x00327: 0x00136 + "\x00\x01\xac\x00d\xe0\x017" + // 0x0006B 0x00327: 0x00137 + "\x00\x010\x00` \x019" + // 0x0004C 0x00301: 0x00139 + "\x00\x01\xb0\x00` \x01:" + // 0x0006C 0x00301: 0x0013A + "\x00\x010\x00d\xe0\x01;" + // 0x0004C 0x00327: 0x0013B + "\x00\x01\xb0\x00d\xe0\x01<" + // 0x0006C 0x00327: 0x0013C + "\x00\x010\x00a\x80\x01=" + // 0x0004C 0x0030C: 0x0013D + "\x00\x01\xb0\x00a\x80\x01>" + // 0x0006C 0x0030C: 0x0013E + "\x00\x018\x00` \x01C" + // 0x0004E 0x00301: 0x00143 + "\x00\x01\xb8\x00` \x01D" + // 0x0006E 0x00301: 0x00144 + "\x00\x018\x00d\xe0\x01E" + // 0x0004E 0x00327: 0x00145 + "\x00\x01\xb8\x00d\xe0\x01F" + // 0x0006E 0x00327: 0x00146 + "\x00\x018\x00a\x80\x01G" + // 0x0004E 0x0030C: 0x00147 + "\x00\x01\xb8\x00a\x80\x01H" + // 0x0006E 0x0030C: 0x00148 + "\x00\x01<\x00`\x80\x01L" + // 0x0004F 0x00304: 0x0014C + "\x00\x01\xbc\x00`\x80\x01M" + // 0x0006F 0x00304: 0x0014D + "\x00\x01<\x00`\xc0\x01N" + // 0x0004F 0x00306: 0x0014E + "\x00\x01\xbc\x00`\xc0\x01O" + // 0x0006F 0x00306: 0x0014F + "\x00\x01<\x00a`\x01P" + // 0x0004F 0x0030B: 0x00150 + "\x00\x01\xbc\x00a`\x01Q" + // 0x0006F 0x0030B: 0x00151 + "\x00\x01H\x00` \x01T" + // 0x00052 0x00301: 0x00154 + "\x00\x01\xc8\x00` \x01U" + // 0x00072 0x00301: 0x00155 + "\x00\x01H\x00d\xe0\x01V" + // 0x00052 0x00327: 0x00156 + "\x00\x01\xc8\x00d\xe0\x01W" + // 0x00072 0x00327: 0x00157 + "\x00\x01H\x00a\x80\x01X" + // 0x00052 0x0030C: 0x00158 + "\x00\x01\xc8\x00a\x80\x01Y" + // 0x00072 0x0030C: 0x00159 + "\x00\x01L\x00` \x01Z" + // 0x00053 0x00301: 0x0015A + "\x00\x01\xcc\x00` \x01[" + // 0x00073 0x00301: 0x0015B + "\x00\x01L\x00`@\x01\\" + // 0x00053 0x00302: 0x0015C + "\x00\x01\xcc\x00`@\x01]" + // 0x00073 0x00302: 0x0015D + "\x00\x01L\x00d\xe0\x01^" + // 0x00053 0x00327: 0x0015E + "\x00\x01\xcc\x00d\xe0\x01_" + // 0x00073 0x00327: 0x0015F + "\x00\x01L\x00a\x80\x01`" + // 0x00053 0x0030C: 0x00160 + "\x00\x01\xcc\x00a\x80\x01a" + // 0x00073 0x0030C: 0x00161 + "\x00\x01P\x00d\xe0\x01b" + // 0x00054 0x00327: 0x00162 + "\x00\x01\xd0\x00d\xe0\x01c" + // 0x00074 0x00327: 0x00163 + "\x00\x01P\x00a\x80\x01d" + // 0x00054 0x0030C: 0x00164 + "\x00\x01\xd0\x00a\x80\x01e" + // 0x00074 0x0030C: 0x00165 + "\x00\x01T\x00``\x01h" + // 0x00055 0x00303: 0x00168 + "\x00\x01\xd4\x00``\x01i" + // 0x00075 0x00303: 0x00169 + "\x00\x01T\x00`\x80\x01j" + // 0x00055 0x00304: 0x0016A + "\x00\x01\xd4\x00`\x80\x01k" + // 0x00075 0x00304: 0x0016B + "\x00\x01T\x00`\xc0\x01l" + // 0x00055 0x00306: 0x0016C + "\x00\x01\xd4\x00`\xc0\x01m" + // 0x00075 0x00306: 0x0016D + "\x00\x01T\x00a@\x01n" + // 0x00055 0x0030A: 0x0016E + "\x00\x01\xd4\x00a@\x01o" + // 0x00075 0x0030A: 0x0016F + "\x00\x01T\x00a`\x01p" + // 0x00055 0x0030B: 0x00170 + "\x00\x01\xd4\x00a`\x01q" + // 0x00075 0x0030B: 0x00171 + "\x00\x01T\x00e\x00\x01r" + // 0x00055 0x00328: 0x00172 + "\x00\x01\xd4\x00e\x00\x01s" + // 0x00075 0x00328: 0x00173 + "\x00\x01\\\x00`@\x01t" + // 0x00057 0x00302: 0x00174 + "\x00\x01\xdc\x00`@\x01u" + // 0x00077 0x00302: 0x00175 + "\x00\x01d\x00`@\x01v" + // 0x00059 0x00302: 0x00176 + "\x00\x01\xe4\x00`@\x01w" + // 0x00079 0x00302: 0x00177 + "\x00\x01d\x00a\x00\x01x" + // 0x00059 0x00308: 0x00178 + "\x00\x01h\x00` \x01y" + // 0x0005A 0x00301: 0x00179 + "\x00\x01\xe8\x00` \x01z" + // 0x0007A 0x00301: 0x0017A + "\x00\x01h\x00`\xe0\x01{" + // 0x0005A 0x00307: 0x0017B + "\x00\x01\xe8\x00`\xe0\x01|" + // 0x0007A 0x00307: 0x0017C + "\x00\x01h\x00a\x80\x01}" + // 0x0005A 0x0030C: 0x0017D + "\x00\x01\xe8\x00a\x80\x01~" + // 0x0007A 0x0030C: 0x0017E + "\x00\x01<\x00c`\x01\xa0" + // 0x0004F 0x0031B: 0x001A0 + "\x00\x01\xbc\x00c`\x01\xa1" + // 0x0006F 0x0031B: 0x001A1 + "\x00\x01T\x00c`\x01\xaf" + // 0x00055 0x0031B: 0x001AF + "\x00\x01\xd4\x00c`\x01\xb0" + // 0x00075 0x0031B: 0x001B0 + "\x00\x01\x04\x00a\x80\x01\xcd" + // 0x00041 0x0030C: 0x001CD + "\x00\x01\x84\x00a\x80\x01\xce" + // 0x00061 0x0030C: 0x001CE + "\x00\x01$\x00a\x80\x01\xcf" + // 0x00049 0x0030C: 0x001CF + "\x00\x01\xa4\x00a\x80\x01\xd0" + // 0x00069 0x0030C: 0x001D0 + "\x00\x01<\x00a\x80\x01\xd1" + // 0x0004F 0x0030C: 0x001D1 + "\x00\x01\xbc\x00a\x80\x01\xd2" + // 0x0006F 0x0030C: 0x001D2 + "\x00\x01T\x00a\x80\x01\xd3" + // 0x00055 0x0030C: 0x001D3 + "\x00\x01\xd4\x00a\x80\x01\xd4" + // 0x00075 0x0030C: 0x001D4 + "\x00\x03p\x00`\x80\x01\xd5" + // 0x000DC 0x00304: 0x001D5 + "\x00\x03\xf0\x00`\x80\x01\xd6" + // 0x000FC 0x00304: 0x001D6 + "\x00\x03p\x00` \x01\xd7" + // 0x000DC 0x00301: 0x001D7 + "\x00\x03\xf0\x00` \x01\xd8" + // 0x000FC 0x00301: 0x001D8 + "\x00\x03p\x00a\x80\x01\xd9" + // 0x000DC 0x0030C: 0x001D9 + "\x00\x03\xf0\x00a\x80\x01\xda" + // 0x000FC 0x0030C: 0x001DA + "\x00\x03p\x00`\x00\x01\xdb" + // 0x000DC 0x00300: 0x001DB + "\x00\x03\xf0\x00`\x00\x01\xdc" + // 0x000FC 0x00300: 0x001DC + "\x00\x03\x10\x00`\x80\x01\xde" + // 0x000C4 0x00304: 0x001DE + "\x00\x03\x90\x00`\x80\x01\xdf" + // 0x000E4 0x00304: 0x001DF + "\x00\b\x98\x00`\x80\x01\xe0" + // 0x00226 0x00304: 0x001E0 + "\x00\b\x9c\x00`\x80\x01\xe1" + // 0x00227 0x00304: 0x001E1 + "\x00\x03\x18\x00`\x80\x01\xe2" + // 0x000C6 0x00304: 0x001E2 + "\x00\x03\x98\x00`\x80\x01\xe3" + // 0x000E6 0x00304: 0x001E3 + "\x00\x01\x1c\x00a\x80\x01\xe6" + // 0x00047 0x0030C: 0x001E6 + "\x00\x01\x9c\x00a\x80\x01\xe7" + // 0x00067 0x0030C: 0x001E7 + "\x00\x01,\x00a\x80\x01\xe8" + // 0x0004B 0x0030C: 0x001E8 + "\x00\x01\xac\x00a\x80\x01\xe9" + // 0x0006B 0x0030C: 0x001E9 + "\x00\x01<\x00e\x00\x01\xea" + // 0x0004F 0x00328: 0x001EA + "\x00\x01\xbc\x00e\x00\x01\xeb" + // 0x0006F 0x00328: 0x001EB + "\x00\a\xa8\x00`\x80\x01\xec" + // 0x001EA 0x00304: 0x001EC + "\x00\a\xac\x00`\x80\x01\xed" + // 0x001EB 0x00304: 0x001ED + "\x00\x06\xdc\x00a\x80\x01\xee" + // 0x001B7 0x0030C: 0x001EE + "\x00\nH\x00a\x80\x01\xef" + // 0x00292 0x0030C: 0x001EF + "\x00\x01\xa8\x00a\x80\x01\xf0" + // 0x0006A 0x0030C: 0x001F0 + "\x00\x01\x1c\x00` \x01\xf4" + // 0x00047 0x00301: 0x001F4 + "\x00\x01\x9c\x00` \x01\xf5" + // 0x00067 0x00301: 0x001F5 + "\x00\x018\x00`\x00\x01\xf8" + // 0x0004E 0x00300: 0x001F8 + "\x00\x01\xb8\x00`\x00\x01\xf9" + // 0x0006E 0x00300: 0x001F9 + "\x00\x03\x14\x00` \x01\xfa" + // 0x000C5 0x00301: 0x001FA + "\x00\x03\x94\x00` \x01\xfb" + // 0x000E5 0x00301: 0x001FB + "\x00\x03\x18\x00` \x01\xfc" + // 0x000C6 0x00301: 0x001FC + "\x00\x03\x98\x00` \x01\xfd" + // 0x000E6 0x00301: 0x001FD + "\x00\x03`\x00` \x01\xfe" + // 0x000D8 0x00301: 0x001FE + "\x00\x03\xe0\x00` \x01\xff" + // 0x000F8 0x00301: 0x001FF + "\x00\x01\x04\x00a\xe0\x02\x00" + // 0x00041 0x0030F: 0x00200 + "\x00\x01\x84\x00a\xe0\x02\x01" + // 0x00061 0x0030F: 0x00201 + "\x00\x01\x04\x00b \x02\x02" + // 0x00041 0x00311: 0x00202 + "\x00\x01\x84\x00b \x02\x03" + // 0x00061 0x00311: 0x00203 + "\x00\x01\x14\x00a\xe0\x02\x04" + // 0x00045 0x0030F: 0x00204 + "\x00\x01\x94\x00a\xe0\x02\x05" + // 0x00065 0x0030F: 0x00205 + "\x00\x01\x14\x00b \x02\x06" + // 0x00045 0x00311: 0x00206 + "\x00\x01\x94\x00b \x02\a" + // 0x00065 0x00311: 0x00207 + "\x00\x01$\x00a\xe0\x02\b" + // 0x00049 0x0030F: 0x00208 + "\x00\x01\xa4\x00a\xe0\x02\t" + // 0x00069 0x0030F: 0x00209 + "\x00\x01$\x00b \x02\n" + // 0x00049 0x00311: 0x0020A + "\x00\x01\xa4\x00b \x02\v" + // 0x00069 0x00311: 0x0020B + "\x00\x01<\x00a\xe0\x02\f" + // 0x0004F 0x0030F: 0x0020C + "\x00\x01\xbc\x00a\xe0\x02\r" + // 0x0006F 0x0030F: 0x0020D + "\x00\x01<\x00b \x02\x0e" + // 0x0004F 0x00311: 0x0020E + "\x00\x01\xbc\x00b \x02\x0f" + // 0x0006F 0x00311: 0x0020F + "\x00\x01H\x00a\xe0\x02\x10" + // 0x00052 0x0030F: 0x00210 + "\x00\x01\xc8\x00a\xe0\x02\x11" + // 0x00072 0x0030F: 0x00211 + "\x00\x01H\x00b \x02\x12" + // 0x00052 0x00311: 0x00212 + "\x00\x01\xc8\x00b \x02\x13" + // 0x00072 0x00311: 0x00213 + "\x00\x01T\x00a\xe0\x02\x14" + // 0x00055 0x0030F: 0x00214 + "\x00\x01\xd4\x00a\xe0\x02\x15" + // 0x00075 0x0030F: 0x00215 + "\x00\x01T\x00b \x02\x16" + // 0x00055 0x00311: 0x00216 + "\x00\x01\xd4\x00b \x02\x17" + // 0x00075 0x00311: 0x00217 + "\x00\x01L\x00d\xc0\x02\x18" + // 0x00053 0x00326: 0x00218 + "\x00\x01\xcc\x00d\xc0\x02\x19" + // 0x00073 0x00326: 0x00219 + "\x00\x01P\x00d\xc0\x02\x1a" + // 0x00054 0x00326: 0x0021A + "\x00\x01\xd0\x00d\xc0\x02\x1b" + // 0x00074 0x00326: 0x0021B + "\x00\x01 \x00a\x80\x02\x1e" + // 0x00048 0x0030C: 0x0021E + "\x00\x01\xa0\x00a\x80\x02\x1f" + // 0x00068 0x0030C: 0x0021F + "\x00\x01\x04\x00`\xe0\x02&" + // 0x00041 0x00307: 0x00226 + "\x00\x01\x84\x00`\xe0\x02'" + // 0x00061 0x00307: 0x00227 + "\x00\x01\x14\x00d\xe0\x02(" + // 0x00045 0x00327: 0x00228 + "\x00\x01\x94\x00d\xe0\x02)" + // 0x00065 0x00327: 0x00229 + "\x00\x03X\x00`\x80\x02*" + // 0x000D6 0x00304: 0x0022A + "\x00\x03\xd8\x00`\x80\x02+" + // 0x000F6 0x00304: 0x0022B + "\x00\x03T\x00`\x80\x02," + // 0x000D5 0x00304: 0x0022C + "\x00\x03\xd4\x00`\x80\x02-" + // 0x000F5 0x00304: 0x0022D + "\x00\x01<\x00`\xe0\x02." + // 0x0004F 0x00307: 0x0022E + "\x00\x01\xbc\x00`\xe0\x02/" + // 0x0006F 0x00307: 0x0022F + "\x00\b\xb8\x00`\x80\x020" + // 0x0022E 0x00304: 0x00230 + "\x00\b\xbc\x00`\x80\x021" + // 0x0022F 0x00304: 0x00231 + "\x00\x01d\x00`\x80\x022" + // 0x00059 0x00304: 0x00232 + "\x00\x01\xe4\x00`\x80\x023" + // 0x00079 0x00304: 0x00233 + "\x00\x02\xa0\x00` \x03\x85" + // 0x000A8 0x00301: 0x00385 + "\x00\x0eD\x00` \x03\x86" + // 0x00391 0x00301: 0x00386 + "\x00\x0eT\x00` \x03\x88" + // 0x00395 0x00301: 0x00388 + "\x00\x0e\\\x00` \x03\x89" + // 0x00397 0x00301: 0x00389 + "\x00\x0ed\x00` \x03\x8a" + // 0x00399 0x00301: 0x0038A + "\x00\x0e|\x00` \x03\x8c" + // 0x0039F 0x00301: 0x0038C + "\x00\x0e\x94\x00` \x03\x8e" + // 0x003A5 0x00301: 0x0038E + "\x00\x0e\xa4\x00` \x03\x8f" + // 0x003A9 0x00301: 0x0038F + "\x00\x0f(\x00` \x03\x90" + // 0x003CA 0x00301: 0x00390 + "\x00\x0ed\x00a\x00\x03\xaa" + // 0x00399 0x00308: 0x003AA + "\x00\x0e\x94\x00a\x00\x03\xab" + // 0x003A5 0x00308: 0x003AB + "\x00\x0e\xc4\x00` \x03\xac" + // 0x003B1 0x00301: 0x003AC + "\x00\x0e\xd4\x00` \x03\xad" + // 0x003B5 0x00301: 0x003AD + "\x00\x0e\xdc\x00` \x03\xae" + // 0x003B7 0x00301: 0x003AE + "\x00\x0e\xe4\x00` \x03\xaf" + // 0x003B9 0x00301: 0x003AF + "\x00\x0f,\x00` \x03\xb0" + // 0x003CB 0x00301: 0x003B0 + "\x00\x0e\xe4\x00a\x00\x03\xca" + // 0x003B9 0x00308: 0x003CA + "\x00\x0f\x14\x00a\x00\x03\xcb" + // 0x003C5 0x00308: 0x003CB + "\x00\x0e\xfc\x00` \x03\xcc" + // 0x003BF 0x00301: 0x003CC + "\x00\x0f\x14\x00` \x03\xcd" + // 0x003C5 0x00301: 0x003CD + "\x00\x0f$\x00` \x03\xce" + // 0x003C9 0x00301: 0x003CE + "\x00\x0fH\x00` \x03\xd3" + // 0x003D2 0x00301: 0x003D3 + "\x00\x0fH\x00a\x00\x03\xd4" + // 0x003D2 0x00308: 0x003D4 + "\x00\x10T\x00`\x00\x04\x00" + // 0x00415 0x00300: 0x00400 + "\x00\x10T\x00a\x00\x04\x01" + // 0x00415 0x00308: 0x00401 + "\x00\x10L\x00` \x04\x03" + // 0x00413 0x00301: 0x00403 + "\x00\x10\x18\x00a\x00\x04\a" + // 0x00406 0x00308: 0x00407 + "\x00\x10h\x00` \x04\f" + // 0x0041A 0x00301: 0x0040C + "\x00\x10`\x00`\x00\x04\r" + // 0x00418 0x00300: 0x0040D + "\x00\x10\x8c\x00`\xc0\x04\x0e" + // 0x00423 0x00306: 0x0040E + "\x00\x10`\x00`\xc0\x04\x19" + // 0x00418 0x00306: 0x00419 + "\x00\x10\xe0\x00`\xc0\x049" + // 0x00438 0x00306: 0x00439 + "\x00\x10\xd4\x00`\x00\x04P" + // 0x00435 0x00300: 0x00450 + "\x00\x10\xd4\x00a\x00\x04Q" + // 0x00435 0x00308: 0x00451 + "\x00\x10\xcc\x00` \x04S" + // 0x00433 0x00301: 0x00453 + "\x00\x11X\x00a\x00\x04W" + // 0x00456 0x00308: 0x00457 + "\x00\x10\xe8\x00` \x04\\" + // 0x0043A 0x00301: 0x0045C + "\x00\x10\xe0\x00`\x00\x04]" + // 0x00438 0x00300: 0x0045D + "\x00\x11\f\x00`\xc0\x04^" + // 0x00443 0x00306: 0x0045E + "\x00\x11\xd0\x00a\xe0\x04v" + // 0x00474 0x0030F: 0x00476 + "\x00\x11\xd4\x00a\xe0\x04w" + // 0x00475 0x0030F: 0x00477 + "\x00\x10X\x00`\xc0\x04\xc1" + // 0x00416 0x00306: 0x004C1 + "\x00\x10\xd8\x00`\xc0\x04\xc2" + // 0x00436 0x00306: 0x004C2 + "\x00\x10@\x00`\xc0\x04\xd0" + // 0x00410 0x00306: 0x004D0 + "\x00\x10\xc0\x00`\xc0\x04\xd1" + // 0x00430 0x00306: 0x004D1 + "\x00\x10@\x00a\x00\x04\xd2" + // 0x00410 0x00308: 0x004D2 + "\x00\x10\xc0\x00a\x00\x04\xd3" + // 0x00430 0x00308: 0x004D3 + "\x00\x10T\x00`\xc0\x04\xd6" + // 0x00415 0x00306: 0x004D6 + "\x00\x10\xd4\x00`\xc0\x04\xd7" + // 0x00435 0x00306: 0x004D7 + "\x00\x13`\x00a\x00\x04\xda" + // 0x004D8 0x00308: 0x004DA + "\x00\x13d\x00a\x00\x04\xdb" + // 0x004D9 0x00308: 0x004DB + "\x00\x10X\x00a\x00\x04\xdc" + // 0x00416 0x00308: 0x004DC + "\x00\x10\xd8\x00a\x00\x04\xdd" + // 0x00436 0x00308: 0x004DD + "\x00\x10\\\x00a\x00\x04\xde" + // 0x00417 0x00308: 0x004DE + "\x00\x10\xdc\x00a\x00\x04\xdf" + // 0x00437 0x00308: 0x004DF + "\x00\x10`\x00`\x80\x04\xe2" + // 0x00418 0x00304: 0x004E2 + "\x00\x10\xe0\x00`\x80\x04\xe3" + // 0x00438 0x00304: 0x004E3 + "\x00\x10`\x00a\x00\x04\xe4" + // 0x00418 0x00308: 0x004E4 + "\x00\x10\xe0\x00a\x00\x04\xe5" + // 0x00438 0x00308: 0x004E5 + "\x00\x10x\x00a\x00\x04\xe6" + // 0x0041E 0x00308: 0x004E6 + "\x00\x10\xf8\x00a\x00\x04\xe7" + // 0x0043E 0x00308: 0x004E7 + "\x00\x13\xa0\x00a\x00\x04\xea" + // 0x004E8 0x00308: 0x004EA + "\x00\x13\xa4\x00a\x00\x04\xeb" + // 0x004E9 0x00308: 0x004EB + "\x00\x10\xb4\x00a\x00\x04\xec" + // 0x0042D 0x00308: 0x004EC + "\x00\x114\x00a\x00\x04\xed" + // 0x0044D 0x00308: 0x004ED + "\x00\x10\x8c\x00`\x80\x04\xee" + // 0x00423 0x00304: 0x004EE + "\x00\x11\f\x00`\x80\x04\xef" + // 0x00443 0x00304: 0x004EF + "\x00\x10\x8c\x00a\x00\x04\xf0" + // 0x00423 0x00308: 0x004F0 + "\x00\x11\f\x00a\x00\x04\xf1" + // 0x00443 0x00308: 0x004F1 + "\x00\x10\x8c\x00a`\x04\xf2" + // 0x00423 0x0030B: 0x004F2 + "\x00\x11\f\x00a`\x04\xf3" + // 0x00443 0x0030B: 0x004F3 + "\x00\x10\x9c\x00a\x00\x04\xf4" + // 0x00427 0x00308: 0x004F4 + "\x00\x11\x1c\x00a\x00\x04\xf5" + // 0x00447 0x00308: 0x004F5 + "\x00\x10\xac\x00a\x00\x04\xf8" + // 0x0042B 0x00308: 0x004F8 + "\x00\x11,\x00a\x00\x04\xf9" + // 0x0044B 0x00308: 0x004F9 + "\x00\x18\x9c\x00\xca`\x06\"" + // 0x00627 0x00653: 0x00622 + "\x00\x18\x9c\x00ʀ\x06#" + // 0x00627 0x00654: 0x00623 + "\x00\x19 \x00ʀ\x06$" + // 0x00648 0x00654: 0x00624 + "\x00\x18\x9c\x00ʠ\x06%" + // 0x00627 0x00655: 0x00625 + "\x00\x19(\x00ʀ\x06&" + // 0x0064A 0x00654: 0x00626 + "\x00\x1bT\x00ʀ\x06\xc0" + // 0x006D5 0x00654: 0x006C0 + "\x00\x1b\x04\x00ʀ\x06\xc2" + // 0x006C1 0x00654: 0x006C2 + "\x00\x1bH\x00ʀ\x06\xd3" + // 0x006D2 0x00654: 0x006D3 + "\x00$\xa0\x01'\x80\t)" + // 0x00928 0x0093C: 0x00929 + "\x00$\xc0\x01'\x80\t1" + // 0x00930 0x0093C: 0x00931 + "\x00$\xcc\x01'\x80\t4" + // 0x00933 0x0093C: 0x00934 + "\x00'\x1c\x017\xc0\t\xcb" + // 0x009C7 0x009BE: 0x009CB + "\x00'\x1c\x01:\xe0\t\xcc" + // 0x009C7 0x009D7: 0x009CC + "\x00-\x1c\x01j\xc0\vH" + // 0x00B47 0x00B56: 0x00B48 + "\x00-\x1c\x01g\xc0\vK" + // 0x00B47 0x00B3E: 0x00B4B + "\x00-\x1c\x01j\xe0\vL" + // 0x00B47 0x00B57: 0x00B4C + "\x00.H\x01z\xe0\v\x94" + // 0x00B92 0x00BD7: 0x00B94 + "\x00/\x18\x01w\xc0\v\xca" + // 0x00BC6 0x00BBE: 0x00BCA + "\x00/\x1c\x01w\xc0\v\xcb" + // 0x00BC7 0x00BBE: 0x00BCB + "\x00/\x18\x01z\xe0\v\xcc" + // 0x00BC6 0x00BD7: 0x00BCC + "\x001\x18\x01\x8a\xc0\fH" + // 0x00C46 0x00C56: 0x00C48 + "\x002\xfc\x01\x9a\xa0\f\xc0" + // 0x00CBF 0x00CD5: 0x00CC0 + "\x003\x18\x01\x9a\xa0\f\xc7" + // 0x00CC6 0x00CD5: 0x00CC7 + "\x003\x18\x01\x9a\xc0\f\xc8" + // 0x00CC6 0x00CD6: 0x00CC8 + "\x003\x18\x01\x98@\f\xca" + // 0x00CC6 0x00CC2: 0x00CCA + "\x003(\x01\x9a\xa0\f\xcb" + // 0x00CCA 0x00CD5: 0x00CCB + "\x005\x18\x01\xa7\xc0\rJ" + // 0x00D46 0x00D3E: 0x00D4A + "\x005\x1c\x01\xa7\xc0\rK" + // 0x00D47 0x00D3E: 0x00D4B + "\x005\x18\x01\xaa\xe0\rL" + // 0x00D46 0x00D57: 0x00D4C + "\x007d\x01\xb9@\r\xda" + // 0x00DD9 0x00DCA: 0x00DDA + "\x007d\x01\xb9\xe0\r\xdc" + // 0x00DD9 0x00DCF: 0x00DDC + "\x007p\x01\xb9@\r\xdd" + // 0x00DDC 0x00DCA: 0x00DDD + "\x007d\x01\xbb\xe0\r\xde" + // 0x00DD9 0x00DDF: 0x00DDE + "\x00@\x94\x02\x05\xc0\x10&" + // 0x01025 0x0102E: 0x01026 + "\x00l\x14\x03f\xa0\x1b\x06" + // 0x01B05 0x01B35: 0x01B06 + "\x00l\x1c\x03f\xa0\x1b\b" + // 0x01B07 0x01B35: 0x01B08 + "\x00l$\x03f\xa0\x1b\n" + // 0x01B09 0x01B35: 0x01B0A + "\x00l,\x03f\xa0\x1b\f" + // 0x01B0B 0x01B35: 0x01B0C + "\x00l4\x03f\xa0\x1b\x0e" + // 0x01B0D 0x01B35: 0x01B0E + "\x00lD\x03f\xa0\x1b\x12" + // 0x01B11 0x01B35: 0x01B12 + "\x00l\xe8\x03f\xa0\x1b;" + // 0x01B3A 0x01B35: 0x01B3B + "\x00l\xf0\x03f\xa0\x1b=" + // 0x01B3C 0x01B35: 0x01B3D + "\x00l\xf8\x03f\xa0\x1b@" + // 0x01B3E 0x01B35: 0x01B40 + "\x00l\xfc\x03f\xa0\x1bA" + // 0x01B3F 0x01B35: 0x01B41 + "\x00m\b\x03f\xa0\x1bC" + // 0x01B42 0x01B35: 0x01B43 + "\x00\x01\x04\x00d\xa0\x1e\x00" + // 0x00041 0x00325: 0x01E00 + "\x00\x01\x84\x00d\xa0\x1e\x01" + // 0x00061 0x00325: 0x01E01 + "\x00\x01\b\x00`\xe0\x1e\x02" + // 0x00042 0x00307: 0x01E02 + "\x00\x01\x88\x00`\xe0\x1e\x03" + // 0x00062 0x00307: 0x01E03 + "\x00\x01\b\x00d`\x1e\x04" + // 0x00042 0x00323: 0x01E04 + "\x00\x01\x88\x00d`\x1e\x05" + // 0x00062 0x00323: 0x01E05 + "\x00\x01\b\x00f \x1e\x06" + // 0x00042 0x00331: 0x01E06 + "\x00\x01\x88\x00f \x1e\a" + // 0x00062 0x00331: 0x01E07 + "\x00\x03\x1c\x00` \x1e\b" + // 0x000C7 0x00301: 0x01E08 + "\x00\x03\x9c\x00` \x1e\t" + // 0x000E7 0x00301: 0x01E09 + "\x00\x01\x10\x00`\xe0\x1e\n" + // 0x00044 0x00307: 0x01E0A + "\x00\x01\x90\x00`\xe0\x1e\v" + // 0x00064 0x00307: 0x01E0B + "\x00\x01\x10\x00d`\x1e\f" + // 0x00044 0x00323: 0x01E0C + "\x00\x01\x90\x00d`\x1e\r" + // 0x00064 0x00323: 0x01E0D + "\x00\x01\x10\x00f \x1e\x0e" + // 0x00044 0x00331: 0x01E0E + "\x00\x01\x90\x00f \x1e\x0f" + // 0x00064 0x00331: 0x01E0F + "\x00\x01\x10\x00d\xe0\x1e\x10" + // 0x00044 0x00327: 0x01E10 + "\x00\x01\x90\x00d\xe0\x1e\x11" + // 0x00064 0x00327: 0x01E11 + "\x00\x01\x10\x00e\xa0\x1e\x12" + // 0x00044 0x0032D: 0x01E12 + "\x00\x01\x90\x00e\xa0\x1e\x13" + // 0x00064 0x0032D: 0x01E13 + "\x00\x04H\x00`\x00\x1e\x14" + // 0x00112 0x00300: 0x01E14 + "\x00\x04L\x00`\x00\x1e\x15" + // 0x00113 0x00300: 0x01E15 + "\x00\x04H\x00` \x1e\x16" + // 0x00112 0x00301: 0x01E16 + "\x00\x04L\x00` \x1e\x17" + // 0x00113 0x00301: 0x01E17 + "\x00\x01\x14\x00e\xa0\x1e\x18" + // 0x00045 0x0032D: 0x01E18 + "\x00\x01\x94\x00e\xa0\x1e\x19" + // 0x00065 0x0032D: 0x01E19 + "\x00\x01\x14\x00f\x00\x1e\x1a" + // 0x00045 0x00330: 0x01E1A + "\x00\x01\x94\x00f\x00\x1e\x1b" + // 0x00065 0x00330: 0x01E1B + "\x00\b\xa0\x00`\xc0\x1e\x1c" + // 0x00228 0x00306: 0x01E1C + "\x00\b\xa4\x00`\xc0\x1e\x1d" + // 0x00229 0x00306: 0x01E1D + "\x00\x01\x18\x00`\xe0\x1e\x1e" + // 0x00046 0x00307: 0x01E1E + "\x00\x01\x98\x00`\xe0\x1e\x1f" + // 0x00066 0x00307: 0x01E1F + "\x00\x01\x1c\x00`\x80\x1e " + // 0x00047 0x00304: 0x01E20 + "\x00\x01\x9c\x00`\x80\x1e!" + // 0x00067 0x00304: 0x01E21 + "\x00\x01 \x00`\xe0\x1e\"" + // 0x00048 0x00307: 0x01E22 + "\x00\x01\xa0\x00`\xe0\x1e#" + // 0x00068 0x00307: 0x01E23 + "\x00\x01 \x00d`\x1e$" + // 0x00048 0x00323: 0x01E24 + "\x00\x01\xa0\x00d`\x1e%" + // 0x00068 0x00323: 0x01E25 + "\x00\x01 \x00a\x00\x1e&" + // 0x00048 0x00308: 0x01E26 + "\x00\x01\xa0\x00a\x00\x1e'" + // 0x00068 0x00308: 0x01E27 + "\x00\x01 \x00d\xe0\x1e(" + // 0x00048 0x00327: 0x01E28 + "\x00\x01\xa0\x00d\xe0\x1e)" + // 0x00068 0x00327: 0x01E29 + "\x00\x01 \x00e\xc0\x1e*" + // 0x00048 0x0032E: 0x01E2A + "\x00\x01\xa0\x00e\xc0\x1e+" + // 0x00068 0x0032E: 0x01E2B + "\x00\x01$\x00f\x00\x1e," + // 0x00049 0x00330: 0x01E2C + "\x00\x01\xa4\x00f\x00\x1e-" + // 0x00069 0x00330: 0x01E2D + "\x00\x03<\x00` \x1e." + // 0x000CF 0x00301: 0x01E2E + "\x00\x03\xbc\x00` \x1e/" + // 0x000EF 0x00301: 0x01E2F + "\x00\x01,\x00` \x1e0" + // 0x0004B 0x00301: 0x01E30 + "\x00\x01\xac\x00` \x1e1" + // 0x0006B 0x00301: 0x01E31 + "\x00\x01,\x00d`\x1e2" + // 0x0004B 0x00323: 0x01E32 + "\x00\x01\xac\x00d`\x1e3" + // 0x0006B 0x00323: 0x01E33 + "\x00\x01,\x00f \x1e4" + // 0x0004B 0x00331: 0x01E34 + "\x00\x01\xac\x00f \x1e5" + // 0x0006B 0x00331: 0x01E35 + "\x00\x010\x00d`\x1e6" + // 0x0004C 0x00323: 0x01E36 + "\x00\x01\xb0\x00d`\x1e7" + // 0x0006C 0x00323: 0x01E37 + "\x00x\xd8\x00`\x80\x1e8" + // 0x01E36 0x00304: 0x01E38 + "\x00x\xdc\x00`\x80\x1e9" + // 0x01E37 0x00304: 0x01E39 + "\x00\x010\x00f \x1e:" + // 0x0004C 0x00331: 0x01E3A + "\x00\x01\xb0\x00f \x1e;" + // 0x0006C 0x00331: 0x01E3B + "\x00\x010\x00e\xa0\x1e<" + // 0x0004C 0x0032D: 0x01E3C + "\x00\x01\xb0\x00e\xa0\x1e=" + // 0x0006C 0x0032D: 0x01E3D + "\x00\x014\x00` \x1e>" + // 0x0004D 0x00301: 0x01E3E + "\x00\x01\xb4\x00` \x1e?" + // 0x0006D 0x00301: 0x01E3F + "\x00\x014\x00`\xe0\x1e@" + // 0x0004D 0x00307: 0x01E40 + "\x00\x01\xb4\x00`\xe0\x1eA" + // 0x0006D 0x00307: 0x01E41 + "\x00\x014\x00d`\x1eB" + // 0x0004D 0x00323: 0x01E42 + "\x00\x01\xb4\x00d`\x1eC" + // 0x0006D 0x00323: 0x01E43 + "\x00\x018\x00`\xe0\x1eD" + // 0x0004E 0x00307: 0x01E44 + "\x00\x01\xb8\x00`\xe0\x1eE" + // 0x0006E 0x00307: 0x01E45 + "\x00\x018\x00d`\x1eF" + // 0x0004E 0x00323: 0x01E46 + "\x00\x01\xb8\x00d`\x1eG" + // 0x0006E 0x00323: 0x01E47 + "\x00\x018\x00f \x1eH" + // 0x0004E 0x00331: 0x01E48 + "\x00\x01\xb8\x00f \x1eI" + // 0x0006E 0x00331: 0x01E49 + "\x00\x018\x00e\xa0\x1eJ" + // 0x0004E 0x0032D: 0x01E4A + "\x00\x01\xb8\x00e\xa0\x1eK" + // 0x0006E 0x0032D: 0x01E4B + "\x00\x03T\x00` \x1eL" + // 0x000D5 0x00301: 0x01E4C + "\x00\x03\xd4\x00` \x1eM" + // 0x000F5 0x00301: 0x01E4D + "\x00\x03T\x00a\x00\x1eN" + // 0x000D5 0x00308: 0x01E4E + "\x00\x03\xd4\x00a\x00\x1eO" + // 0x000F5 0x00308: 0x01E4F + "\x00\x050\x00`\x00\x1eP" + // 0x0014C 0x00300: 0x01E50 + "\x00\x054\x00`\x00\x1eQ" + // 0x0014D 0x00300: 0x01E51 + "\x00\x050\x00` \x1eR" + // 0x0014C 0x00301: 0x01E52 + "\x00\x054\x00` \x1eS" + // 0x0014D 0x00301: 0x01E53 + "\x00\x01@\x00` \x1eT" + // 0x00050 0x00301: 0x01E54 + "\x00\x01\xc0\x00` \x1eU" + // 0x00070 0x00301: 0x01E55 + "\x00\x01@\x00`\xe0\x1eV" + // 0x00050 0x00307: 0x01E56 + "\x00\x01\xc0\x00`\xe0\x1eW" + // 0x00070 0x00307: 0x01E57 + "\x00\x01H\x00`\xe0\x1eX" + // 0x00052 0x00307: 0x01E58 + "\x00\x01\xc8\x00`\xe0\x1eY" + // 0x00072 0x00307: 0x01E59 + "\x00\x01H\x00d`\x1eZ" + // 0x00052 0x00323: 0x01E5A + "\x00\x01\xc8\x00d`\x1e[" + // 0x00072 0x00323: 0x01E5B + "\x00yh\x00`\x80\x1e\\" + // 0x01E5A 0x00304: 0x01E5C + "\x00yl\x00`\x80\x1e]" + // 0x01E5B 0x00304: 0x01E5D + "\x00\x01H\x00f \x1e^" + // 0x00052 0x00331: 0x01E5E + "\x00\x01\xc8\x00f \x1e_" + // 0x00072 0x00331: 0x01E5F + "\x00\x01L\x00`\xe0\x1e`" + // 0x00053 0x00307: 0x01E60 + "\x00\x01\xcc\x00`\xe0\x1ea" + // 0x00073 0x00307: 0x01E61 + "\x00\x01L\x00d`\x1eb" + // 0x00053 0x00323: 0x01E62 + "\x00\x01\xcc\x00d`\x1ec" + // 0x00073 0x00323: 0x01E63 + "\x00\x05h\x00`\xe0\x1ed" + // 0x0015A 0x00307: 0x01E64 + "\x00\x05l\x00`\xe0\x1ee" + // 0x0015B 0x00307: 0x01E65 + "\x00\x05\x80\x00`\xe0\x1ef" + // 0x00160 0x00307: 0x01E66 + "\x00\x05\x84\x00`\xe0\x1eg" + // 0x00161 0x00307: 0x01E67 + "\x00y\x88\x00`\xe0\x1eh" + // 0x01E62 0x00307: 0x01E68 + "\x00y\x8c\x00`\xe0\x1ei" + // 0x01E63 0x00307: 0x01E69 + "\x00\x01P\x00`\xe0\x1ej" + // 0x00054 0x00307: 0x01E6A + "\x00\x01\xd0\x00`\xe0\x1ek" + // 0x00074 0x00307: 0x01E6B + "\x00\x01P\x00d`\x1el" + // 0x00054 0x00323: 0x01E6C + "\x00\x01\xd0\x00d`\x1em" + // 0x00074 0x00323: 0x01E6D + "\x00\x01P\x00f \x1en" + // 0x00054 0x00331: 0x01E6E + "\x00\x01\xd0\x00f \x1eo" + // 0x00074 0x00331: 0x01E6F + "\x00\x01P\x00e\xa0\x1ep" + // 0x00054 0x0032D: 0x01E70 + "\x00\x01\xd0\x00e\xa0\x1eq" + // 0x00074 0x0032D: 0x01E71 + "\x00\x01T\x00d\x80\x1er" + // 0x00055 0x00324: 0x01E72 + "\x00\x01\xd4\x00d\x80\x1es" + // 0x00075 0x00324: 0x01E73 + "\x00\x01T\x00f\x00\x1et" + // 0x00055 0x00330: 0x01E74 + "\x00\x01\xd4\x00f\x00\x1eu" + // 0x00075 0x00330: 0x01E75 + "\x00\x01T\x00e\xa0\x1ev" + // 0x00055 0x0032D: 0x01E76 + "\x00\x01\xd4\x00e\xa0\x1ew" + // 0x00075 0x0032D: 0x01E77 + "\x00\x05\xa0\x00` \x1ex" + // 0x00168 0x00301: 0x01E78 + "\x00\x05\xa4\x00` \x1ey" + // 0x00169 0x00301: 0x01E79 + "\x00\x05\xa8\x00a\x00\x1ez" + // 0x0016A 0x00308: 0x01E7A + "\x00\x05\xac\x00a\x00\x1e{" + // 0x0016B 0x00308: 0x01E7B + "\x00\x01X\x00``\x1e|" + // 0x00056 0x00303: 0x01E7C + "\x00\x01\xd8\x00``\x1e}" + // 0x00076 0x00303: 0x01E7D + "\x00\x01X\x00d`\x1e~" + // 0x00056 0x00323: 0x01E7E + "\x00\x01\xd8\x00d`\x1e\x7f" + // 0x00076 0x00323: 0x01E7F + "\x00\x01\\\x00`\x00\x1e\x80" + // 0x00057 0x00300: 0x01E80 + "\x00\x01\xdc\x00`\x00\x1e\x81" + // 0x00077 0x00300: 0x01E81 + "\x00\x01\\\x00` \x1e\x82" + // 0x00057 0x00301: 0x01E82 + "\x00\x01\xdc\x00` \x1e\x83" + // 0x00077 0x00301: 0x01E83 + "\x00\x01\\\x00a\x00\x1e\x84" + // 0x00057 0x00308: 0x01E84 + "\x00\x01\xdc\x00a\x00\x1e\x85" + // 0x00077 0x00308: 0x01E85 + "\x00\x01\\\x00`\xe0\x1e\x86" + // 0x00057 0x00307: 0x01E86 + "\x00\x01\xdc\x00`\xe0\x1e\x87" + // 0x00077 0x00307: 0x01E87 + "\x00\x01\\\x00d`\x1e\x88" + // 0x00057 0x00323: 0x01E88 + "\x00\x01\xdc\x00d`\x1e\x89" + // 0x00077 0x00323: 0x01E89 + "\x00\x01`\x00`\xe0\x1e\x8a" + // 0x00058 0x00307: 0x01E8A + "\x00\x01\xe0\x00`\xe0\x1e\x8b" + // 0x00078 0x00307: 0x01E8B + "\x00\x01`\x00a\x00\x1e\x8c" + // 0x00058 0x00308: 0x01E8C + "\x00\x01\xe0\x00a\x00\x1e\x8d" + // 0x00078 0x00308: 0x01E8D + "\x00\x01d\x00`\xe0\x1e\x8e" + // 0x00059 0x00307: 0x01E8E + "\x00\x01\xe4\x00`\xe0\x1e\x8f" + // 0x00079 0x00307: 0x01E8F + "\x00\x01h\x00`@\x1e\x90" + // 0x0005A 0x00302: 0x01E90 + "\x00\x01\xe8\x00`@\x1e\x91" + // 0x0007A 0x00302: 0x01E91 + "\x00\x01h\x00d`\x1e\x92" + // 0x0005A 0x00323: 0x01E92 + "\x00\x01\xe8\x00d`\x1e\x93" + // 0x0007A 0x00323: 0x01E93 + "\x00\x01h\x00f \x1e\x94" + // 0x0005A 0x00331: 0x01E94 + "\x00\x01\xe8\x00f \x1e\x95" + // 0x0007A 0x00331: 0x01E95 + "\x00\x01\xa0\x00f \x1e\x96" + // 0x00068 0x00331: 0x01E96 + "\x00\x01\xd0\x00a\x00\x1e\x97" + // 0x00074 0x00308: 0x01E97 + "\x00\x01\xdc\x00a@\x1e\x98" + // 0x00077 0x0030A: 0x01E98 + "\x00\x01\xe4\x00a@\x1e\x99" + // 0x00079 0x0030A: 0x01E99 + "\x00\x05\xfc\x00`\xe0\x1e\x9b" + // 0x0017F 0x00307: 0x01E9B + "\x00\x01\x04\x00d`\x1e\xa0" + // 0x00041 0x00323: 0x01EA0 + "\x00\x01\x84\x00d`\x1e\xa1" + // 0x00061 0x00323: 0x01EA1 + "\x00\x01\x04\x00a \x1e\xa2" + // 0x00041 0x00309: 0x01EA2 + "\x00\x01\x84\x00a \x1e\xa3" + // 0x00061 0x00309: 0x01EA3 + "\x00\x03\b\x00` \x1e\xa4" + // 0x000C2 0x00301: 0x01EA4 + "\x00\x03\x88\x00` \x1e\xa5" + // 0x000E2 0x00301: 0x01EA5 + "\x00\x03\b\x00`\x00\x1e\xa6" + // 0x000C2 0x00300: 0x01EA6 + "\x00\x03\x88\x00`\x00\x1e\xa7" + // 0x000E2 0x00300: 0x01EA7 + "\x00\x03\b\x00a \x1e\xa8" + // 0x000C2 0x00309: 0x01EA8 + "\x00\x03\x88\x00a \x1e\xa9" + // 0x000E2 0x00309: 0x01EA9 + "\x00\x03\b\x00``\x1e\xaa" + // 0x000C2 0x00303: 0x01EAA + "\x00\x03\x88\x00``\x1e\xab" + // 0x000E2 0x00303: 0x01EAB + "\x00z\x80\x00`@\x1e\xac" + // 0x01EA0 0x00302: 0x01EAC + "\x00z\x84\x00`@\x1e\xad" + // 0x01EA1 0x00302: 0x01EAD + "\x00\x04\b\x00` \x1e\xae" + // 0x00102 0x00301: 0x01EAE + "\x00\x04\f\x00` \x1e\xaf" + // 0x00103 0x00301: 0x01EAF + "\x00\x04\b\x00`\x00\x1e\xb0" + // 0x00102 0x00300: 0x01EB0 + "\x00\x04\f\x00`\x00\x1e\xb1" + // 0x00103 0x00300: 0x01EB1 + "\x00\x04\b\x00a \x1e\xb2" + // 0x00102 0x00309: 0x01EB2 + "\x00\x04\f\x00a \x1e\xb3" + // 0x00103 0x00309: 0x01EB3 + "\x00\x04\b\x00``\x1e\xb4" + // 0x00102 0x00303: 0x01EB4 + "\x00\x04\f\x00``\x1e\xb5" + // 0x00103 0x00303: 0x01EB5 + "\x00z\x80\x00`\xc0\x1e\xb6" + // 0x01EA0 0x00306: 0x01EB6 + "\x00z\x84\x00`\xc0\x1e\xb7" + // 0x01EA1 0x00306: 0x01EB7 + "\x00\x01\x14\x00d`\x1e\xb8" + // 0x00045 0x00323: 0x01EB8 + "\x00\x01\x94\x00d`\x1e\xb9" + // 0x00065 0x00323: 0x01EB9 + "\x00\x01\x14\x00a \x1e\xba" + // 0x00045 0x00309: 0x01EBA + "\x00\x01\x94\x00a \x1e\xbb" + // 0x00065 0x00309: 0x01EBB + "\x00\x01\x14\x00``\x1e\xbc" + // 0x00045 0x00303: 0x01EBC + "\x00\x01\x94\x00``\x1e\xbd" + // 0x00065 0x00303: 0x01EBD + "\x00\x03(\x00` \x1e\xbe" + // 0x000CA 0x00301: 0x01EBE + "\x00\x03\xa8\x00` \x1e\xbf" + // 0x000EA 0x00301: 0x01EBF + "\x00\x03(\x00`\x00\x1e\xc0" + // 0x000CA 0x00300: 0x01EC0 + "\x00\x03\xa8\x00`\x00\x1e\xc1" + // 0x000EA 0x00300: 0x01EC1 + "\x00\x03(\x00a \x1e\xc2" + // 0x000CA 0x00309: 0x01EC2 + "\x00\x03\xa8\x00a \x1e\xc3" + // 0x000EA 0x00309: 0x01EC3 + "\x00\x03(\x00``\x1e\xc4" + // 0x000CA 0x00303: 0x01EC4 + "\x00\x03\xa8\x00``\x1e\xc5" + // 0x000EA 0x00303: 0x01EC5 + "\x00z\xe0\x00`@\x1e\xc6" + // 0x01EB8 0x00302: 0x01EC6 + "\x00z\xe4\x00`@\x1e\xc7" + // 0x01EB9 0x00302: 0x01EC7 + "\x00\x01$\x00a \x1e\xc8" + // 0x00049 0x00309: 0x01EC8 + "\x00\x01\xa4\x00a \x1e\xc9" + // 0x00069 0x00309: 0x01EC9 + "\x00\x01$\x00d`\x1e\xca" + // 0x00049 0x00323: 0x01ECA + "\x00\x01\xa4\x00d`\x1e\xcb" + // 0x00069 0x00323: 0x01ECB + "\x00\x01<\x00d`\x1e\xcc" + // 0x0004F 0x00323: 0x01ECC + "\x00\x01\xbc\x00d`\x1e\xcd" + // 0x0006F 0x00323: 0x01ECD + "\x00\x01<\x00a \x1e\xce" + // 0x0004F 0x00309: 0x01ECE + "\x00\x01\xbc\x00a \x1e\xcf" + // 0x0006F 0x00309: 0x01ECF + "\x00\x03P\x00` \x1e\xd0" + // 0x000D4 0x00301: 0x01ED0 + "\x00\x03\xd0\x00` \x1e\xd1" + // 0x000F4 0x00301: 0x01ED1 + "\x00\x03P\x00`\x00\x1e\xd2" + // 0x000D4 0x00300: 0x01ED2 + "\x00\x03\xd0\x00`\x00\x1e\xd3" + // 0x000F4 0x00300: 0x01ED3 + "\x00\x03P\x00a \x1e\xd4" + // 0x000D4 0x00309: 0x01ED4 + "\x00\x03\xd0\x00a \x1e\xd5" + // 0x000F4 0x00309: 0x01ED5 + "\x00\x03P\x00``\x1e\xd6" + // 0x000D4 0x00303: 0x01ED6 + "\x00\x03\xd0\x00``\x1e\xd7" + // 0x000F4 0x00303: 0x01ED7 + "\x00{0\x00`@\x1e\xd8" + // 0x01ECC 0x00302: 0x01ED8 + "\x00{4\x00`@\x1e\xd9" + // 0x01ECD 0x00302: 0x01ED9 + "\x00\x06\x80\x00` \x1e\xda" + // 0x001A0 0x00301: 0x01EDA + "\x00\x06\x84\x00` \x1e\xdb" + // 0x001A1 0x00301: 0x01EDB + "\x00\x06\x80\x00`\x00\x1e\xdc" + // 0x001A0 0x00300: 0x01EDC + "\x00\x06\x84\x00`\x00\x1e\xdd" + // 0x001A1 0x00300: 0x01EDD + "\x00\x06\x80\x00a \x1e\xde" + // 0x001A0 0x00309: 0x01EDE + "\x00\x06\x84\x00a \x1e\xdf" + // 0x001A1 0x00309: 0x01EDF + "\x00\x06\x80\x00``\x1e\xe0" + // 0x001A0 0x00303: 0x01EE0 + "\x00\x06\x84\x00``\x1e\xe1" + // 0x001A1 0x00303: 0x01EE1 + "\x00\x06\x80\x00d`\x1e\xe2" + // 0x001A0 0x00323: 0x01EE2 + "\x00\x06\x84\x00d`\x1e\xe3" + // 0x001A1 0x00323: 0x01EE3 + "\x00\x01T\x00d`\x1e\xe4" + // 0x00055 0x00323: 0x01EE4 + "\x00\x01\xd4\x00d`\x1e\xe5" + // 0x00075 0x00323: 0x01EE5 + "\x00\x01T\x00a \x1e\xe6" + // 0x00055 0x00309: 0x01EE6 + "\x00\x01\xd4\x00a \x1e\xe7" + // 0x00075 0x00309: 0x01EE7 + "\x00\x06\xbc\x00` \x1e\xe8" + // 0x001AF 0x00301: 0x01EE8 + "\x00\x06\xc0\x00` \x1e\xe9" + // 0x001B0 0x00301: 0x01EE9 + "\x00\x06\xbc\x00`\x00\x1e\xea" + // 0x001AF 0x00300: 0x01EEA + "\x00\x06\xc0\x00`\x00\x1e\xeb" + // 0x001B0 0x00300: 0x01EEB + "\x00\x06\xbc\x00a \x1e\xec" + // 0x001AF 0x00309: 0x01EEC + "\x00\x06\xc0\x00a \x1e\xed" + // 0x001B0 0x00309: 0x01EED + "\x00\x06\xbc\x00``\x1e\xee" + // 0x001AF 0x00303: 0x01EEE + "\x00\x06\xc0\x00``\x1e\xef" + // 0x001B0 0x00303: 0x01EEF + "\x00\x06\xbc\x00d`\x1e\xf0" + // 0x001AF 0x00323: 0x01EF0 + "\x00\x06\xc0\x00d`\x1e\xf1" + // 0x001B0 0x00323: 0x01EF1 + "\x00\x01d\x00`\x00\x1e\xf2" + // 0x00059 0x00300: 0x01EF2 + "\x00\x01\xe4\x00`\x00\x1e\xf3" + // 0x00079 0x00300: 0x01EF3 + "\x00\x01d\x00d`\x1e\xf4" + // 0x00059 0x00323: 0x01EF4 + "\x00\x01\xe4\x00d`\x1e\xf5" + // 0x00079 0x00323: 0x01EF5 + "\x00\x01d\x00a \x1e\xf6" + // 0x00059 0x00309: 0x01EF6 + "\x00\x01\xe4\x00a \x1e\xf7" + // 0x00079 0x00309: 0x01EF7 + "\x00\x01d\x00``\x1e\xf8" + // 0x00059 0x00303: 0x01EF8 + "\x00\x01\xe4\x00``\x1e\xf9" + // 0x00079 0x00303: 0x01EF9 + "\x00\x0e\xc4\x00b`\x1f\x00" + // 0x003B1 0x00313: 0x01F00 + "\x00\x0e\xc4\x00b\x80\x1f\x01" + // 0x003B1 0x00314: 0x01F01 + "\x00|\x00\x00`\x00\x1f\x02" + // 0x01F00 0x00300: 0x01F02 + "\x00|\x04\x00`\x00\x1f\x03" + // 0x01F01 0x00300: 0x01F03 + "\x00|\x00\x00` \x1f\x04" + // 0x01F00 0x00301: 0x01F04 + "\x00|\x04\x00` \x1f\x05" + // 0x01F01 0x00301: 0x01F05 + "\x00|\x00\x00h@\x1f\x06" + // 0x01F00 0x00342: 0x01F06 + "\x00|\x04\x00h@\x1f\a" + // 0x01F01 0x00342: 0x01F07 + "\x00\x0eD\x00b`\x1f\b" + // 0x00391 0x00313: 0x01F08 + "\x00\x0eD\x00b\x80\x1f\t" + // 0x00391 0x00314: 0x01F09 + "\x00| \x00`\x00\x1f\n" + // 0x01F08 0x00300: 0x01F0A + "\x00|$\x00`\x00\x1f\v" + // 0x01F09 0x00300: 0x01F0B + "\x00| \x00` \x1f\f" + // 0x01F08 0x00301: 0x01F0C + "\x00|$\x00` \x1f\r" + // 0x01F09 0x00301: 0x01F0D + "\x00| \x00h@\x1f\x0e" + // 0x01F08 0x00342: 0x01F0E + "\x00|$\x00h@\x1f\x0f" + // 0x01F09 0x00342: 0x01F0F + "\x00\x0e\xd4\x00b`\x1f\x10" + // 0x003B5 0x00313: 0x01F10 + "\x00\x0e\xd4\x00b\x80\x1f\x11" + // 0x003B5 0x00314: 0x01F11 + "\x00|@\x00`\x00\x1f\x12" + // 0x01F10 0x00300: 0x01F12 + "\x00|D\x00`\x00\x1f\x13" + // 0x01F11 0x00300: 0x01F13 + "\x00|@\x00` \x1f\x14" + // 0x01F10 0x00301: 0x01F14 + "\x00|D\x00` \x1f\x15" + // 0x01F11 0x00301: 0x01F15 + "\x00\x0eT\x00b`\x1f\x18" + // 0x00395 0x00313: 0x01F18 + "\x00\x0eT\x00b\x80\x1f\x19" + // 0x00395 0x00314: 0x01F19 + "\x00|`\x00`\x00\x1f\x1a" + // 0x01F18 0x00300: 0x01F1A + "\x00|d\x00`\x00\x1f\x1b" + // 0x01F19 0x00300: 0x01F1B + "\x00|`\x00` \x1f\x1c" + // 0x01F18 0x00301: 0x01F1C + "\x00|d\x00` \x1f\x1d" + // 0x01F19 0x00301: 0x01F1D + "\x00\x0e\xdc\x00b`\x1f " + // 0x003B7 0x00313: 0x01F20 + "\x00\x0e\xdc\x00b\x80\x1f!" + // 0x003B7 0x00314: 0x01F21 + "\x00|\x80\x00`\x00\x1f\"" + // 0x01F20 0x00300: 0x01F22 + "\x00|\x84\x00`\x00\x1f#" + // 0x01F21 0x00300: 0x01F23 + "\x00|\x80\x00` \x1f$" + // 0x01F20 0x00301: 0x01F24 + "\x00|\x84\x00` \x1f%" + // 0x01F21 0x00301: 0x01F25 + "\x00|\x80\x00h@\x1f&" + // 0x01F20 0x00342: 0x01F26 + "\x00|\x84\x00h@\x1f'" + // 0x01F21 0x00342: 0x01F27 + "\x00\x0e\\\x00b`\x1f(" + // 0x00397 0x00313: 0x01F28 + "\x00\x0e\\\x00b\x80\x1f)" + // 0x00397 0x00314: 0x01F29 + "\x00|\xa0\x00`\x00\x1f*" + // 0x01F28 0x00300: 0x01F2A + "\x00|\xa4\x00`\x00\x1f+" + // 0x01F29 0x00300: 0x01F2B + "\x00|\xa0\x00` \x1f," + // 0x01F28 0x00301: 0x01F2C + "\x00|\xa4\x00` \x1f-" + // 0x01F29 0x00301: 0x01F2D + "\x00|\xa0\x00h@\x1f." + // 0x01F28 0x00342: 0x01F2E + "\x00|\xa4\x00h@\x1f/" + // 0x01F29 0x00342: 0x01F2F + "\x00\x0e\xe4\x00b`\x1f0" + // 0x003B9 0x00313: 0x01F30 + "\x00\x0e\xe4\x00b\x80\x1f1" + // 0x003B9 0x00314: 0x01F31 + "\x00|\xc0\x00`\x00\x1f2" + // 0x01F30 0x00300: 0x01F32 + "\x00|\xc4\x00`\x00\x1f3" + // 0x01F31 0x00300: 0x01F33 + "\x00|\xc0\x00` \x1f4" + // 0x01F30 0x00301: 0x01F34 + "\x00|\xc4\x00` \x1f5" + // 0x01F31 0x00301: 0x01F35 + "\x00|\xc0\x00h@\x1f6" + // 0x01F30 0x00342: 0x01F36 + "\x00|\xc4\x00h@\x1f7" + // 0x01F31 0x00342: 0x01F37 + "\x00\x0ed\x00b`\x1f8" + // 0x00399 0x00313: 0x01F38 + "\x00\x0ed\x00b\x80\x1f9" + // 0x00399 0x00314: 0x01F39 + "\x00|\xe0\x00`\x00\x1f:" + // 0x01F38 0x00300: 0x01F3A + "\x00|\xe4\x00`\x00\x1f;" + // 0x01F39 0x00300: 0x01F3B + "\x00|\xe0\x00` \x1f<" + // 0x01F38 0x00301: 0x01F3C + "\x00|\xe4\x00` \x1f=" + // 0x01F39 0x00301: 0x01F3D + "\x00|\xe0\x00h@\x1f>" + // 0x01F38 0x00342: 0x01F3E + "\x00|\xe4\x00h@\x1f?" + // 0x01F39 0x00342: 0x01F3F + "\x00\x0e\xfc\x00b`\x1f@" + // 0x003BF 0x00313: 0x01F40 + "\x00\x0e\xfc\x00b\x80\x1fA" + // 0x003BF 0x00314: 0x01F41 + "\x00}\x00\x00`\x00\x1fB" + // 0x01F40 0x00300: 0x01F42 + "\x00}\x04\x00`\x00\x1fC" + // 0x01F41 0x00300: 0x01F43 + "\x00}\x00\x00` \x1fD" + // 0x01F40 0x00301: 0x01F44 + "\x00}\x04\x00` \x1fE" + // 0x01F41 0x00301: 0x01F45 + "\x00\x0e|\x00b`\x1fH" + // 0x0039F 0x00313: 0x01F48 + "\x00\x0e|\x00b\x80\x1fI" + // 0x0039F 0x00314: 0x01F49 + "\x00} \x00`\x00\x1fJ" + // 0x01F48 0x00300: 0x01F4A + "\x00}$\x00`\x00\x1fK" + // 0x01F49 0x00300: 0x01F4B + "\x00} \x00` \x1fL" + // 0x01F48 0x00301: 0x01F4C + "\x00}$\x00` \x1fM" + // 0x01F49 0x00301: 0x01F4D + "\x00\x0f\x14\x00b`\x1fP" + // 0x003C5 0x00313: 0x01F50 + "\x00\x0f\x14\x00b\x80\x1fQ" + // 0x003C5 0x00314: 0x01F51 + "\x00}@\x00`\x00\x1fR" + // 0x01F50 0x00300: 0x01F52 + "\x00}D\x00`\x00\x1fS" + // 0x01F51 0x00300: 0x01F53 + "\x00}@\x00` \x1fT" + // 0x01F50 0x00301: 0x01F54 + "\x00}D\x00` \x1fU" + // 0x01F51 0x00301: 0x01F55 + "\x00}@\x00h@\x1fV" + // 0x01F50 0x00342: 0x01F56 + "\x00}D\x00h@\x1fW" + // 0x01F51 0x00342: 0x01F57 + "\x00\x0e\x94\x00b\x80\x1fY" + // 0x003A5 0x00314: 0x01F59 + "\x00}d\x00`\x00\x1f[" + // 0x01F59 0x00300: 0x01F5B + "\x00}d\x00` \x1f]" + // 0x01F59 0x00301: 0x01F5D + "\x00}d\x00h@\x1f_" + // 0x01F59 0x00342: 0x01F5F + "\x00\x0f$\x00b`\x1f`" + // 0x003C9 0x00313: 0x01F60 + "\x00\x0f$\x00b\x80\x1fa" + // 0x003C9 0x00314: 0x01F61 + "\x00}\x80\x00`\x00\x1fb" + // 0x01F60 0x00300: 0x01F62 + "\x00}\x84\x00`\x00\x1fc" + // 0x01F61 0x00300: 0x01F63 + "\x00}\x80\x00` \x1fd" + // 0x01F60 0x00301: 0x01F64 + "\x00}\x84\x00` \x1fe" + // 0x01F61 0x00301: 0x01F65 + "\x00}\x80\x00h@\x1ff" + // 0x01F60 0x00342: 0x01F66 + "\x00}\x84\x00h@\x1fg" + // 0x01F61 0x00342: 0x01F67 + "\x00\x0e\xa4\x00b`\x1fh" + // 0x003A9 0x00313: 0x01F68 + "\x00\x0e\xa4\x00b\x80\x1fi" + // 0x003A9 0x00314: 0x01F69 + "\x00}\xa0\x00`\x00\x1fj" + // 0x01F68 0x00300: 0x01F6A + "\x00}\xa4\x00`\x00\x1fk" + // 0x01F69 0x00300: 0x01F6B + "\x00}\xa0\x00` \x1fl" + // 0x01F68 0x00301: 0x01F6C + "\x00}\xa4\x00` \x1fm" + // 0x01F69 0x00301: 0x01F6D + "\x00}\xa0\x00h@\x1fn" + // 0x01F68 0x00342: 0x01F6E + "\x00}\xa4\x00h@\x1fo" + // 0x01F69 0x00342: 0x01F6F + "\x00\x0e\xc4\x00`\x00\x1fp" + // 0x003B1 0x00300: 0x01F70 + "\x00\x0e\xd4\x00`\x00\x1fr" + // 0x003B5 0x00300: 0x01F72 + "\x00\x0e\xdc\x00`\x00\x1ft" + // 0x003B7 0x00300: 0x01F74 + "\x00\x0e\xe4\x00`\x00\x1fv" + // 0x003B9 0x00300: 0x01F76 + "\x00\x0e\xfc\x00`\x00\x1fx" + // 0x003BF 0x00300: 0x01F78 + "\x00\x0f\x14\x00`\x00\x1fz" + // 0x003C5 0x00300: 0x01F7A + "\x00\x0f$\x00`\x00\x1f|" + // 0x003C9 0x00300: 0x01F7C + "\x00|\x00\x00h\xa0\x1f\x80" + // 0x01F00 0x00345: 0x01F80 + "\x00|\x04\x00h\xa0\x1f\x81" + // 0x01F01 0x00345: 0x01F81 + "\x00|\b\x00h\xa0\x1f\x82" + // 0x01F02 0x00345: 0x01F82 + "\x00|\f\x00h\xa0\x1f\x83" + // 0x01F03 0x00345: 0x01F83 + "\x00|\x10\x00h\xa0\x1f\x84" + // 0x01F04 0x00345: 0x01F84 + "\x00|\x14\x00h\xa0\x1f\x85" + // 0x01F05 0x00345: 0x01F85 + "\x00|\x18\x00h\xa0\x1f\x86" + // 0x01F06 0x00345: 0x01F86 + "\x00|\x1c\x00h\xa0\x1f\x87" + // 0x01F07 0x00345: 0x01F87 + "\x00| \x00h\xa0\x1f\x88" + // 0x01F08 0x00345: 0x01F88 + "\x00|$\x00h\xa0\x1f\x89" + // 0x01F09 0x00345: 0x01F89 + "\x00|(\x00h\xa0\x1f\x8a" + // 0x01F0A 0x00345: 0x01F8A + "\x00|,\x00h\xa0\x1f\x8b" + // 0x01F0B 0x00345: 0x01F8B + "\x00|0\x00h\xa0\x1f\x8c" + // 0x01F0C 0x00345: 0x01F8C + "\x00|4\x00h\xa0\x1f\x8d" + // 0x01F0D 0x00345: 0x01F8D + "\x00|8\x00h\xa0\x1f\x8e" + // 0x01F0E 0x00345: 0x01F8E + "\x00|<\x00h\xa0\x1f\x8f" + // 0x01F0F 0x00345: 0x01F8F + "\x00|\x80\x00h\xa0\x1f\x90" + // 0x01F20 0x00345: 0x01F90 + "\x00|\x84\x00h\xa0\x1f\x91" + // 0x01F21 0x00345: 0x01F91 + "\x00|\x88\x00h\xa0\x1f\x92" + // 0x01F22 0x00345: 0x01F92 + "\x00|\x8c\x00h\xa0\x1f\x93" + // 0x01F23 0x00345: 0x01F93 + "\x00|\x90\x00h\xa0\x1f\x94" + // 0x01F24 0x00345: 0x01F94 + "\x00|\x94\x00h\xa0\x1f\x95" + // 0x01F25 0x00345: 0x01F95 + "\x00|\x98\x00h\xa0\x1f\x96" + // 0x01F26 0x00345: 0x01F96 + "\x00|\x9c\x00h\xa0\x1f\x97" + // 0x01F27 0x00345: 0x01F97 + "\x00|\xa0\x00h\xa0\x1f\x98" + // 0x01F28 0x00345: 0x01F98 + "\x00|\xa4\x00h\xa0\x1f\x99" + // 0x01F29 0x00345: 0x01F99 + "\x00|\xa8\x00h\xa0\x1f\x9a" + // 0x01F2A 0x00345: 0x01F9A + "\x00|\xac\x00h\xa0\x1f\x9b" + // 0x01F2B 0x00345: 0x01F9B + "\x00|\xb0\x00h\xa0\x1f\x9c" + // 0x01F2C 0x00345: 0x01F9C + "\x00|\xb4\x00h\xa0\x1f\x9d" + // 0x01F2D 0x00345: 0x01F9D + "\x00|\xb8\x00h\xa0\x1f\x9e" + // 0x01F2E 0x00345: 0x01F9E + "\x00|\xbc\x00h\xa0\x1f\x9f" + // 0x01F2F 0x00345: 0x01F9F + "\x00}\x80\x00h\xa0\x1f\xa0" + // 0x01F60 0x00345: 0x01FA0 + "\x00}\x84\x00h\xa0\x1f\xa1" + // 0x01F61 0x00345: 0x01FA1 + "\x00}\x88\x00h\xa0\x1f\xa2" + // 0x01F62 0x00345: 0x01FA2 + "\x00}\x8c\x00h\xa0\x1f\xa3" + // 0x01F63 0x00345: 0x01FA3 + "\x00}\x90\x00h\xa0\x1f\xa4" + // 0x01F64 0x00345: 0x01FA4 + "\x00}\x94\x00h\xa0\x1f\xa5" + // 0x01F65 0x00345: 0x01FA5 + "\x00}\x98\x00h\xa0\x1f\xa6" + // 0x01F66 0x00345: 0x01FA6 + "\x00}\x9c\x00h\xa0\x1f\xa7" + // 0x01F67 0x00345: 0x01FA7 + "\x00}\xa0\x00h\xa0\x1f\xa8" + // 0x01F68 0x00345: 0x01FA8 + "\x00}\xa4\x00h\xa0\x1f\xa9" + // 0x01F69 0x00345: 0x01FA9 + "\x00}\xa8\x00h\xa0\x1f\xaa" + // 0x01F6A 0x00345: 0x01FAA + "\x00}\xac\x00h\xa0\x1f\xab" + // 0x01F6B 0x00345: 0x01FAB + "\x00}\xb0\x00h\xa0\x1f\xac" + // 0x01F6C 0x00345: 0x01FAC + "\x00}\xb4\x00h\xa0\x1f\xad" + // 0x01F6D 0x00345: 0x01FAD + "\x00}\xb8\x00h\xa0\x1f\xae" + // 0x01F6E 0x00345: 0x01FAE + "\x00}\xbc\x00h\xa0\x1f\xaf" + // 0x01F6F 0x00345: 0x01FAF + "\x00\x0e\xc4\x00`\xc0\x1f\xb0" + // 0x003B1 0x00306: 0x01FB0 + "\x00\x0e\xc4\x00`\x80\x1f\xb1" + // 0x003B1 0x00304: 0x01FB1 + "\x00}\xc0\x00h\xa0\x1f\xb2" + // 0x01F70 0x00345: 0x01FB2 + "\x00\x0e\xc4\x00h\xa0\x1f\xb3" + // 0x003B1 0x00345: 0x01FB3 + "\x00\x0e\xb0\x00h\xa0\x1f\xb4" + // 0x003AC 0x00345: 0x01FB4 + "\x00\x0e\xc4\x00h@\x1f\xb6" + // 0x003B1 0x00342: 0x01FB6 + "\x00~\xd8\x00h\xa0\x1f\xb7" + // 0x01FB6 0x00345: 0x01FB7 + "\x00\x0eD\x00`\xc0\x1f\xb8" + // 0x00391 0x00306: 0x01FB8 + "\x00\x0eD\x00`\x80\x1f\xb9" + // 0x00391 0x00304: 0x01FB9 + "\x00\x0eD\x00`\x00\x1f\xba" + // 0x00391 0x00300: 0x01FBA + "\x00\x0eD\x00h\xa0\x1f\xbc" + // 0x00391 0x00345: 0x01FBC + "\x00\x02\xa0\x00h@\x1f\xc1" + // 0x000A8 0x00342: 0x01FC1 + "\x00}\xd0\x00h\xa0\x1f\xc2" + // 0x01F74 0x00345: 0x01FC2 + "\x00\x0e\xdc\x00h\xa0\x1f\xc3" + // 0x003B7 0x00345: 0x01FC3 + "\x00\x0e\xb8\x00h\xa0\x1f\xc4" + // 0x003AE 0x00345: 0x01FC4 + "\x00\x0e\xdc\x00h@\x1f\xc6" + // 0x003B7 0x00342: 0x01FC6 + "\x00\x7f\x18\x00h\xa0\x1f\xc7" + // 0x01FC6 0x00345: 0x01FC7 + "\x00\x0eT\x00`\x00\x1f\xc8" + // 0x00395 0x00300: 0x01FC8 + "\x00\x0e\\\x00`\x00\x1f\xca" + // 0x00397 0x00300: 0x01FCA + "\x00\x0e\\\x00h\xa0\x1f\xcc" + // 0x00397 0x00345: 0x01FCC + "\x00~\xfc\x00`\x00\x1f\xcd" + // 0x01FBF 0x00300: 0x01FCD + "\x00~\xfc\x00` \x1f\xce" + // 0x01FBF 0x00301: 0x01FCE + "\x00~\xfc\x00h@\x1f\xcf" + // 0x01FBF 0x00342: 0x01FCF + "\x00\x0e\xe4\x00`\xc0\x1f\xd0" + // 0x003B9 0x00306: 0x01FD0 + "\x00\x0e\xe4\x00`\x80\x1f\xd1" + // 0x003B9 0x00304: 0x01FD1 + "\x00\x0f(\x00`\x00\x1f\xd2" + // 0x003CA 0x00300: 0x01FD2 + "\x00\x0e\xe4\x00h@\x1f\xd6" + // 0x003B9 0x00342: 0x01FD6 + "\x00\x0f(\x00h@\x1f\xd7" + // 0x003CA 0x00342: 0x01FD7 + "\x00\x0ed\x00`\xc0\x1f\xd8" + // 0x00399 0x00306: 0x01FD8 + "\x00\x0ed\x00`\x80\x1f\xd9" + // 0x00399 0x00304: 0x01FD9 + "\x00\x0ed\x00`\x00\x1f\xda" + // 0x00399 0x00300: 0x01FDA + "\x00\x7f\xf8\x00`\x00\x1f\xdd" + // 0x01FFE 0x00300: 0x01FDD + "\x00\x7f\xf8\x00` \x1f\xde" + // 0x01FFE 0x00301: 0x01FDE + "\x00\x7f\xf8\x00h@\x1f\xdf" + // 0x01FFE 0x00342: 0x01FDF + "\x00\x0f\x14\x00`\xc0\x1f\xe0" + // 0x003C5 0x00306: 0x01FE0 + "\x00\x0f\x14\x00`\x80\x1f\xe1" + // 0x003C5 0x00304: 0x01FE1 + "\x00\x0f,\x00`\x00\x1f\xe2" + // 0x003CB 0x00300: 0x01FE2 + "\x00\x0f\x04\x00b`\x1f\xe4" + // 0x003C1 0x00313: 0x01FE4 + "\x00\x0f\x04\x00b\x80\x1f\xe5" + // 0x003C1 0x00314: 0x01FE5 + "\x00\x0f\x14\x00h@\x1f\xe6" + // 0x003C5 0x00342: 0x01FE6 + "\x00\x0f,\x00h@\x1f\xe7" + // 0x003CB 0x00342: 0x01FE7 + "\x00\x0e\x94\x00`\xc0\x1f\xe8" + // 0x003A5 0x00306: 0x01FE8 + "\x00\x0e\x94\x00`\x80\x1f\xe9" + // 0x003A5 0x00304: 0x01FE9 + "\x00\x0e\x94\x00`\x00\x1f\xea" + // 0x003A5 0x00300: 0x01FEA + "\x00\x0e\x84\x00b\x80\x1f\xec" + // 0x003A1 0x00314: 0x01FEC + "\x00\x02\xa0\x00`\x00\x1f\xed" + // 0x000A8 0x00300: 0x01FED + "\x00}\xf0\x00h\xa0\x1f\xf2" + // 0x01F7C 0x00345: 0x01FF2 + "\x00\x0f$\x00h\xa0\x1f\xf3" + // 0x003C9 0x00345: 0x01FF3 + "\x00\x0f8\x00h\xa0\x1f\xf4" + // 0x003CE 0x00345: 0x01FF4 + "\x00\x0f$\x00h@\x1f\xf6" + // 0x003C9 0x00342: 0x01FF6 + "\x00\x7f\xd8\x00h\xa0\x1f\xf7" + // 0x01FF6 0x00345: 0x01FF7 + "\x00\x0e|\x00`\x00\x1f\xf8" + // 0x0039F 0x00300: 0x01FF8 + "\x00\x0e\xa4\x00`\x00\x1f\xfa" + // 0x003A9 0x00300: 0x01FFA + "\x00\x0e\xa4\x00h\xa0\x1f\xfc" + // 0x003A9 0x00345: 0x01FFC + "\x00\x86@\x00g\x00!\x9a" + // 0x02190 0x00338: 0x0219A + "\x00\x86H\x00g\x00!\x9b" + // 0x02192 0x00338: 0x0219B + "\x00\x86P\x00g\x00!\xae" + // 0x02194 0x00338: 0x021AE + "\x00\x87@\x00g\x00!\xcd" + // 0x021D0 0x00338: 0x021CD + "\x00\x87P\x00g\x00!\xce" + // 0x021D4 0x00338: 0x021CE + "\x00\x87H\x00g\x00!\xcf" + // 0x021D2 0x00338: 0x021CF + "\x00\x88\f\x00g\x00\"\x04" + // 0x02203 0x00338: 0x02204 + "\x00\x88 \x00g\x00\"\t" + // 0x02208 0x00338: 0x02209 + "\x00\x88,\x00g\x00\"\f" + // 0x0220B 0x00338: 0x0220C + "\x00\x88\x8c\x00g\x00\"$" + // 0x02223 0x00338: 0x02224 + "\x00\x88\x94\x00g\x00\"&" + // 0x02225 0x00338: 0x02226 + "\x00\x88\xf0\x00g\x00\"A" + // 0x0223C 0x00338: 0x02241 + "\x00\x89\f\x00g\x00\"D" + // 0x02243 0x00338: 0x02244 + "\x00\x89\x14\x00g\x00\"G" + // 0x02245 0x00338: 0x02247 + "\x00\x89 \x00g\x00\"I" + // 0x02248 0x00338: 0x02249 + "\x00\x00\xf4\x00g\x00\"`" + // 0x0003D 0x00338: 0x02260 + "\x00\x89\x84\x00g\x00\"b" + // 0x02261 0x00338: 0x02262 + "\x00\x894\x00g\x00\"m" + // 0x0224D 0x00338: 0x0226D + "\x00\x00\xf0\x00g\x00\"n" + // 0x0003C 0x00338: 0x0226E + "\x00\x00\xf8\x00g\x00\"o" + // 0x0003E 0x00338: 0x0226F + "\x00\x89\x90\x00g\x00\"p" + // 0x02264 0x00338: 0x02270 + "\x00\x89\x94\x00g\x00\"q" + // 0x02265 0x00338: 0x02271 + "\x00\x89\xc8\x00g\x00\"t" + // 0x02272 0x00338: 0x02274 + "\x00\x89\xcc\x00g\x00\"u" + // 0x02273 0x00338: 0x02275 + "\x00\x89\xd8\x00g\x00\"x" + // 0x02276 0x00338: 0x02278 + "\x00\x89\xdc\x00g\x00\"y" + // 0x02277 0x00338: 0x02279 + "\x00\x89\xe8\x00g\x00\"\x80" + // 0x0227A 0x00338: 0x02280 + "\x00\x89\xec\x00g\x00\"\x81" + // 0x0227B 0x00338: 0x02281 + "\x00\x8a\b\x00g\x00\"\x84" + // 0x02282 0x00338: 0x02284 + "\x00\x8a\f\x00g\x00\"\x85" + // 0x02283 0x00338: 0x02285 + "\x00\x8a\x18\x00g\x00\"\x88" + // 0x02286 0x00338: 0x02288 + "\x00\x8a\x1c\x00g\x00\"\x89" + // 0x02287 0x00338: 0x02289 + "\x00\x8a\x88\x00g\x00\"\xac" + // 0x022A2 0x00338: 0x022AC + "\x00\x8a\xa0\x00g\x00\"\xad" + // 0x022A8 0x00338: 0x022AD + "\x00\x8a\xa4\x00g\x00\"\xae" + // 0x022A9 0x00338: 0x022AE + "\x00\x8a\xac\x00g\x00\"\xaf" + // 0x022AB 0x00338: 0x022AF + "\x00\x89\xf0\x00g\x00\"\xe0" + // 0x0227C 0x00338: 0x022E0 + "\x00\x89\xf4\x00g\x00\"\xe1" + // 0x0227D 0x00338: 0x022E1 + "\x00\x8aD\x00g\x00\"\xe2" + // 0x02291 0x00338: 0x022E2 + "\x00\x8aH\x00g\x00\"\xe3" + // 0x02292 0x00338: 0x022E3 + "\x00\x8a\xc8\x00g\x00\"\xea" + // 0x022B2 0x00338: 0x022EA + "\x00\x8a\xcc\x00g\x00\"\xeb" + // 0x022B3 0x00338: 0x022EB + "\x00\x8a\xd0\x00g\x00\"\xec" + // 0x022B4 0x00338: 0x022EC + "\x00\x8a\xd4\x00g\x00\"\xed" + // 0x022B5 0x00338: 0x022ED + "\x00\xc1,\x06\x13 0L" + // 0x0304B 0x03099: 0x0304C + "\x00\xc14\x06\x13 0N" + // 0x0304D 0x03099: 0x0304E + "\x00\xc1<\x06\x13 0P" + // 0x0304F 0x03099: 0x03050 + "\x00\xc1D\x06\x13 0R" + // 0x03051 0x03099: 0x03052 + "\x00\xc1L\x06\x13 0T" + // 0x03053 0x03099: 0x03054 + "\x00\xc1T\x06\x13 0V" + // 0x03055 0x03099: 0x03056 + "\x00\xc1\\\x06\x13 0X" + // 0x03057 0x03099: 0x03058 + "\x00\xc1d\x06\x13 0Z" + // 0x03059 0x03099: 0x0305A + "\x00\xc1l\x06\x13 0\\" + // 0x0305B 0x03099: 0x0305C + "\x00\xc1t\x06\x13 0^" + // 0x0305D 0x03099: 0x0305E + "\x00\xc1|\x06\x13 0`" + // 0x0305F 0x03099: 0x03060 + "\x00\xc1\x84\x06\x13 0b" + // 0x03061 0x03099: 0x03062 + "\x00\xc1\x90\x06\x13 0e" + // 0x03064 0x03099: 0x03065 + "\x00\xc1\x98\x06\x13 0g" + // 0x03066 0x03099: 0x03067 + "\x00\xc1\xa0\x06\x13 0i" + // 0x03068 0x03099: 0x03069 + "\x00\xc1\xbc\x06\x13 0p" + // 0x0306F 0x03099: 0x03070 + "\x00\xc1\xbc\x06\x13@0q" + // 0x0306F 0x0309A: 0x03071 + "\x00\xc1\xc8\x06\x13 0s" + // 0x03072 0x03099: 0x03073 + "\x00\xc1\xc8\x06\x13@0t" + // 0x03072 0x0309A: 0x03074 + "\x00\xc1\xd4\x06\x13 0v" + // 0x03075 0x03099: 0x03076 + "\x00\xc1\xd4\x06\x13@0w" + // 0x03075 0x0309A: 0x03077 + "\x00\xc1\xe0\x06\x13 0y" + // 0x03078 0x03099: 0x03079 + "\x00\xc1\xe0\x06\x13@0z" + // 0x03078 0x0309A: 0x0307A + "\x00\xc1\xec\x06\x13 0|" + // 0x0307B 0x03099: 0x0307C + "\x00\xc1\xec\x06\x13@0}" + // 0x0307B 0x0309A: 0x0307D + "\x00\xc1\x18\x06\x13 0\x94" + // 0x03046 0x03099: 0x03094 + "\x00\xc2t\x06\x13 0\x9e" + // 0x0309D 0x03099: 0x0309E + "\x00¬\x06\x13 0\xac" + // 0x030AB 0x03099: 0x030AC + "\x00´\x06\x13 0\xae" + // 0x030AD 0x03099: 0x030AE + "\x00¼\x06\x13 0\xb0" + // 0x030AF 0x03099: 0x030B0 + "\x00\xc2\xc4\x06\x13 0\xb2" + // 0x030B1 0x03099: 0x030B2 + "\x00\xc2\xcc\x06\x13 0\xb4" + // 0x030B3 0x03099: 0x030B4 + "\x00\xc2\xd4\x06\x13 0\xb6" + // 0x030B5 0x03099: 0x030B6 + "\x00\xc2\xdc\x06\x13 0\xb8" + // 0x030B7 0x03099: 0x030B8 + "\x00\xc2\xe4\x06\x13 0\xba" + // 0x030B9 0x03099: 0x030BA + "\x00\xc2\xec\x06\x13 0\xbc" + // 0x030BB 0x03099: 0x030BC + "\x00\xc2\xf4\x06\x13 0\xbe" + // 0x030BD 0x03099: 0x030BE + "\x00\xc2\xfc\x06\x13 0\xc0" + // 0x030BF 0x03099: 0x030C0 + "\x00\xc3\x04\x06\x13 0\xc2" + // 0x030C1 0x03099: 0x030C2 + "\x00\xc3\x10\x06\x13 0\xc5" + // 0x030C4 0x03099: 0x030C5 + "\x00\xc3\x18\x06\x13 0\xc7" + // 0x030C6 0x03099: 0x030C7 + "\x00\xc3 \x06\x13 0\xc9" + // 0x030C8 0x03099: 0x030C9 + "\x00\xc3<\x06\x13 0\xd0" + // 0x030CF 0x03099: 0x030D0 + "\x00\xc3<\x06\x13@0\xd1" + // 0x030CF 0x0309A: 0x030D1 + "\x00\xc3H\x06\x13 0\xd3" + // 0x030D2 0x03099: 0x030D3 + "\x00\xc3H\x06\x13@0\xd4" + // 0x030D2 0x0309A: 0x030D4 + "\x00\xc3T\x06\x13 0\xd6" + // 0x030D5 0x03099: 0x030D6 + "\x00\xc3T\x06\x13@0\xd7" + // 0x030D5 0x0309A: 0x030D7 + "\x00\xc3`\x06\x13 0\xd9" + // 0x030D8 0x03099: 0x030D9 + "\x00\xc3`\x06\x13@0\xda" + // 0x030D8 0x0309A: 0x030DA + "\x00\xc3l\x06\x13 0\xdc" + // 0x030DB 0x03099: 0x030DC + "\x00\xc3l\x06\x13@0\xdd" + // 0x030DB 0x0309A: 0x030DD + "\x00\u0098\x06\x13 0\xf4" + // 0x030A6 0x03099: 0x030F4 + "\x00ü\x06\x13 0\xf7" + // 0x030EF 0x03099: 0x030F7 + "\x00\xc3\xc0\x06\x13 0\xf8" + // 0x030F0 0x03099: 0x030F8 + "\x00\xc3\xc4\x06\x13 0\xf9" + // 0x030F1 0x03099: 0x030F9 + "\x00\xc3\xc8\x06\x13 0\xfa" + // 0x030F2 0x03099: 0x030FA + "\x00\xc3\xf4\x06\x13 0\xfe" + // 0x030FD 0x03099: 0x030FE + "\x04Bd\"\x17A\x10\x9a" + // 0x11099 0x110BA: 0x1109A + "\x04Bl\"\x17A\x10\x9c" + // 0x1109B 0x110BA: 0x1109C + "\x04B\x94\"\x17A\x10\xab" + // 0x110A5 0x110BA: 0x110AB + "\x04D\xc4\"$\xe1\x11." + // 0x11131 0x11127: 0x1112E + "\x04D\xc8\"$\xe1\x11/" + // 0x11132 0x11127: 0x1112F + "\x04M\x1c\"g\xc1\x13K" + // 0x11347 0x1133E: 0x1134B + "\x04M\x1c\"j\xe1\x13L" + // 0x11347 0x11357: 0x1134C + "\x04R\xe4\"\x97A\x14\xbb" + // 0x114B9 0x114BA: 0x114BB + "\x04R\xe4\"\x96\x01\x14\xbc" + // 0x114B9 0x114B0: 0x114BC + "\x04R\xe4\"\x97\xa1\x14\xbe" + // 0x114B9 0x114BD: 0x114BE + "\x04V\xe0\"\xb5\xe1\x15\xba" + // 0x115B8 0x115AF: 0x115BA + "\x04V\xe4\"\xb5\xe1\x15\xbb" + // 0x115B9 0x115AF: 0x115BB + "\x04d\xd4#&\x01\x198" + // 0x11935 0x11930: 0x11938 "" // Total size of tables: 56KB (57068 bytes) diff --git a/vendor/golang.org/x/text/unicode/norm/tables17.0.0.go b/vendor/golang.org/x/text/unicode/norm/tables17.0.0.go index dfd555fc..c3231891 100644 --- a/vendor/golang.org/x/text/unicode/norm/tables17.0.0.go +++ b/vendor/golang.org/x/text/unicode/norm/tables17.0.0.go @@ -7135,970 +7135,970 @@ var nfkcSparseValues = [980]valueRange{ } // recompMap: 7688 bytes (entries only) -var recompMap map[uint32]rune +var recompMap map[uint64]rune var recompMapOnce sync.Once const recompMapPacked = "" + - "\x00A\x03\x00\x00\x00\x00\xc0" + // 0x00410300: 0x000000C0 - "\x00A\x03\x01\x00\x00\x00\xc1" + // 0x00410301: 0x000000C1 - "\x00A\x03\x02\x00\x00\x00\xc2" + // 0x00410302: 0x000000C2 - "\x00A\x03\x03\x00\x00\x00\xc3" + // 0x00410303: 0x000000C3 - "\x00A\x03\b\x00\x00\x00\xc4" + // 0x00410308: 0x000000C4 - "\x00A\x03\n\x00\x00\x00\xc5" + // 0x0041030A: 0x000000C5 - "\x00C\x03'\x00\x00\x00\xc7" + // 0x00430327: 0x000000C7 - "\x00E\x03\x00\x00\x00\x00\xc8" + // 0x00450300: 0x000000C8 - "\x00E\x03\x01\x00\x00\x00\xc9" + // 0x00450301: 0x000000C9 - "\x00E\x03\x02\x00\x00\x00\xca" + // 0x00450302: 0x000000CA - "\x00E\x03\b\x00\x00\x00\xcb" + // 0x00450308: 0x000000CB - "\x00I\x03\x00\x00\x00\x00\xcc" + // 0x00490300: 0x000000CC - "\x00I\x03\x01\x00\x00\x00\xcd" + // 0x00490301: 0x000000CD - "\x00I\x03\x02\x00\x00\x00\xce" + // 0x00490302: 0x000000CE - "\x00I\x03\b\x00\x00\x00\xcf" + // 0x00490308: 0x000000CF - "\x00N\x03\x03\x00\x00\x00\xd1" + // 0x004E0303: 0x000000D1 - "\x00O\x03\x00\x00\x00\x00\xd2" + // 0x004F0300: 0x000000D2 - "\x00O\x03\x01\x00\x00\x00\xd3" + // 0x004F0301: 0x000000D3 - "\x00O\x03\x02\x00\x00\x00\xd4" + // 0x004F0302: 0x000000D4 - "\x00O\x03\x03\x00\x00\x00\xd5" + // 0x004F0303: 0x000000D5 - "\x00O\x03\b\x00\x00\x00\xd6" + // 0x004F0308: 0x000000D6 - "\x00U\x03\x00\x00\x00\x00\xd9" + // 0x00550300: 0x000000D9 - "\x00U\x03\x01\x00\x00\x00\xda" + // 0x00550301: 0x000000DA - "\x00U\x03\x02\x00\x00\x00\xdb" + // 0x00550302: 0x000000DB - "\x00U\x03\b\x00\x00\x00\xdc" + // 0x00550308: 0x000000DC - "\x00Y\x03\x01\x00\x00\x00\xdd" + // 0x00590301: 0x000000DD - "\x00a\x03\x00\x00\x00\x00\xe0" + // 0x00610300: 0x000000E0 - "\x00a\x03\x01\x00\x00\x00\xe1" + // 0x00610301: 0x000000E1 - "\x00a\x03\x02\x00\x00\x00\xe2" + // 0x00610302: 0x000000E2 - "\x00a\x03\x03\x00\x00\x00\xe3" + // 0x00610303: 0x000000E3 - "\x00a\x03\b\x00\x00\x00\xe4" + // 0x00610308: 0x000000E4 - "\x00a\x03\n\x00\x00\x00\xe5" + // 0x0061030A: 0x000000E5 - "\x00c\x03'\x00\x00\x00\xe7" + // 0x00630327: 0x000000E7 - "\x00e\x03\x00\x00\x00\x00\xe8" + // 0x00650300: 0x000000E8 - "\x00e\x03\x01\x00\x00\x00\xe9" + // 0x00650301: 0x000000E9 - "\x00e\x03\x02\x00\x00\x00\xea" + // 0x00650302: 0x000000EA - "\x00e\x03\b\x00\x00\x00\xeb" + // 0x00650308: 0x000000EB - "\x00i\x03\x00\x00\x00\x00\xec" + // 0x00690300: 0x000000EC - "\x00i\x03\x01\x00\x00\x00\xed" + // 0x00690301: 0x000000ED - "\x00i\x03\x02\x00\x00\x00\xee" + // 0x00690302: 0x000000EE - "\x00i\x03\b\x00\x00\x00\xef" + // 0x00690308: 0x000000EF - "\x00n\x03\x03\x00\x00\x00\xf1" + // 0x006E0303: 0x000000F1 - "\x00o\x03\x00\x00\x00\x00\xf2" + // 0x006F0300: 0x000000F2 - "\x00o\x03\x01\x00\x00\x00\xf3" + // 0x006F0301: 0x000000F3 - "\x00o\x03\x02\x00\x00\x00\xf4" + // 0x006F0302: 0x000000F4 - "\x00o\x03\x03\x00\x00\x00\xf5" + // 0x006F0303: 0x000000F5 - "\x00o\x03\b\x00\x00\x00\xf6" + // 0x006F0308: 0x000000F6 - "\x00u\x03\x00\x00\x00\x00\xf9" + // 0x00750300: 0x000000F9 - "\x00u\x03\x01\x00\x00\x00\xfa" + // 0x00750301: 0x000000FA - "\x00u\x03\x02\x00\x00\x00\xfb" + // 0x00750302: 0x000000FB - "\x00u\x03\b\x00\x00\x00\xfc" + // 0x00750308: 0x000000FC - "\x00y\x03\x01\x00\x00\x00\xfd" + // 0x00790301: 0x000000FD - "\x00y\x03\b\x00\x00\x00\xff" + // 0x00790308: 0x000000FF - "\x00A\x03\x04\x00\x00\x01\x00" + // 0x00410304: 0x00000100 - "\x00a\x03\x04\x00\x00\x01\x01" + // 0x00610304: 0x00000101 - "\x00A\x03\x06\x00\x00\x01\x02" + // 0x00410306: 0x00000102 - "\x00a\x03\x06\x00\x00\x01\x03" + // 0x00610306: 0x00000103 - "\x00A\x03(\x00\x00\x01\x04" + // 0x00410328: 0x00000104 - "\x00a\x03(\x00\x00\x01\x05" + // 0x00610328: 0x00000105 - "\x00C\x03\x01\x00\x00\x01\x06" + // 0x00430301: 0x00000106 - "\x00c\x03\x01\x00\x00\x01\a" + // 0x00630301: 0x00000107 - "\x00C\x03\x02\x00\x00\x01\b" + // 0x00430302: 0x00000108 - "\x00c\x03\x02\x00\x00\x01\t" + // 0x00630302: 0x00000109 - "\x00C\x03\a\x00\x00\x01\n" + // 0x00430307: 0x0000010A - "\x00c\x03\a\x00\x00\x01\v" + // 0x00630307: 0x0000010B - "\x00C\x03\f\x00\x00\x01\f" + // 0x0043030C: 0x0000010C - "\x00c\x03\f\x00\x00\x01\r" + // 0x0063030C: 0x0000010D - "\x00D\x03\f\x00\x00\x01\x0e" + // 0x0044030C: 0x0000010E - "\x00d\x03\f\x00\x00\x01\x0f" + // 0x0064030C: 0x0000010F - "\x00E\x03\x04\x00\x00\x01\x12" + // 0x00450304: 0x00000112 - "\x00e\x03\x04\x00\x00\x01\x13" + // 0x00650304: 0x00000113 - "\x00E\x03\x06\x00\x00\x01\x14" + // 0x00450306: 0x00000114 - "\x00e\x03\x06\x00\x00\x01\x15" + // 0x00650306: 0x00000115 - "\x00E\x03\a\x00\x00\x01\x16" + // 0x00450307: 0x00000116 - "\x00e\x03\a\x00\x00\x01\x17" + // 0x00650307: 0x00000117 - "\x00E\x03(\x00\x00\x01\x18" + // 0x00450328: 0x00000118 - "\x00e\x03(\x00\x00\x01\x19" + // 0x00650328: 0x00000119 - "\x00E\x03\f\x00\x00\x01\x1a" + // 0x0045030C: 0x0000011A - "\x00e\x03\f\x00\x00\x01\x1b" + // 0x0065030C: 0x0000011B - "\x00G\x03\x02\x00\x00\x01\x1c" + // 0x00470302: 0x0000011C - "\x00g\x03\x02\x00\x00\x01\x1d" + // 0x00670302: 0x0000011D - "\x00G\x03\x06\x00\x00\x01\x1e" + // 0x00470306: 0x0000011E - "\x00g\x03\x06\x00\x00\x01\x1f" + // 0x00670306: 0x0000011F - "\x00G\x03\a\x00\x00\x01 " + // 0x00470307: 0x00000120 - "\x00g\x03\a\x00\x00\x01!" + // 0x00670307: 0x00000121 - "\x00G\x03'\x00\x00\x01\"" + // 0x00470327: 0x00000122 - "\x00g\x03'\x00\x00\x01#" + // 0x00670327: 0x00000123 - "\x00H\x03\x02\x00\x00\x01$" + // 0x00480302: 0x00000124 - "\x00h\x03\x02\x00\x00\x01%" + // 0x00680302: 0x00000125 - "\x00I\x03\x03\x00\x00\x01(" + // 0x00490303: 0x00000128 - "\x00i\x03\x03\x00\x00\x01)" + // 0x00690303: 0x00000129 - "\x00I\x03\x04\x00\x00\x01*" + // 0x00490304: 0x0000012A - "\x00i\x03\x04\x00\x00\x01+" + // 0x00690304: 0x0000012B - "\x00I\x03\x06\x00\x00\x01," + // 0x00490306: 0x0000012C - "\x00i\x03\x06\x00\x00\x01-" + // 0x00690306: 0x0000012D - "\x00I\x03(\x00\x00\x01." + // 0x00490328: 0x0000012E - "\x00i\x03(\x00\x00\x01/" + // 0x00690328: 0x0000012F - "\x00I\x03\a\x00\x00\x010" + // 0x00490307: 0x00000130 - "\x00J\x03\x02\x00\x00\x014" + // 0x004A0302: 0x00000134 - "\x00j\x03\x02\x00\x00\x015" + // 0x006A0302: 0x00000135 - "\x00K\x03'\x00\x00\x016" + // 0x004B0327: 0x00000136 - "\x00k\x03'\x00\x00\x017" + // 0x006B0327: 0x00000137 - "\x00L\x03\x01\x00\x00\x019" + // 0x004C0301: 0x00000139 - "\x00l\x03\x01\x00\x00\x01:" + // 0x006C0301: 0x0000013A - "\x00L\x03'\x00\x00\x01;" + // 0x004C0327: 0x0000013B - "\x00l\x03'\x00\x00\x01<" + // 0x006C0327: 0x0000013C - "\x00L\x03\f\x00\x00\x01=" + // 0x004C030C: 0x0000013D - "\x00l\x03\f\x00\x00\x01>" + // 0x006C030C: 0x0000013E - "\x00N\x03\x01\x00\x00\x01C" + // 0x004E0301: 0x00000143 - "\x00n\x03\x01\x00\x00\x01D" + // 0x006E0301: 0x00000144 - "\x00N\x03'\x00\x00\x01E" + // 0x004E0327: 0x00000145 - "\x00n\x03'\x00\x00\x01F" + // 0x006E0327: 0x00000146 - "\x00N\x03\f\x00\x00\x01G" + // 0x004E030C: 0x00000147 - "\x00n\x03\f\x00\x00\x01H" + // 0x006E030C: 0x00000148 - "\x00O\x03\x04\x00\x00\x01L" + // 0x004F0304: 0x0000014C - "\x00o\x03\x04\x00\x00\x01M" + // 0x006F0304: 0x0000014D - "\x00O\x03\x06\x00\x00\x01N" + // 0x004F0306: 0x0000014E - "\x00o\x03\x06\x00\x00\x01O" + // 0x006F0306: 0x0000014F - "\x00O\x03\v\x00\x00\x01P" + // 0x004F030B: 0x00000150 - "\x00o\x03\v\x00\x00\x01Q" + // 0x006F030B: 0x00000151 - "\x00R\x03\x01\x00\x00\x01T" + // 0x00520301: 0x00000154 - "\x00r\x03\x01\x00\x00\x01U" + // 0x00720301: 0x00000155 - "\x00R\x03'\x00\x00\x01V" + // 0x00520327: 0x00000156 - "\x00r\x03'\x00\x00\x01W" + // 0x00720327: 0x00000157 - "\x00R\x03\f\x00\x00\x01X" + // 0x0052030C: 0x00000158 - "\x00r\x03\f\x00\x00\x01Y" + // 0x0072030C: 0x00000159 - "\x00S\x03\x01\x00\x00\x01Z" + // 0x00530301: 0x0000015A - "\x00s\x03\x01\x00\x00\x01[" + // 0x00730301: 0x0000015B - "\x00S\x03\x02\x00\x00\x01\\" + // 0x00530302: 0x0000015C - "\x00s\x03\x02\x00\x00\x01]" + // 0x00730302: 0x0000015D - "\x00S\x03'\x00\x00\x01^" + // 0x00530327: 0x0000015E - "\x00s\x03'\x00\x00\x01_" + // 0x00730327: 0x0000015F - "\x00S\x03\f\x00\x00\x01`" + // 0x0053030C: 0x00000160 - "\x00s\x03\f\x00\x00\x01a" + // 0x0073030C: 0x00000161 - "\x00T\x03'\x00\x00\x01b" + // 0x00540327: 0x00000162 - "\x00t\x03'\x00\x00\x01c" + // 0x00740327: 0x00000163 - "\x00T\x03\f\x00\x00\x01d" + // 0x0054030C: 0x00000164 - "\x00t\x03\f\x00\x00\x01e" + // 0x0074030C: 0x00000165 - "\x00U\x03\x03\x00\x00\x01h" + // 0x00550303: 0x00000168 - "\x00u\x03\x03\x00\x00\x01i" + // 0x00750303: 0x00000169 - "\x00U\x03\x04\x00\x00\x01j" + // 0x00550304: 0x0000016A - "\x00u\x03\x04\x00\x00\x01k" + // 0x00750304: 0x0000016B - "\x00U\x03\x06\x00\x00\x01l" + // 0x00550306: 0x0000016C - "\x00u\x03\x06\x00\x00\x01m" + // 0x00750306: 0x0000016D - "\x00U\x03\n\x00\x00\x01n" + // 0x0055030A: 0x0000016E - "\x00u\x03\n\x00\x00\x01o" + // 0x0075030A: 0x0000016F - "\x00U\x03\v\x00\x00\x01p" + // 0x0055030B: 0x00000170 - "\x00u\x03\v\x00\x00\x01q" + // 0x0075030B: 0x00000171 - "\x00U\x03(\x00\x00\x01r" + // 0x00550328: 0x00000172 - "\x00u\x03(\x00\x00\x01s" + // 0x00750328: 0x00000173 - "\x00W\x03\x02\x00\x00\x01t" + // 0x00570302: 0x00000174 - "\x00w\x03\x02\x00\x00\x01u" + // 0x00770302: 0x00000175 - "\x00Y\x03\x02\x00\x00\x01v" + // 0x00590302: 0x00000176 - "\x00y\x03\x02\x00\x00\x01w" + // 0x00790302: 0x00000177 - "\x00Y\x03\b\x00\x00\x01x" + // 0x00590308: 0x00000178 - "\x00Z\x03\x01\x00\x00\x01y" + // 0x005A0301: 0x00000179 - "\x00z\x03\x01\x00\x00\x01z" + // 0x007A0301: 0x0000017A - "\x00Z\x03\a\x00\x00\x01{" + // 0x005A0307: 0x0000017B - "\x00z\x03\a\x00\x00\x01|" + // 0x007A0307: 0x0000017C - "\x00Z\x03\f\x00\x00\x01}" + // 0x005A030C: 0x0000017D - "\x00z\x03\f\x00\x00\x01~" + // 0x007A030C: 0x0000017E - "\x00O\x03\x1b\x00\x00\x01\xa0" + // 0x004F031B: 0x000001A0 - "\x00o\x03\x1b\x00\x00\x01\xa1" + // 0x006F031B: 0x000001A1 - "\x00U\x03\x1b\x00\x00\x01\xaf" + // 0x0055031B: 0x000001AF - "\x00u\x03\x1b\x00\x00\x01\xb0" + // 0x0075031B: 0x000001B0 - "\x00A\x03\f\x00\x00\x01\xcd" + // 0x0041030C: 0x000001CD - "\x00a\x03\f\x00\x00\x01\xce" + // 0x0061030C: 0x000001CE - "\x00I\x03\f\x00\x00\x01\xcf" + // 0x0049030C: 0x000001CF - "\x00i\x03\f\x00\x00\x01\xd0" + // 0x0069030C: 0x000001D0 - "\x00O\x03\f\x00\x00\x01\xd1" + // 0x004F030C: 0x000001D1 - "\x00o\x03\f\x00\x00\x01\xd2" + // 0x006F030C: 0x000001D2 - "\x00U\x03\f\x00\x00\x01\xd3" + // 0x0055030C: 0x000001D3 - "\x00u\x03\f\x00\x00\x01\xd4" + // 0x0075030C: 0x000001D4 - "\x00\xdc\x03\x04\x00\x00\x01\xd5" + // 0x00DC0304: 0x000001D5 - "\x00\xfc\x03\x04\x00\x00\x01\xd6" + // 0x00FC0304: 0x000001D6 - "\x00\xdc\x03\x01\x00\x00\x01\xd7" + // 0x00DC0301: 0x000001D7 - "\x00\xfc\x03\x01\x00\x00\x01\xd8" + // 0x00FC0301: 0x000001D8 - "\x00\xdc\x03\f\x00\x00\x01\xd9" + // 0x00DC030C: 0x000001D9 - "\x00\xfc\x03\f\x00\x00\x01\xda" + // 0x00FC030C: 0x000001DA - "\x00\xdc\x03\x00\x00\x00\x01\xdb" + // 0x00DC0300: 0x000001DB - "\x00\xfc\x03\x00\x00\x00\x01\xdc" + // 0x00FC0300: 0x000001DC - "\x00\xc4\x03\x04\x00\x00\x01\xde" + // 0x00C40304: 0x000001DE - "\x00\xe4\x03\x04\x00\x00\x01\xdf" + // 0x00E40304: 0x000001DF - "\x02&\x03\x04\x00\x00\x01\xe0" + // 0x02260304: 0x000001E0 - "\x02'\x03\x04\x00\x00\x01\xe1" + // 0x02270304: 0x000001E1 - "\x00\xc6\x03\x04\x00\x00\x01\xe2" + // 0x00C60304: 0x000001E2 - "\x00\xe6\x03\x04\x00\x00\x01\xe3" + // 0x00E60304: 0x000001E3 - "\x00G\x03\f\x00\x00\x01\xe6" + // 0x0047030C: 0x000001E6 - "\x00g\x03\f\x00\x00\x01\xe7" + // 0x0067030C: 0x000001E7 - "\x00K\x03\f\x00\x00\x01\xe8" + // 0x004B030C: 0x000001E8 - "\x00k\x03\f\x00\x00\x01\xe9" + // 0x006B030C: 0x000001E9 - "\x00O\x03(\x00\x00\x01\xea" + // 0x004F0328: 0x000001EA - "\x00o\x03(\x00\x00\x01\xeb" + // 0x006F0328: 0x000001EB - "\x01\xea\x03\x04\x00\x00\x01\xec" + // 0x01EA0304: 0x000001EC - "\x01\xeb\x03\x04\x00\x00\x01\xed" + // 0x01EB0304: 0x000001ED - "\x01\xb7\x03\f\x00\x00\x01\xee" + // 0x01B7030C: 0x000001EE - "\x02\x92\x03\f\x00\x00\x01\xef" + // 0x0292030C: 0x000001EF - "\x00j\x03\f\x00\x00\x01\xf0" + // 0x006A030C: 0x000001F0 - "\x00G\x03\x01\x00\x00\x01\xf4" + // 0x00470301: 0x000001F4 - "\x00g\x03\x01\x00\x00\x01\xf5" + // 0x00670301: 0x000001F5 - "\x00N\x03\x00\x00\x00\x01\xf8" + // 0x004E0300: 0x000001F8 - "\x00n\x03\x00\x00\x00\x01\xf9" + // 0x006E0300: 0x000001F9 - "\x00\xc5\x03\x01\x00\x00\x01\xfa" + // 0x00C50301: 0x000001FA - "\x00\xe5\x03\x01\x00\x00\x01\xfb" + // 0x00E50301: 0x000001FB - "\x00\xc6\x03\x01\x00\x00\x01\xfc" + // 0x00C60301: 0x000001FC - "\x00\xe6\x03\x01\x00\x00\x01\xfd" + // 0x00E60301: 0x000001FD - "\x00\xd8\x03\x01\x00\x00\x01\xfe" + // 0x00D80301: 0x000001FE - "\x00\xf8\x03\x01\x00\x00\x01\xff" + // 0x00F80301: 0x000001FF - "\x00A\x03\x0f\x00\x00\x02\x00" + // 0x0041030F: 0x00000200 - "\x00a\x03\x0f\x00\x00\x02\x01" + // 0x0061030F: 0x00000201 - "\x00A\x03\x11\x00\x00\x02\x02" + // 0x00410311: 0x00000202 - "\x00a\x03\x11\x00\x00\x02\x03" + // 0x00610311: 0x00000203 - "\x00E\x03\x0f\x00\x00\x02\x04" + // 0x0045030F: 0x00000204 - "\x00e\x03\x0f\x00\x00\x02\x05" + // 0x0065030F: 0x00000205 - "\x00E\x03\x11\x00\x00\x02\x06" + // 0x00450311: 0x00000206 - "\x00e\x03\x11\x00\x00\x02\a" + // 0x00650311: 0x00000207 - "\x00I\x03\x0f\x00\x00\x02\b" + // 0x0049030F: 0x00000208 - "\x00i\x03\x0f\x00\x00\x02\t" + // 0x0069030F: 0x00000209 - "\x00I\x03\x11\x00\x00\x02\n" + // 0x00490311: 0x0000020A - "\x00i\x03\x11\x00\x00\x02\v" + // 0x00690311: 0x0000020B - "\x00O\x03\x0f\x00\x00\x02\f" + // 0x004F030F: 0x0000020C - "\x00o\x03\x0f\x00\x00\x02\r" + // 0x006F030F: 0x0000020D - "\x00O\x03\x11\x00\x00\x02\x0e" + // 0x004F0311: 0x0000020E - "\x00o\x03\x11\x00\x00\x02\x0f" + // 0x006F0311: 0x0000020F - "\x00R\x03\x0f\x00\x00\x02\x10" + // 0x0052030F: 0x00000210 - "\x00r\x03\x0f\x00\x00\x02\x11" + // 0x0072030F: 0x00000211 - "\x00R\x03\x11\x00\x00\x02\x12" + // 0x00520311: 0x00000212 - "\x00r\x03\x11\x00\x00\x02\x13" + // 0x00720311: 0x00000213 - "\x00U\x03\x0f\x00\x00\x02\x14" + // 0x0055030F: 0x00000214 - "\x00u\x03\x0f\x00\x00\x02\x15" + // 0x0075030F: 0x00000215 - "\x00U\x03\x11\x00\x00\x02\x16" + // 0x00550311: 0x00000216 - "\x00u\x03\x11\x00\x00\x02\x17" + // 0x00750311: 0x00000217 - "\x00S\x03&\x00\x00\x02\x18" + // 0x00530326: 0x00000218 - "\x00s\x03&\x00\x00\x02\x19" + // 0x00730326: 0x00000219 - "\x00T\x03&\x00\x00\x02\x1a" + // 0x00540326: 0x0000021A - "\x00t\x03&\x00\x00\x02\x1b" + // 0x00740326: 0x0000021B - "\x00H\x03\f\x00\x00\x02\x1e" + // 0x0048030C: 0x0000021E - "\x00h\x03\f\x00\x00\x02\x1f" + // 0x0068030C: 0x0000021F - "\x00A\x03\a\x00\x00\x02&" + // 0x00410307: 0x00000226 - "\x00a\x03\a\x00\x00\x02'" + // 0x00610307: 0x00000227 - "\x00E\x03'\x00\x00\x02(" + // 0x00450327: 0x00000228 - "\x00e\x03'\x00\x00\x02)" + // 0x00650327: 0x00000229 - "\x00\xd6\x03\x04\x00\x00\x02*" + // 0x00D60304: 0x0000022A - "\x00\xf6\x03\x04\x00\x00\x02+" + // 0x00F60304: 0x0000022B - "\x00\xd5\x03\x04\x00\x00\x02," + // 0x00D50304: 0x0000022C - "\x00\xf5\x03\x04\x00\x00\x02-" + // 0x00F50304: 0x0000022D - "\x00O\x03\a\x00\x00\x02." + // 0x004F0307: 0x0000022E - "\x00o\x03\a\x00\x00\x02/" + // 0x006F0307: 0x0000022F - "\x02.\x03\x04\x00\x00\x020" + // 0x022E0304: 0x00000230 - "\x02/\x03\x04\x00\x00\x021" + // 0x022F0304: 0x00000231 - "\x00Y\x03\x04\x00\x00\x022" + // 0x00590304: 0x00000232 - "\x00y\x03\x04\x00\x00\x023" + // 0x00790304: 0x00000233 - "\x00\xa8\x03\x01\x00\x00\x03\x85" + // 0x00A80301: 0x00000385 - "\x03\x91\x03\x01\x00\x00\x03\x86" + // 0x03910301: 0x00000386 - "\x03\x95\x03\x01\x00\x00\x03\x88" + // 0x03950301: 0x00000388 - "\x03\x97\x03\x01\x00\x00\x03\x89" + // 0x03970301: 0x00000389 - "\x03\x99\x03\x01\x00\x00\x03\x8a" + // 0x03990301: 0x0000038A - "\x03\x9f\x03\x01\x00\x00\x03\x8c" + // 0x039F0301: 0x0000038C - "\x03\xa5\x03\x01\x00\x00\x03\x8e" + // 0x03A50301: 0x0000038E - "\x03\xa9\x03\x01\x00\x00\x03\x8f" + // 0x03A90301: 0x0000038F - "\x03\xca\x03\x01\x00\x00\x03\x90" + // 0x03CA0301: 0x00000390 - "\x03\x99\x03\b\x00\x00\x03\xaa" + // 0x03990308: 0x000003AA - "\x03\xa5\x03\b\x00\x00\x03\xab" + // 0x03A50308: 0x000003AB - "\x03\xb1\x03\x01\x00\x00\x03\xac" + // 0x03B10301: 0x000003AC - "\x03\xb5\x03\x01\x00\x00\x03\xad" + // 0x03B50301: 0x000003AD - "\x03\xb7\x03\x01\x00\x00\x03\xae" + // 0x03B70301: 0x000003AE - "\x03\xb9\x03\x01\x00\x00\x03\xaf" + // 0x03B90301: 0x000003AF - "\x03\xcb\x03\x01\x00\x00\x03\xb0" + // 0x03CB0301: 0x000003B0 - "\x03\xb9\x03\b\x00\x00\x03\xca" + // 0x03B90308: 0x000003CA - "\x03\xc5\x03\b\x00\x00\x03\xcb" + // 0x03C50308: 0x000003CB - "\x03\xbf\x03\x01\x00\x00\x03\xcc" + // 0x03BF0301: 0x000003CC - "\x03\xc5\x03\x01\x00\x00\x03\xcd" + // 0x03C50301: 0x000003CD - "\x03\xc9\x03\x01\x00\x00\x03\xce" + // 0x03C90301: 0x000003CE - "\x03\xd2\x03\x01\x00\x00\x03\xd3" + // 0x03D20301: 0x000003D3 - "\x03\xd2\x03\b\x00\x00\x03\xd4" + // 0x03D20308: 0x000003D4 - "\x04\x15\x03\x00\x00\x00\x04\x00" + // 0x04150300: 0x00000400 - "\x04\x15\x03\b\x00\x00\x04\x01" + // 0x04150308: 0x00000401 - "\x04\x13\x03\x01\x00\x00\x04\x03" + // 0x04130301: 0x00000403 - "\x04\x06\x03\b\x00\x00\x04\a" + // 0x04060308: 0x00000407 - "\x04\x1a\x03\x01\x00\x00\x04\f" + // 0x041A0301: 0x0000040C - "\x04\x18\x03\x00\x00\x00\x04\r" + // 0x04180300: 0x0000040D - "\x04#\x03\x06\x00\x00\x04\x0e" + // 0x04230306: 0x0000040E - "\x04\x18\x03\x06\x00\x00\x04\x19" + // 0x04180306: 0x00000419 - "\x048\x03\x06\x00\x00\x049" + // 0x04380306: 0x00000439 - "\x045\x03\x00\x00\x00\x04P" + // 0x04350300: 0x00000450 - "\x045\x03\b\x00\x00\x04Q" + // 0x04350308: 0x00000451 - "\x043\x03\x01\x00\x00\x04S" + // 0x04330301: 0x00000453 - "\x04V\x03\b\x00\x00\x04W" + // 0x04560308: 0x00000457 - "\x04:\x03\x01\x00\x00\x04\\" + // 0x043A0301: 0x0000045C - "\x048\x03\x00\x00\x00\x04]" + // 0x04380300: 0x0000045D - "\x04C\x03\x06\x00\x00\x04^" + // 0x04430306: 0x0000045E - "\x04t\x03\x0f\x00\x00\x04v" + // 0x0474030F: 0x00000476 - "\x04u\x03\x0f\x00\x00\x04w" + // 0x0475030F: 0x00000477 - "\x04\x16\x03\x06\x00\x00\x04\xc1" + // 0x04160306: 0x000004C1 - "\x046\x03\x06\x00\x00\x04\xc2" + // 0x04360306: 0x000004C2 - "\x04\x10\x03\x06\x00\x00\x04\xd0" + // 0x04100306: 0x000004D0 - "\x040\x03\x06\x00\x00\x04\xd1" + // 0x04300306: 0x000004D1 - "\x04\x10\x03\b\x00\x00\x04\xd2" + // 0x04100308: 0x000004D2 - "\x040\x03\b\x00\x00\x04\xd3" + // 0x04300308: 0x000004D3 - "\x04\x15\x03\x06\x00\x00\x04\xd6" + // 0x04150306: 0x000004D6 - "\x045\x03\x06\x00\x00\x04\xd7" + // 0x04350306: 0x000004D7 - "\x04\xd8\x03\b\x00\x00\x04\xda" + // 0x04D80308: 0x000004DA - "\x04\xd9\x03\b\x00\x00\x04\xdb" + // 0x04D90308: 0x000004DB - "\x04\x16\x03\b\x00\x00\x04\xdc" + // 0x04160308: 0x000004DC - "\x046\x03\b\x00\x00\x04\xdd" + // 0x04360308: 0x000004DD - "\x04\x17\x03\b\x00\x00\x04\xde" + // 0x04170308: 0x000004DE - "\x047\x03\b\x00\x00\x04\xdf" + // 0x04370308: 0x000004DF - "\x04\x18\x03\x04\x00\x00\x04\xe2" + // 0x04180304: 0x000004E2 - "\x048\x03\x04\x00\x00\x04\xe3" + // 0x04380304: 0x000004E3 - "\x04\x18\x03\b\x00\x00\x04\xe4" + // 0x04180308: 0x000004E4 - "\x048\x03\b\x00\x00\x04\xe5" + // 0x04380308: 0x000004E5 - "\x04\x1e\x03\b\x00\x00\x04\xe6" + // 0x041E0308: 0x000004E6 - "\x04>\x03\b\x00\x00\x04\xe7" + // 0x043E0308: 0x000004E7 - "\x04\xe8\x03\b\x00\x00\x04\xea" + // 0x04E80308: 0x000004EA - "\x04\xe9\x03\b\x00\x00\x04\xeb" + // 0x04E90308: 0x000004EB - "\x04-\x03\b\x00\x00\x04\xec" + // 0x042D0308: 0x000004EC - "\x04M\x03\b\x00\x00\x04\xed" + // 0x044D0308: 0x000004ED - "\x04#\x03\x04\x00\x00\x04\xee" + // 0x04230304: 0x000004EE - "\x04C\x03\x04\x00\x00\x04\xef" + // 0x04430304: 0x000004EF - "\x04#\x03\b\x00\x00\x04\xf0" + // 0x04230308: 0x000004F0 - "\x04C\x03\b\x00\x00\x04\xf1" + // 0x04430308: 0x000004F1 - "\x04#\x03\v\x00\x00\x04\xf2" + // 0x0423030B: 0x000004F2 - "\x04C\x03\v\x00\x00\x04\xf3" + // 0x0443030B: 0x000004F3 - "\x04'\x03\b\x00\x00\x04\xf4" + // 0x04270308: 0x000004F4 - "\x04G\x03\b\x00\x00\x04\xf5" + // 0x04470308: 0x000004F5 - "\x04+\x03\b\x00\x00\x04\xf8" + // 0x042B0308: 0x000004F8 - "\x04K\x03\b\x00\x00\x04\xf9" + // 0x044B0308: 0x000004F9 - "\x06'\x06S\x00\x00\x06\"" + // 0x06270653: 0x00000622 - "\x06'\x06T\x00\x00\x06#" + // 0x06270654: 0x00000623 - "\x06H\x06T\x00\x00\x06$" + // 0x06480654: 0x00000624 - "\x06'\x06U\x00\x00\x06%" + // 0x06270655: 0x00000625 - "\x06J\x06T\x00\x00\x06&" + // 0x064A0654: 0x00000626 - "\x06\xd5\x06T\x00\x00\x06\xc0" + // 0x06D50654: 0x000006C0 - "\x06\xc1\x06T\x00\x00\x06\xc2" + // 0x06C10654: 0x000006C2 - "\x06\xd2\x06T\x00\x00\x06\xd3" + // 0x06D20654: 0x000006D3 - "\t(\t<\x00\x00\t)" + // 0x0928093C: 0x00000929 - "\t0\t<\x00\x00\t1" + // 0x0930093C: 0x00000931 - "\t3\t<\x00\x00\t4" + // 0x0933093C: 0x00000934 - "\t\xc7\t\xbe\x00\x00\t\xcb" + // 0x09C709BE: 0x000009CB - "\t\xc7\t\xd7\x00\x00\t\xcc" + // 0x09C709D7: 0x000009CC - "\vG\vV\x00\x00\vH" + // 0x0B470B56: 0x00000B48 - "\vG\v>\x00\x00\vK" + // 0x0B470B3E: 0x00000B4B - "\vG\vW\x00\x00\vL" + // 0x0B470B57: 0x00000B4C - "\v\x92\v\xd7\x00\x00\v\x94" + // 0x0B920BD7: 0x00000B94 - "\v\xc6\v\xbe\x00\x00\v\xca" + // 0x0BC60BBE: 0x00000BCA - "\v\xc7\v\xbe\x00\x00\v\xcb" + // 0x0BC70BBE: 0x00000BCB - "\v\xc6\v\xd7\x00\x00\v\xcc" + // 0x0BC60BD7: 0x00000BCC - "\fF\fV\x00\x00\fH" + // 0x0C460C56: 0x00000C48 - "\f\xbf\f\xd5\x00\x00\f\xc0" + // 0x0CBF0CD5: 0x00000CC0 - "\f\xc6\f\xd5\x00\x00\f\xc7" + // 0x0CC60CD5: 0x00000CC7 - "\f\xc6\f\xd6\x00\x00\f\xc8" + // 0x0CC60CD6: 0x00000CC8 - "\f\xc6\f\xc2\x00\x00\f\xca" + // 0x0CC60CC2: 0x00000CCA - "\f\xca\f\xd5\x00\x00\f\xcb" + // 0x0CCA0CD5: 0x00000CCB - "\rF\r>\x00\x00\rJ" + // 0x0D460D3E: 0x00000D4A - "\rG\r>\x00\x00\rK" + // 0x0D470D3E: 0x00000D4B - "\rF\rW\x00\x00\rL" + // 0x0D460D57: 0x00000D4C - "\r\xd9\r\xca\x00\x00\r\xda" + // 0x0DD90DCA: 0x00000DDA - "\r\xd9\r\xcf\x00\x00\r\xdc" + // 0x0DD90DCF: 0x00000DDC - "\r\xdc\r\xca\x00\x00\r\xdd" + // 0x0DDC0DCA: 0x00000DDD - "\r\xd9\r\xdf\x00\x00\r\xde" + // 0x0DD90DDF: 0x00000DDE - "\x10%\x10.\x00\x00\x10&" + // 0x1025102E: 0x00001026 - "\x1b\x05\x1b5\x00\x00\x1b\x06" + // 0x1B051B35: 0x00001B06 - "\x1b\a\x1b5\x00\x00\x1b\b" + // 0x1B071B35: 0x00001B08 - "\x1b\t\x1b5\x00\x00\x1b\n" + // 0x1B091B35: 0x00001B0A - "\x1b\v\x1b5\x00\x00\x1b\f" + // 0x1B0B1B35: 0x00001B0C - "\x1b\r\x1b5\x00\x00\x1b\x0e" + // 0x1B0D1B35: 0x00001B0E - "\x1b\x11\x1b5\x00\x00\x1b\x12" + // 0x1B111B35: 0x00001B12 - "\x1b:\x1b5\x00\x00\x1b;" + // 0x1B3A1B35: 0x00001B3B - "\x1b<\x1b5\x00\x00\x1b=" + // 0x1B3C1B35: 0x00001B3D - "\x1b>\x1b5\x00\x00\x1b@" + // 0x1B3E1B35: 0x00001B40 - "\x1b?\x1b5\x00\x00\x1bA" + // 0x1B3F1B35: 0x00001B41 - "\x1bB\x1b5\x00\x00\x1bC" + // 0x1B421B35: 0x00001B43 - "\x00A\x03%\x00\x00\x1e\x00" + // 0x00410325: 0x00001E00 - "\x00a\x03%\x00\x00\x1e\x01" + // 0x00610325: 0x00001E01 - "\x00B\x03\a\x00\x00\x1e\x02" + // 0x00420307: 0x00001E02 - "\x00b\x03\a\x00\x00\x1e\x03" + // 0x00620307: 0x00001E03 - "\x00B\x03#\x00\x00\x1e\x04" + // 0x00420323: 0x00001E04 - "\x00b\x03#\x00\x00\x1e\x05" + // 0x00620323: 0x00001E05 - "\x00B\x031\x00\x00\x1e\x06" + // 0x00420331: 0x00001E06 - "\x00b\x031\x00\x00\x1e\a" + // 0x00620331: 0x00001E07 - "\x00\xc7\x03\x01\x00\x00\x1e\b" + // 0x00C70301: 0x00001E08 - "\x00\xe7\x03\x01\x00\x00\x1e\t" + // 0x00E70301: 0x00001E09 - "\x00D\x03\a\x00\x00\x1e\n" + // 0x00440307: 0x00001E0A - "\x00d\x03\a\x00\x00\x1e\v" + // 0x00640307: 0x00001E0B - "\x00D\x03#\x00\x00\x1e\f" + // 0x00440323: 0x00001E0C - "\x00d\x03#\x00\x00\x1e\r" + // 0x00640323: 0x00001E0D - "\x00D\x031\x00\x00\x1e\x0e" + // 0x00440331: 0x00001E0E - "\x00d\x031\x00\x00\x1e\x0f" + // 0x00640331: 0x00001E0F - "\x00D\x03'\x00\x00\x1e\x10" + // 0x00440327: 0x00001E10 - "\x00d\x03'\x00\x00\x1e\x11" + // 0x00640327: 0x00001E11 - "\x00D\x03-\x00\x00\x1e\x12" + // 0x0044032D: 0x00001E12 - "\x00d\x03-\x00\x00\x1e\x13" + // 0x0064032D: 0x00001E13 - "\x01\x12\x03\x00\x00\x00\x1e\x14" + // 0x01120300: 0x00001E14 - "\x01\x13\x03\x00\x00\x00\x1e\x15" + // 0x01130300: 0x00001E15 - "\x01\x12\x03\x01\x00\x00\x1e\x16" + // 0x01120301: 0x00001E16 - "\x01\x13\x03\x01\x00\x00\x1e\x17" + // 0x01130301: 0x00001E17 - "\x00E\x03-\x00\x00\x1e\x18" + // 0x0045032D: 0x00001E18 - "\x00e\x03-\x00\x00\x1e\x19" + // 0x0065032D: 0x00001E19 - "\x00E\x030\x00\x00\x1e\x1a" + // 0x00450330: 0x00001E1A - "\x00e\x030\x00\x00\x1e\x1b" + // 0x00650330: 0x00001E1B - "\x02(\x03\x06\x00\x00\x1e\x1c" + // 0x02280306: 0x00001E1C - "\x02)\x03\x06\x00\x00\x1e\x1d" + // 0x02290306: 0x00001E1D - "\x00F\x03\a\x00\x00\x1e\x1e" + // 0x00460307: 0x00001E1E - "\x00f\x03\a\x00\x00\x1e\x1f" + // 0x00660307: 0x00001E1F - "\x00G\x03\x04\x00\x00\x1e " + // 0x00470304: 0x00001E20 - "\x00g\x03\x04\x00\x00\x1e!" + // 0x00670304: 0x00001E21 - "\x00H\x03\a\x00\x00\x1e\"" + // 0x00480307: 0x00001E22 - "\x00h\x03\a\x00\x00\x1e#" + // 0x00680307: 0x00001E23 - "\x00H\x03#\x00\x00\x1e$" + // 0x00480323: 0x00001E24 - "\x00h\x03#\x00\x00\x1e%" + // 0x00680323: 0x00001E25 - "\x00H\x03\b\x00\x00\x1e&" + // 0x00480308: 0x00001E26 - "\x00h\x03\b\x00\x00\x1e'" + // 0x00680308: 0x00001E27 - "\x00H\x03'\x00\x00\x1e(" + // 0x00480327: 0x00001E28 - "\x00h\x03'\x00\x00\x1e)" + // 0x00680327: 0x00001E29 - "\x00H\x03.\x00\x00\x1e*" + // 0x0048032E: 0x00001E2A - "\x00h\x03.\x00\x00\x1e+" + // 0x0068032E: 0x00001E2B - "\x00I\x030\x00\x00\x1e," + // 0x00490330: 0x00001E2C - "\x00i\x030\x00\x00\x1e-" + // 0x00690330: 0x00001E2D - "\x00\xcf\x03\x01\x00\x00\x1e." + // 0x00CF0301: 0x00001E2E - "\x00\xef\x03\x01\x00\x00\x1e/" + // 0x00EF0301: 0x00001E2F - "\x00K\x03\x01\x00\x00\x1e0" + // 0x004B0301: 0x00001E30 - "\x00k\x03\x01\x00\x00\x1e1" + // 0x006B0301: 0x00001E31 - "\x00K\x03#\x00\x00\x1e2" + // 0x004B0323: 0x00001E32 - "\x00k\x03#\x00\x00\x1e3" + // 0x006B0323: 0x00001E33 - "\x00K\x031\x00\x00\x1e4" + // 0x004B0331: 0x00001E34 - "\x00k\x031\x00\x00\x1e5" + // 0x006B0331: 0x00001E35 - "\x00L\x03#\x00\x00\x1e6" + // 0x004C0323: 0x00001E36 - "\x00l\x03#\x00\x00\x1e7" + // 0x006C0323: 0x00001E37 - "\x1e6\x03\x04\x00\x00\x1e8" + // 0x1E360304: 0x00001E38 - "\x1e7\x03\x04\x00\x00\x1e9" + // 0x1E370304: 0x00001E39 - "\x00L\x031\x00\x00\x1e:" + // 0x004C0331: 0x00001E3A - "\x00l\x031\x00\x00\x1e;" + // 0x006C0331: 0x00001E3B - "\x00L\x03-\x00\x00\x1e<" + // 0x004C032D: 0x00001E3C - "\x00l\x03-\x00\x00\x1e=" + // 0x006C032D: 0x00001E3D - "\x00M\x03\x01\x00\x00\x1e>" + // 0x004D0301: 0x00001E3E - "\x00m\x03\x01\x00\x00\x1e?" + // 0x006D0301: 0x00001E3F - "\x00M\x03\a\x00\x00\x1e@" + // 0x004D0307: 0x00001E40 - "\x00m\x03\a\x00\x00\x1eA" + // 0x006D0307: 0x00001E41 - "\x00M\x03#\x00\x00\x1eB" + // 0x004D0323: 0x00001E42 - "\x00m\x03#\x00\x00\x1eC" + // 0x006D0323: 0x00001E43 - "\x00N\x03\a\x00\x00\x1eD" + // 0x004E0307: 0x00001E44 - "\x00n\x03\a\x00\x00\x1eE" + // 0x006E0307: 0x00001E45 - "\x00N\x03#\x00\x00\x1eF" + // 0x004E0323: 0x00001E46 - "\x00n\x03#\x00\x00\x1eG" + // 0x006E0323: 0x00001E47 - "\x00N\x031\x00\x00\x1eH" + // 0x004E0331: 0x00001E48 - "\x00n\x031\x00\x00\x1eI" + // 0x006E0331: 0x00001E49 - "\x00N\x03-\x00\x00\x1eJ" + // 0x004E032D: 0x00001E4A - "\x00n\x03-\x00\x00\x1eK" + // 0x006E032D: 0x00001E4B - "\x00\xd5\x03\x01\x00\x00\x1eL" + // 0x00D50301: 0x00001E4C - "\x00\xf5\x03\x01\x00\x00\x1eM" + // 0x00F50301: 0x00001E4D - "\x00\xd5\x03\b\x00\x00\x1eN" + // 0x00D50308: 0x00001E4E - "\x00\xf5\x03\b\x00\x00\x1eO" + // 0x00F50308: 0x00001E4F - "\x01L\x03\x00\x00\x00\x1eP" + // 0x014C0300: 0x00001E50 - "\x01M\x03\x00\x00\x00\x1eQ" + // 0x014D0300: 0x00001E51 - "\x01L\x03\x01\x00\x00\x1eR" + // 0x014C0301: 0x00001E52 - "\x01M\x03\x01\x00\x00\x1eS" + // 0x014D0301: 0x00001E53 - "\x00P\x03\x01\x00\x00\x1eT" + // 0x00500301: 0x00001E54 - "\x00p\x03\x01\x00\x00\x1eU" + // 0x00700301: 0x00001E55 - "\x00P\x03\a\x00\x00\x1eV" + // 0x00500307: 0x00001E56 - "\x00p\x03\a\x00\x00\x1eW" + // 0x00700307: 0x00001E57 - "\x00R\x03\a\x00\x00\x1eX" + // 0x00520307: 0x00001E58 - "\x00r\x03\a\x00\x00\x1eY" + // 0x00720307: 0x00001E59 - "\x00R\x03#\x00\x00\x1eZ" + // 0x00520323: 0x00001E5A - "\x00r\x03#\x00\x00\x1e[" + // 0x00720323: 0x00001E5B - "\x1eZ\x03\x04\x00\x00\x1e\\" + // 0x1E5A0304: 0x00001E5C - "\x1e[\x03\x04\x00\x00\x1e]" + // 0x1E5B0304: 0x00001E5D - "\x00R\x031\x00\x00\x1e^" + // 0x00520331: 0x00001E5E - "\x00r\x031\x00\x00\x1e_" + // 0x00720331: 0x00001E5F - "\x00S\x03\a\x00\x00\x1e`" + // 0x00530307: 0x00001E60 - "\x00s\x03\a\x00\x00\x1ea" + // 0x00730307: 0x00001E61 - "\x00S\x03#\x00\x00\x1eb" + // 0x00530323: 0x00001E62 - "\x00s\x03#\x00\x00\x1ec" + // 0x00730323: 0x00001E63 - "\x01Z\x03\a\x00\x00\x1ed" + // 0x015A0307: 0x00001E64 - "\x01[\x03\a\x00\x00\x1ee" + // 0x015B0307: 0x00001E65 - "\x01`\x03\a\x00\x00\x1ef" + // 0x01600307: 0x00001E66 - "\x01a\x03\a\x00\x00\x1eg" + // 0x01610307: 0x00001E67 - "\x1eb\x03\a\x00\x00\x1eh" + // 0x1E620307: 0x00001E68 - "\x1ec\x03\a\x00\x00\x1ei" + // 0x1E630307: 0x00001E69 - "\x00T\x03\a\x00\x00\x1ej" + // 0x00540307: 0x00001E6A - "\x00t\x03\a\x00\x00\x1ek" + // 0x00740307: 0x00001E6B - "\x00T\x03#\x00\x00\x1el" + // 0x00540323: 0x00001E6C - "\x00t\x03#\x00\x00\x1em" + // 0x00740323: 0x00001E6D - "\x00T\x031\x00\x00\x1en" + // 0x00540331: 0x00001E6E - "\x00t\x031\x00\x00\x1eo" + // 0x00740331: 0x00001E6F - "\x00T\x03-\x00\x00\x1ep" + // 0x0054032D: 0x00001E70 - "\x00t\x03-\x00\x00\x1eq" + // 0x0074032D: 0x00001E71 - "\x00U\x03$\x00\x00\x1er" + // 0x00550324: 0x00001E72 - "\x00u\x03$\x00\x00\x1es" + // 0x00750324: 0x00001E73 - "\x00U\x030\x00\x00\x1et" + // 0x00550330: 0x00001E74 - "\x00u\x030\x00\x00\x1eu" + // 0x00750330: 0x00001E75 - "\x00U\x03-\x00\x00\x1ev" + // 0x0055032D: 0x00001E76 - "\x00u\x03-\x00\x00\x1ew" + // 0x0075032D: 0x00001E77 - "\x01h\x03\x01\x00\x00\x1ex" + // 0x01680301: 0x00001E78 - "\x01i\x03\x01\x00\x00\x1ey" + // 0x01690301: 0x00001E79 - "\x01j\x03\b\x00\x00\x1ez" + // 0x016A0308: 0x00001E7A - "\x01k\x03\b\x00\x00\x1e{" + // 0x016B0308: 0x00001E7B - "\x00V\x03\x03\x00\x00\x1e|" + // 0x00560303: 0x00001E7C - "\x00v\x03\x03\x00\x00\x1e}" + // 0x00760303: 0x00001E7D - "\x00V\x03#\x00\x00\x1e~" + // 0x00560323: 0x00001E7E - "\x00v\x03#\x00\x00\x1e\x7f" + // 0x00760323: 0x00001E7F - "\x00W\x03\x00\x00\x00\x1e\x80" + // 0x00570300: 0x00001E80 - "\x00w\x03\x00\x00\x00\x1e\x81" + // 0x00770300: 0x00001E81 - "\x00W\x03\x01\x00\x00\x1e\x82" + // 0x00570301: 0x00001E82 - "\x00w\x03\x01\x00\x00\x1e\x83" + // 0x00770301: 0x00001E83 - "\x00W\x03\b\x00\x00\x1e\x84" + // 0x00570308: 0x00001E84 - "\x00w\x03\b\x00\x00\x1e\x85" + // 0x00770308: 0x00001E85 - "\x00W\x03\a\x00\x00\x1e\x86" + // 0x00570307: 0x00001E86 - "\x00w\x03\a\x00\x00\x1e\x87" + // 0x00770307: 0x00001E87 - "\x00W\x03#\x00\x00\x1e\x88" + // 0x00570323: 0x00001E88 - "\x00w\x03#\x00\x00\x1e\x89" + // 0x00770323: 0x00001E89 - "\x00X\x03\a\x00\x00\x1e\x8a" + // 0x00580307: 0x00001E8A - "\x00x\x03\a\x00\x00\x1e\x8b" + // 0x00780307: 0x00001E8B - "\x00X\x03\b\x00\x00\x1e\x8c" + // 0x00580308: 0x00001E8C - "\x00x\x03\b\x00\x00\x1e\x8d" + // 0x00780308: 0x00001E8D - "\x00Y\x03\a\x00\x00\x1e\x8e" + // 0x00590307: 0x00001E8E - "\x00y\x03\a\x00\x00\x1e\x8f" + // 0x00790307: 0x00001E8F - "\x00Z\x03\x02\x00\x00\x1e\x90" + // 0x005A0302: 0x00001E90 - "\x00z\x03\x02\x00\x00\x1e\x91" + // 0x007A0302: 0x00001E91 - "\x00Z\x03#\x00\x00\x1e\x92" + // 0x005A0323: 0x00001E92 - "\x00z\x03#\x00\x00\x1e\x93" + // 0x007A0323: 0x00001E93 - "\x00Z\x031\x00\x00\x1e\x94" + // 0x005A0331: 0x00001E94 - "\x00z\x031\x00\x00\x1e\x95" + // 0x007A0331: 0x00001E95 - "\x00h\x031\x00\x00\x1e\x96" + // 0x00680331: 0x00001E96 - "\x00t\x03\b\x00\x00\x1e\x97" + // 0x00740308: 0x00001E97 - "\x00w\x03\n\x00\x00\x1e\x98" + // 0x0077030A: 0x00001E98 - "\x00y\x03\n\x00\x00\x1e\x99" + // 0x0079030A: 0x00001E99 - "\x01\x7f\x03\a\x00\x00\x1e\x9b" + // 0x017F0307: 0x00001E9B - "\x00A\x03#\x00\x00\x1e\xa0" + // 0x00410323: 0x00001EA0 - "\x00a\x03#\x00\x00\x1e\xa1" + // 0x00610323: 0x00001EA1 - "\x00A\x03\t\x00\x00\x1e\xa2" + // 0x00410309: 0x00001EA2 - "\x00a\x03\t\x00\x00\x1e\xa3" + // 0x00610309: 0x00001EA3 - "\x00\xc2\x03\x01\x00\x00\x1e\xa4" + // 0x00C20301: 0x00001EA4 - "\x00\xe2\x03\x01\x00\x00\x1e\xa5" + // 0x00E20301: 0x00001EA5 - "\x00\xc2\x03\x00\x00\x00\x1e\xa6" + // 0x00C20300: 0x00001EA6 - "\x00\xe2\x03\x00\x00\x00\x1e\xa7" + // 0x00E20300: 0x00001EA7 - "\x00\xc2\x03\t\x00\x00\x1e\xa8" + // 0x00C20309: 0x00001EA8 - "\x00\xe2\x03\t\x00\x00\x1e\xa9" + // 0x00E20309: 0x00001EA9 - "\x00\xc2\x03\x03\x00\x00\x1e\xaa" + // 0x00C20303: 0x00001EAA - "\x00\xe2\x03\x03\x00\x00\x1e\xab" + // 0x00E20303: 0x00001EAB - "\x1e\xa0\x03\x02\x00\x00\x1e\xac" + // 0x1EA00302: 0x00001EAC - "\x1e\xa1\x03\x02\x00\x00\x1e\xad" + // 0x1EA10302: 0x00001EAD - "\x01\x02\x03\x01\x00\x00\x1e\xae" + // 0x01020301: 0x00001EAE - "\x01\x03\x03\x01\x00\x00\x1e\xaf" + // 0x01030301: 0x00001EAF - "\x01\x02\x03\x00\x00\x00\x1e\xb0" + // 0x01020300: 0x00001EB0 - "\x01\x03\x03\x00\x00\x00\x1e\xb1" + // 0x01030300: 0x00001EB1 - "\x01\x02\x03\t\x00\x00\x1e\xb2" + // 0x01020309: 0x00001EB2 - "\x01\x03\x03\t\x00\x00\x1e\xb3" + // 0x01030309: 0x00001EB3 - "\x01\x02\x03\x03\x00\x00\x1e\xb4" + // 0x01020303: 0x00001EB4 - "\x01\x03\x03\x03\x00\x00\x1e\xb5" + // 0x01030303: 0x00001EB5 - "\x1e\xa0\x03\x06\x00\x00\x1e\xb6" + // 0x1EA00306: 0x00001EB6 - "\x1e\xa1\x03\x06\x00\x00\x1e\xb7" + // 0x1EA10306: 0x00001EB7 - "\x00E\x03#\x00\x00\x1e\xb8" + // 0x00450323: 0x00001EB8 - "\x00e\x03#\x00\x00\x1e\xb9" + // 0x00650323: 0x00001EB9 - "\x00E\x03\t\x00\x00\x1e\xba" + // 0x00450309: 0x00001EBA - "\x00e\x03\t\x00\x00\x1e\xbb" + // 0x00650309: 0x00001EBB - "\x00E\x03\x03\x00\x00\x1e\xbc" + // 0x00450303: 0x00001EBC - "\x00e\x03\x03\x00\x00\x1e\xbd" + // 0x00650303: 0x00001EBD - "\x00\xca\x03\x01\x00\x00\x1e\xbe" + // 0x00CA0301: 0x00001EBE - "\x00\xea\x03\x01\x00\x00\x1e\xbf" + // 0x00EA0301: 0x00001EBF - "\x00\xca\x03\x00\x00\x00\x1e\xc0" + // 0x00CA0300: 0x00001EC0 - "\x00\xea\x03\x00\x00\x00\x1e\xc1" + // 0x00EA0300: 0x00001EC1 - "\x00\xca\x03\t\x00\x00\x1e\xc2" + // 0x00CA0309: 0x00001EC2 - "\x00\xea\x03\t\x00\x00\x1e\xc3" + // 0x00EA0309: 0x00001EC3 - "\x00\xca\x03\x03\x00\x00\x1e\xc4" + // 0x00CA0303: 0x00001EC4 - "\x00\xea\x03\x03\x00\x00\x1e\xc5" + // 0x00EA0303: 0x00001EC5 - "\x1e\xb8\x03\x02\x00\x00\x1e\xc6" + // 0x1EB80302: 0x00001EC6 - "\x1e\xb9\x03\x02\x00\x00\x1e\xc7" + // 0x1EB90302: 0x00001EC7 - "\x00I\x03\t\x00\x00\x1e\xc8" + // 0x00490309: 0x00001EC8 - "\x00i\x03\t\x00\x00\x1e\xc9" + // 0x00690309: 0x00001EC9 - "\x00I\x03#\x00\x00\x1e\xca" + // 0x00490323: 0x00001ECA - "\x00i\x03#\x00\x00\x1e\xcb" + // 0x00690323: 0x00001ECB - "\x00O\x03#\x00\x00\x1e\xcc" + // 0x004F0323: 0x00001ECC - "\x00o\x03#\x00\x00\x1e\xcd" + // 0x006F0323: 0x00001ECD - "\x00O\x03\t\x00\x00\x1e\xce" + // 0x004F0309: 0x00001ECE - "\x00o\x03\t\x00\x00\x1e\xcf" + // 0x006F0309: 0x00001ECF - "\x00\xd4\x03\x01\x00\x00\x1e\xd0" + // 0x00D40301: 0x00001ED0 - "\x00\xf4\x03\x01\x00\x00\x1e\xd1" + // 0x00F40301: 0x00001ED1 - "\x00\xd4\x03\x00\x00\x00\x1e\xd2" + // 0x00D40300: 0x00001ED2 - "\x00\xf4\x03\x00\x00\x00\x1e\xd3" + // 0x00F40300: 0x00001ED3 - "\x00\xd4\x03\t\x00\x00\x1e\xd4" + // 0x00D40309: 0x00001ED4 - "\x00\xf4\x03\t\x00\x00\x1e\xd5" + // 0x00F40309: 0x00001ED5 - "\x00\xd4\x03\x03\x00\x00\x1e\xd6" + // 0x00D40303: 0x00001ED6 - "\x00\xf4\x03\x03\x00\x00\x1e\xd7" + // 0x00F40303: 0x00001ED7 - "\x1e\xcc\x03\x02\x00\x00\x1e\xd8" + // 0x1ECC0302: 0x00001ED8 - "\x1e\xcd\x03\x02\x00\x00\x1e\xd9" + // 0x1ECD0302: 0x00001ED9 - "\x01\xa0\x03\x01\x00\x00\x1e\xda" + // 0x01A00301: 0x00001EDA - "\x01\xa1\x03\x01\x00\x00\x1e\xdb" + // 0x01A10301: 0x00001EDB - "\x01\xa0\x03\x00\x00\x00\x1e\xdc" + // 0x01A00300: 0x00001EDC - "\x01\xa1\x03\x00\x00\x00\x1e\xdd" + // 0x01A10300: 0x00001EDD - "\x01\xa0\x03\t\x00\x00\x1e\xde" + // 0x01A00309: 0x00001EDE - "\x01\xa1\x03\t\x00\x00\x1e\xdf" + // 0x01A10309: 0x00001EDF - "\x01\xa0\x03\x03\x00\x00\x1e\xe0" + // 0x01A00303: 0x00001EE0 - "\x01\xa1\x03\x03\x00\x00\x1e\xe1" + // 0x01A10303: 0x00001EE1 - "\x01\xa0\x03#\x00\x00\x1e\xe2" + // 0x01A00323: 0x00001EE2 - "\x01\xa1\x03#\x00\x00\x1e\xe3" + // 0x01A10323: 0x00001EE3 - "\x00U\x03#\x00\x00\x1e\xe4" + // 0x00550323: 0x00001EE4 - "\x00u\x03#\x00\x00\x1e\xe5" + // 0x00750323: 0x00001EE5 - "\x00U\x03\t\x00\x00\x1e\xe6" + // 0x00550309: 0x00001EE6 - "\x00u\x03\t\x00\x00\x1e\xe7" + // 0x00750309: 0x00001EE7 - "\x01\xaf\x03\x01\x00\x00\x1e\xe8" + // 0x01AF0301: 0x00001EE8 - "\x01\xb0\x03\x01\x00\x00\x1e\xe9" + // 0x01B00301: 0x00001EE9 - "\x01\xaf\x03\x00\x00\x00\x1e\xea" + // 0x01AF0300: 0x00001EEA - "\x01\xb0\x03\x00\x00\x00\x1e\xeb" + // 0x01B00300: 0x00001EEB - "\x01\xaf\x03\t\x00\x00\x1e\xec" + // 0x01AF0309: 0x00001EEC - "\x01\xb0\x03\t\x00\x00\x1e\xed" + // 0x01B00309: 0x00001EED - "\x01\xaf\x03\x03\x00\x00\x1e\xee" + // 0x01AF0303: 0x00001EEE - "\x01\xb0\x03\x03\x00\x00\x1e\xef" + // 0x01B00303: 0x00001EEF - "\x01\xaf\x03#\x00\x00\x1e\xf0" + // 0x01AF0323: 0x00001EF0 - "\x01\xb0\x03#\x00\x00\x1e\xf1" + // 0x01B00323: 0x00001EF1 - "\x00Y\x03\x00\x00\x00\x1e\xf2" + // 0x00590300: 0x00001EF2 - "\x00y\x03\x00\x00\x00\x1e\xf3" + // 0x00790300: 0x00001EF3 - "\x00Y\x03#\x00\x00\x1e\xf4" + // 0x00590323: 0x00001EF4 - "\x00y\x03#\x00\x00\x1e\xf5" + // 0x00790323: 0x00001EF5 - "\x00Y\x03\t\x00\x00\x1e\xf6" + // 0x00590309: 0x00001EF6 - "\x00y\x03\t\x00\x00\x1e\xf7" + // 0x00790309: 0x00001EF7 - "\x00Y\x03\x03\x00\x00\x1e\xf8" + // 0x00590303: 0x00001EF8 - "\x00y\x03\x03\x00\x00\x1e\xf9" + // 0x00790303: 0x00001EF9 - "\x03\xb1\x03\x13\x00\x00\x1f\x00" + // 0x03B10313: 0x00001F00 - "\x03\xb1\x03\x14\x00\x00\x1f\x01" + // 0x03B10314: 0x00001F01 - "\x1f\x00\x03\x00\x00\x00\x1f\x02" + // 0x1F000300: 0x00001F02 - "\x1f\x01\x03\x00\x00\x00\x1f\x03" + // 0x1F010300: 0x00001F03 - "\x1f\x00\x03\x01\x00\x00\x1f\x04" + // 0x1F000301: 0x00001F04 - "\x1f\x01\x03\x01\x00\x00\x1f\x05" + // 0x1F010301: 0x00001F05 - "\x1f\x00\x03B\x00\x00\x1f\x06" + // 0x1F000342: 0x00001F06 - "\x1f\x01\x03B\x00\x00\x1f\a" + // 0x1F010342: 0x00001F07 - "\x03\x91\x03\x13\x00\x00\x1f\b" + // 0x03910313: 0x00001F08 - "\x03\x91\x03\x14\x00\x00\x1f\t" + // 0x03910314: 0x00001F09 - "\x1f\b\x03\x00\x00\x00\x1f\n" + // 0x1F080300: 0x00001F0A - "\x1f\t\x03\x00\x00\x00\x1f\v" + // 0x1F090300: 0x00001F0B - "\x1f\b\x03\x01\x00\x00\x1f\f" + // 0x1F080301: 0x00001F0C - "\x1f\t\x03\x01\x00\x00\x1f\r" + // 0x1F090301: 0x00001F0D - "\x1f\b\x03B\x00\x00\x1f\x0e" + // 0x1F080342: 0x00001F0E - "\x1f\t\x03B\x00\x00\x1f\x0f" + // 0x1F090342: 0x00001F0F - "\x03\xb5\x03\x13\x00\x00\x1f\x10" + // 0x03B50313: 0x00001F10 - "\x03\xb5\x03\x14\x00\x00\x1f\x11" + // 0x03B50314: 0x00001F11 - "\x1f\x10\x03\x00\x00\x00\x1f\x12" + // 0x1F100300: 0x00001F12 - "\x1f\x11\x03\x00\x00\x00\x1f\x13" + // 0x1F110300: 0x00001F13 - "\x1f\x10\x03\x01\x00\x00\x1f\x14" + // 0x1F100301: 0x00001F14 - "\x1f\x11\x03\x01\x00\x00\x1f\x15" + // 0x1F110301: 0x00001F15 - "\x03\x95\x03\x13\x00\x00\x1f\x18" + // 0x03950313: 0x00001F18 - "\x03\x95\x03\x14\x00\x00\x1f\x19" + // 0x03950314: 0x00001F19 - "\x1f\x18\x03\x00\x00\x00\x1f\x1a" + // 0x1F180300: 0x00001F1A - "\x1f\x19\x03\x00\x00\x00\x1f\x1b" + // 0x1F190300: 0x00001F1B - "\x1f\x18\x03\x01\x00\x00\x1f\x1c" + // 0x1F180301: 0x00001F1C - "\x1f\x19\x03\x01\x00\x00\x1f\x1d" + // 0x1F190301: 0x00001F1D - "\x03\xb7\x03\x13\x00\x00\x1f " + // 0x03B70313: 0x00001F20 - "\x03\xb7\x03\x14\x00\x00\x1f!" + // 0x03B70314: 0x00001F21 - "\x1f \x03\x00\x00\x00\x1f\"" + // 0x1F200300: 0x00001F22 - "\x1f!\x03\x00\x00\x00\x1f#" + // 0x1F210300: 0x00001F23 - "\x1f \x03\x01\x00\x00\x1f$" + // 0x1F200301: 0x00001F24 - "\x1f!\x03\x01\x00\x00\x1f%" + // 0x1F210301: 0x00001F25 - "\x1f \x03B\x00\x00\x1f&" + // 0x1F200342: 0x00001F26 - "\x1f!\x03B\x00\x00\x1f'" + // 0x1F210342: 0x00001F27 - "\x03\x97\x03\x13\x00\x00\x1f(" + // 0x03970313: 0x00001F28 - "\x03\x97\x03\x14\x00\x00\x1f)" + // 0x03970314: 0x00001F29 - "\x1f(\x03\x00\x00\x00\x1f*" + // 0x1F280300: 0x00001F2A - "\x1f)\x03\x00\x00\x00\x1f+" + // 0x1F290300: 0x00001F2B - "\x1f(\x03\x01\x00\x00\x1f," + // 0x1F280301: 0x00001F2C - "\x1f)\x03\x01\x00\x00\x1f-" + // 0x1F290301: 0x00001F2D - "\x1f(\x03B\x00\x00\x1f." + // 0x1F280342: 0x00001F2E - "\x1f)\x03B\x00\x00\x1f/" + // 0x1F290342: 0x00001F2F - "\x03\xb9\x03\x13\x00\x00\x1f0" + // 0x03B90313: 0x00001F30 - "\x03\xb9\x03\x14\x00\x00\x1f1" + // 0x03B90314: 0x00001F31 - "\x1f0\x03\x00\x00\x00\x1f2" + // 0x1F300300: 0x00001F32 - "\x1f1\x03\x00\x00\x00\x1f3" + // 0x1F310300: 0x00001F33 - "\x1f0\x03\x01\x00\x00\x1f4" + // 0x1F300301: 0x00001F34 - "\x1f1\x03\x01\x00\x00\x1f5" + // 0x1F310301: 0x00001F35 - "\x1f0\x03B\x00\x00\x1f6" + // 0x1F300342: 0x00001F36 - "\x1f1\x03B\x00\x00\x1f7" + // 0x1F310342: 0x00001F37 - "\x03\x99\x03\x13\x00\x00\x1f8" + // 0x03990313: 0x00001F38 - "\x03\x99\x03\x14\x00\x00\x1f9" + // 0x03990314: 0x00001F39 - "\x1f8\x03\x00\x00\x00\x1f:" + // 0x1F380300: 0x00001F3A - "\x1f9\x03\x00\x00\x00\x1f;" + // 0x1F390300: 0x00001F3B - "\x1f8\x03\x01\x00\x00\x1f<" + // 0x1F380301: 0x00001F3C - "\x1f9\x03\x01\x00\x00\x1f=" + // 0x1F390301: 0x00001F3D - "\x1f8\x03B\x00\x00\x1f>" + // 0x1F380342: 0x00001F3E - "\x1f9\x03B\x00\x00\x1f?" + // 0x1F390342: 0x00001F3F - "\x03\xbf\x03\x13\x00\x00\x1f@" + // 0x03BF0313: 0x00001F40 - "\x03\xbf\x03\x14\x00\x00\x1fA" + // 0x03BF0314: 0x00001F41 - "\x1f@\x03\x00\x00\x00\x1fB" + // 0x1F400300: 0x00001F42 - "\x1fA\x03\x00\x00\x00\x1fC" + // 0x1F410300: 0x00001F43 - "\x1f@\x03\x01\x00\x00\x1fD" + // 0x1F400301: 0x00001F44 - "\x1fA\x03\x01\x00\x00\x1fE" + // 0x1F410301: 0x00001F45 - "\x03\x9f\x03\x13\x00\x00\x1fH" + // 0x039F0313: 0x00001F48 - "\x03\x9f\x03\x14\x00\x00\x1fI" + // 0x039F0314: 0x00001F49 - "\x1fH\x03\x00\x00\x00\x1fJ" + // 0x1F480300: 0x00001F4A - "\x1fI\x03\x00\x00\x00\x1fK" + // 0x1F490300: 0x00001F4B - "\x1fH\x03\x01\x00\x00\x1fL" + // 0x1F480301: 0x00001F4C - "\x1fI\x03\x01\x00\x00\x1fM" + // 0x1F490301: 0x00001F4D - "\x03\xc5\x03\x13\x00\x00\x1fP" + // 0x03C50313: 0x00001F50 - "\x03\xc5\x03\x14\x00\x00\x1fQ" + // 0x03C50314: 0x00001F51 - "\x1fP\x03\x00\x00\x00\x1fR" + // 0x1F500300: 0x00001F52 - "\x1fQ\x03\x00\x00\x00\x1fS" + // 0x1F510300: 0x00001F53 - "\x1fP\x03\x01\x00\x00\x1fT" + // 0x1F500301: 0x00001F54 - "\x1fQ\x03\x01\x00\x00\x1fU" + // 0x1F510301: 0x00001F55 - "\x1fP\x03B\x00\x00\x1fV" + // 0x1F500342: 0x00001F56 - "\x1fQ\x03B\x00\x00\x1fW" + // 0x1F510342: 0x00001F57 - "\x03\xa5\x03\x14\x00\x00\x1fY" + // 0x03A50314: 0x00001F59 - "\x1fY\x03\x00\x00\x00\x1f[" + // 0x1F590300: 0x00001F5B - "\x1fY\x03\x01\x00\x00\x1f]" + // 0x1F590301: 0x00001F5D - "\x1fY\x03B\x00\x00\x1f_" + // 0x1F590342: 0x00001F5F - "\x03\xc9\x03\x13\x00\x00\x1f`" + // 0x03C90313: 0x00001F60 - "\x03\xc9\x03\x14\x00\x00\x1fa" + // 0x03C90314: 0x00001F61 - "\x1f`\x03\x00\x00\x00\x1fb" + // 0x1F600300: 0x00001F62 - "\x1fa\x03\x00\x00\x00\x1fc" + // 0x1F610300: 0x00001F63 - "\x1f`\x03\x01\x00\x00\x1fd" + // 0x1F600301: 0x00001F64 - "\x1fa\x03\x01\x00\x00\x1fe" + // 0x1F610301: 0x00001F65 - "\x1f`\x03B\x00\x00\x1ff" + // 0x1F600342: 0x00001F66 - "\x1fa\x03B\x00\x00\x1fg" + // 0x1F610342: 0x00001F67 - "\x03\xa9\x03\x13\x00\x00\x1fh" + // 0x03A90313: 0x00001F68 - "\x03\xa9\x03\x14\x00\x00\x1fi" + // 0x03A90314: 0x00001F69 - "\x1fh\x03\x00\x00\x00\x1fj" + // 0x1F680300: 0x00001F6A - "\x1fi\x03\x00\x00\x00\x1fk" + // 0x1F690300: 0x00001F6B - "\x1fh\x03\x01\x00\x00\x1fl" + // 0x1F680301: 0x00001F6C - "\x1fi\x03\x01\x00\x00\x1fm" + // 0x1F690301: 0x00001F6D - "\x1fh\x03B\x00\x00\x1fn" + // 0x1F680342: 0x00001F6E - "\x1fi\x03B\x00\x00\x1fo" + // 0x1F690342: 0x00001F6F - "\x03\xb1\x03\x00\x00\x00\x1fp" + // 0x03B10300: 0x00001F70 - "\x03\xb5\x03\x00\x00\x00\x1fr" + // 0x03B50300: 0x00001F72 - "\x03\xb7\x03\x00\x00\x00\x1ft" + // 0x03B70300: 0x00001F74 - "\x03\xb9\x03\x00\x00\x00\x1fv" + // 0x03B90300: 0x00001F76 - "\x03\xbf\x03\x00\x00\x00\x1fx" + // 0x03BF0300: 0x00001F78 - "\x03\xc5\x03\x00\x00\x00\x1fz" + // 0x03C50300: 0x00001F7A - "\x03\xc9\x03\x00\x00\x00\x1f|" + // 0x03C90300: 0x00001F7C - "\x1f\x00\x03E\x00\x00\x1f\x80" + // 0x1F000345: 0x00001F80 - "\x1f\x01\x03E\x00\x00\x1f\x81" + // 0x1F010345: 0x00001F81 - "\x1f\x02\x03E\x00\x00\x1f\x82" + // 0x1F020345: 0x00001F82 - "\x1f\x03\x03E\x00\x00\x1f\x83" + // 0x1F030345: 0x00001F83 - "\x1f\x04\x03E\x00\x00\x1f\x84" + // 0x1F040345: 0x00001F84 - "\x1f\x05\x03E\x00\x00\x1f\x85" + // 0x1F050345: 0x00001F85 - "\x1f\x06\x03E\x00\x00\x1f\x86" + // 0x1F060345: 0x00001F86 - "\x1f\a\x03E\x00\x00\x1f\x87" + // 0x1F070345: 0x00001F87 - "\x1f\b\x03E\x00\x00\x1f\x88" + // 0x1F080345: 0x00001F88 - "\x1f\t\x03E\x00\x00\x1f\x89" + // 0x1F090345: 0x00001F89 - "\x1f\n\x03E\x00\x00\x1f\x8a" + // 0x1F0A0345: 0x00001F8A - "\x1f\v\x03E\x00\x00\x1f\x8b" + // 0x1F0B0345: 0x00001F8B - "\x1f\f\x03E\x00\x00\x1f\x8c" + // 0x1F0C0345: 0x00001F8C - "\x1f\r\x03E\x00\x00\x1f\x8d" + // 0x1F0D0345: 0x00001F8D - "\x1f\x0e\x03E\x00\x00\x1f\x8e" + // 0x1F0E0345: 0x00001F8E - "\x1f\x0f\x03E\x00\x00\x1f\x8f" + // 0x1F0F0345: 0x00001F8F - "\x1f \x03E\x00\x00\x1f\x90" + // 0x1F200345: 0x00001F90 - "\x1f!\x03E\x00\x00\x1f\x91" + // 0x1F210345: 0x00001F91 - "\x1f\"\x03E\x00\x00\x1f\x92" + // 0x1F220345: 0x00001F92 - "\x1f#\x03E\x00\x00\x1f\x93" + // 0x1F230345: 0x00001F93 - "\x1f$\x03E\x00\x00\x1f\x94" + // 0x1F240345: 0x00001F94 - "\x1f%\x03E\x00\x00\x1f\x95" + // 0x1F250345: 0x00001F95 - "\x1f&\x03E\x00\x00\x1f\x96" + // 0x1F260345: 0x00001F96 - "\x1f'\x03E\x00\x00\x1f\x97" + // 0x1F270345: 0x00001F97 - "\x1f(\x03E\x00\x00\x1f\x98" + // 0x1F280345: 0x00001F98 - "\x1f)\x03E\x00\x00\x1f\x99" + // 0x1F290345: 0x00001F99 - "\x1f*\x03E\x00\x00\x1f\x9a" + // 0x1F2A0345: 0x00001F9A - "\x1f+\x03E\x00\x00\x1f\x9b" + // 0x1F2B0345: 0x00001F9B - "\x1f,\x03E\x00\x00\x1f\x9c" + // 0x1F2C0345: 0x00001F9C - "\x1f-\x03E\x00\x00\x1f\x9d" + // 0x1F2D0345: 0x00001F9D - "\x1f.\x03E\x00\x00\x1f\x9e" + // 0x1F2E0345: 0x00001F9E - "\x1f/\x03E\x00\x00\x1f\x9f" + // 0x1F2F0345: 0x00001F9F - "\x1f`\x03E\x00\x00\x1f\xa0" + // 0x1F600345: 0x00001FA0 - "\x1fa\x03E\x00\x00\x1f\xa1" + // 0x1F610345: 0x00001FA1 - "\x1fb\x03E\x00\x00\x1f\xa2" + // 0x1F620345: 0x00001FA2 - "\x1fc\x03E\x00\x00\x1f\xa3" + // 0x1F630345: 0x00001FA3 - "\x1fd\x03E\x00\x00\x1f\xa4" + // 0x1F640345: 0x00001FA4 - "\x1fe\x03E\x00\x00\x1f\xa5" + // 0x1F650345: 0x00001FA5 - "\x1ff\x03E\x00\x00\x1f\xa6" + // 0x1F660345: 0x00001FA6 - "\x1fg\x03E\x00\x00\x1f\xa7" + // 0x1F670345: 0x00001FA7 - "\x1fh\x03E\x00\x00\x1f\xa8" + // 0x1F680345: 0x00001FA8 - "\x1fi\x03E\x00\x00\x1f\xa9" + // 0x1F690345: 0x00001FA9 - "\x1fj\x03E\x00\x00\x1f\xaa" + // 0x1F6A0345: 0x00001FAA - "\x1fk\x03E\x00\x00\x1f\xab" + // 0x1F6B0345: 0x00001FAB - "\x1fl\x03E\x00\x00\x1f\xac" + // 0x1F6C0345: 0x00001FAC - "\x1fm\x03E\x00\x00\x1f\xad" + // 0x1F6D0345: 0x00001FAD - "\x1fn\x03E\x00\x00\x1f\xae" + // 0x1F6E0345: 0x00001FAE - "\x1fo\x03E\x00\x00\x1f\xaf" + // 0x1F6F0345: 0x00001FAF - "\x03\xb1\x03\x06\x00\x00\x1f\xb0" + // 0x03B10306: 0x00001FB0 - "\x03\xb1\x03\x04\x00\x00\x1f\xb1" + // 0x03B10304: 0x00001FB1 - "\x1fp\x03E\x00\x00\x1f\xb2" + // 0x1F700345: 0x00001FB2 - "\x03\xb1\x03E\x00\x00\x1f\xb3" + // 0x03B10345: 0x00001FB3 - "\x03\xac\x03E\x00\x00\x1f\xb4" + // 0x03AC0345: 0x00001FB4 - "\x03\xb1\x03B\x00\x00\x1f\xb6" + // 0x03B10342: 0x00001FB6 - "\x1f\xb6\x03E\x00\x00\x1f\xb7" + // 0x1FB60345: 0x00001FB7 - "\x03\x91\x03\x06\x00\x00\x1f\xb8" + // 0x03910306: 0x00001FB8 - "\x03\x91\x03\x04\x00\x00\x1f\xb9" + // 0x03910304: 0x00001FB9 - "\x03\x91\x03\x00\x00\x00\x1f\xba" + // 0x03910300: 0x00001FBA - "\x03\x91\x03E\x00\x00\x1f\xbc" + // 0x03910345: 0x00001FBC - "\x00\xa8\x03B\x00\x00\x1f\xc1" + // 0x00A80342: 0x00001FC1 - "\x1ft\x03E\x00\x00\x1f\xc2" + // 0x1F740345: 0x00001FC2 - "\x03\xb7\x03E\x00\x00\x1f\xc3" + // 0x03B70345: 0x00001FC3 - "\x03\xae\x03E\x00\x00\x1f\xc4" + // 0x03AE0345: 0x00001FC4 - "\x03\xb7\x03B\x00\x00\x1f\xc6" + // 0x03B70342: 0x00001FC6 - "\x1f\xc6\x03E\x00\x00\x1f\xc7" + // 0x1FC60345: 0x00001FC7 - "\x03\x95\x03\x00\x00\x00\x1f\xc8" + // 0x03950300: 0x00001FC8 - "\x03\x97\x03\x00\x00\x00\x1f\xca" + // 0x03970300: 0x00001FCA - "\x03\x97\x03E\x00\x00\x1f\xcc" + // 0x03970345: 0x00001FCC - "\x1f\xbf\x03\x00\x00\x00\x1f\xcd" + // 0x1FBF0300: 0x00001FCD - "\x1f\xbf\x03\x01\x00\x00\x1f\xce" + // 0x1FBF0301: 0x00001FCE - "\x1f\xbf\x03B\x00\x00\x1f\xcf" + // 0x1FBF0342: 0x00001FCF - "\x03\xb9\x03\x06\x00\x00\x1f\xd0" + // 0x03B90306: 0x00001FD0 - "\x03\xb9\x03\x04\x00\x00\x1f\xd1" + // 0x03B90304: 0x00001FD1 - "\x03\xca\x03\x00\x00\x00\x1f\xd2" + // 0x03CA0300: 0x00001FD2 - "\x03\xb9\x03B\x00\x00\x1f\xd6" + // 0x03B90342: 0x00001FD6 - "\x03\xca\x03B\x00\x00\x1f\xd7" + // 0x03CA0342: 0x00001FD7 - "\x03\x99\x03\x06\x00\x00\x1f\xd8" + // 0x03990306: 0x00001FD8 - "\x03\x99\x03\x04\x00\x00\x1f\xd9" + // 0x03990304: 0x00001FD9 - "\x03\x99\x03\x00\x00\x00\x1f\xda" + // 0x03990300: 0x00001FDA - "\x1f\xfe\x03\x00\x00\x00\x1f\xdd" + // 0x1FFE0300: 0x00001FDD - "\x1f\xfe\x03\x01\x00\x00\x1f\xde" + // 0x1FFE0301: 0x00001FDE - "\x1f\xfe\x03B\x00\x00\x1f\xdf" + // 0x1FFE0342: 0x00001FDF - "\x03\xc5\x03\x06\x00\x00\x1f\xe0" + // 0x03C50306: 0x00001FE0 - "\x03\xc5\x03\x04\x00\x00\x1f\xe1" + // 0x03C50304: 0x00001FE1 - "\x03\xcb\x03\x00\x00\x00\x1f\xe2" + // 0x03CB0300: 0x00001FE2 - "\x03\xc1\x03\x13\x00\x00\x1f\xe4" + // 0x03C10313: 0x00001FE4 - "\x03\xc1\x03\x14\x00\x00\x1f\xe5" + // 0x03C10314: 0x00001FE5 - "\x03\xc5\x03B\x00\x00\x1f\xe6" + // 0x03C50342: 0x00001FE6 - "\x03\xcb\x03B\x00\x00\x1f\xe7" + // 0x03CB0342: 0x00001FE7 - "\x03\xa5\x03\x06\x00\x00\x1f\xe8" + // 0x03A50306: 0x00001FE8 - "\x03\xa5\x03\x04\x00\x00\x1f\xe9" + // 0x03A50304: 0x00001FE9 - "\x03\xa5\x03\x00\x00\x00\x1f\xea" + // 0x03A50300: 0x00001FEA - "\x03\xa1\x03\x14\x00\x00\x1f\xec" + // 0x03A10314: 0x00001FEC - "\x00\xa8\x03\x00\x00\x00\x1f\xed" + // 0x00A80300: 0x00001FED - "\x1f|\x03E\x00\x00\x1f\xf2" + // 0x1F7C0345: 0x00001FF2 - "\x03\xc9\x03E\x00\x00\x1f\xf3" + // 0x03C90345: 0x00001FF3 - "\x03\xce\x03E\x00\x00\x1f\xf4" + // 0x03CE0345: 0x00001FF4 - "\x03\xc9\x03B\x00\x00\x1f\xf6" + // 0x03C90342: 0x00001FF6 - "\x1f\xf6\x03E\x00\x00\x1f\xf7" + // 0x1FF60345: 0x00001FF7 - "\x03\x9f\x03\x00\x00\x00\x1f\xf8" + // 0x039F0300: 0x00001FF8 - "\x03\xa9\x03\x00\x00\x00\x1f\xfa" + // 0x03A90300: 0x00001FFA - "\x03\xa9\x03E\x00\x00\x1f\xfc" + // 0x03A90345: 0x00001FFC - "!\x90\x038\x00\x00!\x9a" + // 0x21900338: 0x0000219A - "!\x92\x038\x00\x00!\x9b" + // 0x21920338: 0x0000219B - "!\x94\x038\x00\x00!\xae" + // 0x21940338: 0x000021AE - "!\xd0\x038\x00\x00!\xcd" + // 0x21D00338: 0x000021CD - "!\xd4\x038\x00\x00!\xce" + // 0x21D40338: 0x000021CE - "!\xd2\x038\x00\x00!\xcf" + // 0x21D20338: 0x000021CF - "\"\x03\x038\x00\x00\"\x04" + // 0x22030338: 0x00002204 - "\"\b\x038\x00\x00\"\t" + // 0x22080338: 0x00002209 - "\"\v\x038\x00\x00\"\f" + // 0x220B0338: 0x0000220C - "\"#\x038\x00\x00\"$" + // 0x22230338: 0x00002224 - "\"%\x038\x00\x00\"&" + // 0x22250338: 0x00002226 - "\"<\x038\x00\x00\"A" + // 0x223C0338: 0x00002241 - "\"C\x038\x00\x00\"D" + // 0x22430338: 0x00002244 - "\"E\x038\x00\x00\"G" + // 0x22450338: 0x00002247 - "\"H\x038\x00\x00\"I" + // 0x22480338: 0x00002249 - "\x00=\x038\x00\x00\"`" + // 0x003D0338: 0x00002260 - "\"a\x038\x00\x00\"b" + // 0x22610338: 0x00002262 - "\"M\x038\x00\x00\"m" + // 0x224D0338: 0x0000226D - "\x00<\x038\x00\x00\"n" + // 0x003C0338: 0x0000226E - "\x00>\x038\x00\x00\"o" + // 0x003E0338: 0x0000226F - "\"d\x038\x00\x00\"p" + // 0x22640338: 0x00002270 - "\"e\x038\x00\x00\"q" + // 0x22650338: 0x00002271 - "\"r\x038\x00\x00\"t" + // 0x22720338: 0x00002274 - "\"s\x038\x00\x00\"u" + // 0x22730338: 0x00002275 - "\"v\x038\x00\x00\"x" + // 0x22760338: 0x00002278 - "\"w\x038\x00\x00\"y" + // 0x22770338: 0x00002279 - "\"z\x038\x00\x00\"\x80" + // 0x227A0338: 0x00002280 - "\"{\x038\x00\x00\"\x81" + // 0x227B0338: 0x00002281 - "\"\x82\x038\x00\x00\"\x84" + // 0x22820338: 0x00002284 - "\"\x83\x038\x00\x00\"\x85" + // 0x22830338: 0x00002285 - "\"\x86\x038\x00\x00\"\x88" + // 0x22860338: 0x00002288 - "\"\x87\x038\x00\x00\"\x89" + // 0x22870338: 0x00002289 - "\"\xa2\x038\x00\x00\"\xac" + // 0x22A20338: 0x000022AC - "\"\xa8\x038\x00\x00\"\xad" + // 0x22A80338: 0x000022AD - "\"\xa9\x038\x00\x00\"\xae" + // 0x22A90338: 0x000022AE - "\"\xab\x038\x00\x00\"\xaf" + // 0x22AB0338: 0x000022AF - "\"|\x038\x00\x00\"\xe0" + // 0x227C0338: 0x000022E0 - "\"}\x038\x00\x00\"\xe1" + // 0x227D0338: 0x000022E1 - "\"\x91\x038\x00\x00\"\xe2" + // 0x22910338: 0x000022E2 - "\"\x92\x038\x00\x00\"\xe3" + // 0x22920338: 0x000022E3 - "\"\xb2\x038\x00\x00\"\xea" + // 0x22B20338: 0x000022EA - "\"\xb3\x038\x00\x00\"\xeb" + // 0x22B30338: 0x000022EB - "\"\xb4\x038\x00\x00\"\xec" + // 0x22B40338: 0x000022EC - "\"\xb5\x038\x00\x00\"\xed" + // 0x22B50338: 0x000022ED - "0K0\x99\x00\x000L" + // 0x304B3099: 0x0000304C - "0M0\x99\x00\x000N" + // 0x304D3099: 0x0000304E - "0O0\x99\x00\x000P" + // 0x304F3099: 0x00003050 - "0Q0\x99\x00\x000R" + // 0x30513099: 0x00003052 - "0S0\x99\x00\x000T" + // 0x30533099: 0x00003054 - "0U0\x99\x00\x000V" + // 0x30553099: 0x00003056 - "0W0\x99\x00\x000X" + // 0x30573099: 0x00003058 - "0Y0\x99\x00\x000Z" + // 0x30593099: 0x0000305A - "0[0\x99\x00\x000\\" + // 0x305B3099: 0x0000305C - "0]0\x99\x00\x000^" + // 0x305D3099: 0x0000305E - "0_0\x99\x00\x000`" + // 0x305F3099: 0x00003060 - "0a0\x99\x00\x000b" + // 0x30613099: 0x00003062 - "0d0\x99\x00\x000e" + // 0x30643099: 0x00003065 - "0f0\x99\x00\x000g" + // 0x30663099: 0x00003067 - "0h0\x99\x00\x000i" + // 0x30683099: 0x00003069 - "0o0\x99\x00\x000p" + // 0x306F3099: 0x00003070 - "0o0\x9a\x00\x000q" + // 0x306F309A: 0x00003071 - "0r0\x99\x00\x000s" + // 0x30723099: 0x00003073 - "0r0\x9a\x00\x000t" + // 0x3072309A: 0x00003074 - "0u0\x99\x00\x000v" + // 0x30753099: 0x00003076 - "0u0\x9a\x00\x000w" + // 0x3075309A: 0x00003077 - "0x0\x99\x00\x000y" + // 0x30783099: 0x00003079 - "0x0\x9a\x00\x000z" + // 0x3078309A: 0x0000307A - "0{0\x99\x00\x000|" + // 0x307B3099: 0x0000307C - "0{0\x9a\x00\x000}" + // 0x307B309A: 0x0000307D - "0F0\x99\x00\x000\x94" + // 0x30463099: 0x00003094 - "0\x9d0\x99\x00\x000\x9e" + // 0x309D3099: 0x0000309E - "0\xab0\x99\x00\x000\xac" + // 0x30AB3099: 0x000030AC - "0\xad0\x99\x00\x000\xae" + // 0x30AD3099: 0x000030AE - "0\xaf0\x99\x00\x000\xb0" + // 0x30AF3099: 0x000030B0 - "0\xb10\x99\x00\x000\xb2" + // 0x30B13099: 0x000030B2 - "0\xb30\x99\x00\x000\xb4" + // 0x30B33099: 0x000030B4 - "0\xb50\x99\x00\x000\xb6" + // 0x30B53099: 0x000030B6 - "0\xb70\x99\x00\x000\xb8" + // 0x30B73099: 0x000030B8 - "0\xb90\x99\x00\x000\xba" + // 0x30B93099: 0x000030BA - "0\xbb0\x99\x00\x000\xbc" + // 0x30BB3099: 0x000030BC - "0\xbd0\x99\x00\x000\xbe" + // 0x30BD3099: 0x000030BE - "0\xbf0\x99\x00\x000\xc0" + // 0x30BF3099: 0x000030C0 - "0\xc10\x99\x00\x000\xc2" + // 0x30C13099: 0x000030C2 - "0\xc40\x99\x00\x000\xc5" + // 0x30C43099: 0x000030C5 - "0\xc60\x99\x00\x000\xc7" + // 0x30C63099: 0x000030C7 - "0\xc80\x99\x00\x000\xc9" + // 0x30C83099: 0x000030C9 - "0\xcf0\x99\x00\x000\xd0" + // 0x30CF3099: 0x000030D0 - "0\xcf0\x9a\x00\x000\xd1" + // 0x30CF309A: 0x000030D1 - "0\xd20\x99\x00\x000\xd3" + // 0x30D23099: 0x000030D3 - "0\xd20\x9a\x00\x000\xd4" + // 0x30D2309A: 0x000030D4 - "0\xd50\x99\x00\x000\xd6" + // 0x30D53099: 0x000030D6 - "0\xd50\x9a\x00\x000\xd7" + // 0x30D5309A: 0x000030D7 - "0\xd80\x99\x00\x000\xd9" + // 0x30D83099: 0x000030D9 - "0\xd80\x9a\x00\x000\xda" + // 0x30D8309A: 0x000030DA - "0\xdb0\x99\x00\x000\xdc" + // 0x30DB3099: 0x000030DC - "0\xdb0\x9a\x00\x000\xdd" + // 0x30DB309A: 0x000030DD - "0\xa60\x99\x00\x000\xf4" + // 0x30A63099: 0x000030F4 - "0\xef0\x99\x00\x000\xf7" + // 0x30EF3099: 0x000030F7 - "0\xf00\x99\x00\x000\xf8" + // 0x30F03099: 0x000030F8 - "0\xf10\x99\x00\x000\xf9" + // 0x30F13099: 0x000030F9 - "0\xf20\x99\x00\x000\xfa" + // 0x30F23099: 0x000030FA - "0\xfd0\x99\x00\x000\xfe" + // 0x30FD3099: 0x000030FE - "\x05\xd2\x03\a\x00\x01\x05\xc9" + // 0x05D20307: 0x000105C9 - "\x05\xda\x03\a\x00\x01\x05\xe4" + // 0x05DA0307: 0x000105E4 - "\x10\x99\x10\xba\x00\x01\x10\x9a" + // 0x109910BA: 0x0001109A - "\x10\x9b\x10\xba\x00\x01\x10\x9c" + // 0x109B10BA: 0x0001109C - "\x10\xa5\x10\xba\x00\x01\x10\xab" + // 0x10A510BA: 0x000110AB - "\x111\x11'\x00\x01\x11." + // 0x11311127: 0x0001112E - "\x112\x11'\x00\x01\x11/" + // 0x11321127: 0x0001112F - "\x13G\x13>\x00\x01\x13K" + // 0x1347133E: 0x0001134B - "\x13G\x13W\x00\x01\x13L" + // 0x13471357: 0x0001134C - "\x13\x82\x13\xc9\x00\x01\x13\x83" + // 0x138213C9: 0x00011383 - "\x13\x84\x13\xbb\x00\x01\x13\x85" + // 0x138413BB: 0x00011385 - "\x13\x8b\x13\xc2\x00\x01\x13\x8e" + // 0x138B13C2: 0x0001138E - "\x13\x90\x13\xc9\x00\x01\x13\x91" + // 0x139013C9: 0x00011391 - "\x13\xc2\x13\xc2\x00\x01\x13\xc5" + // 0x13C213C2: 0x000113C5 - "\x13\xc2\x13\xb8\x00\x01\x13\xc7" + // 0x13C213B8: 0x000113C7 - "\x13\xc2\x13\xc9\x00\x01\x13\xc8" + // 0x13C213C9: 0x000113C8 - "\x14\xb9\x14\xba\x00\x01\x14\xbb" + // 0x14B914BA: 0x000114BB - "\x14\xb9\x14\xb0\x00\x01\x14\xbc" + // 0x14B914B0: 0x000114BC - "\x14\xb9\x14\xbd\x00\x01\x14\xbe" + // 0x14B914BD: 0x000114BE - "\x15\xb8\x15\xaf\x00\x01\x15\xba" + // 0x15B815AF: 0x000115BA - "\x15\xb9\x15\xaf\x00\x01\x15\xbb" + // 0x15B915AF: 0x000115BB - "\x195\x190\x00\x01\x198" + // 0x19351930: 0x00011938 - "a\x1ea\x1e\x00\x01a!" + // 0x611E611E: 0x00016121 - "a\x1ea)\x00\x01a\"" + // 0x611E6129: 0x00016122 - "a\x1ea\x1f\x00\x01a#" + // 0x611E611F: 0x00016123 - "a)a\x1f\x00\x01a$" + // 0x6129611F: 0x00016124 - "a\x1ea \x00\x01a%" + // 0x611E6120: 0x00016125 - "a!a\x1f\x00\x01a&" + // 0x6121611F: 0x00016126 - "a\"a\x1f\x00\x01a'" + // 0x6122611F: 0x00016127 - "a!a \x00\x01a(" + // 0x61216120: 0x00016128 - "mgmg\x00\x01mh" + // 0x6D676D67: 0x00016D68 - "mcmg\x00\x01mi" + // 0x6D636D67: 0x00016D69 - "mimg\x00\x01mj" + // 0x6D696D67: 0x00016D6A + "\x00\x01\x04\x00`\x00\x00\xc0" + // 0x00041 0x00300: 0x000C0 + "\x00\x01\x04\x00` \x00\xc1" + // 0x00041 0x00301: 0x000C1 + "\x00\x01\x04\x00`@\x00\xc2" + // 0x00041 0x00302: 0x000C2 + "\x00\x01\x04\x00``\x00\xc3" + // 0x00041 0x00303: 0x000C3 + "\x00\x01\x04\x00a\x00\x00\xc4" + // 0x00041 0x00308: 0x000C4 + "\x00\x01\x04\x00a@\x00\xc5" + // 0x00041 0x0030A: 0x000C5 + "\x00\x01\f\x00d\xe0\x00\xc7" + // 0x00043 0x00327: 0x000C7 + "\x00\x01\x14\x00`\x00\x00\xc8" + // 0x00045 0x00300: 0x000C8 + "\x00\x01\x14\x00` \x00\xc9" + // 0x00045 0x00301: 0x000C9 + "\x00\x01\x14\x00`@\x00\xca" + // 0x00045 0x00302: 0x000CA + "\x00\x01\x14\x00a\x00\x00\xcb" + // 0x00045 0x00308: 0x000CB + "\x00\x01$\x00`\x00\x00\xcc" + // 0x00049 0x00300: 0x000CC + "\x00\x01$\x00` \x00\xcd" + // 0x00049 0x00301: 0x000CD + "\x00\x01$\x00`@\x00\xce" + // 0x00049 0x00302: 0x000CE + "\x00\x01$\x00a\x00\x00\xcf" + // 0x00049 0x00308: 0x000CF + "\x00\x018\x00``\x00\xd1" + // 0x0004E 0x00303: 0x000D1 + "\x00\x01<\x00`\x00\x00\xd2" + // 0x0004F 0x00300: 0x000D2 + "\x00\x01<\x00` \x00\xd3" + // 0x0004F 0x00301: 0x000D3 + "\x00\x01<\x00`@\x00\xd4" + // 0x0004F 0x00302: 0x000D4 + "\x00\x01<\x00``\x00\xd5" + // 0x0004F 0x00303: 0x000D5 + "\x00\x01<\x00a\x00\x00\xd6" + // 0x0004F 0x00308: 0x000D6 + "\x00\x01T\x00`\x00\x00\xd9" + // 0x00055 0x00300: 0x000D9 + "\x00\x01T\x00` \x00\xda" + // 0x00055 0x00301: 0x000DA + "\x00\x01T\x00`@\x00\xdb" + // 0x00055 0x00302: 0x000DB + "\x00\x01T\x00a\x00\x00\xdc" + // 0x00055 0x00308: 0x000DC + "\x00\x01d\x00` \x00\xdd" + // 0x00059 0x00301: 0x000DD + "\x00\x01\x84\x00`\x00\x00\xe0" + // 0x00061 0x00300: 0x000E0 + "\x00\x01\x84\x00` \x00\xe1" + // 0x00061 0x00301: 0x000E1 + "\x00\x01\x84\x00`@\x00\xe2" + // 0x00061 0x00302: 0x000E2 + "\x00\x01\x84\x00``\x00\xe3" + // 0x00061 0x00303: 0x000E3 + "\x00\x01\x84\x00a\x00\x00\xe4" + // 0x00061 0x00308: 0x000E4 + "\x00\x01\x84\x00a@\x00\xe5" + // 0x00061 0x0030A: 0x000E5 + "\x00\x01\x8c\x00d\xe0\x00\xe7" + // 0x00063 0x00327: 0x000E7 + "\x00\x01\x94\x00`\x00\x00\xe8" + // 0x00065 0x00300: 0x000E8 + "\x00\x01\x94\x00` \x00\xe9" + // 0x00065 0x00301: 0x000E9 + "\x00\x01\x94\x00`@\x00\xea" + // 0x00065 0x00302: 0x000EA + "\x00\x01\x94\x00a\x00\x00\xeb" + // 0x00065 0x00308: 0x000EB + "\x00\x01\xa4\x00`\x00\x00\xec" + // 0x00069 0x00300: 0x000EC + "\x00\x01\xa4\x00` \x00\xed" + // 0x00069 0x00301: 0x000ED + "\x00\x01\xa4\x00`@\x00\xee" + // 0x00069 0x00302: 0x000EE + "\x00\x01\xa4\x00a\x00\x00\xef" + // 0x00069 0x00308: 0x000EF + "\x00\x01\xb8\x00``\x00\xf1" + // 0x0006E 0x00303: 0x000F1 + "\x00\x01\xbc\x00`\x00\x00\xf2" + // 0x0006F 0x00300: 0x000F2 + "\x00\x01\xbc\x00` \x00\xf3" + // 0x0006F 0x00301: 0x000F3 + "\x00\x01\xbc\x00`@\x00\xf4" + // 0x0006F 0x00302: 0x000F4 + "\x00\x01\xbc\x00``\x00\xf5" + // 0x0006F 0x00303: 0x000F5 + "\x00\x01\xbc\x00a\x00\x00\xf6" + // 0x0006F 0x00308: 0x000F6 + "\x00\x01\xd4\x00`\x00\x00\xf9" + // 0x00075 0x00300: 0x000F9 + "\x00\x01\xd4\x00` \x00\xfa" + // 0x00075 0x00301: 0x000FA + "\x00\x01\xd4\x00`@\x00\xfb" + // 0x00075 0x00302: 0x000FB + "\x00\x01\xd4\x00a\x00\x00\xfc" + // 0x00075 0x00308: 0x000FC + "\x00\x01\xe4\x00` \x00\xfd" + // 0x00079 0x00301: 0x000FD + "\x00\x01\xe4\x00a\x00\x00\xff" + // 0x00079 0x00308: 0x000FF + "\x00\x01\x04\x00`\x80\x01\x00" + // 0x00041 0x00304: 0x00100 + "\x00\x01\x84\x00`\x80\x01\x01" + // 0x00061 0x00304: 0x00101 + "\x00\x01\x04\x00`\xc0\x01\x02" + // 0x00041 0x00306: 0x00102 + "\x00\x01\x84\x00`\xc0\x01\x03" + // 0x00061 0x00306: 0x00103 + "\x00\x01\x04\x00e\x00\x01\x04" + // 0x00041 0x00328: 0x00104 + "\x00\x01\x84\x00e\x00\x01\x05" + // 0x00061 0x00328: 0x00105 + "\x00\x01\f\x00` \x01\x06" + // 0x00043 0x00301: 0x00106 + "\x00\x01\x8c\x00` \x01\a" + // 0x00063 0x00301: 0x00107 + "\x00\x01\f\x00`@\x01\b" + // 0x00043 0x00302: 0x00108 + "\x00\x01\x8c\x00`@\x01\t" + // 0x00063 0x00302: 0x00109 + "\x00\x01\f\x00`\xe0\x01\n" + // 0x00043 0x00307: 0x0010A + "\x00\x01\x8c\x00`\xe0\x01\v" + // 0x00063 0x00307: 0x0010B + "\x00\x01\f\x00a\x80\x01\f" + // 0x00043 0x0030C: 0x0010C + "\x00\x01\x8c\x00a\x80\x01\r" + // 0x00063 0x0030C: 0x0010D + "\x00\x01\x10\x00a\x80\x01\x0e" + // 0x00044 0x0030C: 0x0010E + "\x00\x01\x90\x00a\x80\x01\x0f" + // 0x00064 0x0030C: 0x0010F + "\x00\x01\x14\x00`\x80\x01\x12" + // 0x00045 0x00304: 0x00112 + "\x00\x01\x94\x00`\x80\x01\x13" + // 0x00065 0x00304: 0x00113 + "\x00\x01\x14\x00`\xc0\x01\x14" + // 0x00045 0x00306: 0x00114 + "\x00\x01\x94\x00`\xc0\x01\x15" + // 0x00065 0x00306: 0x00115 + "\x00\x01\x14\x00`\xe0\x01\x16" + // 0x00045 0x00307: 0x00116 + "\x00\x01\x94\x00`\xe0\x01\x17" + // 0x00065 0x00307: 0x00117 + "\x00\x01\x14\x00e\x00\x01\x18" + // 0x00045 0x00328: 0x00118 + "\x00\x01\x94\x00e\x00\x01\x19" + // 0x00065 0x00328: 0x00119 + "\x00\x01\x14\x00a\x80\x01\x1a" + // 0x00045 0x0030C: 0x0011A + "\x00\x01\x94\x00a\x80\x01\x1b" + // 0x00065 0x0030C: 0x0011B + "\x00\x01\x1c\x00`@\x01\x1c" + // 0x00047 0x00302: 0x0011C + "\x00\x01\x9c\x00`@\x01\x1d" + // 0x00067 0x00302: 0x0011D + "\x00\x01\x1c\x00`\xc0\x01\x1e" + // 0x00047 0x00306: 0x0011E + "\x00\x01\x9c\x00`\xc0\x01\x1f" + // 0x00067 0x00306: 0x0011F + "\x00\x01\x1c\x00`\xe0\x01 " + // 0x00047 0x00307: 0x00120 + "\x00\x01\x9c\x00`\xe0\x01!" + // 0x00067 0x00307: 0x00121 + "\x00\x01\x1c\x00d\xe0\x01\"" + // 0x00047 0x00327: 0x00122 + "\x00\x01\x9c\x00d\xe0\x01#" + // 0x00067 0x00327: 0x00123 + "\x00\x01 \x00`@\x01$" + // 0x00048 0x00302: 0x00124 + "\x00\x01\xa0\x00`@\x01%" + // 0x00068 0x00302: 0x00125 + "\x00\x01$\x00``\x01(" + // 0x00049 0x00303: 0x00128 + "\x00\x01\xa4\x00``\x01)" + // 0x00069 0x00303: 0x00129 + "\x00\x01$\x00`\x80\x01*" + // 0x00049 0x00304: 0x0012A + "\x00\x01\xa4\x00`\x80\x01+" + // 0x00069 0x00304: 0x0012B + "\x00\x01$\x00`\xc0\x01," + // 0x00049 0x00306: 0x0012C + "\x00\x01\xa4\x00`\xc0\x01-" + // 0x00069 0x00306: 0x0012D + "\x00\x01$\x00e\x00\x01." + // 0x00049 0x00328: 0x0012E + "\x00\x01\xa4\x00e\x00\x01/" + // 0x00069 0x00328: 0x0012F + "\x00\x01$\x00`\xe0\x010" + // 0x00049 0x00307: 0x00130 + "\x00\x01(\x00`@\x014" + // 0x0004A 0x00302: 0x00134 + "\x00\x01\xa8\x00`@\x015" + // 0x0006A 0x00302: 0x00135 + "\x00\x01,\x00d\xe0\x016" + // 0x0004B 0x00327: 0x00136 + "\x00\x01\xac\x00d\xe0\x017" + // 0x0006B 0x00327: 0x00137 + "\x00\x010\x00` \x019" + // 0x0004C 0x00301: 0x00139 + "\x00\x01\xb0\x00` \x01:" + // 0x0006C 0x00301: 0x0013A + "\x00\x010\x00d\xe0\x01;" + // 0x0004C 0x00327: 0x0013B + "\x00\x01\xb0\x00d\xe0\x01<" + // 0x0006C 0x00327: 0x0013C + "\x00\x010\x00a\x80\x01=" + // 0x0004C 0x0030C: 0x0013D + "\x00\x01\xb0\x00a\x80\x01>" + // 0x0006C 0x0030C: 0x0013E + "\x00\x018\x00` \x01C" + // 0x0004E 0x00301: 0x00143 + "\x00\x01\xb8\x00` \x01D" + // 0x0006E 0x00301: 0x00144 + "\x00\x018\x00d\xe0\x01E" + // 0x0004E 0x00327: 0x00145 + "\x00\x01\xb8\x00d\xe0\x01F" + // 0x0006E 0x00327: 0x00146 + "\x00\x018\x00a\x80\x01G" + // 0x0004E 0x0030C: 0x00147 + "\x00\x01\xb8\x00a\x80\x01H" + // 0x0006E 0x0030C: 0x00148 + "\x00\x01<\x00`\x80\x01L" + // 0x0004F 0x00304: 0x0014C + "\x00\x01\xbc\x00`\x80\x01M" + // 0x0006F 0x00304: 0x0014D + "\x00\x01<\x00`\xc0\x01N" + // 0x0004F 0x00306: 0x0014E + "\x00\x01\xbc\x00`\xc0\x01O" + // 0x0006F 0x00306: 0x0014F + "\x00\x01<\x00a`\x01P" + // 0x0004F 0x0030B: 0x00150 + "\x00\x01\xbc\x00a`\x01Q" + // 0x0006F 0x0030B: 0x00151 + "\x00\x01H\x00` \x01T" + // 0x00052 0x00301: 0x00154 + "\x00\x01\xc8\x00` \x01U" + // 0x00072 0x00301: 0x00155 + "\x00\x01H\x00d\xe0\x01V" + // 0x00052 0x00327: 0x00156 + "\x00\x01\xc8\x00d\xe0\x01W" + // 0x00072 0x00327: 0x00157 + "\x00\x01H\x00a\x80\x01X" + // 0x00052 0x0030C: 0x00158 + "\x00\x01\xc8\x00a\x80\x01Y" + // 0x00072 0x0030C: 0x00159 + "\x00\x01L\x00` \x01Z" + // 0x00053 0x00301: 0x0015A + "\x00\x01\xcc\x00` \x01[" + // 0x00073 0x00301: 0x0015B + "\x00\x01L\x00`@\x01\\" + // 0x00053 0x00302: 0x0015C + "\x00\x01\xcc\x00`@\x01]" + // 0x00073 0x00302: 0x0015D + "\x00\x01L\x00d\xe0\x01^" + // 0x00053 0x00327: 0x0015E + "\x00\x01\xcc\x00d\xe0\x01_" + // 0x00073 0x00327: 0x0015F + "\x00\x01L\x00a\x80\x01`" + // 0x00053 0x0030C: 0x00160 + "\x00\x01\xcc\x00a\x80\x01a" + // 0x00073 0x0030C: 0x00161 + "\x00\x01P\x00d\xe0\x01b" + // 0x00054 0x00327: 0x00162 + "\x00\x01\xd0\x00d\xe0\x01c" + // 0x00074 0x00327: 0x00163 + "\x00\x01P\x00a\x80\x01d" + // 0x00054 0x0030C: 0x00164 + "\x00\x01\xd0\x00a\x80\x01e" + // 0x00074 0x0030C: 0x00165 + "\x00\x01T\x00``\x01h" + // 0x00055 0x00303: 0x00168 + "\x00\x01\xd4\x00``\x01i" + // 0x00075 0x00303: 0x00169 + "\x00\x01T\x00`\x80\x01j" + // 0x00055 0x00304: 0x0016A + "\x00\x01\xd4\x00`\x80\x01k" + // 0x00075 0x00304: 0x0016B + "\x00\x01T\x00`\xc0\x01l" + // 0x00055 0x00306: 0x0016C + "\x00\x01\xd4\x00`\xc0\x01m" + // 0x00075 0x00306: 0x0016D + "\x00\x01T\x00a@\x01n" + // 0x00055 0x0030A: 0x0016E + "\x00\x01\xd4\x00a@\x01o" + // 0x00075 0x0030A: 0x0016F + "\x00\x01T\x00a`\x01p" + // 0x00055 0x0030B: 0x00170 + "\x00\x01\xd4\x00a`\x01q" + // 0x00075 0x0030B: 0x00171 + "\x00\x01T\x00e\x00\x01r" + // 0x00055 0x00328: 0x00172 + "\x00\x01\xd4\x00e\x00\x01s" + // 0x00075 0x00328: 0x00173 + "\x00\x01\\\x00`@\x01t" + // 0x00057 0x00302: 0x00174 + "\x00\x01\xdc\x00`@\x01u" + // 0x00077 0x00302: 0x00175 + "\x00\x01d\x00`@\x01v" + // 0x00059 0x00302: 0x00176 + "\x00\x01\xe4\x00`@\x01w" + // 0x00079 0x00302: 0x00177 + "\x00\x01d\x00a\x00\x01x" + // 0x00059 0x00308: 0x00178 + "\x00\x01h\x00` \x01y" + // 0x0005A 0x00301: 0x00179 + "\x00\x01\xe8\x00` \x01z" + // 0x0007A 0x00301: 0x0017A + "\x00\x01h\x00`\xe0\x01{" + // 0x0005A 0x00307: 0x0017B + "\x00\x01\xe8\x00`\xe0\x01|" + // 0x0007A 0x00307: 0x0017C + "\x00\x01h\x00a\x80\x01}" + // 0x0005A 0x0030C: 0x0017D + "\x00\x01\xe8\x00a\x80\x01~" + // 0x0007A 0x0030C: 0x0017E + "\x00\x01<\x00c`\x01\xa0" + // 0x0004F 0x0031B: 0x001A0 + "\x00\x01\xbc\x00c`\x01\xa1" + // 0x0006F 0x0031B: 0x001A1 + "\x00\x01T\x00c`\x01\xaf" + // 0x00055 0x0031B: 0x001AF + "\x00\x01\xd4\x00c`\x01\xb0" + // 0x00075 0x0031B: 0x001B0 + "\x00\x01\x04\x00a\x80\x01\xcd" + // 0x00041 0x0030C: 0x001CD + "\x00\x01\x84\x00a\x80\x01\xce" + // 0x00061 0x0030C: 0x001CE + "\x00\x01$\x00a\x80\x01\xcf" + // 0x00049 0x0030C: 0x001CF + "\x00\x01\xa4\x00a\x80\x01\xd0" + // 0x00069 0x0030C: 0x001D0 + "\x00\x01<\x00a\x80\x01\xd1" + // 0x0004F 0x0030C: 0x001D1 + "\x00\x01\xbc\x00a\x80\x01\xd2" + // 0x0006F 0x0030C: 0x001D2 + "\x00\x01T\x00a\x80\x01\xd3" + // 0x00055 0x0030C: 0x001D3 + "\x00\x01\xd4\x00a\x80\x01\xd4" + // 0x00075 0x0030C: 0x001D4 + "\x00\x03p\x00`\x80\x01\xd5" + // 0x000DC 0x00304: 0x001D5 + "\x00\x03\xf0\x00`\x80\x01\xd6" + // 0x000FC 0x00304: 0x001D6 + "\x00\x03p\x00` \x01\xd7" + // 0x000DC 0x00301: 0x001D7 + "\x00\x03\xf0\x00` \x01\xd8" + // 0x000FC 0x00301: 0x001D8 + "\x00\x03p\x00a\x80\x01\xd9" + // 0x000DC 0x0030C: 0x001D9 + "\x00\x03\xf0\x00a\x80\x01\xda" + // 0x000FC 0x0030C: 0x001DA + "\x00\x03p\x00`\x00\x01\xdb" + // 0x000DC 0x00300: 0x001DB + "\x00\x03\xf0\x00`\x00\x01\xdc" + // 0x000FC 0x00300: 0x001DC + "\x00\x03\x10\x00`\x80\x01\xde" + // 0x000C4 0x00304: 0x001DE + "\x00\x03\x90\x00`\x80\x01\xdf" + // 0x000E4 0x00304: 0x001DF + "\x00\b\x98\x00`\x80\x01\xe0" + // 0x00226 0x00304: 0x001E0 + "\x00\b\x9c\x00`\x80\x01\xe1" + // 0x00227 0x00304: 0x001E1 + "\x00\x03\x18\x00`\x80\x01\xe2" + // 0x000C6 0x00304: 0x001E2 + "\x00\x03\x98\x00`\x80\x01\xe3" + // 0x000E6 0x00304: 0x001E3 + "\x00\x01\x1c\x00a\x80\x01\xe6" + // 0x00047 0x0030C: 0x001E6 + "\x00\x01\x9c\x00a\x80\x01\xe7" + // 0x00067 0x0030C: 0x001E7 + "\x00\x01,\x00a\x80\x01\xe8" + // 0x0004B 0x0030C: 0x001E8 + "\x00\x01\xac\x00a\x80\x01\xe9" + // 0x0006B 0x0030C: 0x001E9 + "\x00\x01<\x00e\x00\x01\xea" + // 0x0004F 0x00328: 0x001EA + "\x00\x01\xbc\x00e\x00\x01\xeb" + // 0x0006F 0x00328: 0x001EB + "\x00\a\xa8\x00`\x80\x01\xec" + // 0x001EA 0x00304: 0x001EC + "\x00\a\xac\x00`\x80\x01\xed" + // 0x001EB 0x00304: 0x001ED + "\x00\x06\xdc\x00a\x80\x01\xee" + // 0x001B7 0x0030C: 0x001EE + "\x00\nH\x00a\x80\x01\xef" + // 0x00292 0x0030C: 0x001EF + "\x00\x01\xa8\x00a\x80\x01\xf0" + // 0x0006A 0x0030C: 0x001F0 + "\x00\x01\x1c\x00` \x01\xf4" + // 0x00047 0x00301: 0x001F4 + "\x00\x01\x9c\x00` \x01\xf5" + // 0x00067 0x00301: 0x001F5 + "\x00\x018\x00`\x00\x01\xf8" + // 0x0004E 0x00300: 0x001F8 + "\x00\x01\xb8\x00`\x00\x01\xf9" + // 0x0006E 0x00300: 0x001F9 + "\x00\x03\x14\x00` \x01\xfa" + // 0x000C5 0x00301: 0x001FA + "\x00\x03\x94\x00` \x01\xfb" + // 0x000E5 0x00301: 0x001FB + "\x00\x03\x18\x00` \x01\xfc" + // 0x000C6 0x00301: 0x001FC + "\x00\x03\x98\x00` \x01\xfd" + // 0x000E6 0x00301: 0x001FD + "\x00\x03`\x00` \x01\xfe" + // 0x000D8 0x00301: 0x001FE + "\x00\x03\xe0\x00` \x01\xff" + // 0x000F8 0x00301: 0x001FF + "\x00\x01\x04\x00a\xe0\x02\x00" + // 0x00041 0x0030F: 0x00200 + "\x00\x01\x84\x00a\xe0\x02\x01" + // 0x00061 0x0030F: 0x00201 + "\x00\x01\x04\x00b \x02\x02" + // 0x00041 0x00311: 0x00202 + "\x00\x01\x84\x00b \x02\x03" + // 0x00061 0x00311: 0x00203 + "\x00\x01\x14\x00a\xe0\x02\x04" + // 0x00045 0x0030F: 0x00204 + "\x00\x01\x94\x00a\xe0\x02\x05" + // 0x00065 0x0030F: 0x00205 + "\x00\x01\x14\x00b \x02\x06" + // 0x00045 0x00311: 0x00206 + "\x00\x01\x94\x00b \x02\a" + // 0x00065 0x00311: 0x00207 + "\x00\x01$\x00a\xe0\x02\b" + // 0x00049 0x0030F: 0x00208 + "\x00\x01\xa4\x00a\xe0\x02\t" + // 0x00069 0x0030F: 0x00209 + "\x00\x01$\x00b \x02\n" + // 0x00049 0x00311: 0x0020A + "\x00\x01\xa4\x00b \x02\v" + // 0x00069 0x00311: 0x0020B + "\x00\x01<\x00a\xe0\x02\f" + // 0x0004F 0x0030F: 0x0020C + "\x00\x01\xbc\x00a\xe0\x02\r" + // 0x0006F 0x0030F: 0x0020D + "\x00\x01<\x00b \x02\x0e" + // 0x0004F 0x00311: 0x0020E + "\x00\x01\xbc\x00b \x02\x0f" + // 0x0006F 0x00311: 0x0020F + "\x00\x01H\x00a\xe0\x02\x10" + // 0x00052 0x0030F: 0x00210 + "\x00\x01\xc8\x00a\xe0\x02\x11" + // 0x00072 0x0030F: 0x00211 + "\x00\x01H\x00b \x02\x12" + // 0x00052 0x00311: 0x00212 + "\x00\x01\xc8\x00b \x02\x13" + // 0x00072 0x00311: 0x00213 + "\x00\x01T\x00a\xe0\x02\x14" + // 0x00055 0x0030F: 0x00214 + "\x00\x01\xd4\x00a\xe0\x02\x15" + // 0x00075 0x0030F: 0x00215 + "\x00\x01T\x00b \x02\x16" + // 0x00055 0x00311: 0x00216 + "\x00\x01\xd4\x00b \x02\x17" + // 0x00075 0x00311: 0x00217 + "\x00\x01L\x00d\xc0\x02\x18" + // 0x00053 0x00326: 0x00218 + "\x00\x01\xcc\x00d\xc0\x02\x19" + // 0x00073 0x00326: 0x00219 + "\x00\x01P\x00d\xc0\x02\x1a" + // 0x00054 0x00326: 0x0021A + "\x00\x01\xd0\x00d\xc0\x02\x1b" + // 0x00074 0x00326: 0x0021B + "\x00\x01 \x00a\x80\x02\x1e" + // 0x00048 0x0030C: 0x0021E + "\x00\x01\xa0\x00a\x80\x02\x1f" + // 0x00068 0x0030C: 0x0021F + "\x00\x01\x04\x00`\xe0\x02&" + // 0x00041 0x00307: 0x00226 + "\x00\x01\x84\x00`\xe0\x02'" + // 0x00061 0x00307: 0x00227 + "\x00\x01\x14\x00d\xe0\x02(" + // 0x00045 0x00327: 0x00228 + "\x00\x01\x94\x00d\xe0\x02)" + // 0x00065 0x00327: 0x00229 + "\x00\x03X\x00`\x80\x02*" + // 0x000D6 0x00304: 0x0022A + "\x00\x03\xd8\x00`\x80\x02+" + // 0x000F6 0x00304: 0x0022B + "\x00\x03T\x00`\x80\x02," + // 0x000D5 0x00304: 0x0022C + "\x00\x03\xd4\x00`\x80\x02-" + // 0x000F5 0x00304: 0x0022D + "\x00\x01<\x00`\xe0\x02." + // 0x0004F 0x00307: 0x0022E + "\x00\x01\xbc\x00`\xe0\x02/" + // 0x0006F 0x00307: 0x0022F + "\x00\b\xb8\x00`\x80\x020" + // 0x0022E 0x00304: 0x00230 + "\x00\b\xbc\x00`\x80\x021" + // 0x0022F 0x00304: 0x00231 + "\x00\x01d\x00`\x80\x022" + // 0x00059 0x00304: 0x00232 + "\x00\x01\xe4\x00`\x80\x023" + // 0x00079 0x00304: 0x00233 + "\x00\x02\xa0\x00` \x03\x85" + // 0x000A8 0x00301: 0x00385 + "\x00\x0eD\x00` \x03\x86" + // 0x00391 0x00301: 0x00386 + "\x00\x0eT\x00` \x03\x88" + // 0x00395 0x00301: 0x00388 + "\x00\x0e\\\x00` \x03\x89" + // 0x00397 0x00301: 0x00389 + "\x00\x0ed\x00` \x03\x8a" + // 0x00399 0x00301: 0x0038A + "\x00\x0e|\x00` \x03\x8c" + // 0x0039F 0x00301: 0x0038C + "\x00\x0e\x94\x00` \x03\x8e" + // 0x003A5 0x00301: 0x0038E + "\x00\x0e\xa4\x00` \x03\x8f" + // 0x003A9 0x00301: 0x0038F + "\x00\x0f(\x00` \x03\x90" + // 0x003CA 0x00301: 0x00390 + "\x00\x0ed\x00a\x00\x03\xaa" + // 0x00399 0x00308: 0x003AA + "\x00\x0e\x94\x00a\x00\x03\xab" + // 0x003A5 0x00308: 0x003AB + "\x00\x0e\xc4\x00` \x03\xac" + // 0x003B1 0x00301: 0x003AC + "\x00\x0e\xd4\x00` \x03\xad" + // 0x003B5 0x00301: 0x003AD + "\x00\x0e\xdc\x00` \x03\xae" + // 0x003B7 0x00301: 0x003AE + "\x00\x0e\xe4\x00` \x03\xaf" + // 0x003B9 0x00301: 0x003AF + "\x00\x0f,\x00` \x03\xb0" + // 0x003CB 0x00301: 0x003B0 + "\x00\x0e\xe4\x00a\x00\x03\xca" + // 0x003B9 0x00308: 0x003CA + "\x00\x0f\x14\x00a\x00\x03\xcb" + // 0x003C5 0x00308: 0x003CB + "\x00\x0e\xfc\x00` \x03\xcc" + // 0x003BF 0x00301: 0x003CC + "\x00\x0f\x14\x00` \x03\xcd" + // 0x003C5 0x00301: 0x003CD + "\x00\x0f$\x00` \x03\xce" + // 0x003C9 0x00301: 0x003CE + "\x00\x0fH\x00` \x03\xd3" + // 0x003D2 0x00301: 0x003D3 + "\x00\x0fH\x00a\x00\x03\xd4" + // 0x003D2 0x00308: 0x003D4 + "\x00\x10T\x00`\x00\x04\x00" + // 0x00415 0x00300: 0x00400 + "\x00\x10T\x00a\x00\x04\x01" + // 0x00415 0x00308: 0x00401 + "\x00\x10L\x00` \x04\x03" + // 0x00413 0x00301: 0x00403 + "\x00\x10\x18\x00a\x00\x04\a" + // 0x00406 0x00308: 0x00407 + "\x00\x10h\x00` \x04\f" + // 0x0041A 0x00301: 0x0040C + "\x00\x10`\x00`\x00\x04\r" + // 0x00418 0x00300: 0x0040D + "\x00\x10\x8c\x00`\xc0\x04\x0e" + // 0x00423 0x00306: 0x0040E + "\x00\x10`\x00`\xc0\x04\x19" + // 0x00418 0x00306: 0x00419 + "\x00\x10\xe0\x00`\xc0\x049" + // 0x00438 0x00306: 0x00439 + "\x00\x10\xd4\x00`\x00\x04P" + // 0x00435 0x00300: 0x00450 + "\x00\x10\xd4\x00a\x00\x04Q" + // 0x00435 0x00308: 0x00451 + "\x00\x10\xcc\x00` \x04S" + // 0x00433 0x00301: 0x00453 + "\x00\x11X\x00a\x00\x04W" + // 0x00456 0x00308: 0x00457 + "\x00\x10\xe8\x00` \x04\\" + // 0x0043A 0x00301: 0x0045C + "\x00\x10\xe0\x00`\x00\x04]" + // 0x00438 0x00300: 0x0045D + "\x00\x11\f\x00`\xc0\x04^" + // 0x00443 0x00306: 0x0045E + "\x00\x11\xd0\x00a\xe0\x04v" + // 0x00474 0x0030F: 0x00476 + "\x00\x11\xd4\x00a\xe0\x04w" + // 0x00475 0x0030F: 0x00477 + "\x00\x10X\x00`\xc0\x04\xc1" + // 0x00416 0x00306: 0x004C1 + "\x00\x10\xd8\x00`\xc0\x04\xc2" + // 0x00436 0x00306: 0x004C2 + "\x00\x10@\x00`\xc0\x04\xd0" + // 0x00410 0x00306: 0x004D0 + "\x00\x10\xc0\x00`\xc0\x04\xd1" + // 0x00430 0x00306: 0x004D1 + "\x00\x10@\x00a\x00\x04\xd2" + // 0x00410 0x00308: 0x004D2 + "\x00\x10\xc0\x00a\x00\x04\xd3" + // 0x00430 0x00308: 0x004D3 + "\x00\x10T\x00`\xc0\x04\xd6" + // 0x00415 0x00306: 0x004D6 + "\x00\x10\xd4\x00`\xc0\x04\xd7" + // 0x00435 0x00306: 0x004D7 + "\x00\x13`\x00a\x00\x04\xda" + // 0x004D8 0x00308: 0x004DA + "\x00\x13d\x00a\x00\x04\xdb" + // 0x004D9 0x00308: 0x004DB + "\x00\x10X\x00a\x00\x04\xdc" + // 0x00416 0x00308: 0x004DC + "\x00\x10\xd8\x00a\x00\x04\xdd" + // 0x00436 0x00308: 0x004DD + "\x00\x10\\\x00a\x00\x04\xde" + // 0x00417 0x00308: 0x004DE + "\x00\x10\xdc\x00a\x00\x04\xdf" + // 0x00437 0x00308: 0x004DF + "\x00\x10`\x00`\x80\x04\xe2" + // 0x00418 0x00304: 0x004E2 + "\x00\x10\xe0\x00`\x80\x04\xe3" + // 0x00438 0x00304: 0x004E3 + "\x00\x10`\x00a\x00\x04\xe4" + // 0x00418 0x00308: 0x004E4 + "\x00\x10\xe0\x00a\x00\x04\xe5" + // 0x00438 0x00308: 0x004E5 + "\x00\x10x\x00a\x00\x04\xe6" + // 0x0041E 0x00308: 0x004E6 + "\x00\x10\xf8\x00a\x00\x04\xe7" + // 0x0043E 0x00308: 0x004E7 + "\x00\x13\xa0\x00a\x00\x04\xea" + // 0x004E8 0x00308: 0x004EA + "\x00\x13\xa4\x00a\x00\x04\xeb" + // 0x004E9 0x00308: 0x004EB + "\x00\x10\xb4\x00a\x00\x04\xec" + // 0x0042D 0x00308: 0x004EC + "\x00\x114\x00a\x00\x04\xed" + // 0x0044D 0x00308: 0x004ED + "\x00\x10\x8c\x00`\x80\x04\xee" + // 0x00423 0x00304: 0x004EE + "\x00\x11\f\x00`\x80\x04\xef" + // 0x00443 0x00304: 0x004EF + "\x00\x10\x8c\x00a\x00\x04\xf0" + // 0x00423 0x00308: 0x004F0 + "\x00\x11\f\x00a\x00\x04\xf1" + // 0x00443 0x00308: 0x004F1 + "\x00\x10\x8c\x00a`\x04\xf2" + // 0x00423 0x0030B: 0x004F2 + "\x00\x11\f\x00a`\x04\xf3" + // 0x00443 0x0030B: 0x004F3 + "\x00\x10\x9c\x00a\x00\x04\xf4" + // 0x00427 0x00308: 0x004F4 + "\x00\x11\x1c\x00a\x00\x04\xf5" + // 0x00447 0x00308: 0x004F5 + "\x00\x10\xac\x00a\x00\x04\xf8" + // 0x0042B 0x00308: 0x004F8 + "\x00\x11,\x00a\x00\x04\xf9" + // 0x0044B 0x00308: 0x004F9 + "\x00\x18\x9c\x00\xca`\x06\"" + // 0x00627 0x00653: 0x00622 + "\x00\x18\x9c\x00ʀ\x06#" + // 0x00627 0x00654: 0x00623 + "\x00\x19 \x00ʀ\x06$" + // 0x00648 0x00654: 0x00624 + "\x00\x18\x9c\x00ʠ\x06%" + // 0x00627 0x00655: 0x00625 + "\x00\x19(\x00ʀ\x06&" + // 0x0064A 0x00654: 0x00626 + "\x00\x1bT\x00ʀ\x06\xc0" + // 0x006D5 0x00654: 0x006C0 + "\x00\x1b\x04\x00ʀ\x06\xc2" + // 0x006C1 0x00654: 0x006C2 + "\x00\x1bH\x00ʀ\x06\xd3" + // 0x006D2 0x00654: 0x006D3 + "\x00$\xa0\x01'\x80\t)" + // 0x00928 0x0093C: 0x00929 + "\x00$\xc0\x01'\x80\t1" + // 0x00930 0x0093C: 0x00931 + "\x00$\xcc\x01'\x80\t4" + // 0x00933 0x0093C: 0x00934 + "\x00'\x1c\x017\xc0\t\xcb" + // 0x009C7 0x009BE: 0x009CB + "\x00'\x1c\x01:\xe0\t\xcc" + // 0x009C7 0x009D7: 0x009CC + "\x00-\x1c\x01j\xc0\vH" + // 0x00B47 0x00B56: 0x00B48 + "\x00-\x1c\x01g\xc0\vK" + // 0x00B47 0x00B3E: 0x00B4B + "\x00-\x1c\x01j\xe0\vL" + // 0x00B47 0x00B57: 0x00B4C + "\x00.H\x01z\xe0\v\x94" + // 0x00B92 0x00BD7: 0x00B94 + "\x00/\x18\x01w\xc0\v\xca" + // 0x00BC6 0x00BBE: 0x00BCA + "\x00/\x1c\x01w\xc0\v\xcb" + // 0x00BC7 0x00BBE: 0x00BCB + "\x00/\x18\x01z\xe0\v\xcc" + // 0x00BC6 0x00BD7: 0x00BCC + "\x001\x18\x01\x8a\xc0\fH" + // 0x00C46 0x00C56: 0x00C48 + "\x002\xfc\x01\x9a\xa0\f\xc0" + // 0x00CBF 0x00CD5: 0x00CC0 + "\x003\x18\x01\x9a\xa0\f\xc7" + // 0x00CC6 0x00CD5: 0x00CC7 + "\x003\x18\x01\x9a\xc0\f\xc8" + // 0x00CC6 0x00CD6: 0x00CC8 + "\x003\x18\x01\x98@\f\xca" + // 0x00CC6 0x00CC2: 0x00CCA + "\x003(\x01\x9a\xa0\f\xcb" + // 0x00CCA 0x00CD5: 0x00CCB + "\x005\x18\x01\xa7\xc0\rJ" + // 0x00D46 0x00D3E: 0x00D4A + "\x005\x1c\x01\xa7\xc0\rK" + // 0x00D47 0x00D3E: 0x00D4B + "\x005\x18\x01\xaa\xe0\rL" + // 0x00D46 0x00D57: 0x00D4C + "\x007d\x01\xb9@\r\xda" + // 0x00DD9 0x00DCA: 0x00DDA + "\x007d\x01\xb9\xe0\r\xdc" + // 0x00DD9 0x00DCF: 0x00DDC + "\x007p\x01\xb9@\r\xdd" + // 0x00DDC 0x00DCA: 0x00DDD + "\x007d\x01\xbb\xe0\r\xde" + // 0x00DD9 0x00DDF: 0x00DDE + "\x00@\x94\x02\x05\xc0\x10&" + // 0x01025 0x0102E: 0x01026 + "\x00l\x14\x03f\xa0\x1b\x06" + // 0x01B05 0x01B35: 0x01B06 + "\x00l\x1c\x03f\xa0\x1b\b" + // 0x01B07 0x01B35: 0x01B08 + "\x00l$\x03f\xa0\x1b\n" + // 0x01B09 0x01B35: 0x01B0A + "\x00l,\x03f\xa0\x1b\f" + // 0x01B0B 0x01B35: 0x01B0C + "\x00l4\x03f\xa0\x1b\x0e" + // 0x01B0D 0x01B35: 0x01B0E + "\x00lD\x03f\xa0\x1b\x12" + // 0x01B11 0x01B35: 0x01B12 + "\x00l\xe8\x03f\xa0\x1b;" + // 0x01B3A 0x01B35: 0x01B3B + "\x00l\xf0\x03f\xa0\x1b=" + // 0x01B3C 0x01B35: 0x01B3D + "\x00l\xf8\x03f\xa0\x1b@" + // 0x01B3E 0x01B35: 0x01B40 + "\x00l\xfc\x03f\xa0\x1bA" + // 0x01B3F 0x01B35: 0x01B41 + "\x00m\b\x03f\xa0\x1bC" + // 0x01B42 0x01B35: 0x01B43 + "\x00\x01\x04\x00d\xa0\x1e\x00" + // 0x00041 0x00325: 0x01E00 + "\x00\x01\x84\x00d\xa0\x1e\x01" + // 0x00061 0x00325: 0x01E01 + "\x00\x01\b\x00`\xe0\x1e\x02" + // 0x00042 0x00307: 0x01E02 + "\x00\x01\x88\x00`\xe0\x1e\x03" + // 0x00062 0x00307: 0x01E03 + "\x00\x01\b\x00d`\x1e\x04" + // 0x00042 0x00323: 0x01E04 + "\x00\x01\x88\x00d`\x1e\x05" + // 0x00062 0x00323: 0x01E05 + "\x00\x01\b\x00f \x1e\x06" + // 0x00042 0x00331: 0x01E06 + "\x00\x01\x88\x00f \x1e\a" + // 0x00062 0x00331: 0x01E07 + "\x00\x03\x1c\x00` \x1e\b" + // 0x000C7 0x00301: 0x01E08 + "\x00\x03\x9c\x00` \x1e\t" + // 0x000E7 0x00301: 0x01E09 + "\x00\x01\x10\x00`\xe0\x1e\n" + // 0x00044 0x00307: 0x01E0A + "\x00\x01\x90\x00`\xe0\x1e\v" + // 0x00064 0x00307: 0x01E0B + "\x00\x01\x10\x00d`\x1e\f" + // 0x00044 0x00323: 0x01E0C + "\x00\x01\x90\x00d`\x1e\r" + // 0x00064 0x00323: 0x01E0D + "\x00\x01\x10\x00f \x1e\x0e" + // 0x00044 0x00331: 0x01E0E + "\x00\x01\x90\x00f \x1e\x0f" + // 0x00064 0x00331: 0x01E0F + "\x00\x01\x10\x00d\xe0\x1e\x10" + // 0x00044 0x00327: 0x01E10 + "\x00\x01\x90\x00d\xe0\x1e\x11" + // 0x00064 0x00327: 0x01E11 + "\x00\x01\x10\x00e\xa0\x1e\x12" + // 0x00044 0x0032D: 0x01E12 + "\x00\x01\x90\x00e\xa0\x1e\x13" + // 0x00064 0x0032D: 0x01E13 + "\x00\x04H\x00`\x00\x1e\x14" + // 0x00112 0x00300: 0x01E14 + "\x00\x04L\x00`\x00\x1e\x15" + // 0x00113 0x00300: 0x01E15 + "\x00\x04H\x00` \x1e\x16" + // 0x00112 0x00301: 0x01E16 + "\x00\x04L\x00` \x1e\x17" + // 0x00113 0x00301: 0x01E17 + "\x00\x01\x14\x00e\xa0\x1e\x18" + // 0x00045 0x0032D: 0x01E18 + "\x00\x01\x94\x00e\xa0\x1e\x19" + // 0x00065 0x0032D: 0x01E19 + "\x00\x01\x14\x00f\x00\x1e\x1a" + // 0x00045 0x00330: 0x01E1A + "\x00\x01\x94\x00f\x00\x1e\x1b" + // 0x00065 0x00330: 0x01E1B + "\x00\b\xa0\x00`\xc0\x1e\x1c" + // 0x00228 0x00306: 0x01E1C + "\x00\b\xa4\x00`\xc0\x1e\x1d" + // 0x00229 0x00306: 0x01E1D + "\x00\x01\x18\x00`\xe0\x1e\x1e" + // 0x00046 0x00307: 0x01E1E + "\x00\x01\x98\x00`\xe0\x1e\x1f" + // 0x00066 0x00307: 0x01E1F + "\x00\x01\x1c\x00`\x80\x1e " + // 0x00047 0x00304: 0x01E20 + "\x00\x01\x9c\x00`\x80\x1e!" + // 0x00067 0x00304: 0x01E21 + "\x00\x01 \x00`\xe0\x1e\"" + // 0x00048 0x00307: 0x01E22 + "\x00\x01\xa0\x00`\xe0\x1e#" + // 0x00068 0x00307: 0x01E23 + "\x00\x01 \x00d`\x1e$" + // 0x00048 0x00323: 0x01E24 + "\x00\x01\xa0\x00d`\x1e%" + // 0x00068 0x00323: 0x01E25 + "\x00\x01 \x00a\x00\x1e&" + // 0x00048 0x00308: 0x01E26 + "\x00\x01\xa0\x00a\x00\x1e'" + // 0x00068 0x00308: 0x01E27 + "\x00\x01 \x00d\xe0\x1e(" + // 0x00048 0x00327: 0x01E28 + "\x00\x01\xa0\x00d\xe0\x1e)" + // 0x00068 0x00327: 0x01E29 + "\x00\x01 \x00e\xc0\x1e*" + // 0x00048 0x0032E: 0x01E2A + "\x00\x01\xa0\x00e\xc0\x1e+" + // 0x00068 0x0032E: 0x01E2B + "\x00\x01$\x00f\x00\x1e," + // 0x00049 0x00330: 0x01E2C + "\x00\x01\xa4\x00f\x00\x1e-" + // 0x00069 0x00330: 0x01E2D + "\x00\x03<\x00` \x1e." + // 0x000CF 0x00301: 0x01E2E + "\x00\x03\xbc\x00` \x1e/" + // 0x000EF 0x00301: 0x01E2F + "\x00\x01,\x00` \x1e0" + // 0x0004B 0x00301: 0x01E30 + "\x00\x01\xac\x00` \x1e1" + // 0x0006B 0x00301: 0x01E31 + "\x00\x01,\x00d`\x1e2" + // 0x0004B 0x00323: 0x01E32 + "\x00\x01\xac\x00d`\x1e3" + // 0x0006B 0x00323: 0x01E33 + "\x00\x01,\x00f \x1e4" + // 0x0004B 0x00331: 0x01E34 + "\x00\x01\xac\x00f \x1e5" + // 0x0006B 0x00331: 0x01E35 + "\x00\x010\x00d`\x1e6" + // 0x0004C 0x00323: 0x01E36 + "\x00\x01\xb0\x00d`\x1e7" + // 0x0006C 0x00323: 0x01E37 + "\x00x\xd8\x00`\x80\x1e8" + // 0x01E36 0x00304: 0x01E38 + "\x00x\xdc\x00`\x80\x1e9" + // 0x01E37 0x00304: 0x01E39 + "\x00\x010\x00f \x1e:" + // 0x0004C 0x00331: 0x01E3A + "\x00\x01\xb0\x00f \x1e;" + // 0x0006C 0x00331: 0x01E3B + "\x00\x010\x00e\xa0\x1e<" + // 0x0004C 0x0032D: 0x01E3C + "\x00\x01\xb0\x00e\xa0\x1e=" + // 0x0006C 0x0032D: 0x01E3D + "\x00\x014\x00` \x1e>" + // 0x0004D 0x00301: 0x01E3E + "\x00\x01\xb4\x00` \x1e?" + // 0x0006D 0x00301: 0x01E3F + "\x00\x014\x00`\xe0\x1e@" + // 0x0004D 0x00307: 0x01E40 + "\x00\x01\xb4\x00`\xe0\x1eA" + // 0x0006D 0x00307: 0x01E41 + "\x00\x014\x00d`\x1eB" + // 0x0004D 0x00323: 0x01E42 + "\x00\x01\xb4\x00d`\x1eC" + // 0x0006D 0x00323: 0x01E43 + "\x00\x018\x00`\xe0\x1eD" + // 0x0004E 0x00307: 0x01E44 + "\x00\x01\xb8\x00`\xe0\x1eE" + // 0x0006E 0x00307: 0x01E45 + "\x00\x018\x00d`\x1eF" + // 0x0004E 0x00323: 0x01E46 + "\x00\x01\xb8\x00d`\x1eG" + // 0x0006E 0x00323: 0x01E47 + "\x00\x018\x00f \x1eH" + // 0x0004E 0x00331: 0x01E48 + "\x00\x01\xb8\x00f \x1eI" + // 0x0006E 0x00331: 0x01E49 + "\x00\x018\x00e\xa0\x1eJ" + // 0x0004E 0x0032D: 0x01E4A + "\x00\x01\xb8\x00e\xa0\x1eK" + // 0x0006E 0x0032D: 0x01E4B + "\x00\x03T\x00` \x1eL" + // 0x000D5 0x00301: 0x01E4C + "\x00\x03\xd4\x00` \x1eM" + // 0x000F5 0x00301: 0x01E4D + "\x00\x03T\x00a\x00\x1eN" + // 0x000D5 0x00308: 0x01E4E + "\x00\x03\xd4\x00a\x00\x1eO" + // 0x000F5 0x00308: 0x01E4F + "\x00\x050\x00`\x00\x1eP" + // 0x0014C 0x00300: 0x01E50 + "\x00\x054\x00`\x00\x1eQ" + // 0x0014D 0x00300: 0x01E51 + "\x00\x050\x00` \x1eR" + // 0x0014C 0x00301: 0x01E52 + "\x00\x054\x00` \x1eS" + // 0x0014D 0x00301: 0x01E53 + "\x00\x01@\x00` \x1eT" + // 0x00050 0x00301: 0x01E54 + "\x00\x01\xc0\x00` \x1eU" + // 0x00070 0x00301: 0x01E55 + "\x00\x01@\x00`\xe0\x1eV" + // 0x00050 0x00307: 0x01E56 + "\x00\x01\xc0\x00`\xe0\x1eW" + // 0x00070 0x00307: 0x01E57 + "\x00\x01H\x00`\xe0\x1eX" + // 0x00052 0x00307: 0x01E58 + "\x00\x01\xc8\x00`\xe0\x1eY" + // 0x00072 0x00307: 0x01E59 + "\x00\x01H\x00d`\x1eZ" + // 0x00052 0x00323: 0x01E5A + "\x00\x01\xc8\x00d`\x1e[" + // 0x00072 0x00323: 0x01E5B + "\x00yh\x00`\x80\x1e\\" + // 0x01E5A 0x00304: 0x01E5C + "\x00yl\x00`\x80\x1e]" + // 0x01E5B 0x00304: 0x01E5D + "\x00\x01H\x00f \x1e^" + // 0x00052 0x00331: 0x01E5E + "\x00\x01\xc8\x00f \x1e_" + // 0x00072 0x00331: 0x01E5F + "\x00\x01L\x00`\xe0\x1e`" + // 0x00053 0x00307: 0x01E60 + "\x00\x01\xcc\x00`\xe0\x1ea" + // 0x00073 0x00307: 0x01E61 + "\x00\x01L\x00d`\x1eb" + // 0x00053 0x00323: 0x01E62 + "\x00\x01\xcc\x00d`\x1ec" + // 0x00073 0x00323: 0x01E63 + "\x00\x05h\x00`\xe0\x1ed" + // 0x0015A 0x00307: 0x01E64 + "\x00\x05l\x00`\xe0\x1ee" + // 0x0015B 0x00307: 0x01E65 + "\x00\x05\x80\x00`\xe0\x1ef" + // 0x00160 0x00307: 0x01E66 + "\x00\x05\x84\x00`\xe0\x1eg" + // 0x00161 0x00307: 0x01E67 + "\x00y\x88\x00`\xe0\x1eh" + // 0x01E62 0x00307: 0x01E68 + "\x00y\x8c\x00`\xe0\x1ei" + // 0x01E63 0x00307: 0x01E69 + "\x00\x01P\x00`\xe0\x1ej" + // 0x00054 0x00307: 0x01E6A + "\x00\x01\xd0\x00`\xe0\x1ek" + // 0x00074 0x00307: 0x01E6B + "\x00\x01P\x00d`\x1el" + // 0x00054 0x00323: 0x01E6C + "\x00\x01\xd0\x00d`\x1em" + // 0x00074 0x00323: 0x01E6D + "\x00\x01P\x00f \x1en" + // 0x00054 0x00331: 0x01E6E + "\x00\x01\xd0\x00f \x1eo" + // 0x00074 0x00331: 0x01E6F + "\x00\x01P\x00e\xa0\x1ep" + // 0x00054 0x0032D: 0x01E70 + "\x00\x01\xd0\x00e\xa0\x1eq" + // 0x00074 0x0032D: 0x01E71 + "\x00\x01T\x00d\x80\x1er" + // 0x00055 0x00324: 0x01E72 + "\x00\x01\xd4\x00d\x80\x1es" + // 0x00075 0x00324: 0x01E73 + "\x00\x01T\x00f\x00\x1et" + // 0x00055 0x00330: 0x01E74 + "\x00\x01\xd4\x00f\x00\x1eu" + // 0x00075 0x00330: 0x01E75 + "\x00\x01T\x00e\xa0\x1ev" + // 0x00055 0x0032D: 0x01E76 + "\x00\x01\xd4\x00e\xa0\x1ew" + // 0x00075 0x0032D: 0x01E77 + "\x00\x05\xa0\x00` \x1ex" + // 0x00168 0x00301: 0x01E78 + "\x00\x05\xa4\x00` \x1ey" + // 0x00169 0x00301: 0x01E79 + "\x00\x05\xa8\x00a\x00\x1ez" + // 0x0016A 0x00308: 0x01E7A + "\x00\x05\xac\x00a\x00\x1e{" + // 0x0016B 0x00308: 0x01E7B + "\x00\x01X\x00``\x1e|" + // 0x00056 0x00303: 0x01E7C + "\x00\x01\xd8\x00``\x1e}" + // 0x00076 0x00303: 0x01E7D + "\x00\x01X\x00d`\x1e~" + // 0x00056 0x00323: 0x01E7E + "\x00\x01\xd8\x00d`\x1e\x7f" + // 0x00076 0x00323: 0x01E7F + "\x00\x01\\\x00`\x00\x1e\x80" + // 0x00057 0x00300: 0x01E80 + "\x00\x01\xdc\x00`\x00\x1e\x81" + // 0x00077 0x00300: 0x01E81 + "\x00\x01\\\x00` \x1e\x82" + // 0x00057 0x00301: 0x01E82 + "\x00\x01\xdc\x00` \x1e\x83" + // 0x00077 0x00301: 0x01E83 + "\x00\x01\\\x00a\x00\x1e\x84" + // 0x00057 0x00308: 0x01E84 + "\x00\x01\xdc\x00a\x00\x1e\x85" + // 0x00077 0x00308: 0x01E85 + "\x00\x01\\\x00`\xe0\x1e\x86" + // 0x00057 0x00307: 0x01E86 + "\x00\x01\xdc\x00`\xe0\x1e\x87" + // 0x00077 0x00307: 0x01E87 + "\x00\x01\\\x00d`\x1e\x88" + // 0x00057 0x00323: 0x01E88 + "\x00\x01\xdc\x00d`\x1e\x89" + // 0x00077 0x00323: 0x01E89 + "\x00\x01`\x00`\xe0\x1e\x8a" + // 0x00058 0x00307: 0x01E8A + "\x00\x01\xe0\x00`\xe0\x1e\x8b" + // 0x00078 0x00307: 0x01E8B + "\x00\x01`\x00a\x00\x1e\x8c" + // 0x00058 0x00308: 0x01E8C + "\x00\x01\xe0\x00a\x00\x1e\x8d" + // 0x00078 0x00308: 0x01E8D + "\x00\x01d\x00`\xe0\x1e\x8e" + // 0x00059 0x00307: 0x01E8E + "\x00\x01\xe4\x00`\xe0\x1e\x8f" + // 0x00079 0x00307: 0x01E8F + "\x00\x01h\x00`@\x1e\x90" + // 0x0005A 0x00302: 0x01E90 + "\x00\x01\xe8\x00`@\x1e\x91" + // 0x0007A 0x00302: 0x01E91 + "\x00\x01h\x00d`\x1e\x92" + // 0x0005A 0x00323: 0x01E92 + "\x00\x01\xe8\x00d`\x1e\x93" + // 0x0007A 0x00323: 0x01E93 + "\x00\x01h\x00f \x1e\x94" + // 0x0005A 0x00331: 0x01E94 + "\x00\x01\xe8\x00f \x1e\x95" + // 0x0007A 0x00331: 0x01E95 + "\x00\x01\xa0\x00f \x1e\x96" + // 0x00068 0x00331: 0x01E96 + "\x00\x01\xd0\x00a\x00\x1e\x97" + // 0x00074 0x00308: 0x01E97 + "\x00\x01\xdc\x00a@\x1e\x98" + // 0x00077 0x0030A: 0x01E98 + "\x00\x01\xe4\x00a@\x1e\x99" + // 0x00079 0x0030A: 0x01E99 + "\x00\x05\xfc\x00`\xe0\x1e\x9b" + // 0x0017F 0x00307: 0x01E9B + "\x00\x01\x04\x00d`\x1e\xa0" + // 0x00041 0x00323: 0x01EA0 + "\x00\x01\x84\x00d`\x1e\xa1" + // 0x00061 0x00323: 0x01EA1 + "\x00\x01\x04\x00a \x1e\xa2" + // 0x00041 0x00309: 0x01EA2 + "\x00\x01\x84\x00a \x1e\xa3" + // 0x00061 0x00309: 0x01EA3 + "\x00\x03\b\x00` \x1e\xa4" + // 0x000C2 0x00301: 0x01EA4 + "\x00\x03\x88\x00` \x1e\xa5" + // 0x000E2 0x00301: 0x01EA5 + "\x00\x03\b\x00`\x00\x1e\xa6" + // 0x000C2 0x00300: 0x01EA6 + "\x00\x03\x88\x00`\x00\x1e\xa7" + // 0x000E2 0x00300: 0x01EA7 + "\x00\x03\b\x00a \x1e\xa8" + // 0x000C2 0x00309: 0x01EA8 + "\x00\x03\x88\x00a \x1e\xa9" + // 0x000E2 0x00309: 0x01EA9 + "\x00\x03\b\x00``\x1e\xaa" + // 0x000C2 0x00303: 0x01EAA + "\x00\x03\x88\x00``\x1e\xab" + // 0x000E2 0x00303: 0x01EAB + "\x00z\x80\x00`@\x1e\xac" + // 0x01EA0 0x00302: 0x01EAC + "\x00z\x84\x00`@\x1e\xad" + // 0x01EA1 0x00302: 0x01EAD + "\x00\x04\b\x00` \x1e\xae" + // 0x00102 0x00301: 0x01EAE + "\x00\x04\f\x00` \x1e\xaf" + // 0x00103 0x00301: 0x01EAF + "\x00\x04\b\x00`\x00\x1e\xb0" + // 0x00102 0x00300: 0x01EB0 + "\x00\x04\f\x00`\x00\x1e\xb1" + // 0x00103 0x00300: 0x01EB1 + "\x00\x04\b\x00a \x1e\xb2" + // 0x00102 0x00309: 0x01EB2 + "\x00\x04\f\x00a \x1e\xb3" + // 0x00103 0x00309: 0x01EB3 + "\x00\x04\b\x00``\x1e\xb4" + // 0x00102 0x00303: 0x01EB4 + "\x00\x04\f\x00``\x1e\xb5" + // 0x00103 0x00303: 0x01EB5 + "\x00z\x80\x00`\xc0\x1e\xb6" + // 0x01EA0 0x00306: 0x01EB6 + "\x00z\x84\x00`\xc0\x1e\xb7" + // 0x01EA1 0x00306: 0x01EB7 + "\x00\x01\x14\x00d`\x1e\xb8" + // 0x00045 0x00323: 0x01EB8 + "\x00\x01\x94\x00d`\x1e\xb9" + // 0x00065 0x00323: 0x01EB9 + "\x00\x01\x14\x00a \x1e\xba" + // 0x00045 0x00309: 0x01EBA + "\x00\x01\x94\x00a \x1e\xbb" + // 0x00065 0x00309: 0x01EBB + "\x00\x01\x14\x00``\x1e\xbc" + // 0x00045 0x00303: 0x01EBC + "\x00\x01\x94\x00``\x1e\xbd" + // 0x00065 0x00303: 0x01EBD + "\x00\x03(\x00` \x1e\xbe" + // 0x000CA 0x00301: 0x01EBE + "\x00\x03\xa8\x00` \x1e\xbf" + // 0x000EA 0x00301: 0x01EBF + "\x00\x03(\x00`\x00\x1e\xc0" + // 0x000CA 0x00300: 0x01EC0 + "\x00\x03\xa8\x00`\x00\x1e\xc1" + // 0x000EA 0x00300: 0x01EC1 + "\x00\x03(\x00a \x1e\xc2" + // 0x000CA 0x00309: 0x01EC2 + "\x00\x03\xa8\x00a \x1e\xc3" + // 0x000EA 0x00309: 0x01EC3 + "\x00\x03(\x00``\x1e\xc4" + // 0x000CA 0x00303: 0x01EC4 + "\x00\x03\xa8\x00``\x1e\xc5" + // 0x000EA 0x00303: 0x01EC5 + "\x00z\xe0\x00`@\x1e\xc6" + // 0x01EB8 0x00302: 0x01EC6 + "\x00z\xe4\x00`@\x1e\xc7" + // 0x01EB9 0x00302: 0x01EC7 + "\x00\x01$\x00a \x1e\xc8" + // 0x00049 0x00309: 0x01EC8 + "\x00\x01\xa4\x00a \x1e\xc9" + // 0x00069 0x00309: 0x01EC9 + "\x00\x01$\x00d`\x1e\xca" + // 0x00049 0x00323: 0x01ECA + "\x00\x01\xa4\x00d`\x1e\xcb" + // 0x00069 0x00323: 0x01ECB + "\x00\x01<\x00d`\x1e\xcc" + // 0x0004F 0x00323: 0x01ECC + "\x00\x01\xbc\x00d`\x1e\xcd" + // 0x0006F 0x00323: 0x01ECD + "\x00\x01<\x00a \x1e\xce" + // 0x0004F 0x00309: 0x01ECE + "\x00\x01\xbc\x00a \x1e\xcf" + // 0x0006F 0x00309: 0x01ECF + "\x00\x03P\x00` \x1e\xd0" + // 0x000D4 0x00301: 0x01ED0 + "\x00\x03\xd0\x00` \x1e\xd1" + // 0x000F4 0x00301: 0x01ED1 + "\x00\x03P\x00`\x00\x1e\xd2" + // 0x000D4 0x00300: 0x01ED2 + "\x00\x03\xd0\x00`\x00\x1e\xd3" + // 0x000F4 0x00300: 0x01ED3 + "\x00\x03P\x00a \x1e\xd4" + // 0x000D4 0x00309: 0x01ED4 + "\x00\x03\xd0\x00a \x1e\xd5" + // 0x000F4 0x00309: 0x01ED5 + "\x00\x03P\x00``\x1e\xd6" + // 0x000D4 0x00303: 0x01ED6 + "\x00\x03\xd0\x00``\x1e\xd7" + // 0x000F4 0x00303: 0x01ED7 + "\x00{0\x00`@\x1e\xd8" + // 0x01ECC 0x00302: 0x01ED8 + "\x00{4\x00`@\x1e\xd9" + // 0x01ECD 0x00302: 0x01ED9 + "\x00\x06\x80\x00` \x1e\xda" + // 0x001A0 0x00301: 0x01EDA + "\x00\x06\x84\x00` \x1e\xdb" + // 0x001A1 0x00301: 0x01EDB + "\x00\x06\x80\x00`\x00\x1e\xdc" + // 0x001A0 0x00300: 0x01EDC + "\x00\x06\x84\x00`\x00\x1e\xdd" + // 0x001A1 0x00300: 0x01EDD + "\x00\x06\x80\x00a \x1e\xde" + // 0x001A0 0x00309: 0x01EDE + "\x00\x06\x84\x00a \x1e\xdf" + // 0x001A1 0x00309: 0x01EDF + "\x00\x06\x80\x00``\x1e\xe0" + // 0x001A0 0x00303: 0x01EE0 + "\x00\x06\x84\x00``\x1e\xe1" + // 0x001A1 0x00303: 0x01EE1 + "\x00\x06\x80\x00d`\x1e\xe2" + // 0x001A0 0x00323: 0x01EE2 + "\x00\x06\x84\x00d`\x1e\xe3" + // 0x001A1 0x00323: 0x01EE3 + "\x00\x01T\x00d`\x1e\xe4" + // 0x00055 0x00323: 0x01EE4 + "\x00\x01\xd4\x00d`\x1e\xe5" + // 0x00075 0x00323: 0x01EE5 + "\x00\x01T\x00a \x1e\xe6" + // 0x00055 0x00309: 0x01EE6 + "\x00\x01\xd4\x00a \x1e\xe7" + // 0x00075 0x00309: 0x01EE7 + "\x00\x06\xbc\x00` \x1e\xe8" + // 0x001AF 0x00301: 0x01EE8 + "\x00\x06\xc0\x00` \x1e\xe9" + // 0x001B0 0x00301: 0x01EE9 + "\x00\x06\xbc\x00`\x00\x1e\xea" + // 0x001AF 0x00300: 0x01EEA + "\x00\x06\xc0\x00`\x00\x1e\xeb" + // 0x001B0 0x00300: 0x01EEB + "\x00\x06\xbc\x00a \x1e\xec" + // 0x001AF 0x00309: 0x01EEC + "\x00\x06\xc0\x00a \x1e\xed" + // 0x001B0 0x00309: 0x01EED + "\x00\x06\xbc\x00``\x1e\xee" + // 0x001AF 0x00303: 0x01EEE + "\x00\x06\xc0\x00``\x1e\xef" + // 0x001B0 0x00303: 0x01EEF + "\x00\x06\xbc\x00d`\x1e\xf0" + // 0x001AF 0x00323: 0x01EF0 + "\x00\x06\xc0\x00d`\x1e\xf1" + // 0x001B0 0x00323: 0x01EF1 + "\x00\x01d\x00`\x00\x1e\xf2" + // 0x00059 0x00300: 0x01EF2 + "\x00\x01\xe4\x00`\x00\x1e\xf3" + // 0x00079 0x00300: 0x01EF3 + "\x00\x01d\x00d`\x1e\xf4" + // 0x00059 0x00323: 0x01EF4 + "\x00\x01\xe4\x00d`\x1e\xf5" + // 0x00079 0x00323: 0x01EF5 + "\x00\x01d\x00a \x1e\xf6" + // 0x00059 0x00309: 0x01EF6 + "\x00\x01\xe4\x00a \x1e\xf7" + // 0x00079 0x00309: 0x01EF7 + "\x00\x01d\x00``\x1e\xf8" + // 0x00059 0x00303: 0x01EF8 + "\x00\x01\xe4\x00``\x1e\xf9" + // 0x00079 0x00303: 0x01EF9 + "\x00\x0e\xc4\x00b`\x1f\x00" + // 0x003B1 0x00313: 0x01F00 + "\x00\x0e\xc4\x00b\x80\x1f\x01" + // 0x003B1 0x00314: 0x01F01 + "\x00|\x00\x00`\x00\x1f\x02" + // 0x01F00 0x00300: 0x01F02 + "\x00|\x04\x00`\x00\x1f\x03" + // 0x01F01 0x00300: 0x01F03 + "\x00|\x00\x00` \x1f\x04" + // 0x01F00 0x00301: 0x01F04 + "\x00|\x04\x00` \x1f\x05" + // 0x01F01 0x00301: 0x01F05 + "\x00|\x00\x00h@\x1f\x06" + // 0x01F00 0x00342: 0x01F06 + "\x00|\x04\x00h@\x1f\a" + // 0x01F01 0x00342: 0x01F07 + "\x00\x0eD\x00b`\x1f\b" + // 0x00391 0x00313: 0x01F08 + "\x00\x0eD\x00b\x80\x1f\t" + // 0x00391 0x00314: 0x01F09 + "\x00| \x00`\x00\x1f\n" + // 0x01F08 0x00300: 0x01F0A + "\x00|$\x00`\x00\x1f\v" + // 0x01F09 0x00300: 0x01F0B + "\x00| \x00` \x1f\f" + // 0x01F08 0x00301: 0x01F0C + "\x00|$\x00` \x1f\r" + // 0x01F09 0x00301: 0x01F0D + "\x00| \x00h@\x1f\x0e" + // 0x01F08 0x00342: 0x01F0E + "\x00|$\x00h@\x1f\x0f" + // 0x01F09 0x00342: 0x01F0F + "\x00\x0e\xd4\x00b`\x1f\x10" + // 0x003B5 0x00313: 0x01F10 + "\x00\x0e\xd4\x00b\x80\x1f\x11" + // 0x003B5 0x00314: 0x01F11 + "\x00|@\x00`\x00\x1f\x12" + // 0x01F10 0x00300: 0x01F12 + "\x00|D\x00`\x00\x1f\x13" + // 0x01F11 0x00300: 0x01F13 + "\x00|@\x00` \x1f\x14" + // 0x01F10 0x00301: 0x01F14 + "\x00|D\x00` \x1f\x15" + // 0x01F11 0x00301: 0x01F15 + "\x00\x0eT\x00b`\x1f\x18" + // 0x00395 0x00313: 0x01F18 + "\x00\x0eT\x00b\x80\x1f\x19" + // 0x00395 0x00314: 0x01F19 + "\x00|`\x00`\x00\x1f\x1a" + // 0x01F18 0x00300: 0x01F1A + "\x00|d\x00`\x00\x1f\x1b" + // 0x01F19 0x00300: 0x01F1B + "\x00|`\x00` \x1f\x1c" + // 0x01F18 0x00301: 0x01F1C + "\x00|d\x00` \x1f\x1d" + // 0x01F19 0x00301: 0x01F1D + "\x00\x0e\xdc\x00b`\x1f " + // 0x003B7 0x00313: 0x01F20 + "\x00\x0e\xdc\x00b\x80\x1f!" + // 0x003B7 0x00314: 0x01F21 + "\x00|\x80\x00`\x00\x1f\"" + // 0x01F20 0x00300: 0x01F22 + "\x00|\x84\x00`\x00\x1f#" + // 0x01F21 0x00300: 0x01F23 + "\x00|\x80\x00` \x1f$" + // 0x01F20 0x00301: 0x01F24 + "\x00|\x84\x00` \x1f%" + // 0x01F21 0x00301: 0x01F25 + "\x00|\x80\x00h@\x1f&" + // 0x01F20 0x00342: 0x01F26 + "\x00|\x84\x00h@\x1f'" + // 0x01F21 0x00342: 0x01F27 + "\x00\x0e\\\x00b`\x1f(" + // 0x00397 0x00313: 0x01F28 + "\x00\x0e\\\x00b\x80\x1f)" + // 0x00397 0x00314: 0x01F29 + "\x00|\xa0\x00`\x00\x1f*" + // 0x01F28 0x00300: 0x01F2A + "\x00|\xa4\x00`\x00\x1f+" + // 0x01F29 0x00300: 0x01F2B + "\x00|\xa0\x00` \x1f," + // 0x01F28 0x00301: 0x01F2C + "\x00|\xa4\x00` \x1f-" + // 0x01F29 0x00301: 0x01F2D + "\x00|\xa0\x00h@\x1f." + // 0x01F28 0x00342: 0x01F2E + "\x00|\xa4\x00h@\x1f/" + // 0x01F29 0x00342: 0x01F2F + "\x00\x0e\xe4\x00b`\x1f0" + // 0x003B9 0x00313: 0x01F30 + "\x00\x0e\xe4\x00b\x80\x1f1" + // 0x003B9 0x00314: 0x01F31 + "\x00|\xc0\x00`\x00\x1f2" + // 0x01F30 0x00300: 0x01F32 + "\x00|\xc4\x00`\x00\x1f3" + // 0x01F31 0x00300: 0x01F33 + "\x00|\xc0\x00` \x1f4" + // 0x01F30 0x00301: 0x01F34 + "\x00|\xc4\x00` \x1f5" + // 0x01F31 0x00301: 0x01F35 + "\x00|\xc0\x00h@\x1f6" + // 0x01F30 0x00342: 0x01F36 + "\x00|\xc4\x00h@\x1f7" + // 0x01F31 0x00342: 0x01F37 + "\x00\x0ed\x00b`\x1f8" + // 0x00399 0x00313: 0x01F38 + "\x00\x0ed\x00b\x80\x1f9" + // 0x00399 0x00314: 0x01F39 + "\x00|\xe0\x00`\x00\x1f:" + // 0x01F38 0x00300: 0x01F3A + "\x00|\xe4\x00`\x00\x1f;" + // 0x01F39 0x00300: 0x01F3B + "\x00|\xe0\x00` \x1f<" + // 0x01F38 0x00301: 0x01F3C + "\x00|\xe4\x00` \x1f=" + // 0x01F39 0x00301: 0x01F3D + "\x00|\xe0\x00h@\x1f>" + // 0x01F38 0x00342: 0x01F3E + "\x00|\xe4\x00h@\x1f?" + // 0x01F39 0x00342: 0x01F3F + "\x00\x0e\xfc\x00b`\x1f@" + // 0x003BF 0x00313: 0x01F40 + "\x00\x0e\xfc\x00b\x80\x1fA" + // 0x003BF 0x00314: 0x01F41 + "\x00}\x00\x00`\x00\x1fB" + // 0x01F40 0x00300: 0x01F42 + "\x00}\x04\x00`\x00\x1fC" + // 0x01F41 0x00300: 0x01F43 + "\x00}\x00\x00` \x1fD" + // 0x01F40 0x00301: 0x01F44 + "\x00}\x04\x00` \x1fE" + // 0x01F41 0x00301: 0x01F45 + "\x00\x0e|\x00b`\x1fH" + // 0x0039F 0x00313: 0x01F48 + "\x00\x0e|\x00b\x80\x1fI" + // 0x0039F 0x00314: 0x01F49 + "\x00} \x00`\x00\x1fJ" + // 0x01F48 0x00300: 0x01F4A + "\x00}$\x00`\x00\x1fK" + // 0x01F49 0x00300: 0x01F4B + "\x00} \x00` \x1fL" + // 0x01F48 0x00301: 0x01F4C + "\x00}$\x00` \x1fM" + // 0x01F49 0x00301: 0x01F4D + "\x00\x0f\x14\x00b`\x1fP" + // 0x003C5 0x00313: 0x01F50 + "\x00\x0f\x14\x00b\x80\x1fQ" + // 0x003C5 0x00314: 0x01F51 + "\x00}@\x00`\x00\x1fR" + // 0x01F50 0x00300: 0x01F52 + "\x00}D\x00`\x00\x1fS" + // 0x01F51 0x00300: 0x01F53 + "\x00}@\x00` \x1fT" + // 0x01F50 0x00301: 0x01F54 + "\x00}D\x00` \x1fU" + // 0x01F51 0x00301: 0x01F55 + "\x00}@\x00h@\x1fV" + // 0x01F50 0x00342: 0x01F56 + "\x00}D\x00h@\x1fW" + // 0x01F51 0x00342: 0x01F57 + "\x00\x0e\x94\x00b\x80\x1fY" + // 0x003A5 0x00314: 0x01F59 + "\x00}d\x00`\x00\x1f[" + // 0x01F59 0x00300: 0x01F5B + "\x00}d\x00` \x1f]" + // 0x01F59 0x00301: 0x01F5D + "\x00}d\x00h@\x1f_" + // 0x01F59 0x00342: 0x01F5F + "\x00\x0f$\x00b`\x1f`" + // 0x003C9 0x00313: 0x01F60 + "\x00\x0f$\x00b\x80\x1fa" + // 0x003C9 0x00314: 0x01F61 + "\x00}\x80\x00`\x00\x1fb" + // 0x01F60 0x00300: 0x01F62 + "\x00}\x84\x00`\x00\x1fc" + // 0x01F61 0x00300: 0x01F63 + "\x00}\x80\x00` \x1fd" + // 0x01F60 0x00301: 0x01F64 + "\x00}\x84\x00` \x1fe" + // 0x01F61 0x00301: 0x01F65 + "\x00}\x80\x00h@\x1ff" + // 0x01F60 0x00342: 0x01F66 + "\x00}\x84\x00h@\x1fg" + // 0x01F61 0x00342: 0x01F67 + "\x00\x0e\xa4\x00b`\x1fh" + // 0x003A9 0x00313: 0x01F68 + "\x00\x0e\xa4\x00b\x80\x1fi" + // 0x003A9 0x00314: 0x01F69 + "\x00}\xa0\x00`\x00\x1fj" + // 0x01F68 0x00300: 0x01F6A + "\x00}\xa4\x00`\x00\x1fk" + // 0x01F69 0x00300: 0x01F6B + "\x00}\xa0\x00` \x1fl" + // 0x01F68 0x00301: 0x01F6C + "\x00}\xa4\x00` \x1fm" + // 0x01F69 0x00301: 0x01F6D + "\x00}\xa0\x00h@\x1fn" + // 0x01F68 0x00342: 0x01F6E + "\x00}\xa4\x00h@\x1fo" + // 0x01F69 0x00342: 0x01F6F + "\x00\x0e\xc4\x00`\x00\x1fp" + // 0x003B1 0x00300: 0x01F70 + "\x00\x0e\xd4\x00`\x00\x1fr" + // 0x003B5 0x00300: 0x01F72 + "\x00\x0e\xdc\x00`\x00\x1ft" + // 0x003B7 0x00300: 0x01F74 + "\x00\x0e\xe4\x00`\x00\x1fv" + // 0x003B9 0x00300: 0x01F76 + "\x00\x0e\xfc\x00`\x00\x1fx" + // 0x003BF 0x00300: 0x01F78 + "\x00\x0f\x14\x00`\x00\x1fz" + // 0x003C5 0x00300: 0x01F7A + "\x00\x0f$\x00`\x00\x1f|" + // 0x003C9 0x00300: 0x01F7C + "\x00|\x00\x00h\xa0\x1f\x80" + // 0x01F00 0x00345: 0x01F80 + "\x00|\x04\x00h\xa0\x1f\x81" + // 0x01F01 0x00345: 0x01F81 + "\x00|\b\x00h\xa0\x1f\x82" + // 0x01F02 0x00345: 0x01F82 + "\x00|\f\x00h\xa0\x1f\x83" + // 0x01F03 0x00345: 0x01F83 + "\x00|\x10\x00h\xa0\x1f\x84" + // 0x01F04 0x00345: 0x01F84 + "\x00|\x14\x00h\xa0\x1f\x85" + // 0x01F05 0x00345: 0x01F85 + "\x00|\x18\x00h\xa0\x1f\x86" + // 0x01F06 0x00345: 0x01F86 + "\x00|\x1c\x00h\xa0\x1f\x87" + // 0x01F07 0x00345: 0x01F87 + "\x00| \x00h\xa0\x1f\x88" + // 0x01F08 0x00345: 0x01F88 + "\x00|$\x00h\xa0\x1f\x89" + // 0x01F09 0x00345: 0x01F89 + "\x00|(\x00h\xa0\x1f\x8a" + // 0x01F0A 0x00345: 0x01F8A + "\x00|,\x00h\xa0\x1f\x8b" + // 0x01F0B 0x00345: 0x01F8B + "\x00|0\x00h\xa0\x1f\x8c" + // 0x01F0C 0x00345: 0x01F8C + "\x00|4\x00h\xa0\x1f\x8d" + // 0x01F0D 0x00345: 0x01F8D + "\x00|8\x00h\xa0\x1f\x8e" + // 0x01F0E 0x00345: 0x01F8E + "\x00|<\x00h\xa0\x1f\x8f" + // 0x01F0F 0x00345: 0x01F8F + "\x00|\x80\x00h\xa0\x1f\x90" + // 0x01F20 0x00345: 0x01F90 + "\x00|\x84\x00h\xa0\x1f\x91" + // 0x01F21 0x00345: 0x01F91 + "\x00|\x88\x00h\xa0\x1f\x92" + // 0x01F22 0x00345: 0x01F92 + "\x00|\x8c\x00h\xa0\x1f\x93" + // 0x01F23 0x00345: 0x01F93 + "\x00|\x90\x00h\xa0\x1f\x94" + // 0x01F24 0x00345: 0x01F94 + "\x00|\x94\x00h\xa0\x1f\x95" + // 0x01F25 0x00345: 0x01F95 + "\x00|\x98\x00h\xa0\x1f\x96" + // 0x01F26 0x00345: 0x01F96 + "\x00|\x9c\x00h\xa0\x1f\x97" + // 0x01F27 0x00345: 0x01F97 + "\x00|\xa0\x00h\xa0\x1f\x98" + // 0x01F28 0x00345: 0x01F98 + "\x00|\xa4\x00h\xa0\x1f\x99" + // 0x01F29 0x00345: 0x01F99 + "\x00|\xa8\x00h\xa0\x1f\x9a" + // 0x01F2A 0x00345: 0x01F9A + "\x00|\xac\x00h\xa0\x1f\x9b" + // 0x01F2B 0x00345: 0x01F9B + "\x00|\xb0\x00h\xa0\x1f\x9c" + // 0x01F2C 0x00345: 0x01F9C + "\x00|\xb4\x00h\xa0\x1f\x9d" + // 0x01F2D 0x00345: 0x01F9D + "\x00|\xb8\x00h\xa0\x1f\x9e" + // 0x01F2E 0x00345: 0x01F9E + "\x00|\xbc\x00h\xa0\x1f\x9f" + // 0x01F2F 0x00345: 0x01F9F + "\x00}\x80\x00h\xa0\x1f\xa0" + // 0x01F60 0x00345: 0x01FA0 + "\x00}\x84\x00h\xa0\x1f\xa1" + // 0x01F61 0x00345: 0x01FA1 + "\x00}\x88\x00h\xa0\x1f\xa2" + // 0x01F62 0x00345: 0x01FA2 + "\x00}\x8c\x00h\xa0\x1f\xa3" + // 0x01F63 0x00345: 0x01FA3 + "\x00}\x90\x00h\xa0\x1f\xa4" + // 0x01F64 0x00345: 0x01FA4 + "\x00}\x94\x00h\xa0\x1f\xa5" + // 0x01F65 0x00345: 0x01FA5 + "\x00}\x98\x00h\xa0\x1f\xa6" + // 0x01F66 0x00345: 0x01FA6 + "\x00}\x9c\x00h\xa0\x1f\xa7" + // 0x01F67 0x00345: 0x01FA7 + "\x00}\xa0\x00h\xa0\x1f\xa8" + // 0x01F68 0x00345: 0x01FA8 + "\x00}\xa4\x00h\xa0\x1f\xa9" + // 0x01F69 0x00345: 0x01FA9 + "\x00}\xa8\x00h\xa0\x1f\xaa" + // 0x01F6A 0x00345: 0x01FAA + "\x00}\xac\x00h\xa0\x1f\xab" + // 0x01F6B 0x00345: 0x01FAB + "\x00}\xb0\x00h\xa0\x1f\xac" + // 0x01F6C 0x00345: 0x01FAC + "\x00}\xb4\x00h\xa0\x1f\xad" + // 0x01F6D 0x00345: 0x01FAD + "\x00}\xb8\x00h\xa0\x1f\xae" + // 0x01F6E 0x00345: 0x01FAE + "\x00}\xbc\x00h\xa0\x1f\xaf" + // 0x01F6F 0x00345: 0x01FAF + "\x00\x0e\xc4\x00`\xc0\x1f\xb0" + // 0x003B1 0x00306: 0x01FB0 + "\x00\x0e\xc4\x00`\x80\x1f\xb1" + // 0x003B1 0x00304: 0x01FB1 + "\x00}\xc0\x00h\xa0\x1f\xb2" + // 0x01F70 0x00345: 0x01FB2 + "\x00\x0e\xc4\x00h\xa0\x1f\xb3" + // 0x003B1 0x00345: 0x01FB3 + "\x00\x0e\xb0\x00h\xa0\x1f\xb4" + // 0x003AC 0x00345: 0x01FB4 + "\x00\x0e\xc4\x00h@\x1f\xb6" + // 0x003B1 0x00342: 0x01FB6 + "\x00~\xd8\x00h\xa0\x1f\xb7" + // 0x01FB6 0x00345: 0x01FB7 + "\x00\x0eD\x00`\xc0\x1f\xb8" + // 0x00391 0x00306: 0x01FB8 + "\x00\x0eD\x00`\x80\x1f\xb9" + // 0x00391 0x00304: 0x01FB9 + "\x00\x0eD\x00`\x00\x1f\xba" + // 0x00391 0x00300: 0x01FBA + "\x00\x0eD\x00h\xa0\x1f\xbc" + // 0x00391 0x00345: 0x01FBC + "\x00\x02\xa0\x00h@\x1f\xc1" + // 0x000A8 0x00342: 0x01FC1 + "\x00}\xd0\x00h\xa0\x1f\xc2" + // 0x01F74 0x00345: 0x01FC2 + "\x00\x0e\xdc\x00h\xa0\x1f\xc3" + // 0x003B7 0x00345: 0x01FC3 + "\x00\x0e\xb8\x00h\xa0\x1f\xc4" + // 0x003AE 0x00345: 0x01FC4 + "\x00\x0e\xdc\x00h@\x1f\xc6" + // 0x003B7 0x00342: 0x01FC6 + "\x00\x7f\x18\x00h\xa0\x1f\xc7" + // 0x01FC6 0x00345: 0x01FC7 + "\x00\x0eT\x00`\x00\x1f\xc8" + // 0x00395 0x00300: 0x01FC8 + "\x00\x0e\\\x00`\x00\x1f\xca" + // 0x00397 0x00300: 0x01FCA + "\x00\x0e\\\x00h\xa0\x1f\xcc" + // 0x00397 0x00345: 0x01FCC + "\x00~\xfc\x00`\x00\x1f\xcd" + // 0x01FBF 0x00300: 0x01FCD + "\x00~\xfc\x00` \x1f\xce" + // 0x01FBF 0x00301: 0x01FCE + "\x00~\xfc\x00h@\x1f\xcf" + // 0x01FBF 0x00342: 0x01FCF + "\x00\x0e\xe4\x00`\xc0\x1f\xd0" + // 0x003B9 0x00306: 0x01FD0 + "\x00\x0e\xe4\x00`\x80\x1f\xd1" + // 0x003B9 0x00304: 0x01FD1 + "\x00\x0f(\x00`\x00\x1f\xd2" + // 0x003CA 0x00300: 0x01FD2 + "\x00\x0e\xe4\x00h@\x1f\xd6" + // 0x003B9 0x00342: 0x01FD6 + "\x00\x0f(\x00h@\x1f\xd7" + // 0x003CA 0x00342: 0x01FD7 + "\x00\x0ed\x00`\xc0\x1f\xd8" + // 0x00399 0x00306: 0x01FD8 + "\x00\x0ed\x00`\x80\x1f\xd9" + // 0x00399 0x00304: 0x01FD9 + "\x00\x0ed\x00`\x00\x1f\xda" + // 0x00399 0x00300: 0x01FDA + "\x00\x7f\xf8\x00`\x00\x1f\xdd" + // 0x01FFE 0x00300: 0x01FDD + "\x00\x7f\xf8\x00` \x1f\xde" + // 0x01FFE 0x00301: 0x01FDE + "\x00\x7f\xf8\x00h@\x1f\xdf" + // 0x01FFE 0x00342: 0x01FDF + "\x00\x0f\x14\x00`\xc0\x1f\xe0" + // 0x003C5 0x00306: 0x01FE0 + "\x00\x0f\x14\x00`\x80\x1f\xe1" + // 0x003C5 0x00304: 0x01FE1 + "\x00\x0f,\x00`\x00\x1f\xe2" + // 0x003CB 0x00300: 0x01FE2 + "\x00\x0f\x04\x00b`\x1f\xe4" + // 0x003C1 0x00313: 0x01FE4 + "\x00\x0f\x04\x00b\x80\x1f\xe5" + // 0x003C1 0x00314: 0x01FE5 + "\x00\x0f\x14\x00h@\x1f\xe6" + // 0x003C5 0x00342: 0x01FE6 + "\x00\x0f,\x00h@\x1f\xe7" + // 0x003CB 0x00342: 0x01FE7 + "\x00\x0e\x94\x00`\xc0\x1f\xe8" + // 0x003A5 0x00306: 0x01FE8 + "\x00\x0e\x94\x00`\x80\x1f\xe9" + // 0x003A5 0x00304: 0x01FE9 + "\x00\x0e\x94\x00`\x00\x1f\xea" + // 0x003A5 0x00300: 0x01FEA + "\x00\x0e\x84\x00b\x80\x1f\xec" + // 0x003A1 0x00314: 0x01FEC + "\x00\x02\xa0\x00`\x00\x1f\xed" + // 0x000A8 0x00300: 0x01FED + "\x00}\xf0\x00h\xa0\x1f\xf2" + // 0x01F7C 0x00345: 0x01FF2 + "\x00\x0f$\x00h\xa0\x1f\xf3" + // 0x003C9 0x00345: 0x01FF3 + "\x00\x0f8\x00h\xa0\x1f\xf4" + // 0x003CE 0x00345: 0x01FF4 + "\x00\x0f$\x00h@\x1f\xf6" + // 0x003C9 0x00342: 0x01FF6 + "\x00\x7f\xd8\x00h\xa0\x1f\xf7" + // 0x01FF6 0x00345: 0x01FF7 + "\x00\x0e|\x00`\x00\x1f\xf8" + // 0x0039F 0x00300: 0x01FF8 + "\x00\x0e\xa4\x00`\x00\x1f\xfa" + // 0x003A9 0x00300: 0x01FFA + "\x00\x0e\xa4\x00h\xa0\x1f\xfc" + // 0x003A9 0x00345: 0x01FFC + "\x00\x86@\x00g\x00!\x9a" + // 0x02190 0x00338: 0x0219A + "\x00\x86H\x00g\x00!\x9b" + // 0x02192 0x00338: 0x0219B + "\x00\x86P\x00g\x00!\xae" + // 0x02194 0x00338: 0x021AE + "\x00\x87@\x00g\x00!\xcd" + // 0x021D0 0x00338: 0x021CD + "\x00\x87P\x00g\x00!\xce" + // 0x021D4 0x00338: 0x021CE + "\x00\x87H\x00g\x00!\xcf" + // 0x021D2 0x00338: 0x021CF + "\x00\x88\f\x00g\x00\"\x04" + // 0x02203 0x00338: 0x02204 + "\x00\x88 \x00g\x00\"\t" + // 0x02208 0x00338: 0x02209 + "\x00\x88,\x00g\x00\"\f" + // 0x0220B 0x00338: 0x0220C + "\x00\x88\x8c\x00g\x00\"$" + // 0x02223 0x00338: 0x02224 + "\x00\x88\x94\x00g\x00\"&" + // 0x02225 0x00338: 0x02226 + "\x00\x88\xf0\x00g\x00\"A" + // 0x0223C 0x00338: 0x02241 + "\x00\x89\f\x00g\x00\"D" + // 0x02243 0x00338: 0x02244 + "\x00\x89\x14\x00g\x00\"G" + // 0x02245 0x00338: 0x02247 + "\x00\x89 \x00g\x00\"I" + // 0x02248 0x00338: 0x02249 + "\x00\x00\xf4\x00g\x00\"`" + // 0x0003D 0x00338: 0x02260 + "\x00\x89\x84\x00g\x00\"b" + // 0x02261 0x00338: 0x02262 + "\x00\x894\x00g\x00\"m" + // 0x0224D 0x00338: 0x0226D + "\x00\x00\xf0\x00g\x00\"n" + // 0x0003C 0x00338: 0x0226E + "\x00\x00\xf8\x00g\x00\"o" + // 0x0003E 0x00338: 0x0226F + "\x00\x89\x90\x00g\x00\"p" + // 0x02264 0x00338: 0x02270 + "\x00\x89\x94\x00g\x00\"q" + // 0x02265 0x00338: 0x02271 + "\x00\x89\xc8\x00g\x00\"t" + // 0x02272 0x00338: 0x02274 + "\x00\x89\xcc\x00g\x00\"u" + // 0x02273 0x00338: 0x02275 + "\x00\x89\xd8\x00g\x00\"x" + // 0x02276 0x00338: 0x02278 + "\x00\x89\xdc\x00g\x00\"y" + // 0x02277 0x00338: 0x02279 + "\x00\x89\xe8\x00g\x00\"\x80" + // 0x0227A 0x00338: 0x02280 + "\x00\x89\xec\x00g\x00\"\x81" + // 0x0227B 0x00338: 0x02281 + "\x00\x8a\b\x00g\x00\"\x84" + // 0x02282 0x00338: 0x02284 + "\x00\x8a\f\x00g\x00\"\x85" + // 0x02283 0x00338: 0x02285 + "\x00\x8a\x18\x00g\x00\"\x88" + // 0x02286 0x00338: 0x02288 + "\x00\x8a\x1c\x00g\x00\"\x89" + // 0x02287 0x00338: 0x02289 + "\x00\x8a\x88\x00g\x00\"\xac" + // 0x022A2 0x00338: 0x022AC + "\x00\x8a\xa0\x00g\x00\"\xad" + // 0x022A8 0x00338: 0x022AD + "\x00\x8a\xa4\x00g\x00\"\xae" + // 0x022A9 0x00338: 0x022AE + "\x00\x8a\xac\x00g\x00\"\xaf" + // 0x022AB 0x00338: 0x022AF + "\x00\x89\xf0\x00g\x00\"\xe0" + // 0x0227C 0x00338: 0x022E0 + "\x00\x89\xf4\x00g\x00\"\xe1" + // 0x0227D 0x00338: 0x022E1 + "\x00\x8aD\x00g\x00\"\xe2" + // 0x02291 0x00338: 0x022E2 + "\x00\x8aH\x00g\x00\"\xe3" + // 0x02292 0x00338: 0x022E3 + "\x00\x8a\xc8\x00g\x00\"\xea" + // 0x022B2 0x00338: 0x022EA + "\x00\x8a\xcc\x00g\x00\"\xeb" + // 0x022B3 0x00338: 0x022EB + "\x00\x8a\xd0\x00g\x00\"\xec" + // 0x022B4 0x00338: 0x022EC + "\x00\x8a\xd4\x00g\x00\"\xed" + // 0x022B5 0x00338: 0x022ED + "\x00\xc1,\x06\x13 0L" + // 0x0304B 0x03099: 0x0304C + "\x00\xc14\x06\x13 0N" + // 0x0304D 0x03099: 0x0304E + "\x00\xc1<\x06\x13 0P" + // 0x0304F 0x03099: 0x03050 + "\x00\xc1D\x06\x13 0R" + // 0x03051 0x03099: 0x03052 + "\x00\xc1L\x06\x13 0T" + // 0x03053 0x03099: 0x03054 + "\x00\xc1T\x06\x13 0V" + // 0x03055 0x03099: 0x03056 + "\x00\xc1\\\x06\x13 0X" + // 0x03057 0x03099: 0x03058 + "\x00\xc1d\x06\x13 0Z" + // 0x03059 0x03099: 0x0305A + "\x00\xc1l\x06\x13 0\\" + // 0x0305B 0x03099: 0x0305C + "\x00\xc1t\x06\x13 0^" + // 0x0305D 0x03099: 0x0305E + "\x00\xc1|\x06\x13 0`" + // 0x0305F 0x03099: 0x03060 + "\x00\xc1\x84\x06\x13 0b" + // 0x03061 0x03099: 0x03062 + "\x00\xc1\x90\x06\x13 0e" + // 0x03064 0x03099: 0x03065 + "\x00\xc1\x98\x06\x13 0g" + // 0x03066 0x03099: 0x03067 + "\x00\xc1\xa0\x06\x13 0i" + // 0x03068 0x03099: 0x03069 + "\x00\xc1\xbc\x06\x13 0p" + // 0x0306F 0x03099: 0x03070 + "\x00\xc1\xbc\x06\x13@0q" + // 0x0306F 0x0309A: 0x03071 + "\x00\xc1\xc8\x06\x13 0s" + // 0x03072 0x03099: 0x03073 + "\x00\xc1\xc8\x06\x13@0t" + // 0x03072 0x0309A: 0x03074 + "\x00\xc1\xd4\x06\x13 0v" + // 0x03075 0x03099: 0x03076 + "\x00\xc1\xd4\x06\x13@0w" + // 0x03075 0x0309A: 0x03077 + "\x00\xc1\xe0\x06\x13 0y" + // 0x03078 0x03099: 0x03079 + "\x00\xc1\xe0\x06\x13@0z" + // 0x03078 0x0309A: 0x0307A + "\x00\xc1\xec\x06\x13 0|" + // 0x0307B 0x03099: 0x0307C + "\x00\xc1\xec\x06\x13@0}" + // 0x0307B 0x0309A: 0x0307D + "\x00\xc1\x18\x06\x13 0\x94" + // 0x03046 0x03099: 0x03094 + "\x00\xc2t\x06\x13 0\x9e" + // 0x0309D 0x03099: 0x0309E + "\x00¬\x06\x13 0\xac" + // 0x030AB 0x03099: 0x030AC + "\x00´\x06\x13 0\xae" + // 0x030AD 0x03099: 0x030AE + "\x00¼\x06\x13 0\xb0" + // 0x030AF 0x03099: 0x030B0 + "\x00\xc2\xc4\x06\x13 0\xb2" + // 0x030B1 0x03099: 0x030B2 + "\x00\xc2\xcc\x06\x13 0\xb4" + // 0x030B3 0x03099: 0x030B4 + "\x00\xc2\xd4\x06\x13 0\xb6" + // 0x030B5 0x03099: 0x030B6 + "\x00\xc2\xdc\x06\x13 0\xb8" + // 0x030B7 0x03099: 0x030B8 + "\x00\xc2\xe4\x06\x13 0\xba" + // 0x030B9 0x03099: 0x030BA + "\x00\xc2\xec\x06\x13 0\xbc" + // 0x030BB 0x03099: 0x030BC + "\x00\xc2\xf4\x06\x13 0\xbe" + // 0x030BD 0x03099: 0x030BE + "\x00\xc2\xfc\x06\x13 0\xc0" + // 0x030BF 0x03099: 0x030C0 + "\x00\xc3\x04\x06\x13 0\xc2" + // 0x030C1 0x03099: 0x030C2 + "\x00\xc3\x10\x06\x13 0\xc5" + // 0x030C4 0x03099: 0x030C5 + "\x00\xc3\x18\x06\x13 0\xc7" + // 0x030C6 0x03099: 0x030C7 + "\x00\xc3 \x06\x13 0\xc9" + // 0x030C8 0x03099: 0x030C9 + "\x00\xc3<\x06\x13 0\xd0" + // 0x030CF 0x03099: 0x030D0 + "\x00\xc3<\x06\x13@0\xd1" + // 0x030CF 0x0309A: 0x030D1 + "\x00\xc3H\x06\x13 0\xd3" + // 0x030D2 0x03099: 0x030D3 + "\x00\xc3H\x06\x13@0\xd4" + // 0x030D2 0x0309A: 0x030D4 + "\x00\xc3T\x06\x13 0\xd6" + // 0x030D5 0x03099: 0x030D6 + "\x00\xc3T\x06\x13@0\xd7" + // 0x030D5 0x0309A: 0x030D7 + "\x00\xc3`\x06\x13 0\xd9" + // 0x030D8 0x03099: 0x030D9 + "\x00\xc3`\x06\x13@0\xda" + // 0x030D8 0x0309A: 0x030DA + "\x00\xc3l\x06\x13 0\xdc" + // 0x030DB 0x03099: 0x030DC + "\x00\xc3l\x06\x13@0\xdd" + // 0x030DB 0x0309A: 0x030DD + "\x00\u0098\x06\x13 0\xf4" + // 0x030A6 0x03099: 0x030F4 + "\x00ü\x06\x13 0\xf7" + // 0x030EF 0x03099: 0x030F7 + "\x00\xc3\xc0\x06\x13 0\xf8" + // 0x030F0 0x03099: 0x030F8 + "\x00\xc3\xc4\x06\x13 0\xf9" + // 0x030F1 0x03099: 0x030F9 + "\x00\xc3\xc8\x06\x13 0\xfa" + // 0x030F2 0x03099: 0x030FA + "\x00\xc3\xf4\x06\x13 0\xfe" + // 0x030FD 0x03099: 0x030FE + "\x04\x17H\x00`\xe1\x05\xc9" + // 0x105D2 0x00307: 0x105C9 + "\x04\x17h\x00`\xe1\x05\xe4" + // 0x105DA 0x00307: 0x105E4 + "\x04Bd\"\x17A\x10\x9a" + // 0x11099 0x110BA: 0x1109A + "\x04Bl\"\x17A\x10\x9c" + // 0x1109B 0x110BA: 0x1109C + "\x04B\x94\"\x17A\x10\xab" + // 0x110A5 0x110BA: 0x110AB + "\x04D\xc4\"$\xe1\x11." + // 0x11131 0x11127: 0x1112E + "\x04D\xc8\"$\xe1\x11/" + // 0x11132 0x11127: 0x1112F + "\x04M\x1c\"g\xc1\x13K" + // 0x11347 0x1133E: 0x1134B + "\x04M\x1c\"j\xe1\x13L" + // 0x11347 0x11357: 0x1134C + "\x04N\b\"y!\x13\x83" + // 0x11382 0x113C9: 0x11383 + "\x04N\x10\"wa\x13\x85" + // 0x11384 0x113BB: 0x11385 + "\x04N,\"xA\x13\x8e" + // 0x1138B 0x113C2: 0x1138E + "\x04N@\"y!\x13\x91" + // 0x11390 0x113C9: 0x11391 + "\x04O\b\"xA\x13\xc5" + // 0x113C2 0x113C2: 0x113C5 + "\x04O\b\"w\x01\x13\xc7" + // 0x113C2 0x113B8: 0x113C7 + "\x04O\b\"y!\x13\xc8" + // 0x113C2 0x113C9: 0x113C8 + "\x04R\xe4\"\x97A\x14\xbb" + // 0x114B9 0x114BA: 0x114BB + "\x04R\xe4\"\x96\x01\x14\xbc" + // 0x114B9 0x114B0: 0x114BC + "\x04R\xe4\"\x97\xa1\x14\xbe" + // 0x114B9 0x114BD: 0x114BE + "\x04V\xe0\"\xb5\xe1\x15\xba" + // 0x115B8 0x115AF: 0x115BA + "\x04V\xe4\"\xb5\xe1\x15\xbb" + // 0x115B9 0x115AF: 0x115BB + "\x04d\xd4#&\x01\x198" + // 0x11935 0x11930: 0x11938 + "\x05\x84x,#\xc1a!" + // 0x1611E 0x1611E: 0x16121 + "\x05\x84x,%!a\"" + // 0x1611E 0x16129: 0x16122 + "\x05\x84x,#\xe1a#" + // 0x1611E 0x1611F: 0x16123 + "\x05\x84\xa4,#\xe1a$" + // 0x16129 0x1611F: 0x16124 + "\x05\x84x,$\x01a%" + // 0x1611E 0x16120: 0x16125 + "\x05\x84\x84,#\xe1a&" + // 0x16121 0x1611F: 0x16126 + "\x05\x84\x88,#\xe1a'" + // 0x16122 0x1611F: 0x16127 + "\x05\x84\x84,$\x01a(" + // 0x16121 0x16120: 0x16128 + "\x05\xb5\x9c-\xac\xe1mh" + // 0x16D67 0x16D67: 0x16D68 + "\x05\xb5\x8c-\xac\xe1mi" + // 0x16D63 0x16D67: 0x16D69 + "\x05\xb5\xa4-\xac\xe1mj" + // 0x16D69 0x16D67: 0x16D6A "" // Total size of tables: 57KB (58086 bytes) diff --git a/vendor/golang.org/x/text/unicode/norm/transform.go b/vendor/golang.org/x/text/unicode/norm/transform.go index a1d366ae..583230d4 100644 --- a/vendor/golang.org/x/text/unicode/norm/transform.go +++ b/vendor/golang.org/x/text/unicode/norm/transform.go @@ -33,7 +33,7 @@ func (f Form) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) return nDst + n, nSrc + n, err } - if err == nil && n < len(src) && !atEOF { + if err == nil && n < len(src) { err = transform.ErrShortSrc } return n, n, err @@ -67,6 +67,7 @@ func (f Form) transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) nSrc = end // Next quickSpan. + var err error end = rb.nsrc eof := atEOF if n := nSrc + len(dst) - nDst; n < end { @@ -79,7 +80,7 @@ func (f Form) transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) nSrc += n nDst += n if ok { - if err == nil && n < rb.nsrc && !atEOF { + if err == nil && nSrc < rb.nsrc { err = transform.ErrShortSrc } return nDst, nSrc, err diff --git a/vendor/modules.txt b/vendor/modules.txt index 5e75a871..edb656d3 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -272,8 +272,8 @@ github.com/cockroachdb/swiss # github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 ## explicit; go 1.19 github.com/cockroachdb/tokenbucket -# github.com/conductorone/baton-sdk v0.40.0 -## explicit; go 1.27.1 +# github.com/conductorone/baton-sdk v0.42.3 +## explicit; go 1.27.3 github.com/conductorone/baton-sdk/internal/atomicfile github.com/conductorone/baton-sdk/internal/connector github.com/conductorone/baton-sdk/pb/c1/c1z/v1 @@ -743,7 +743,7 @@ go.uber.org/zap/zapcore # go.yaml.in/yaml/v3 v3.0.5 ## explicit; go 1.16 go.yaml.in/yaml/v3 -# golang.org/x/crypto v0.56.0 +# golang.org/x/crypto v0.57.0 ## explicit; go 1.26.0 golang.org/x/crypto/blowfish golang.org/x/crypto/chacha20 @@ -761,8 +761,8 @@ golang.org/x/crypto/ssh/internal/bcrypt_pbkdf # golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 ## explicit; go 1.25.0 golang.org/x/exp/constraints -# golang.org/x/net v0.58.0 -## explicit; go 1.25.0 +# golang.org/x/net v0.60.0 +## explicit; go 1.26.0 golang.org/x/net/context/ctxhttp golang.org/x/net/http/httpguts golang.org/x/net/http2 @@ -779,12 +779,12 @@ golang.org/x/oauth2/clientcredentials golang.org/x/oauth2/internal golang.org/x/oauth2/jws golang.org/x/oauth2/jwt -# golang.org/x/sync v0.22.0 -## explicit; go 1.25.0 +# golang.org/x/sync v0.23.0 +## explicit; go 1.26.0 golang.org/x/sync/semaphore golang.org/x/sync/singleflight -# golang.org/x/sys v0.47.0 -## explicit; go 1.25.0 +# golang.org/x/sys v0.48.0 +## explicit; go 1.26.0 golang.org/x/sys/cpu golang.org/x/sys/execabs golang.org/x/sys/plan9 @@ -795,11 +795,11 @@ golang.org/x/sys/windows/svc golang.org/x/sys/windows/svc/debug golang.org/x/sys/windows/svc/eventlog golang.org/x/sys/windows/svc/mgr -# golang.org/x/term v0.45.0 -## explicit; go 1.25.0 +# golang.org/x/term v0.46.0 +## explicit; go 1.26.0 golang.org/x/term -# golang.org/x/text v0.41.0 -## explicit; go 1.25.0 +# golang.org/x/text v0.42.0 +## explicit; go 1.26.0 golang.org/x/text/cases golang.org/x/text/encoding golang.org/x/text/encoding/internal From bedb9d5469414352df4345a70fa3b3824ea4e611 Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Mon, 26 Oct 2026 10:47:27 -0300 Subject: [PATCH 30/36] docs(databricks): say a pending workspace fails the sync Co-authored-by: Cursor --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e87553be..695c4127 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ it. The connector never guesses when the answer is unknown: | Attached workspace | Result | |--------------------|--------| | `RUNNING` and in scope | the metastore syncs through it | -| `PROVISIONING`, `NOT_PROVISIONED`, `CANCELLING` | still changing, so the metastore is kept and its grants fail with `FailedPrecondition` until the state settles | +| `PROVISIONING`, `NOT_PROVISIONED`, `CANCELLING` | still changing, so the metastore is kept and the sync fails with `FailedPrecondition` until a workspace is `RUNNING` | | `FAILED`, `BANNED`, or a status Databricks does not document | will not become `RUNNING`, so it is skipped; a metastore left with no other workspace is not synced | | assignment list unreadable (401, 403, 404) | unknown, so the metastore is kept and the sync fails naming the assignment list | From 4e9dc4f967f43c029047cd1bd2922a731b86cf6b Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Tue, 27 Oct 2026 09:57:46 -0300 Subject: [PATCH 31/36] fix(databricks): route a metastore through a workspace it can fully read A partial permissions read looks like an empty grant list, so List and provisioning pick the first running in-scope workspace where the connector owns the metastore or is a direct Admin. Co-authored-by: Cursor --- README.md | 10 +- docs/docs-info.md | 4 +- pkg/connector/connector.go | 2 +- pkg/connector/metastore.go | 67 ++++--- pkg/connector/metastore_helpers.go | 122 +++++++----- pkg/connector/metastore_test.go | 285 +++++++++++++++++++++++++---- 6 files changed, 375 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 695c4127..57f9e11c 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,10 @@ members as holders. metastore level. A metastore lives on the account plane, but its privileges are read through a -workspace attached to it. +workspace attached to it. The connector picks that workspace itself: the first +running one in scope where it can read every privilege, which it proves by +owning the metastore or by holding Admin directly on that workspace. Grant and +Revoke resolve the same route from the metastore id. A metastore is synced only while a workspace this sync can use is attached to it. The connector never guesses when the answer is unknown: @@ -188,9 +191,10 @@ fails with an `InvalidArgument` error. ## Unity Catalog permissions The service principal must own the metastore, or be assigned **Admin** directly -on the workspace the metastore is read through. Admin inherited only through a +on at least one running workspace attached to it. Admin inherited only through a group is not enough. Databricks answers an under-permissioned read with HTTP 200 -and only the rows the caller may see. The connector refuses that read, so the +and only the rows the caller may see, so the connector reads a metastore only +through a workspace that passes that check. When no attached workspace does, the sync fails and ConductorOne keeps the grants it already has. The connector requests the `all-apis` OAuth scope. Each resource type declares diff --git a/docs/docs-info.md b/docs/docs-info.md index 2ed955b6..de5f520d 100644 --- a/docs/docs-info.md +++ b/docs/docs-info.md @@ -40,7 +40,7 @@ While developing the connector, please fill out this form. This information is n > > The OAuth token carries scopes on top of that. The connector requests `all-apis`, which covers the four it uses: `unity-catalog` (the securables and the permissions endpoint), `scim` (account principals), `access-management` (roles and workspace permission assignments) and `provisioning` (the account workspaces listing). > - > Unity Catalog adds a third layer on top of scopes and roles: standing on the securable itself. For each metastore being synced, the service principal must either own the metastore or be assigned Admin directly on the workspace it is read through. Without that standing the sync fails, and ConductorOne keeps the grants it already has. + > Unity Catalog adds a third layer on top of scopes and roles: standing on the securable itself. For each metastore being synced, the service principal must either own the metastore or be assigned Admin directly on at least one running workspace attached to it. Without that standing the sync fails, and ConductorOne keeps the grants it already has. * If applicable: Is the list of scopes or permissions different to sync (read) versus provision (read-write)? If so, list the difference here. @@ -56,6 +56,6 @@ While developing the connector, please fill out this form. This information is n - **Direct Admin, or the sync fails.** Admin inherited through a group does not count. Databricks then returns HTTP 200 with only the privileges the caller can see. The sync fails instead of reporting that list, so ConductorOne keeps the grants it already has. - **Ownership is read-only.** It is shown and cannot be granted or revoked. An owner that is not an account identity (`System user`, `_workspace_admins_…`) is not shown. - **No `MANAGE` or `ALL_PRIVILEGES` on a metastore.** Databricks rejects both, so they are not offered. -- **Read through one running workspace.** Privileges are not served on the account plane. With no running workspace in scope, the metastore is not synced and ConductorOne removes it. A workspace that is still provisioning fails the sync until it is `RUNNING`. An unreadable assignment list fails the sync and ConductorOne keeps the metastore. +- **Read through one running workspace.** Privileges are not served on the account plane. The connector reads each metastore through the first running workspace in scope where it can see every privilege. With no running workspace in scope, the metastore is not synced and ConductorOne removes it. A workspace that is still provisioning fails the sync until it is `RUNNING`. An unreadable assignment list fails the sync and ConductorOne keeps the metastore. - **`workspaces` scopes metastores.** An allowlist that names no workspace on the account fails at credential validation, before any sync. - **Not synced yet:** catalogs, schemas, tables, volumes, functions, registered models, external locations, storage credentials, connections, shares, recipients, and tags. diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index d8048d6c..3b99ea58 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -17,7 +17,7 @@ import ( ) type Databricks struct { - client *databricks.Client + client *databricks.Client clientID string workspaces []string opts *cli.ConnectorOpts diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index d1ad1eca..dfd8b4ca 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -2,6 +2,8 @@ package connector import ( "context" + "encoding/json" + "errors" "fmt" "slices" "strconv" @@ -94,14 +96,12 @@ func (m *metastoreBuilder) List(ctx context.Context, parentResourceID *v2.Resour } // A 404 here does not mean the metastore is gone. Skipping it would make ConductorOne delete it. - workspace, inScope, err := m.routeMetastore(ctx, metastore.MetastoreID, scoped, running, &annos) + workspace, pending, err := m.routeMetastore(ctx, metastore.MetastoreID, metastore.Owner, scoped, running, &annos) if err != nil { - return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf( - "databricks-connector: failed to list the workspaces assigned to metastore %s, so the sync cannot tell which serve it: %w", - metastore.MetastoreID, err) + return nil, &rs.SyncOpResults{Annotations: annos}, err } if workspace == nil { - if inScope { + if pending { return nil, &rs.SyncOpResults{Annotations: annos}, status.Errorf(codes.FailedPrecondition, "databricks-connector: metastore %s has no running workspace in sync scope", metastore.MetastoreID) } @@ -145,17 +145,14 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at l := ctxzap.Extract(ctx) metastoreID := resource.GetId().GetResource() profile := rs.GetProfile(resource) + // List writes the route. An empty one is a resource this sync did not emit. workspace, _ := rs.GetProfileStringValue(profile, profileKeyWorkspace) if workspace == "" { - l.Debug("databricks-connector: metastore has no workspace on its profile; returning no grants", zap.String("metastore", metastoreID)) - return nil, nil, nil + return nil, nil, status.Errorf(codes.FailedPrecondition, + "databricks-connector: metastore %s has no workspace on its profile", metastoreID) } annos := annotations.Annotations{} - if err := m.ensureGrantsAreTrustworthy(ctx, resource, metastoreID, workspace, &annos); err != nil { - return nil, &rs.SyncOpResults{Annotations: annos}, err - } - pageToken := attr.PageToken.Token assignments, nextPageToken, rateLimit, err := m.client.ListPermissions(ctx, workspace, databricks.SecurableMetastore, metastoreID, "", pageToken) noteRateLimit(&annos, rateLimit) @@ -255,17 +252,17 @@ func (m *metastoreBuilder) resolveChange( "databricks-connector: %s is not a grantable metastore privilege", privilege) } - workspace, _ := rs.GetProfileStringValue(rs.GetProfile(resource), profileKeyWorkspace) - if workspace == "" { - return metastoreChange{}, status.Errorf(codes.FailedPrecondition, - "databricks-connector: metastore %s has no workspace on its profile", metastoreID) - } + // Provisioning carries ids, not the profile a sync wrote. + routed, err := m.resolveMetastoreRoute(ctx, metastoreID, annos) + if err != nil { + if errors.Is(err, errMetastoreGone) && isGrant { + return metastoreChange{}, status.Errorf(codes.NotFound, + "databricks-connector: metastore %s is no longer on the account", metastoreID) + } - // A privilege list the connector cannot fully read also cannot be safely - // written to: the PATCH would be built from the rows it is allowed to see. - if err := m.ensureGrantsAreTrustworthy(ctx, resource, metastoreID, workspace, annos); err != nil { return metastoreChange{}, err } + workspace := routed.DeploymentName scimID, err := unityPrincipalScimID(principalID) if err != nil { @@ -274,11 +271,14 @@ func (m *metastoreBuilder) resolveChange( principalName, err := unityPrincipalName(ctx, m.client, principalID) if err != nil { - return metastoreChange{}, fmt.Errorf("databricks-connector: failed to resolve principal %s: %w", principalID.GetResource(), err) - } - // Revoke can still remove a deleted principal's row by its id. - if isGrant && principalName == "" { - return metastoreChange{}, status.Errorf(codes.NotFound, "databricks-connector: principal %s no longer exists in Databricks", scimID) + if !errors.Is(err, errPrincipalNotFound) { + return metastoreChange{}, fmt.Errorf("databricks-connector: failed to resolve principal %s: %w", principalID.GetResource(), err) + } + // Revoke addresses a vanished principal's rows by id. + if isGrant { + return metastoreChange{}, status.Errorf(codes.NotFound, + "databricks-connector: principal %s no longer exists in Databricks", scimID) + } } return metastoreChange{ @@ -349,6 +349,11 @@ func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annot annos := annotations.Annotations{} change, err := m.resolveChange(ctx, revoked.GetPrincipal().GetId(), revoked.GetEntitlement(), false, &annos) if err != nil { + if errors.Is(err, errMetastoreGone) { + annos.Update(&v2.GrantAlreadyRevoked{}) + return annos, nil + } + return annos, err } @@ -365,15 +370,14 @@ func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annot return annos, fmt.Errorf("databricks-connector: failed to read privileges on metastore %s: %w", change.metastoreID, err) } + // A missed row can be a partial read, and removing a privilege nobody holds is a no-op. held, ok := change.matchHeld(seen) - if !ok { - annos.Update(&v2.GrantAlreadyRevoked{}) - return annos, nil - } - removal := databricks.PermissionsChange{Principal: change.principalName, Remove: []string{change.privilege}} - if heldID := held.PrincipalID.String(); heldID != "" && heldID != "0" { + switch heldID := held.PrincipalID.String(); { + case ok && heldID != "" && heldID != "0": removal = databricks.PermissionsChange{PrincipalID: held.PrincipalID, Remove: []string{change.privilege}} + case change.principalName == "": + removal = databricks.PermissionsChange{PrincipalID: json.Number(change.scimID), Remove: []string{change.privilege}} } result, rateLimit, err := m.client.UpdatePermissions(ctx, change.workspace, databricks.SecurableMetastore, change.metastoreID, @@ -386,6 +390,9 @@ func (m *metastoreBuilder) Revoke(ctx context.Context, revoked *v2.Grant) (annot return annos, status.Errorf(codes.Internal, "databricks-connector: revoking %s on metastore %s was accepted but not applied", change.privilege, change.metastoreID) } + if !ok { + annos.Update(&v2.GrantAlreadyRevoked{}) + } return annos, nil } diff --git a/pkg/connector/metastore_helpers.go b/pkg/connector/metastore_helpers.go index a56e333a..9217762a 100644 --- a/pkg/connector/metastore_helpers.go +++ b/pkg/connector/metastore_helpers.go @@ -234,11 +234,15 @@ func workspaceIsUsable(workspaceStatus string) bool { } } -// routeMetastore picks the first running in-scope workspace attached to the -// metastore. inScope reports whether any in-scope workspace is attached at all. +// errMetastoreGone marks a metastore the account no longer lists. +var errMetastoreGone = errors.New("metastore is no longer on the account") + +// routeMetastore picks a workspace the connector can read every privilege through: +// it owns the metastore, or it is a direct Admin there. A nil workspace with no +// error means none is running and in scope; pending says one can still become RUNNING. func (m *metastoreBuilder) routeMetastore( ctx context.Context, - metastoreID string, + metastoreID, owner string, scoped map[string]databricks.Workspace, running map[string]bool, annos *annotations.Annotations, @@ -246,37 +250,84 @@ func (m *metastoreBuilder) routeMetastore( ids, rateLimit, err := m.client.ListMetastoreWorkspaces(ctx, metastoreID) noteRateLimit(annos, rateLimit) if err != nil { - return nil, false, err + return nil, false, fmt.Errorf( + "databricks-connector: failed to list the workspaces assigned to metastore %s, so the sync cannot tell which serve it: %w", + metastoreID, err) } - inScope := false + pending := false + var candidates []databricks.Workspace for _, id := range ids { workspace, ok := scoped[id] if !ok { continue } if running[id] { - return &workspace, true, nil + candidates = append(candidates, workspace) + continue + } + pending = true + } + if len(candidates) == 0 { + return nil, pending, nil + } + + if ownsPrincipal(m.clientID, owner) { + return &candidates[0], false, nil + } + + for i := range candidates { + admin, err := isWorkspaceAdmin(ctx, m.client, m.clientID, strconv.Itoa(candidates[i].ID), annos) + if err != nil { + return nil, false, err + } + if admin { + return &candidates[i], false, nil } - inScope = true } - return nil, inScope, nil + return nil, false, untrustedMetastoreError(metastoreID, candidates) } -// metastoreGrantsAreTrustworthy is true when the connector owns the metastore or is a direct Admin of the routed workspace. MANAGE is not a metastore privilege. -func metastoreGrantsAreTrustworthy( +// resolveMetastoreRoute routes a metastore from its id, reading owner and standing live. +func (m *metastoreBuilder) resolveMetastoreRoute( ctx context.Context, - client *databricks.Client, - ownPrincipal, metastoreOwner, workspaceID string, + metastoreID string, annos *annotations.Annotations, -) (bool, error) { - // An owner holds every privilege implicitly, which is not reported as a grant. - if ownsPrincipal(ownPrincipal, metastoreOwner) { - return true, nil +) (*databricks.Workspace, error) { + metastores, rateLimit, err := m.client.ListMetastores(ctx) + noteRateLimit(annos, rateLimit) + if err != nil { + return nil, fmt.Errorf("databricks-connector: failed to list metastores: %w", err) + } + + found := slices.IndexFunc(metastores, func(metastore databricks.Metastore) bool { + return metastore.MetastoreID == metastoreID + }) + if found < 0 { + return nil, errMetastoreGone } - return isWorkspaceAdmin(ctx, client, ownPrincipal, workspaceID, annos) + scoped, running, err := m.scopedWorkspaces(ctx, annos) + if err != nil { + return nil, err + } + + workspace, pending, err := m.routeMetastore(ctx, metastoreID, metastores[found].Owner, scoped, running, annos) + if err != nil { + return nil, err + } + if workspace == nil { + reason := "no workspace in sync scope is attached to it" + if pending { + reason = "every workspace in sync scope attached to it is still on its way to RUNNING" + } + + return nil, status.Errorf(codes.FailedPrecondition, + "databricks-connector: cannot route metastore %s: %s", metastoreID, reason) + } + + return workspace, nil } // ownsPrincipal reports whether name is the service principal the connector @@ -376,39 +427,18 @@ func deniesTrust(err error) bool { } } -// ensureGrantsAreTrustworthy refuses the call unless the privilege list is -// complete. Databricks returns HTTP 200 with only the rows the caller may see, -// and reporting that subset would make ConductorOne revoke the rest. -// The check is not cached. -func (m *metastoreBuilder) ensureGrantsAreTrustworthy( - ctx context.Context, - resource *v2.Resource, - metastoreID, workspace string, - annos *annotations.Annotations, -) error { - profile := rs.GetProfile(resource) - owner, _ := rs.GetProfileStringValue(profile, profileKeyOwner) - workspaceID, _ := rs.GetProfileStringValue(profile, profileKeyWorkspaceID) - - trustworthy, err := metastoreGrantsAreTrustworthy(ctx, m.client, m.clientID, owner, workspaceID, annos) - if err != nil { - return err - } - if !trustworthy { - return untrustedMetastoreError(metastoreID, workspace) +func untrustedMetastoreError(metastoreID string, tried []databricks.Workspace) error { + names := make([]string, 0, len(tried)) + for _, workspace := range tried { + names = append(names, workspace.DeploymentName) } - return nil -} - -// untrustedMetastoreError names the metastore, the workspace the read goes -// through and what an operator has to change. -func untrustedMetastoreError(metastoreID, workspace string) error { return status.Errorf(codes.PermissionDenied, - "databricks-connector: cannot read every privilege on metastore %s through workspace %s, and Databricks answers a partial read with 200, "+ + "databricks-connector: cannot read every privilege on metastore %s through any workspace attached to it (%s), "+ + "and Databricks answers a partial read with 200, "+ "so reporting it would revoke the access ConductorOne cannot see; "+ - "the connector's service principal must own the metastore or be assigned Admin directly on the workspace", - metastoreID, workspace) + "the connector's service principal must own the metastore or be assigned Admin directly on one of those workspaces", + metastoreID, strings.Join(names, ", ")) } // principalResolver maps a privilege assignment to a C1 resource id. The id is used when present; the name is only the fallback. diff --git a/pkg/connector/metastore_test.go b/pkg/connector/metastore_test.go index 7eba05e0..532b7558 100644 --- a/pkg/connector/metastore_test.go +++ b/pkg/connector/metastore_test.go @@ -182,11 +182,27 @@ func (f *fakeAccount) scim(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"totalResults": len(found), "Resources": found}) } +// principalID is the SCIM id the account holds for a principal name. +func (f *fakeAccount) principalID(name string) int { + for _, records := range []map[string]string{f.users, f.groups, f.sps} { + for id, recorded := range records { + if recorded == name { + numeric, _ := strconv.Atoi(id) + return numeric + } + } + } + + return 0 +} + func (f *fakeAccount) apply(change map[string]any) { added, _ := change["add"].([]any) for _, privilege := range added { f.permissions = append(f.permissions, map[string]any{ - "principal": change["principal"], "principal_id": 1, "privileges": []string{privilege.(string)}, + "principal": change["principal"], + "principal_id": f.principalID(fmt.Sprint(change["principal"])), + "privileges": []string{privilege.(string)}, }) } if remove, ok := change["remove"].([]any); ok { @@ -218,7 +234,8 @@ func newTestMetastoreBuilderAs(t *testing.T, f *fakeAccount, allowlist []string, var testAccountResourceID = &v2.ResourceId{ResourceType: "account", Resource: testAccountId} -func testMetastore(t *testing.T, workspace string) *v2.Resource { +// testSyncedMetastore is the resource List emits, route included. +func testSyncedMetastore(t *testing.T, workspace string) *v2.Resource { t.Helper() r, err := metastoreResource(context.Background(), &databricks.Metastore{MetastoreID: "ms-a", Name: "main", Owner: "eng"}, testAccountResourceID, workspace, "1", "prod") @@ -229,6 +246,17 @@ func testMetastore(t *testing.T, workspace string) *v2.Resource { return r } +// testMetastore is the resource a provisioning call carries: an id, no profile. +func testMetastore(t *testing.T) *v2.Resource { + t.Helper() + r, err := rs.NewAppResource("main", metastoreResourceType, "ms-a", nil, rs.WithParentResourceID(testAccountResourceID)) + if err != nil { + t.Fatalf("NewAppResource: %v", err) + } + + return r +} + // Which workspaces serve a metastore decides whether it syncs at all, so an // assignment list the connector cannot read is unknown, not absent. Skipping it // would drop the metastore and make ConductorOne remove its grants. @@ -286,7 +314,8 @@ func TestMetastoreListSeparatesTerminalFromPendingWorkspaces(t *testing.T) { } } -func TestMetastoreListPicksTheFirstUsableWorkspace(t *testing.T) { +// An owner sees every privilege, so the route is the first running workspace in scope. +func TestMetastoreListRoutesTheOwnerThroughTheFirstScopedWorkspace(t *testing.T) { tests := []struct { name string allowlist []string @@ -341,27 +370,27 @@ func grantKeys(grants []*v2.Grant) []string { return keys } -// Databricks answers an under-permissioned read of the permissions endpoint -// with 200 and only the privileges the caller may see, which is indistinguishable -// from a metastore with no grants. The sync therefore reports grants only when -// it can prove it reads all of them. -func TestMetastoreGrantsRequireFullVisibility(t *testing.T) { - adminByAppID := []map[string]any{ +var ( + adminByAppID = []map[string]any{ {"principal": map[string]any{"service_principal_name": "app-9"}, "permissions": []string{"ADMIN"}}, } - adminByScimID := []map[string]any{ + adminByScimID = []map[string]any{ {"principal": map[string]any{"principal_id": 3}, "permissions": []string{"ADMIN"}}, } - notAdmin := []map[string]any{ + notAdmin = []map[string]any{ {"principal": map[string]any{"service_principal_name": "app-9"}, "permissions": []string{"USER"}}, {"principal": map[string]any{"group_name": "admins"}, "permissions": []string{"ADMIN"}}, } +) +// A read without standing returns 200 with a partial list, so List routes only +// through a workspace it can fully read. +func TestMetastoreListRoutesOnlyThroughAWorkspaceItCanFullyRead(t *testing.T) { tests := []struct { name string clientID string members any - wantGrants bool + wantRouted bool wantProbes int }{ {"owner of the metastore", testOwnPrincipal, nil, true, 0}, @@ -375,23 +404,20 @@ func TestMetastoreGrantsRequireFullVisibility(t *testing.T) { t.Run(test.name, func(t *testing.T) { f := defaultFakeAccount() f.workspaceMembers = map[string]any{"1": test.members} - f.permissions = []map[string]any{ - {"principal": "alice@example.com", "principal_id": 1, "privileges": []string{"CREATE_CATALOG"}}, - } m := newTestMetastoreBuilderAs(t, f, nil, syncPrincipals{}, test.clientID) - grants, _, err := m.Grants(context.Background(), testMetastore(t, "wsa"), rs.SyncOpAttrs{}) + resources, _, err := m.List(context.Background(), testAccountResourceID, rs.SyncOpAttrs{}) switch { - case test.wantGrants && err != nil: - t.Fatalf("Grants: %v", err) - case test.wantGrants && len(grants) == 0: - t.Fatal("Grants returned nothing, want the privileges it can read") - case !test.wantGrants: + case test.wantRouted && err != nil: + t.Fatalf("List: %v", err) + case test.wantRouted && len(resources) != 1: + t.Fatalf("List returned %d resources, want the metastore", len(resources)) + case !test.wantRouted: if status.Code(err) != codes.PermissionDenied { - t.Fatalf("Grants = %v, want PermissionDenied", err) + t.Fatalf("List = %v, want PermissionDenied", err) } - if len(grants) != 0 { - t.Errorf("grants = %v, want none", grantKeys(grants)) + if len(resources) != 0 { + t.Errorf("resources = %v, want none", resources) } if !strings.Contains(err.Error(), "ms-a") { t.Errorf("error %q does not name the metastore", err) @@ -404,27 +430,90 @@ func TestMetastoreGrantsRequireFullVisibility(t *testing.T) { } } +// The route is where standing holds, not the first workspace the assignment list names. +func TestMetastoreListRoutesThroughTheWorkspaceWhereStandingHolds(t *testing.T) { + f := defaultFakeAccount() + f.metastores = []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "someone-else"}} + f.assignments = map[string]any{"ms-a": []int{1, 2}} + f.workspaces = []map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, + } + f.workspaceMembers = map[string]any{"1": nil, "2": adminByAppID} + m := newTestMetastoreBuilderAs(t, f, nil, syncPrincipals{}, "app-9") + + resources, _, err := m.List(context.Background(), testAccountResourceID, rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(resources) != 1 { + t.Fatalf("List returned %d resources, want 1", len(resources)) + } + workspace, _ := rs.GetProfileStringValue(rs.GetProfile(resources[0]), profileKeyWorkspace) + if workspace != "wsb" { + t.Errorf("profile workspace = %q, want wsb, the workspace where the connector is Admin", workspace) + } + if !slices.Equal(f.memberLists, []string{"1", "2"}) { + t.Errorf("probed %v, want both workspaces in order until one holds Admin", f.memberLists) + } +} + +// Grants uses the route List already chose and does not probe again. +func TestMetastoreGrantsReadThroughTheRoutedWorkspace(t *testing.T) { + f := defaultFakeAccount() + f.permissions = []map[string]any{ + {"principal": "alice@example.com", "principal_id": 1, "privileges": []string{"CREATE_CATALOG"}}, + } + m := newTestMetastoreBuilderAs(t, f, nil, syncPrincipals{}, "app-9") + + grants, _, err := m.Grants(context.Background(), testSyncedMetastore(t, "wsb"), rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("Grants: %v", err) + } + if want := []string{"1 metastore:ms-a:CREATE_CATALOG"}; !slices.Equal(grantKeys(grants), want) { + t.Errorf("grants = %v, want %v", grantKeys(grants), want) + } + if !slices.Equal(f.permHosts, []string{"wsb"}) { + t.Errorf("permissions read through %v, want the routed workspace wsb", f.permHosts) + } + if len(f.memberLists) != 0 { + t.Errorf("workspace assignments listed %d times, want none: List already settled the route", len(f.memberLists)) + } +} + +// No grants and no error would revoke every privilege, so an unrouted resource fails. +func TestMetastoreGrantsFailWithoutARouteOnTheProfile(t *testing.T) { + f := defaultFakeAccount() + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) + + grants, _, err := m.Grants(context.Background(), testMetastore(t), rs.SyncOpAttrs{}) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("Grants = %v, want FailedPrecondition", err) + } + if len(grants) != 0 { + t.Errorf("grants = %v, want none", grantKeys(grants)) + } +} + var alice = &v2.Resource{Id: &v2.ResourceId{ResourceType: "user", Resource: "1"}} func TestMetastoreGrantRejections(t *testing.T) { tests := []struct { name string - workspace string privilege string patchStatus int code codes.Code }{ - {"owner", "wsa", ownerEntitlement, 0, codes.InvalidArgument}, - {"legacy alias", "wsa", "USAGE", 0, codes.InvalidArgument}, - {"not offered", "wsa", "MANAGE", 0, codes.InvalidArgument}, - {"no workspace on the profile", "", "CREATE_CATALOG", 0, codes.FailedPrecondition}, - {"forbidden", "wsa", "CREATE_CATALOG", http.StatusForbidden, codes.PermissionDenied}, + {"owner", ownerEntitlement, 0, codes.InvalidArgument}, + {"legacy alias", "USAGE", 0, codes.InvalidArgument}, + {"not offered", "MANAGE", 0, codes.InvalidArgument}, + {"forbidden", "CREATE_CATALOG", http.StatusForbidden, codes.PermissionDenied}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { f := defaultFakeAccount() m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) - g := grant.NewGrant(testMetastore(t, test.workspace), test.privilege, alice.GetId()) + g := grant.NewGrant(testMetastore(t), test.privilege, alice.GetId()) f.patchStatus = test.patchStatus _, err := m.Grant(context.Background(), alice, g.GetEntitlement()) @@ -438,11 +527,73 @@ func TestMetastoreGrantRejections(t *testing.T) { } } +// Grant and Revoke resolve the route from the metastore id. +func TestMetastoreProvisioningRoutesFromTheMetastoreID(t *testing.T) { + f := defaultFakeAccount() + f.metastores = []map[string]any{{"metastore_id": "ms-a", "name": "main", "owner": "someone-else"}} + f.assignments = map[string]any{"ms-a": []int{1, 2}} + f.workspaces = []map[string]any{ + {"workspace_id": 1, "workspace_name": "prod", "deployment_name": "wsa", "workspace_status": "RUNNING"}, + {"workspace_id": 2, "workspace_name": "dev", "deployment_name": "wsb", "workspace_status": "RUNNING"}, + } + f.workspaceMembers = map[string]any{"1": nil, "2": adminByAppID} + m := newTestMetastoreBuilderAs(t, f, nil, syncPrincipals{}, "app-9") + g := grant.NewGrant(testMetastore(t), "CREATE_CATALOG", alice.GetId()) + + if _, err := m.Grant(context.Background(), alice, g.GetEntitlement()); err != nil { + t.Fatalf("Grant: %v", err) + } + if len(f.patches) != 1 { + t.Fatalf("patches = %v, want one", f.patches) + } + for _, host := range f.permHosts { + if host != "wsb" { + t.Fatalf("permissions addressed %v, want only wsb, the workspace where the connector is Admin", f.permHosts) + } + } +} + +// A deleted metastore has nothing to grant and nothing left to revoke. +func TestMetastoreProvisioningOnADeletedMetastore(t *testing.T) { + newBuilder := func(t *testing.T) (*metastoreBuilder, *fakeAccount) { + f := defaultFakeAccount() + f.metastores = []map[string]any{{"metastore_id": "ms-other", "name": "other", "owner": "eng"}} + + return newTestMetastoreBuilder(t, f, nil, syncPrincipals{}), f + } + + t.Run("grant", func(t *testing.T) { + m, f := newBuilder(t) + g := grant.NewGrant(testMetastore(t), "CREATE_CATALOG", alice.GetId()) + + _, err := m.Grant(context.Background(), alice, g.GetEntitlement()) + if status.Code(err) != codes.NotFound { + t.Fatalf("Grant = %v, want NotFound", err) + } + if len(f.patches) != 0 { + t.Errorf("patches = %v, want none", f.patches) + } + }) + + t.Run("revoke", func(t *testing.T) { + m, f := newBuilder(t) + g := grant.NewGrant(testMetastore(t), "CREATE_CATALOG", alice.GetId()) + + annos, err := m.Revoke(context.Background(), g) + if err != nil || !annos.Contains(&v2.GrantAlreadyRevoked{}) { + t.Fatalf("Revoke = %v, %v; want GrantAlreadyRevoked", annos, err) + } + if len(f.patches) != 0 { + t.Errorf("patches = %v, want none", f.patches) + } + }) +} + func TestMetastoreRevoke(t *testing.T) { f := defaultFakeAccount() f.permissions = []map[string]any{{"principal": "alice@example.com", "principal_id": 1, "privileges": []string{"CREATE_CATALOG"}}} m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) - g := grant.NewGrant(testMetastore(t, "wsa"), "CREATE_CATALOG", alice.GetId()) + g := grant.NewGrant(testMetastore(t), "CREATE_CATALOG", alice.GetId()) if _, err := m.Revoke(context.Background(), g); err != nil { t.Fatalf("Revoke: %v", err) @@ -457,6 +608,74 @@ func TestMetastoreRevoke(t *testing.T) { } } +// A row the pre-read missed may be a partial read, so the removal still goes out. +func TestMetastoreRevokeWritesWhenThePreReadMissesTheRow(t *testing.T) { + f := defaultFakeAccount() + f.permissions = nil + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) + g := grant.NewGrant(testMetastore(t), "CREATE_CATALOG", alice.GetId()) + + annos, err := m.Revoke(context.Background(), g) + if err != nil { + t.Fatalf("Revoke: %v", err) + } + if len(f.patches) != 1 { + t.Fatalf("patches = %v, want the removal to go out anyway", f.patches) + } + if got := f.patches[0]["remove"]; fmt.Sprint(got) != "[CREATE_CATALOG]" { + t.Errorf("removal = %v, want CREATE_CATALOG", got) + } + if got := f.patches[0]["principal"]; fmt.Sprint(got) != "alice@example.com" { + t.Errorf("removal addressed %v, want the principal name alice@example.com", got) + } + if _, byID := f.patches[0]["principal_id"]; byID { + t.Errorf("removal = %v, want no principal_id: the pre-read saw no row to take one from", f.patches[0]) + } + if !annos.Contains(&v2.GrantAlreadyRevoked{}) { + t.Error("want GrantAlreadyRevoked: the echo proves the privilege is not held") + } +} + +// A deleted principal's rows are only addressable by id. +func TestMetastoreRevokeRemovesADeletedPrincipalByID(t *testing.T) { + f := defaultFakeAccount() + f.users = map[string]string{} + f.permissions = []map[string]any{ + {"principal": "alice@example.com", "principal_id": 1, "privileges": []string{"CREATE_CATALOG"}}, + } + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) + g := grant.NewGrant(testMetastore(t), "CREATE_CATALOG", alice.GetId()) + + if _, err := m.Revoke(context.Background(), g); err != nil { + t.Fatalf("Revoke: %v", err) + } + if len(f.patches) != 1 { + t.Fatalf("patches = %v, want one removal", f.patches) + } + if got := f.patches[0]["principal_id"]; fmt.Sprint(got) != "1" { + t.Errorf("removal addressed %v, want principal_id 1", got) + } + if _, byName := f.patches[0]["principal"]; byName { + t.Errorf("removal = %v, want no principal name: Databricks no longer has one", f.patches[0]) + } +} + +// A grant to a deleted principal would write an empty name. +func TestMetastoreGrantToADeletedPrincipalFails(t *testing.T) { + f := defaultFakeAccount() + f.users = map[string]string{} + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) + g := grant.NewGrant(testMetastore(t), "CREATE_CATALOG", alice.GetId()) + + _, err := m.Grant(context.Background(), alice, g.GetEntitlement()) + if status.Code(err) != codes.NotFound { + t.Fatalf("Grant = %v, want NotFound", err) + } + if len(f.patches) != 0 { + t.Errorf("patches = %v, want none", f.patches) + } +} + // A privilege list the connector cannot fully read cannot be written to either: // the change would be decided from the subset Databricks chose to show. func TestMetastoreProvisioningRefusesAPartialPrivilegeList(t *testing.T) { @@ -467,7 +686,7 @@ func TestMetastoreProvisioningRefusesAPartialPrivilegeList(t *testing.T) { {"principal": "alice@example.com", "principal_id": 1, "privileges": []string{"CREATE_CATALOG"}}, } m := newTestMetastoreBuilderAs(t, f, nil, syncPrincipals{}, "app-9") - g := grant.NewGrant(testMetastore(t, "wsa"), "CREATE_CATALOG", alice.GetId()) + g := grant.NewGrant(testMetastore(t), "CREATE_CATALOG", alice.GetId()) var err error if verb == "grant" { From 6384cfa15b868e88d4d3f57395ca1185e3da92ed Mon Sep 17 00:00:00 2001 From: agustin-conductor Date: Tue, 27 Oct 2026 10:24:18 -0300 Subject: [PATCH 32/36] [CXP-1251] - Paginate group member grants to stay under the Lambda 6MB response limit (#81) * Paginate group and workspace member grants to stay under Lambda payload limit Databricks returns every group member in a single Get group response and has no member pagination. Grants() emitted one grant per member in one response, so a group with ~11k+ members (e.g. an all-users account group) exceeded the 6MB Lambda response limit and failed with HTTP 413 on every retry. Page members locally (GrantsPageSize = 2000, ~1.1MB per page) using the page token as a member offset, and emit role rule-set grants on the first page only. Databricks returns members in a different order on every call, so they are sorted before slicing so pages line up across re-fetches in separate Lambda invocations. Apply the same paging to workspace assignment grants. Co-Authored-By: Claude Opus 5.5 * Page member grants by last emitted key instead of offset An offset page token into a list that is re-fetched for every page drifts when members are added or removed between page invocations: a removal skips the first member of the next page (C1 reads that grant as revoked) and an addition grants one member twice. Store the key of the last emitted item (member Ref for groups, principal ID for workspaces) and start the next page after it, so membership churn no longer shifts page boundaries. Add churn tests for both paths, a workspace multi-page test, and mark the test-only shuffle as not security sensitive for gosec. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- pkg/connector/groups.go | 35 +++++- pkg/connector/groups_test.go | 204 +++++++++++++++++++++++++++++++ pkg/connector/pagination.go | 50 ++++++++ pkg/connector/workspaces.go | 36 +++++- pkg/connector/workspaces_test.go | 90 ++++++++++++++ 5 files changed, 406 insertions(+), 9 deletions(-) create mode 100644 pkg/connector/workspaces_test.go diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go index a394e5a6..b5979b73 100644 --- a/pkg/connector/groups.go +++ b/pkg/connector/groups.go @@ -170,7 +170,7 @@ func (g *groupBuilder) Entitlements(ctx context.Context, resource *v2.Resource, // Grants return all grants relevant to the group. // Databricks Groups have membership and role permissions grants (granting identity resource some permission to this specific group, e.g. group manager). -func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { +func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, attr rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { l := ctxzap.Extract(ctx) var rv []*v2.Grant @@ -186,6 +186,11 @@ func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs.S workspaceId = parentId.Resource } + bag, cursor, err := parseCursorToken(attr.PageToken.Token, &v2.ResourceId{ResourceType: groupResourceType.Id}) + if err != nil { + return nil, nil, fmt.Errorf("databricks-connector: failed to parse page token: %w", err) + } + // membership grants // Always fetch the group with members attribute to ensure we get the members // regardless of authentication type (OAuth vs personal access token) @@ -199,8 +204,19 @@ func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs.S annos.WithRateLimiting(rateLimitData) } - l.Debug("grants: group resource", zap.String("group_id", groupId.Resource), zap.Int("member_count", len(group.Members))) - for _, member := range group.Members { + // The API returns every member at once and has no member pagination, so members are paged + // locally to keep each response under the Lambda payload limit. The API returns members in a + // different order on every call, so pageAfter sorts them for pages to line up across re-fetches. + members, nextCursor := pageAfter(group.Members, func(m databricks.Member) string { return m.Ref }, cursor) + + l.Debug("grants: group resource", + zap.String("group_id", groupId.Resource), + zap.Int("member_count", len(group.Members)), + zap.Int("page_member_count", len(members)), + zap.String("cursor", cursor), + ) + + for _, member := range members { // member.Ref contains the type and ID separated by "/", e.g., "Users/123" or "Groups/456" pp := strings.Split(member.Ref, "/") if len(pp) != 2 { @@ -230,7 +246,16 @@ func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs.S rv = append(rv, grant.NewGrant(resource, groupMemberEntitlement, resourceId, grant.WithAnnotation(anns...))) } - // role permissions grants + nextPage, err := bag.NextToken(nextCursor) + if err != nil { + return nil, nil, fmt.Errorf("databricks-connector: failed to create next page token: %w", err) + } + + // role permissions grants are only emitted with the first page of members + if cursor != "" { + return rv, &rs.SyncOpResults{NextPageToken: nextPage, Annotations: annos}, nil + } + ruleSets, rateLimitDataRuleSets, err := g.client.ListRuleSets(ctx, workspaceId, GroupsType, groupId.Resource) if err != nil { return nil, nil, fmt.Errorf("databricks-connector: failed to list role rule sets for group %s: %w", resource.Id.Resource, err) @@ -260,7 +285,7 @@ func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs.S } } - return rv, &rs.SyncOpResults{Annotations: annos}, nil + return rv, &rs.SyncOpResults{NextPageToken: nextPage, Annotations: annos}, nil } func (g *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { diff --git a/pkg/connector/groups_test.go b/pkg/connector/groups_test.go index b9478870..9882f7d6 100644 --- a/pkg/connector/groups_test.go +++ b/pkg/connector/groups_test.go @@ -3,15 +3,19 @@ package connector import ( "context" "encoding/json" + "fmt" "io" + "math/rand/v2" "net/http" "net/http/httptest" "net/url" "slices" + "strings" "testing" "github.com/conductorone/baton-databricks/pkg/databricks" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/pagination" ent "github.com/conductorone/baton-sdk/pkg/types/entitlement" rs "github.com/conductorone/baton-sdk/pkg/types/resource" "google.golang.org/grpc/codes" @@ -373,3 +377,203 @@ func TestGroupRuleSetPrincipalLookup(t *testing.T) { } } } + +// shuffled returns a shuffled copy of items, mimicking Databricks returning members in a different +// order on every call. +func shuffled[T any](items []T) []T { + out := slices.Clone(items) + rand.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] }) // #nosec G404 -- test data order, not security sensitive + return out +} + +func testGroupMembers(count int) []databricks.Member { + members := make([]databricks.Member, 0, count) + for i := range count { + id := fmt.Sprintf("%05d", i) + members = append(members, databricks.Member{ID: id, Ref: "Users/" + id}) + } + return members +} + +// newTestGroupBuilder backs a group builder with a test server serving an account group with +// *members as its members and one rule set granting a service principal the manager role. +// Tests may change *members between pages. It counts the rule set requests so tests can assert +// they are only made once. +func newTestGroupBuilder(t *testing.T, members *[]databricks.Member, ruleSetCalls *int) *groupBuilder { + t.Helper() + // Every page re-fetches the group. Disable the HTTP cache so each page sees a fresh, reshuffled + // response, as it does when pages run in separate Lambda invocations. + t.Setenv("BATON_DISABLE_HTTP_CACHE", "true") + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/scim/v2/Groups/g1"): + _ = json.NewEncoder(w).Encode(databricks.Group{ID: "g1", DisplayName: "all users", Members: shuffled(*members)}) + case strings.HasSuffix(r.URL.Path, "/access-control/rule-sets"): + *ruleSetCalls++ + _, _ = w.Write([]byte(`{"grant_rules":[{"role":"roles/group.manager","principals":["servicePrincipals/app-1"]}],"etag":"e1"}`)) + case strings.HasSuffix(r.URL.Path, "/scim/v2/ServicePrincipals"): + _, _ = w.Write([]byte(`{"Resources":[{"id":"sp-1","applicationId":"app-1"}],"totalResults":1}`)) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + + target, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parse server url: %v", err) + } + + httpClient := &http.Client{Transport: &hostRewriter{target: target}} + c, err := databricks.NewClient(context.Background(), httpClient, testHostname, testAccountHost, testAccountId, "", &databricks.NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + return newGroupBuilder(c) +} + +func testAccountGroup(t *testing.T) *v2.Resource { + t.Helper() + parent := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId} + g, err := groupResource(context.Background(), &databricks.Group{ID: "g1", DisplayName: "all users"}, parent) + if err != nil { + t.Fatalf("groupResource: %v", err) + } + return g +} + +// groupGrantPages pages through Grants for group until the page token is empty, calling +// afterPage after each page. It returns how many times each member was granted, the manager +// grant count and the page count, and checks no page exceeds GrantsPageSize member grants. +func groupGrantPages(t *testing.T, g *groupBuilder, group *v2.Resource, afterPage func(page int)) (map[string]int, int, int) { + t.Helper() + seen := map[string]int{} + managerGrants := 0 + pages := 0 + token := "" + for { + grants, results, err := g.Grants(context.Background(), group, rs.SyncOpAttrs{PageToken: pagination.Token{Token: token}}) + if err != nil { + t.Fatalf("Grants page %d: %v", pages, err) + } + pages++ + + memberGrants := 0 + for _, gr := range grants { + switch { + case strings.HasSuffix(gr.Entitlement.Id, ":"+groupMemberEntitlement): + memberGrants++ + seen[gr.Principal.Id.Resource]++ + case strings.HasSuffix(gr.Entitlement.Id, ":"+groupManagerEntitlement): + managerGrants++ + } + } + if memberGrants > int(GrantsPageSize) { + t.Errorf("page %d has %d member grants, want at most %d", pages, memberGrants, GrantsPageSize) + } + + token = results.NextPageToken + if token == "" { + return seen, managerGrants, pages + } + if pages > 100 { + t.Fatal("pagination did not terminate") + } + if afterPage != nil { + afterPage(pages) + } + } +} + +// A group's members come back in one API response, so Grants must split them across pages: +// a single page for a group with thousands of members exceeds the 6MB Lambda response limit. +func TestGroupGrantsPagesMembers(t *testing.T) { + memberCount := 2*int(GrantsPageSize) + 500 + members := testGroupMembers(memberCount) + ruleSetCalls := 0 + g := newTestGroupBuilder(t, &members, &ruleSetCalls) + + seen, managerGrants, pages := groupGrantPages(t, g, testAccountGroup(t), nil) + + if pages != 3 { + t.Errorf("got %d pages, want 3", pages) + } + if len(seen) != memberCount { + t.Errorf("got %d distinct member grants, want %d", len(seen), memberCount) + } + for id, n := range seen { + if n != 1 { + t.Errorf("member %s granted %d times, want 1", id, n) + } + } + if managerGrants != 1 || ruleSetCalls != 1 { + t.Errorf("got %d manager grants from %d rule set calls, want 1 from 1", managerGrants, ruleSetCalls) + } +} + +// Members removed or added between page fetches must not shift the next page's start, or a +// member near the boundary is skipped (C1 reads its grant as revoked) or granted twice. +func TestGroupGrantsMembershipChurnBetweenPages(t *testing.T) { + memberCount := 2*int(GrantsPageSize) + 500 + tests := []struct { + name string + change func([]databricks.Member) []databricks.Member + }{ + { + // With an offset cursor, every later member moves up one slot and the first member + // of page 2 is never granted. + name: "member already emitted is removed", + change: func(m []databricks.Member) []databricks.Member { return slices.Clone(m[1:]) }, + }, + { + // With an offset cursor, every later member moves down one slot and the last member + // of page 1 is granted again on page 2. + name: "member sorting before the cursor is added", + change: func(m []databricks.Member) []databricks.Member { + return append(slices.Clone(m), databricks.Member{ID: "00000a", Ref: "Users/00000a"}) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + members := testGroupMembers(memberCount) + ruleSetCalls := 0 + g := newTestGroupBuilder(t, &members, &ruleSetCalls) + + seen, _, _ := groupGrantPages(t, g, testAccountGroup(t), func(page int) { + if page == 1 { + members = tt.change(members) + } + }) + + // Every original member is granted exactly once; the removed one on page 1, before + // its removal. A member added before the cursor is picked up by the next sync. + for _, m := range testGroupMembers(memberCount) { + if seen[m.ID] != 1 { + t.Errorf("member %s granted %d times, want 1", m.ID, seen[m.ID]) + } + } + }) + } +} + +func TestGroupGrantsSmallGroupSinglePage(t *testing.T) { + members := testGroupMembers(3) + ruleSetCalls := 0 + g := newTestGroupBuilder(t, &members, &ruleSetCalls) + + grants, results, err := g.Grants(context.Background(), testAccountGroup(t), rs.SyncOpAttrs{}) + if err != nil { + t.Fatalf("Grants: %v", err) + } + if results.NextPageToken != "" { + t.Errorf("got next page token %q, want none", results.NextPageToken) + } + if len(grants) != 4 { + t.Errorf("got %d grants, want 3 members + 1 manager", len(grants)) + } +} diff --git a/pkg/connector/pagination.go b/pkg/connector/pagination.go index ca0c111d..f9157b61 100644 --- a/pkg/connector/pagination.go +++ b/pkg/connector/pagination.go @@ -2,7 +2,9 @@ package connector import ( "fmt" + "slices" "strconv" + "strings" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/pagination" @@ -10,6 +12,10 @@ import ( const ResourcesPageSize uint = 50 +// GrantsPageSize caps grants built from a single unpaginated API response (e.g. group members). +// Each grant serializes to ~550 bytes, so a page is ~1.1MB, well under the 6MB Lambda response limit. +const GrantsPageSize uint = 2000 + func parsePageToken(i string, resourceID *v2.ResourceId) (*pagination.Bag, uint, error) { b := &pagination.Bag{} err := b.Unmarshal(i) @@ -58,3 +64,47 @@ func prepareNextToken(page uint, pageTotal int, total uint) string { return token } + +// parseCursorToken returns the pagination bag and the cursor stored in its current page token, +// which is empty on the first page. +func parseCursorToken(i string, resourceID *v2.ResourceId) (*pagination.Bag, string, error) { + b := &pagination.Bag{} + err := b.Unmarshal(i) + if err != nil { + return nil, "", err + } + + if b.Current() == nil { + b.Push(pagination.PageState{ + ResourceTypeID: resourceID.ResourceType, + ResourceID: resourceID.Resource, + }) + } + + return b, b.PageToken(), nil +} + +// pageAfter sorts items by key and returns up to GrantsPageSize of them that sort after cursor, +// plus the cursor for the next page (empty when this is the last page). Keys must be unique. +// Paging from the last emitted key rather than an offset keeps page boundaries stable when items +// are added or removed between page fetches. +func pageAfter[T any](items []T, key func(T) string, cursor string) ([]T, string) { + slices.SortFunc(items, func(a, b T) int { return strings.Compare(key(a), key(b)) }) + + start := 0 + if cursor != "" { + var found bool + start, found = slices.BinarySearchFunc(items, cursor, func(item T, c string) int { return strings.Compare(key(item), c) }) + if found { + start++ + } + } + + end := min(start+int(GrantsPageSize), len(items)) + var next string + if end < len(items) { + next = key(items[end-1]) + } + + return items[start:end], next +} diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index 49ec7cea..20990ca6 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -23,6 +23,13 @@ import ( const workspaceMemberEntitlement = "member" +func principalID(a databricks.WorkspaceAssignment) int { + if a.Principal == nil { + return 0 + } + return a.Principal.ID +} + type workspaceBuilder struct { client *databricks.Client resourceType *v2.ResourceType @@ -166,7 +173,7 @@ func (w *workspaceBuilder) Entitlements(_ context.Context, resource *v2.Resource // Grants returns slice of grants representing workspace members. // To get workspace members, we can only use the account API. -func (w *workspaceBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { +func (w *workspaceBuilder) Grants(ctx context.Context, resource *v2.Resource, attr rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) { l := ctxzap.Extract(ctx) if !w.client.IsAccountAPIAvailable() { @@ -180,6 +187,11 @@ func (w *workspaceBuilder) Grants(ctx context.Context, resource *v2.Resource, _ return nil, nil, fmt.Errorf("databricks-connector: failed to get workspace ID") } + bag, cursor, err := parseCursorToken(attr.PageToken.Token, &v2.ResourceId{ResourceType: workspaceResourceType.Id}) + if err != nil { + return nil, nil, fmt.Errorf("databricks-connector: failed to parse page token: %w", err) + } + workspace := strconv.Itoa(int(workspaceId)) assignments, rateLimitDesc, err := w.client.ListWorkspaceMembers(ctx, workspace) annos := annotations.Annotations{} @@ -212,9 +224,20 @@ func (w *workspaceBuilder) Grants(ctx context.Context, resource *v2.Resource, _ return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf("databricks-connector: failed to list workspace members: %w", err) } + // The API returns every assignment at once, so they are paged locally to keep each + // response under the Lambda payload limit. + assignmentsPage, nextCursor := pageAfter(assignments, func(a databricks.WorkspaceAssignment) string { + return strconv.Itoa(principalID(a)) + }, cursor) + var rv []*v2.Grant - l.Debug("grants: workspace resource", zap.String("workspace_id", workspace), zap.Int("assignments_count", len(assignments))) - for _, assignment := range assignments { + l.Debug("grants: workspace resource", + zap.String("workspace_id", workspace), + zap.Int("assignments_count", len(assignments)), + zap.Int("page_assignments_count", len(assignmentsPage)), + zap.String("cursor", cursor), + ) + for _, assignment := range assignmentsPage { resourceType, err := prepareResourceType(assignment.Principal) if err != nil { return nil, nil, fmt.Errorf("databricks-connector: failed to prepare resource type: %w", err) @@ -238,7 +261,12 @@ func (w *workspaceBuilder) Grants(ctx context.Context, resource *v2.Resource, _ rv = append(rv, grant.NewGrant(resource, workspaceMemberEntitlement, resourceID, grant.WithAnnotation(annotations...))) } - return rv, nil, nil + nextPage, err := bag.NextToken(nextCursor) + if err != nil { + return nil, nil, fmt.Errorf("databricks-connector: failed to create next page token: %w", err) + } + + return rv, &rs.SyncOpResults{NextPageToken: nextPage, Annotations: annos}, nil } // workspacePrincipalID returns the numeric principal ID the permission assignments API expects. diff --git a/pkg/connector/workspaces_test.go b/pkg/connector/workspaces_test.go new file mode 100644 index 00000000..3d3bc54c --- /dev/null +++ b/pkg/connector/workspaces_test.go @@ -0,0 +1,90 @@ +package connector + +import ( + "context" + "slices" + "strconv" + "testing" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/pagination" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" +) + +func testUserAssignments(count int) []map[string]any { + assignments := make([]map[string]any, 0, count) + for i := range count { + assignments = append(assignments, map[string]any{ + "principal": map[string]any{"principal_id": 1000 + i, "user_name": "user" + strconv.Itoa(i) + "@example.com"}, + "permissions": []string{"USER"}, + }) + } + return assignments +} + +func testWorkspace(t *testing.T) *v2.Resource { + t.Helper() + r, err := workspaceResource(context.Background(), + &databricks.Workspace{ID: testProvisioningWorkspaceID, Name: "ws", DeploymentName: "ws-deploy"}, + &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: testAccountId}, + ) + if err != nil { + t.Fatalf("workspaceResource: %v", err) + } + return r +} + +// Workspace assignments come back in one API response, so Grants must split them across pages +// without skipping or repeating a principal when assignments change between page fetches. +func TestWorkspaceGrantsPagesAssignments(t *testing.T) { + // Every page re-fetches the assignments; disable the HTTP cache so each page sees the change. + t.Setenv("BATON_DISABLE_HTTP_CACHE", "true") + + count := 2*int(GrantsPageSize) + 500 + m := &workspaceAssignmentsServer{assignments: shuffled(testUserAssignments(count))} + w := newWorkspaceBuilderWithMock(t, m) + workspace := testWorkspace(t) + + seen := map[string]int{} + pages := 0 + token := "" + for { + grants, results, err := w.Grants(context.Background(), workspace, rs.SyncOpAttrs{PageToken: pagination.Token{Token: token}}) + if err != nil { + t.Fatalf("Grants page %d: %v", pages, err) + } + pages++ + if len(grants) > int(GrantsPageSize) { + t.Errorf("page %d has %d grants, want at most %d", pages, len(grants), GrantsPageSize) + } + for _, gr := range grants { + seen[gr.Principal.Id.Resource]++ + } + + token = results.NextPageToken + if token == "" { + break + } + if pages > 100 { + t.Fatal("pagination did not terminate") + } + if pages == 1 { + // Remove the first assignment, already emitted on page 1, and reshuffle. + m.mu.Lock() + all := testUserAssignments(count) + m.assignments = shuffled(slices.Clone(all[1:])) + m.mu.Unlock() + } + } + + if pages != 3 { + t.Errorf("got %d pages, want 3", pages) + } + for i := range count { + id := strconv.Itoa(1000 + i) + if seen[id] != 1 { + t.Errorf("principal %s granted %d times, want 1", id, seen[id]) + } + } +} From e0e33202046993199b41ae9076f8a552741b9b44 Mon Sep 17 00:00:00 2001 From: Javier David Carnelli Date: Tue, 27 Oct 2026 12:36:54 -0300 Subject: [PATCH 33/36] CXP-897 Incremental sync support (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: incremental sync v1 - scoped implementation * feat: extend incremental sync to role/permission-assignment changes Add audit log action mappings for account-admin, workspace-access, and SQL-access role changes, plus a coarser fallback for the cluster-create and instance-pool-create entitlements. mapAuditRowToResource now returns multiple affected resources per audit row so a single action can refresh both a principal and the role(s) it holds. Co-Authored-By: Claude Sonnet 5 * fix: add event_id tiebreaker to audit log pagination ORDER BY event_time ASC alone gives no deterministic ordering among rows sharing an event_time, so paging via a >= start_time filter plus a remembered ID set can stall forever if a single event_time has >= auditLogPageLimit rows. Order by (event_time, event_id) and page with a composite (event_time, event_id) > predicate instead, so the cursor always advances regardless of how many rows share a timestamp. * fix: bound sql statement polling and cancel on give-up pollStatement could block for as long as the caller's context allowed if a warehouse got stuck PENDING/RUNNING (cold start, queued, quota), hanging Validate() indefinitely when incremental sync is enabled. Cap polling at statementPollMaxWait and cancel the statement via DELETE when giving up so it stops occupying the warehouse. * fix: nil-safe parent access in user/service-principal/role Get The SDK's GetResource passes request.GetParentResourceId() straight through to Get, which is nil whenever C1 has no parent recorded for the resource (e.g. workspace-token auth, where userResource deliberately omits WithParentResourceID). That nil parent was then dereferenced directly in userResource, servicePrincipalResource, and roleResource, panicking on resync after a RESOURCE_CHANGE event. Use the nil-safe GetResourceType()/GetResource() getters instead, matching the pattern groups.go already used. * fix: mirror token-auth branch in workspaceBuilder.Get Get always called GetWorkspace, which hits the Account API via ListWorkspaces. The Account API is unreachable under workspace-token auth, so any targeted sync of a workspace (advertised via CAPABILITY_TARGETED_SYNC) always failed in that mode, even though List already builds a minimalWorkspaceResource from the configured workspace list to avoid the same call. Get now mirrors that branch. * fix: select audit event parent by Account API availability mapAuditRowToResource picked the user/group/service-principal parent from whether the audit row carried a workspace_id, not from how the resource is actually synced. Users/groups/service principals are only ever synced under the account when the Account API is reachable (accountResource declares them as children only then; groupGrantParent already encodes this rule), so a workspace-scoped row in that mode built an ID that was never synced (e.g. workspace//group/x instead of account//group/x), and the real resource never got refreshed. Select the parent from IsAccountAPIAvailable() instead, matching groupGrantParent. Also thread the real ctx through instead of context.Background(), now that groupResourceId's ctx parameter is actually used for something worth passing correctly. * fix: don't require the Account API for incremental sync under token auth Validate() and ListEvents() both called ListWorkspaces unconditionally to resolve the audit-log query workspace, but the Account API is unreachable under workspace-token auth — so incremental sync always failed Validate() and every ListEvents poll for token-auth customers. Add resolveSQLWorkspaces, which builds minimal workspaces from the configured deployment names under token auth (mirroring workspaceBuilder.List's token-auth branch) instead of calling ListWorkspaces, and use it in both places. auditEventFeed now carries the configured workspace list to support this. Workspace-scoped audit rows can't be resolved to a deployment name under token auth this way (no numeric workspace ID is ever learned), so they're skipped by mapAuditRowToResource rather than mis-resolved — a known limitation, not a regression, since incremental sync couldn't run under token auth at all before this. * refactor: simplify quotedInClause and ExecuteStatement's status check quotedInClause hand-rolled a strings.Join; use it directly. The two-case switch in ExecuteStatement (success vs everything else) reads clearer as a plain if. Cosmetic only, per review feedback. * feat: add sql-warehouse-workspace to pin the audit-log query workspace sqlQueryWorkspace picked whichever workspace sorted alphabetically first by deployment name to run the system.access.audit query, with no way to route it to the workspace that actually hosts sql-warehouse-id. SQL warehouses only exist in one workspace, so querying through the wrong one 404s — this broke deterministically for any account with more than one workspace, unless the warehouse happened to live in the alphabetically-smallest one. Add --sql-warehouse-workspace to pin the deployment name explicitly, validated against the resolved workspace list in both Validate() and ListEvents() via a shared resolveQueryWorkspace helper. When unset and more than one workspace is available, log a Debug line naming the arbitrarily-picked workspace and pointing at the new flag, so it's diagnosable without escalating to Warn/Error for what is, until set, a config gap rather than a connector fault. Regenerated config_schema.json and pkg/config/conf.gen.go; updated README's incremental sync section and flag list. * fix: address remaining audit-log review findings - Use RFC3339Nano for start_time so the (event_time, event_id) tiebreaker keeps sub-second precision, fixing duplicate re-emission at page boundaries. - Reject enable-incremental-sync under workspace token auth at Validate(), since no audit row can ever resolve to a synced resource that way and it would otherwise poll forever for nothing. - Add the three incremental-sync config fields to both field groups so they're selectable in the UI; regenerate config_schema.json. - Propagate rate-limit info from the SQL Statement Execution API through ExecuteStatement into ListEvents' annotations. Co-Authored-By: Claude Sonnet 5 * fix: log corrupt event cursors instead of resetting silently decodeEventCursor now logs at Debug when a cursor fails to decode, so the watermark reset is observable instead of invisible. Co-Authored-By: Claude Sonnet 5 * chore: promote Debug logs to Warn for visibility * chore: retrigger GitHub jobs * fix: drop incremental-sync fields from the workspace-token config group Validate() rejects enable-incremental-sync under token auth, so offering those fields in the workspace-token group let the UI present an option that can never succeed. Remove them from that group (they stay in oauth2) and regenerate config_schema.json; note the OAuth-only requirement in the README. Co-Authored-By: Claude Sonnet 5 * fix: address remaining audit-log review findings (round 2) - Filter resolveSQLWorkspaces through --workspaces on the OAuth2 path, and mirror the same allowlist check in workspaceBuilder.Get. - Clamp intra-page cursor advances to the trailing-lag boundary so a full page of recent rows can't permanently defeat auditLogTrailingLag. - Trust an explicit earliestEvent watermark instead of clamping it to the 1h lookback default. - Bound Validate()'s audit-log probe with a dedicated timeout. - Validate parsed audit-log rows have enough columns before indexing. - Cancel timed-out SQL statements via POST .../cancel instead of DELETE, which only closes an already-terminal statement. - Note the OAuth2-only requirement for incremental sync in the docs. Co-Authored-By: Claude Sonnet 5 * fix: address remaining audit-log review findings (round 3) - Key the audit action-name map by (service_name, action_name) instead of action_name alone, since action_name is ambiguous across services (e.g. "delete" also means cluster termination). Fixes the map along the way: adds addPrincipalsToGroup/removePrincipalsFromGroup, renames deleteGroup -> removeGroup and createUser/deleteUser -> add/delete, and stops attaching the account-admin role to removeAdmin (which actually revokes workspace admin). - Drop the write-only LatestEventSeen cursor field. - Detect a stale-but-valid cursor (older than system.access.audit's 365-day retention) and self-heal it like a corrupt one; keep routine corrupt/missing-cursor logging at Debug and log genuine staleness at Warn. - Skip and log an individual malformed audit-log row instead of failing the whole page. - Add regression coverage: a >page-limit tied-timestamp burst, an end-to-end mocked ListEvents run (also covering the service_name filter and rate-limit propagation), and a skip-malformed-row test. Co-Authored-By: Claude Sonnet 5 * fix: infer the SQL warehouse's workspace instead of requiring it in config sql-warehouse-workspace forced operators to manually pin the workspace hosting the configured SQL warehouse, and was unenforced for the multi-workspace case it existed to cover (see the resolveQueryWorkspace Debug-log-and-guess fallback this replaces). Databricks has no account-level "which workspace owns this warehouse" API, but each workspace's SQL warehouse endpoint is queryable directly, so the connector now probes each candidate workspace for the configured sql-warehouse-id and caches the match instead of asking for it up front. - Add Client.WarehouseExists to probe a single workspace for a warehouse ID, distinguishing 404 from a real error. - Replace resolveQueryWorkspace's pin-or-guess logic with resolveWarehouseWorkspace, which skips probing entirely for a single workspace and otherwise probes until one workspace confirms the warehouse; the resolved workspace is cached per auditEventFeed instance so it's found once, not on every poll. - Remove the sql-warehouse-workspace config field, its config_schema.json entry, and its README documentation/CLI help text. Co-Authored-By: Claude Sonnet 5 * fix: stop fetching group members needlessly in groupBuilder.Get Get is only used to rebuild the resource, which needs id/displayName/ parent, not membership — Grants already fetches members separately when building membership grants. Matches List, which already fetches without members. Co-Authored-By: Claude Sonnet 5 * chore: clean up and comments * feat: emit CREATE_GRANT/CREATE_REVOKE for group membership audit events Group membership actions (addPrincipalToGroup, removePrincipalFromGroup, and their plural batch variants) are atomic, unambiguous grant/revoke events per Databricks' own audit log docs: the group is the entitlement's resource, targetUserId is the principal, and add/remove gives the direction. Emitting CREATE_GRANT/CREATE_REVOKE for these lets C1 apply the grant directly instead of waiting on a targeted resync. Other assignment-shaped actions (setAdmin/removeAdmin, the roleNames-driven update* actions, ACL-change actions) stay RESOURCE_CHANGE: their scope or direction isn't fully determinable from the audit row alone, and guessing would risk writing an incorrect grant straight into C1's data model. EventFeedMetadata now advertises all three event types. Co-Authored-By: Claude Sonnet 5 * fix: bound the audit log query by lag instead of clamping the cursor advanceEventCursor tried to enforce auditLogTrailingLag by overwriting the cursor with a synthetic now-lag timestamp after the fact, which caused three bugs: it could jump StartAt backwards past where paging already was (no floor against the incoming cursor), it wiped the (event_time, event_id) tiebreaker whenever it fired (re-emitting the whole trailing window every poll), and hasMore was computed from the post-parse row count so one malformed row on a full page caused premature draining. Bound the query itself by lag_cutoff (event_time <= :lag_cutoff) instead. Every row queryAuditLog can return is now already within the lag window, so the cursor only ever advances to a real row's boundary (hasMore) or jumps straight to lag_cutoff once drained (proven empty beyond it) - no synthetic timestamp, no clamp, no floor needed. hasMore now uses the raw statement row count. Also guards against querying an inverted range when the cursor is already past lag_cutoff (current gap: auditLogLookback < auditLogTrailingLag on bootstrap). Co-Authored-By: Claude Sonnet 5 * fix: remaining audit-log review findings (bootstrap lookback, log levels, exclude check) - auditLogLookback now equals auditLogTrailingLag instead of a narrower 1h default, so a fresh install's first poll isn't immediately past lagCutoff (which would otherwise skip every query until it caught up). - Downgrade the 4 remaining skip-and-continue logs from Warn to Debug (stale cursor, malformed row, statement poll timeout, cancel failure) - these are graceful degradation, not connector bugs, and Warn surfaces as a C1 incident. - workspaceBuilder.Get's token-auth branch now also checks IsWorkspaceNameExcluded, mirroring List, so a workspace in both --workspaces and --databricks-exclude-workspaces is rejected consistently by both methods. - Comment out createServicePrincipal/updateServicePrincipal/ deleteServicePrincipal and setAccountAdmin/removeAccountAdmin in auditLogActions: none of these are real action_name values per the official Databricks audit log reference (verified against the AWS, GCP, and Azure docs mirrors) - setAdmin already covers account-admin grants, and the only documented service-principal action is changeServicePrincipalAcls. Co-Authored-By: Claude Sonnet 5 * fix: address PR comments * fix: resolve group-membership grant principal type dynamically principalType was hardcoded to userResourceType for the four addPrincipalToGroup/removePrincipalFromGroup (singular and plural) grant mappings, but request_params.targetUserId can also be a nested group or a service principal - the audit log has no field to tell which. A wrong guess silently fails C1's grant lookup rather than corrupting data, but the connector already knows how to sync all three principal types, so it's worth resolving properly. Add resolvePrincipalType: probes GetUser, then GetGroup, then GetServicePrincipal (in that order, stopping at the first match) using the same 404-detection idiom as the existing WarehouseExists probe. A real (non-404) API error propagates up and is logged/skipped per-row rather than aborting the whole poll; rate-limit info is still recorded even on a skipped row. Co-Authored-By: Claude Sonnet 5 * fix: stop advanceEventCursor stall and mistranslated nested-group principal id advanceEventCursor's panic fix regressed into a stall: when hasMore is true but only the last raw row fails to parse, lastRawBoundary is zero, and the old fallback returned the cursor unchanged while ListEvents still reported HasMore true - the SDK would re-issue the identical query forever, even though the other rows on that page parsed fine. Fall back to the last successfully parsed row first, and only stop claiming HasMore when neither source can advance. Also log (instead of discarding) parseEventBoundary's error on the raw last-row extraction, so a stuck cursor is diagnosable. Separately, a grant-mapped principal that resolves to a nested group was emitted with its raw native id instead of the composite id (account/workspace + group) every other group resource in this connector uses - apply the same groupResourceId transform Grants() uses for nested-group members, so the principal actually matches what was synced. Co-Authored-By: Claude Sonnet 5 * chore: fix lint errors * feat: enable incremental sync via sql-warehouse-id, drop enable-incremental-sync Incremental sync is now on exactly when sql-warehouse-id is set (whitespace counts as unset) and off when it is empty, removing the separate enable-incremental-sync flag. - config: remove EnableIncrementalSyncField; sql-warehouse-id description leads with the on/off rule; regenerate conf.gen.go and config_schema.json - Validate: no ID skips all incremental-sync checks; a set ID fails validation if no workspace is in sync scope, the warehouse can't be located, or system.access.audit can't be queried. Logs whether incremental sync is enabled - audit feed: gated on sqlWarehouseID instead of the removed bool - README and connector.mdx: document the new behavior, drop the flag - tests for disabled/enabled validation paths and the no-op feed Co-Authored-By: Claude Opus 5.5 * fix: address audit feed review comments (retention, rate limits, gRPC codes) - lower auditLogRetention to 30 days; C1 doesn't request event-feed data older than that - propagate rate limit data from ListWorkspaces in ListEvents, from GetWorkspace in workspaceBuilder.Get, from the Validate incremental-sync checks, and on error paths of the user/group/service principal Get methods - add gRPC status codes to connector-inferred errors: NotFound for missing warehouse/workspace, FailedPrecondition for no workspace in scope and failed statements, Aborted for canceled/closed statements, DeadlineExceeded for statement poll timeout Co-Authored-By: Claude Opus 5.5 * fix: bypass GET cache for statement polling and incremental-sync refreshes - add databricks.WithoutCache(ctx); doRequest sends GETs made with it with Cache-Control: no-cache so the uhttp response cache is skipped - pollStatement polls with it: the poll URL never changes, so a cached PENDING/RUNNING response froze polling until statementPollMaxWait - user/group/service principal/workspace Get (RESOURCE_CHANGE refreshes) use it so re-syncs return fresh data; regular sync callers of the same client methods stay cached - resolveWarehouseWorkspace skips workspaces that return 401/403 instead of aborting, so a service principal not assigned to every workspace can still locate the warehouse; the not-found error names skipped workspaces - tests for poll-past-cache, cache scoping, and inaccessible workspaces Co-Authored-By: Claude Opus 5.5 * fix: retry audit rows whose principal lookup fails instead of skipping them A transient principal lookup failure (429, 5xx, transport) was logged at Debug and the row skipped, so the cursor advanced past it and the grant/revoke was lost until the next full sync. ListEvents now returns the error before encoding the cursor, so the next poll retries the page. Also clarify in docs/connector.mdx that removed group memberships are picked up incrementally; only deletions wait for the next full sync. Co-Authored-By: Claude Opus 5.5 * feat: map account-admin, user activation and workspace assignment audit events Per the official audit log reference (AWS/Azure/GCP): - setAccountAdmin/removeAccountAdmin are real `accounts` actions; map them to the account-admin role (previously commented out as nonexistent). - setAdmin is workspace admin (it pairs with removeAdmin in the workspace-level table), so it no longer refreshes the account-admin role. - Map activateUser/deactivateUser to a user refresh. - Map `workspace` add/update/deletePermissionAssignment to a workspace refresh, resolving the workspace from request_params when the row's workspace_id is 0. Co-Authored-By: Claude Opus 5.5 * fix: satisfy nilerr in workspace_id request param parsing Co-Authored-By: Claude Opus 5.5 * fix: address audit feed review suggestions - Only fail the poll on retryable principal lookup errors; skip rows whose lookup fails permanently (sampled Warn) so the cursor can't get stuck. - Refresh the group when a membership row's principal can't be resolved instead of dropping the row. - Keep refreshing the account-admin role on setAdmin. - Resolve account-level updatePermissionAssignment rows' workspace from request_params. Co-Authored-By: Claude Opus 5.5 * refactor: drop unreachable workspace-parent fallback from audit feed The account API is always available (OAuth is the only auth method and Validate fails without it), so principals are always account-parented. Remove the accountAPIAvailable parameter, the workspace-parent fallback, and the workspace-scoped principal lookup, along with the tests that covered the impossible token-auth state. Co-Authored-By: Claude Opus 5.5 * test: cover ListEvents full-page cursor advance and !advanced wiring Add a ListEvents-level test for a full audit log page: it pages forward from the last raw row's boundary, and when no boundary parses it reports HasMore=false with the cursor unchanged. Co-Authored-By: Claude Opus 5.5 * fix: stop incremental sync checks from blocking connector validation Validate no longer checks workspace scope, the SQL warehouse, or system.access.audit access when sql-warehouse-id is set. Incremental sync is optional, so a misconfiguration should not block full syncs or provisioning; the event feed already reports these problems as errors on each poll. Co-Authored-By: Claude Opus 5.5 * docs: document incremental sync requirements and service principal admin role Spell out what incremental sync needs (Unity Catalog, system tables, grants, warehouse access), the single-region limit of system.access.audit, and the serverless warehouse recommendation. State that the service principal needs the Account admin role, and fix the account-level Account admin wording. Co-Authored-By: Claude Opus 5.5 * fix: explain SQL statement timeouts and cancel statements on every timeout When a statement doesn't finish within the poll limit, include why: the query was still running, or for a pending statement, the warehouse's state (stopped, starting, or running but at capacity). A deadline that expires mid-poll is now treated as a timeout too, so the statement is canceled instead of left running. Co-Authored-By: Claude Opus 5.5 * docs: state the same-region requirement in the sql-warehouse-id description system.access.audit only exposes workspace-level events for the warehouse's cloud region, so the warehouse can't be in just any workspace. Update the field description, its comment, the regenerated config schema and the README help output to match. Co-Authored-By: Claude Opus 5.5 * perf: cache principal type lookups within each ListEvents call resolvePrincipalType tried user, group, then service principal for every grant-mapped row, and the HTTP cache only keeps 200s, so a principal named on many rows repeated its 404 lookups each time. Memoize the resolved type (including no match) per ListEvents call; errors are not cached. Co-Authored-By: Claude Opus 5.5 * fix: use a tagged switch on statement state (staticcheck QF1002) Co-Authored-By: Claude Opus 5.5 * chore: fix lint * test: add a local test server for the incremental sync event feed Adds test-server/, a stdlib-only stand-in for the Databricks APIs that Validate and the audit-log event feed call: OAuth M2M, workspaces, account SCIM lookups, SQL warehouses and the Statement Execution API. It serves TLS with a generated CA, routes account and workspace hosts by Host header, and recognizes the connector's system.access.audit query over seeded audit rows. Each behavior is labeled DOC, ASSUMPTION or DECISION in code and README. Adds a CI job that runs the event feed against it, diffs the events with testdata/expected-events.jsonl and checks the timeout message for a warehouse that never starts. It needs no secrets. Co-Authored-By: Claude Opus 5.5 * feat: add debug logs to trace the incremental sync event feed Log each step of ListEvents (poll window, workspaces in scope, warehouse workspace resolution, audit query, row-to-event mapping) and a per-poll summary with audit row and event counts by type. Log SQL statement submission, success and result chunks with row counts, warehouse lookups, principal type resolution, and the targeted Get refreshes. Every message names the function that logged it; all are at Debug level. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Sonnet 5 --- .github/workflows/ci.yaml | 58 + .gitignore | 3 + README.md | 67 + baton_capabilities.json | 11 +- config_schema.json | 6 + docs/connector.mdx | 71 +- pkg/config/conf.gen.go | 1 + pkg/config/config.go | 15 + pkg/connector/account.go | 14 + pkg/connector/audit_event_feed.go | 963 +++++++++++++ pkg/connector/audit_event_feed_test.go | 1467 ++++++++++++++++++++ pkg/connector/connector.go | 41 +- pkg/connector/groups.go | 34 + pkg/connector/roles.go | 47 +- pkg/connector/service-principals.go | 35 +- pkg/connector/users.go | 33 +- pkg/connector/validate_test.go | 111 ++ pkg/connector/workspaces.go | 25 + pkg/databricks/client.go | 26 +- pkg/databricks/request.go | 16 + pkg/databricks/sql.go | 340 +++++ pkg/databricks/sql_test.go | 130 ++ test-server/README.md | 287 ++++ test-server/handlers.go | 262 ++++ test-server/main.go | 160 +++ test-server/main_test.go | 456 ++++++ test-server/seeds.go | 150 ++ test-server/state.go | 187 +++ test-server/statements.go | 633 +++++++++ test-server/testdata/expected-events.jsonl | 25 + 30 files changed, 5649 insertions(+), 25 deletions(-) create mode 100644 pkg/connector/audit_event_feed.go create mode 100644 pkg/connector/audit_event_feed_test.go create mode 100644 pkg/databricks/sql.go create mode 100644 pkg/databricks/sql_test.go create mode 100644 test-server/README.md create mode 100644 test-server/handlers.go create mode 100644 test-server/main.go create mode 100644 test-server/main_test.go create mode 100644 test-server/seeds.go create mode 100644 test-server/state.go create mode 100644 test-server/statements.go create mode 100644 test-server/testdata/expected-events.jsonl diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6030d7f1..5a0408d0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,3 +34,61 @@ jobs: baton-entitlement: 'group:account/8c6f99ec-78ce-4654-8f92-e716b3dd67a7/group/66271399072731:member' baton-principal: '5346803201281760' baton-principal-type: 'user' + # Runs the incremental sync event feed against the local test server (test-server/), so it + # needs no Databricks account or secrets and also runs on pull requests from forks. + test-server: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build connector and test server + run: | + go build -o baton-databricks ./cmd/baton-databricks + go build -o /tmp/databricks-test-server ./test-server + - name: Start test server + run: | + /tmp/databricks-test-server --ca-out /tmp/test-server-ca.pem > test-server.log 2>&1 & + for i in $(seq 1 30); do + if curl -sf --cacert /tmp/test-server-ca.pem https://localhost:8765/health > /dev/null; then + echo "test server is up"; exit 0 + fi + sleep 1 + done + echo "test server did not come up in time"; cat test-server.log; exit 1 + - name: Run incremental sync against the test server + env: + SSL_CERT_FILE: /tmp/test-server-ca.pem + run: | + ./baton-databricks \ + --hostname localhost:8765 --account-hostname localhost:8765 \ + --account-id test-account \ + --databricks-client-id test-client --databricks-client-secret test-secret \ + --sql-warehouse-id wh-serverless \ + --event-feed true --event-feed-id databricks_audit_log \ + --event-feed-start-at "$(date -u -d '2 days ago' +%Y-%m-%dT%H:%M:%SZ)" \ + > events.jsonl + - name: Compare events with the expected output + run: | + normalize() { jq -c -S 'del(.occurredAt)' "$1" | sort; } + diff <(normalize test-server/testdata/expected-events.jsonl) <(normalize events.jsonl) + - name: Check the cold-start timeout message + env: + SSL_CERT_FILE: /tmp/test-server-ca.pem + run: | + if ./baton-databricks \ + --hostname localhost:8765 --account-hostname localhost:8765 \ + --account-id test-account \ + --databricks-client-id test-client --databricks-client-secret test-secret \ + --sql-warehouse-id wh-broken \ + --event-feed true --event-feed-id databricks_audit_log \ + --event-feed-start-at "$(date -u -d '2 days ago' +%Y-%m-%dT%H:%M:%SZ)" 2> err.log; then + echo "expected the event feed to fail with a broken warehouse"; exit 1 + fi + grep -q "has not started" err.log + - name: Show test server log on failure + if: failure() + run: cat test-server.log diff --git a/.gitignore b/.gitignore index b2118062..e9ac02eb 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ # Dependency directories (remove the comment below to include it) # vendor/ dist/ + +# CA certificate written by the local test server (test-server/) +test-server-ca.pem diff --git a/README.md b/README.md index 371bbe5e..65930d04 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,72 @@ To instead exclude specific workspaces from the sync, pass them to the list. Each entry can be a workspace name, deployment name, or numeric workspace ID. Excluded workspaces and their roles are skipped entirely. +## Incremental sync + +By default, `baton-databricks` does a full resync of every resource on every run. +You can opt into an additional, cheap pathway that polls a Databricks audit log +between full syncs to pick up access changes early. Full syncs still run as the +correctness backstop; incremental sync does not detect deletions, which are +only caught by the next full sync. + +**Incremental sync is enabled by setting `--sql-warehouse-id` (or +`BATON_SQL_WAREHOUSE_ID`) and disabled by leaving it unset or empty.** There is +no separate on/off flag. The incremental-sync setup (warehouse found, +`system.access.audit` queryable, at least one workspace in sync scope) is not +checked at startup, so a misconfiguration never blocks full syncs or +provisioning. Instead, each event-feed poll fails with an error naming the +problem until it is fixed. + +Incremental sync requires a reachable Account API to resolve audit events back +to synced resources, same as the rest of the connector. Audit events are only +resolved for workspaces in sync scope, so `--workspaces` and +`--databricks-exclude-workspaces` apply to incremental sync the same way they +apply to full syncs. + +### Requirements + +Incremental sync works only when all of the following are true: + +- **Unity Catalog.** The SQL warehouse is in a workspace enabled for Unity + Catalog, and the metastore is on Unity Catalog Privilege Model Version 1.0 + ([system tables + requirements](https://docs.databricks.com/aws/en/admin/system-tables/)). +- **System tables.** The connector queries the `system.access.audit` system + table, which Databricks currently offers as a Public Preview. +- **A SQL warehouse, preferably serverless.** Pass its ID with + `--sql-warehouse-id`. Any warehouse works, including the **Starter + Warehouse** Databricks creates in each workspace, so you don't need to create + one. +- **Grants for the service principal** the connector authenticates as, on top of + the Account admin role it already needs: + - `USE CATALOG` on the `system` catalog + - `USE SCHEMA` and `SELECT` on the `system.access` schema + - `CAN USE` on the SQL warehouse + + For example, run as a metastore admin: + + ```sql + GRANT USE CATALOG ON CATALOG system TO ``; + GRANT USE SCHEMA, SELECT ON SCHEMA system.access TO ``; + ``` + +**We recommend a serverless SQL warehouse.** It starts in seconds, while a pro +or classic warehouse can take several minutes. Each poll waits at most 5 minutes +for its statement, including warehouse startup; a poll that runs out of time +fails with a `DeadlineExceeded` error and is retried on the next poll. Serverless +SQL warehouses require the Premium plan or above. + +**Incremental sync covers one cloud region.** `system.access.audit` holds +workspace-level events only for workspaces in the same cloud region as the +workspace that queries it; account-level events are global ([system tables +reference](https://docs.databricks.com/aws/en/admin/system-tables/)). The +connector finds the warehouse's workspace automatically, and it receives +incremental updates only for workspaces in that region. Workspaces in other +regions are still covered by full syncs. + +Audit log entries are delivered with a delay, so the connector reads them about +four hours after they happen. + ## Group provisioning Account groups are provisioned through the OAuth client ID and secret flow. @@ -175,6 +241,7 @@ Flags: -p, --provisioning This must be set in order for provisioning actions to be enabled ($BATON_PROVISIONING) --skip-entitlements-and-grants This must be set to skip syncing of entitlements and grants ($BATON_SKIP_ENTITLEMENTS_AND_GRANTS) --skip-full-sync This must be set to skip a full sync ($BATON_SKIP_FULL_SYNC) + --sql-warehouse-id string Setting this enables incremental sync; leaving it empty disables it. ID of the Databricks SQL warehouse used to query the system.access.audit log between full syncs, so access changes show up before the next full sync (deletions are still only caught by full syncs). The warehouse can be in any workspace in the same cloud region as the workspaces to update incrementally; Databricks only exposes workspace-level audit events for that region. The connector discovers the warehouse's workspace automatically. ($BATON_SQL_WAREHOUSE_ID) --storage-engine string The storage engine to use when opening the sync c1z file: sqlite or pebble. Defaults to pebble when unset. ($BATON_STORAGE_ENGINE) --sync-resource-types strings The resource type IDs to sync ($BATON_SYNC_RESOURCE_TYPES) --sync-resources strings The resource IDs to sync ($BATON_SYNC_RESOURCES) diff --git a/baton_capabilities.json b/baton_capabilities.json index 91985cec..57cfd894 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -8,6 +8,7 @@ }, "capabilities": [ "CAPABILITY_SYNC", + "CAPABILITY_TARGETED_SYNC", "CAPABILITY_PROVISION" ], "permissions": {} @@ -22,6 +23,7 @@ }, "capabilities": [ "CAPABILITY_SYNC", + "CAPABILITY_TARGETED_SYNC", "CAPABILITY_PROVISION" ], "permissions": {} @@ -36,6 +38,7 @@ }, "capabilities": [ "CAPABILITY_SYNC", + "CAPABILITY_TARGETED_SYNC", "CAPABILITY_PROVISION" ], "permissions": {} @@ -50,6 +53,7 @@ }, "capabilities": [ "CAPABILITY_SYNC", + "CAPABILITY_TARGETED_SYNC", "CAPABILITY_PROVISION" ], "permissions": {} @@ -69,6 +73,7 @@ }, "capabilities": [ "CAPABILITY_SYNC", + "CAPABILITY_TARGETED_SYNC", "CAPABILITY_ACCOUNT_PROVISIONING", "CAPABILITY_RESOURCE_DELETE" ], @@ -84,6 +89,7 @@ }, "capabilities": [ "CAPABILITY_SYNC", + "CAPABILITY_TARGETED_SYNC", "CAPABILITY_PROVISION" ], "permissions": {} @@ -93,7 +99,10 @@ "CAPABILITY_PROVISION", "CAPABILITY_SYNC", "CAPABILITY_ACCOUNT_PROVISIONING", - "CAPABILITY_RESOURCE_DELETE" + "CAPABILITY_RESOURCE_DELETE", + "CAPABILITY_TARGETED_SYNC", + "CAPABILITY_EVENT_FEED_V2", + "CAPABILITY_SERVICE_MODE_TARGETED_SYNC" ], "credentialDetails": { "capabilityAccountProvisioning": { diff --git a/config_schema.json b/config_schema.json index e387c1ed..b12191ad 100644 --- a/config_schema.json +++ b/config_schema.json @@ -151,6 +151,12 @@ "displayName": "Exclude Workspaces", "description": "Workspaces to exclude from sync, identified by workspace name, deployment name, or numeric workspace ID. Mutually exclusive with workspaces.", "stringSliceField": {} + }, + { + "name": "sql-warehouse-id", + "displayName": "SQL Warehouse ID", + "description": "Setting this enables incremental sync; leaving it empty disables it. ID of the Databricks SQL warehouse used to query the system.access.audit log between full syncs, so access changes show up before the next full sync (deletions are still only caught by full syncs). The warehouse can be in any workspace in the same cloud region as the workspaces to update incrementally; Databricks only exposes workspace-level audit events for that region. The connector discovers the warehouse's workspace automatically.", + "stringField": {} } ], "constraints": [ diff --git a/docs/connector.mdx b/docs/connector.mdx index f57273e3..3c649d1a 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -31,25 +31,83 @@ Revoking the workspace **member** entitlement removes the principal's entire wor The connector authenticates with **OAuth** — an account-level service principal's client ID and secret. This is the only authentication method the connector supports. +### Optional: faster updates between syncs + +By default, the Databricks connector picks up access changes on its regular sync schedule. You can optionally turn on incremental sync, which checks Databricks' activity log between full syncs so that changes like new group members show up in C1 sooner. Full syncs still run as usual and remain the source of truth. Removed group memberships are picked up incrementally too, but deleted users, groups, and service principals are only reflected after the next full sync. + +Incremental sync is turned on by providing the ID of a Databricks SQL warehouse (the **SQL Warehouse ID** setting, `BATON_SQL_WAREHOUSE_ID`) and turned off by leaving it empty — there is no separate on/off switch. Ask your connector operator or C1 support contact to enable it for you. + +#### Incremental sync requirements + +Your Databricks account must meet all of the following: + +- **Unity Catalog.** The SQL warehouse must be in a workspace enabled for Unity Catalog, and the metastore must be on Unity Catalog Privilege Model Version 1.0. Databricks enables most workspaces created after November 2023 for Unity Catalog automatically. +- **System tables.** The connector reads Databricks' audit log system table, `system.access.audit`. Databricks currently offers this table as a Public Preview. +- **A SQL warehouse, preferably serverless.** You don't need to create a new one: Databricks creates a **Starter Warehouse** in each workspace automatically, and any SQL warehouse works. See the recommendation below about the warehouse type. +- **Permissions for the connector's service principal**, in addition to the **Account admin** role it already needs: + - `USE CATALOG` on the `system` catalog + - `USE SCHEMA` and `SELECT` on the `system.access` schema + - **Can use** on the SQL warehouse + + +**We recommend a serverless SQL warehouse.** It starts in seconds, while a pro or classic warehouse can take several minutes to start. Each incremental update waits at most 5 minutes for the warehouse and query, so a slower start can delay updates until the next one. Serverless SQL warehouses require the Premium plan or above. + + + +**Incremental sync covers one cloud region.** Databricks keeps workspace activity in the audit log of the region where each workspace is deployed. The connector only receives incremental updates for workspaces in the same region as the SQL warehouse. Workspaces in other regions are still updated by full syncs. + + +#### Set up incremental sync + + +A Databricks user with the **Account admin** role, and the **Metastore admin** role or the right to grant access to the `system` catalog, must perform this task. + + + + + Choose a SQL warehouse in a Unity Catalog-enabled workspace, in the same region as the workspaces you want to update incrementally. In that workspace, go to **SQL Warehouses**, open the warehouse, and copy its ID. Check its type: if it isn't **Serverless**, switch it to serverless if your plan allows it (see the recommendation above). + + + On the **SQL Warehouses** page, click the kebab menu (three-dot icon) next to the warehouse, click **Permissions**, and give the connector's service principal the **Can use** permission. + + + In the same workspace, run the following in the SQL editor, replacing `` with the service principal's application (client) ID: + + ```sql + GRANT USE CATALOG ON CATALOG system TO ``; + GRANT USE SCHEMA, SELECT ON SCHEMA system.access TO ``; + ``` + + + Enter the warehouse ID in the connector's **SQL Warehouse ID** setting (`BATON_SQL_WAREHOUSE_ID`). + + + +The SQL warehouse can live in any workspace in that region — the connector finds it automatically. Changes reach C1 roughly four hours after they happen: Databricks delivers audit log entries with a delay, so the connector waits before reading them. + + +The incremental sync setup isn't checked when the connector starts, so a problem with it never blocks full syncs or provisioning. If something is missing, such as a permission or a warehouse that can't be found, every incremental update fails and the connector logs an error that names the cause until you fix it. + + ## Gather Databricks credentials Configuring the connector requires you to pass in credentials generated in Databricks. Gather these credentials before you move on. -A user with the **Account admin** role in each Databricks workspace you want to sync must perform this task. +A user with the **Account admin** role in your Databricks account must perform this task. Account admin is an account-level role, managed in the Databricks account console. ### Look up your Databricks account ID - Ensure that your Databricks user account has the **Account admin** role and is assigned to each Databricks workspace you want to sync to C1. + Log in to the Databricks account console with a user that has the **Account admin** role. In the Databricks account console, open the menu that appears next to your username in the upper right corner. - Carefully and copy and save account ID. + Carefully copy and save the account ID. @@ -64,6 +122,9 @@ The Databricks connector authenticates with OAuth, which syncs info from all Dat Carefully copy and save the OAuth client ID and secret. + + Give the service principal the **Account admin** role: in the account console, go to **User management** > **Service principals**, click the service principal, and on the **Roles** tab select **Account admin**. Databricks requires this role for any service principal that calls account-level APIs, and the connector fails validation without it. + **Done.** Here's the set of credentials you'll need when setting up the connector: @@ -214,6 +275,10 @@ stringData: # Mutually exclusive with BATON_WORKSPACES — set one or the other, never both. BATON_DATABRICKS_EXCLUDE_WORKSPACES: + # Optional: setting this enables incremental sync; leave it unset to disable it. + # ID of the SQL warehouse used to query the system.access.audit log between full syncs. + # BATON_SQL_WAREHOUSE_ID: + # Optional: include if you want C1 to provision access using this connector BATON_PROVISIONING: true ``` diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index dd2a3bba..7094ed29 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -12,6 +12,7 @@ type Databricks struct { Workspaces []string `mapstructure:"workspaces"` BaseUrl string `mapstructure:"base-url"` DatabricksExcludeWorkspaces []string `mapstructure:"databricks-exclude-workspaces"` + SqlWarehouseId string `mapstructure:"sql-warehouse-id"` } func (c *Databricks) findFieldByTag(tagValue string) (any, bool) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 35afe969..66373b64 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -54,6 +54,20 @@ var ( field.WithDescription("Workspaces to exclude from sync, identified by workspace name, deployment name, or numeric workspace ID. Mutually exclusive with workspaces."), field.WithDisplayName("Exclude Workspaces"), ) + // SQLWarehouseIDField doubles as the incremental-sync switch: set enables it, empty disables it. + // system.access.audit only has workspace-level events from the warehouse's region, so the + // warehouse must share a region with the workspaces incremental sync should cover. + SQLWarehouseIDField = field.StringField( + "sql-warehouse-id", + field.WithDescription( + "Setting this enables incremental sync; leaving it empty disables it. "+ + "ID of the Databricks SQL warehouse used to query the system.access.audit log between full syncs, "+ + "so access changes show up before the next full sync (deletions are still only caught by full syncs). "+ + "The warehouse can be in any workspace in the same cloud region as the workspaces to update incrementally; "+ + "Databricks only exposes workspace-level audit events for that region. The connector discovers the warehouse's workspace automatically.", + ), + field.WithDisplayName("SQL Warehouse ID"), + ) configFields = []field.SchemaField{ AccountHostnameField, AccountIdField, @@ -63,6 +77,7 @@ var ( WorkspacesField, BaseURLField, ExcludeWorkspacesField, + SQLWarehouseIDField, } ) diff --git a/pkg/connector/account.go b/pkg/connector/account.go index 3684ed45..c2726501 100644 --- a/pkg/connector/account.go +++ b/pkg/connector/account.go @@ -219,6 +219,20 @@ func (a *accountBuilder) Grant(ctx context.Context, principal *v2.Resource, enti return nil, nil } +// Get returns the singleton account resource, used to re-sync it after a RESOURCE_CHANGE event. +func (a *accountBuilder) Get(ctx context.Context, resourceId *v2.ResourceId, parentResourceId *v2.ResourceId) (*v2.Resource, annotations.Annotations, error) { + resource, err := a.accountResource(ctx) + if err != nil { + return nil, nil, err + } + + ctxzap.Extract(ctx).Debug("databricks-connector: Get: refreshed account", + zap.String("resource_id", resourceId.Resource), + zap.String("parent_resource_id", parentResourceId.GetResource()), + ) + return resource, nil, nil +} + func (a *accountBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) { l := ctxzap.Extract(ctx) diff --git a/pkg/connector/audit_event_feed.go b/pkg/connector/audit_event_feed.go new file mode 100644 index 00000000..9298246e --- /dev/null +++ b/pkg/connector/audit_event_feed.go @@ -0,0 +1,963 @@ +package connector + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + ent "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + auditEventFeedId = "databricks_audit_log" + + // Trail the watermark by this much instead of the newest event seen, since slower-indexing + // areas of the audited system could otherwise have events skipped permanently. + auditLogTrailingLag = 4 * time.Hour + + // The first poll looks back this far since there's no prior watermark yet. Must be >= + // auditLogTrailingLag, or the lagCutoff guard in ListEvents skips every poll until it is. + auditLogLookback = auditLogTrailingLag + + auditLogPageLimit = 1000 + + // auditLogRetention caps how far back the feed reads; C1 doesn't request event-feed data + // older than ~30 days, so an older cursor is treated as stale. + auditLogRetention = 30 * 24 * time.Hour + + auditServiceAccounts = "accounts" + auditServiceWorkspace = "workspace" +) + +// auditActionMapping describes what an audit log action_name affects: an optional primary +// resource (resourceType + the request_params key holding its native ID), an optional +// account-scoped role, and/or optional workspace-scoped roles/entitlements. grant, when set, +// means the row is an atomic entitlement assignment/revocation rather than a generic change. +type auditActionMapping struct { + resourceType *v2.ResourceType + idParam string + // workspaceIdParam names a request_params key holding the workspace id, for account-level + // actions on a workspace, whose workspace_id column is 0. + workspaceIdParam string + accountRole string + roleNames []string + grant *auditGrantMapping +} + +// auditGrantMapping names the principal and direction of a grant-mapped action. The +// entitlement's resource is resourceType/idParam on the enclosing auditActionMapping. +type auditGrantMapping struct { + entitlement string + principalParam string + revoke bool +} + +// auditActionKey identifies an audit log action by (service_name, action_name), since +// action_name alone is ambiguous across services (e.g. "delete" also means "cluster terminated"). +type auditActionKey struct { + Service string + Action string +} + +// auditLogActions maps (service_name, action_name) pairs to the resources they affect, per +// https://docs.databricks.com/aws/en/admin/account-settings/audit-logs. +var auditLogActions = map[auditActionKey]auditActionMapping{ + {auditServiceAccounts, "createGroup"}: {resourceType: groupResourceType, idParam: "targetGroupId"}, + // Databricks documents targetUserId as the added/removed member's id on all four of these + // actions, singular and plural alike: https://docs.databricks.com/aws/en/admin/account-settings/audit-logs. + {auditServiceAccounts, "addPrincipalToGroup"}: { + resourceType: groupResourceType, idParam: "targetGroupId", + grant: &auditGrantMapping{entitlement: groupMemberEntitlement, principalParam: "targetUserId"}, + }, + {auditServiceAccounts, "removePrincipalFromGroup"}: { + resourceType: groupResourceType, idParam: "targetGroupId", + grant: &auditGrantMapping{entitlement: groupMemberEntitlement, principalParam: "targetUserId", revoke: true}, + }, + {auditServiceAccounts, "addPrincipalsToGroup"}: { + resourceType: groupResourceType, idParam: "targetGroupId", + grant: &auditGrantMapping{entitlement: groupMemberEntitlement, principalParam: "targetUserId"}, + }, + {auditServiceAccounts, "removePrincipalsFromGroup"}: { + resourceType: groupResourceType, idParam: "targetGroupId", + grant: &auditGrantMapping{entitlement: groupMemberEntitlement, principalParam: "targetUserId", revoke: true}, + }, + {auditServiceAccounts, "removeGroup"}: {resourceType: groupResourceType, idParam: "targetGroupId"}, + {auditServiceAccounts, "updateGroup"}: { + resourceType: groupResourceType, idParam: "targetGroupId", + roleNames: []string{ClusterCreateRole, InstancePoolCreateRole}, + }, + // "add"/"delete" are the real user-lifecycle events; deleteUser is a parameterless PII purge. + {auditServiceAccounts, "add"}: {resourceType: userResourceType, idParam: "targetUserId"}, + {auditServiceAccounts, "updateUser"}: { + resourceType: userResourceType, idParam: "targetUserId", + roleNames: []string{ClusterCreateRole, InstancePoolCreateRole}, + }, + {auditServiceAccounts, "delete"}: {resourceType: userResourceType, idParam: "targetUserId"}, + {auditServiceAccounts, "activateUser"}: {resourceType: userResourceType, idParam: "targetUserId"}, + {auditServiceAccounts, "deactivateUser"}: {resourceType: userResourceType, idParam: "targetUserId"}, + // createServicePrincipal/updateServicePrincipal/deleteServicePrincipal: commented out, not + // real action_name values. Not found anywhere in the official audit log reference (AWS/GCP/ + // Azure) — the only documented service-principal action is changeServicePrincipalAcls. + // {auditServiceAccounts, "createServicePrincipal"}: {resourceType: servicePrincipalResourceType, idParam: "targetServicePrincipalId"}, + // {auditServiceAccounts, "updateServicePrincipal"}: { + // resourceType: servicePrincipalResourceType, idParam: "targetServicePrincipalId", + // roleNames: []string{ClusterCreateRole, InstancePoolCreateRole}, + // }, + // {auditServiceAccounts, "deleteServicePrincipal"}: {resourceType: servicePrincipalResourceType, idParam: "targetServicePrincipalId"}, + {auditServiceAccounts, "changeDatabricksWorkspaceAcl"}: {resourceType: workspaceResourceType, roleNames: []string{WorkspaceAccessRole}}, + {auditServiceAccounts, "changeDatabricksSqlAcl"}: {roleNames: []string{SQLAccessRole}}, + // The reference describes setAdmin as account admin but lists it with the workspace-level + // events, so the account-admin role is refreshed too in case it is. + {auditServiceAccounts, "setAdmin"}: {resourceType: userResourceType, idParam: "targetUserId", accountRole: AccountAdminRole}, + {auditServiceAccounts, "removeAdmin"}: {resourceType: userResourceType, idParam: "targetUserId"}, + {auditServiceAccounts, "setAccountAdmin"}: {resourceType: userResourceType, idParam: "targetUserId", accountRole: AccountAdminRole}, + {auditServiceAccounts, "removeAccountAdmin"}: {resourceType: userResourceType, idParam: "targetUserId", accountRole: AccountAdminRole}, + // Workspace membership (permission assignments). The GCP reference only lists + // updatePermissionAssignment; AWS and Azure list all three. + {auditServiceWorkspace, "addPermissionAssignment"}: {resourceType: workspaceResourceType, workspaceIdParam: "workspace_id"}, + {auditServiceWorkspace, "updatePermissionAssignment"}: {resourceType: workspaceResourceType, workspaceIdParam: "workspace_id"}, + {auditServiceWorkspace, "deletePermissionAssignment"}: {resourceType: workspaceResourceType, workspaceIdParam: "workspace_id"}, +} + +func auditLogActionNames() []string { + seen := make(map[string]struct{}, len(auditLogActions)) + for key := range auditLogActions { + seen[key.Action] = struct{}{} + } + names := make([]string, 0, len(seen)) + for name := range seen { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func auditLogServiceNames() []string { + seen := make(map[string]struct{}, len(auditLogActions)) + for key := range auditLogActions { + seen[key.Service] = struct{}{} + } + names := make([]string, 0, len(seen)) + for name := range seen { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// eventPageCursor is the opaque state persisted between ListEvents calls. (StartAt, +// StartAfterEventID) form a composite boundary: unprocessed rows are those with +// event_time > StartAt, or event_time == StartAt AND event_id > StartAfterEventID. This +// keeps the boundary well-ordered even when many rows share the same event_time. +type eventPageCursor struct { + StartAt time.Time `json:"start_at"` + StartAfterEventID string `json:"start_after_event_id"` +} + +func encodeEventCursor(c eventPageCursor) (string, error) { + b, err := json.Marshal(c) + if err != nil { + return "", fmt.Errorf("failed to marshal event cursor: %w", err) + } + return base64.StdEncoding.EncodeToString(b), nil +} + +// decodeEventCursor returns a zero-value cursor (self-healing to the lookback default) when +// missing, corrupt, or stale. All three are skip-and-continue, not connector bugs, so all three +// log at Debug, not Warn (which C1 surfaces as an incident). +func decodeEventCursor(ctx context.Context, s string, now time.Time) eventPageCursor { + l := ctxzap.Extract(ctx) + + if s == "" { + return eventPageCursor{} + } + + raw, err := base64.StdEncoding.DecodeString(s) + if err != nil { + l.Debug("databricks-connector: corrupt event cursor, resetting to lookback default", zap.Error(err)) + return eventPageCursor{} + } + + var c eventPageCursor + if err := json.Unmarshal(raw, &c); err != nil { + l.Debug("databricks-connector: corrupt event cursor, resetting to lookback default", zap.Error(err)) + return eventPageCursor{} + } + + if !c.StartAt.IsZero() && now.Sub(c.StartAt) > auditLogRetention { + l.Debug("databricks-connector: event cursor is older than the audit log retention window, resetting to lookback default", + zap.Time("cursor_start_at", c.StartAt), + ) + return eventPageCursor{} + } + + return c +} + +type auditLogRow struct { + EventID string + EventTime time.Time + WorkspaceID int64 + ActionName string + ServiceName string + RequestParams map[string]string +} + +type auditEventFeed struct { + client *databricks.Client + workspaces []string + // sqlWarehouseID enables the feed when non-empty; empty means incremental sync is off. + sqlWarehouseID string + + // queryWorkspaceID caches which workspace hosts sqlWarehouseID across polls. It's a discovered value, not provided by configs. + queryWorkspaceMu sync.Mutex + queryWorkspaceID string + + skippedLookupFailures atomic.Int64 +} + +func newAuditEventFeed( + client *databricks.Client, + workspaces []string, + sqlWarehouseID string, +) *auditEventFeed { + return &auditEventFeed{ + client: client, + workspaces: workspaces, + sqlWarehouseID: sqlWarehouseID, + } +} + +// resolveQueryWorkspaceID returns which workspace hosts f.sqlWarehouseID, resolving it +// once via resolveWarehouseWorkspace rather than probing on every poll. allWorkspaces +// must be every workspace in the account, not filtered by the --workspaces allowlist: +// the warehouse can live in a workspace outside that scope (in the same region). +func (f *auditEventFeed) resolveQueryWorkspaceID(ctx context.Context, allWorkspaces []databricks.Workspace) (string, *v2.RateLimitDescription, error) { + f.queryWorkspaceMu.Lock() + defer f.queryWorkspaceMu.Unlock() + + l := ctxzap.Extract(ctx) + if f.queryWorkspaceID != "" { + l.Debug("databricks-connector: resolveQueryWorkspaceID: using cached sql warehouse workspace", + zap.String("warehouse_id", f.sqlWarehouseID), + zap.String("workspace_id", f.queryWorkspaceID), + ) + return f.queryWorkspaceID, nil, nil + } + + id, rateLimit, err := resolveWarehouseWorkspace(ctx, f.client, allWorkspaces, f.sqlWarehouseID) + if err != nil { + return "", rateLimit, err + } + + l.Debug("databricks-connector: resolveQueryWorkspaceID: discovered sql warehouse workspace", + zap.String("warehouse_id", f.sqlWarehouseID), + zap.String("workspace_id", id), + zap.Int("candidate_workspace_count", len(allWorkspaces)), + ) + f.queryWorkspaceID = id + return id, rateLimit, nil +} + +// EventFeedMetadata is registered unconditionally; whether sql-warehouse-id is set gates +// behavior inside ListEvents instead, to avoid confusing "feed not found" errors when it's off. +func (f *auditEventFeed) EventFeedMetadata(_ context.Context) *v2.EventFeedMetadata { + return &v2.EventFeedMetadata{ + Id: auditEventFeedId, + SupportedEventTypes: []v2.EventType{ + v2.EventType_EVENT_TYPE_RESOURCE_CHANGE, + v2.EventType_EVENT_TYPE_CREATE_GRANT, + v2.EventType_EVENT_TYPE_CREATE_REVOKE, + }, + } +} + +// bootstrapStartAt picks the watermark for a feed with no prior cursor. A zero-valued +// earliestEvent (unset) is treated like nil; a real one is clamped to auditLogRetention so a +// very old value can't trigger a full-table scan against system.access.audit. +func bootstrapStartAt(now time.Time, earliestEvent *timestamppb.Timestamp) time.Time { + if earliestEvent == nil || earliestEvent.AsTime().Unix() == 0 { + return now.Add(-auditLogLookback) + } + + start := earliestEvent.AsTime() + if floor := now.Add(-auditLogRetention); start.Before(floor) { + return floor + } + return start +} + +func (f *auditEventFeed) ListEvents( + ctx context.Context, + earliestEvent *timestamppb.Timestamp, + pToken *pagination.StreamToken, +) ([]*v2.Event, *pagination.StreamState, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + annos := annotations.Annotations{} + + if f.sqlWarehouseID == "" { + return nil, &pagination.StreamState{}, nil, nil + } + + now := time.Now() + cursor := decodeEventCursor(ctx, pToken.Cursor, now) + + if cursor.StartAt.IsZero() { + cursor = eventPageCursor{StartAt: bootstrapStartAt(now, earliestEvent)} + } + + // Rows are only ever fetched up to lagCutoff (see queryAuditLog), so slow-indexing rows + // have auditLogTrailingLag to appear before we consider that window done. If the cursor + // is already past it (e.g. right after bootstrap, where the lookback is narrower than the + // lag), there's nothing to query yet. + lagCutoff := now.Add(-auditLogTrailingLag) + l.Debug("databricks-connector: ListEvents: poll started", + zap.Bool("resumed_from_cursor", pToken.Cursor != ""), + zap.Time("window_start", cursor.StartAt), + zap.String("window_start_after_event_id", cursor.StartAfterEventID), + zap.Time("window_end", lagCutoff), + ) + if !cursor.StartAt.Before(lagCutoff) { + l.Debug("databricks-connector: ListEvents: nothing to query yet, window start is within the trailing lag", + zap.Time("window_start", cursor.StartAt), + zap.Time("window_end", lagCutoff), + ) + encoded, err := encodeEventCursor(cursor) + if err != nil { + return nil, nil, nil, fmt.Errorf("databricks-connector: failed to encode event cursor: %w", err) + } + return nil, &pagination.StreamState{Cursor: encoded, HasMore: false}, annos, nil + } + + // ListWorkspaces already applies --databricks-exclude-workspaces. allWorkspaces (not + // narrowed by --workspaces) locates the query warehouse; scopedWorkspaces decides which + // workspaces' audit rows get resolved, so events never surface a workspace a full sync skips. + allWorkspaces, rateLimit, err := f.client.ListWorkspaces(ctx) + if rateLimit != nil { + annos.WithRateLimiting(rateLimit) + } + if err != nil { + return nil, nil, annos, fmt.Errorf("databricks-connector: failed to list workspaces: %w", err) + } + scopedWorkspaces := filterConfiguredWorkspaces(allWorkspaces, f.workspaces) + l.Debug("databricks-connector: ListEvents: listed workspaces", + zap.Int("workspace_count", len(allWorkspaces)), + zap.Int("in_scope_workspace_count", len(scopedWorkspaces)), + ) + if len(scopedWorkspaces) == 0 { + return nil, nil, annos, uhttp.WrapErrors(codes.FailedPrecondition, "databricks-connector: no workspace available to query system.access.audit") + } + + workspaceLookup := make(map[int64]string, len(scopedWorkspaces)) + for _, w := range scopedWorkspaces { + workspaceLookup[int64(w.ID)] = w.DeploymentName + } + + queryWorkspaceId, rateLimit, err := f.resolveQueryWorkspaceID(ctx, allWorkspaces) + if rateLimit != nil { + annos.WithRateLimiting(rateLimit) + } + if err != nil { + return nil, nil, annos, err + } + + rows, rawRowCount, lastRawBoundary, rateLimit, err := f.queryAuditLog(ctx, queryWorkspaceId, cursor, lagCutoff) + if err != nil { + if rateLimit != nil { + annos.WithRateLimiting(rateLimit) + } + return nil, nil, annos, fmt.Errorf("databricks-connector: failed to query audit log: %w", err) + } + + if rateLimit != nil { + annos.WithRateLimiting(rateLimit) + } + + var events []*v2.Event + var unmappedRows, skippedLookupRows, resourceChangeEvents, grantEvents, revokeEvents int + principalTypes := principalTypeCache{} + for _, row := range rows { + affected, rowRateLimit, err := mapAuditRowToResource(ctx, f.client, principalTypes, row, f.client.GetAccountId(), workspaceLookup) + if rowRateLimit != nil { + annos.WithRateLimiting(rowRateLimit) + } + // Unmappable rows come back as no error, so this is a real lookup failure. Transient + // ones are returned so the next poll retries the row; others would block the cursor + // on every poll, so they're skipped and left to the next full sync. + if err != nil { + if isRetryableLookupError(err) { + return nil, nil, annos, fmt.Errorf("databricks-connector: failed to map audit row %s (%s): %w", row.EventID, row.ActionName, err) + } + if count := f.skippedLookupFailures.Add(1); count == 1 || count == 10 || count == 100 || count%1000 == 0 { + l.Warn("databricks-connector: skipping audit row, principal lookup failed", + zap.String("action_name", row.ActionName), + zap.String("event_id", row.EventID), + zap.Int64("total_occurrences", count), + zap.Error(err), + ) + } + skippedLookupRows++ + continue + } + if len(affected) == 0 { + unmappedRows++ + l.Debug("databricks-connector: skipping audit row with no resource mapping", + zap.String("action_name", row.ActionName), + zap.String("event_id", row.EventID), + ) + continue + } + + l.Debug("databricks-connector: ListEvents: mapped audit row to events", + zap.String("event_id", row.EventID), + zap.String("service_name", row.ServiceName), + zap.String("action_name", row.ActionName), + zap.Int("event_count", len(affected)), + ) + for i, a := range affected { + event := &v2.Event{ + Id: fmt.Sprintf("%s/%d", row.EventID, i), + OccurredAt: timestamppb.New(row.EventTime), + } + switch { + case a.grant != nil && a.grant.revoke: + revokeEvents++ + event.Event = &v2.Event_CreateRevokeEvent{ + CreateRevokeEvent: &v2.CreateRevokeEvent{Entitlement: a.grant.entitlement, Principal: a.grant.principal}, + } + case a.grant != nil: + grantEvents++ + event.Event = &v2.Event_CreateGrantEvent{ + CreateGrantEvent: &v2.CreateGrantEvent{Entitlement: a.grant.entitlement, Principal: a.grant.principal}, + } + default: + resourceChangeEvents++ + event.Event = &v2.Event_ResourceChangeEvent{ + ResourceChangeEvent: &v2.ResourceChangeEvent{ResourceId: a.resourceId, ParentResourceId: a.parentResourceId}, + } + } + events = append(events, event) + } + } + + hasMore := rawRowCount >= auditLogPageLimit + nextCursor, advanced := advanceEventCursor(cursor, rows, lastRawBoundary, hasMore, lagCutoff) + if !advanced { + hasMore = false + } + + encoded, err := encodeEventCursor(nextCursor) + if err != nil { + return nil, nil, annos, fmt.Errorf("databricks-connector: failed to encode event cursor: %w", err) + } + + l.Debug("databricks-connector: ListEvents: poll finished", + zap.Int("audit_rows_fetched", rawRowCount), + zap.Int("audit_rows_parsed", len(rows)), + zap.Int("audit_rows_unmapped", unmappedRows), + zap.Int("audit_rows_skipped_lookup_failure", skippedLookupRows), + zap.Int("events_total", len(events)), + zap.Int("events_resource_change", resourceChangeEvents), + zap.Int("events_create_grant", grantEvents), + zap.Int("events_create_revoke", revokeEvents), + zap.Bool("has_more", hasMore), + zap.Time("next_window_start", nextCursor.StartAt), + zap.String("next_window_start_after_event_id", nextCursor.StartAfterEventID), + ) + return events, &pagination.StreamState{Cursor: encoded, HasMore: hasMore}, annos, nil +} + +func isRetryableLookupError(err error) bool { + switch status.Code(err) { + case codes.Unavailable, codes.ResourceExhausted, codes.DeadlineExceeded, codes.Internal, codes.Unknown, codes.Canceled: + return true + default: + return false + } +} + +// advanceEventCursor advances to the last row processed while more of the page remains, or +// to lagCutoff once drained: rows are already bounded by lagCutoff (see queryAuditLog), so a +// drained page proves nothing else exists up to that point. Prefers lastRawBoundary (from the +// raw page, so a page where every row fails to parse still advances), falling back to the last +// successfully parsed row. advanced is false only if neither is available, so the caller can +// avoid reporting HasMore with an unchanged cursor. +func advanceEventCursor(cursor eventPageCursor, rows []auditLogRow, lastRawBoundary eventPageCursor, hasMore bool, lagCutoff time.Time) (eventPageCursor, bool) { + if hasMore { + if !lastRawBoundary.StartAt.IsZero() { + return lastRawBoundary, true + } + if len(rows) > 0 { + last := rows[len(rows)-1] + return eventPageCursor{StartAt: last.EventTime, StartAfterEventID: last.EventID}, true + } + return cursor, false + } + return eventPageCursor{StartAt: lagCutoff}, true +} + +// affectedResource is either one resource a mapped audit row's action changed (RESOURCE_CHANGE), +// or, when grant is set, the entitlement/principal it assigned or revoked (CREATE_GRANT/CREATE_REVOKE). +type affectedResource struct { + resourceId *v2.ResourceId + parentResourceId *v2.ResourceId + grant *affectedGrant +} + +type affectedGrant struct { + entitlement *v2.Entitlement + principal *v2.Resource + revoke bool +} + +// mapAuditRowToResource maps an audit row to every Baton resource its action affects, skipping +// anything unresolvable. Principals are always account-parented, mirroring full sync, regardless +// of the workspace the audit row occurred in. +func mapAuditRowToResource( + ctx context.Context, + client *databricks.Client, + principalTypes principalTypeCache, + row auditLogRow, + accountId string, + workspaceLookup map[int64]string, +) ([]affectedResource, *v2.RateLimitDescription, error) { + mapping, ok := auditLogActions[auditActionKey{Service: row.ServiceName, Action: row.ActionName}] + if !ok { + return nil, nil, nil + } + + accountParent := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: accountId} + + workspaceId := row.WorkspaceID + if workspaceId == 0 && mapping.workspaceIdParam != "" { + // An unparseable value leaves workspaceId at 0, so the row is skipped below. + if parsed, err := strconv.ParseInt(row.RequestParams[mapping.workspaceIdParam], 10, 64); err == nil { + workspaceId = parsed + } + } + + var workspaceParent *v2.ResourceId + if workspaceId != 0 { + deploymentName, found := workspaceLookup[workspaceId] + if !found { + return nil, nil, nil + } + workspaceParent = &v2.ResourceId{ResourceType: workspaceResourceType.Id, Resource: deploymentName} + } + + var affected []affectedResource + + switch { + case mapping.resourceType == workspaceResourceType: + if workspaceParent == nil { + return nil, nil, nil + } + affected = append(affected, affectedResource{resourceId: workspaceParent, parentResourceId: accountParent}) + case mapping.resourceType != nil: + nativeId, ok := row.RequestParams[mapping.idParam] + if !ok || nativeId == "" { + return nil, nil, nil + } + + resourceId := &v2.ResourceId{ResourceType: mapping.resourceType.Id, Resource: nativeId} + if mapping.resourceType == groupResourceType { + resourceId.Resource = groupResourceId(ctx, nativeId, accountParent) + } + + if mapping.grant != nil { + g, rateLimit, err := buildAffectedGrant(ctx, client, principalTypes, row, mapping.grant, resourceId, accountParent) + if err != nil { + return nil, rateLimit, fmt.Errorf("databricks-connector: failed to resolve grant principal for event %s: %w", row.EventID, err) + } + if g == nil { + // No resolvable principal: still refresh the group's grants. + return []affectedResource{{resourceId: resourceId, parentResourceId: accountParent}}, rateLimit, nil + } + return []affectedResource{{grant: g}}, rateLimit, nil + } + + affected = append(affected, affectedResource{resourceId: resourceId, parentResourceId: accountParent}) + } + + if mapping.accountRole != "" { + affected = append(affected, affectedResource{ + resourceId: &v2.ResourceId{ResourceType: roleResourceType.Id, Resource: roleResourceId(mapping.accountRole, accountParent)}, + parentResourceId: accountParent, + }) + } + + if workspaceParent != nil { + for _, roleName := range mapping.roleNames { + affected = append(affected, affectedResource{ + resourceId: &v2.ResourceId{ResourceType: roleResourceType.Id, Resource: roleResourceId(roleName, workspaceParent)}, + parentResourceId: workspaceParent, + }) + } + } + + return affected, nil, nil +} + +// buildAffectedGrant builds the entitlement (on entitlementResourceId/parent) and principal a +// grant-mapped row names, skipping rows missing the principal's native id. +func buildAffectedGrant( + ctx context.Context, + client *databricks.Client, + principalTypes principalTypeCache, + row auditLogRow, + gm *auditGrantMapping, + entitlementResourceId, parent *v2.ResourceId, +) (*affectedGrant, *v2.RateLimitDescription, error) { + principalNativeId, ok := row.RequestParams[gm.principalParam] + if !ok || principalNativeId == "" { + return nil, nil, nil + } + + principalType, rateLimit, err := resolvePrincipalType(ctx, client, principalTypes, principalNativeId) + if err != nil { + return nil, rateLimit, err + } + if principalType == nil { + return nil, rateLimit, nil + } + + principalResourceId := principalNativeId + if principalType == groupResourceType { + principalResourceId = groupResourceId(ctx, principalNativeId, parent) + } + + entitlementResource := &v2.Resource{Id: entitlementResourceId, ParentResourceId: parent} + return &affectedGrant{ + entitlement: ent.NewAssignmentEntitlement(entitlementResource, gm.entitlement), + principal: &v2.Resource{Id: &v2.ResourceId{ResourceType: principalType.Id, Resource: principalResourceId}}, + revoke: gm.revoke, + }, rateLimit, nil +} + +// principalTypeCache memoizes resolvePrincipalType for one ListEvents call, so a principal +// named on many rows is looked up once. A nil value means the id matched no principal. +type principalTypeCache map[string]*v2.ResourceType + +// resolvePrincipalType finds which resource type nativeId belongs to, trying user, group, then +// service principal in that order via the Account API and stopping at the first match: the audit log's +// targetUserId doesn't record which kind of principal it actually is. Returns a nil type (no +// error) if nativeId doesn't match any of the three. Results, including no match, are cached +// in principalTypes; errors aren't. +func resolvePrincipalType(ctx context.Context, client *databricks.Client, principalTypes principalTypeCache, nativeId string) (*v2.ResourceType, *v2.RateLimitDescription, error) { + if resourceType, ok := principalTypes[nativeId]; ok { + return resourceType, nil, nil + } + + candidates := []struct { + resourceType *v2.ResourceType + found func() (bool, *v2.RateLimitDescription, error) + }{ + {userResourceType, func() (bool, *v2.RateLimitDescription, error) { + return principalExists(client.GetUser(ctx, "", nativeId)) + }}, + {groupResourceType, func() (bool, *v2.RateLimitDescription, error) { + return principalExists(client.GetGroup(ctx, "", nativeId)) + }}, + {servicePrincipalResourceType, func() (bool, *v2.RateLimitDescription, error) { + return principalExists(client.GetServicePrincipal(ctx, "", nativeId)) + }}, + } + + var rateLimit *v2.RateLimitDescription + for _, candidate := range candidates { + found, rl, err := candidate.found() + if rl != nil { + rateLimit = rl + } + if err != nil { + return nil, rateLimit, fmt.Errorf("failed to check principal %s: %w", nativeId, err) + } + if found { + ctxzap.Extract(ctx).Debug("databricks-connector: resolvePrincipalType: resolved audit principal", + zap.String("principal_id", nativeId), + zap.String("resource_type", candidate.resourceType.Id), + ) + principalTypes[nativeId] = candidate.resourceType + return candidate.resourceType, rateLimit, nil + } + } + + ctxzap.Extract(ctx).Debug("databricks-connector: resolvePrincipalType: audit principal matched no user, group or service principal", + zap.String("principal_id", nativeId), + ) + principalTypes[nativeId] = nil + return nil, rateLimit, nil +} + +// principalExists adapts a client Get* call into a found/not-found result: a 404 means not +// found (not an error), anything else is a real failure. +func principalExists[T any](_ T, rateLimit *v2.RateLimitDescription, err error) (bool, *v2.RateLimitDescription, error) { + if err == nil { + return true, rateLimit, nil + } + var apiErr *databricks.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return false, rateLimit, nil + } + return false, rateLimit, err +} + +// filterConfiguredWorkspaces narrows workspaces to configuredWorkspaces (the --workspaces +// allowlist), or returns workspaces unchanged when the allowlist is empty. This scopes +// which workspaces' audit rows get resolved to resources — it must NOT be applied before +// locating the query warehouse (resolveWarehouseWorkspace), which can live in any workspace +// in the account regardless of this allowlist. +func filterConfiguredWorkspaces(workspaces []databricks.Workspace, configuredWorkspaces []string) []databricks.Workspace { + if len(configuredWorkspaces) == 0 { + return workspaces + } + + configured := make(map[string]struct{}, len(configuredWorkspaces)) + for _, name := range configuredWorkspaces { + configured[name] = struct{}{} + } + + filtered := make([]databricks.Workspace, 0, len(workspaces)) + for _, w := range workspaces { + if _, ok := matchConfiguredWorkspace(configured, w.DeploymentName, w.Name, strconv.Itoa(w.ID)); ok { + filtered = append(filtered, w) + } + } + + return filtered +} + +// resolveWarehouseWorkspace finds which workspace hosts warehouseId by probing each +// candidate workspace, since Databricks has no account-level lookup for this. +func resolveWarehouseWorkspace(ctx context.Context, client *databricks.Client, workspaces []databricks.Workspace, warehouseId string) (string, *v2.RateLimitDescription, error) { + var rateLimit *v2.RateLimitDescription + var inaccessible []string + for _, w := range workspaces { + found, rl, err := client.WarehouseExists(ctx, w.DeploymentName, warehouseId) + if rl != nil { + rateLimit = rl + } + if err != nil { + // The service principal needn't be assigned to every workspace; keep looking. + if isAccessDenied(err) { + inaccessible = append(inaccessible, w.DeploymentName) + continue + } + return "", rateLimit, fmt.Errorf( + "databricks-connector: failed to check workspace %s for sql-warehouse-id %s: %w", + w.DeploymentName, warehouseId, err, + ) + } + if found { + return w.DeploymentName, rateLimit, nil + } + } + + skipped := "" + if len(inaccessible) > 0 { + skipped = fmt.Sprintf(" (skipped %d inaccessible: %s)", len(inaccessible), strings.Join(inaccessible, ", ")) + } + if len(workspaces) == 1 { + return "", rateLimit, uhttp.WrapErrors(codes.NotFound, fmt.Sprintf( + "databricks-connector: sql-warehouse-id %q was not found in workspace %s%s", + warehouseId, workspaces[0].DeploymentName, skipped, + )) + } + return "", rateLimit, uhttp.WrapErrors(codes.NotFound, fmt.Sprintf( + "databricks-connector: sql-warehouse-id %q was not found in any of the %d available workspaces%s", + warehouseId, len(workspaces), skipped, + )) +} + +// isAccessDenied reports whether err is a 401/403 from the Databricks API. +func isAccessDenied(err error) bool { + var apiErr *databricks.APIError + return errors.As(err, &apiErr) && + (apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden) +} + +// queryAuditLog returns rows in (cursor, lagCutoff], plus the raw row count (before +// parseAuditLogRows may drop malformed ones) so callers can tell if the page was full. +func (f *auditEventFeed) queryAuditLog(ctx context.Context, workspaceId string, cursor eventPageCursor, lagCutoff time.Time) ([]auditLogRow, int, eventPageCursor, *v2.RateLimitDescription, error) { + // The (event_time, event_id) tiebreaker keeps ordering deterministic and lets us page + // with a composite > predicate, so progress never stalls even if many rows share one + // event_time (see advanceEventCursor); this holds as long as event_id compares consistently + // under Databricks SQL's ">"/ORDER BY, which is true for this table's opaque IDs. + statement := fmt.Sprintf(` + SELECT event_id, event_time, workspace_id, action_name, service_name, request_params + FROM system.access.audit + WHERE event_date >= :start_date + AND (event_time > :start_time OR (event_time = :start_time AND event_id > :start_after_event_id)) + AND event_time <= :lag_cutoff + AND service_name IN (%s) + AND action_name IN (%s) + ORDER BY event_time ASC, event_id ASC + LIMIT %d + `, quotedInClause(auditLogServiceNames()), quotedInClause(auditLogActionNames()), auditLogPageLimit) + + result, rateLimit, err := f.client.ExecuteStatement( + ctx, + workspaceId, + f.sqlWarehouseID, + statement, + databricks.StatementParameter{Name: "start_date", Value: cursor.StartAt.UTC().Format("2006-01-02"), Type: "DATE"}, + // RFC3339Nano, not RFC3339: the (event_time, event_id) tiebreaker needs start_time + // to round-trip at the same sub-second precision parseAuditLogRows parses. + databricks.StatementParameter{Name: "start_time", Value: cursor.StartAt.UTC().Format(time.RFC3339Nano), Type: "TIMESTAMP"}, + databricks.StatementParameter{Name: "start_after_event_id", Value: cursor.StartAfterEventID, Type: "STRING"}, + databricks.StatementParameter{Name: "lag_cutoff", Value: lagCutoff.UTC().Format(time.RFC3339Nano), Type: "TIMESTAMP"}, + ) + if err != nil { + return nil, 0, eventPageCursor{}, rateLimit, err + } + + rows, lastRawBoundary, err := parseAuditLogRows(ctx, result) + if err != nil { + return nil, len(result.Rows), lastRawBoundary, rateLimit, err + } + + ctxzap.Extract(ctx).Debug("databricks-connector: queryAuditLog: audit log query succeeded", + zap.String("workspace_id", workspaceId), + zap.String("warehouse_id", f.sqlWarehouseID), + zap.Int("audit_rows_fetched", len(result.Rows)), + zap.Int("audit_rows_parsed", len(rows)), + zap.Int("audit_rows_malformed", len(result.Rows)-len(rows)), + ) + return rows, len(result.Rows), lastRawBoundary, rateLimit, nil +} + +func quotedInClause(values []string) string { + quoted := make([]string, len(values)) + for i, v := range values { + quoted[i] = "'" + v + "'" + } + + return strings.Join(quoted, ", ") +} + +const ( + colEventID = "event_id" + colEventTime = "event_time" + colWorkspaceID = "workspace_id" + colActionName = "action_name" + colServiceName = "service_name" + colRequestParams = "request_params" +) + +// parseAuditLogRows skips (and logs) any individual row that fails to parse instead of +// failing the whole page; a missing expected column is a schema problem, so that still fails. +// lastRawBoundary is the last row's (event_time, event_id) regardless of whether the rest of +// that row parsed, so a page where every row fails can still page forward. +func parseAuditLogRows(ctx context.Context, result *databricks.StatementResult) ([]auditLogRow, eventPageCursor, error) { + colIndex := make(map[string]int, len(result.Columns)) + for i, name := range result.Columns { + colIndex[name] = i + } + + maxColIndex := 0 + for _, name := range []string{colEventID, colEventTime, colWorkspaceID, colActionName, colServiceName, colRequestParams} { + idx, ok := colIndex[name] + if !ok { + return nil, eventPageCursor{}, fmt.Errorf("audit log query result missing column %q", name) + } + if idx > maxColIndex { + maxColIndex = idx + } + } + + l := ctxzap.Extract(ctx) + + rows := make([]auditLogRow, 0, len(result.Rows)) + for _, r := range result.Rows { + row, err := parseAuditLogRow(r, colIndex, maxColIndex) + if err != nil { + l.Debug("databricks-connector: skipping malformed audit log row", zap.Error(err)) + continue + } + rows = append(rows, row) + } + + var lastRawBoundary eventPageCursor + if len(result.Rows) > 0 { + var err error + lastRawBoundary, err = parseEventBoundary(result.Rows[len(result.Rows)-1], colIndex) + if err != nil { + l.Debug("databricks-connector: failed to parse last raw row's event boundary", zap.Error(err)) + } + } + + return rows, lastRawBoundary, nil +} + +// parseEventBoundary extracts just event_id/event_time from a raw row, tolerating failures +// elsewhere in the row (e.g. malformed request_params) that would fail parseAuditLogRow. +func parseEventBoundary(r []string, colIndex map[string]int) (eventPageCursor, error) { + idIdx, timeIdx := colIndex[colEventID], colIndex[colEventTime] + if len(r) <= idIdx || len(r) <= timeIdx { + return eventPageCursor{}, fmt.Errorf("audit log query result row has %d columns, expected event_id/event_time", len(r)) + } + + eventTime, err := time.Parse("2006-01-02 15:04:05.999", r[timeIdx]) + if err != nil { + eventTime, err = time.Parse(time.RFC3339, r[timeIdx]) + if err != nil { + return eventPageCursor{}, fmt.Errorf("failed to parse event_time %q: %w", r[timeIdx], err) + } + } + + return eventPageCursor{StartAt: eventTime, StartAfterEventID: r[idIdx]}, nil +} + +func parseAuditLogRow(r []string, colIndex map[string]int, maxColIndex int) (auditLogRow, error) { + if len(r) <= maxColIndex { + return auditLogRow{}, fmt.Errorf("audit log query result row has %d columns, expected at least %d", len(r), maxColIndex+1) + } + + boundary, err := parseEventBoundary(r, colIndex) + if err != nil { + return auditLogRow{}, err + } + + var workspaceId int64 + if v := r[colIndex[colWorkspaceID]]; v != "" { + workspaceId, err = strconv.ParseInt(v, 10, 64) + if err != nil { + return auditLogRow{}, fmt.Errorf("failed to parse workspace_id %q: %w", v, err) + } + } + + requestParams := map[string]string{} + if v := r[colIndex[colRequestParams]]; v != "" { + if err := json.Unmarshal([]byte(v), &requestParams); err != nil { + return auditLogRow{}, fmt.Errorf("failed to parse request_params %q: %w", v, err) + } + } + + return auditLogRow{ + EventID: boundary.StartAfterEventID, + EventTime: boundary.StartAt, + WorkspaceID: workspaceId, + ActionName: r[colIndex[colActionName]], + ServiceName: r[colIndex[colServiceName]], + RequestParams: requestParams, + }, nil +} diff --git a/pkg/connector/audit_event_feed_test.go b/pkg/connector/audit_event_feed_test.go new file mode 100644 index 00000000..60ad8572 --- /dev/null +++ b/pkg/connector/audit_event_feed_test.go @@ -0,0 +1,1467 @@ +package connector + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/conductorone/baton-databricks/pkg/databricks" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/pagination" + ent "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// testEarliestEvent starts a fresh cursor before lagCutoff, so ListEvents' bootstrap +// doesn't hit the guard for cursor.StartAt >= lagCutoff (see TestListEventsSkipsQueryPastLagCutoff). +func testEarliestEvent() *timestamppb.Timestamp { + return timestamppb.New(time.Now().Add(-2 * auditLogTrailingLag)) +} + +func TestBootstrapStartAt(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + cases := []struct { + name string + earliestEvent *timestamppb.Timestamp + want time.Time + }{ + {"nil earliestEvent uses the lookback default", nil, now.Add(-auditLogLookback)}, + {"zero-valued earliestEvent is treated like nil", timestamppb.New(time.Unix(0, 0)), now.Add(-auditLogLookback)}, + {"recent earliestEvent is used as-is", timestamppb.New(now.Add(-time.Hour)), now.Add(-time.Hour)}, + {"earliestEvent older than retention is clamped", timestamppb.New(now.AddDate(-2, 0, 0)), now.Add(-auditLogRetention)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := bootstrapStartAt(now, tc.earliestEvent); !got.Equal(tc.want) { + t.Errorf("bootstrapStartAt() = %v, want %v", got, tc.want) + } + }) + } +} + +// writeJSONNotFound writes a 404 with a JSON body; a plain-text one (e.g. http.NotFound) +// breaks the client's JSON decoder. +func writeJSONNotFound(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error_code":"RESOURCE_DOES_NOT_EXIST","message":"not found"}`)) +} + +// newProbeTestClient builds a databricks.Client whose requests are redirected to a local +// httptest.Server running handler (see redirectTransport). +func newProbeTestClient(t *testing.T, handler http.HandlerFunc) *databricks.Client { + t.Helper() + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + target, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + + httpClient := &http.Client{Transport: &redirectTransport{target: target}} + auth := &databricks.NoAuth{} + client, err := databricks.NewClient(context.Background(), httpClient, "example.cloud.databricks.com", "accounts.cloud.databricks.com", "acct-1", "", auth, nil) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + return client +} + +func TestResolveWarehouseWorkspace(t *testing.T) { + const warehouseId = "wh-123" + + t.Run("single workspace is still probed", func(t *testing.T) { + probed := false + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + probed = true + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":%q}`, warehouseId) + }) + workspaces := []databricks.Workspace{{ID: 1, DeploymentName: "dbc-only"}} + + got, _, err := resolveWarehouseWorkspace(context.Background(), client, workspaces, warehouseId) + if err != nil { + t.Fatalf("resolveWarehouseWorkspace() error = %v", err) + } + if got != "dbc-only" { + t.Errorf("got %q, want %q", got, "dbc-only") + } + if !probed { + t.Error("WarehouseExists was never called; a single workspace must still be probed so a bad sql-warehouse-id is caught with a clear error") + } + }) + + t.Run("single workspace missing the warehouse gets a specific not-found error", func(t *testing.T) { + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + writeJSONNotFound(w) + }) + workspaces := []databricks.Workspace{{ID: 1, DeploymentName: "dbc-only"}} + + _, _, err := resolveWarehouseWorkspace(context.Background(), client, workspaces, warehouseId) + if err == nil { + t.Fatal("resolveWarehouseWorkspace() error = nil, want error when the single workspace doesn't have the warehouse") + } + wantMsg := `sql-warehouse-id "wh-123" was not found in workspace dbc-only` + if !strings.Contains(err.Error(), wantMsg) { + t.Errorf("error = %q, want it to contain %q", err.Error(), wantMsg) + } + }) + + t.Run("probes each workspace until the warehouse is found", func(t *testing.T) { + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.Header.Get("X-Test-Original-Host"), "dbc-bbb.") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":%q}`, warehouseId) + return + } + writeJSONNotFound(w) + }) + workspaces := []databricks.Workspace{ + {ID: 1, DeploymentName: "dbc-aaa"}, + {ID: 2, DeploymentName: "dbc-bbb"}, + } + + got, _, err := resolveWarehouseWorkspace(context.Background(), client, workspaces, warehouseId) + if err != nil { + t.Fatalf("resolveWarehouseWorkspace() error = %v", err) + } + if got != "dbc-bbb" { + t.Errorf("got %q, want %q", got, "dbc-bbb") + } + }) + + t.Run("warehouse not found anywhere is a clear error", func(t *testing.T) { + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + writeJSONNotFound(w) + }) + workspaces := []databricks.Workspace{ + {ID: 1, DeploymentName: "dbc-aaa"}, + {ID: 2, DeploymentName: "dbc-bbb"}, + } + + _, _, err := resolveWarehouseWorkspace(context.Background(), client, workspaces, warehouseId) + if err == nil { + t.Fatal("resolveWarehouseWorkspace() error = nil, want error when no workspace has the warehouse") + } + }) + + t.Run("an inaccessible workspace is skipped, not fatal", func(t *testing.T) { + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.Header.Get("X-Test-Original-Host"), "dbc-aaa.") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"not assigned to workspace"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":%q}`, warehouseId) + }) + workspaces := []databricks.Workspace{ + {ID: 1, DeploymentName: "dbc-aaa"}, + {ID: 2, DeploymentName: "dbc-bbb"}, + } + + got, _, err := resolveWarehouseWorkspace(context.Background(), client, workspaces, warehouseId) + if err != nil { + t.Fatalf("resolveWarehouseWorkspace() error = %v", err) + } + if got != "dbc-bbb" { + t.Errorf("got %q, want %q", got, "dbc-bbb") + } + }) + + t.Run("all workspaces inaccessible is a not-found naming them", func(t *testing.T) { + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"unauthorized"}`)) + }) + workspaces := []databricks.Workspace{ + {ID: 1, DeploymentName: "dbc-aaa"}, + {ID: 2, DeploymentName: "dbc-bbb"}, + } + + _, _, err := resolveWarehouseWorkspace(context.Background(), client, workspaces, warehouseId) + if got := status.Code(err); got != codes.NotFound { + t.Fatalf("error code = %s (%v), want %s", got, err, codes.NotFound) + } + if !strings.Contains(err.Error(), "skipped 2 inaccessible: dbc-aaa, dbc-bbb") { + t.Errorf("error = %q, want it to name the skipped workspaces", err.Error()) + } + }) + + t.Run("a real error from a probe is returned, not swallowed as not-found", func(t *testing.T) { + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"boom"}`)) + }) + workspaces := []databricks.Workspace{ + {ID: 1, DeploymentName: "dbc-aaa"}, + {ID: 2, DeploymentName: "dbc-bbb"}, + } + + _, _, err := resolveWarehouseWorkspace(context.Background(), client, workspaces, warehouseId) + if err == nil { + t.Fatal("resolveWarehouseWorkspace() error = nil, want a propagated probe error") + } + }) +} + +func TestEventCursorRoundTrip(t *testing.T) { + want := eventPageCursor{ + StartAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + StartAfterEventID: "b", + } + + encoded, err := encodeEventCursor(want) + if err != nil { + t.Fatalf("encodeEventCursor() error = %v", err) + } + + got := decodeEventCursor(context.Background(), encoded, want.StartAt) + if !got.StartAt.Equal(want.StartAt) || got.StartAfterEventID != want.StartAfterEventID { + t.Errorf("decodeEventCursor() = %+v, want %+v", got, want) + } +} + +func TestDecodeEventCursorSelfHeals(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + cases := []string{"", "not-base64!!!", "aW52YWxpZC1qc29u"} // last one is base64("invalid-json") + for _, c := range cases { + got := decodeEventCursor(context.Background(), c, now) + if !got.StartAt.IsZero() { + t.Errorf("decodeEventCursor(%q) = %+v, want zero-value cursor", c, got) + } + } +} + +// TestDecodeEventCursorResetsStaleCursor covers a valid cursor whose StartAt has aged past +// the audit log retention window, which must self-heal like a corrupt cursor. +func TestDecodeEventCursorResetsStaleCursor(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + stale := eventPageCursor{StartAt: now.Add(-auditLogRetention - time.Hour), StartAfterEventID: "x"} + encoded, err := encodeEventCursor(stale) + if err != nil { + t.Fatalf("encodeEventCursor() error = %v", err) + } + + got := decodeEventCursor(context.Background(), encoded, now) + if !got.StartAt.IsZero() || got.StartAfterEventID != "" { + t.Errorf("decodeEventCursor() = %+v, want zero-value cursor for a stale StartAt", got) + } + + fresh := eventPageCursor{StartAt: now.Add(-auditLogRetention + time.Hour), StartAfterEventID: "y"} + encoded, err = encodeEventCursor(fresh) + if err != nil { + t.Fatalf("encodeEventCursor() error = %v", err) + } + + got = decodeEventCursor(context.Background(), encoded, now) + if !got.StartAt.Equal(fresh.StartAt) || got.StartAfterEventID != fresh.StartAfterEventID { + t.Errorf("decodeEventCursor() = %+v, want unchanged %+v (within retention)", got, fresh) + } +} + +func TestAdvanceEventCursorAdvancesToLastRawBoundary(t *testing.T) { + cursor := eventPageCursor{StartAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} + lastRawBoundary := eventPageCursor{StartAt: cursor.StartAt.Add(time.Minute), StartAfterEventID: "evt-999"} + + next, advanced := advanceEventCursor(cursor, nil, lastRawBoundary, true, cursor.StartAt.Add(auditLogTrailingLag)) + + if !advanced || next != lastRawBoundary { + t.Errorf("next = %+v, advanced = %v, want %+v, true", next, advanced, lastRawBoundary) + } +} + +// TestAdvanceEventCursorFallsBackToLastParsedRow covers a full page where only the last raw +// row fails to parse: lastRawBoundary is zero-value, but the other rows parsed fine and must +// still advance the cursor instead of stalling on a page that otherwise made progress. +func TestAdvanceEventCursorFallsBackToLastParsedRow(t *testing.T) { + cursor := eventPageCursor{StartAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} + rows := []auditLogRow{ + {EventID: "evt-1", EventTime: cursor.StartAt.Add(time.Minute)}, + {EventID: "evt-2", EventTime: cursor.StartAt.Add(2 * time.Minute)}, + } + + next, advanced := advanceEventCursor(cursor, rows, eventPageCursor{}, true, cursor.StartAt.Add(auditLogTrailingLag)) + + want := eventPageCursor{StartAt: rows[1].EventTime, StartAfterEventID: rows[1].EventID} + if !advanced || next != want { + t.Errorf("next = %+v, advanced = %v, want %+v, true", next, advanced, want) + } +} + +// TestAdvanceEventCursorFullyMalformedPageDoesNotStall covers a full page where every row +// failed to parse: neither lastRawBoundary nor rows can advance the cursor, so this must +// signal advanced=false (letting the caller stop claiming HasMore) instead of looping forever +// on an unchanged cursor. It also must not panic indexing an empty rows slice. +func TestAdvanceEventCursorFullyMalformedPageDoesNotStall(t *testing.T) { + cursor := eventPageCursor{StartAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), StartAfterEventID: "evt-1"} + + next, advanced := advanceEventCursor(cursor, nil, eventPageCursor{}, true, cursor.StartAt.Add(auditLogTrailingLag)) + + if advanced || next != cursor { + t.Errorf("next = %+v, advanced = %v, want unchanged cursor %+v, false", next, advanced, cursor) + } +} + +// TestAdvanceEventCursorDrainedJumpsToLagCutoff verifies a drained page advances straight to +// lagCutoff: rows are already bounded by it (see queryAuditLog), so draining proves nothing +// else exists up to that point. +func TestAdvanceEventCursorDrainedJumpsToLagCutoff(t *testing.T) { + cursor := eventPageCursor{StartAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} + lagCutoff := cursor.StartAt.Add(4 * time.Hour) + + next, advanced := advanceEventCursor(cursor, nil, eventPageCursor{}, false, lagCutoff) + + if !advanced || !next.StartAt.Equal(lagCutoff) || next.StartAfterEventID != "" { + t.Errorf("next = %+v, advanced = %v, want StartAt=%v with no tiebreaker, true", next, advanced, lagCutoff) + } +} + +func TestMapAuditRowToResource(t *testing.T) { + // None of these cases are grant-mapped, so mapAuditRowToResource should never reach the + // principal-type lookup; a client that fails any request proves that. + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request %s %s: no case here is grant-mapped", r.Method, r.URL.Path) + }) + workspaceLookup := map[int64]string{123: "my-workspace"} + accountId := "acct-1" + + accountParent := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: accountId} + workspaceParent := &v2.ResourceId{ResourceType: workspaceResourceType.Id, Resource: "my-workspace"} + + type wantResource struct { + resourceType string + resource string + parentType string + parentID string + } + + cases := []struct { + name string + row auditLogRow + want []wantResource + }{ + { + name: "account-level group create", + row: auditLogRow{ + ActionName: "createGroup", + ServiceName: "accounts", + WorkspaceID: 0, + RequestParams: map[string]string{"targetGroupId": "g-1"}, + }, + want: []wantResource{ + {groupResourceType.Id, groupResourceId(context.Background(), "g-1", accountParent), accountResourceType.Id, accountId}, + }, + }, + { + name: "workspace-scoped group change stays account-parented", + row: auditLogRow{ + ActionName: "removeGroup", + ServiceName: "accounts", + WorkspaceID: 123, + RequestParams: map[string]string{"targetGroupId": "g-1"}, + }, + want: []wantResource{ + // Groups are only ever synced as children of the account when the Account + // API is reachable, regardless of which workspace the change occurred in. + {groupResourceType.Id, groupResourceId(context.Background(), "g-1", accountParent), accountResourceType.Id, accountId}, + }, + }, + { + name: "workspace-scoped acl change also refreshes the workspace-access role", + row: auditLogRow{ + ActionName: "changeDatabricksWorkspaceAcl", + ServiceName: "accounts", + WorkspaceID: 123, + }, + want: []wantResource{ + {workspaceResourceType.Id, "my-workspace", accountResourceType.Id, accountId}, + {roleResourceType.Id, roleResourceId(WorkspaceAccessRole, workspaceParent), workspaceResourceType.Id, "my-workspace"}, + }, + }, + { + name: "setAdmin refreshes the user and the account-admin role", + row: auditLogRow{ + ActionName: "setAdmin", + ServiceName: "accounts", + RequestParams: map[string]string{"targetUserId": "u-1"}, + }, + want: []wantResource{ + {userResourceType.Id, "u-1", accountResourceType.Id, accountId}, + {roleResourceType.Id, roleResourceId(AccountAdminRole, accountParent), accountResourceType.Id, accountId}, + }, + }, + { + name: "setAccountAdmin refreshes the user and the account-admin role", + row: auditLogRow{ + ActionName: "setAccountAdmin", + ServiceName: "accounts", + RequestParams: map[string]string{"targetUserId": "u-1"}, + }, + want: []wantResource{ + {userResourceType.Id, "u-1", accountResourceType.Id, accountId}, + {roleResourceType.Id, roleResourceId(AccountAdminRole, accountParent), accountResourceType.Id, accountId}, + }, + }, + { + name: "removeAccountAdmin refreshes the user and the account-admin role", + row: auditLogRow{ + ActionName: "removeAccountAdmin", + ServiceName: "accounts", + RequestParams: map[string]string{"targetUserId": "u-1"}, + }, + want: []wantResource{ + {userResourceType.Id, "u-1", accountResourceType.Id, accountId}, + {roleResourceType.Id, roleResourceId(AccountAdminRole, accountParent), accountResourceType.Id, accountId}, + }, + }, + { + name: "deactivateUser refreshes the user", + row: auditLogRow{ + ActionName: "deactivateUser", + ServiceName: "accounts", + RequestParams: map[string]string{"targetUserId": "u-1"}, + }, + want: []wantResource{ + {userResourceType.Id, "u-1", accountResourceType.Id, accountId}, + }, + }, + { + name: "account-level addPermissionAssignment resolves the workspace from request_params", + row: auditLogRow{ + ActionName: "addPermissionAssignment", + ServiceName: "workspace", + WorkspaceID: 0, + RequestParams: map[string]string{"principal_id": "u-1", "account_id": accountId, "workspace_id": "123"}, + }, + want: []wantResource{ + {workspaceResourceType.Id, "my-workspace", accountResourceType.Id, accountId}, + }, + }, + { + name: "account-level updatePermissionAssignment resolves the workspace from request_params", + row: auditLogRow{ + ActionName: "updatePermissionAssignment", + ServiceName: "workspace", + WorkspaceID: 0, + RequestParams: map[string]string{"principal_id": "u-1", "workspace_id": "123"}, + }, + want: []wantResource{ + {workspaceResourceType.Id, "my-workspace", accountResourceType.Id, accountId}, + }, + }, + { + name: "updatePermissionAssignment uses the row's workspace_id", + row: auditLogRow{ + ActionName: "updatePermissionAssignment", + ServiceName: "workspace", + WorkspaceID: 123, + RequestParams: map[string]string{"principal_id": "u-1"}, + }, + want: []wantResource{ + {workspaceResourceType.Id, "my-workspace", accountResourceType.Id, accountId}, + }, + }, + { + name: "deletePermissionAssignment for a workspace outside sync scope is skipped", + row: auditLogRow{ + ActionName: "deletePermissionAssignment", + ServiceName: "workspace", + RequestParams: map[string]string{"principal_id": "u-1", "workspace_id": "999"}, + }, + }, + { + name: "permission assignment with an unparseable workspace_id is skipped", + row: auditLogRow{ + ActionName: "deletePermissionAssignment", + ServiceName: "workspace", + RequestParams: map[string]string{"principal_id": "u-1", "workspace_id": "not-a-number"}, + }, + }, + { + name: "updateUser stays account-parented, but workspace roles still refresh", + row: auditLogRow{ + ActionName: "updateUser", + ServiceName: "accounts", + WorkspaceID: 123, + RequestParams: map[string]string{"targetUserId": "u-1"}, + }, + want: []wantResource{ + {userResourceType.Id, "u-1", accountResourceType.Id, accountId}, + {roleResourceType.Id, roleResourceId(ClusterCreateRole, workspaceParent), workspaceResourceType.Id, "my-workspace"}, + {roleResourceType.Id, roleResourceId(InstancePoolCreateRole, workspaceParent), workspaceResourceType.Id, "my-workspace"}, + }, + }, + { + name: "unknown action is skipped", + row: auditLogRow{ActionName: "someUnityCatalogAction"}, + }, + { + name: "known action_name under an unmapped service_name is skipped", + row: auditLogRow{ActionName: "delete", ServiceName: "clusters"}, + }, + { + name: "unresolvable workspace is skipped", + row: auditLogRow{ + ActionName: "add", + ServiceName: "accounts", + WorkspaceID: 999, + RequestParams: map[string]string{"targetUserId": "u-1"}, + }, + }, + { + name: "missing id param is skipped", + row: auditLogRow{ActionName: "add", ServiceName: "accounts", WorkspaceID: 0}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _, err := mapAuditRowToResource(context.Background(), client, principalTypeCache{}, tc.row, accountId, workspaceLookup) + if err != nil { + t.Fatalf("mapAuditRowToResource() error = %v", err) + } + if len(got) != len(tc.want) { + t.Fatalf("got %d affected resources, want %d: %+v", len(got), len(tc.want), got) + } + for i, w := range tc.want { + if got[i].resourceId.ResourceType != w.resourceType || got[i].resourceId.Resource != w.resource { + t.Errorf("[%d] resourceId = %+v, want type=%s id=%s", i, got[i].resourceId, w.resourceType, w.resource) + } + if got[i].parentResourceId.ResourceType != w.parentType || got[i].parentResourceId.Resource != w.parentID { + t.Errorf("[%d] parentResourceId = %+v, want type=%s id=%s", i, got[i].parentResourceId, w.parentType, w.parentID) + } + } + }) + } +} + +func assertGroupRefresh(t *testing.T, got []affectedResource, wantGroupId string) { + t.Helper() + if len(got) != 1 || got[0].grant != nil { + t.Fatalf("got %+v, want exactly one group refresh", got) + } + if id := got[0].resourceId; id.GetResourceType() != groupResourceType.Id || id.GetResource() != wantGroupId { + t.Errorf("resourceId = %+v, want type=%s id=%s", id, groupResourceType.Id, wantGroupId) + } +} + +// principalKindTestClient serves GetUser/GetGroup/GetServicePrincipal lookups for a single +// native id, returning 200 for kind ("Users", "Groups", or "ServicePrincipals") and 404 for +// the other two - or 500 for all three if kind is "error". +func principalKindTestClient(t *testing.T, nativeId, kind string) *databricks.Client { + t.Helper() + return newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/"+nativeId) { + writeJSONNotFound(w) + return + } + if kind == "error" { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"boom"}`)) + return + } + if strings.Contains(r.URL.Path, "/"+kind+"/") { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + return + } + writeJSONNotFound(w) + }) +} + +// TestMapAuditRowToResourceGrantMapping covers the group-membership actions that map to an +// atomic CREATE_GRANT/CREATE_REVOKE instead of a RESOURCE_CHANGE. +func TestMapAuditRowToResourceGrantMapping(t *testing.T) { + accountId := "acct-1" + accountParent := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: accountId} + wantGroupId := groupResourceId(context.Background(), "g-1", accountParent) + wantEntitlementId := ent.NewEntitlementID(&v2.Resource{Id: &v2.ResourceId{ResourceType: groupResourceType.Id, Resource: wantGroupId}}, groupMemberEntitlement) + + cases := []struct { + name string + actionName string + wantRevoke bool + }{ + {"addPrincipalToGroup grants", "addPrincipalToGroup", false}, + {"removePrincipalFromGroup revokes", "removePrincipalFromGroup", true}, + {"addPrincipalsToGroup grants", "addPrincipalsToGroup", false}, + {"removePrincipalsFromGroup revokes", "removePrincipalsFromGroup", true}, + } + + client := principalKindTestClient(t, "u-1", "Users") + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + row := auditLogRow{ + ActionName: tc.actionName, + ServiceName: auditServiceAccounts, + RequestParams: map[string]string{"targetGroupId": "g-1", "targetUserId": "u-1"}, + } + + got, _, err := mapAuditRowToResource(context.Background(), client, principalTypeCache{}, row, accountId, nil) + if err != nil { + t.Fatalf("mapAuditRowToResource() error = %v", err) + } + if len(got) != 1 || got[0].grant == nil { + t.Fatalf("got %+v, want exactly one grant-mapped affected resource", got) + } + + g := got[0].grant + if g.revoke != tc.wantRevoke { + t.Errorf("revoke = %v, want %v", g.revoke, tc.wantRevoke) + } + if g.entitlement.GetId() != wantEntitlementId { + t.Errorf("entitlement id = %q, want %q", g.entitlement.GetId(), wantEntitlementId) + } + if g.principal.GetId().GetResourceType() != userResourceType.Id || g.principal.GetId().GetResource() != "u-1" { + t.Errorf("principal = %+v, want type=%s id=u-1", g.principal.GetId(), userResourceType.Id) + } + }) + } + + t.Run("missing principal id still refreshes the group", func(t *testing.T) { + row := auditLogRow{ + ActionName: "addPrincipalToGroup", + ServiceName: auditServiceAccounts, + RequestParams: map[string]string{"targetGroupId": "g-1"}, + } + got, _, err := mapAuditRowToResource(context.Background(), client, principalTypeCache{}, row, accountId, nil) + if err != nil { + t.Fatalf("mapAuditRowToResource() error = %v", err) + } + assertGroupRefresh(t, got, wantGroupId) + }) + + t.Run("principal resolves to a nested group", func(t *testing.T) { + groupClient := principalKindTestClient(t, "g-2", "Groups") + row := auditLogRow{ + ActionName: "addPrincipalToGroup", + ServiceName: auditServiceAccounts, + RequestParams: map[string]string{"targetGroupId": "g-1", "targetUserId": "g-2"}, + } + got, _, err := mapAuditRowToResource(context.Background(), groupClient, principalTypeCache{}, row, accountId, nil) + if err != nil { + t.Fatalf("mapAuditRowToResource() error = %v", err) + } + if len(got) != 1 || got[0].grant == nil { + t.Fatalf("got %+v, want exactly one grant-mapped affected resource", got) + } + wantId := groupResourceId(context.Background(), "g-2", accountParent) + if p := got[0].grant.principal.GetId(); p.GetResourceType() != groupResourceType.Id || p.GetResource() != wantId { + t.Errorf("principal = %+v, want type=%s id=%s", p, groupResourceType.Id, wantId) + } + }) + + t.Run("principal resolves to a service principal", func(t *testing.T) { + spClient := principalKindTestClient(t, "sp-1", "ServicePrincipals") + row := auditLogRow{ + ActionName: "addPrincipalToGroup", + ServiceName: auditServiceAccounts, + RequestParams: map[string]string{"targetGroupId": "g-1", "targetUserId": "sp-1"}, + } + got, _, err := mapAuditRowToResource(context.Background(), spClient, principalTypeCache{}, row, accountId, nil) + if err != nil { + t.Fatalf("mapAuditRowToResource() error = %v", err) + } + if len(got) != 1 || got[0].grant == nil { + t.Fatalf("got %+v, want exactly one grant-mapped affected resource", got) + } + if p := got[0].grant.principal.GetId(); p.GetResourceType() != servicePrincipalResourceType.Id || p.GetResource() != "sp-1" { + t.Errorf("principal = %+v, want type=%s id=sp-1", p, servicePrincipalResourceType.Id) + } + }) + + t.Run("principal not found anywhere still refreshes the group", func(t *testing.T) { + notFoundClient := principalKindTestClient(t, "ghost", "none") + row := auditLogRow{ + ActionName: "addPrincipalToGroup", + ServiceName: auditServiceAccounts, + RequestParams: map[string]string{"targetGroupId": "g-1", "targetUserId": "ghost"}, + } + got, _, err := mapAuditRowToResource(context.Background(), notFoundClient, principalTypeCache{}, row, accountId, nil) + if err != nil { + t.Fatalf("mapAuditRowToResource() error = %v", err) + } + assertGroupRefresh(t, got, wantGroupId) + }) + + t.Run("a real lookup error propagates instead of being swallowed", func(t *testing.T) { + errClient := principalKindTestClient(t, "u-1", "error") + row := auditLogRow{ + ActionName: "addPrincipalToGroup", + ServiceName: auditServiceAccounts, + RequestParams: map[string]string{"targetGroupId": "g-1", "targetUserId": "u-1"}, + } + if _, _, err := mapAuditRowToResource(context.Background(), errClient, principalTypeCache{}, row, accountId, nil); err == nil { + t.Error("mapAuditRowToResource() error = nil, want a propagated lookup error") + } + }) +} + +func TestParseAuditLogRowsDedupesNothingAndParsesFields(t *testing.T) { + result := &databricks.StatementResult{ + Columns: []string{"event_id", "event_time", "workspace_id", "action_name", "service_name", "request_params"}, + Rows: [][]string{ + {"evt-1", "2026-01-01 00:00:00.000", "123", "createGroup", "accounts", `{"targetGroupId":"g-1"}`}, + {"evt-2", "2026-01-01T00:01:00Z", "0", "add", "accounts", `{"targetUserId":"u-1"}`}, + }, + } + + rows, _, err := parseAuditLogRows(context.Background(), result) + if err != nil { + t.Fatalf("parseAuditLogRows() error = %v", err) + } + if len(rows) != 2 { + t.Fatalf("len(rows) = %d, want 2", len(rows)) + } + if rows[0].WorkspaceID != 123 || rows[0].ServiceName != "accounts" || rows[0].RequestParams["targetGroupId"] != "g-1" { + t.Errorf("row[0] = %+v", rows[0]) + } + if rows[1].WorkspaceID != 0 || rows[1].RequestParams["targetUserId"] != "u-1" { + t.Errorf("row[1] = %+v", rows[1]) + } +} + +func TestParseAuditLogRowsMissingColumnErrors(t *testing.T) { + result := &databricks.StatementResult{ + Columns: []string{"event_id", "event_time"}, + Rows: [][]string{{"evt-1", "2026-01-01 00:00:00.000"}}, + } + + if _, _, err := parseAuditLogRows(context.Background(), result); err == nil { + t.Error("parseAuditLogRows() error = nil, want error for missing required column") + } +} + +// TestParseAuditLogRowsSkipsMalformedRow verifies a single poisoned row (unparseable +// event_time here) is skipped rather than failing the whole page. +func TestParseAuditLogRowsSkipsMalformedRow(t *testing.T) { + result := &databricks.StatementResult{ + Columns: []string{"event_id", "event_time", "workspace_id", "action_name", "service_name", "request_params"}, + Rows: [][]string{ + {"evt-1", "not-a-timestamp", "123", "createGroup", "accounts", `{"targetGroupId":"g-1"}`}, + {"evt-2", "2026-01-01T00:01:00Z", "0", "add", "accounts", `{"targetUserId":"u-1"}`}, + }, + } + + rows, _, err := parseAuditLogRows(context.Background(), result) + if err != nil { + t.Fatalf("parseAuditLogRows() error = %v, want the malformed row skipped instead", err) + } + if len(rows) != 1 { + t.Fatalf("len(rows) = %d, want 1 (malformed row skipped)", len(rows)) + } + if rows[0].EventID != "evt-2" { + t.Errorf("rows[0].EventID = %q, want %q", rows[0].EventID, "evt-2") + } +} + +// redirectTransport rewrites every outgoing request to target, since workspaceUrl always +// builds a "." subdomain a local httptest.Server can't listen on directly. +type redirectTransport struct { + target *url.URL +} + +func (t *redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + originalHost := req.URL.Host + req.URL.Scheme = t.target.Scheme + req.URL.Host = t.target.Host + req.Host = t.target.Host + // Preserve the workspace-specific host the client intended, since it's otherwise lost + // once every request is rewritten to the same local test server. + req.Header.Set("X-Test-Original-Host", originalHost) + return http.DefaultTransport.RoundTrip(req) +} + +// TestListEventsSkipsQueryPastLagCutoff covers the bootstrap default landing exactly at +// lagCutoff (auditLogLookback == auditLogTrailingLag): ListEvents must not query an +// inverted (start > end) range. +func TestListEventsSkipsQueryPastLagCutoff(t *testing.T) { + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request %s %s: should have been skipped by the lagCutoff guard", r.Method, r.URL.Path) + }) + + feed := newAuditEventFeed(client, nil, "wh-1") + events, streamState, _, err := feed.ListEvents(context.Background(), nil, &pagination.StreamToken{Cursor: ""}) + if err != nil { + t.Fatalf("ListEvents() error = %v", err) + } + if len(events) != 0 { + t.Errorf("len(events) = %d, want 0", len(events)) + } + if streamState.HasMore { + t.Error("HasMore = true, want false") + } + if streamState.Cursor == "" { + t.Error("Cursor is empty, want the bootstrap cursor to still be encoded") + } +} + +// TestListEventsEndToEnd exercises ListEvents against a mocked Statement Execution API, +// verifying the service_name query filter, resource mapping, and rate-limit propagation. +func TestListEventsEndToEnd(t *testing.T) { + const wantLimit = 100 + const wantRemaining = 42 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // ListEvents always lists workspaces via the Account API before querying the audit log. + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/accounts/acct-1/workspaces") { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode([]map[string]any{ + {"workspace_id": 1, "workspace_name": "ws1", "deployment_name": "ws1"}, + }); err != nil { + t.Fatalf("failed to encode mock workspaces response: %v", err) + } + return + } + + // resolveWarehouseWorkspace probes every candidate workspace (including a lone + // one) via WarehouseExists before running the audit-log query. + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/sql/warehouses/wh-1") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":"wh-1"}`) + return + } + + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/api/2.0/sql/statements") { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + + var body struct { + Statement string `json:"statement"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode statement request body: %v", err) + } + if !strings.Contains(body.Statement, "service_name") { + t.Errorf("statement does not select/filter service_name: %s", body.Statement) + } + if !strings.Contains(body.Statement, "'accounts'") { + t.Errorf("statement does not filter service_name IN ('accounts', ...): %s", body.Statement) + } + + w.Header().Set("X-Ratelimit-Limit", strconv.Itoa(wantLimit)) + w.Header().Set("X-Ratelimit-Remaining", strconv.Itoa(wantRemaining)) + w.Header().Set("Content-Type", "application/json") + + resp := map[string]any{ + "statement_id": "stmt-1", + "status": map[string]any{"state": "SUCCEEDED"}, + "manifest": map[string]any{ + "schema": map[string]any{ + "columns": []map[string]any{ + {"name": "event_id"}, + {"name": "event_time"}, + {"name": "workspace_id"}, + {"name": "action_name"}, + {"name": "service_name"}, + {"name": "request_params"}, + }, + }, + }, + "result": map[string]any{ + "data_array": [][]string{ + {"evt-1", "2026-01-01T00:00:00Z", "0", "add", "accounts", `{"targetUserId":"u-1"}`}, + }, + }, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Fatalf("failed to encode mock statement response: %v", err) + } + })) + defer server.Close() + + target, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + + httpClient := &http.Client{Transport: &redirectTransport{target: target}} + auth := &databricks.NoAuth{} + client, err := databricks.NewClient(context.Background(), httpClient, "example.cloud.databricks.com", "accounts.cloud.databricks.com", "acct-1", "", auth, nil) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + // Mirrors what Validate() sets before any sync/event-feed call runs in production. + + feed := newAuditEventFeed(client, nil, "wh-1") + + events, streamState, annos, err := feed.ListEvents(context.Background(), testEarliestEvent(), &pagination.StreamToken{Cursor: ""}) + if err != nil { + t.Fatalf("ListEvents() error = %v", err) + } + if len(events) != 1 { + t.Fatalf("len(events) = %d, want 1: %+v", len(events), events) + } + + rc := events[0].GetResourceChangeEvent() + if rc.GetResourceId().GetResourceType() != userResourceType.Id || rc.GetResourceId().GetResource() != "u-1" { + t.Errorf("event resource = %+v, want type=%s id=u-1", rc.GetResourceId(), userResourceType.Id) + } + if streamState.Cursor == "" { + t.Error("StreamState.Cursor is empty, want an encoded cursor") + } + + rld := &v2.RateLimitDescription{} + ok, err := annos.Pick(rld) + if err != nil { + t.Fatalf("annos.Pick() error = %v", err) + } + if !ok { + t.Fatal("annotations do not carry a RateLimitDescription, want the rate-limit headers propagated") + } + if rld.GetLimit() != wantLimit || rld.GetRemaining() != wantRemaining { + t.Errorf("RateLimitDescription = %+v, want limit=%d remaining=%d", rld, wantLimit, wantRemaining) + } +} + +// TestListEventsEmitsCreateGrantEvent verifies ListEvents wires a grant-mapped audit row all +// the way to a CreateGrantEvent, not a ResourceChangeEvent. +func TestListEventsEmitsCreateGrantEvent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/accounts/acct-1/workspaces") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{{"workspace_id": 1, "workspace_name": "ws1", "deployment_name": "ws1"}}) + return + } + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/sql/warehouses/wh-1") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":"wh-1"}`) + return + } + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/accounts/acct-1/scim/v2/Users/u-1") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{}`) + return + } + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/api/2.0/sql/statements") { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "statement_id": "stmt-1", + "status": map[string]any{"state": "SUCCEEDED"}, + "manifest": map[string]any{ + "schema": map[string]any{ + "columns": []map[string]any{ + {"name": "event_id"}, {"name": "event_time"}, {"name": "workspace_id"}, + {"name": "action_name"}, {"name": "service_name"}, {"name": "request_params"}, + }, + }, + }, + "result": map[string]any{ + "data_array": [][]string{ + {"evt-1", "2026-01-01T00:00:00Z", "0", "addPrincipalToGroup", "accounts", `{"targetGroupId":"g-1","targetUserId":"u-1"}`}, + }, + }, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Fatalf("failed to encode mock statement response: %v", err) + } + })) + defer server.Close() + + target, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + + httpClient := &http.Client{Transport: &redirectTransport{target: target}} + auth := &databricks.NoAuth{} + client, err := databricks.NewClient(context.Background(), httpClient, "example.cloud.databricks.com", "accounts.cloud.databricks.com", "acct-1", "", auth, nil) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + feed := newAuditEventFeed(client, nil, "wh-1") + events, _, _, err := feed.ListEvents(context.Background(), testEarliestEvent(), &pagination.StreamToken{Cursor: ""}) + if err != nil { + t.Fatalf("ListEvents() error = %v", err) + } + if len(events) != 1 { + t.Fatalf("len(events) = %d, want 1: %+v", len(events), events) + } + + cg := events[0].GetCreateGrantEvent() + if cg == nil { + t.Fatalf("event = %+v, want a CreateGrantEvent", events[0]) + } + accountParent := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: "acct-1"} + wantGroupId := groupResourceId(context.Background(), "g-1", accountParent) + if cg.GetEntitlement().GetResource().GetId().GetResource() != wantGroupId { + t.Errorf("entitlement resource = %q, want %q", cg.GetEntitlement().GetResource().GetId().GetResource(), wantGroupId) + } + if cg.GetPrincipal().GetId().GetResourceType() != userResourceType.Id || cg.GetPrincipal().GetId().GetResource() != "u-1" { + t.Errorf("principal = %+v, want type=%s id=u-1", cg.GetPrincipal().GetId(), userResourceType.Id) + } +} + +// newLookupFailureFeed returns a feed whose single grant-mapped audit row's principal lookup +// fails with lookupStatus. +func newLookupFailureFeed(t *testing.T, lookupStatus int) *auditEventFeed { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/accounts/acct-1/workspaces") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{{"workspace_id": 1, "workspace_name": "ws1", "deployment_name": "ws1"}}) + return + } + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/sql/warehouses/wh-1") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":"wh-1"}`) + return + } + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/accounts/acct-1/scim/v2/Users/u-1") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(lookupStatus) + fmt.Fprint(w, `{"detail":"lookup failed"}`) + return + } + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/api/2.0/sql/statements") { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "statement_id": "stmt-1", + "status": map[string]any{"state": "SUCCEEDED"}, + "manifest": map[string]any{ + "schema": map[string]any{ + "columns": []map[string]any{ + {"name": "event_id"}, {"name": "event_time"}, {"name": "workspace_id"}, + {"name": "action_name"}, {"name": "service_name"}, {"name": "request_params"}, + }, + }, + }, + "result": map[string]any{ + "data_array": [][]string{ + {"evt-1", "2026-01-01T00:00:00Z", "0", "addPrincipalToGroup", "accounts", `{"targetGroupId":"g-1","targetUserId":"u-1"}`}, + }, + }, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Fatalf("failed to encode mock statement response: %v", err) + } + })) + t.Cleanup(server.Close) + + target, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + + httpClient := &http.Client{Transport: &redirectTransport{target: target}} + auth := &databricks.NoAuth{} + client, err := databricks.NewClient(context.Background(), httpClient, "example.cloud.databricks.com", "accounts.cloud.databricks.com", "acct-1", "", auth, nil) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + return newAuditEventFeed(client, nil, "wh-1") +} + +// TestListEventsReturnsErrorOnRetryableLookupFailure verifies that a transient principal lookup +// failure fails the poll, so the cursor isn't advanced past the row. +func TestListEventsReturnsErrorOnRetryableLookupFailure(t *testing.T) { + feed := newLookupFailureFeed(t, http.StatusServiceUnavailable) + events, streamState, _, err := feed.ListEvents(context.Background(), testEarliestEvent(), &pagination.StreamToken{Cursor: ""}) + if err == nil { + t.Fatalf("ListEvents() error = nil, want the principal lookup failure; events=%+v", events) + } + if events != nil || streamState != nil { + t.Errorf("ListEvents() = (%+v, %+v), want no events and no stream state so the cursor is not advanced", events, streamState) + } +} + +// TestListEventsSkipsRowOnPermanentLookupFailure verifies that a non-retryable principal lookup +// failure skips the row instead of blocking the cursor on every poll. +func TestListEventsSkipsRowOnPermanentLookupFailure(t *testing.T) { + feed := newLookupFailureFeed(t, http.StatusForbidden) + events, streamState, _, err := feed.ListEvents(context.Background(), testEarliestEvent(), &pagination.StreamToken{Cursor: ""}) + if err != nil { + t.Fatalf("ListEvents() error = %v, want the row skipped", err) + } + if len(events) != 0 { + t.Errorf("len(events) = %d, want 0", len(events)) + } + if streamState == nil || streamState.Cursor == "" { + t.Errorf("streamState = %+v, want an advanced cursor", streamState) + } +} + +// TestListEventsFullPageCursorAdvance verifies the HasMore wiring on a full page +// (auditLogPageLimit raw rows): it pages forward from the last raw row's boundary, but when +// no boundary can be parsed it reports HasMore=false with the cursor unchanged rather than +// claiming more data behind a cursor that would repeat the same query. +func TestListEventsFullPageCursorAdvance(t *testing.T) { + start := eventPageCursor{StartAt: time.Now().Add(-2 * auditLogTrailingLag).UTC().Truncate(time.Second), StartAfterEventID: "evt-0"} + lastTime := start.StartAt.Add(time.Minute) + + cases := []struct { + name string + lastRowTime string + wantHasMore bool + wantCursor eventPageCursor + }{ + { + name: "parseable last row pages forward", + lastRowTime: lastTime.Format(time.RFC3339), + wantHasMore: true, + wantCursor: eventPageCursor{StartAt: lastTime, StartAfterEventID: fmt.Sprintf("evt-%d", auditLogPageLimit)}, + }, + { + name: "no parseable boundary stops without advancing", + lastRowTime: "not-a-time", + wantHasMore: false, + wantCursor: start, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/accounts/acct-1/workspaces"): + _ = json.NewEncoder(w).Encode([]map[string]any{{"workspace_id": 1, "workspace_name": "ws1", "deployment_name": "ws1"}}) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/sql/warehouses/wh-1"): + fmt.Fprint(w, `{"id":"wh-1"}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/api/2.0/sql/statements"): + // Every row but the last has an unparseable event_time, so no row parses + // and only the last raw row can supply the boundary. The action is + // unmapped, so no principal lookups are made either way. + data := make([][]string, auditLogPageLimit) + for i := range data { + data[i] = []string{fmt.Sprintf("evt-%d", i+1), "not-a-time", "0", "someUnmappedAction", "accounts", `{}`} + } + data[len(data)-1][1] = tc.lastRowTime + _ = json.NewEncoder(w).Encode(map[string]any{ + "statement_id": "stmt-1", + "status": map[string]any{"state": "SUCCEEDED"}, + "manifest": map[string]any{"schema": map[string]any{"columns": []map[string]any{ + {"name": "event_id"}, {"name": "event_time"}, {"name": "workspace_id"}, + {"name": "action_name"}, {"name": "service_name"}, {"name": "request_params"}, + }}}, + "result": map[string]any{"data_array": data}, + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }) + + encoded, err := encodeEventCursor(start) + if err != nil { + t.Fatalf("encodeEventCursor() error = %v", err) + } + + feed := newAuditEventFeed(client, nil, "wh-1") + events, streamState, _, err := feed.ListEvents(context.Background(), nil, &pagination.StreamToken{Cursor: encoded}) + if err != nil { + t.Fatalf("ListEvents() error = %v", err) + } + if len(events) != 0 { + t.Errorf("len(events) = %d, want 0", len(events)) + } + if streamState.HasMore != tc.wantHasMore { + t.Errorf("HasMore = %v, want %v", streamState.HasMore, tc.wantHasMore) + } + got := decodeEventCursor(context.Background(), streamState.Cursor, time.Now()) + if !got.StartAt.Equal(tc.wantCursor.StartAt) || got.StartAfterEventID != tc.wantCursor.StartAfterEventID { + t.Errorf("cursor = %+v, want %+v", got, tc.wantCursor) + } + }) + } +} + +// TestListEventsFindsWarehouseOutsideWorkspacesAllowlist verifies that resolving the SQL +// warehouse's workspace is NOT scoped by --workspaces: the warehouse can live in any +// workspace in the account, so the allowlist must only narrow which workspaces' audit +// rows get resolved to resources, not which workspaces are searched for the warehouse. +func TestListEventsFindsWarehouseOutsideWorkspacesAllowlist(t *testing.T) { + queriedWorkspace := "" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/accounts/acct-1/workspaces") { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode([]map[string]any{ + {"workspace_id": 1, "workspace_name": "ws1", "deployment_name": "ws1"}, + {"workspace_id": 2, "workspace_name": "ws2", "deployment_name": "ws2"}, + }); err != nil { + t.Fatalf("failed to encode mock workspaces response: %v", err) + } + return + } + + // The warehouse only exists in ws1, which is NOT in the --workspaces allowlist + // below (only ws2 is configured). Resolution must still find it in ws1. + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/sql/warehouses/wh-1") { + isWs1 := strings.HasPrefix(r.Header.Get("X-Test-Original-Host"), "ws1.") + w.Header().Set("Content-Type", "application/json") + if isWs1 { + fmt.Fprintf(w, `{"id":"wh-1"}`) + return + } + writeJSONNotFound(w) + return + } + + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/api/2.0/sql/statements") { + queriedWorkspace = r.Header.Get("X-Test-Original-Host") + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "statement_id": "stmt-1", + "status": map[string]any{"state": "SUCCEEDED"}, + "manifest": map[string]any{ + "schema": map[string]any{ + "columns": []map[string]any{ + {"name": "event_id"}, {"name": "event_time"}, {"name": "workspace_id"}, + {"name": "action_name"}, {"name": "service_name"}, {"name": "request_params"}, + }, + }, + }, + "result": map[string]any{"data_array": [][]string{}}, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Fatalf("failed to encode mock statement response: %v", err) + } + return + } + + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + })) + defer server.Close() + + target, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + + httpClient := &http.Client{Transport: &redirectTransport{target: target}} + auth := &databricks.NoAuth{} + client, err := databricks.NewClient(context.Background(), httpClient, "example.cloud.databricks.com", "accounts.cloud.databricks.com", "acct-1", "", auth, nil) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + // --workspaces is scoped to ws2 only; the warehouse lives in ws1. + feed := newAuditEventFeed(client, []string{"ws2"}, "wh-1") + + _, _, _, err = feed.ListEvents(context.Background(), testEarliestEvent(), &pagination.StreamToken{Cursor: ""}) + if err != nil { + t.Fatalf("ListEvents() error = %v, want warehouse resolution to succeed despite living outside --workspaces", err) + } + if !strings.HasPrefix(queriedWorkspace, "ws1.") { + t.Errorf("audit query ran against %q, want it to run against ws1 (where the warehouse actually lives)", queriedWorkspace) + } +} + +func TestFilterConfiguredWorkspaces(t *testing.T) { + all := []databricks.Workspace{ + {ID: 1, Name: "Prod", DeploymentName: "dbc-prod"}, + {ID: 2, Name: "Dev", DeploymentName: "dbc-dev"}, + } + + tests := []struct { + name string + configured []string + want []int + }{ + {"empty allowlist keeps all", nil, []int{1, 2}}, + {"deployment name", []string{"dbc-dev"}, []int{2}}, + {"workspace name, case-insensitive", []string{"PROD"}, []int{1}}, + {"numeric ID", []string{"2"}, []int{2}}, + {"no match", []string{"other"}, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := filterConfiguredWorkspaces(all, tt.configured) + var ids []int + for _, w := range got { + ids = append(ids, w.ID) + } + if fmt.Sprint(ids) != fmt.Sprint(tt.want) { + t.Errorf("filterConfiguredWorkspaces(%v) = %v, want %v", tt.configured, ids, tt.want) + } + }) + } +} + +// TestExecuteStatementPollsPastCache guards against the uhttp GET cache freezing +// pollStatement: the poll URL never changes, so a cached RUNNING response would be +// returned until statementPollMaxWait. +func TestExecuteStatementPollsPastCache(t *testing.T) { + var polls atomic.Int32 + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + state := "PENDING" + if r.Method == http.MethodGet { + state = "RUNNING" + if polls.Add(1) >= 2 { + state = "SUCCEEDED" + } + } + fmt.Fprintf(w, `{"statement_id":"stmt-1","status":{"state":%q},"manifest":{"schema":{"columns":[{"name":"x"}]}},"result":{"data_array":[]}}`, state) + }) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if _, _, err := client.ExecuteStatement(ctx, "dbc-aaa", "wh-1", "SELECT 1"); err != nil { + t.Fatalf("ExecuteStatement() error = %v", err) + } + if got := polls.Load(); got < 2 { + t.Errorf("server received %d poll GETs, want >= 2 (poll response was served from cache)", got) + } +} + +// TestGetBypassesCacheButSyncCallersDont verifies incremental-sync refreshes (builder Get) +// always hit the API, while the same client call from the regular sync path stays cached. +func TestGetBypassesCacheButSyncCallersDont(t *testing.T) { + var hits atomic.Int32 + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"id":"u-1","userName":"alice@example.com","active":true}`) + }) + ctx := context.Background() + accountParent := &v2.ResourceId{ResourceType: accountResourceType.Id, Resource: "acct-1"} + userId := &v2.ResourceId{ResourceType: userResourceType.Id, Resource: "u-1"} + + for range 2 { + if _, _, err := client.GetUser(ctx, "", "u-1"); err != nil { + t.Fatalf("GetUser() error = %v", err) + } + } + if got := hits.Load(); got != 1 { + t.Fatalf("regular GetUser calls made %d API calls, want 1 (second must be cached)", got) + } + + builder := newUserBuilder(client) + for range 2 { + if _, _, err := builder.Get(ctx, userId, accountParent); err != nil { + t.Fatalf("Get() error = %v", err) + } + } + if got := hits.Load() - 1; got != 2 { + t.Errorf("builder Get made %d API calls, want 2 (must bypass the cache)", got) + } +} + +// TestListEventsCachesPrincipalTypes verifies a principal named on several rows of one page is +// looked up once, including an id that matches no principal at all. +func TestListEventsCachesPrincipalTypes(t *testing.T) { + var mu sync.Mutex + hits := map[string]int{} + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/accounts/acct-1/workspaces"): + _ = json.NewEncoder(w).Encode([]map[string]any{{"workspace_id": 1, "workspace_name": "ws1", "deployment_name": "ws1"}}) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/sql/warehouses/wh-1"): + fmt.Fprint(w, `{"id":"wh-1"}`) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/scim/v2/"): + mu.Lock() + hits[r.URL.Path]++ + mu.Unlock() + if strings.HasSuffix(r.URL.Path, "/ServicePrincipals/sp-1") { + fmt.Fprint(w, `{}`) + return + } + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"detail":"not found"}`) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/api/2.0/sql/statements"): + row := func(id, principal string) []string { + return []string{id, "2026-01-01T00:00:00Z", "0", "addPrincipalToGroup", "accounts", fmt.Sprintf(`{"targetGroupId":"g-1","targetUserId":%q}`, principal)} + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "statement_id": "stmt-1", + "status": map[string]any{"state": "SUCCEEDED"}, + "manifest": map[string]any{"schema": map[string]any{"columns": []map[string]any{ + {"name": "event_id"}, {"name": "event_time"}, {"name": "workspace_id"}, + {"name": "action_name"}, {"name": "service_name"}, {"name": "request_params"}, + }}}, + "result": map[string]any{"data_array": [][]string{ + row("evt-1", "sp-1"), row("evt-2", "sp-1"), row("evt-3", "sp-1"), + row("evt-4", "gone-1"), row("evt-5", "gone-1"), + }}, + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }) + + feed := newAuditEventFeed(client, nil, "wh-1") + events, _, _, err := feed.ListEvents(context.Background(), testEarliestEvent(), &pagination.StreamToken{Cursor: ""}) + if err != nil { + t.Fatalf("ListEvents() error = %v", err) + } + if len(events) != 5 { + t.Fatalf("len(events) = %d, want 5", len(events)) + } + for _, id := range []string{"sp-1", "gone-1"} { + for _, kind := range []string{"Users", "Groups", "ServicePrincipals"} { + path := fmt.Sprintf("/api/2.0/accounts/acct-1/scim/v2/%s/%s", kind, id) + if got := hits[path]; got != 1 { + t.Errorf("%s hit %d times, want 1", path, got) + } + } + } +} + +// TestResolvePrincipalTypeDoesNotCacheErrors verifies a failed lookup is retried on the next +// row instead of being remembered. +func TestResolvePrincipalTypeDoesNotCacheErrors(t *testing.T) { + var calls atomic.Int32 + client := newProbeTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if calls.Add(1) == 1 { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `{"detail":"denied"}`) + return + } + fmt.Fprint(w, `{}`) + }) + + principalTypes := principalTypeCache{} + if _, _, err := resolvePrincipalType(context.Background(), client, principalTypes, "u-1"); err == nil { + t.Fatal("first resolvePrincipalType() error = nil, want the lookup failure") + } + got, _, err := resolvePrincipalType(context.Background(), client, principalTypes, "u-1") + if err != nil { + t.Fatalf("second resolvePrincipalType() error = %v", err) + } + if got != userResourceType { + t.Errorf("second resolvePrincipalType() = %v, want %s", got, userResourceType.Id) + } + if n := calls.Load(); n != 2 { + t.Errorf("lookup made %d calls, want 2 (the error must not be cached)", n) + } +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index f97021e6..082ad834 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "strings" "github.com/conductorone/baton-databricks/pkg/config" "github.com/conductorone/baton-databricks/pkg/databricks" @@ -11,15 +12,19 @@ import ( "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/cli" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" ) type Databricks struct { client *databricks.Client workspaces []string + // sqlWarehouseID enables incremental sync when non-empty. + sqlWarehouseID string } // ResourceSyncers returns a ResourceSyncerV2 for each resource type that should be synced from the upstream service. -func (d *Databricks) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncerV2 { +func (d *Databricks) ResourceSyncers(_ context.Context) []connectorbuilder.ResourceSyncerV2 { syncers := []connectorbuilder.ResourceSyncerV2{ newAccountBuilder(d.client), newGroupBuilder(d.client), @@ -32,14 +37,22 @@ func (d *Databricks) ResourceSyncers(ctx context.Context) []connectorbuilder.Res return syncers } +// EventFeeds registers the audit-log event feed unconditionally; whether sql-warehouse-id +// is set gates its behavior inside ListEvents instead. +func (d *Databricks) EventFeeds(_ context.Context) []connectorbuilder.EventFeed { + return []connectorbuilder.EventFeed{ + newAuditEventFeed(d.client, d.workspaces, d.sqlWarehouseID), + } +} + // Asset takes an input AssetRef and attempts to fetch it using the connector's authenticated http client // It streams a response, always starting with a metadata object, following by chunked payloads for the asset. -func (d *Databricks) Asset(ctx context.Context, asset *v2.AssetRef) (string, io.ReadCloser, error) { +func (d *Databricks) Asset(_ context.Context, _ *v2.AssetRef) (string, io.ReadCloser, error) { return "", nil, nil } // Metadata returns metadata about the connector. -func (d *Databricks) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { +func (d *Databricks) Metadata(_ context.Context) (*v2.ConnectorMetadata, error) { return &v2.ConnectorMetadata{ DisplayName: "Databricks", Description: "Connector syncing Databricks workspaces, users, groups, service principals and roles to Baton", @@ -112,11 +125,23 @@ func (d *Databricks) Validate(ctx context.Context) (annotations.Annotations, err // Workspace enumeration is the other account-plane call every sync depends on. // Per-workspace probing is deliberately not done here: a workspace the service // principal can't reach is skipped during sync rather than failing validation. - if _, _, err := d.client.ListWorkspaces(ctx); err != nil { - return nil, fmt.Errorf("databricks-connector: failed to list workspaces: %w", err) + annos := annotations.Annotations{} + _, rateLimit, err := d.client.ListWorkspaces(ctx) + if rateLimit != nil { + annos.WithRateLimiting(rateLimit) + } + if err != nil { + return annos, fmt.Errorf("databricks-connector: failed to list workspaces: %w", err) + } + + l := ctxzap.Extract(ctx) + if d.sqlWarehouseID == "" { + l.Info("databricks-connector: incremental sync disabled (sql-warehouse-id not set)") + return annos, nil } - return nil, nil + l.Info("databricks-connector: incremental sync enabled", zap.String("sql_warehouse_id", d.sqlWarehouseID)) + return annos, nil } // New returns a new instance of the connector. @@ -129,6 +154,7 @@ func New( auth databricks.Auth, excludeWorkspaces []string, workspaces []string, + sqlWarehouseID string, ) (*Databricks, error) { httpClient, err := auth.GetClient(ctx) if err != nil { @@ -143,6 +169,8 @@ func New( return &Databricks{ client: client, workspaces: workspaces, + // Trimmed so a whitespace-only value counts as unset (incremental sync off). + sqlWarehouseID: strings.TrimSpace(sqlWarehouseID), }, nil } @@ -160,6 +188,7 @@ func NewConnector(ctx context.Context, cfg *config.Databricks, _ *cli.ConnectorO auth, cfg.DatabricksExcludeWorkspaces, cfg.Workspaces, + cfg.SqlWarehouseId, ) if err != nil { return nil, nil, err diff --git a/pkg/connector/groups.go b/pkg/connector/groups.go index b5979b73..5053fb28 100644 --- a/pkg/connector/groups.go +++ b/pkg/connector/groups.go @@ -288,6 +288,40 @@ func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, attr r return rv, &rs.SyncOpResults{NextPageToken: nextPage, Annotations: annos}, nil } +// Get re-fetches a single group, used to re-sync it after a RESOURCE_CHANGE event; fetched +// without members since groupResource doesn't need them (Grants fetches them separately). +func (g *groupBuilder) Get(ctx context.Context, resourceId *v2.ResourceId, parentResourceId *v2.ResourceId) (*v2.Resource, annotations.Annotations, error) { + parentId, groupId, err := parseResourceId(resourceId.Resource) + if err != nil { + return nil, nil, fmt.Errorf("databricks-connector: failed to parse group resource id: %w", err) + } + + var workspaceId string + if parentId != nil && parentId.ResourceType == workspaceResourceType.Id { + workspaceId = parentId.Resource + } + + group, rateLimitData, err := g.client.GetGroup(databricks.WithoutCache(ctx), workspaceId, groupId.Resource, databricks.NewGroupAttrVars()) + annos := annotations.Annotations{} + if rateLimitData != nil { + annos.WithRateLimiting(rateLimitData) + } + if err != nil { + return nil, annos, fmt.Errorf("databricks-connector: failed to get group %s: %w", groupId.Resource, err) + } + + resource, err := groupResource(ctx, group, parentResourceId) + if err != nil { + return nil, nil, err + } + + ctxzap.Extract(ctx).Debug("databricks-connector: Get: refreshed group", + zap.String("resource_id", resourceId.Resource), + zap.String("parent_resource_id", parentResourceId.GetResource()), + ) + return resource, annos, nil +} + func (g *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { l := ctxzap.Extract(ctx) diff --git a/pkg/connector/roles.go b/pkg/connector/roles.go index 175f6ae5..127589c8 100644 --- a/pkg/connector/roles.go +++ b/pkg/connector/roles.go @@ -3,6 +3,7 @@ package connector import ( "context" "fmt" + "strings" "github.com/conductorone/baton-databricks/pkg/databricks" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" @@ -43,21 +44,22 @@ func (r *roleBuilder) ResourceType(ctx context.Context) *v2.ResourceType { return roleResourceType } +// roleResourceId builds a role's resource ID, namespaced by workspace for workspace roles. +func roleResourceId(role string, parent *v2.ResourceId) string { + if parent.GetResourceType() == workspaceResourceType.Id { + return fmt.Sprintf("%s:%s", parent.Resource, role) + } + return role +} + func roleResource(ctx context.Context, role string, parent *v2.ResourceId) (*v2.Resource, error) { - var roleID string profile := map[string]interface{}{ "role_name": role, - "parent_type": parent.ResourceType, - "parent_id": parent.Resource, + "parent_type": parent.GetResourceType(), + "parent_id": parent.GetResource(), } - // To differentiate between what type of role does the resource represent. - switch parent.ResourceType { - case workspaceResourceType.Id: - roleID = fmt.Sprintf("%s:%s", parent.Resource, role) - case accountResourceType.Id: - roleID = role - } + roleID := roleResourceId(role, parent) resource, err := rs.NewRoleResource( role, @@ -297,6 +299,31 @@ func (r *roleBuilder) Grants(ctx context.Context, resource *v2.Resource, attr rs return rv, &rs.SyncOpResults{NextPageToken: nextPage}, nil } +// Get rebuilds a single role resource, used to re-sync it after a RESOURCE_CHANGE event. +// Roles are synthetic (not fetched from an API), so this just reconstructs the resource +// from its resource ID the same way List does. +func (r *roleBuilder) Get(ctx context.Context, resourceId *v2.ResourceId, parentResourceId *v2.ResourceId) (*v2.Resource, annotations.Annotations, error) { + roleName := resourceId.Resource + if parentResourceId.GetResourceType() == workspaceResourceType.Id { + _, name, found := strings.Cut(resourceId.Resource, ":") + if !found { + return nil, nil, fmt.Errorf("databricks-connector: invalid workspace role resource id: %s", resourceId.Resource) + } + roleName = name + } + + resource, err := roleResource(ctx, roleName, parentResourceId) + if err != nil { + return nil, nil, err + } + + ctxzap.Extract(ctx).Debug("databricks-connector: Get: refreshed role", + zap.String("resource_id", resourceId.Resource), + zap.String("parent_resource_id", parentResourceId.GetResource()), + ) + return resource, nil, nil +} + func (r *roleBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { l := ctxzap.Extract(ctx) diff --git a/pkg/connector/service-principals.go b/pkg/connector/service-principals.go index ccd109ba..51bc131d 100644 --- a/pkg/connector/service-principals.go +++ b/pkg/connector/service-principals.go @@ -30,15 +30,16 @@ func (s *servicePrincipalBuilder) servicePrincipalResource(ctx context.Context, profile := map[string]interface{}{ "application_id": servicePrincipal.ApplicationID, "display_name": servicePrincipal.DisplayName, - "parent_type": parent.ResourceType, - "parent_id": parent.Resource, + "parent_type": parent.GetResourceType(), + "parent_id": parent.GetResource(), } options := []rs.ResourceOption{ rs.WithResourceProfile(profile), } + // keep the parent resource id, only if the parent resource is account - if parent.ResourceType == accountResourceType.Id { + if parent.GetResourceType() == accountResourceType.Id { options = append(options, rs.WithParentResourceID(parent)) } @@ -220,6 +221,34 @@ func (s *servicePrincipalBuilder) Grants(ctx context.Context, resource *v2.Resou return rv, nil, nil } +// Get re-fetches a single service principal, used to re-sync it after a RESOURCE_CHANGE event. +func (s *servicePrincipalBuilder) Get(ctx context.Context, resourceId *v2.ResourceId, parentResourceId *v2.ResourceId) (*v2.Resource, annotations.Annotations, error) { + var workspaceId string + if parentResourceId.GetResourceType() == workspaceResourceType.Id { + workspaceId = parentResourceId.Resource + } + + servicePrincipal, rateLimitData, err := s.client.GetServicePrincipal(databricks.WithoutCache(ctx), workspaceId, resourceId.Resource) + annos := annotations.Annotations{} + if rateLimitData != nil { + annos.WithRateLimiting(rateLimitData) + } + if err != nil { + return nil, annos, fmt.Errorf("databricks-connector: failed to get service principal %s: %w", resourceId.Resource, err) + } + + resource, err := s.servicePrincipalResource(ctx, servicePrincipal, parentResourceId) + if err != nil { + return nil, nil, err + } + + ctxzap.Extract(ctx).Debug("databricks-connector: Get: refreshed service principal", + zap.String("resource_id", resourceId.Resource), + zap.String("parent_resource_id", parentResourceId.GetResource()), + ) + return resource, annos, nil +} + func (s *servicePrincipalBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { l := ctxzap.Extract(ctx) diff --git a/pkg/connector/users.go b/pkg/connector/users.go index 5e3f780b..028e3e29 100644 --- a/pkg/connector/users.go +++ b/pkg/connector/users.go @@ -11,6 +11,8 @@ import ( "github.com/conductorone/baton-sdk/pkg/connectorbuilder" rs "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/conductorone/baton-sdk/pkg/uhttp" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -66,8 +68,9 @@ func (u *userBuilder) userResource(ctx context.Context, user *databricks.User, p rs.WithResourceStatus(status, ""), rs.WithAnnotation(&v2.RawId{Id: user.ID}), } + // keep the parent resource id, only if the parent resource is account - if parent.ResourceType == accountResourceType.Id { + if parent.GetResourceType() == accountResourceType.Id { options = append(options, rs.WithParentResourceID(parent)) } @@ -272,6 +275,34 @@ func (o *userBuilder) CreateAccount(ctx context.Context, accountInfo *v2.Account }, nil, nil, nil } +// Get re-fetches a single user, used to re-sync it after a RESOURCE_CHANGE event. +func (u *userBuilder) Get(ctx context.Context, resourceId *v2.ResourceId, parentResourceId *v2.ResourceId) (*v2.Resource, annotations.Annotations, error) { + var workspaceId string + if parentResourceId.GetResourceType() == workspaceResourceType.Id { + workspaceId = parentResourceId.Resource + } + + user, rateLimitData, err := u.client.GetUser(databricks.WithoutCache(ctx), workspaceId, resourceId.Resource) + annos := annotations.Annotations{} + if rateLimitData != nil { + annos.WithRateLimiting(rateLimitData) + } + if err != nil { + return nil, annos, fmt.Errorf("databricks-connector: failed to get user %s: %w", resourceId.Resource, err) + } + + resource, err := u.userResource(ctx, user, parentResourceId) + if err != nil { + return nil, nil, err + } + + ctxzap.Extract(ctx).Debug("databricks-connector: Get: refreshed user", + zap.String("resource_id", resourceId.Resource), + zap.String("parent_resource_id", parentResourceId.GetResource()), + ) + return resource, annos, nil +} + func (o *userBuilder) Delete(ctx context.Context, resourceId *v2.ResourceId) (annotations.Annotations, error) { _, err := o.client.DeleteUser(ctx, "", resourceId.Resource) if err != nil { diff --git a/pkg/connector/validate_test.go b/pkg/connector/validate_test.go index 225b54e6..04678e99 100644 --- a/pkg/connector/validate_test.go +++ b/pkg/connector/validate_test.go @@ -5,9 +5,11 @@ import ( "io" "net/http" "strings" + "sync" "testing" "github.com/conductorone/baton-databricks/pkg/databricks" + "github.com/conductorone/baton-sdk/pkg/pagination" ) // rolesTransport answers the assignable-roles calls Validate makes. failAccount @@ -54,3 +56,112 @@ func TestValidateOAuthAccountCheckFailureReturnsError(t *testing.T) { t.Fatal("Validate: want error on OAuth account check failure, got nil") } } + +// incrementalTransport serves the account calls Validate always makes (roles, one +// workspace) and records every path, so tests can assert whether any incremental-sync +// call (SQL warehouse lookup, statement execution) was made. +type incrementalTransport struct { + mu sync.Mutex + paths []string +} + +func (t *incrementalTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.mu.Lock() + t.paths = append(t.paths, req.URL.Path) + t.mu.Unlock() + + status, body := http.StatusOK, `{"roles":[]}` + switch { + case strings.HasSuffix(req.URL.Path, "/accounts/acct-1/workspaces"): + body = `[{"workspace_id":1,"workspace_name":"ws1","deployment_name":"ws1"}]` + case strings.Contains(req.URL.Path, "/sql/warehouses/"): + status, body = http.StatusNotFound, `{"error_code":"RESOURCE_DOES_NOT_EXIST","message":"not found"}` + } + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil +} + +func (t *incrementalTransport) hitIncrementalSyncAPI() bool { + t.mu.Lock() + defer t.mu.Unlock() + for _, p := range t.paths { + if strings.Contains(p, "/sql/") { + return true + } + } + return false +} + +// No sql-warehouse-id means incremental sync is off: Validate succeeds without touching +// any warehouse/audit-log API. +func TestValidateWithoutWarehouseIDDisablesIncrementalSync(t *testing.T) { + tr := &incrementalTransport{} + d := newValidateConnector(t, &databricks.NoAuth{}, tr) + + if _, err := d.Validate(context.Background()); err != nil { + t.Fatalf("Validate: want nil with incremental sync disabled, got %v", err) + } + if tr.hitIncrementalSyncAPI() { + t.Errorf("Validate made incremental-sync calls with no sql-warehouse-id: %v", tr.paths) + } +} + +// Incremental sync misconfiguration (unknown warehouse, no workspace in scope) must not +// block full syncs, so Validate succeeds without probing the warehouse. +func TestValidateWithWarehouseIDDoesNotProbeIncrementalSync(t *testing.T) { + cases := []struct { + name string + workspaces []string + }{ + {name: "unknown warehouse"}, + {name: "no workspace in scope", workspaces: []string{"not-a-workspace"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tr := &incrementalTransport{} + d := newValidateConnector(t, &databricks.NoAuth{}, tr) + d.sqlWarehouseID = "wh-missing" + d.workspaces = tc.workspaces + + if _, err := d.Validate(context.Background()); err != nil { + t.Fatalf("Validate: want nil, got %v", err) + } + if tr.hitIncrementalSyncAPI() { + t.Errorf("Validate made incremental-sync calls: %v", tr.paths) + } + }) + } +} + +// A whitespace-only sql-warehouse-id counts as unset, so incremental sync stays off. +func TestNewTreatsBlankWarehouseIDAsDisabled(t *testing.T) { + d, err := New(context.Background(), "cloud.databricks.com", "accounts.cloud.databricks.com", "acct-1", "", &databricks.NoAuth{}, nil, nil, " ") + if err != nil { + t.Fatalf("New: %v", err) + } + if d.sqlWarehouseID != "" { + t.Errorf("sqlWarehouseID = %q, want empty (incremental sync disabled)", d.sqlWarehouseID) + } +} + +// With no sql-warehouse-id the feed is a no-op: no events, no API calls. +func TestListEventsWithoutWarehouseIDIsNoop(t *testing.T) { + tr := &incrementalTransport{} + d := newValidateConnector(t, &databricks.NoAuth{}, tr) + feed := newAuditEventFeed(d.client, nil, "") + + events, state, _, err := feed.ListEvents(context.Background(), nil, &pagination.StreamToken{}) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(events) != 0 || state.HasMore { + t.Errorf("ListEvents = %d events, HasMore=%v; want none", len(events), state.HasMore) + } + if len(tr.paths) != 0 { + t.Errorf("ListEvents made API calls with incremental sync disabled: %v", tr.paths) + } +} diff --git a/pkg/connector/workspaces.go b/pkg/connector/workspaces.go index 20990ca6..1dee3c4a 100644 --- a/pkg/connector/workspaces.go +++ b/pkg/connector/workspaces.go @@ -269,6 +269,31 @@ func (w *workspaceBuilder) Grants(ctx context.Context, resource *v2.Resource, at return rv, &rs.SyncOpResults{NextPageToken: nextPage, Annotations: annos}, nil } +// Get re-fetches a single workspace, used to re-sync it after a RESOURCE_CHANGE event. +// GetWorkspace excludes it the same way ListWorkspaces does, so a deliberately excluded +// workspace naturally comes back not-found rather than needing a separate check here. +func (w *workspaceBuilder) Get(ctx context.Context, resourceId *v2.ResourceId, parentResourceId *v2.ResourceId) (*v2.Resource, annotations.Annotations, error) { + workspace, rateLimitData, err := w.client.GetWorkspace(databricks.WithoutCache(ctx), resourceId.Resource) + annos := annotations.Annotations{} + if rateLimitData != nil { + annos.WithRateLimiting(rateLimitData) + } + if err != nil { + return nil, annos, fmt.Errorf("databricks-connector: failed to get workspace %s: %w", resourceId.Resource, err) + } + + resource, err := workspaceResource(ctx, workspace, parentResourceId) + if err != nil { + return nil, annos, err + } + + ctxzap.Extract(ctx).Debug("databricks-connector: Get: refreshed workspace", + zap.String("resource_id", resourceId.Resource), + zap.String("parent_resource_id", parentResourceId.GetResource()), + ) + return resource, annos, nil +} + // workspacePrincipalID returns the numeric principal ID the permission assignments API expects. // Groups are only synced under the account, so only "account//group/" group IDs of // the given account are accepted; anything else is rejected. diff --git a/pkg/databricks/client.go b/pkg/databricks/client.go index 0678ab24..c01e3899 100644 --- a/pkg/databricks/client.go +++ b/pkg/databricks/client.go @@ -11,10 +11,10 @@ import ( "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" - "google.golang.org/grpc/codes" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/uhttp" + "google.golang.org/grpc/codes" ) const ( @@ -660,6 +660,30 @@ func (c *Client) ListWorkspaces( return filtered, ratelimitData, nil } +// GetWorkspace finds a single workspace by deployment name from the account's +// workspace list (there is no single-workspace GET endpoint). +func (c *Client) GetWorkspace( + ctx context.Context, + deploymentName string, +) ( + *Workspace, + *v2.RateLimitDescription, + error, +) { + workspaces, ratelimitData, err := c.ListWorkspaces(ctx) + if err != nil { + return nil, ratelimitData, err + } + + for _, w := range workspaces { + if w.DeploymentName == deploymentName { + return &w, ratelimitData, nil + } + } + + return nil, ratelimitData, uhttp.WrapErrors(codes.NotFound, fmt.Sprintf("workspace %s not found", deploymentName)) +} + func (c *Client) ListWorkspaceMembers( ctx context.Context, workspaceId string, diff --git a/pkg/databricks/request.go b/pkg/databricks/request.go index e22fb6c2..468c8a3e 100644 --- a/pkg/databricks/request.go +++ b/pkg/databricks/request.go @@ -88,6 +88,19 @@ func nameWorkspace403Remedy(workspaceId string, err error) error { return err } +type noCacheKey struct{} + +// WithoutCache marks ctx so GETs made with it bypass the uhttp response cache, for +// callers that need fresh data (statement polling, incremental-sync refreshes). +func WithoutCache(ctx context.Context) context.Context { + return context.WithValue(ctx, noCacheKey{}, true) +} + +func isNoCache(ctx context.Context) bool { + v, _ := ctx.Value(noCacheKey{}).(bool) + return v +} + func (c *Client) Get( ctx context.Context, urlAddress *url.URL, @@ -235,6 +248,9 @@ func (c *Client) doRequestWithOptions( if body != nil { options = append(options, uhttp.WithJSONBody(body)) } + if method == http.MethodGet && isNoCache(ctx) { + options = append(options, uhttp.WithNoCache()) + } options = append(options, extraOptions...) req, err := c.httpClient.NewRequest(ctx, method, uri, options...) diff --git a/pkg/databricks/sql.go b/pkg/databricks/sql.go new file mode 100644 index 00000000..f7818fc6 --- /dev/null +++ b/pkg/databricks/sql.go @@ -0,0 +1,340 @@ +package databricks + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + "google.golang.org/grpc/codes" +) + +const ( + statementsEndpoint = "/api/2.0/sql/statements" + warehousesEndpoint = "/api/2.0/sql/warehouses" + + statementWaitTimeout = "30s" + + warehouseStateRunning = "RUNNING" + warehouseStateStarting = "STARTING" +) + +// Vars so tests can shorten them. +var ( + statementPollInterval = 2 * time.Second + statementPollMaxWait = 5 * time.Minute +) + +type StatementState string + +const ( + StatementStatePending StatementState = "PENDING" + StatementStateRunning StatementState = "RUNNING" + StatementStateSucceeded StatementState = "SUCCEEDED" + StatementStateFailed StatementState = "FAILED" + StatementStateCanceled StatementState = "CANCELED" + StatementStateClosed StatementState = "CLOSED" +) + +// StatementParameter binds a named parameter referenced as ":name" in a SQL statement. +type StatementParameter struct { + Name string `json:"name"` + Value string `json:"value"` + Type string `json:"type,omitempty"` +} + +type statementRequestBody struct { + WarehouseID string `json:"warehouse_id"` + Statement string `json:"statement"` + WaitTimeout string `json:"wait_timeout,omitempty"` + Format string `json:"format"` + Disposition string `json:"disposition"` + Parameters []StatementParameter `json:"parameters,omitempty"` +} + +type statementError struct { + ErrorCode string `json:"error_code"` + Message string `json:"message"` +} + +type statementStatus struct { + State StatementState `json:"state"` + Error *statementError `json:"error,omitempty"` +} + +type statementManifest struct { + Schema struct { + Columns []struct { + Name string `json:"name"` + } `json:"columns"` + } `json:"schema"` +} + +type statementResultChunk struct { + NextChunkIndex *int `json:"next_chunk_index,omitempty"` + DataArray [][]string `json:"data_array"` +} + +type statementResponse struct { + StatementID string `json:"statement_id"` + Status statementStatus `json:"status"` + Manifest statementManifest `json:"manifest"` + Result statementResultChunk `json:"result"` +} + +// StatementResult is the flattened result of a SQL statement executed via the +// Statement Execution API, with all result chunks already collected. +type StatementResult struct { + Columns []string + Rows [][]string +} + +// ExecuteStatement runs a SQL statement via the Statement Execution API and returns every +// row, along with the rate-limit info from the last call that reported any. +func (c *Client) ExecuteStatement( + ctx context.Context, + workspaceId string, + warehouseId string, + statement string, + params ...StatementParameter, +) (*StatementResult, *v2.RateLimitDescription, error) { + l := ctxzap.Extract(ctx) + u := c.workspaceUrl(workspaceId).JoinPath(statementsEndpoint) + + body := statementRequestBody{ + WarehouseID: warehouseId, + Statement: statement, + WaitTimeout: statementWaitTimeout, + Format: "JSON_ARRAY", + Disposition: "INLINE", + Parameters: params, + } + + var res statementResponse + rateLimit, err := c.Post(ctx, u, body, &res) + if err != nil { + return nil, rateLimit, fmt.Errorf("failed to submit statement: %w", err) + } + l.Debug("ExecuteStatement: submitted sql statement", + zap.String("workspace_id", workspaceId), + zap.String("warehouse_id", warehouseId), + zap.String("statement_id", res.StatementID), + zap.String("state", string(res.Status.State)), + ) + + res, polledRateLimit, err := c.pollStatement(ctx, workspaceId, warehouseId, res) + if polledRateLimit != nil { + rateLimit = polledRateLimit + } + if err != nil { + return nil, rateLimit, err + } + + if res.Status.State != StatementStateSucceeded { + msg := "" + if res.Status.Error != nil { + msg = res.Status.Error.Message + } + // FAILED is usually a query/permission problem; CANCELED/CLOSED means it was stopped. + code := codes.FailedPrecondition + if res.Status.State != StatementStateFailed { + code = codes.Aborted + } + return nil, rateLimit, uhttp.WrapErrors(code, fmt.Sprintf("statement %s did not succeed: state=%s message=%s", res.StatementID, res.Status.State, msg)) + } + + result, resultRateLimit, err := c.collectStatementResult(ctx, workspaceId, res) + if resultRateLimit != nil { + rateLimit = resultRateLimit + } + if err != nil { + return nil, rateLimit, err + } + + l.Debug("ExecuteStatement: sql statement succeeded", + zap.String("statement_id", res.StatementID), + zap.Int("column_count", len(result.Columns)), + zap.Int("row_count", len(result.Rows)), + ) + return result, rateLimit, nil +} + +// pollStatement blocks until the statement reaches a terminal state, for the case of a +// cold warehouse start still running after the initial statementWaitTimeout. Capped at +// statementPollMaxWait so a warehouse stuck PENDING/RUNNING can't hang the caller +// indefinitely; on giving up (or on ctx cancellation) it cancels the statement so it stops +// occupying the warehouse. +func (c *Client) pollStatement(ctx context.Context, workspaceId, warehouseId string, res statementResponse) (statementResponse, *v2.RateLimitDescription, error) { + l := ctxzap.Extract(ctx) + + pollCtx, cancel := context.WithTimeout(ctx, statementPollMaxWait) + defer cancel() + + var rateLimit *v2.RateLimitDescription + for res.Status.State == StatementStatePending || res.Status.State == StatementStateRunning { + select { + case <-pollCtx.Done(): + return res, rateLimit, c.abandonStatement(ctx, workspaceId, warehouseId, res) + case <-time.After(statementPollInterval): + } + + l.Debug("polling databricks sql statement", zap.String("statement_id", res.StatementID), zap.String("state", string(res.Status.State))) + + u := c.workspaceUrl(workspaceId).JoinPath(statementsEndpoint, res.StatementID) + var polled statementResponse + // No cache: the URL is identical on every poll, so a cached PENDING/RUNNING + // response would otherwise be returned until statementPollMaxWait. + polledRateLimit, err := c.Get(WithoutCache(pollCtx), u, &polled) + if polledRateLimit != nil { + rateLimit = polledRateLimit + } + if err != nil { + // The poll deadline can expire mid-request; that's still a timeout. + if pollCtx.Err() != nil { + return res, rateLimit, c.abandonStatement(ctx, workspaceId, warehouseId, res) + } + return res, rateLimit, fmt.Errorf("failed to poll statement %s: %w", res.StatementID, err) + } + res = polled + } + + return res, rateLimit, nil +} + +// abandonStatement cancels a statement that didn't finish in time and returns ctx's error if +// the caller gave up, or else a DeadlineExceeded error saying why the statement didn't finish. +func (c *Client) abandonStatement(ctx context.Context, workspaceId, warehouseId string, res statementResponse) error { + c.cancelStatement(ctx, workspaceId, res.StatementID) + if err := ctx.Err(); err != nil { + return err + } + + l := ctxzap.Extract(ctx) + l.Debug("sql statement did not reach a terminal state before poll timeout, canceled it", + zap.String("statement_id", res.StatementID), + zap.String("state", string(res.Status.State)), + zap.Duration("max_wait", statementPollMaxWait), + ) + + msg := fmt.Sprintf("statement %s did not finish within %s (state=%s)", res.StatementID, statementPollMaxWait, res.Status.State) + switch res.Status.State { + case StatementStateRunning: + msg += ": the query was still running" + case StatementStatePending: + // PENDING means waiting for compute, so the warehouse state says why. + state, err := c.warehouseState(ctx, workspaceId, warehouseId) + switch { + case err != nil: + l.Debug("failed to get sql warehouse state", zap.String("warehouse_id", warehouseId), zap.Error(err)) + case state == "": + case state == warehouseStateRunning: + msg += fmt.Sprintf(": SQL warehouse %s is running but did not pick up the statement; it may be at capacity", warehouseId) + case state == warehouseStateStarting: + msg += fmt.Sprintf(": SQL warehouse %s was still starting; a serverless warehouse starts much faster", warehouseId) + default: + msg += fmt.Sprintf(": SQL warehouse %s is %s and has not started; check its Monitoring tab for start failures", warehouseId, state) + } + default: + // Terminal states never reach here: polling only times out while PENDING or RUNNING. + } + + return uhttp.WrapErrors(codes.DeadlineExceeded, msg) +} + +// warehouseState returns the warehouse's state (e.g. RUNNING, STARTING, STOPPED). Like +// cancelStatement, it ignores ctx's cancellation. +func (c *Client) warehouseState(ctx context.Context, workspaceId, warehouseId string) (string, error) { + reqCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + + u := c.workspaceUrl(workspaceId).JoinPath(warehousesEndpoint, warehouseId) + var res struct { + State string `json:"state"` + } + if _, err := c.Get(WithoutCache(reqCtx), u, &res); err != nil { + return "", err + } + return res.State, nil +} + +// cancelStatement best-effort cancels a statement we've given up polling on. The request uses +// ctx's values (for the logger) but not its cancellation, since ctx/pollCtx may already be +// done. Uses /cancel, not DELETE (which only closes an already-terminal statement and +// wouldn't stop one still running). +func (c *Client) cancelStatement(ctx context.Context, workspaceId, statementId string) { + l := ctxzap.Extract(ctx) + + reqCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + + u := c.workspaceUrl(workspaceId).JoinPath(statementsEndpoint, statementId, "cancel") + response := struct{}{} + if _, err := c.Post(reqCtx, u, nil, &response); err != nil { + l.Debug("failed to cancel timed-out sql statement", zap.String("statement_id", statementId), zap.Error(err)) + } +} + +func (c *Client) collectStatementResult(ctx context.Context, workspaceId string, res statementResponse) (*StatementResult, *v2.RateLimitDescription, error) { + columns := make([]string, len(res.Manifest.Schema.Columns)) + for i, col := range res.Manifest.Schema.Columns { + columns[i] = col.Name + } + + rows := make([][]string, 0, len(res.Result.DataArray)) + rows = append(rows, res.Result.DataArray...) + + var rateLimit *v2.RateLimitDescription + nextChunk := res.Result.NextChunkIndex + for nextChunk != nil { + u := c.workspaceUrl(workspaceId).JoinPath(statementsEndpoint, res.StatementID, "result", "chunks", strconv.Itoa(*nextChunk)) + var chunk statementResultChunk + chunkRateLimit, err := c.Get(ctx, u, &chunk) + if chunkRateLimit != nil { + rateLimit = chunkRateLimit + } + if err != nil { + return nil, rateLimit, fmt.Errorf("failed to fetch statement result chunk %d: %w", *nextChunk, err) + } + ctxzap.Extract(ctx).Debug("collectStatementResult: fetched sql statement result chunk", + zap.String("statement_id", res.StatementID), + zap.Int("chunk_index", *nextChunk), + zap.Int("row_count", len(chunk.DataArray)), + ) + rows = append(rows, chunk.DataArray...) + nextChunk = chunk.NextChunkIndex + } + + return &StatementResult{Columns: columns, Rows: rows}, rateLimit, nil +} + +// WarehouseExists reports whether warehouseId exists in workspaceId, since SQL warehouses +// are workspace-scoped with no account-level lookup. +func (c *Client) WarehouseExists(ctx context.Context, workspaceId, warehouseId string) (bool, *v2.RateLimitDescription, error) { + u := c.workspaceUrl(workspaceId).JoinPath(warehousesEndpoint, warehouseId) + + var res struct{} + rateLimit, err := c.Get(ctx, u, &res) + if err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + ctxzap.Extract(ctx).Debug("WarehouseExists: sql warehouse not found in workspace", + zap.String("workspace_id", workspaceId), + zap.String("warehouse_id", warehouseId), + ) + return false, rateLimit, nil + } + return false, rateLimit, err + } + + ctxzap.Extract(ctx).Debug("WarehouseExists: sql warehouse found in workspace", + zap.String("workspace_id", workspaceId), + zap.String("warehouse_id", warehouseId), + ) + return true, rateLimit, nil +} diff --git a/pkg/databricks/sql_test.go b/pkg/databricks/sql_test.go new file mode 100644 index 00000000..b86d24e3 --- /dev/null +++ b/pkg/databricks/sql_test.go @@ -0,0 +1,130 @@ +package databricks + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type rewriteHostTransport struct{ target *url.URL } + +func (t rewriteHostTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.URL.Scheme = t.target.Scheme + req.URL.Host = t.target.Host + return http.DefaultTransport.RoundTrip(req) +} + +func newSQLTestClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + target, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("url.Parse: %v", err) + } + c, err := NewClient(context.Background(), &http.Client{Transport: rewriteHostTransport{target: target}}, + "example.cloud.databricks.com", "accounts.cloud.databricks.com", "acc-1", "", &NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + return c +} + +func shortenStatementPolling(t *testing.T) { + t.Helper() + interval, maxWait := statementPollInterval, statementPollMaxWait + statementPollInterval, statementPollMaxWait = 10*time.Millisecond, 100*time.Millisecond + t.Cleanup(func() { statementPollInterval, statementPollMaxWait = interval, maxWait }) +} + +func TestExecuteStatementTimeoutExplainsWarehouseState(t *testing.T) { + shortenStatementPolling(t) + + cases := []struct { + name string + statementState StatementState + warehouseState string + want string + }{ + {"stopped warehouse", StatementStatePending, "STOPPED", "SQL warehouse wh-1 is STOPPED and has not started"}, + {"starting warehouse", StatementStatePending, "STARTING", "SQL warehouse wh-1 was still starting"}, + {"running warehouse", StatementStatePending, "RUNNING", "SQL warehouse wh-1 is running but did not pick up the statement"}, + {"running query", StatementStateRunning, "RUNNING", "the query was still running"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var canceled atomic.Bool + c := newSQLTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/cancel"): + canceled.Store(true) + fmt.Fprint(w, `{}`) + case strings.HasSuffix(r.URL.Path, "/sql/warehouses/wh-1"): + fmt.Fprintf(w, `{"id":"wh-1","state":%q}`, tc.warehouseState) + default: + fmt.Fprintf(w, `{"statement_id":"stmt-1","status":{"state":%q}}`, tc.statementState) + } + }) + + _, _, err := c.ExecuteStatement(context.Background(), "dbc-aaa", "wh-1", "SELECT 1") + if err == nil { + t.Fatal("ExecuteStatement() error = nil, want a timeout") + } + if got := status.Code(err); got != codes.DeadlineExceeded { + t.Errorf("error code = %s, want %s", got, codes.DeadlineExceeded) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error = %q, want it to contain %q", err, tc.want) + } + if !canceled.Load() { + t.Error("statement was not canceled") + } + }) + } +} + +// A poll that is still in flight when the deadline expires must also cancel the statement. +func TestExecuteStatementTimeoutDuringPollCancelsStatement(t *testing.T) { + shortenStatementPolling(t) + + var canceled atomic.Bool + c := newSQLTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/cancel"): + canceled.Store(true) + fmt.Fprint(w, `{}`) + case strings.HasSuffix(r.URL.Path, "/sql/warehouses/wh-1"): + fmt.Fprint(w, `{"id":"wh-1","state":"STOPPED"}`) + case r.Method == http.MethodGet: + select { + case <-r.Context().Done(): + case <-time.After(2 * time.Second): + } + default: + fmt.Fprint(w, `{"statement_id":"stmt-1","status":{"state":"PENDING"}}`) + } + }) + + _, _, err := c.ExecuteStatement(context.Background(), "dbc-aaa", "wh-1", "SELECT 1") + if got := status.Code(err); got != codes.DeadlineExceeded { + t.Fatalf("error = %v, want code %s", err, codes.DeadlineExceeded) + } + if !strings.Contains(err.Error(), "has not started") { + t.Errorf("error = %q, want the warehouse state explained", err) + } + if !canceled.Load() { + t.Error("statement was not canceled") + } +} diff --git a/test-server/README.md b/test-server/README.md new file mode 100644 index 00000000..4162cd4c --- /dev/null +++ b/test-server/README.md @@ -0,0 +1,287 @@ +# Databricks test server + +A local stand-in for the Databricks APIs that baton-databricks calls from `Validate` and the +incremental sync event feed (`databricks_audit_log`, which reads `system.access.audit` through the +SQL Statement Execution API). It lets you run the event feed end to end without a Databricks +account, locally and in CI. + +Go standard library only, built from the root module. State is in memory, seeded at startup and +reset on every start. Full sync and provisioning endpoints are not implemented yet. + +## Running it + +```bash +# Start the server from the repo root; it writes its CA certificate to ./test-server-ca.pem +go run ./test-server/ + +# Build the connector and run the event feed against it; events are printed to stdout as JSON lines +go build ./cmd/baton-databricks +SSL_CERT_FILE=./test-server-ca.pem ./baton-databricks \ + --hostname localhost:8765 --account-hostname localhost:8765 \ + --account-id test-account \ + --databricks-client-id test-client --databricks-client-secret test-secret \ + --sql-warehouse-id wh-serverless \ + --event-feed true --event-feed-id databricks_audit_log \ + --event-feed-start-at "$(date -u -d '2 days ago' +%Y-%m-%dT%H:%M:%SZ)" +``` + +`--event-feed true` is what selects the event feed mode; `--event-feed-id` alone doesn't. The +connector's logs go to stderr, so stdout holds only events. + +To compare with the expected output (event times depend on when the server started, so +`occurredAt` is dropped): + +```bash +normalize() { jq -c -S 'del(.occurredAt)' "$1" | sort; } +diff <(normalize test-server/testdata/expected-events.jsonl) <(normalize events.jsonl) +``` + +### Flags + +| Flag | Default | Meaning | +|---|---|---| +| `--addr` | `localhost:8765` | Listen address | +| `--ca-out` | `./test-server-ca.pem` | Where to write the generated CA certificate | +| `--warehouse-start-delay` | `0s` | How long a stopped warehouse takes to reach `RUNNING` once a statement starts it | +| `--chunk-rows` | `0` | Rows per result chunk; `0` returns every row in one chunk | + +### TLS and hosts + +The connector always builds `https://` URLs, so the server serves TLS. At startup it creates a +throwaway CA and a certificate for `localhost`, `*.localhost` and each seeded workspace host +(`ws-alpha.localhost`, ...), and writes the CA to `--ca-out`. The connector trusts it through +`SSL_CERT_FILE`. The workspace hosts are listed because OpenSSL, and so curl, doesn't match a +`*.localhost` wildcard; Go does. + +The server routes by `Host` header: + +- `localhost:8765` is the account API and OAuth host (`--account-hostname`). +- `.localhost:8765` is that workspace (the connector builds + `.`). Go resolves any `*.localhost` name to loopback, so no DNS setup + is needed. An unknown deployment name gets a 404. + +**macOS:** Go ignores `SSL_CERT_FILE` on macOS and uses the keychain. Either trust the CA in the +keychain or run the connector in a Linux container. + +### Other warehouses + +- `--sql-warehouse-id wh-pro` reads the same events through a stopped warehouse in `ws-beta`. + The first statement starts it. With `--warehouse-start-delay 35s` the connector's first wait (30s) + times out and it then polls until the warehouse is running. +- `--sql-warehouse-id wh-broken` never starts. The connector gives up after its 5-minute limit, + cancels the statement, and fails with "SQL warehouse wh-broken is STOPPED and has not started". + +## Auth + +| | Value | +|---|---| +| Account ID | `test-account` | +| Client ID / secret | `test-client` / `test-secret` | +| Access token | `test-access-token` | + +The token endpoint takes the client ID and secret as HTTP Basic auth, with form fields +`grant_type=client_credentials` and `scope=all-apis`. Every other endpoint (except `/health`) +needs `Authorization: Bearer test-access-token` and returns 401 +`{"error_code": "UNAUTHENTICATED", ...}` without it. + +## Endpoints + +| Host | Method | Path | Docs | +|---|---|---|---| +| account | POST | `/oidc/accounts/{account_id}/v1/token` | [OAuth M2M](https://docs.databricks.com/aws/en/dev-tools/auth/oauth-m2m) | +| account | GET | `/api/2.0/preview/accounts/{account_id}/access-control/assignable-roles` | [Account access control](https://docs.databricks.com/api/account/accountaccesscontrol/getassignablerolesforresource) | +| account | GET | `/api/2.0/accounts/{account_id}/workspaces` | [List workspaces](https://docs.databricks.com/api/account/workspaces/list) | +| account | GET | `/api/2.0/accounts/{account_id}/scim/v2/Users/{id}` | [Get account user](https://docs.databricks.com/api/account/accountusers/get) | +| account | GET | `/api/2.0/accounts/{account_id}/scim/v2/Groups/{id}` | [Get account group](https://docs.databricks.com/api/account/accountgroups/get) | +| account | GET | `/api/2.0/accounts/{account_id}/scim/v2/ServicePrincipals/{id}` | [Get account service principal](https://docs.databricks.com/api/account/accountserviceprincipals/get) | +| workspace | GET | `/api/2.0/sql/warehouses/{id}` | [Get a warehouse](https://docs.databricks.com/api/workspace/warehouses/get) | +| workspace | POST | `/api/2.0/sql/statements` | [Execute a statement](https://docs.databricks.com/api/workspace/statementexecution/executestatement) | +| workspace | GET | `/api/2.0/sql/statements/{id}` | [Get a statement](https://docs.databricks.com/api/workspace/statementexecution/getstatement) | +| workspace | POST | `/api/2.0/sql/statements/{id}/cancel` | [Cancel a statement](https://docs.databricks.com/api/workspace/statementexecution/cancelexecution) | +| workspace | GET | `/api/2.0/sql/statements/{id}/result/chunks/{n}` | [Get result chunk](https://docs.databricks.com/api/workspace/statementexecution/getstatementresultchunkn) | +| any | GET | `/health` | None: local readiness check | + +Every route in `handlers.go` links its doc page. + +## Behavior and provenance + +Code comments label each behavior: + +- `DOC`: stated in the official Databricks docs (with a link). +- `ASSUMPTION`: not documented; our best guess at the real behavior. +- `DECISION`: a choice made for this server, with no real-world counterpart. + +### From the docs + +- Token endpoint: Basic client credentials, `grant_type=client_credentials`, `scope=all-apis`; + returns `{access_token, token_type: "Bearer", expires_in: 3600}`. +- API errors use `{error_code, message}`; a missing or wrong token is 401 `UNAUTHENTICATED`. +- The workspaces list is a bare JSON array (`workspace_id` int64, `workspace_name`, + `deployment_name`, `aws_region`, `workspace_status`, ...). +- SCIM user, group and service principal objects and their fields. +- Warehouse fields and `state` values (`STARTING`, `RUNNING`, `STOPPED`, ...). A statement on a + warehouse that isn't running stays `PENDING`. +- `wait_timeout` is `0s` or `5s`–`50s` (default `10s`); anything else is 400 + `INVALID_PARAMETER_VALUE`. `on_wait_timeout` is `CONTINUE` (default) or `CANCEL`. +- A statement that finishes within `wait_timeout` returns its result; otherwise the response has + its ID and `PENDING`. GET returns the manifest and first chunk once `SUCCEEDED`. Cancel returns + `{}` and the statement becomes `CANCELED`. +- `data_array` values are strings, nulls are JSON `null`, and `next_chunk_index` is absent on the + last chunk. Column `type_name` values (`STRING`, `TIMESTAMP`, `LONG`, `MAP`, ...). +- Parameters are named `:name` markers with an optional `type` (default `STRING`). +- `system.access.audit` columns; account-level events have `workspace_id = 0`; the + `request_params` keys of each event ([audit log reference](https://docs.databricks.com/aws/en/admin/account-settings/audit-logs)), + including workspace-level `createGroup` without `targetGroupId` and `updatePermissionAssignment` + without `workspace_id`. +- Regionality: "Most audit logs are only available in the region of the workspace" + ([system tables: audit logs](https://docs.databricks.com/aws/en/admin/system-tables/audit-logs)). + Here, a warehouse sees workspace-level rows only from workspaces in its own region, and + account-level rows from every region. + +### What's not from the docs + +| Kind | Behavior | +|---|---| +| `ASSUMPTION` | Token endpoint failures use the RFC 6749 body `{error, error_description}` (401 bad client, 400 bad grant or scope). | +| `ASSUMPTION` | An account ID other than `test-account` gets 404 `RESOURCE_DOES_NOT_EXIST`. | +| `ASSUMPTION` | SCIM 404 body uses the SCIM error schema (`schemas`, `detail`, `status: "404"`). | +| `ASSUMPTION` | Unknown API paths get 404 `ENDPOINT_NOT_FOUND`. | +| `ASSUMPTION` | A warehouse that doesn't exist in the request's workspace is 404 `RESOURCE_DOES_NOT_EXIST`, both for GET warehouse and for `warehouse_id` on a statement. The docs only say it's an error. | +| `ASSUMPTION` | A positional (unnamed) parameter is 400 `INVALID_PARAMETER_VALUE`. | +| `ASSUMPTION` | Submitting a statement starts a stopped warehouse, which is `RUNNING` after `--warehouse-start-delay`. | +| `ASSUMPTION` | `JSON_ARRAY` value formats: `TIMESTAMP` as `2006-01-02T15:04:05.000Z` (UTC, milliseconds), `DATE` as `2006-01-02`, `MAP` as a JSON object string. | +| `ASSUMPTION` | An empty result is one chunk with `row_count: 0` and no `data_array`. | +| `ASSUMPTION` | Unknown statement IDs are 404 `RESOURCE_DOES_NOT_EXIST`; an out-of-range chunk or a chunk of a statement without a result is 400. | +| `ASSUMPTION` | An unbound `:name` marker fails the statement with `[UNBOUND_SQL_PARAMETER]`. Accepted `TIMESTAMP` parameter formats: RFC 3339 or `yyyy-mm-dd hh:mm:ss[.ffffff]`. | +| `DECISION` | Hardcoded credentials, account ID and token. | +| `DECISION` | TLS with a generated CA; routing by `Host` header; `/health`. | +| `DECISION` | Assignable roles returns a fixed list. | +| `DECISION` | Seed warehouses: `wh-serverless` running, `wh-pro` stopped, `wh-broken` never starts (stays `STOPPED` with `health.status: FAILED`). | +| `DECISION` | Statements go straight from `PENDING` to `SUCCEEDED`/`FAILED` once the warehouse runs; there's no visible `RUNNING` state. | +| `DECISION` | One result chunk unless `--chunk-rows` is set; only `format: JSON_ARRAY` and `disposition: INLINE` (no `EXTERNAL_LINKS`); no 12-hour statement expiry. | +| `DECISION` | SQL handling (below). | +| `DECISION` | Seed data is synthetic (below). | + +### SQL handling (`DECISION`) + +The server doesn't run SQL. It recognizes `SELECT FROM system.access.audit` and: + +- binds the typed `:name` parameters; +- applies the predicates the connector's query uses, recognized by shape: + `event_date >= :d`, `(event_time > :t OR (event_time = :t AND event_id > :id))`, + `event_time <= :cutoff`, `service_name IN (...)` and `action_name IN (...)`; +- requires `ORDER BY event_time ASC, event_id ASC` and applies a trailing `LIMIT n`; +- fails any other statement with `FAILED` and "test-server: unsupported statement". + +This is deliberately tied to the shape of the connector's audit query +(`pkg/connector/audit_event_feed.go`). If that query changes shape, update `statements.go`. + +## Seed data + +All IDs and times are made up. Times are relative to server start. + +**Workspaces** + +| `deployment_name` | `workspace_id` | Region | +|---|---|---| +| `ws-alpha` | 7474645012345671 | us-east-1 | +| `ws-beta` | 7474645012345672 | us-east-1 | +| `ws-gamma` | 8828000011112223 | eu-west-1 | + +**Principals** + +| Kind | ID | Name | +|---|---|---| +| User | 4410000000000001 | alice@example.com | +| User | 4410000000000002 | bob@example.com | +| User | 4410000000000003 | carol@example.com (inactive) | +| User | 4410000000000004 | dave@example.com | +| Group | 5520000000000001 | data-engineers | +| Group | 5520000000000002 | analysts | +| Service principal | 6630000000000001 | ci-bot | +| None (deleted principal) | 9990000000000001 | matches nothing | + +**Warehouses** + +| ID | Workspace | State | +|---|---|---| +| `wh-serverless` | `ws-alpha` | `RUNNING` (serverless) | +| `wh-pro` | `ws-beta` | `STOPPED`; starts on the first statement | +| `wh-broken` | `ws-alpha` | `STOPPED`; never starts | + +**Audit rows and the events they should produce** + +Rows the connector should read fall between 26 and 5 hours before server start: older than its +4-hour lag window and inside an `--event-feed-start-at` of 2 days ago. Event IDs are +`/`, and seed event IDs are `e0000001-0000-4000-8000-0000000000NN`. The expected +events, as protojson, are in `testdata/expected-events.jsonl`. Every principal is parented to the +account, as in full sync. + +| # | Time | Workspace | `service_name` / `action_name` | Key `request_params` | Expected events | +|---|---|---|---|---|---| +| 1 | −26h | 0 | accounts / `add` | targetUserId dave | dave changed | +| 2 | −25h | 0 | accounts / `activateUser` | targetUserId bob | bob changed | +| 3 | −24h | 0 | accounts / `deactivateUser` | targetUserId carol | carol changed | +| 4 | −23h | 0 | accounts / `createGroup` | targetGroupId analysts | analysts changed | +| 5 | −22h | 0 | accounts / `addPrincipalToGroup` | data-engineers ← alice | grant: alice gets data-engineers `member` | +| 6 | −21h | 0 | accounts / `addPrincipalToGroup` | data-engineers ← analysts | grant: group analysts gets data-engineers `member` | +| 7 | −20h | 0 | accounts / `addPrincipalsToGroup` | analysts ← ci-bot | grant: service principal ci-bot gets analysts `member` | +| 8 | −19h | 0 | accounts / `removePrincipalFromGroup` | data-engineers ← bob | revoke: bob loses data-engineers `member` | +| 9 | −18h | 0 | accounts / `removePrincipalFromGroup` | data-engineers ← deleted principal | data-engineers changed (member can't be resolved) | +| 10 | −17h | 0 | accounts / `setAccountAdmin` | targetUserId alice | alice changed; `account_admin` role changed | +| 11 | −16h | ws-alpha | accounts / `setAdmin` | targetUserId bob | bob changed; `account_admin` role changed (the connector refreshes it in case `setAdmin` is account admin, which the reference is ambiguous about) | +| 12 | −15h | ws-alpha | accounts / `removeAdmin` | targetUserId bob | bob changed | +| 13 | −14h | ws-alpha | accounts / `createGroup` (workspace-level, no `targetGroupId`) | targetUserId alice | none | +| 14 | −13h | ws-beta | accounts / `delete` | targetUserId carol | carol changed | +| 15 | −12h | 0 | workspace / `addPermissionAssignment` | workspace_id ws-alpha | ws-alpha changed | +| 16 | −11h | 0 | workspace / `updatePermissionAssignment` (no `workspace_id`) | principal_id dave | none | +| 17 | −10h | ws-beta | workspace / `deletePermissionAssignment` | workspace_id ws-beta | ws-beta changed | +| 18 | −9h | ws-gamma | accounts / `add` | targetUserId alice | none: eu-west-1 isn't visible from a us-east-1 warehouse | +| 19 | −8h | ws-alpha | accounts / `updateUser` | targetUserId alice | alice changed; `ws-alpha:allow-cluster-create` and `ws-alpha:allow-instance-pool-create` changed | +| 20 | −7h | 0 | accounts / `updateGroup` | targetGroupId analysts | analysts changed | +| 21 | −6h | 0 | accounts / `removeGroup` | targetGroupId data-engineers | data-engineers changed | +| 22 | −6h (same time as 21) | 0 | accounts / `deactivateUser` | targetUserId dave | dave changed | +| 23 | −5h30m | ws-alpha | accounts / `changeDatabricksWorkspaceAcl` | targetUserId carol | ws-alpha changed; `ws-alpha:workspace-access` changed | +| 24 | −5h | ws-alpha | clusters / `create` | | none: not requested | +| 25 | −2h | 0 | accounts / `add` | targetUserId bob | none: newer than the 4-hour lag cutoff | +| 26 | −72h | 0 | accounts / `add` | targetUserId carol | none: older than the feed start | + +That adds up to 25 events. + +## curl examples + +```bash +CA="--cacert ./test-server-ca.pem" + +curl -s $CA https://localhost:8765/health + +TOKEN=$(curl -s $CA -u test-client:test-secret \ + -d grant_type=client_credentials -d scope=all-apis \ + https://localhost:8765/oidc/accounts/test-account/v1/token | jq -r .access_token) + +curl -s $CA -H "Authorization: Bearer $TOKEN" https://localhost:8765/api/2.0/accounts/test-account/workspaces | jq +curl -s $CA -H "Authorization: Bearer $TOKEN" https://localhost:8765/api/2.0/accounts/test-account/scim/v2/Users/4410000000000001 | jq +curl -s $CA -H "Authorization: Bearer $TOKEN" https://ws-alpha.localhost:8765/api/2.0/sql/warehouses/wh-serverless | jq + +curl -s $CA -H "Authorization: Bearer $TOKEN" https://ws-alpha.localhost:8765/api/2.0/sql/statements \ + -d '{ + "warehouse_id": "wh-serverless", + "statement": "SELECT event_id, event_time, action_name FROM system.access.audit WHERE action_name IN ('\''add'\'') ORDER BY event_time ASC, event_id ASC LIMIT 10", + "wait_timeout": "10s" + }' | jq +``` + +## Tests and CI + +`main_test.go` tests the server in process: auth, routing, `wait_timeout` validation, warehouse +and statement state transitions (including the stopped and broken warehouses), cancel and +`on_wait_timeout: CANCEL`, chunking, regionality and the audit filter. It runs with +`go test ./...`. + +The `test-server` job in `.github/workflows/ci.yaml` starts the server, runs the event feed +against `wh-serverless` and diffs the events with `testdata/expected-events.jsonl`. It then runs +against `wh-broken` and checks for the "has not started" message. That step waits out the +connector's 5-minute statement limit. The job needs no secrets. + +If a diff shows up, either the connector or the expectation is wrong; check the row's documented +meaning in the table above before updating `expected-events.jsonl`. diff --git a/test-server/handlers.go b/test-server/handlers.go new file mode 100644 index 00000000..1b88e348 --- /dev/null +++ b/test-server/handlers.go @@ -0,0 +1,262 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + "time" +) + +func (s *server) accountRoutes() *http.ServeMux { + mux := http.NewServeMux() + // DOC: https://docs.databricks.com/aws/en/dev-tools/auth/oauth-m2m + mux.HandleFunc("POST /oidc/accounts/{account_id}/v1/token", requireAccount(s.handleToken)) + // DOC: https://docs.databricks.com/api/account/accountaccesscontrol/getassignablerolesforresource + mux.HandleFunc("GET /api/2.0/preview/accounts/{account_id}/access-control/assignable-roles", + requireBearer(requireAccount(s.handleAssignableRoles))) + // DOC: https://docs.databricks.com/api/account/workspaces/list + mux.HandleFunc("GET /api/2.0/accounts/{account_id}/workspaces", requireBearer(requireAccount(s.handleListWorkspaces))) + // DOC: https://docs.databricks.com/api/account/accountusers/get + mux.HandleFunc("GET /api/2.0/accounts/{account_id}/scim/v2/Users/{id}", requireBearer(requireAccount(s.handleGetUser))) + // DOC: https://docs.databricks.com/api/account/accountgroups/get + mux.HandleFunc("GET /api/2.0/accounts/{account_id}/scim/v2/Groups/{id}", requireBearer(requireAccount(s.handleGetGroup))) + // DOC: https://docs.databricks.com/api/account/accountserviceprincipals/get + mux.HandleFunc("GET /api/2.0/accounts/{account_id}/scim/v2/ServicePrincipals/{id}", + requireBearer(requireAccount(s.handleGetServicePrincipal))) + mux.HandleFunc("/", handleNotFound) + return mux +} + +func (s *server) workspaceRoutes() *http.ServeMux { + mux := http.NewServeMux() + // DOC: https://docs.databricks.com/api/workspace/warehouses/get + mux.HandleFunc("GET /api/2.0/sql/warehouses/{id}", requireBearer(s.handleGetWarehouse)) + // DOC: https://docs.databricks.com/api/workspace/statementexecution/executestatement + mux.HandleFunc("POST /api/2.0/sql/statements", requireBearer(s.handleExecuteStatement)) + // DOC: https://docs.databricks.com/api/workspace/statementexecution/getstatement + mux.HandleFunc("GET /api/2.0/sql/statements/{id}", requireBearer(s.handleGetStatement)) + // DOC: https://docs.databricks.com/api/workspace/statementexecution/cancelexecution + mux.HandleFunc("POST /api/2.0/sql/statements/{id}/cancel", requireBearer(s.handleCancelStatement)) + // DOC: https://docs.databricks.com/api/workspace/statementexecution/getstatementresultchunkn + mux.HandleFunc("GET /api/2.0/sql/statements/{id}/result/chunks/{n}", requireBearer(s.handleGetResultChunk)) + mux.HandleFunc("/", handleNotFound) + return mux +} + +// handleNotFound answers any route this server doesn't implement. +// ASSUMPTION: Databricks answers unknown API paths with 404 ENDPOINT_NOT_FOUND. +func handleNotFound(w http.ResponseWriter, r *http.Request) { + writeError(w, http.StatusNotFound, "ENDPOINT_NOT_FOUND", "test-server: no route for "+r.Method+" "+r.URL.Path) +} + +// handleToken implements the OAuth M2M client-credentials token endpoint. +// DOC: HTTP Basic client ID/secret, grant_type=client_credentials, scope=all-apis; returns +// {access_token, token_type: "Bearer", expires_in: 3600}. +// ASSUMPTION: failures use the RFC 6749 error body ({error, error_description}) with 400/401. +func (s *server) handleToken(w http.ResponseWriter, r *http.Request) { + clientID, clientSecret, ok := r.BasicAuth() + if !ok || clientID != testClientID || clientSecret != testClientSecret { + writeJSON(w, http.StatusUnauthorized, map[string]string{ + "error": "invalid_client", + "error_description": "Client authentication failed", + }) + return + } + if err := r.ParseForm(); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request", "error_description": err.Error()}) + return + } + if r.PostForm.Get("grant_type") != "client_credentials" { + writeJSON(w, http.StatusBadRequest, map[string]string{ + "error": "unsupported_grant_type", + "error_description": "grant_type must be client_credentials", + }) + return + } + if r.PostForm.Get("scope") != "all-apis" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_scope", "error_description": "scope must be all-apis"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "access_token": testAccessToken, + "token_type": "Bearer", + "expires_in": 3600, + "scope": "all-apis", + }) +} + +// handleAssignableRoles returns the roles assignable on a resource. +// DOC: response shape {roles: [{name}]}. DECISION: a fixed list, whatever the resource. +func (s *server) handleAssignableRoles(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "roles": []map[string]string{ + {"name": "roles/marketplace.admin"}, + }, + }) +} + +// handleListWorkspaces returns the account's workspaces. +// DOC: a bare JSON array of workspace objects. +func (s *server) handleListWorkspaces(w http.ResponseWriter, _ *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + out := make([]map[string]any, 0, len(s.st.workspaces)) + for _, ws := range s.st.workspaces { + out = append(out, map[string]any{ + "account_id": testAccountID, + "workspace_id": ws.ID, + "workspace_name": ws.Name, + "deployment_name": ws.DeploymentName, + "aws_region": ws.Region, + "workspace_status": "RUNNING", + "pricing_tier": "PREMIUM", + "creation_time": s.start.Add(-90 * 24 * time.Hour).UnixMilli(), + }) + } + writeJSON(w, http.StatusOK, out) +} + +// DOC: SCIM user, group and service principal objects, per the account SCIM API pages. +func (s *server) handleGetUser(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + u, ok := s.st.users[r.PathValue("id")] + s.mu.Unlock() + if !ok { + writeSCIMNotFound(w, "User "+r.PathValue("id")+" not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, + "id": u.ID, + "userName": u.UserName, + "displayName": u.DisplayName, + "active": u.Active, + "emails": []map[string]any{{"value": u.UserName, "type": "work", "primary": true}}, + }) +} + +func (s *server) handleGetGroup(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + g, ok := s.st.groups[r.PathValue("id")] + s.mu.Unlock() + if !ok { + writeSCIMNotFound(w, "Group "+r.PathValue("id")+" not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"}, + "id": g.ID, + "displayName": g.DisplayName, + }) +} + +func (s *server) handleGetServicePrincipal(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + sp, ok := s.st.servicePrincipals[r.PathValue("id")] + s.mu.Unlock() + if !ok { + writeSCIMNotFound(w, "ServicePrincipal "+r.PathValue("id")+" not found") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ServicePrincipal"}, + "id": sp.ID, + "applicationId": sp.ApplicationID, + "displayName": sp.DisplayName, + "active": sp.Active, + }) +} + +// writeSCIMNotFound writes a SCIM 404. +// ASSUMPTION: the SCIM error schema (schemas, detail, status); the 404 body is not documented. +func writeSCIMNotFound(w http.ResponseWriter, detail string) { + writeJSON(w, http.StatusNotFound, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:Error"}, + "detail": detail, + "status": "404", + }) +} + +// handleGetWarehouse returns a warehouse in the request's workspace. +// DOC: warehouse fields and state values (STARTING, RUNNING, STOPPING, STOPPED, DELETING, DELETED). +// ASSUMPTION: a warehouse that isn't in this workspace is 404 RESOURCE_DOES_NOT_EXIST. +func (s *server) handleGetWarehouse(w http.ResponseWriter, r *http.Request) { + ws := requestWorkspace(r) + + s.mu.Lock() + defer s.mu.Unlock() + + wh, ok := s.st.warehouses[r.PathValue("id")] + if !ok || wh.DeploymentName != ws.DeploymentName { + writeError(w, http.StatusNotFound, "RESOURCE_DOES_NOT_EXIST", "Warehouse "+r.PathValue("id")+" does not exist.") + return + } + + state := s.warehouseState(wh) + health := map[string]any{"status": "HEALTHY"} + if wh.NeverStarts { + // DECISION: the broken warehouse reports why it can't start. + health = map[string]any{ + "status": "FAILED", + "summary": "test-server: this warehouse never starts", + "failure_reason": map[string]any{ + "code": "INSTANCE_UNREACHABLE", + "type": "CLOUD_FAILURE", + }, + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "id": wh.ID, + "name": wh.Name, + "warehouse_type": wh.WarehouseType, + "enable_serverless_compute": wh.Serverless, + "cluster_size": "2X-Small", + "min_num_clusters": 1, + "max_num_clusters": 1, + "auto_stop_mins": 10, + "state": state, + "health": health, + "num_clusters": boolToInt(state == warehouseRunning), + "creator_name": "admin@example.com", + "jdbc_url": "jdbc:spark://" + ws.DeploymentName + ".localhost:443/default;transportMode=http;ssl=1;httpPath=/sql/1.0/warehouses/" + wh.ID, + "odbc_params": map[string]any{ + "hostname": ws.DeploymentName + ".localhost", + "path": "/sql/1.0/warehouses/" + wh.ID, + "protocol": "https", + "port": 443, + }, + }) +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// apiError is the documented error body: https://docs.databricks.com/api/workspace/introduction. +type apiError struct { + ErrorCode string `json:"error_code"` + Message string `json:"message"` +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, apiError{ErrorCode: code, Message: message}) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + log.Printf("test-server: writing response: %v", err) + } +} + +func parseChunkIndex(s string) (int, bool) { + n, err := strconv.Atoi(s) + return n, err == nil && n >= 0 +} diff --git a/test-server/main.go b/test-server/main.go new file mode 100644 index 00000000..8e23a73d --- /dev/null +++ b/test-server/main.go @@ -0,0 +1,160 @@ +// Command test-server is a local stand-in for the Databricks APIs that baton-databricks calls +// from Validate and the incremental sync event feed (system.access.audit). It serves TLS on +// one address and routes by Host header: "localhost" is the account API and OAuth host, and +// ".localhost" is that workspace. See README.md for usage, endpoints, seed +// data and which behaviors come from the Databricks docs. +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "flag" + "fmt" + "log" + "math/big" + "net" + "net/http" + "os" + "time" +) + +type config struct { + addr string + caOut string + warehouseStartDelay time.Duration + chunkRows int +} + +func main() { + var cfg config + flag.StringVar(&cfg.addr, "addr", "localhost:8765", "address to listen on") + flag.StringVar(&cfg.caOut, "ca-out", "./test-server-ca.pem", "file to write the generated CA certificate to (PEM)") + flag.DurationVar(&cfg.warehouseStartDelay, "warehouse-start-delay", 0, + "how long a stopped warehouse takes to reach RUNNING after a statement starts it") + flag.IntVar(&cfg.chunkRows, "chunk-rows", 0, "rows per result chunk; 0 returns every row in a single chunk") + flag.Parse() + + if err := run(cfg); err != nil { + fmt.Fprintf(os.Stderr, "test-server: %v\n", err) + os.Exit(1) + } +} + +func run(cfg config) error { + if cfg.chunkRows < 0 { + return errors.New("--chunk-rows must be >= 0") + } + + srv := newServer(cfg, time.Now()) + + hosts := []string{"localhost", "*.localhost"} + for _, ws := range srv.st.workspaces { + hosts = append(hosts, ws.DeploymentName+".localhost") + } + caPEM, cert, err := generateCertificates(hosts) + if err != nil { + return err + } + if err := os.WriteFile(cfg.caOut, caPEM, 0o600); err != nil { + return fmt.Errorf("writing CA certificate: %w", err) + } + httpServer := &http.Server{ + Addr: cfg.addr, + Handler: logRequests(srv), + ReadHeaderTimeout: 10 * time.Second, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + }, + } + + log.Printf("test-server: listening on https://%s (CA written to %s)", cfg.addr, cfg.caOut) + log.Printf("test-server: account API at https://%s, workspaces at https://.%s", cfg.addr, cfg.addr) + return httpServer.ListenAndServeTLS("", "") +} + +// generateCertificates creates a throwaway CA and a server certificate for hosts (DECISION: the +// connector always builds https:// URLs, so the server needs TLS; clients trust the CA through +// SSL_CERT_FILE). Go accepts the *.localhost wildcard, but OpenSSL (and so curl) doesn't match a +// wildcard that close to the top level, so each seeded workspace host is listed too. +func generateCertificates(hosts []string) ([]byte, tls.Certificate, error) { + now := time.Now() + + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, tls.Certificate{}, fmt.Errorf("generating CA key: %w", err) + } + caTemplate := &x509.Certificate{ + SerialNumber: randomSerial(), + Subject: pkix.Name{CommonName: "baton-databricks test-server CA"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(7 * 24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + return nil, tls.Certificate{}, fmt.Errorf("creating CA certificate: %w", err) + } + caCert, err := x509.ParseCertificate(caDER) + if err != nil { + return nil, tls.Certificate{}, fmt.Errorf("parsing CA certificate: %w", err) + } + + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, tls.Certificate{}, fmt.Errorf("generating server key: %w", err) + } + leafTemplate := &x509.Certificate{ + SerialNumber: randomSerial(), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(7 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: hosts, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caCert, &leafKey.PublicKey, caKey) + if err != nil { + return nil, tls.Certificate{}, fmt.Errorf("creating server certificate: %w", err) + } + + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}) + return caPEM, tls.Certificate{Certificate: [][]byte{leafDER, caDER}, PrivateKey: leafKey}, nil +} + +func randomSerial() *big.Int { + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 62)) + if err != nil { + return big.NewInt(time.Now().UnixNano()) + } + return serial +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +func logRequests(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + started := time.Now() + next.ServeHTTP(rec, r) + //nolint:gosec // G706: local test-server log; request values are %q-quoted, so they cannot forge log lines. + log.Printf("%q %q -> %d (%s)", r.Method, r.Host+r.URL.Path, rec.status, time.Since(started).Round(time.Millisecond)) + }) +} diff --git a/test-server/main_test.go b/test-server/main_test.go new file mode 100644 index 00000000..525d85ce --- /dev/null +++ b/test-server/main_test.go @@ -0,0 +1,456 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +var testStart = time.Date(2026, 1, 15, 12, 0, 0, 0, time.UTC) + +// testServer is a server whose clock only moves when the test advances it. +type testServer struct { + *server + clock time.Time +} + +func newTestServer(cfg config) *testServer { + ts := &testServer{server: newServer(cfg, testStart), clock: testStart} + ts.now = func() time.Time { return ts.clock } + return ts +} + +func (ts *testServer) do(t *testing.T, method, url, body string) (int, map[string]any) { + t.Helper() + rec := ts.raw(t, method, url, body, "Bearer "+testAccessToken) + return rec.Code, decodeObject(t, rec) +} + +func (ts *testServer) raw(t *testing.T, method, url, body, authorization string) *httptest.ResponseRecorder { + req := httptest.NewRequestWithContext(t.Context(), method, url, strings.NewReader(body)) + if authorization != "" { + req.Header.Set("Authorization", authorization) + } + if method == http.MethodPost && strings.Contains(url, "/oidc/") { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + rec := httptest.NewRecorder() + ts.ServeHTTP(rec, req) + return rec +} + +func decodeObject(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("response is not a JSON object: %v: %s", err, rec.Body.String()) + } + return out +} + +const ( + accountURL = "https://localhost:8765" + alphaURL = "https://ws-alpha.localhost:8765" + betaURL = "https://ws-beta.localhost:8765" +) + +// connectorStatement has the shape of the connector's audit query (pkg/connector/audit_event_feed.go). +const connectorStatement = ` + SELECT event_id, event_time, workspace_id, action_name, service_name, request_params + FROM system.access.audit + WHERE event_date >= :start_date + AND (event_time > :start_time OR (event_time = :start_time AND event_id > :start_after_event_id)) + AND event_time <= :lag_cutoff + AND service_name IN ('accounts', 'workspace') + AND action_name IN (%s) + ORDER BY event_time ASC, event_id ASC + LIMIT %d + ` + +const connectorActions = `'activateUser', 'add', 'addPermissionAssignment', 'addPrincipalToGroup', 'addPrincipalsToGroup', + 'changeDatabricksSqlAcl', 'changeDatabricksWorkspaceAcl', 'createGroup', 'deactivateUser', 'delete', + 'deletePermissionAssignment', 'removeAccountAdmin', 'removeAdmin', 'removeGroup', 'removePrincipalFromGroup', + 'removePrincipalsFromGroup', 'setAccountAdmin', 'setAdmin', 'updateGroup', 'updatePermissionAssignment', 'updateUser'` + +func statementRequest(t *testing.T, warehouseID, waitTimeout string, startTime time.Time, startAfter string, limit int) string { + t.Helper() + cutoff := testStart.Add(-4 * time.Hour) + body := map[string]any{ + "warehouse_id": warehouseID, + "statement": fmt.Sprintf(connectorStatement, connectorActions, limit), + "wait_timeout": waitTimeout, + "format": "JSON_ARRAY", + "disposition": "INLINE", + "parameters": []map[string]string{ + {"name": "start_date", "value": startTime.Format(time.DateOnly), "type": "DATE"}, + {"name": "start_time", "value": startTime.Format(time.RFC3339Nano), "type": "TIMESTAMP"}, + {"name": "start_after_event_id", "value": startAfter, "type": "STRING"}, + {"name": "lag_cutoff", "value": cutoff.Format(time.RFC3339Nano), "type": "TIMESTAMP"}, + }, + } + b, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func eventID(n int) string { + return fmt.Sprintf("e0000001-0000-4000-8000-%012d", n) +} + +func eventIDs(t *testing.T, body map[string]any) []string { + t.Helper() + result, _ := body["result"].(map[string]any) + rows, _ := result["data_array"].([]any) + ids := make([]string, 0, len(rows)) + for _, r := range rows { + row, _ := r.([]any) + id, _ := row[0].(string) + ids = append(ids, id) + } + return ids +} + +func stmtState(body map[string]any) string { + status, _ := body["status"].(map[string]any) + s, _ := status["state"].(string) + return s +} + +func TestHealth(t *testing.T) { + ts := newTestServer(config{}) + for _, url := range []string{accountURL + "/health", alphaURL + "/health"} { + if rec := ts.raw(t, http.MethodGet, url, "", ""); rec.Code != http.StatusOK { + t.Errorf("%s: got %d", url, rec.Code) + } + } +} + +func TestToken(t *testing.T) { + ts := newTestServer(config{}) + tokenURL := accountURL + "/oidc/accounts/test-account/v1/token" + form := "grant_type=client_credentials&scope=all-apis" + + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, tokenURL, strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(testClientID, testClientSecret) + rec := httptest.NewRecorder() + ts.ServeHTTP(rec, req) + body := decodeObject(t, rec) + if rec.Code != http.StatusOK || body["access_token"] != testAccessToken || body["token_type"] != "Bearer" { + t.Fatalf("good credentials: got %d %v", rec.Code, body) + } + + req = httptest.NewRequestWithContext(t.Context(), http.MethodPost, tokenURL, strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(testClientID, "wrong") + rec = httptest.NewRecorder() + ts.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("bad secret: got %d", rec.Code) + } +} + +func TestBearerRequired(t *testing.T) { + ts := newTestServer(config{}) + for _, auth := range []string{"", "Bearer wrong"} { + rec := ts.raw(t, http.MethodGet, accountURL+"/api/2.0/accounts/test-account/workspaces", "", auth) + body := decodeObject(t, rec) + if rec.Code != http.StatusUnauthorized || body["error_code"] != "UNAUTHENTICATED" { + t.Errorf("auth %q: got %d %v", auth, rec.Code, body) + } + } +} + +func TestAccountEndpoints(t *testing.T) { + ts := newTestServer(config{}) + + rec := ts.raw(t, http.MethodGet, accountURL+"/api/2.0/accounts/test-account/workspaces", "", "Bearer "+testAccessToken) + var workspaces []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &workspaces); err != nil || len(workspaces) != 3 { + t.Fatalf("workspaces: want a bare array of 3, got %d %s", rec.Code, rec.Body.String()) + } + // Large IDs must come out as exact integers, not floats in scientific notation. + if !strings.Contains(rec.Body.String(), `"workspace_id":7474645012345671`) { + t.Errorf("workspace_id not rendered as an exact integer: %s", rec.Body.String()) + } + + if code, _ := ts.do(t, http.MethodGet, accountURL+"/api/2.0/preview/accounts/test-account/access-control/assignable-roles", ""); code != http.StatusOK { + t.Errorf("assignable roles: got %d", code) + } + if code, _ := ts.do(t, http.MethodGet, accountURL+"/api/2.0/accounts/other-account/workspaces", ""); code != http.StatusNotFound { + t.Errorf("other account: got %d", code) + } + + scim := accountURL + "/api/2.0/accounts/test-account/scim/v2/" + cases := []struct { + path string + want int + }{ + {"Users/" + userAlice, http.StatusOK}, + {"Groups/" + groupAnalysts, http.StatusOK}, + {"ServicePrincipals/" + spCIBot, http.StatusOK}, + {"Users/" + groupAnalysts, http.StatusNotFound}, + {"Users/" + deletedPrincipal, http.StatusNotFound}, + {"Groups/" + deletedPrincipal, http.StatusNotFound}, + {"ServicePrincipals/" + deletedPrincipal, http.StatusNotFound}, + } + for _, c := range cases { + code, body := ts.do(t, http.MethodGet, scim+c.path, "") + if code != c.want { + t.Errorf("%s: got %d, want %d", c.path, code, c.want) + } + if c.want == http.StatusNotFound && body["status"] != "404" { + t.Errorf("%s: want a SCIM error body, got %v", c.path, body) + } + } +} + +func TestRouting(t *testing.T) { + ts := newTestServer(config{}) + if code, _ := ts.do(t, http.MethodGet, "https://ws-nope.localhost:8765/api/2.0/sql/warehouses/wh-serverless", ""); code != http.StatusNotFound { + t.Errorf("unknown workspace host: got %d", code) + } + if code, _ := ts.do(t, http.MethodGet, alphaURL+"/api/2.0/sql/warehouses/wh-serverless", ""); code != http.StatusOK { + t.Errorf("warehouse in its workspace: got %d", code) + } + if code, _ := ts.do(t, http.MethodGet, betaURL+"/api/2.0/sql/warehouses/wh-serverless", ""); code != http.StatusNotFound { + t.Errorf("warehouse from another workspace: got %d", code) + } + if code, _ := ts.do(t, http.MethodGet, accountURL+"/api/2.0/sql/warehouses/wh-serverless", ""); code != http.StatusNotFound { + t.Errorf("workspace API on the account host: got %d", code) + } +} + +func TestWaitTimeoutValidation(t *testing.T) { + ts := newTestServer(config{}) + for _, wait := range []string{"0s", "5s", "50s"} { + code, _ := ts.do(t, http.MethodPost, alphaURL+"/api/2.0/sql/statements", statementRequest(t, "wh-serverless", wait, testStart.Add(-48*time.Hour), "", 10)) + if code != http.StatusOK { + t.Errorf("wait_timeout %s: got %d", wait, code) + } + } + for _, wait := range []string{"1s", "4s", "51s", "10", "5m", "-5s"} { + code, body := ts.do(t, http.MethodPost, alphaURL+"/api/2.0/sql/statements", statementRequest(t, "wh-serverless", wait, testStart.Add(-48*time.Hour), "", 10)) + if code != http.StatusBadRequest || body["error_code"] != "INVALID_PARAMETER_VALUE" { + t.Errorf("wait_timeout %s: got %d %v", wait, code, body) + } + } +} + +func TestStatementErrors(t *testing.T) { + ts := newTestServer(config{}) + url := alphaURL + "/api/2.0/sql/statements" + + code, _ := ts.do(t, http.MethodPost, url, statementRequest(t, "wh-missing", "0s", testStart, "", 10)) + if code != http.StatusNotFound { + t.Errorf("unknown warehouse: got %d", code) + } + code, _ = ts.do(t, http.MethodPost, url, `{"warehouse_id":"wh-serverless","statement":"SELECT ?","parameters":[{"value":"1"}]}`) + if code != http.StatusBadRequest { + t.Errorf("positional parameter: got %d", code) + } + + code, body := ts.do(t, http.MethodPost, url, `{"warehouse_id":"wh-serverless","statement":"SELECT 1","wait_timeout":"0s"}`) + status, _ := body["status"].(map[string]any) + errBody, _ := status["error"].(map[string]any) + msg, _ := errBody["message"].(string) + if code != http.StatusOK || stmtState(body) != statementFailed || !strings.Contains(msg, "test-server: unsupported statement") { + t.Errorf("unsupported statement: got %d %v", code, body) + } + + code, body = ts.do(t, http.MethodPost, url, + `{"warehouse_id":"wh-serverless","statement":"SELECT event_id FROM system.access.audit WHERE event_time > :t ORDER BY event_time, event_id","wait_timeout":"0s"}`) + if code != http.StatusOK || stmtState(body) != statementFailed { + t.Errorf("unbound parameter: got %d %v", code, body) + } + + if code, _ := ts.do(t, http.MethodGet, url+"/no-such-statement", ""); code != http.StatusNotFound { + t.Errorf("unknown statement: got %d", code) + } +} + +func TestAuditQueryRunningWarehouse(t *testing.T) { + ts := newTestServer(config{}) + code, body := ts.do(t, http.MethodPost, alphaURL+"/api/2.0/sql/statements", + statementRequest(t, "wh-serverless", "30s", testStart.Add(-48*time.Hour), "", 1000)) + if code != http.StatusOK || stmtState(body) != statementSucceeded { + t.Fatalf("got %d %v", code, body) + } + + // Visible from us-east-1, inside (start-48h, start-4h], in the requested service/action + // lists: every seed row except 18 (eu-west-1), 24 (clusters), 25 (too new) and 26 (too old). + var want []string + for n := 1; n <= 23; n++ { + if n != 18 { + want = append(want, eventID(n)) + } + } + if got := eventIDs(t, body); strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("rows:\n got %v\nwant %v", got, want) + } + + manifest, _ := body["manifest"].(map[string]any) + schema, _ := manifest["schema"].(map[string]any) + columns, _ := schema["columns"].([]any) + if len(columns) != 6 { + t.Errorf("want 6 columns, got %v", columns) + } + result, _ := body["result"].(map[string]any) + if _, ok := result["next_chunk_index"]; ok { + t.Errorf("single chunk must not have next_chunk_index: %v", result) + } + + rows, _ := result["data_array"].([]any) + first, _ := rows[0].([]any) + if first[1] != testStart.Add(-26*time.Hour).Format("2006-01-02T15:04:05.000Z") || first[2] != "0" { + t.Errorf("unexpected value formats: %v", first) + } + var params map[string]string + if s, _ := first[5].(string); json.Unmarshal([]byte(s), ¶ms) != nil || params["targetUserId"] != userDave { + t.Errorf("request_params is not a JSON object string: %v", first[5]) + } +} + +func TestAuditQueryPaging(t *testing.T) { + ts := newTestServer(config{}) + rowTime := func(n int) time.Time { + for _, r := range ts.st.audit { + if r.EventID == eventID(n) { + return r.EventTime + } + } + t.Fatalf("no seed row %d", n) + return time.Time{} + } + + _, body := ts.do(t, http.MethodPost, alphaURL+"/api/2.0/sql/statements", statementRequest(t, "wh-serverless", "10s", rowTime(5), eventID(5), 3)) + if got := eventIDs(t, body); strings.Join(got, ",") != strings.Join([]string{eventID(6), eventID(7), eventID(8)}, ",") { + t.Errorf("page after row 5: got %v", got) + } + + // Rows 21 and 22 share an event_time: the event_id tiebreaker must return only 22. + _, body = ts.do(t, http.MethodPost, alphaURL+"/api/2.0/sql/statements", statementRequest(t, "wh-serverless", "10s", rowTime(21), eventID(21), 1)) + if got := eventIDs(t, body); len(got) != 1 || got[0] != eventID(22) { + t.Errorf("tiebreak after row 21: got %v", got) + } +} + +func TestRegionality(t *testing.T) { + ts := newTestServer(config{}) + stmt := fmt.Sprintf(connectorStatement, connectorActions, 1000) + params := []statementParameter{ + {Name: "start_date", Value: ptr(testStart.Add(-48 * time.Hour).Format(time.DateOnly)), Type: "DATE"}, + {Name: "start_time", Value: ptr(testStart.Add(-48 * time.Hour).Format(time.RFC3339Nano)), Type: "TIMESTAMP"}, + {Name: "start_after_event_id", Value: ptr(""), Type: "STRING"}, + {Name: "lag_cutoff", Value: ptr(testStart.Add(-4 * time.Hour).Format(time.RFC3339Nano)), Type: "TIMESTAMP"}, + } + _, rows, err := ts.st.runAuditQuery(stmt, params, "eu-west-1") + if err != nil { + t.Fatal(err.message) + } + got := map[string]bool{} + for _, r := range rows { + got[*r[0]] = true + } + if !got[eventID(18)] { + t.Error("eu-west-1 should see the ws-gamma row") + } + if !got[eventID(1)] { + t.Error("eu-west-1 should see account-level rows") + } + for _, n := range []int{11, 14, 19} { + if got[eventID(n)] { + t.Errorf("eu-west-1 should not see us-east-1 workspace row %d", n) + } + } +} + +func ptr(s string) *string { return &s } + +func TestStoppedWarehouseColdStart(t *testing.T) { + ts := newTestServer(config{warehouseStartDelay: time.Minute}) + _, body := ts.do(t, http.MethodPost, betaURL+"/api/2.0/sql/statements", statementRequest(t, "wh-pro", "0s", testStart.Add(-48*time.Hour), "", 1000)) + if stmtState(body) != statementPending { + t.Fatalf("want PENDING while the warehouse starts, got %v", body) + } + id, _ := body["statement_id"].(string) + + if _, wh := ts.do(t, http.MethodGet, betaURL+"/api/2.0/sql/warehouses/wh-pro", ""); wh["state"] != warehouseStarting { + t.Errorf("want STARTING, got %v", wh["state"]) + } + if _, polled := ts.do(t, http.MethodGet, betaURL+"/api/2.0/sql/statements/"+id, ""); stmtState(polled) != statementPending { + t.Errorf("want PENDING before the start delay, got %v", polled) + } + + ts.clock = ts.clock.Add(time.Minute) + if _, wh := ts.do(t, http.MethodGet, betaURL+"/api/2.0/sql/warehouses/wh-pro", ""); wh["state"] != warehouseRunning { + t.Errorf("want RUNNING, got %v", wh["state"]) + } + _, polled := ts.do(t, http.MethodGet, betaURL+"/api/2.0/sql/statements/"+id, "") + if stmtState(polled) != statementSucceeded || len(eventIDs(t, polled)) != 22 { + t.Errorf("want SUCCEEDED with 22 rows after the start delay, got %v", polled) + } +} + +func TestBrokenWarehouseAndCancel(t *testing.T) { + ts := newTestServer(config{}) + _, body := ts.do(t, http.MethodPost, alphaURL+"/api/2.0/sql/statements", statementRequest(t, "wh-broken", "0s", testStart.Add(-48*time.Hour), "", 1000)) + id, _ := body["statement_id"].(string) + + ts.clock = ts.clock.Add(time.Hour) + if _, polled := ts.do(t, http.MethodGet, alphaURL+"/api/2.0/sql/statements/"+id, ""); stmtState(polled) != statementPending { + t.Errorf("want PENDING forever, got %v", polled) + } + _, wh := ts.do(t, http.MethodGet, alphaURL+"/api/2.0/sql/warehouses/wh-broken", "") + health, _ := wh["health"].(map[string]any) + if wh["state"] != warehouseStopped || health["status"] != "FAILED" { + t.Errorf("want STOPPED with FAILED health, got %v", wh) + } + + code, cancel := ts.do(t, http.MethodPost, alphaURL+"/api/2.0/sql/statements/"+id+"/cancel", "") + if code != http.StatusOK || len(cancel) != 0 { + t.Errorf("cancel: want 200 {}, got %d %v", code, cancel) + } + if _, polled := ts.do(t, http.MethodGet, alphaURL+"/api/2.0/sql/statements/"+id, ""); stmtState(polled) != statementCanceled { + t.Errorf("want CANCELED after cancel, got %v", polled) + } +} + +func TestOnWaitTimeoutCancel(t *testing.T) { + ts := newTestServer(config{}) + req := strings.Replace(statementRequest(t, "wh-broken", "5s", testStart.Add(-48*time.Hour), "", 1000), + `"wait_timeout":"5s"`, `"wait_timeout":"5s","on_wait_timeout":"CANCEL"`, 1) + if _, body := ts.do(t, http.MethodPost, alphaURL+"/api/2.0/sql/statements", req); stmtState(body) != statementCanceled { + t.Errorf("want CANCELED after wait_timeout with on_wait_timeout=CANCEL, got %v", body) + } +} + +func TestChunks(t *testing.T) { + ts := newTestServer(config{chunkRows: 10}) + _, body := ts.do(t, http.MethodPost, alphaURL+"/api/2.0/sql/statements", statementRequest(t, "wh-serverless", "10s", testStart.Add(-48*time.Hour), "", 1000)) + id, _ := body["statement_id"].(string) + manifest, _ := body["manifest"].(map[string]any) + if manifest["total_chunk_count"] != float64(3) || manifest["total_row_count"] != float64(22) { + t.Fatalf("want 3 chunks / 22 rows, got %v", manifest) + } + + all := eventIDs(t, body) + next, _ := body["result"].(map[string]any)["next_chunk_index"].(float64) + for next > 0 { + _, chunk := ts.do(t, http.MethodGet, fmt.Sprintf("%s/api/2.0/sql/statements/%s/result/chunks/%d", alphaURL, id, int(next)), "") + all = append(all, eventIDs(t, map[string]any{"result": chunk})...) + next, _ = chunk["next_chunk_index"].(float64) + } + if len(all) != 22 { + t.Errorf("want 22 rows across chunks, got %d", len(all)) + } + if code, _ := ts.do(t, http.MethodGet, fmt.Sprintf("%s/api/2.0/sql/statements/%s/result/chunks/3", alphaURL, id), ""); code != http.StatusBadRequest { + t.Errorf("out-of-range chunk: got %d", code) + } +} diff --git a/test-server/seeds.go b/test-server/seeds.go new file mode 100644 index 00000000..5f37ea46 --- /dev/null +++ b/test-server/seeds.go @@ -0,0 +1,150 @@ +package main + +import "time" + +// Seed IDs. DECISION: all made up; numeric IDs are 16 digits like real Databricks IDs. +const ( + wsAlphaID int64 = 7474645012345671 + wsBetaID int64 = 7474645012345672 + wsGammaID int64 = 8828000011112223 + + userAlice = "4410000000000001" + userBob = "4410000000000002" + userCarol = "4410000000000003" + userDave = "4410000000000004" + + groupDataEngineers = "5520000000000001" + groupAnalysts = "5520000000000002" + + spCIBot = "6630000000000001" + + // deletedPrincipal is named in an audit row but matches no user, group or service principal. + deletedPrincipal = "9990000000000001" +) + +// seedState builds the in-memory account. Audit row times are relative to start. +func seedState(start time.Time) *state { + st := &state{ + // DOC: deployment_name is the workspace hostname prefix; aws_region per workspace. + workspaces: []workspace{ + {ID: wsAlphaID, Name: "Alpha", DeploymentName: "ws-alpha", Region: "us-east-1"}, + {ID: wsBetaID, Name: "Beta", DeploymentName: "ws-beta", Region: "us-east-1"}, + {ID: wsGammaID, Name: "Gamma", DeploymentName: "ws-gamma", Region: "eu-west-1"}, + }, + users: map[string]scimUser{ + userAlice: {ID: userAlice, UserName: "alice@example.com", DisplayName: "Alice Anders", Active: true}, + userBob: {ID: userBob, UserName: "bob@example.com", DisplayName: "Bob Brown", Active: true}, + userCarol: {ID: userCarol, UserName: "carol@example.com", DisplayName: "Carol Chen", Active: false}, + userDave: {ID: userDave, UserName: "dave@example.com", DisplayName: "Dave Diaz", Active: true}, + }, + groups: map[string]scimGroup{ + groupDataEngineers: {ID: groupDataEngineers, DisplayName: "data-engineers"}, + groupAnalysts: {ID: groupAnalysts, DisplayName: "analysts"}, + }, + servicePrincipals: map[string]scimServicePrincipal{ + spCIBot: {ID: spCIBot, ApplicationID: "0d1c2b3a-0000-4000-8000-000000000001", DisplayName: "ci-bot", Active: true}, + }, + // DECISION: one running serverless warehouse, one stopped pro warehouse, one that never starts. + warehouses: map[string]*warehouse{ + "wh-serverless": { + ID: "wh-serverless", Name: "Serverless Starter Warehouse", DeploymentName: "ws-alpha", + WarehouseType: "PRO", Serverless: true, InitialState: warehouseRunning, + }, + "wh-pro": { + ID: "wh-pro", Name: "Pro Warehouse", DeploymentName: "ws-beta", + WarehouseType: "PRO", InitialState: warehouseStopped, + }, + "wh-broken": { + ID: "wh-broken", Name: "Broken Warehouse", DeploymentName: "ws-alpha", + WarehouseType: "PRO", InitialState: warehouseStopped, NeverStarts: true, + }, + }, + statements: map[string]*statement{}, + } + st.audit = seedAudit(start) + return st +} + +// seedAudit returns synthetic system.access.audit rows. +// DOC: request_params keys per action follow https://docs.databricks.com/aws/en/admin/account-settings/audit-logs, +// and account-level events have workspace_id 0. +// DECISION: IDs and times are made up. Rows the connector should read fall between 26 and 5 +// hours before start: older than its 4-hour lag window and inside an --event-feed-start-at +// of 2 days ago. Two extra rows sit outside that window on purpose. +func seedAudit(start time.Time) []auditRow { + at := func(h, m int) time.Time { + return start.Add(-time.Duration(h)*time.Hour - time.Duration(m)*time.Minute).UTC().Truncate(time.Millisecond) + } + user := func(endpoint, id, name string) map[string]string { + return map[string]string{"endpoint": endpoint, "targetUserId": id, "targetUserName": name} + } + membership := func(groupID, groupName, memberID, memberName string) map[string]string { + return map[string]string{ + "endpoint": "scim", "targetGroupId": groupID, "targetGroupName": groupName, + "targetUserId": memberID, "targetUserName": memberName, + } + } + tie := at(6, 0) + + return []auditRow{ + {EventID: "e0000001-0000-4000-8000-000000000001", EventTime: at(26, 0), ServiceName: "accounts", ActionName: "add", + RequestParams: user("scim", userDave, "dave@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000002", EventTime: at(25, 0), ServiceName: "accounts", ActionName: "activateUser", + RequestParams: user("scim", userBob, "bob@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000003", EventTime: at(24, 0), ServiceName: "accounts", ActionName: "deactivateUser", + RequestParams: user("scim", userCarol, "carol@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000004", EventTime: at(23, 0), ServiceName: "accounts", ActionName: "createGroup", + RequestParams: map[string]string{"endpoint": "scim", "targetGroupId": groupAnalysts, "targetGroupName": "analysts"}}, + {EventID: "e0000001-0000-4000-8000-000000000005", EventTime: at(22, 0), ServiceName: "accounts", ActionName: "addPrincipalToGroup", + RequestParams: membership(groupDataEngineers, "data-engineers", userAlice, "alice@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000006", EventTime: at(21, 0), ServiceName: "accounts", ActionName: "addPrincipalToGroup", + RequestParams: membership(groupDataEngineers, "data-engineers", groupAnalysts, "analysts")}, + {EventID: "e0000001-0000-4000-8000-000000000007", EventTime: at(20, 0), ServiceName: "accounts", ActionName: "addPrincipalsToGroup", + RequestParams: membership(groupAnalysts, "analysts", spCIBot, "0d1c2b3a-0000-4000-8000-000000000001")}, + {EventID: "e0000001-0000-4000-8000-000000000008", EventTime: at(19, 0), ServiceName: "accounts", ActionName: "removePrincipalFromGroup", + RequestParams: membership(groupDataEngineers, "data-engineers", userBob, "bob@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000009", EventTime: at(18, 0), ServiceName: "accounts", ActionName: "removePrincipalFromGroup", + RequestParams: membership(groupDataEngineers, "data-engineers", deletedPrincipal, "gone@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000010", EventTime: at(17, 0), ServiceName: "accounts", ActionName: "setAccountAdmin", + RequestParams: user("adminConsole", userAlice, "alice@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000011", EventTime: at(16, 0), WorkspaceID: wsAlphaID, ServiceName: "accounts", ActionName: "setAdmin", + RequestParams: user("adminConsole", userBob, "bob@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000012", EventTime: at(15, 0), WorkspaceID: wsAlphaID, ServiceName: "accounts", ActionName: "removeAdmin", + RequestParams: user("adminConsole", userBob, "bob@example.com")}, + // DOC: workspace-level createGroup has no targetGroupId. + {EventID: "e0000001-0000-4000-8000-000000000013", EventTime: at(14, 0), WorkspaceID: wsAlphaID, ServiceName: "accounts", ActionName: "createGroup", + RequestParams: user("scim", userAlice, "alice@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000014", EventTime: at(13, 0), WorkspaceID: wsBetaID, ServiceName: "accounts", ActionName: "delete", + RequestParams: user("scim", userCarol, "carol@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000015", EventTime: at(12, 0), ServiceName: "workspace", ActionName: "addPermissionAssignment", + RequestParams: map[string]string{"principal_id": userDave, "account_id": testAccountID, "workspace_id": "7474645012345671"}}, + // DOC: updatePermissionAssignment has no workspace_id. + {EventID: "e0000001-0000-4000-8000-000000000016", EventTime: at(11, 0), ServiceName: "workspace", ActionName: "updatePermissionAssignment", + RequestParams: map[string]string{"principal_id": userDave, "permissions": `["ADMIN"]`}}, + {EventID: "e0000001-0000-4000-8000-000000000017", EventTime: at(10, 0), WorkspaceID: wsBetaID, ServiceName: "workspace", ActionName: "deletePermissionAssignment", + RequestParams: map[string]string{"principal_id": userBob, "account_id": testAccountID, "workspace_id": "7474645012345672"}}, + // ws-gamma is in eu-west-1, so this row is invisible from a us-east-1 warehouse. + {EventID: "e0000001-0000-4000-8000-000000000018", EventTime: at(9, 0), WorkspaceID: wsGammaID, ServiceName: "accounts", ActionName: "add", + RequestParams: user("scim", userAlice, "alice@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000019", EventTime: at(8, 0), WorkspaceID: wsAlphaID, ServiceName: "accounts", ActionName: "updateUser", + RequestParams: user("scim", userAlice, "alice@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000020", EventTime: at(7, 0), ServiceName: "accounts", ActionName: "updateGroup", + RequestParams: map[string]string{"endpoint": "scim", "targetGroupId": groupAnalysts, "targetGroupName": "analysts"}}, + // Two rows with the same event_time, to exercise the (event_time, event_id) ordering. + {EventID: "e0000001-0000-4000-8000-000000000021", EventTime: tie, ServiceName: "accounts", ActionName: "removeGroup", + RequestParams: map[string]string{"endpoint": "scim", "targetGroupId": groupDataEngineers, "targetGroupName": "data-engineers"}}, + {EventID: "e0000001-0000-4000-8000-000000000022", EventTime: tie, ServiceName: "accounts", ActionName: "deactivateUser", + RequestParams: user("scim", userDave, "dave@example.com")}, + {EventID: "e0000001-0000-4000-8000-000000000023", EventTime: at(5, 30), WorkspaceID: wsAlphaID, ServiceName: "accounts", ActionName: "changeDatabricksWorkspaceAcl", + RequestParams: map[string]string{"shardName": "ws-alpha", "targetUserId": userCarol, "resourceId": "7474645012345671", "aclPermissionSet": "workspace-access"}}, + // Not an action the connector asks for. + {EventID: "e0000001-0000-4000-8000-000000000024", EventTime: at(5, 0), WorkspaceID: wsAlphaID, ServiceName: "clusters", ActionName: "create", + RequestParams: map[string]string{"cluster_name": "etl"}}, + // Outside the window: newer than the connector's 4-hour lag cutoff. + {EventID: "e0000001-0000-4000-8000-000000000025", EventTime: at(2, 0), ServiceName: "accounts", ActionName: "add", + RequestParams: user("scim", userBob, "bob@example.com")}, + // Outside the window: older than an --event-feed-start-at of 2 days ago. + {EventID: "e0000001-0000-4000-8000-000000000026", EventTime: at(72, 0), ServiceName: "accounts", ActionName: "add", + RequestParams: user("scim", userCarol, "carol@example.com")}, + } +} diff --git a/test-server/state.go b/test-server/state.go new file mode 100644 index 00000000..96fef2b2 --- /dev/null +++ b/test-server/state.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "net" + "net/http" + "strings" + "sync" + "time" +) + +// DECISION: hardcoded test credentials. +const ( + testAccountID = "test-account" + testClientID = "test-client" + testClientSecret = "test-secret" + testAccessToken = "test-access-token" +) + +type workspace struct { + ID int64 + Name string + DeploymentName string + Region string +} + +type scimUser struct { + ID string + UserName string + DisplayName string + Active bool +} + +type scimGroup struct { + ID string + DisplayName string +} + +type scimServicePrincipal struct { + ID string + ApplicationID string + DisplayName string + Active bool +} + +type warehouse struct { + ID string + Name string + DeploymentName string // workspace hosting the warehouse + WarehouseType string + Serverless bool + InitialState string + // NeverStarts makes start attempts fail: the warehouse stays STOPPED with a FAILED health status. + NeverStarts bool + + startRequestedAt time.Time +} + +type auditRow struct { + EventID string + EventTime time.Time + WorkspaceID int64 + ServiceName string + ActionName string + RequestParams map[string]string +} + +// state is the in-memory account, seeded at startup (see seeds.go) and reset on every start. +type state struct { + workspaces []workspace + users map[string]scimUser + groups map[string]scimGroup + servicePrincipals map[string]scimServicePrincipal + warehouses map[string]*warehouse + audit []auditRow + statements map[string]*statement + nextStatement int +} + +func (st *state) workspaceByDeployment(name string) *workspace { + for i := range st.workspaces { + if st.workspaces[i].DeploymentName == name { + return &st.workspaces[i] + } + } + return nil +} + +func (st *state) workspaceByID(id int64) *workspace { + for i := range st.workspaces { + if st.workspaces[i].ID == id { + return &st.workspaces[i] + } + } + return nil +} + +type server struct { + cfg config + start time.Time + now func() time.Time + + mu sync.Mutex + st *state + + account *http.ServeMux + workspace *http.ServeMux +} + +func newServer(cfg config, start time.Time) *server { + s := &server{ + cfg: cfg, + start: start, + now: time.Now, + st: seedState(start), + } + s.account = s.accountRoutes() + s.workspace = s.workspaceRoutes() + return s +} + +type workspaceKey struct{} + +func requestWorkspace(r *http.Request) *workspace { + ws, _ := r.Context().Value(workspaceKey{}).(*workspace) + return ws +} + +// ServeHTTP routes by Host header (DECISION): localhost is the account host, and +// .localhost is a workspace host, mirroring .. +func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // DECISION: local readiness check on every host; not a Databricks endpoint. + if r.URL.Path == "/health" && r.Method == http.MethodGet { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + return + } + + host := r.Host + if h, _, err := net.SplitHostPort(r.Host); err == nil { + host = h + } + host = strings.ToLower(strings.TrimSuffix(host, ".")) + + switch { + case host == "localhost" || host == "127.0.0.1" || host == "::1": + s.account.ServeHTTP(w, r) + case strings.HasSuffix(host, ".localhost"): + deployment := strings.TrimSuffix(host, ".localhost") + s.mu.Lock() + ws := s.st.workspaceByDeployment(deployment) + s.mu.Unlock() + if ws == nil { + // DECISION: a real unknown deployment name doesn't resolve in DNS at all. + writeError(w, http.StatusNotFound, "ENDPOINT_NOT_FOUND", "test-server: no workspace with deployment name "+deployment) + return + } + s.workspace.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), workspaceKey{}, ws))) + default: + writeError(w, http.StatusNotFound, "ENDPOINT_NOT_FOUND", "test-server: unknown host "+host) + } +} + +// requireBearer enforces the access token on every API call except the token endpoint. +// DOC: https://docs.databricks.com/aws/en/dev-tools/auth/oauth-m2m (Authorization: Bearer ). +// DOC: error body {error_code, message}, https://docs.databricks.com/api/workspace/introduction. +func requireBearer(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || token != testAccessToken { + writeError(w, http.StatusUnauthorized, "UNAUTHENTICATED", "Credential was not sent or was of an unsupported type for this API.") + return + } + next(w, r) + } +} + +// requireAccount rejects account API paths for any account other than testAccountID. +// ASSUMPTION: the exact status and code for another account's ID are not documented. +func requireAccount(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("account_id") != testAccountID { + writeError(w, http.StatusNotFound, "RESOURCE_DOES_NOT_EXIST", "Account "+r.PathValue("account_id")+" does not exist.") + return + } + next(w, r) + } +} diff --git a/test-server/statements.go b/test-server/statements.go new file mode 100644 index 00000000..e72ebcf7 --- /dev/null +++ b/test-server/statements.go @@ -0,0 +1,633 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +// Warehouse states. DOC: https://docs.databricks.com/api/workspace/warehouses/get +const ( + warehouseStarting = "STARTING" + warehouseRunning = "RUNNING" + warehouseStopped = "STOPPED" +) + +// Statement states. DOC: https://docs.databricks.com/api/workspace/statementexecution/getstatement +const ( + statementPending = "PENDING" + statementSucceeded = "SUCCEEDED" + statementFailed = "FAILED" + statementCanceled = "CANCELED" +) + +// warehouseState returns wh's state now. Callers hold s.mu. +// ASSUMPTION: submitting a statement starts a stopped warehouse, which is RUNNING after +// --warehouse-start-delay. DECISION: a NeverStarts warehouse stays STOPPED. +func (s *server) warehouseState(wh *warehouse) string { + if wh.InitialState != warehouseStopped { + return wh.InitialState + } + if wh.startRequestedAt.IsZero() || wh.NeverStarts { + return warehouseStopped + } + if s.now().Sub(wh.startRequestedAt) >= s.cfg.warehouseStartDelay { + return warehouseRunning + } + return warehouseStarting +} + +type column struct { + Name string `json:"name"` + TypeName string `json:"type_name"` + TypeText string `json:"type_text"` + Position int `json:"position"` +} + +type statement struct { + ID string + DeploymentName string + WarehouseID string + State string + ErrorCode string + ErrorMessage string + + // The outcome, decided at submit time and revealed once the warehouse is running. + outcomeState string + outcomeCode string + outcomeMessage string + columns []column + rows [][]*string +} + +// refresh moves a PENDING statement to its outcome once its warehouse is running. +// DOC: a statement on a warehouse that isn't running stays PENDING, waiting for the warehouse. +// DECISION: statements go straight from PENDING to SUCCEEDED/FAILED (no visible RUNNING). +func (s *server) refresh(stmt *statement) { + if stmt.State != statementPending { + return + } + if s.warehouseState(s.st.warehouses[stmt.WarehouseID]) != warehouseRunning { + return + } + stmt.State = stmt.outcomeState + stmt.ErrorCode = stmt.outcomeCode + stmt.ErrorMessage = stmt.outcomeMessage +} + +func isTerminal(state string) bool { + return state != statementPending +} + +type statementParameter struct { + Name string `json:"name"` + Value *string `json:"value"` + Type string `json:"type"` +} + +type executeRequest struct { + WarehouseID string `json:"warehouse_id"` + Statement string `json:"statement"` + WaitTimeout *string `json:"wait_timeout"` + OnWaitTimeout string `json:"on_wait_timeout"` + Format string `json:"format"` + Disposition string `json:"disposition"` + Parameters []statementParameter `json:"parameters"` +} + +var waitTimeoutRe = regexp.MustCompile(`^(\d+)s$`) + +// parseWaitTimeout validates wait_timeout. +// DOC: 0s, or between 5s and 50s; the default is 10s. +func parseWaitTimeout(v *string) (time.Duration, bool) { + if v == nil { + return 10 * time.Second, true + } + m := waitTimeoutRe.FindStringSubmatch(*v) + if m == nil { + return 0, false + } + n, err := strconv.Atoi(m[1]) + if err != nil || (n != 0 && (n < 5 || n > 50)) { + return 0, false + } + return time.Duration(n) * time.Second, true +} + +// handleExecuteStatement submits a statement. +// DOC: if it finishes within wait_timeout the response has the result; otherwise it has the +// statement ID with PENDING or RUNNING, and on_wait_timeout=CANCEL cancels it instead. +func (s *server) handleExecuteStatement(w http.ResponseWriter, r *http.Request) { + ws := requestWorkspace(r) + + var req executeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "MALFORMED_REQUEST", "Could not parse request body: "+err.Error()) + return + } + + wait, ok := parseWaitTimeout(req.WaitTimeout) + if !ok { + writeError(w, http.StatusBadRequest, "INVALID_PARAMETER_VALUE", + "wait_timeout must be 0 seconds (disables wait), or between 5 seconds and 50 seconds.") + return + } + switch req.OnWaitTimeout { + case "", "CONTINUE", "CANCEL": + default: + writeError(w, http.StatusBadRequest, "INVALID_PARAMETER_VALUE", "on_wait_timeout must be CONTINUE or CANCEL.") + return + } + // DECISION: only the JSON_ARRAY format and INLINE disposition are implemented. + if req.Format != "" && req.Format != "JSON_ARRAY" { + writeError(w, http.StatusBadRequest, "INVALID_PARAMETER_VALUE", "test-server: only format JSON_ARRAY is supported") + return + } + if req.Disposition != "" && req.Disposition != "INLINE" { + writeError(w, http.StatusBadRequest, "INVALID_PARAMETER_VALUE", "test-server: only disposition INLINE is supported") + return + } + // DOC: parameters are named (:name) markers. ASSUMPTION: the status and code for a + // positional (unnamed) parameter. + for _, p := range req.Parameters { + if p.Name == "" { + writeError(w, http.StatusBadRequest, "INVALID_PARAMETER_VALUE", "Positional parameters are not supported; use named parameter markers.") + return + } + } + + s.mu.Lock() + wh, ok := s.st.warehouses[req.WarehouseID] + if !ok || wh.DeploymentName != ws.DeploymentName { + s.mu.Unlock() + // DOC: an unknown warehouse_id is an error. ASSUMPTION: its status and code. + writeError(w, http.StatusNotFound, "RESOURCE_DOES_NOT_EXIST", "Warehouse "+req.WarehouseID+" does not exist.") + return + } + if wh.InitialState == warehouseStopped && wh.startRequestedAt.IsZero() { + wh.startRequestedAt = s.now() + } + + s.st.nextStatement++ + stmt := &statement{ + ID: fmt.Sprintf("01f00000-0000-4000-8000-%012d", s.st.nextStatement), + DeploymentName: ws.DeploymentName, + WarehouseID: wh.ID, + State: statementPending, + } + s.st.statements[stmt.ID] = stmt + + region := "" + if hostWS := s.st.workspaceByDeployment(wh.DeploymentName); hostWS != nil { + region = hostWS.Region + } + cols, rows, err := s.st.runAuditQuery(req.Statement, req.Parameters, region) + if err != nil { + stmt.outcomeState = statementFailed + stmt.outcomeCode = err.code + stmt.outcomeMessage = err.message + } else { + stmt.outcomeState = statementSucceeded + stmt.columns = cols + stmt.rows = rows + } + s.refresh(stmt) + s.mu.Unlock() + + deadline := time.NewTimer(wait) + defer deadline.Stop() + tick := time.NewTicker(20 * time.Millisecond) + defer tick.Stop() + for { + s.mu.Lock() + s.refresh(stmt) + if isTerminal(stmt.State) { + body := s.statementBody(stmt) + s.mu.Unlock() + writeJSON(w, http.StatusOK, body) + return + } + s.mu.Unlock() + + select { + case <-r.Context().Done(): + return + case <-deadline.C: + s.mu.Lock() + if req.OnWaitTimeout == "CANCEL" { + stmt.State = statementCanceled + } + body := s.statementBody(stmt) + s.mu.Unlock() + writeJSON(w, http.StatusOK, body) + return + case <-tick.C: + } + } +} + +// lookupStatement finds a statement submitted in the request's workspace. Callers hold s.mu. +// ASSUMPTION: an unknown statement ID is 404 RESOURCE_DOES_NOT_EXIST. +func (s *server) lookupStatement(w http.ResponseWriter, r *http.Request) *statement { + stmt, ok := s.st.statements[r.PathValue("id")] + if !ok || stmt.DeploymentName != requestWorkspace(r).DeploymentName { + writeError(w, http.StatusNotFound, "RESOURCE_DOES_NOT_EXIST", "Statement "+r.PathValue("id")+" does not exist.") + return nil + } + return stmt +} + +// handleGetStatement returns a statement's status. +// DOC: once SUCCEEDED it includes the manifest and the first chunk; terminal states are HTTP 200. +func (s *server) handleGetStatement(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + stmt := s.lookupStatement(w, r) + if stmt == nil { + return + } + s.refresh(stmt) + writeJSON(w, http.StatusOK, s.statementBody(stmt)) +} + +// handleCancelStatement requests cancellation. +// DOC: returns {}; the caller polls the statement to see CANCELED. +func (s *server) handleCancelStatement(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + stmt := s.lookupStatement(w, r) + if stmt == nil { + return + } + s.refresh(stmt) + if !isTerminal(stmt.State) { + stmt.State = statementCanceled + } + writeJSON(w, http.StatusOK, map[string]any{}) +} + +// handleGetResultChunk returns one result chunk of a SUCCEEDED statement. +// DOC: same shape as the result object, with next_chunk_index absent on the last chunk. +// ASSUMPTION: the status and code for an out-of-range chunk or a statement without a result. +func (s *server) handleGetResultChunk(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + + stmt := s.lookupStatement(w, r) + if stmt == nil { + return + } + s.refresh(stmt) + if stmt.State != statementSucceeded { + writeError(w, http.StatusBadRequest, "INVALID_STATE", "Statement "+stmt.ID+" has no result: state is "+stmt.State+".") + return + } + n, ok := parseChunkIndex(r.PathValue("n")) + if !ok || n >= s.chunkCount(stmt) { + writeError(w, http.StatusBadRequest, "INVALID_PARAMETER_VALUE", "Chunk index "+r.PathValue("n")+" is out of range.") + return + } + writeJSON(w, http.StatusOK, s.chunkBody(stmt, n)) +} + +// chunkCount is how many chunks stmt's result has. DECISION: one chunk unless --chunk-rows is set. +// ASSUMPTION: an empty result is reported as a single empty chunk. +func (s *server) chunkCount(stmt *statement) int { + if s.cfg.chunkRows <= 0 || len(stmt.rows) == 0 { + return 1 + } + return (len(stmt.rows) + s.cfg.chunkRows - 1) / s.cfg.chunkRows +} + +func (s *server) chunkBounds(stmt *statement, n int) (int, int) { + if s.cfg.chunkRows <= 0 { + return 0, len(stmt.rows) + } + start := n * s.cfg.chunkRows + return start, min(start+s.cfg.chunkRows, len(stmt.rows)) +} + +// chunkBody renders result chunk n. DOC: data_array values are strings and nulls are JSON null. +func (s *server) chunkBody(stmt *statement, n int) map[string]any { + start, end := s.chunkBounds(stmt, n) + body := map[string]any{ + "chunk_index": n, + "row_offset": start, + "row_count": end - start, + } + if end > start { + body["data_array"] = stmt.rows[start:end] + } + if n+1 < s.chunkCount(stmt) { + body["next_chunk_index"] = n + 1 + body["next_chunk_internal_link"] = fmt.Sprintf("/api/2.0/sql/statements/%s/result/chunks/%d", stmt.ID, n+1) + } + return body +} + +// statementBody renders a statement as the Execute/Get responses do. Callers hold s.mu. +func (s *server) statementBody(stmt *statement) map[string]any { + status := map[string]any{"state": stmt.State} + if stmt.ErrorMessage != "" { + status["error"] = map[string]string{"error_code": stmt.ErrorCode, "message": stmt.ErrorMessage} + } + body := map[string]any{"statement_id": stmt.ID, "status": status} + if stmt.State != statementSucceeded { + return body + } + + count := s.chunkCount(stmt) + chunks := make([]map[string]any, 0, count) + for n := range count { + start, end := s.chunkBounds(stmt, n) + chunks = append(chunks, map[string]any{"chunk_index": n, "row_offset": start, "row_count": end - start}) + } + body["manifest"] = map[string]any{ + "format": "JSON_ARRAY", + "schema": map[string]any{ + "column_count": len(stmt.columns), + "columns": stmt.columns, + }, + "total_chunk_count": count, + "chunks": chunks, + "total_row_count": len(stmt.rows), + "truncated": false, + } + body["result"] = s.chunkBody(stmt, 0) + return body +} + +// SQL handling (DECISION): the server doesn't run SQL. It recognizes the shape of the +// connector's query against system.access.audit and evaluates just that. +var ( + auditFromRe = regexp.MustCompile(`(?is)^\s*SELECT\s+(.+?)\s+FROM\s+system\.access\.audit\b`) + paramMarkerRe = regexp.MustCompile(`(?:^|[^:\w]):([A-Za-z_]\w*)`) + eventDateRe = regexp.MustCompile(`(?i)\bevent_date\s*>=\s*:(\w+)`) + afterRe = regexp.MustCompile( + `(?i)\(\s*event_time\s*>\s*:(\w+)\s+OR\s*\(\s*event_time\s*=\s*:(\w+)\s+AND\s+event_id\s*>\s*:(\w+)\s*\)\s*\)`) + cutoffRe = regexp.MustCompile(`(?i)\bevent_time\s*<=\s*:(\w+)`) + serviceListRe = regexp.MustCompile(`(?i)\bservice_name\s+IN\s*\(([^)]*)\)`) + actionListRe = regexp.MustCompile(`(?i)\baction_name\s+IN\s*\(([^)]*)\)`) + orderByRe = regexp.MustCompile(`(?i)\bORDER\s+BY\s+event_time(\s+ASC)?\s*,\s*event_id(\s+ASC)?\b`) + limitRe = regexp.MustCompile(`(?i)\bLIMIT\s+(\d+)\s*;?\s*$`) +) + +// auditColumns are the documented system.access.audit columns this server serves. +// DOC: https://docs.databricks.com/aws/en/admin/system-tables/audit-logs +// DOC: type_name values, https://docs.databricks.com/api/workspace/statementexecution/executestatement +var auditColumns = map[string]column{ + "event_id": {Name: "event_id", TypeName: "STRING", TypeText: "STRING"}, + "event_time": {Name: "event_time", TypeName: "TIMESTAMP", TypeText: "TIMESTAMP"}, + "event_date": {Name: "event_date", TypeName: "DATE", TypeText: "DATE"}, + "workspace_id": {Name: "workspace_id", TypeName: "LONG", TypeText: "BIGINT"}, + "service_name": {Name: "service_name", TypeName: "STRING", TypeText: "STRING"}, + "action_name": {Name: "action_name", TypeName: "STRING", TypeText: "STRING"}, + "request_params": {Name: "request_params", TypeName: "MAP", TypeText: "MAP"}, +} + +type sqlError struct { + code string + message string +} + +func unsupported(detail string) *sqlError { + return &sqlError{code: "BAD_REQUEST", message: "test-server: unsupported statement: " + detail} +} + +type boundParam struct { + typ string + value string + time time.Time +} + +// bindParameters binds typed named parameters. +// DOC: named markers with an optional type; the default type is STRING. +// ASSUMPTION: the accepted TIMESTAMP and DATE literal formats. +func bindParameters(params []statementParameter) (map[string]boundParam, *sqlError) { + bound := make(map[string]boundParam, len(params)) + for _, p := range params { + typ := strings.ToUpper(p.Type) + if typ == "" { + typ = "STRING" + } + if p.Value == nil { + return nil, &sqlError{code: "BAD_REQUEST", message: "test-server: NULL parameter values are not supported"} + } + bp := boundParam{typ: typ, value: *p.Value} + switch typ { + case "STRING": + case "TIMESTAMP": + t, err := time.Parse(time.RFC3339Nano, bp.value) + if err != nil { + t, err = time.Parse("2006-01-02 15:04:05.999999", bp.value) + } + if err != nil { + return nil, &sqlError{code: "INVALID_PARAMETER_VALUE", message: fmt.Sprintf("Cannot parse %q as TIMESTAMP for parameter %s.", bp.value, p.Name)} + } + bp.time = t.UTC() + case "DATE": + t, err := time.Parse(time.DateOnly, bp.value) + if err != nil { + return nil, &sqlError{code: "INVALID_PARAMETER_VALUE", message: fmt.Sprintf("Cannot parse %q as DATE for parameter %s.", bp.value, p.Name)} + } + bp.time = t + default: + return nil, &sqlError{code: "BAD_REQUEST", message: "test-server: unsupported parameter type " + typ} + } + bound[p.Name] = bp + } + return bound, nil +} + +func parseInList(list string) []string { + var values []string + for _, v := range strings.Split(list, ",") { + values = append(values, strings.Trim(strings.TrimSpace(v), "'")) + } + return values +} + +func contains(values []string, v string) bool { + for _, x := range values { + if x == v { + return true + } + } + return false +} + +// timeParam returns a bound TIMESTAMP/DATE parameter. Callers check names against bound first. +func timeParam(bound map[string]boundParam, name string, want string) (time.Time, *sqlError) { + p := bound[name] + if p.typ != want { + return time.Time{}, unsupported(fmt.Sprintf("parameter %s must be bound with type %s", name, want)) + } + return p.time, nil +} + +// runAuditQuery evaluates the connector's audit query against the seed rows visible from a +// warehouse in region. Callers hold s.mu. +// DOC: workspace-level events are only visible from the workspace's region ("Most audit logs +// are only available in the region of the workspace"); account-level events (workspace_id 0) +// are visible from every region. +func (st *state) runAuditQuery(sql string, params []statementParameter, region string) ([]column, [][]*string, *sqlError) { + m := auditFromRe.FindStringSubmatch(sql) + if m == nil { + return nil, nil, unsupported("only SELECT ... FROM system.access.audit is supported") + } + + var cols []column + for i, name := range strings.Split(m[1], ",") { + col, ok := auditColumns[strings.ToLower(strings.TrimSpace(name))] + if !ok { + return nil, nil, unsupported(fmt.Sprintf("column %q", strings.TrimSpace(name))) + } + col.Position = i + cols = append(cols, col) + } + + bound, bindErr := bindParameters(params) + if bindErr != nil { + return nil, nil, bindErr + } + // ASSUMPTION: the error for a marker with no bound parameter (UNBOUND_SQL_PARAMETER). + for _, mm := range paramMarkerRe.FindAllStringSubmatch(sql, -1) { + if _, ok := bound[mm[1]]; !ok { + return nil, nil, &sqlError{code: "BAD_REQUEST", message: fmt.Sprintf("[UNBOUND_SQL_PARAMETER] Found the unbound parameter: %s.", mm[1])} + } + } + + if !orderByRe.MatchString(sql) { + return nil, nil, unsupported("ORDER BY event_time ASC, event_id ASC is required") + } + + var ( + startDate, startTime, cutoff time.Time + startAfterID string + hasStartDate, hasAfter bool + hasCutoff bool + services, actions []string + limit = -1 + err *sqlError + ) + if mm := eventDateRe.FindStringSubmatch(sql); mm != nil { + if startDate, err = timeParam(bound, mm[1], "DATE"); err != nil { + return nil, nil, err + } + hasStartDate = true + } + if mm := afterRe.FindStringSubmatch(sql); mm != nil { + if mm[1] != mm[2] { + return nil, nil, unsupported("the two event_time comparisons must use the same parameter") + } + if startTime, err = timeParam(bound, mm[1], "TIMESTAMP"); err != nil { + return nil, nil, err + } + startAfterID = bound[mm[3]].value + hasAfter = true + } + if mm := cutoffRe.FindStringSubmatch(sql); mm != nil { + if cutoff, err = timeParam(bound, mm[1], "TIMESTAMP"); err != nil { + return nil, nil, err + } + hasCutoff = true + } + if mm := serviceListRe.FindStringSubmatch(sql); mm != nil { + services = parseInList(mm[1]) + } + if mm := actionListRe.FindStringSubmatch(sql); mm != nil { + actions = parseInList(mm[1]) + } + if mm := limitRe.FindStringSubmatch(sql); mm != nil { + limit, _ = strconv.Atoi(mm[1]) + } + + matched := make([]auditRow, 0, len(st.audit)) + for _, row := range st.audit { + if row.WorkspaceID != 0 { + ws := st.workspaceByID(row.WorkspaceID) + if ws == nil || ws.Region != region { + continue + } + } + eventTime := row.EventTime.UTC() + eventDate := time.Date(eventTime.Year(), eventTime.Month(), eventTime.Day(), 0, 0, 0, 0, time.UTC) + if hasStartDate && eventDate.Before(startDate) { + continue + } + afterCursor := eventTime.After(startTime) || (eventTime.Equal(startTime) && row.EventID > startAfterID) + if hasAfter && !afterCursor { + continue + } + if hasCutoff && eventTime.After(cutoff) { + continue + } + if services != nil && !contains(services, row.ServiceName) { + continue + } + if actions != nil && !contains(actions, row.ActionName) { + continue + } + matched = append(matched, row) + } + + sort.Slice(matched, func(i, j int) bool { + if !matched[i].EventTime.Equal(matched[j].EventTime) { + return matched[i].EventTime.Before(matched[j].EventTime) + } + return matched[i].EventID < matched[j].EventID + }) + if limit >= 0 && len(matched) > limit { + matched = matched[:limit] + } + + rows := make([][]*string, 0, len(matched)) + for _, row := range matched { + values := make([]*string, len(cols)) + for i, col := range cols { + values[i] = formatValue(row, col.Name) + } + rows = append(rows, values) + } + return cols, rows, nil +} + +// formatValue renders a column value as JSON_ARRAY does. +// ASSUMPTION: TIMESTAMP as ISO 8601 UTC with milliseconds, DATE as yyyy-mm-dd, and MAP as a +// JSON object string. +func formatValue(row auditRow, name string) *string { + var v string + switch name { + case "event_id": + v = row.EventID + case "event_time": + v = row.EventTime.UTC().Format("2006-01-02T15:04:05.000Z") + case "event_date": + v = row.EventTime.UTC().Format(time.DateOnly) + case "workspace_id": + v = strconv.FormatInt(row.WorkspaceID, 10) + case "service_name": + v = row.ServiceName + case "action_name": + v = row.ActionName + case "request_params": + if row.RequestParams == nil { + return nil + } + b, err := json.Marshal(row.RequestParams) + if err != nil { + return nil + } + v = string(b) + default: + return nil + } + return &v +} diff --git a/test-server/testdata/expected-events.jsonl b/test-server/testdata/expected-events.jsonl new file mode 100644 index 00000000..8a6be962 --- /dev/null +++ b/test-server/testdata/expected-events.jsonl @@ -0,0 +1,25 @@ +{"id":"e0000001-0000-4000-8000-000000000001/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000004"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000002/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000002"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000003/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000003"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000004/0","resourceChangeEvent":{"resourceId":{"resourceType":"group","resource":"account/test-account/group/5520000000000002"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000005/0","createGrantEvent":{"entitlement":{"resource":{"id":{"resourceType":"group","resource":"account/test-account/group/5520000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}},"id":"group:account/test-account/group/5520000000000001:member","displayName":"member","purpose":"PURPOSE_VALUE_ASSIGNMENT","slug":"member"},"principal":{"id":{"resourceType":"user","resource":"4410000000000001"}}}} +{"id":"e0000001-0000-4000-8000-000000000006/0","createGrantEvent":{"entitlement":{"resource":{"id":{"resourceType":"group","resource":"account/test-account/group/5520000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}},"id":"group:account/test-account/group/5520000000000001:member","displayName":"member","purpose":"PURPOSE_VALUE_ASSIGNMENT","slug":"member"},"principal":{"id":{"resourceType":"group","resource":"account/test-account/group/5520000000000002"}}}} +{"id":"e0000001-0000-4000-8000-000000000007/0","createGrantEvent":{"entitlement":{"resource":{"id":{"resourceType":"group","resource":"account/test-account/group/5520000000000002"},"parentResourceId":{"resourceType":"account","resource":"test-account"}},"id":"group:account/test-account/group/5520000000000002:member","displayName":"member","purpose":"PURPOSE_VALUE_ASSIGNMENT","slug":"member"},"principal":{"id":{"resourceType":"service_principal","resource":"6630000000000001"}}}} +{"id":"e0000001-0000-4000-8000-000000000008/0","createRevokeEvent":{"entitlement":{"resource":{"id":{"resourceType":"group","resource":"account/test-account/group/5520000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}},"id":"group:account/test-account/group/5520000000000001:member","displayName":"member","purpose":"PURPOSE_VALUE_ASSIGNMENT","slug":"member"},"principal":{"id":{"resourceType":"user","resource":"4410000000000002"}}}} +{"id":"e0000001-0000-4000-8000-000000000009/0","resourceChangeEvent":{"resourceId":{"resourceType":"group","resource":"account/test-account/group/5520000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000010/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000010/1","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"account_admin"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000011/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000002"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000011/1","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"account_admin"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000012/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000002"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000014/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000003"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000015/0","resourceChangeEvent":{"resourceId":{"resourceType":"workspace","resource":"ws-alpha"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000017/0","resourceChangeEvent":{"resourceId":{"resourceType":"workspace","resource":"ws-beta"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000019/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000019/1","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"ws-alpha:allow-cluster-create"},"parentResourceId":{"resourceType":"workspace","resource":"ws-alpha"}}} +{"id":"e0000001-0000-4000-8000-000000000019/2","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"ws-alpha:allow-instance-pool-create"},"parentResourceId":{"resourceType":"workspace","resource":"ws-alpha"}}} +{"id":"e0000001-0000-4000-8000-000000000020/0","resourceChangeEvent":{"resourceId":{"resourceType":"group","resource":"account/test-account/group/5520000000000002"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000021/0","resourceChangeEvent":{"resourceId":{"resourceType":"group","resource":"account/test-account/group/5520000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000022/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000004"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000023/0","resourceChangeEvent":{"resourceId":{"resourceType":"workspace","resource":"ws-alpha"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000023/1","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"ws-alpha:workspace-access"},"parentResourceId":{"resourceType":"workspace","resource":"ws-alpha"}}} From e76e8e34403d2c8cfed8307054afd4aed8d7a4de Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Wed, 28 Oct 2026 10:05:53 -0300 Subject: [PATCH 34/36] fix(databricks): keep a not-found account read from emptying the sync A 404 on the metastore's workspace list, on the account's workspace list, or on ListMetastores reached the SDK as codes.NotFound. The metastore List is a typed child of the account, so the SDK runs it with allowWarning, reads that as "no metastores" and ends the sync successfully with none. ConductorOne then deletes every metastore and its grants. Only the not-found case is re-coded to FailedPrecondition. Wrapping every failure that way, as metastore Grants did, turned a 429 or a 5xx into a code the SDK does not retry, so one throttled permissions page failed the whole sync instead of backing off. Measured against a live tenant through a proxy that injects the failure: an injected 404 on the assignment list used to exit 0 with zero metastores and now fails the sync, and an injected 429 on the permissions read used to stop after one call and now retries with backoff. Validate no longer reports a workspace that databricks-exclude-workspaces removed as absent from the account, since ListWorkspaces drops those before the check sees them. Co-authored-by: Cursor --- README.md | 6 ++-- pkg/connector/connector.go | 6 +++- pkg/connector/metastore.go | 14 +++++---- pkg/connector/metastore_helpers.go | 31 ++++++++++++++++---- pkg/connector/metastore_test.go | 47 +++++++++++++++++++++++++++--- 5 files changed, 85 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 5cd2063e..2fe5a7f6 100644 --- a/README.md +++ b/README.md @@ -239,9 +239,11 @@ it. The connector never guesses when the answer is unknown: | Attached workspace | Result | |--------------------|--------| | `RUNNING` and in scope | the metastore syncs through it | -| `PROVISIONING`, `NOT_PROVISIONED`, `CANCELLING` | still changing, so the metastore is kept and the sync fails with `FailedPrecondition` until a workspace is `RUNNING` | +| `PROVISIONING`, `NOT_PROVISIONED` | still changing, so the metastore is kept and the sync fails with `FailedPrecondition` until a workspace is `RUNNING` | +| `CANCELLING` | the workspace is being deleted and will not become `RUNNING`, so the metastore is kept and the sync fails until that workspace is gone or another attached one is `RUNNING` | | `FAILED`, `BANNED`, or a status Databricks does not document | will not become `RUNNING`, so it is skipped; a metastore left with no other workspace is not synced | -| assignment list unreadable (401, 403, 404) | unknown, so the metastore is kept and the sync fails naming the assignment list | +| metastore workspace list unreadable (401, 403, 404) | unknown, so the metastore is kept and the sync fails naming that list | +| workspace permission list unreadable (401, 403, 404) | treated as not Admin on that workspace; the sync then fails with the usual missing-standing error, which does not name this list | A metastore is left out of the sync only when the answer is known: every attached workspace is out of scope or unusable. Leaving it out is what makes diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 71c74269..47dedc09 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -154,7 +154,11 @@ func (d *Databricks) Validate(ctx context.Context) (annotations.Annotations, err _, ok := matchConfiguredWorkspace(configuredWorkspaceSet(d.workspaces), w.DeploymentName, w.Name, strconv.Itoa(w.ID)) return ok }) { - return annos, fmt.Errorf("databricks-connector: none of the configured workspaces (%s) exists on the account", strings.Join(d.workspaces, ", ")) + // ListWorkspaces already dropped the excluded ones, so a name that is + // only excluded would otherwise be reported as absent from the account. + return annos, fmt.Errorf( + "databricks-connector: none of the configured workspaces (%s) is on the account and not excluded by databricks-exclude-workspaces", + strings.Join(d.workspaces, ", ")) } l := ctxzap.Extract(ctx) diff --git a/pkg/connector/metastore.go b/pkg/connector/metastore.go index dfd8b4ca..726f8208 100644 --- a/pkg/connector/metastore.go +++ b/pkg/connector/metastore.go @@ -70,7 +70,8 @@ func metastoreResource(_ context.Context, metastore *databricks.Metastore, paren } // List emits each metastore with the workspace its permissions are read through. -// A metastore with no usable workspace is left out: its grants cannot be read. +// A metastore is left out only when no attached workspace is in scope or usable. +// A pending workspace fails the sync instead, so ConductorOne keeps the metastore. func (m *metastoreBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, _ rs.SyncOpAttrs) ([]*v2.Resource, *rs.SyncOpResults, error) { if parentResourceID.GetResourceType() != accountResourceType.Id { return nil, nil, nil @@ -80,7 +81,7 @@ func (m *metastoreBuilder) List(ctx context.Context, parentResourceID *v2.Resour metastores, rateLimit, err := m.client.ListMetastores(ctx) noteRateLimit(&annos, rateLimit) if err != nil { - return nil, &rs.SyncOpResults{Annotations: annos}, fmt.Errorf("databricks-connector: failed to list metastores: %w", err) + return nil, &rs.SyncOpResults{Annotations: annos}, refuseUnreadable("databricks-connector: failed to list metastores", err) } scoped, running, err := m.scopedWorkspaces(ctx, &annos) @@ -157,10 +158,11 @@ func (m *metastoreBuilder) Grants(ctx context.Context, resource *v2.Resource, at assignments, nextPageToken, rateLimit, err := m.client.ListPermissions(ctx, workspace, databricks.SecurableMetastore, metastoreID, "", pageToken) noteRateLimit(&annos, rateLimit) if err != nil { - // FailedPrecondition, not NotFound: the SDK reads NotFound as "no grants". - // Whether the metastore is gone is settled by the next List. - return nil, &rs.SyncOpResults{Annotations: annos}, uhttp.WrapErrors(codes.FailedPrecondition, - fmt.Sprintf("databricks-connector: failed to list permissions on metastore %s through workspace %s", metastoreID, workspace), err) + // Only a NotFound is FailedPrecondition. The SDK reads NotFound as "no + // grants", and whether the metastore is gone is settled by the next List. + // A throttle or a 5xx keeps the status the client returned, so the SDK retries it. + return nil, &rs.SyncOpResults{Annotations: annos}, refuseUnreadable(fmt.Sprintf( + "databricks-connector: failed to list permissions on metastore %s through workspace %s", metastoreID, workspace), err) } resolver := newPrincipalResolver(m.client, &annos) diff --git a/pkg/connector/metastore_helpers.go b/pkg/connector/metastore_helpers.go index 9217762a..4687597f 100644 --- a/pkg/connector/metastore_helpers.go +++ b/pkg/connector/metastore_helpers.go @@ -14,6 +14,7 @@ import ( "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/types/grant" rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-sdk/pkg/uhttp" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" "google.golang.org/grpc/codes" @@ -27,8 +28,9 @@ const ( // holder a metastore admin for the workspace's Unity Catalog reads. workspacePermissionAdmin = "ADMIN" - // Statuses the account API documents for a workspace. PROVISIONING, - // NOT_PROVISIONED and CANCELLING can still change; FAILED and BANNED cannot. + // Statuses the account API documents for a workspace. PROVISIONING and + // NOT_PROVISIONED can still become RUNNING. CANCELLING is a workspace being + // deleted: it will not become RUNNING. FAILED and BANNED cannot either. workspaceStatusRunning = "RUNNING" workspaceStatusProvisioning = "PROVISIONING" workspaceStatusNotProvisioned = "NOT_PROVISIONED" @@ -155,6 +157,21 @@ func apiStatus(err error) int { return 0 } +// refuseUnreadable keeps a NotFound from reading as an empty result: the SDK +// skips a child List that answers NotFound, and ConductorOne then deletes what +// that parent contained. Every other status, a throttle included, keeps the +// code the client returned so the SDK can still back off and retry. +func refuseUnreadable(msg string, err error) error { + if err == nil { + return nil + } + if apiStatus(err) == http.StatusNotFound || status.Code(err) == codes.NotFound { + return uhttp.WrapErrors(codes.FailedPrecondition, msg, err) + } + + return fmt.Errorf("%s: %w", msg, err) +} + // unityPrincipalScimID is the numeric SCIM id a privilege assignment carries in principal_id. func unityPrincipalScimID(principal *v2.ResourceId) (string, error) { if principal.GetResourceType() != groupResourceType.Id { @@ -194,7 +211,7 @@ func (m *metastoreBuilder) scopedWorkspaces(ctx context.Context, annos *annotati workspaces, rateLimit, err := m.client.ListWorkspaces(ctx) noteRateLimit(annos, rateLimit) if err != nil { - return nil, nil, fmt.Errorf("databricks-connector: failed to list workspaces: %w", err) + return nil, nil, refuseUnreadable("databricks-connector: failed to list workspaces", err) } scoped := make(map[string]databricks.Workspace, len(workspaces)) @@ -250,9 +267,11 @@ func (m *metastoreBuilder) routeMetastore( ids, rateLimit, err := m.client.ListMetastoreWorkspaces(ctx, metastoreID) noteRateLimit(annos, rateLimit) if err != nil { - return nil, false, fmt.Errorf( - "databricks-connector: failed to list the workspaces assigned to metastore %s, so the sync cannot tell which serve it: %w", - metastoreID, err) + // A 404 here is not an empty assignment list. NotFound on this List makes + // the SDK skip the metastore, and ConductorOne deletes it. + return nil, false, refuseUnreadable(fmt.Sprintf( + "databricks-connector: failed to list the workspaces assigned to metastore %s, so the sync cannot tell which serve it", + metastoreID), err) } pending := false diff --git a/pkg/connector/metastore_test.go b/pkg/connector/metastore_test.go index 532b7558..0db0c170 100644 --- a/pkg/connector/metastore_test.go +++ b/pkg/connector/metastore_test.go @@ -259,21 +259,31 @@ func testMetastore(t *testing.T) *v2.Resource { // Which workspaces serve a metastore decides whether it syncs at all, so an // assignment list the connector cannot read is unknown, not absent. Skipping it -// would drop the metastore and make ConductorOne remove its grants. +// would drop the metastore and make ConductorOne remove its grants. Only the +// NotFound has to be re-coded: the SDK runs the metastore List with +// allowWarning, reads NotFound as "the account has no metastores" and lets the +// sync succeed with none. A 401 or 403 already fails it. func TestMetastoreListFailsWhenAnAssignmentListCannotBeRead(t *testing.T) { - for _, code := range []int{http.StatusNotFound, http.StatusForbidden, http.StatusUnauthorized} { + for code, want := range map[int]codes.Code{ + http.StatusNotFound: codes.FailedPrecondition, + http.StatusForbidden: codes.PermissionDenied, + http.StatusUnauthorized: codes.Unauthenticated, + } { t.Run(http.StatusText(code), func(t *testing.T) { f := defaultFakeAccount() f.assignments = map[string]any{"ms-a": code} m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) resources, _, err := m.List(context.Background(), testAccountResourceID, rs.SyncOpAttrs{}) - if err == nil { - t.Fatalf("List returned %d resources and no error, want the sync to fail", len(resources)) + if status.Code(err) != want { + t.Fatalf("List = %v, want %s", err, want) } if !strings.Contains(err.Error(), "ms-a") { t.Errorf("error %q does not name the metastore", err) } + if len(resources) != 0 { + t.Errorf("listed %d metastores, want none", len(resources)) + } }) } } @@ -481,6 +491,35 @@ func TestMetastoreGrantsReadThroughTheRoutedWorkspace(t *testing.T) { } } +// A NotFound must not look like an empty grant page, and a throttle must stay +// retryable. Wrapping every permissions error as FailedPrecondition does both wrong. +func TestMetastoreGrantsKeepThePermissionsStatus(t *testing.T) { + tests := []struct { + name string + code int + want codes.Code + }{ + {name: "not found", code: http.StatusNotFound, want: codes.FailedPrecondition}, + {name: "throttle", code: http.StatusTooManyRequests, want: codes.Unavailable}, + {name: "unavailable", code: http.StatusServiceUnavailable, want: codes.Unavailable}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f := defaultFakeAccount() + f.permStatus = test.code + m := newTestMetastoreBuilder(t, f, nil, syncPrincipals{}) + + grants, _, err := m.Grants(context.Background(), testSyncedMetastore(t, "wsa"), rs.SyncOpAttrs{}) + if status.Code(err) != test.want { + t.Fatalf("Grants = %v, want %s", err, test.want) + } + if len(grants) != 0 { + t.Errorf("grants = %v, want none", grantKeys(grants)) + } + }) + } +} + // No grants and no error would revoke every privilege, so an unrouted resource fails. func TestMetastoreGrantsFailWithoutARouteOnTheProfile(t *testing.T) { f := defaultFakeAccount() From 24ba46c7eef9bef9e3fb208f98225d2230ee735b Mon Sep 17 00:00:00 2001 From: Mateo Hernandez Date: Wed, 28 Oct 2026 11:46:33 -0300 Subject: [PATCH 35/36] docs(databricks): shorten the metastore readme and the missing-workspace error Co-authored-by: Cursor --- README.md | 24 ++++-------------------- pkg/connector/connector.go | 4 +--- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 2fe5a7f6..585e49d5 100644 --- a/README.md +++ b/README.md @@ -233,22 +233,9 @@ running one in scope where it can read every privilege, which it proves by owning the metastore or by holding Admin directly on that workspace. Grant and Revoke resolve the same route from the metastore id. -A metastore is synced only while a workspace this sync can use is attached to -it. The connector never guesses when the answer is unknown: - -| Attached workspace | Result | -|--------------------|--------| -| `RUNNING` and in scope | the metastore syncs through it | -| `PROVISIONING`, `NOT_PROVISIONED` | still changing, so the metastore is kept and the sync fails with `FailedPrecondition` until a workspace is `RUNNING` | -| `CANCELLING` | the workspace is being deleted and will not become `RUNNING`, so the metastore is kept and the sync fails until that workspace is gone or another attached one is `RUNNING` | -| `FAILED`, `BANNED`, or a status Databricks does not document | will not become `RUNNING`, so it is skipped; a metastore left with no other workspace is not synced | -| metastore workspace list unreadable (401, 403, 404) | unknown, so the metastore is kept and the sync fails naming that list | -| workspace permission list unreadable (401, 403, 404) | treated as not Admin on that workspace; the sync then fails with the usual missing-standing error, which does not name this list | - -A metastore is left out of the sync only when the answer is known: every -attached workspace is out of scope or unusable. Leaving it out is what makes -ConductorOne remove it and its grants, so an unknown answer must not take that -path. +A metastore syncs through a running workspace in scope. The sync fails, naming +the metastore, while an attached workspace in scope is still provisioning, or +while the metastore's workspace list cannot be read. ## Unity Catalog provisioning @@ -260,10 +247,7 @@ fails with an `InvalidArgument` error. The service principal must own the metastore, or be assigned **Admin** directly on at least one running workspace attached to it. Admin inherited only through a -group is not enough. Databricks answers an under-permissioned read with HTTP 200 -and only the rows the caller may see, so the connector reads a metastore only -through a workspace that passes that check. When no attached workspace does, the -sync fails and ConductorOne keeps the grants it already has. +group is not enough. Otherwise the sync fails naming the metastore. The connector requests the `all-apis` OAuth scope. Each resource type declares the scopes, roles and privileges it needs, so `./baton-databricks capabilities` diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 47dedc09..09b739d2 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -154,10 +154,8 @@ func (d *Databricks) Validate(ctx context.Context) (annotations.Annotations, err _, ok := matchConfiguredWorkspace(configuredWorkspaceSet(d.workspaces), w.DeploymentName, w.Name, strconv.Itoa(w.ID)) return ok }) { - // ListWorkspaces already dropped the excluded ones, so a name that is - // only excluded would otherwise be reported as absent from the account. return annos, fmt.Errorf( - "databricks-connector: none of the configured workspaces (%s) is on the account and not excluded by databricks-exclude-workspaces", + "databricks-connector: none of the configured workspaces (%s) is on the account", strings.Join(d.workspaces, ", ")) } From 03655ad19bff1745eb3d8e152c4be6a2709735f3 Mon Sep 17 00:00:00 2001 From: Manuel Traversaro Sasia Date: Mon, 2 Nov 2026 15:48:00 -0300 Subject: [PATCH 36/36] docs: align the incremental sync docs with the event feed's behavior (#88) * docs: align incremental sync coverage, timeout and warehouse notes with the code Co-Authored-By: Claude Opus 5.5 * docs: tighten incremental sync scope, timeout and warehouse wording Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- README.md | 33 ++++++++++++++++++++++++--------- config_schema.json | 2 +- docs/connector.mdx | 10 ++++++---- pkg/config/config.go | 4 ++-- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 585e49d5..ec789267 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,17 @@ from ConductorOne. By default, `baton-databricks` does a full resync of every resource on every run. You can opt into an additional, cheap pathway that polls a Databricks audit log between full syncs to pick up access changes early. Full syncs still run as the -correctness backstop; incremental sync does not detect deletions, which are -only caught by the next full sync. +correctness backstop. Incremental sync picks up added and removed group +memberships, but deleted users, groups and service principals are only caught +by the next full sync. + +Role assignments that Databricks stores in access-control rule sets (group roles +such as `roles/group.manager`, service principal roles, and the account +`marketplace.admin` role) are also only caught by the next full sync: the +connector does not read the audit events Databricks writes for rule-set +changes. A `roles/group.manager` change, for example, is logged as +`updateRuleSet` under the `accountsAccessControl` audit service, which the +connector does not query. **Incremental sync is enabled by setting `--sql-warehouse-id` (or `BATON_SQL_WAREHOUSE_ID`) and disabled by leaving it unset or empty.** There is @@ -157,7 +166,8 @@ Incremental sync works only when all of the following are true: - `USE SCHEMA` and `SELECT` on the `system.access` schema - `CAN USE` on the SQL warehouse - For example, run as a metastore admin: + For example, run as a metastore admin, replacing `` with the + service principal's application (client) ID: ```sql GRANT USE CATALOG ON CATALOG system TO ``; @@ -165,16 +175,21 @@ Incremental sync works only when all of the following are true: ``` **We recommend a serverless SQL warehouse.** It starts in seconds, while a pro -or classic warehouse can take several minutes. Each poll waits at most 5 minutes -for its statement, including warehouse startup; a poll that runs out of time -fails with a `DeadlineExceeded` error and is retried on the next poll. Serverless -SQL warehouses require the Premium plan or above. +or classic warehouse can take several minutes. Each statement gets up to 30 +seconds on the initial request, including warehouse startup, then up to 5 more +minutes of polling. A statement still unfinished after that is cancelled, so a +poll can take close to 6 minutes before it fails with a `DeadlineExceeded` +error and is retried on the next poll. Serverless SQL warehouses require the +Premium plan or above. **Incremental sync covers one cloud region.** `system.access.audit` holds workspace-level events only for workspaces in the same cloud region as the workspace that queries it; account-level events are global ([system tables reference](https://docs.databricks.com/aws/en/admin/system-tables/)). The -connector finds the warehouse's workspace automatically, and it receives +connector finds the warehouse's workspace automatically by checking each +workspace the service principal can access, except those excluded with +`--databricks-exclude-workspaces`; the `--workspaces` allowlist does not limit +this search. It receives incremental updates only for workspaces in that region. Workspaces in other regions are still covered by full syncs. @@ -306,7 +321,7 @@ Flags: -p, --provisioning This must be set in order for provisioning actions to be enabled ($BATON_PROVISIONING) --skip-entitlements-and-grants This must be set to skip syncing of entitlements and grants ($BATON_SKIP_ENTITLEMENTS_AND_GRANTS) --skip-full-sync This must be set to skip a full sync ($BATON_SKIP_FULL_SYNC) - --sql-warehouse-id string Setting this enables incremental sync; leaving it empty disables it. ID of the Databricks SQL warehouse used to query the system.access.audit log between full syncs, so access changes show up before the next full sync (deletions are still only caught by full syncs). The warehouse can be in any workspace in the same cloud region as the workspaces to update incrementally; Databricks only exposes workspace-level audit events for that region. The connector discovers the warehouse's workspace automatically. ($BATON_SQL_WAREHOUSE_ID) + --sql-warehouse-id string Setting this enables incremental sync; leaving it empty disables it. ID of the Databricks SQL warehouse used to query the system.access.audit log between full syncs, so access changes show up before the next full sync (deleted users, groups and service principals are still only caught by full syncs). The warehouse can be in any workspace not excluded by databricks-exclude-workspaces, in the same cloud region as the workspaces to update incrementally; Databricks only exposes workspace-level audit events for that region. The connector discovers the warehouse's workspace automatically. ($BATON_SQL_WAREHOUSE_ID) --storage-engine string The storage engine to use when opening the sync c1z file: sqlite or pebble. Defaults to pebble when unset. ($BATON_STORAGE_ENGINE) --sync-resource-types strings The resource type IDs to sync ($BATON_SYNC_RESOURCE_TYPES) --sync-resources strings The resource IDs to sync ($BATON_SYNC_RESOURCES) diff --git a/config_schema.json b/config_schema.json index b12191ad..65d90702 100644 --- a/config_schema.json +++ b/config_schema.json @@ -155,7 +155,7 @@ { "name": "sql-warehouse-id", "displayName": "SQL Warehouse ID", - "description": "Setting this enables incremental sync; leaving it empty disables it. ID of the Databricks SQL warehouse used to query the system.access.audit log between full syncs, so access changes show up before the next full sync (deletions are still only caught by full syncs). The warehouse can be in any workspace in the same cloud region as the workspaces to update incrementally; Databricks only exposes workspace-level audit events for that region. The connector discovers the warehouse's workspace automatically.", + "description": "Setting this enables incremental sync; leaving it empty disables it. ID of the Databricks SQL warehouse used to query the system.access.audit log between full syncs, so access changes show up before the next full sync (deleted users, groups and service principals are still only caught by full syncs). The warehouse can be in any workspace not excluded by databricks-exclude-workspaces, in the same cloud region as the workspaces to update incrementally; Databricks only exposes workspace-level audit events for that region. The connector discovers the warehouse's workspace automatically.", "stringField": {} } ], diff --git a/docs/connector.mdx b/docs/connector.mdx index 5c2edbfc..db250bf5 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -58,6 +58,8 @@ The connector authenticates with **OAuth** — an account-level service principa By default, the Databricks connector picks up access changes on its regular sync schedule. You can optionally turn on incremental sync, which checks Databricks' activity log between full syncs so that changes like new group members show up in C1 sooner. Full syncs still run as usual and remain the source of truth. Removed group memberships are picked up incrementally too, but deleted users, groups, and service principals are only reflected after the next full sync. +Role assignments that Databricks stores in access-control rule sets are also only reflected after the next full sync. These are group roles such as group manager, service principal roles, and the account `marketplace.admin` role. + Incremental sync is turned on by providing the ID of a Databricks SQL warehouse (the **SQL Warehouse ID** setting, `BATON_SQL_WAREHOUSE_ID`) and turned off by leaving it empty — there is no separate on/off switch. Ask your connector operator or C1 support contact to enable it for you. #### Incremental sync requirements @@ -73,7 +75,7 @@ Your Databricks account must meet all of the following: - **Can use** on the SQL warehouse -**We recommend a serverless SQL warehouse.** It starts in seconds, while a pro or classic warehouse can take several minutes to start. Each incremental update waits at most 5 minutes for the warehouse and query, so a slower start can delay updates until the next one. Serverless SQL warehouses require the Premium plan or above. +**We recommend a serverless SQL warehouse.** It starts in seconds, while a pro or classic warehouse can take several minutes to start. The connector waits up to about 6 minutes for the warehouse to start and each query to finish, so a slower start can delay updates until the next one. Serverless SQL warehouses require the Premium plan or above. @@ -106,7 +108,7 @@ A Databricks user with the **Account admin** role, and the **Metastore admin** r -The SQL warehouse can live in any workspace in that region — the connector finds it automatically. Changes reach C1 roughly four hours after they happen: Databricks delivers audit log entries with a delay, so the connector waits before reading them. +The SQL warehouse can live in any workspace in that region that is not listed in **Exclude Workspaces**, including a workspace outside **Workspaces** — the connector finds it automatically. **Workspaces** and **Exclude Workspaces** also limit which workspaces incremental sync updates, as they do for full syncs. Changes reach C1 roughly four hours after they happen: Databricks delivers audit log entries with a delay, so the connector waits before reading them. The incremental sync setup isn't checked when the connector starts, so a problem with it never blocks full syncs or provisioning. If something is missing, such as a permission or a warehouse that can't be found, every incremental update fails and the connector logs an error that names the cause until you fix it. @@ -317,7 +319,7 @@ stringData: BATON_CLIENT_ID: BATON_CLIENT_SECRET: - # Databricks account ID (required for both credential options below) + # Databricks account ID (required) BATON_ACCOUNT_ID: # Databricks credentials: OAuth @@ -333,7 +335,7 @@ stringData: # Optional: exclude specific workspaces from the sync # (workspace name, deployment name, or numeric ID). # Mutually exclusive with BATON_WORKSPACES — set one or the other, never both. - BATON_DATABRICKS_EXCLUDE_WORKSPACES: + # BATON_DATABRICKS_EXCLUDE_WORKSPACES: # Optional: setting this enables incremental sync; leave it unset to disable it. # ID of the SQL warehouse used to query the system.access.audit log between full syncs. diff --git a/pkg/config/config.go b/pkg/config/config.go index 66373b64..900f0e8d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -62,8 +62,8 @@ var ( field.WithDescription( "Setting this enables incremental sync; leaving it empty disables it. "+ "ID of the Databricks SQL warehouse used to query the system.access.audit log between full syncs, "+ - "so access changes show up before the next full sync (deletions are still only caught by full syncs). "+ - "The warehouse can be in any workspace in the same cloud region as the workspaces to update incrementally; "+ + "so access changes show up before the next full sync (deleted users, groups and service principals are still only caught by full syncs). "+ + "The warehouse can be in any workspace not excluded by databricks-exclude-workspaces, in the same cloud region as the workspaces to update incrementally; "+ "Databricks only exposes workspace-level audit events for that region. The connector discovers the warehouse's workspace automatically.", ), field.WithDisplayName("SQL Warehouse ID"),