Skip to content

[CXH-2722] - Add Unity Catalog schema sync - Databricks Connector - #72

Open
mateoHernandez123 wants to merge 46 commits into
mateoHernandez123/unity-catalog-catalog-syncfrom
mateoHernandez123/unity-catalog-schema-sync
Open

mateoHernandez123 wants to merge 46 commits into
mateoHernandez123/unity-catalog-catalog-syncfrom
mateoHernandez123/unity-catalog-schema-sync

Conversation

@mateoHernandez123

@mateoHernandez123 mateoHernandez123 commented Oct 9, 2026 •

Copy link
Copy Markdown

Description

  • Bug fix
  • New feature

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_SCHEMA without 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.

flowchart TD
  account[account]
  metastore[metastore]
  catalog[catalog]
  other["user, group, service principal, role, workspace"]
  account --> other
  account --> metastore
  metastore --> catalog
Loading

After this PR. Schemas hang from the catalog, with their own entitlements and grants.

flowchart TD
  account[account]
  metastore[metastore]
  catalog[catalog]
  schema[schema]
  other["user, group, service principal, role, workspace"]
  account --> other
  account --> metastore
  metastore --> catalog
  catalog --> schema
  style schema fill:#1f6feb,color:#fff,stroke:#1f6feb
Loading
Order PR Delivers Base State
1 #70 Client, routing, metastore staging Merged
2 #71 Catalog staging Open
3 #72 Schema #71 This PR
4 #73 Table and volume #72 Open

Sync:

flowchart LR
  catalog[catalog]
  info[information_schema]
  schema[schema]
  catalog -.->|skipped| info
  catalog --> schema
  schema --> entitlements[one entitlement per schema privilege]
  schema --> owner[read-only owner]
Loading

Provisioning:

  • Grant/Revoke schema privileges — the same PATCH .../permissions path and the same echo-confirmed idempotency as the catalog: GrantAlreadyExists and GrantAlreadyRevoked.
  • A revoke that fails with SCHEMA_DOES_NOT_EXIST or CATALOG_DOES_NOT_EXIST is already revoked. Only the error code proves the absence, because an unbound or unprivileged workspace answers 404 as well.
  • Owner is read-only.

Auth:

Credentials are unchanged. There is no new config field.

Schema List fails with PermissionDenied unless the service principal owns the metastore, owns the catalog, or holds MANAGE, USE_SCHEMA or ALL_PRIVILEGES on 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:

  • Shared trust probe: holdsPrivilegeOn is 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.
  • Principals: schema Grants uses the same principal path as [CXH-2720] - Add Unity Catalog client, access-path routing, and metastore sync - Databricks Connector #70. It reads the full account SCIM only when assignments exist, and uses the Find* filters for an owner-only grant.

Useful links:

@linear-code

linear-code Bot commented Oct 9, 2026

Copy link
Copy Markdown

CXH-2722

@mateoHernandez123
mateoHernandez123 force-pushed the mateoHernandez123/unity-catalog-catalog-sync branch from d90274a to aabdb53 Compare October 9, 2026 13:20
@mateoHernandez123
mateoHernandez123 force-pushed the mateoHernandez123/unity-catalog-schema-sync branch from 920df46 to f9142b4 Compare October 9, 2026 13:20
@mateoHernandez123
mateoHernandez123 force-pushed the mateoHernandez123/unity-catalog-catalog-sync branch from aabdb53 to b12726a Compare October 9, 2026 14:27
@mateoHernandez123
mateoHernandez123 force-pushed the mateoHernandez123/unity-catalog-schema-sync branch from f9142b4 to 29274ee Compare October 9, 2026 14:27
@mateoHernandez123
mateoHernandez123 force-pushed the mateoHernandez123/unity-catalog-catalog-sync branch from b12726a to b2ecaf2 Compare October 9, 2026 16:46
@mateoHernandez123
mateoHernandez123 force-pushed the mateoHernandez123/unity-catalog-schema-sync branch from 29274ee to e74210e Compare October 9, 2026 16:46
@mateoHernandez123
mateoHernandez123 force-pushed the mateoHernandez123/unity-catalog-catalog-sync branch from b2ecaf2 to 00adc02 Compare October 9, 2026 16:54
@mateoHernandez123
mateoHernandez123 force-pushed the mateoHernandez123/unity-catalog-schema-sync branch from e74210e to 7deff05 Compare October 9, 2026 16:54
@mateoHernandez123
mateoHernandez123 force-pushed the mateoHernandez123/unity-catalog-catalog-sync branch from 00adc02 to 5933db6 Compare October 9, 2026 18:16
@mateoHernandez123
mateoHernandez123 force-pushed the mateoHernandez123/unity-catalog-schema-sync branch from 7deff05 to 1253d7e Compare October 9, 2026 18:17

@FeliLucero1 FeliLucero1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) 👍

Comment thread pkg/connector/schema.go Resolved
Comment thread pkg/connector/schema.go Resolved
Comment thread pkg/connector/schema.go Resolved
Comment thread pkg/connector/schema.go Resolved
Comment thread pkg/connector/schema.go Resolved
Comment thread pkg/connector/schema.go Resolved
Comment thread pkg/connector/schema.go Resolved

@FeliLucero1 FeliLucero1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image Image Image

@mateoHernandez123
mateoHernandez123 marked this pull request as ready for review October 22, 2026 21:38
mateoHernandez123 and others added 8 commits October 23, 2026 10:12
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

@FeliLucero1 FeliLucero1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread pkg/connector/schema.go Resolved
Comment thread pkg/connector/schema.go Resolved
Comment thread docs/connector.mdx Resolved
mateoHernandez123 and others added 6 commits October 27, 2026 14:08
…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]>
…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 sergiocorral-conductorone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment thread pkg/connector/schema.go Resolved
Comment thread pkg/connector/catalog.go Resolved
Comment thread pkg/connector/schema.go Resolved
Comment thread pkg/connector/schema_test.go Resolved
mateoHernandez123 and others added 2 commits October 28, 2026 10:14
…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]>
mateoHernandez123 and others added 2 commits October 28, 2026 16:17
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]>
Comment thread pkg/connector/catalog.go
rs.WithParentResourceID(parent),
rs.WithAnnotation(&v2.RawId{Id: resourceID}),
rs.WithAnnotation(
// Without this annotation the schema syncer's List is never invoked.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: Similar to the comment I made on #73, I don't think this line is needed. Child resources always work this way.

Comment thread pkg/connector/schema.go

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is an internal error. Not that it really matters, as InvalidArgument will fail the sync too.

Comment thread pkg/connector/schema.go
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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a log level higher than debug? I don't know how important it is or how likely it is to happen.

Comment thread pkg/connector/schema.go

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

https://github.com/ConductorOne/baton-sdk/blob/3bed541a13e01fbadd3ba94f0b58bb4ec369ea0c/pkg/connectorbuilder/resource_syncer.go#L177

Comment thread pkg/connector/schema.go

var sawCatalog bool
var lastDeny error
for i := range usable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you could remove the workspace := usable[i] with:

Suggested change
for i := range usable {
for _, workspace := range usable {

Comment thread pkg/connector/schema.go
metastoreOwner: owner,
parentOwner: catalog.Owner,
fullName: catalogName,
workspace: route.Workspace,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/connector/schema.go
// 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/connector/schema.go
var lastDeny error
for i := range usable {
workspace := usable[i]
catalog, err := catalogThrough(ctx, b.client, workspace.DeploymentName, metastoreID, catalogName, annos)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 sergiocorral-conductorone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread pkg/connector/schema.go
lastDeny = err
continue
}
return err

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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":

  • workspaceForCatalog does if errors.Is(err, errCatalogGone) { continue }.
  • It only reports gone when every workspace says so.
  • Even then, confirmCatalogGone re-checks with include_unbound=true and 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.

Comment thread pkg/connector/schema.go
}

// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 returns PermissionDenied ("cannot read every privilege on schema…") instead of GrantAlreadyRevoked.
  • 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants