CXP-897 Incremental sync support - #56
JavierCarnelli-ConductorOne wants to merge 21 commits into
Conversation
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 <noreply@anthropic.com>
…-event-feeds # Conflicts: # README.md # pkg/connector/connector.go
Connector PR Review: CXP-897 Incremental sync supportBlocking Issues: 1 | Suggestions: 3 | Threads Resolved: 0 Review SummaryScanned the full PR diff (17 files: new audit-log event feed, SQL Statement Execution API client, Security IssuesNone found. The audit SQL statement interpolates only hardcoded constants from Correctness Issues
Suggestions
Prompt for AI agents |
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.
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.
- 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 <noreply@anthropic.com>
Requires another review
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
| } | ||
|
|
||
| // auditLogActions maps audit log action_name values to the resources they affect. | ||
| var auditLogActions = map[string]auditActionMapping{ |
There was a problem hiding this comment.
This is still keyed only by action_name, so the filter drops documented IAM events: addPrincipalsToGroup / removePrincipalsFromGroup, user lifecycle add/delete (not createUser/deleteUser — that last one is the PII purge), removeGroup, and setAccountAdmin / removeAccountAdmin. removeAdmin is workspace-admin revoke but the map attaches AccountAdminRole.
Key the map by (service_name, action_name) and select service_name in the query so generic names like add stay unambiguous. Source: https://docs.databricks.com/aws/en/admin/account-settings/audit-logs
There was a problem hiding this comment.
round 3 fixed the (service, action) keying + plural membership / removeGroup / add+delete — nice. one gap still open though: account-level admin events are setAccountAdmin / removeAccountAdmin (still under accounts), and those aren't in the map. setAdmin alone won't catch the real account-admin grant/revoke.
There was a problem hiding this comment.
This is still only partially fixed at ec6bc18. The (service_name, action_name) keying and plural group membership events are now correct, but the map still contains only setAdmin / removeAdmin; the documented account-level setAccountAdmin / removeAccountAdmin events are absent. Please add those account-admin mappings before resolving this thread. Source: https://docs.databricks.com/aws/en/admin/account-settings/audit-logs
| ctxzap.Extract(ctx).Warn("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 { | ||
| ctxzap.Extract(ctx).Warn("databricks-connector: corrupt event cursor, resetting to lookback default", zap.Error(err)) |
There was a problem hiding this comment.
These are skip-and-continue on a corrupt cursor (self-heal to lookback). Please log at Debug, not Warn — Warn on this path shows up as an incident in C1.
Same for the matching Warn on the unmarshal failure below.
There was a problem hiding this comment.
The base64/JSON corruption paths were moved to Debug, but the same skip-and-continue flow still adds Warn at audit_event_feed.go:158 (stale cursor) and :579 (malformed row). This PR also adds two more at pkg/databricks/sql.go:157 and :194. Please downgrade all four to Debug; connector code must not introduce Warn logs because C1 surfaces them as incidents. Team precedent: https://github.com/ConductorOne/baton-google-cloud-platform/pull/75
| auditEventFeedId = "databricks_audit_log" | ||
|
|
||
| // The first poll looks back this far since there's no prior watermark yet. | ||
| auditLogLookback = 1 * time.Hour |
There was a problem hiding this comment.
Bootstrap lookback (1h) is narrower than the steady-state trailing lag (auditLogTrailingLag, 4h below). Steady-state deliberately holds the watermark 4h behind the newest event to protect against slow-indexing/late-arriving rows, but a brand-new install only looks back 1h — so a fresh connector could miss events that are 1-4h old, which is exactly the class of event the trailing lag exists to protect against once running. Worth reconciling the two constants (or documenting why bootstrap intentionally gets a narrower window than ongoing sync).
There was a problem hiding this comment.
fair point — bootstrap at 1h vs steady-state 4h lag is a real gap for late-indexed events on a fresh install. either bump lookback to match the lag or call it out in the README.
| // 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 string, res statementResponse) (statementResponse, *v2.RateLimitDescription, error) { |
There was a problem hiding this comment.
pollStatement blocks synchronously in-band (up to statementPollMaxWait = 5 min) inside ExecuteStatement, which ListEvents/Validate call directly. None of the peer connectors surveyed have this shape since their APIs return results directly rather than requiring async statement submission + polling — this one ties up a sync worker for up to 5 minutes per statement on a cold warehouse. Probably fine at current scale, but worth keeping in mind if this connector ever needs to poll many workspaces/accounts concurrently — there's no async/job-queue model to prevent worker starvation under load. The hard cap + best-effort cancel on give-up is a good safeguard regardless.
There was a problem hiding this comment.
agreed as a scale note — 5m in-band poll is fine for one warehouse today; just something to watch if this ever fans out. the hard cap + /cancel on give-up is the important bit and that's in place.
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
…onfig 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 <noreply@anthropic.com>
Resolves conflicts between this branch's incremental-sync/audit-log work and main's account-hostname hardening, workspace-token sync-limit warnings, and config/docs cleanup (BaseURLField dropped from field groups, PAT setup docs, Users row in capabilities table): - pkg/connector/connector.go: kept main's hardened OAuth account-API validation and workspace-token-limits Warn in Validate(), combined with this branch's incremental-sync validation block that runs after them. - pkg/config/config.go: combined main's workspace-token HelpText and BaseURLField removal from both field groups with this branch's EnableIncrementalSyncField/SQLWarehouseIDField additions to the OAuth2 group. - config_schema.json: regenerated from the merged config so it reflects both sides. - README.md / docs/connector.mdx: kept both this branch's incremental sync section and main's docs fixes (Users capability row, group provisioning note, PAT/workspace-token clarifications) side by side. go build, go vet, and go test ./... all pass post-merge.
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 <noreply@anthropic.com>
needs a new review
| if hasMore { | ||
| startAt := latest | ||
| startAfterEventID := lastEventID | ||
| // Clamp intra-page advances to the trailing-lag boundary; otherwise the never-regress | ||
| // floor below would permanently defeat auditLogTrailingLag for this burst. | ||
| if laggedFloor := now.Add(-auditLogTrailingLag); startAt.After(laggedFloor) { | ||
| startAt = laggedFloor | ||
| startAfterEventID = "" | ||
| } | ||
| return eventPageCursor{StartAt: startAt, StartAfterEventID: startAfterEventID} |
There was a problem hiding this comment.
🟠 Bug: the hasMore branch has no never-regress guard, so this clamp can move StartAt backwards and stall paging. The first poll starts at now-1h (auditLogLookback); a full first page clamps StartAt to now-4h (auditLogTrailingLag) — 3h earlier than where it started. Worse, once clamped there, if ≥1000 matching events fall inside the trailing-4h window every subsequent page is also full, latest is again after now-4h, and the cursor is clamped straight back to now-4h with HasMore: true. The feed then re-runs the SQL statement and re-emits the same ~1000 events in a tight loop, only creeping forward at wall-clock rate. Consider clamping to max(cursor.StartAt, now-auditLogTrailingLag) (i.e. never below the incoming cursor), or dropping the clamp and applying the trailing lag only on the drained path.
There was a problem hiding this comment.
+1 still broken — clamp to now-4h with no floor at cursor.StartAt means a full first page from now-1h jumps backwards, and with ≥1000 rows in the lag window you just loop on the same page. TestAdvanceEventCursorFullPageClampsToLagWindow currently expects that regress. clamp to max(cursor.StartAt, now-lag) (keep StartAfterEventID when you land on the old StartAt).
| } | ||
| } | ||
|
|
||
| hasMore := len(rows) >= auditLogPageLimit |
There was a problem hiding this comment.
🟡 Suggestion: rows is the post-parse slice, and parseAuditLogRows drops malformed rows. A genuinely full page (1000 rows returned by LIMIT 1000) with even one unparseable row yields len(rows) == 999, so hasMore is false and the feed switches to the drained/trailing-lag path while more data is waiting. Track the raw row count from result.Rows for the hasMore decision instead.
There was a problem hiding this comment.
agreed — one skipped malformed row turns a real full page into len==999 and we prematurely drain. hasMore should use the raw statement row count, not the post-parse slice.
| // 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) { | ||
| if len(workspaces) == 1 { |
There was a problem hiding this comment.
🟡 Suggestion: the single-workspace short-circuit skips the WarehouseExists probe entirely, so a typo'd --sql-warehouse-id in a single-workspace account is never caught here. Validate() still fails, but with an opaque statement-execution error rather than the clear "not found in any of the N available workspaces" message this function produces for multi-workspace accounts. Probing even when there's one candidate costs one call at startup.
There was a problem hiding this comment.
yep — single-workspace short-circuit skips the nice not-found error. probing once is cheap (and we cache anyway), worth doing for the clearer Validate failure.
| if w.client.IsTokenAuth() { | ||
| if _, ok := w.workspaces[resourceId.Resource]; !ok { | ||
| return nil, nil, fmt.Errorf("databricks-connector: workspace %s is not configured", resourceId.Resource) | ||
| } | ||
|
|
||
| ws := &databricks.Workspace{DeploymentName: resourceId.Resource} | ||
| resource, err := minimalWorkspaceResource(ctx, ws, parentResourceId) | ||
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
|
|
There was a problem hiding this comment.
🟡 Suggestion: the token-auth branch checks membership in w.workspaces but not w.client.IsWorkspaceNameExcluded(...), which List applies at line 89. The OAuth branch below gets exclusions for free (they're applied inside ListWorkspaces), so only this path can hand back a workspace a full sync deliberately skips. Now that CAPABILITY_TARGETED_SYNC is declared for every resource type, Get can be called independently of the event feed.
There was a problem hiding this comment.
+1 — List filters excludes at L89, token Get doesn't. incremental is OAuth-only so the feed won't hit this, but targeted sync still can. mirror the exclude check.
|
This branch currently has merge conflicts in |
No description provided.