Skip to content

CXH-2379: fix grant/revoke idempotency for DDL-based engines - #151

Open
al-conductorone wants to merge 4 commits into
mainfrom
cxh-2379-baton-sql-fix-grant-and-revoke-idempotency-for-ddl-based
Open

CXH-2379: fix grant/revoke idempotency for DDL-based engines#151
al-conductorone wants to merge 4 commits into
mainfrom
cxh-2379-baton-sql-fix-grant-and-revoke-idempotency-for-ddl-based

Conversation

@al-conductorone

Copy link
Copy Markdown
Contributor

Repeat grant or revoke requests against DDL-based databases (such as Db2) no longer fail; the connector now recognizes when access is already in the requested state and reports the operation as a successful no-op.

Validation-query "no rows" now wraps ErrQueryAffectedZeroRows so the
provisioning layer's errors.Is check reports GrantAlreadyExists /
GrantAlreadyRevoked instead of failing the task. DDL dialects (e.g. Db2)
whose GRANT/REVOKE raise an error rather than affecting rows can only
signal prior state through validation_queries, which previously landed on
the failing path.

Adds regression tests driving Grant/Revoke end-to-end over in-memory
sqlite for both the already-applied (idempotent) and apply cases.
@linear-code

linear-code Bot commented Sep 2, 2026

Copy link
Copy Markdown

CXH-2379

Comment thread pkg/bsql/query.go Outdated
return fmt.Errorf("validation query returned no rows")
// Wrap the sentinel so the idempotency path reports already-applied instead of
// failing; validation "no rows" is the only zero-effect signal DDL dialects (Db2) emit.
return fmt.Errorf("validation query returned no rows: %w", ErrQueryAffectedZeroRows)

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: This reinterprets every validation_queries "no rows" as "already applied", for all engines and all configs — not just DDL dialects. This repo's own shipped example (examples/postgres-test.yml:384-391) uses the grant validation query as an existence precondition (FROM users u, roles r WHERE u.username = ? AND r.role_name = ?), so after this change a grant for a nonexistent user or role returns nil error + GrantAlreadyExists and ConductorOne records access that was never applied. Suggest gating the new semantics behind an opt-in field on EntitlementProvisioningQueries (e.g. validation_queries_signal_idempotency: true) so existing precondition-style configs keep failing loudly.

Comment thread pkg/bsql/query.go Outdated
return anno, fmt.Errorf("grant provisioning: validation query returned no rows")
// Wrap the sentinel so the caller reports GrantAlreadyExists instead of failing;
// validation "no rows" is the only zero-effect signal DDL dialects (Db2) emit.
return anno, fmt.Errorf("grant provisioning: validation query returned no rows: %w", ErrQueryAffectedZeroRows)

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: Same concern as the sibling change in RunProvisioningQueriesWithExecutorGrant (pkg/bsql/provisioning.go:89) now converts this into nil error + GrantAlreadyExists, so a genuine precondition failure (missing user/role, wrong tenant) is reported to ConductorOne as a successful grant instead of an error. Gate this behind opt-in config, or scope it to DDL dialects via s.dbEngine, rather than changing behavior for all existing configs.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: CXH-2379: fix grant/revoke idempotency for DDL-based engines

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 2963cce98f5b.
Review mode: incremental since 399c8557
View review run

Review Summary

