Skip to content

Add Unity Catalog resource sync (catalogs, schemas, tables) - #66

Closed
afalahi wants to merge 3 commits into
mainfrom
alifalahi/cxh-2341-baton-databricks-sync-unity-catalog-resources-catalogs
Closed

afalahi wants to merge 3 commits into
mainfrom
alifalahi/cxh-2341-baton-databricks-sync-unity-catalog-resources-catalogs

Conversation

@afalahi

@afalahi afalahi commented Sep 21, 2026

Copy link
Copy Markdown

Summary

Adds Unity Catalog data assets to the baton-databricks connector so access reviews can certify who can read/modify specific catalogs, schemas, and tables. Previously the connector synced only account-level identity resources (accounts, groups, roles, service principals, workspaces).

Resolves CXH-2341.

What's included

Resources — three new resource types synced in a workspace → catalog → schema → table hierarchy:

  • catalog, schema, table builders with full List / Entitlements / Grants / Grant / Revoke.
  • IDs encode {deployment}::{full_name} so child List calls can reconstruct the workspace and Unity Catalog full name from the parent ResourceId alone.

Entitlements & grants

  • One permission entitlement per Unity Catalog privilege per level (supersets covering SELECT, MODIFY, MANAGE, ALL_PRIVILEGES, USE_*, etc.).
  • Grants read from the direct privilege-assignments (GET /api/2.1/unity-catalog/permissions/{type}/{full_name}), not effective/inherited, to avoid over-reporting in reviews.
  • Securable owners are surfaced as an explicit read-only owner entitlement, since ownership carries implicit full control the permissions API does not return.
  • Group principals get membership-expansion annotations so reviews see effective members.

Principal resolution — Unity Catalog references principals by name; grants map both directions:

  • name → resource: user by userName, group by display name, service principal by application ID.
  • resource → name for provisioning.

Provisioning — Grant/Revoke via PATCH .../permissions/{type}/{full_name} with add/remove changes. Owner is intentionally rejected (not settable through the permissions API).

Client layer (pkg/databricks/unity_catalog.go) — cursor-paginated ListCatalogs / ListSchemas / ListTables, plus ListPermissions / UpdatePermissions.

Testing

  • go build ./..., go vet ./..., gofmt -l: clean.
  • go test ./...: all packages pass.
  • TestUnityCatalogRequests captures real requests through the client and asserts UC path assembly, dotted full-name preserved as a single path segment, query params, and the PATCH changes body.

Operational note

The OAuth service principal must be assigned to each target workspace with rights to enumerate Unity Catalog securables and read grants; end-to-end sync was not run here (no live tenant).

Out of scope

Metastore-level resource and any tag-driven / just-in-time-elevation overlays are deliberately excluded.

Sync Unity Catalog catalogs, schemas, and tables as resources with
per-privilege entitlements and permission grants for access reviews.
Resolve UC principals (user, group, service principal by application
ID) and surface securable owners. Support grant/revoke via the
permissions API.
@linear-code

linear-code Bot commented Sep 21, 2026

Copy link
Copy Markdown

CXH-2341

Comment thread pkg/connector/unity_catalog.go Outdated
Comment on lines +163 to +169
for _, priv := range a.Privileges {
g, err := securableGrant(ctx, c, resource, workspaceId, priv, principalID)
if err != nil {
return nil, err
}
rv = append(rv, g)
}

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.

🟡 Suggestion: the privilege lists at lines 34-48 aren't actually supersets of what the API can return (EXTERNAL_USE_SCHEMA is missing at catalog/schema level, and Databricks keeps adding privileges), so this loop can emit a grant whose entitlement ID was never produced by Entitlements(). Either skip-and-Warn on privileges not in the level's list, or drive entitlement creation from the same source of truth so the invariant in the comment holds.

