Repository navigation
[CXH-2722] - Add Unity Catalog schema sync - Databricks Connector - #72
mateoHernandez123 wants to merge 46 commits into
Conversation
d90274a to
aabdb53
Compare
920df46 to
f9142b4
Compare
aabdb53 to
b12726a
Compare
f9142b4 to
29274ee
Compare
b12726a to
b2ecaf2
Compare
29274ee to
e74210e
Compare
b2ecaf2 to
00adc02
Compare
e74210e to
7deff05
Compare
00adc02 to
5933db6
Compare
7deff05 to
1253d7e
Compare
FeliLucero1
left a comment
There was a problem hiding this comment.
Did a pass on this one. The main things are the two mediums: Revoke on a dropped schema fails instead of returning already-revoked, and List swallows a 404 into an empty page. The rest is perf/docs/nits.
Stuff tagged (out of scope) is inherited from #71 or is stack-wide, so FYI, not blocking for this PR. Also checked the schema privilege set against the Databricks docs and it matches exactly (27, minus BROWSE / CREATE_SCHEMA / USE_CATALOG) 👍
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Cursor <[email protected]>
Carries the main merge from the parent branches: the uhttp cache now keys on both the host scope header and Cache-Control, and the workspace assignment read-before-write uses GetUncached. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… stack The tree is unchanged; this only propagates the parent link so GitHub sees main as merged. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…catalog-sync' into mateoHernandez123/unity-catalog-schema-sync
Co-authored-by: Cursor <[email protected]>
Co-authored-by: Cursor <[email protected]>
FeliLucero1
left a comment
There was a problem hiding this comment.
Another pass. Nice work on the live checks (SCHEMA_DOES_NOT_EXIST, the privilege PATCH) and moving Grant/Revoke onto the shared helpers 👍
The main things this round are about schemas silently disappearing: the partial-listing skip (replied on the :73 thread), the usable[0] regression (replied on Sergio's :353 thread), and a 404 that still comes out as NotFound. The rest are nits. (out of scope): the same NotFound-wrapping shape exists in catalog.go pushRoute (#71).
…oHernandez123/unity-catalog-schema-sync Co-authored-by: Cursor <[email protected]> # Conflicts: # docs/connector.mdx # pkg/connector/metastore_helpers.go
…of skipping it Skipping every catalog the connector cannot fully list deletes the schemas of a catalog that used to list completely and later lost standing, along with their whole subtree. The sync now stops on that catalog, so ConductorOne keeps what it already has. The built-in system catalog stays a skip: a workspace admin sees 2 of its 15 schemas and this connector has never synced the rest, so leaving it out deletes nothing. pushRoute also walks every running in-scope workspace until one lists the catalog and can read every schema, rather than taking the first one and emitting no schemas when it does not list the catalog. Co-authored-by: Cursor <[email protected]>
…oHernandez123/unity-catalog-schema-sync Co-authored-by: Cursor <[email protected]> # Conflicts: # docs/docs-info.md # pkg/databricks/unity_catalog.go
065b397 dropped the tests that proved the route tries another workspace, and the walk came back without them. These pin both halves: a catalog the first workspace does not serve is read through the next one, and no workspace serving it fails the sync rather than emitting zero schemas, which ConductorOne would apply as deleting every schema the catalog had. Co-authored-by: Cursor <[email protected]>
…oHernandez123/unity-catalog-schema-sync
…t is partial The sync stops on system. It is built in, owned by System user, and Databricks refuses a MANAGE grant on it, same as samples. This connector has never synced either, so leaving them out deletes nothing. Any other catalog still fails the sync, which keeps what ConductorOne already has. Co-authored-by: Cursor <[email protected]>
sergiocorral-conductorone
left a comment
There was a problem hiding this comment.
Automated review (follow-up)
Head moved since our last LGTM (ce73192 → abee6af). Resolved at head: the usable[0] regression (workspace walk is back, TestSchemaListTriesTheNextWorkspace restored), the partial-listing fail-closed behaviour, the docs nit on the workspace-admins-group exception. Still open from earlier rounds: FeliLucero1's thread on schema.go:269 (plain %w on the metastore-assignment / workspace / metastore-owner reads lets a NotFound become an SDK warning). New this round: 1 regression (Revoke on a dropped schema), 1 medium (built-in catalog skip), 2 low.
drafted 4 → confirmed 4, low-confidence 0, dropped 0, added 0 (panel: opus-5.5 / gpt-6-astra / gemini-4-argon, all high)
…oHernandez123/unity-catalog-schema-sync Co-authored-by: Cursor <[email protected]> # Conflicts: # pkg/connector/catalog.go # pkg/connector/catalog_test.go
schemaLevel carried no goneErrorCodes, so IsSecurableGone never matched and the 404 SCHEMA_DOES_NOT_EXIST on the permissions read fell through as a plain error. That error keeps uhttp's NotFound, which ConductorOne treats as non-retryable, so the revoke task failed and the grant was never marked revoked. CATALOG_DOES_NOT_EXIST is in the list too, since dropping the parent catalog makes the schema read answer with it. Schema List no longer special-cases the system catalog. Catalog List already leaves system and samples out, by catalog_type and before their schemas are read, so that branch could not be reached and matched only the name. docs/connector.mdx said the catalog was left out because its schemas cannot be listed; it is left out one level up. Co-authored-by: Cursor <[email protected]>
Co-authored-by: Cursor <[email protected]>
Conflicts came from the two layers moving the same code in different directions. This branch generalised holdsCatalogManage into holdsPrivilegeOn, and the catalog branch added two error branches to the version it deleted. Resolved by keeping this layer's helper and porting the behaviour into it: holdsPrivilegeOn now takes the gone error codes of the level that calls it. A level that names them reports errSecurableGone for a 404 that carries one, and refuses any other 404 instead of leaving the privilege unproven. The schema call sites name none, so their reads are unchanged. The workspace admins group that owns a catalog is now checked against the workspace its name carries, on the schema layer too. The List fallback test no longer reaches the second workspace through that mismatch, so it refuses the privilege read on the first workspace instead, and a new test covers the admins group of another workspace settling trust on the first route. Co-Authored-By: Claude Opus 5 <[email protected]>
| rs.WithParentResourceID(parent), | ||
| rs.WithAnnotation(&v2.RawId{Id: resourceID}), | ||
| rs.WithAnnotation( | ||
| // Without this annotation the schema syncer's List is never invoked. |
There was a problem hiding this comment.
Nitpick: Similar to the comment I made on #73, I don't think this line is needed. Child resources always work this way.
|
|
||
| var route schemaRoute | ||
| if err := json.Unmarshal([]byte(bag.ResourceID()), &route); err != nil { | ||
| return nil, nil, status.Errorf(codes.InvalidArgument, "databricks-connector: invalid schema page token: %v", err) |
There was a problem hiding this comment.
I think this is an internal error. Not that it really matters, as InvalidArgument will fail the sync too.
| return nil, &rs.SyncOpResults{Annotations: annos}, err | ||
| } | ||
| if bag.Current() == nil { | ||
| ctxzap.Extract(ctx).Debug("databricks-connector: no workspace in sync scope lists the catalog; listing no schemas", |
There was a problem hiding this comment.
Should this be a log level higher than debug? I don't know how important it is or how likely it is to happen.
|
|
||
| if next != "" && next == bag.PageToken() { | ||
| return nil, &rs.SyncOpResults{Annotations: annos}, status.Errorf(codes.Internal, | ||
| "databricks-connector: listing schemas of catalog %s repeated page token %q", catalogName, next) |
There was a problem hiding this comment.
baton-sdk will always error if a List(), Entitlements(), or Grants() returns the same page token as the previous call. So it's fine to delete this code.
|
|
||
| var sawCatalog bool | ||
| var lastDeny error | ||
| for i := range usable { |
There was a problem hiding this comment.
I think you could remove the workspace := usable[i] with:
| for i := range usable { | |
| for _, workspace := range usable { |
| metastoreOwner: owner, | ||
| parentOwner: catalog.Owner, | ||
| fullName: catalogName, | ||
| workspace: route.Workspace, |
There was a problem hiding this comment.
route.Workspace is really workspace.DeploymentName and route.WorkspaceID is strconv.Itoa(workspace.ID). So you could declare the schemaRoute in the json.Marshal() call if you changed these calls to use the values. Something like workspaceID := strconv.Itoa(workspace.ID) would avoid two int->string conversions, which doesn't matter for performance but might be a little more readable.
| // and the SDK reads a NotFound as a securable with no children. | ||
| func schemaListingError(catalogName, workspace string, err error) error { | ||
| switch apiStatus(err) { | ||
| case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound: |
There was a problem hiding this comment.
Do we want to convert all of these into FailedPrecondition? Unauthorized and Forbidden will still cause baton-sdk to fail the sync, so there's no change in behavior there. But changing NotFound to FailedPrecondition means that a sync will fail instead of skipping the resource in some cases.
There was a problem hiding this comment.
FWIW I think FailedPrecondition is what we want for these. With baton-sdk v0.40.0, a NotFound from List gets skipped as a warning, and nothing in the c1z says it was partial. On the c1 side, uplift deletes anything that's missing from the c1z (Bucket in pkg/mapper/resource/v2/uplift.go ~1259-1300 → DeleteAppResource). It doesn't check whether the parent was fully listed, and there's no mass-deletion threshold. So "skip" here ends up as "delete every schema + grant under that catalog" on a transient 404. Mateo reproduced it with a proxy injecting the 404: the old binary exited 0 with zero metastores. Failing the sync keeps the previous data. Happy to be corrected if the SDK handles this somewhere I missed though.
| var lastDeny error | ||
| for i := range usable { | ||
| workspace := usable[i] | ||
| catalog, err := catalogThrough(ctx, b.client, workspace.DeploymentName, metastoreID, catalogName, annos) |
There was a problem hiding this comment.
[low] since the #71 merge, catalogThrough returns errCatalogGone (plain errors.New, so codes.Unknown, no prefix), but this loop only special-cases deniesTrust, so it goes straight up through return err. If a catalog gets dropped between catalog List and schema List, the whole sync fails. catalog.go:266 already does if errors.Is(err, errCatalogGone) { continue }, so maybe return nil here for the same case?
Related: schemaListingIsComplete / schemaGrantsAreTrustworthy still pass nil goneCodes to holdsPrivilegeOn. So a schema dropped between List and Grants, when the SP only has MANAGE on the schema, ends in a misleading "grant MANAGE" PermissionDenied. That's the fix Sergio got at catalog level on #71, just not carried down here. Passing schemaLevel.goneErrorCodes would cover it.
sergiocorral-conductorone
left a comment
There was a problem hiding this comment.
Automated review (follow-up round on new commits).
Stats: drafted 2 → confirmed 2, low-confidence 0, dropped 0, added 0 (panel: opus-5.5 / gpt-6-astra / gemini-4-argon, all high).
| lastDeny = err | ||
| continue | ||
| } | ||
| return err |
There was a problem hiding this comment.
[major] The walk here no longer gets past a workspace that answers 404 CATALOG_DOES_NOT_EXIST. After the #71 change picked up in this range, catalogThrough returns the plain errCatalogGone sentinel for that answer (catalog.go case databricks.IsSecurableGone(err, errorCodeCatalogGone): return nil, errCatalogGone). It used to return nil, nil, which this loop skipped with catalog == nil -> continue. errCatalogGone is not an API error, so deniesTrust is false, and the loop hits return err. The schema List then fails with gRPC code Unknown and the message "catalog no longer exists", and wsb is never tried.
The rest of this range treats a per-workspace CATALOG_DOES_NOT_EXIST as "this workspace doesn't serve it":
workspaceForCatalogdoesif errors.Is(err, errCatalogGone) { continue }.- It only reports gone when every workspace says so.
- Even then,
confirmCatalogGonere-checks withinclude_unbound=trueand can answer "still exists but no workspace in sync scope can list it".
So by the code's own model, an ISOLATED catalog bound only to usable[1] can come back gone from usable[0]. Through this List, that now breaks the sync for that catalog, which is the case 4279653524 and TestSchemaListTriesTheNextWorkspace exist for. The existing test misses it because serveCatalog sends a 404 with no error_code, which takes the deniesTrust branch. Suggest:
if errors.Is(err, errCatalogGone) || deniesTrust(err) {
lastDeny = err // or track gone separately
continue
}Also add a variant of TestSchemaListTriesTheNextWorkspace where wsa's 404 carries error_code: CATALOG_DOES_NOT_EXIST. If every workspace says gone, the existing !sawCatalog FailedPrecondition still keeps C1's schemas, so that path stays fail-closed.
| } | ||
|
|
||
| // MANAGE on the schema is the cheaper of the two reads, so it goes first. | ||
| manages, err := holdsPrivilegeOn(ctx, client, trust, databricks.SecurableSchema, trust.fullName, managePrivilege, nil, annos) |
There was a problem hiding this comment.
[minor] Follow-up on 4396462360: the goneErrorCodes fix only works when the trust check short-circuits on ownership. revokeSecurable runs resolveChange first, and resolveChange runs ensureGrantsAreTrustworthy before the permissions read that maps the gone codes. Here the probes pass nil goneCodes. In this range holdsPrivilegeOn then treats a 404 as "unproven" (deniesTrust includes 404), not as gone.
So for a principal that doesn't own the metastore or the catalog:
- Parent catalog dropped: both MANAGE probes (schema, then catalog) 404 with
CATALOG_DOES_NOT_EXIST. Trust stays unproven, and Revoke returnsPermissionDenied("cannot read every privilege on schema…") instead ofGrantAlreadyRevoked. - Schema dropped, principal holds MANAGE only on the schema: same result.
The docs' MANAGE-per-catalog option is exactly this kind of principal, and the new test only covers a principal that owns the metastore (testSchema(..., testOwnPrincipal, ...)). Catalog level already does this (catalogGrantsAreTrustworthy passes []string{errorCodeCatalogGone} and maps errSecurableGone). Passing schemaLevel.goneErrorCodes to these two probes, and returning a gone result that revokeSecurable turns into GrantAlreadyRevoked, would make the fix hold for non-owners. You can test it with the existing fake by setting metastoreOwner/catalogOwner to someone else and putting the permErrorCode 404 on the probe reads.



Description
Third of four stacked PRs for Unity Catalog (CXH-2722). A schema sits between a catalog and the tables and volumes an access review certifies. A principal can hold
USE_SCHEMAwithout holding anything on those tables, so the schema is its own resource.#70 is merged. #71 merges before this one.
What the connector syncs
Before, with #71 applied. The catalog is a leaf.
After this PR. Schemas hang from the catalog, with their own entitlements and grants.
stagingstagingSync:
schema, NEW) — child ofcatalog, read withGET /api/2.1/unity-catalog/schemas?catalog_name=. Keyed{metastore_id}::{catalog}.{schema}. The privilege set is the catalog set withoutBROWSE,CREATE_SCHEMAandUSE_CATALOG, plus a read-onlyowner.information_schemais skipped. Its system views are owned by principals that do not exist in SCIM.Provisioning:
PATCH .../permissionspath and the same echo-confirmed idempotency as the catalog:GrantAlreadyExistsandGrantAlreadyRevoked.SCHEMA_DOES_NOT_EXISTorCATALOG_DOES_NOT_EXISTis already revoked. Only the error code proves the absence, because an unbound or unprivileged workspace answers 404 as well.Auth:
Credentials are unchanged. There is no new config field.
Schema List fails with
PermissionDeniedunless the service principal owns the metastore, owns the catalog, or holdsMANAGE,USE_SCHEMAorALL_PRIVILEGESon the catalog. Workspace Admin alone is not enough.A direct Admin is also enough when the catalog is owned by that workspace's admins group. The connector reads the workspace id off the group name
_workspace_admins_<workspace>_<id>and checks that workspace, whatever route the schema is listed through. Permission visibility follows the caller, not the host.The visibility probe walks the attached workspaces. A 401 or 403 leaves the privilege unproven and the probe tries the next workspace. Any other probe error fails that catalog's schema List. The page token carries the workspace and the metastore owner. It does not carry the catalog owner.
Architecture highlights:
holdsPrivilegeOnis the one uncached read of the connector's own effective privileges. Each level passes the privileges it accepts and the error codes that prove the securable is gone. The schema level names none, so every 404 there leaves the privilege unproven rather than claiming an absence.Find*filters for an owner-only grant.Useful links: