CXH-2379: fix grant/revoke idempotency for DDL-based engines - #151
CXH-2379: fix grant/revoke idempotency for DDL-based engines#151al-conductorone wants to merge 4 commits into
Conversation
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.
| 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) |
There was a problem hiding this comment.
🟠 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.
| 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) |
There was a problem hiding this comment.
🟠 Bug: Same concern as the sibling change in RunProvisioningQueriesWithExecutor — Grant (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.
Connector PR Review: CXH-2379: fix grant/revoke idempotency for DDL-based enginesBlocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0 Review SummaryThe new commit adds Security IssuesNone found. Correctness IssuesNone found. Suggestions
Prompt for AI agentsNote: 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 |
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) |
There was a problem hiding this comment.
🟡 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)
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.
|
|
||
| if !valid { | ||
| if s.validationNoRowsMeansIdempotent() { | ||
| return anno, fmt.Errorf("grant provisioning: validation query returned no rows: %w", ErrQueryAffectedZeroRows) |
There was a problem hiding this comment.
🟡 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).
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.