Comment on lines +98 to +118
func resolvePrincipalResourceID(ctx context.Context, c *databricks.Client, workspaceId, principal string) (*v2.ResourceId, error) {
if userID, _, err := c.FindUserID(ctx, workspaceId, principal); err == nil && userID != "" {
return &v2.ResourceId{ResourceType: userResourceType.Id, Resource: userID}, nil
} else if err != nil {
return nil, err
}

if groupID, _, err := c.FindGroupID(ctx, workspaceId, principal); err == nil && groupID != "" {
return &v2.ResourceId{ResourceType: groupResourceType.Id, Resource: groupID}, nil
} else if err != nil {
return nil, err
}

if spID, _, err := c.FindServicePrincipalID(ctx, workspaceId, principal); err == nil && spID != "" {
return &v2.ResourceId{ResourceType: servicePrincipalResourceType.Id, Resource: spID}, nil
} else if err != nil {
return nil, err
}

return nil, nil
}

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.

🟡 Suggestion: this runs 1-3 uncached SCIM list calls per principal per securable. Grants() is invoked for every catalog, schema and table, so a metastore with a few thousand tables and a handful of grantees each turns into tens of thousands of SCIM requests per sync — slow and very likely to hit Databricks rate limits. A per-workspace map[string]*v2.ResourceId cache (plus a negative cache for unresolved names) on the builder or client would collapse almost all of it.

Comment thread pkg/connector/unity_catalog.go Outdated
return nil, err
}

if groupID, _, err := c.FindGroupID(ctx, workspaceId, principal); err == nil && groupID != "" {

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.

🟡 Suggestion: these Find*ID helpers interpolate the principal straight into a SCIM filter (displayName eq '<value>' in client.go:371). A Unity Catalog group named e.g. Bob's Team produces a malformed filter, the SCIM call 400s, and the error propagates up through securableGrants and fails the sync. Worth escaping/quoting the value in the Find* helpers (or treating an invalid-filter 400 as unresolved) now that arbitrary UC principal strings reach this path.

Comment thread pkg/connector/unity_catalog.go Outdated
Comment thread pkg/connector/workspaces.go Outdated
Degrade gracefully when Unity Catalog is unavailable or the service
principal lacks UC access: catalog/schema/table List and grant
enumeration log a warning and skip instead of failing the whole sync,
while context cancellation still propagates. Regenerate
baton_capabilities.json and update the docs capabilities table for the
new catalog, schema, and table resource types.
name, _, err := c.FindUsername(ctx, workspaceId, principal.Resource)
return name, err
case groupResourceType.Id:
name, _, err := c.FindGroupDisplayName(ctx, workspaceId, principal.Resource)

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.

🟠 Bug: Group principals in this connector carry a composite resource ID (groupResourceId() builds account/<accountId>/group/<groupId> or workspace/<ws>/group/<groupId>), and securableGrant emits group grants through groupGrantExpansion, so principal.Resource here is that composite string — not a SCIM group ID. FindGroupDisplayName then filters id eq 'account/acc-x/group/123', matches nothing, and returns "", so securableGrantChange fails with "could not resolve principal ..." for every group Grant/Revoke on a catalog/schema/table. groupBuilder.Grant/Revoke and servicePrincipalBuilder handle this by calling parseResourceId(principal.Id.Resource) first; do the same here (and derive the SCIM scope from the parsed parent, since an account-parented group must be looked up against the account API).

Confidence: high.

rv = append(rv, ent.NewPermissionEntitlement(
resource,
ownerEntitlement,
ent.WithGrantableTo(userResourceType, groupResourceType, servicePrincipalResourceType),

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.

🟡 Suggestion: The owner entitlement is declared grantable to users, groups and service principals, but securableGrantChange unconditionally rejects it ("ownership cannot be provisioned via the permissions API"). C1 will offer this entitlement for access requests and every grant attempt will fail. Drop ent.WithGrantableTo(...) for the owner entitlement so it stays read-only, matching the comment on ownerEntitlement.

// connector resource ID. Returns nil when the principal cannot be matched to any
// known identity so the caller can skip it.
func resolvePrincipalResourceID(ctx context.Context, c *databricks.Client, workspaceId, principal string) (*v2.ResourceId, error) {
if userID, _, err := c.FindUserID(ctx, workspaceId, principal); err == nil && userID != "" {

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.

🟡 Suggestion: Resolution is scoped to the securable's workspace SCIM API only. Unity Catalog privileges can be held by account-level users/groups/service principals that are not assigned to that workspace, so those assignments resolve to nil and are dropped with a Warn — silent grant loss rather than a visible failure. Consider falling back to an account-scoped lookup (workspaceId == "") when c.IsAccountAPIAvailable() and the workspace lookup finds nothing.

Confidence: medium.

Comment thread pkg/connector/unity_catalog.go Outdated
}
// Degrade gracefully: a securable whose grants cannot be read (UC access
// missing, securable deleted mid-sync) must not fail the whole sync.
l.Warn("databricks-connector: unable to list unity catalog permissions, skipping securable grants",

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.

🟡 Suggestion: This Warn (and the unresolved-principal Warns below) fires once per securable, so a metastore where UC permissions are not readable will emit one warning per table — thousands per sync. Consider logarithmic sampling (1, 10, 100, every 1000) with a total_occurrences field, or aggregating to a single warning per catalog.

NextPageToken string `json:"next_page_token"`
}

type permissionsResponse struct {

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.

🟡 Suggestion: permissionsResponse omits next_page_token, which the UC "Get permissions" endpoint does return. It is safe today because ListPermissions sends no max_results (the API then returns all assignments), but if that default ever changes — or a caller adds max_results — grants will be silently truncated with no way to detect it. Worth decoding next_page_token and looping/returning a cursor.

}

// ListTables returns a page of tables within a schema.
func (c *Client) ListTables(

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.

🟡 Suggestion: The List Tables response carries full column and property metadata by default, none of which is used by tableResource — that is a large payload per page on wide tables. Passing omit_columns=true and omit_properties=true via ucListVars would cut response size substantially.

@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Connector PR Review: Add Unity Catalog resource sync (catalogs, schemas, tables)

Blocking Issues: 2 | Suggestions: 7 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base a2f929012b2b.
Review mode: incremental since 543574c3
View review run

Review Summary

The full PR diff was scanned for security and correctness; the new commit was additionally reviewed at suggestion level. Commit 6912637 addresses three prior findings well: UC sync is now opt-in behind sync-unity-catalog / sync-unity-catalog-tables (gated by omitting the child-resource-type annotations rather than by conditional builder registration — correct per F1), principal resolution now tries account SCIM before the workspace scope, the new ucResolver caches results (including negatives) to stop the SCIM lookup storm, and rate-limit descriptions are now propagated from List and Grants. The composite-group-ID Grant/Revoke bug and the non-grantable owner entitlement remain unaddressed, and the new account-scope resolution introduces a related dangling-principal case for workspace-local identities.

Security Issues

None found.

Correctness Issues

  • pkg/connector/unity_catalog.go:138-148 — new: the workspace-scope fallback returns workspace-SCIM IDs, but when the account API is available identities are synced only from account SCIM, so those grants point at principals that were never synced (securableGrant also picks the group parent from IsAccountAPIAvailable() alone).
  • pkg/connector/unity_catalog.go:205 — unaddressed prior finding: resolvePrincipalName still passes the composite group resource ID (account/<acct>/group/<id>) to FindGroupDisplayName, whose SCIM filter is id eq '<value>'. Both scopes return empty, so every group Grant/Revoke on a catalog/schema/table fails with "could not resolve principal". Use parseResourceId first, as groups.go:292 does.

Suggestions

  • pkg/connector/unity_catalog.go:268-277 — new: the privilege lists are now an authoritative filter, so unlisted privileges (legacy USAGE/CREATE/CREATE_VIEW, future enum additions) are silently dropped from reviews; the per-privilege Warn also needs logarithmic sampling (L7).
  • pkg/connector/unity_catalog_test.go — new: the gating test is good, but the resolver's scope ordering and caching, the privilege filter, and the rate-limit annotation pass-through are all new behavior with no coverage.
  • pkg/connector/unity_catalog.go:92 — unaddressed: owner is GrantableTo users/groups/service principals but securableGrantChange always rejects it; make it read-only.
  • pkg/connector/unity_catalog.go:228 — unaddressed: the per-securable "unable to list permissions" and "unresolved principal" warns still fire once per securable.
  • pkg/databricks/unity_catalog.go:79 — unaddressed: permissionsResponse still ignores next_page_token; safe only while max_results is unset.
  • pkg/databricks/unity_catalog.go:158 — unaddressed: ListTables still pulls unused column/property metadata; pass omit_columns=true/omit_properties=true.
  • README.md:79 / docs/connector.mdx — partially addressed: docs/connector.mdx now documents both new config vars and the three capability rows, but the README "Data Model" list still omits Catalogs/Schemas/Tables and neither doc states the Unity Catalog metastore privileges the service principal needs to enumerate securables and read grants (D3).
  • No spec/openapi.json in the repo while this PR adds five UC endpoints; consider generating it with the build-openapi-spec.md skill.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Correctness Issues

In `pkg/connector/unity_catalog.go`:
- Around line 138-148 (`ucResolver.resolve`): the scope loop discards which scope
  matched. When the account API is available, identities are synced only from
  account SCIM (see account.go lines 52-59), so a principal matched by the
  workspace-scope fallback returns a workspace-SCIM ID that corresponds to no
  synced resource. `securableGrant` (line 324) compounds this by choosing the
  group parent from `IsAccountAPIAvailable()` alone, yielding
  `account/<accountId>/group/<workspaceGroupId>`. Fix by either (a) returning the
  matched scope from `resolve` and threading it into `groupGrantParent` so a
  workspace-scope match gets a workspace parent, or (b) dropping the workspace
  fallback when `IsAccountAPIAvailable()` is true, since those identities are not
  in the resource graph.
- Around line 199-213 (`resolvePrincipalName`) — still unfixed: for groups this
  passes `principal.Resource`, which is the composite connector ID
  (`account/<acct>/group/<id>` or `workspace/<ws>/group/<id>`) built by
  `groupResourceId`. `FindGroupDisplayName` filters SCIM on `id eq '<value>'`, so
  it matches nothing and returns "" with a nil error, making every group
  Grant/Revoke on a catalog/schema/table fail with "could not resolve principal".
  Decompose with `parseResourceId(principal.Resource)` first and pass the bare
  group ID, mirroring `groups.go:292`.

## Suggestions

In `pkg/connector/unity_catalog.go`:
- Around line 268-277: the `known` set makes the per-level privilege lists an
  authoritative filter, so any privilege Databricks reports that is not listed
  (legacy `USAGE`, `CREATE`, `CREATE_VIEW` on migrated metastores, or future
  enum additions) is dropped from the grant set and never surfaces in an access
  review. Consider emitting an entitlement dynamically for unknown privileges, or
  add a table-driven test pinning each level's list to the documented enum so
  drift fails CI. Also apply logarithmic sampling (1, 10, 100, every 1000) with a
  `total_occurrences` field to this Warn: it can fire once per privilege per
  table.
- Around line 89-95: the `owner` entitlement is declared
  `ent.WithGrantableTo(userResourceType, groupResourceType, servicePrincipalResourceType)`
  but `securableGrantChange` (line 348) unconditionally rejects it. Remove the
  `WithGrantableTo` option so it is surfaced as read-only.
- Around line 228 and 261: these per-securable Warns still fire once per
  securable; aggregate per catalog or use logarithmic sampling.

In `pkg/connector/unity_catalog_test.go`:
- Add coverage for the behavior introduced in this commit: `ucResolver` scope
  ordering (account before workspace, workspace-only when the account API is
  unavailable), cache hits including negative results, the privilege allowlist
  filter in `securableGrants`, and the rate-limit annotation pass-through from
  `List`/`Grants`.

In `pkg/databricks/unity_catalog.go`:
- Around line 79-81: add `NextPageToken string `json:"next_page_token"`` to
  `permissionsResponse` and either paginate `ListPermissions` or document why a
  single page is sufficient.
- Around line 158-180 (`ListTables`): add `omit_columns=true` and
  `omit_properties=true` query params; the connector uses neither, and the
  default response carries full column and property metadata per table.

In `README.md`:
- Around line 79: add Catalogs, Schemas and Tables to the "Data Model" list, and
  document (in both README.md and docs/connector.mdx) the Unity Catalog metastore
  privileges the OAuth service principal needs to enumerate securables and read
  grants, since the capability rows now advertise UC sync and provisioning.

In `spec/openapi.json`:
- The file does not exist while this PR adds five Unity Catalog endpoints. Run the
  `build-openapi-spec.md` skill so the new endpoints are covered by a checked-in
  spec.

@github-actions github-actions Bot 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.

Blocking issues found — see review comments.

… limits

- Gate Unity Catalog behind sync-unity-catalog (and table sync behind
  sync-unity-catalog-tables) so existing installs are unaffected until
  opted in; regenerate config schema and document the flags.
- Resolve UC principals against account SCIM first (with workspace
  fallback) so grants match account-level identities, not just
  workspace-scoped ones.
- Cache principal resolution per workspace to avoid a SCIM lookup storm
  across every catalog/schema/table.
- Skip (with warning) privileges outside a securable's modeled set so no
  grant references an entitlement Entitlements() never produced; add
  EXTERNAL_USE_SCHEMA.
- Return rate-limit annotations from UC List and Grants.
Comment on lines +138 to +148
var result *v2.ResourceId
for _, scope := range r.scimScopes(workspaceId) {
id, err := resolvePrincipalResourceID(ctx, r.client, scope, principal)
if err != nil {
return nil, err
}
if id != nil {
result = id
break
}
}

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.

🟠 Bug: the new workspace-scope fallback yields principal IDs that match no synced resource (high confidence). When the account API is available, identities are synced only from account SCIM (account.go:52-59 adds user/group/service_principal as account children; workspaceResource does not), yet scimScopes falls back to workspace SCIM. resolve discards which scope matched, so a workspace-local identity returns its workspace SCIM ID — and securableGrant then picks the group parent purely from IsAccountAPIAvailable(), producing account/<acct>/group/<workspaceGroupId>. Those grants point at principals that were never synced.

Either return the matched scope from resolve and pass it to groupGrantParent (workspace parent for a workspace-scope match), or skip the workspace fallback entirely when the account API is available, since those identities aren't in the graph anyway.

Comment on lines +268 to +277
for _, priv := range a.Privileges {
if _, ok := known[priv]; !ok {
l.Warn("databricks-connector: skipping unmodeled unity catalog privilege",
zap.String("privilege", priv),
zap.String("securable_type", securableType),
zap.String("securable", fullName),
)
continue
}
g, err := securableGrant(ctx, r.client, resource, workspaceId, priv, principalID)

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.

🟡 Suggestion: the privilege lists are now an authoritative allowlist, which turns a previously-visible problem (grant referencing an entitlement Entitlements() never emitted) into silent under-reporting. Any UC privilege outside the list — legacy USAGE/CREATE/CREATE_VIEW on migrated metastores, or any value Databricks adds to the privilege enum later — disappears from the access review with only a log line. Consider emitting the entitlement dynamically for unknown privileges, or at minimum adding a table-driven test that pins each level's list against the documented enum so drift fails CI.

Separately, this Warn fires per privilege per securable, so a metastore with thousands of tables can emit tens of thousands of identical lines. Logarithmic sampling (1, 10, 100, every 1000) with a total_occurrences field would keep it readable.

@github-actions github-actions Bot 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.

Blocking issues found — see review comments.

@mateoHernandez123

mateoHernandez123 commented Oct 9, 2026 •

Copy link
Copy Markdown

Closing this in favour of a stack that grew out of it, and I want to be clear up front that your research is what got the ticket moving. The resource shape, reading direct privilege assignments rather than effective ones so reviews do not over-report, surfacing securable owners as their own read-only entitlement, and resolving service principals by application ID all carried straight through into what shipped.

Two things we found running it against a live account changed the shape enough that it became a different change rather than a revision of this one:

A catalog's parent is the metastore, not the workspace. The same catalog is reachable from every workspace the metastore is assigned to, so a {deployment}::{full_name} id produced one C1 resource per workspace that happened to see it. Ids are now {metastore_id}::{full_name}, and the metastore itself became a synced resource type.

The workspace is an access path that has to be resolved, not a given. Metastores live on the account plane, but securables only answer on a workspace host the metastore is assigned to, so every call now works out which running workspace can actually reach a given catalog. That matters more than it sounds: an ISOLATED catalog is only listable from the workspaces bound to it, so a metastore's catalog list has to be unioned across all of them — and uhttp keys its response cache on path and query with no host component, so until the cache key was scoped per host, that union collapsed onto whichever workspace was asked first and a catalog nobody listed read as a deletion.

Scope ended up at five securable levels — metastore, catalog, schema, table and volume — with Grant/Revoke on each, so it is going in as four stacked PRs instead of one:

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.

2 participants