The new commit adds TestRevoke_ValidationNoRowsOnNonDDLEngineFailsLoudly, which pins the false branch of validationNoRowsMeansIdempotent() on the revoke path — default SQLite engine, unseeded DB, asserting Revoke returns an error with nil annotations. That addresses the prior suggestion on provisioning_validation_idempotency_test.go:109, and both earlier blocking findings remain resolved by the s.dbEngine == database.DB2 gate. The full PR diff was re-scanned for security and correctness; no dependency manifests changed and the incremental artifact was complete (no dropped paths, not truncated). One non-blocking annotation-loss edge case remains on the DB2 grant path.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/bsql/query.go:1088 — on the DB2 idempotent path the populated anno (possibly holding GrantReplaced) is discarded by Grant in pkg/bsql/provisioning.go:89-94; with no_transaction: true the replace revoke has already committed, so the replacement is never reported.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/bsql/provisioning.go`:
- Around line 88-96: In `Grant`, the `errors.Is(err, ErrQueryAffectedZeroRows)` branch throws away the
  annotations returned by `RunGrantProvisioning` and builds a fresh empty annotation set holding only
  `GrantAlreadyExists`. On the new DB2 validation-query path (`pkg/bsql/query.go:1087-1088`),
  `RunGrantProvisioning` can return that sentinel after the `grant_replace` branch
  (`pkg/bsql/query.go:1042-1057`) has already run the replaced grant's revoke and set `GrantReplaced`.
  When the entitlement is configured with `no_transaction: true` that revoke is not rolled back, so the
  old grant is gone from the database but `GrantReplaced` never reaches ConductorOne. Fix by capturing
  the annotations returned by `RunGrantProvisioning` and merging `GrantAlreadyExists` into them
  (update and return that anno) instead of discarding them.

Note: this run could not write the machine-readable review-state marker (the sandbox blocked the required literal), so the next review will fall back to full mode against base 2963cce98f5b53d2eee77a3323ad55c3f46af843. Reviewed head: 97654550cca2274d87589e4df3bab4404cbc4392.

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

A validation query returning no rows now maps to ErrQueryAffectedZeroRows
(reported as GrantAlreadyExists / GrantAlreadyRevoked) only on DDL-based
engines (Db2), which don't report rows-affected. Other engines keep using
validation queries as existence preconditions that fail loudly, so a grant
against a missing user or role is no longer silently reported as success.
This also restores the grant_replace abort behavior on those engines: a
replaced-grant revoke whose validation returns no rows returns a plain
error instead of the sentinel, so GrantReplaced is not emitted.

Document the engine-specific ValidationQueries semantics and add a test
covering the non-DDL loud-failure path.
}

func TestRevoke_ValidationNoRowsReportsAlreadyRevoked(t *testing.T) {
s, _ := newRevokeProvisioningTestSyncer(t)

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 engine gate is covered asymmetrically — TestGrant_ValidationNoRowsOnNonDDLEngineFailsLoudly pins the non-DDL behavior for RunGrantProvisioning, but there's no equivalent for the revoke path through RunProvisioningQueriesWithExecutor. Adding a mirror test that leaves s.dbEngine at the default and asserts Revoke returns an error (and no GrantAlreadyRevoked) when the revoke validation query matches nothing would lock in both halves of validationNoRowsMeansIdempotent(). (medium confidence)

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

No blocking issues found.

Mirror TestGrant_ValidationNoRowsOnNonDDLEngineFailsLoudly on the revoke
path: on a non-DB2 engine, a revoke validation query returning no rows is
a failed precondition, so Revoke returns an error with nil annotations
rather than GrantAlreadyRevoked. Pins the false branch of
validationNoRowsMeansIdempotent() for RunProvisioningQueriesWithExecutor.
Comment thread pkg/bsql/query.go

if !valid {
if s.validationNoRowsMeansIdempotent() {
return anno, fmt.Errorf("grant provisioning: validation query returned no rows: %w", ErrQueryAffectedZeroRows)

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: on the DB2 path this returns the sentinel with anno already populated, but Grant (pkg/bsql/provisioning.go:89-94) discards the returned annotations and builds a fresh annotations.Annotations{} with only GrantAlreadyExists. With no_transaction: true there is no rollback, so a grant_replace revoke that already committed above (line 1042) is lost: the DB revoked the old grant but GrantReplaced never reaches ConductorOne. Consider preserving the returned annotations in the errors.Is(err, ErrQueryAffectedZeroRows) branch of Grant (medium confidence — requires the DB2 + grant_replace + validation_queries + no_transaction combination).

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

No blocking issues found.

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