Skip to content

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

Open
al-conductorone wants to merge 8 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 8 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: 2 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 2963cce98f5b.
Review mode: incremental since 5024f30a
View review run

Review Summary

The new commit is documentation and coverage only: a validation_queries semantics section in docs/db2.md, a three-line explanatory comment at the grant_replace zero-rows guard in query.go, and TestGrant_ReplaceDB2RevokeValidationNoRowsStillReportsGrantReplaced. Both prior findings are addressed: the DB2 grant_replace no-rows behavior is now explicitly intended, commented, and covered by a test that asserts GrantReplaced is emitted, the old viewer row survives, and the admin grant lands; and the config.go footgun warning now has a config-author-facing counterpart in docs/db2.md. The full PR diff was re-scanned for security and correctness: no injection, secrets, auth, or resource-safety issues; the runValidationQueries extraction preserves rows.Err()-before-Close() ordering on both call paths, and the engine gate plus the transactional / no_transaction annotation split in provisioning.go hold up. Two documentation-accuracy suggestions remain.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/bsql/query.go:1065-1067 - the new comment frames the zero-rows swallow as DDL/Db2-specific, but the guard is engine-agnostic: the sentinel also arrives from the revoke queries affecting zero rows on any engine. (confidence: high)
  • docs/db2.md:152-154 - the new semantics section covers grant and revoke but omits the grant_replace case, where a no-rows revoke validation on Db2 emits GrantReplaced for a grant that was never revoked. (confidence: medium)
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/bsql/query.go`:
- Around line 1065-1067: The comment on the errors.Is(err, ErrQueryAffectedZeroRows) guard in
  RunGrantProvisioning's grant_replace branch says "On DDL engines (Db2) a zero-rows revoke
  means nothing to revoke", implying the swallow is engine-gated. It is not: the sentinel
  reaches this guard both from runValidationQueries (DB2-only, via
  validationNoRowsMeansIdempotent) and from the revoke queries themselves all affecting zero
  rows (query.go:705-707), which applies to every engine. Reword the comment to say the
  sentinel covers both sources - a validation query reporting "nothing to revoke" on Db2, and
  revoke queries matching no rows on any engine - so a future reader does not assume
  validationNoRowsMeansIdempotent() guards this branch. Do not add an engine gate here; that
  would change pre-existing behavior on non-Db2 engines.

In `docs/db2.md`:
- Around line 152-154: The "Provisioning: validation_queries semantics on Db2" bullet list
  describes only the grant (GrantAlreadyExists) and revoke (GrantAlreadyRevoked) outcomes. Add a
  third bullet for the grant_replace path: on Db2, when a grant_replace revoke's own
  validation_queries return no rows, query.go:1064-1075 swallows the sentinel and still emits
  GrantReplaced with the replaced grant's ID, so ConductorOne removes that grant from its graph
  even though no REVOKE statement ran and the membership may still exist in the database. State
  that a grant_replace revoke's validation query must therefore answer "is the old membership
  present?" and must never be used as an existence check on the principal or role.

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

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.

Comment thread pkg/bsql/query.go
// don't report rows-affected, so the validation query is the only zero-effect signal
// available. Engines that report rows-affected keep using validation queries as
// existence preconditions that fail loudly.
func (s *SQLSyncer) validationNoRowsMeansIdempotent() bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Review] this might mask real failures, not just idempotency

So validationNoRowsMeansIdempotent() just checks the engine, it doesn't care what the validation query was actually checking. Problem is, examples/postgres-test.yml:384-391 already does validation_queries as a plain existence precondition (checks a user + role row exist) that has nothing to do with grant idempotency — idempotency there is handled separately via ON CONFLICT DO NOTHING in the actual insert.

If someone copies that exact pattern into a DB2 config, a typo'd or dropped role would make the validation query return 0 rows too, and now that silently becomes GrantAlreadyExists/GrantAlreadyRevoked instead of a hard error. Kinda the opposite failure mode from what this PR is fixing lol. Nothing else in the config (RejectIf is opposite polarity + grant-only, PrincipalExistsCheck runs after commit and is non-fatal) would catch it, and the new tests only cover the intended idempotency-gate case.

Might be worth a flag/second list to distinguish "precondition" queries from "idempotency gate" queries, or at least a loud warning in the ValidationQueries doc comment so DB2 authors don't reuse the postgres-test.yml pattern verbatim.

Comment thread pkg/bsql/query.go
// don't report rows-affected, so the validation query is the only zero-effect signal
// available. Engines that report rows-affected keep using validation queries as
// existence preconditions that fail loudly.
func (s *SQLSyncer) validationNoRowsMeansIdempotent() bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Review] why only DB2?

The bug report and this PR's own description frame this as a general "DDL-based engines" problem, not a DB2-only thing — and examples/oracle-test.yml has the exact same DDL-shaped GRANT ... TO / REVOKE pattern, so Oracle is probably exposed to the same bug. Hardcoding database.DB2 here means it's still broken there.

Could totally be intentional (only fix what's actually verified, per the stability-first vibe of this repo) — if so no action needed, just curious if that's the reasoning or if it's worth a quick follow-up ticket for Oracle/MSSQL/HDB/Vertica too.

Comment thread pkg/bsql/query.go
}

if !valid {
if s.validationNoRowsMeansIdempotent() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[Review] duplicated with the same block in RunGrantProvisioning

This loop and the one in RunGrantProvisioning (~line 1087) were already near-identical before this PR, and now they both got the same 4-line idempotency check copy-pasted in. They've actually already drifted from each other independently — the RunGrantProvisioning copy does _ = result.Close() on the result.Err() failure path, this one doesn't.

Not blocking, just a nit — might be worth pulling into a shared runValidationQueries() helper since this PR touched both spots identically anyway, so future changes (like extending the DB2 check to other engines) don't need to be applied twice.

- Extract shared runValidationQueries helper so the grant and revoke
  validation loops stop drifting (the copies had diverged on result.Close).
- Warn in the ValidationQueries doc comment that DDL-engine authors must not
  reuse validation_queries as an existence precondition, since a no-rows result
  is reported as idempotent success and would mask real failures.
- Preserve annotations returned by RunGrantProvisioning in the already-exists
  branch so a GrantReplaced from a committed grant_replace revoke survives.
Comment thread pkg/bsql/provisioning.go Outdated
Comment on lines 91 to 93
// Reuse the returned annotations so a GrantReplaced from an already-committed
// grant_replace revoke survives; a fresh set would drop it.
anno.Update(&v2.GrantAlreadyExists{})

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: reusing anno here is only safe when the grant ran with no_transaction: true. In the default transactional path, RunGrantProvisioning returns anno, ErrQueryAffectedZeroRows (query.go:1079 / 1112) before committed = true, so the deferred tx.Rollback() undoes the grant_replace revoke — yet the GrantReplaced annotation set at query.go:1070 now survives and is reported to C1 as if the old grant were removed. Gate the reuse on provisioningConfig.Grant.NoTransaction (or drop GrantReplaced in RunGrantProvisioning when the tx is rolled back).

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

On the transactional grant path, RunGrantProvisioning returns the
zero-rows sentinel before commit, so the deferred rollback undoes any
grant_replace revoke. Grant reused those annotations, reporting
GrantReplaced for a removal the database no longer reflects. Keep the
returned annotations only on the no_transaction path, where the replace
already committed; otherwise return a fresh GrantAlreadyExists.

Adds regression tests for both the rolled-back (no GrantReplaced, old
grant survives) and committed (GrantReplaced, old grant gone) paths.
Comment thread pkg/bsql/provisioning.go Outdated
anno := annotations.Annotations{}
// Reuse the returned annotations so a GrantReplaced from an already-committed
// grant_replace revoke survives; a fresh set would drop it.
anno.Update(&v2.GrantAlreadyExists{})

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.

Reusing anno here can report a replacement that never committed. In the default transactional path, RunGrantProvisioning can add GrantReplaced and then return ErrQueryAffectedZeroRows; the deferred rollback restores the revoked grant, but this branch preserves GrantReplaced. Please drop that annotation when the transaction rolls back, or only preserve it when the revoke was already committed.

Comment thread pkg/bsql/query.go
// don't report rows-affected, so the validation query is the only zero-effect signal
// available. Engines that report rows-affected keep using validation queries as
// existence preconditions that fail loudly.
func (s *SQLSyncer) validationNoRowsMeansIdempotent() bool {

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.

Why is this limited to Db2? The pinned go-ora v2.8.24 driver classifies Oracle GRANT/REVOKE statements as OTHERS, and RowsAffected() is populated from CurRowNumber, so Oracle can expose the same zero-row DDL behavior. Please either include Oracle with an executable regression test or document concrete runtime evidence that its validation semantics differ.

Comment thread pkg/bsql/query.go
if !valid {
return anno, fmt.Errorf("grant provisioning: validation query returned no rows")
}
if err := s.runValidationQueries(ctx, validationQueries, vars, executor); err != 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: runValidationQueries is also reached from the grant_replace path above via RunProvisioningQueriesWithExecutor (line 1057). On DB2 the revoke's own validation_queries returning no rows now yields the wrapped ErrQueryAffectedZeroRows sentinel, which lines 1064-1068 swallow — so anno.Update(&v2.GrantReplaced{...}) at line 1070 still fires even though no revoke query ran. Previously that validation failure aborted the grant. The reported end state (old grant absent) is arguably still true, but this is the same reporting class as the bug fixed in provisioning.go, and neither new test covers DB2 + grant_replace; worth an explicit decision plus a case in provisioning_grant_replace_test.go.

Comment thread pkg/bsql/config.go
Comment on lines +425 to +433
// ValidationQueries is a list of SQL statements run before the provisioning queries.
// On engines that report rows-affected, a query returning no rows fails the operation
// (an existence precondition). On DDL-based engines (Db2) that don't report rows-affected,
// a query returning no rows instead means the state is already as desired, so the operation
// is reported as an idempotent success (GrantAlreadyExists / GrantAlreadyRevoked).
//
// Warning: on DDL-based engines, do NOT use these as existence preconditions
// (e.g. "does this user/role exist?"). A no-rows result is reported as idempotent
// success, so a missing or mistyped principal is silently swallowed instead of erroring.

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 footgun warning only lives in a Go struct comment, but the people it targets are writing DB2 YAML configs. docs/db2.md has no provisioning/validation_queries section at all, so a config author has no way to learn that on Db2 a no-rows validation result is silently converted into an idempotent success. Consider adding the same warning to docs/db2.md.

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

On Db2 a grant_replace revoke whose validation query returns no rows swallows
ErrQueryAffectedZeroRows and still reports GrantReplaced: the old grant is
already gone, which is the state a replace aims for. Document this at the guard,
cover it with a DB2 test, and add a validation_queries section to docs/db2.md
warning against using them as existence preconditions on Db2.
Comment thread pkg/bsql/query.go
Comment on lines +1065 to +1067
// On DDL engines (Db2) a zero-rows revoke means "nothing to revoke": the
// old grant is already gone, which is the state a replace aims for. Treat
// that as success and still report GrantReplaced. Any other error aborts.

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 comment attributes an engine gate the guard doesn't have. ErrQueryAffectedZeroRows reaches here two ways: from runValidationQueries (DB2-only, via validationNoRowsMeansIdempotent) and from the revoke queries themselves affecting zero rows (query.go:705-707) — which is engine-agnostic. So on Postgres/MySQL/etc. a replace revoke whose DELETE matches nothing is also swallowed and still reports GrantReplaced. That swallow predates this PR, so the fix is wording, not gating: say the sentinel covers both sources rather than framing it as DDL/Db2-specific, so a future reader doesn't assume validationNoRowsMeansIdempotent() guards this branch.

Comment thread docs/db2.md
Comment on lines +152 to +154
- **Db2:** a `validation_query` returning no rows is reported as an **idempotent success**
(`GrantAlreadyExists` on grant, `GrantAlreadyRevoked` on revoke). No rows means "the state
is already as desired, there is no work to do".

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 section covers grant and revoke but omits the grant_replace case, which is the highest-consequence instance of the footgun it warns about. On Db2, when a grant_replace revoke's own validation_queries return no rows, query.go:1064-1075 swallows the sentinel and still emits GrantReplaced with the old grant's ID — so ConductorOne drops that grant from its graph even though no REVOKE ran and the membership may still exist in the database. Worth a third bullet stating that on Db2 a grant_replace revoke's validation query returning no rows is reported as GrantReplaced, so it must answer "is the old membership present?" and never be an existence check.

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

Oracle GRANT/REVOKE are DDL like Db2: an already-applied REVOKE raises
ORA-01951 (and re-GRANT succeeds without affecting rows), so the
validation query is the only zero-effect signal. Add Oracle to
validationNoRowsMeansIdempotent() so validation 'no rows' maps to
GrantAlreadyExists / GrantAlreadyRevoked instead of failing the task.

Verified live against Oracle XE 21c (grant/re-grant -> GrantAlreadyExists,
revoke/re-revoke -> GrantAlreadyRevoked, no ORA-01951, revoke DDL skipped).
Adds an engine-gate regression test and updates the ValidationQueries doc.
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.

4 participants