Skip to content

CXH-2417: extend DDL grant/revoke idempotency to Oracle behind a per-config opt-in - #152

Merged
al-conductorone merged 5 commits into
mainfrom
cxh-2417-baton-sql-extend-ddl-grantrevoke-idempotency-to-oracle
Sep 11, 2026
Merged

al-conductorone merged 5 commits into
mainfrom
cxh-2417-baton-sql-extend-ddl-grantrevoke-idempotency-to-oracle

Conversation

@al-conductorone

Copy link
Copy Markdown
Contributor

Lets Oracle and Db2 connectors treat an already-applied grant or revoke as success instead of erroring (Oracle otherwise fails a repeat revoke with ORA-01951), when the config opts in. Off by default, so existing connectors are unchanged. Stacks on #151.

@linear-code

linear-code Bot commented Sep 9, 2026

Copy link
Copy Markdown

CXH-2417

database.Oracle: true,
database.SQLite: false,
database.MySQL: false,
database.PostgreSQL: false,

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] nice, the gate itself is solid — one gap though

This nails down the boolean gate (opt-in off never signals idempotency, opt-in on only for Db2/Oracle), but there's no actual end-to-end Grant()/Revoke() test with dbEngine = database.Oracle + opt-in on asserting the GrantAlreadyExists/GrantAlreadyRevoked annotations actually come out — Db2 gets that full-path test, Oracle only gets exercised at this unit level. Probably low risk since the code path past the engine switch is identical, but would fully close the loop this PR is meant to validate.

Also noticed (unrelated file, examples/oracle-test.yml): the "admin" entitlements (WITH ADMIN OPTION) don't opt in and have no validation_queries, so a repeat revoke there still hits ORA-01951. Probably intentional but a one-line comment in the example would stop someone from thinking it got missed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

the ..._Oracle full-path grant/revoke tests are already on the branch, so oracle gets end-to-end coverage too. admin entitlements opt in and use |keyword now.

Comment thread examples/oracle-test.yml Outdated
Comment thread examples/oracle-test.yml
Comment thread pkg/bsql/config.go
…potency in example

Closes the review gap that Oracle was only exercised at the engine-gate unit level: add
end-to-end Grant()/Revoke() tests under dbEngine=Oracle + opt-in that assert
GrantAlreadyExists/GrantAlreadyRevoked. The SQLite harness can't bind Oracle's ":N"
placeholders, so the validation query is bind-free; the main INSERT/DELETE is skipped on
the idempotent no-rows path anyway.

Also document in examples/oracle-test.yml that the admin (WITH ADMIN OPTION) entitlements
omit validation_queries idempotency on purpose.
@al-conductorone
al-conductorone changed the base branch from cxh-2379-baton-sql-fix-grant-and-revoke-idempotency-for-ddl-based to main September 10, 2026 19:02
@al-conductorone
al-conductorone force-pushed the cxh-2417-baton-sql-extend-ddl-grantrevoke-idempotency-to-oracle branch from 0b23827 to 0b6f229 Compare September 10, 2026 19:03
Comment thread pkg/bsql/query.go Outdated
// swallowing it anywhere it could mean a missing or mistyped principal is a silent-access bug.
func (s *SQLSyncer) validationNoRowsMeansIdempotent(signalIdempotency bool) bool {
if !signalIdempotency {
return false

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 is a silent behavior change for Db2, not just an Oracle addition. Before this PR validationNoRowsMeansIdempotent() returned true for Db2 based on engine alone, so any Db2 config with validation_queries got no-rows-means-idempotent for free; now it hard-errors unless the config adds validation_queries_signal_idempotency: true. The PR description says "Off by default, so existing connectors are unchanged," which isn't true for Db2 configs written against #151 — worth an explicit migration note in docs/db2.md ("existing configs relying on the previous default must add the flag") and a correction in the PR body.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed. db2 stays default-on by engine (unchanged from #151), only oracle needs the opt-in now.

Comment thread pkg/bsql/query.go

if !valid {
if s.validationNoRowsMeansIdempotent() {
if s.validationNoRowsMeansIdempotent(signalIdempotency) {

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: When an operator sets validation_queries_signal_idempotency: true on a non-DDL engine (Postgres, MySQL, MSSQL…), the flag is silently ignored and the first symptom is a hard grant/revoke failure that looks unrelated to the config. Consider a one-time l.Warn here (or in config load) when signalIdempotency is set but the engine isn't in the DDL set, so the ignored opt-in is visible rather than inferred from docs.

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.

Nit, not blocking. Please drop this Warn — C1 surfaces Warn as an incident. Either reject the invalid opt-in at config-validate time, or log it at Debug. Pattern: https://github.com/ConductorOne/baton-google-cloud-platform/pull/75

Comment thread pkg/bsql/query.go Outdated
ctx context.Context,
queries,
validationQueries []string,
signalIdempotency 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.

🟡 Suggestion: RunProvisioningQueries, RunRevokeProvisioning, RunProvisioningQueriesWithExecutor, and RunGrantProvisioning are all exported and each gains a positional bool. All in-repo callers are updated, but any out-of-repo consumer of pkg/bsql breaks at compile time. Since these signatures are already long and now carry two independent bools (signalIdempotency, useTx), an options struct would make this and future additions non-breaking and remove the positional-bool readability cost.

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.

Nit, not blocking. The extra positional bool on the exported Run*Provisioning* helpers is a compile break for any out-of-repo pkg/bsql caller. An options struct would keep this additive; not required for this PR if there are no known external callers.

revoke.ValidationQueries = []string{
`SELECT 1 FROM user_roles WHERE user_id = ?<user_id> AND role = 'does-not-exist'`,
}
revoke.ValidationQueriesSignalIdempotency = true

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 grant_replace path only has coverage with the opt-in on. The opt-in-off case on a DDL engine changed behavior in this PR — the replace revoke's no-rows validation now returns a plain error that does not wrap ErrQueryAffectedZeroRows, so RunGrantProvisioning fails the whole grant instead of reporting GrantReplaced. Worth a companion test asserting that, since it's the new default for existing Db2 grant_replace configs.

@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: CXH-2417: extend DDL grant/revoke idempotency to Oracle behind a per-config opt-in

Blocking Issues: 0 | Suggestions: 3 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 297f56e6001e.
Review mode: incremental since 187057bf
View review run

Review Summary

The new commit is docs-only: docs/provisioning.md now states the no_transaction: true requirement as applying wherever the reinterpretation is active (Db2 always, Oracle when the flag is on) instead of Db2-only, which addresses the prose half of the prior finding — but the YAML example at docs/provisioning.md:24-35 still omits no_transaction: true, so copying it verbatim still fails validateProvisioningIdempotency on Oracle with the opt-in on. I re-scanned the full PR diff for security and correctness: the |keyword renderer fails closed (letters, digits and single spaces only, so SESSION; DROP TABLE x is rejected, and parseErr is propagated by both call sites), the grant_replace path nil-checks provisioningConfig.Revoke before reading the new opt-in, and the previously noted l.Warn on the ignored-flag path is now a config-validate rejection plus Debug. No new blocking issues; the three suggestions below are carried forward from the previous review and are still open.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • docs/provisioning.md:24-35 (carried over, partially addressed) — the prose at lines 40-42 is now correct, but the YAML example still omits no_transaction: true while setting validation_queries_signal_idempotency: true, so the copied snippet fails startup validation on Oracle.
  • docs/oracle.md:43,53,66 / examples/oracle-test.yml:333,341,354-362,437,455 (carried over) — |unquoted runs through SanitizeIdentifier (pkg/bsql/query.go:54-58), which drops $ and #; an Oracle principal like C##ADMIN is validated as C##ADMIN but granted as CADMIN.
  • pkg/bsql/query.go:442-447 (carried over) — ProvisioningOptions is a source-breaking signature change to four exported methods (RunProvisioningQueries, RunRevokeProvisioning, RunGrantProvisioning, RunProvisioningQueriesWithExecutor); worth a release note for out-of-repo importers of pkg/bsql. Separately, the new no_transaction rule in validateProvisioningIdempotency makes existing Db2 configs that use validation_queries without no_transaction fail at startup.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `docs/provisioning.md`:
- Around lines 24-35: The YAML example sets `validation_queries_signal_idempotency: true` on
  both the grant and the revoke but omits `no_transaction: true`. `validateProvisioningIdempotency`
  in `pkg/bsql/validate.go:112-116` rejects that combination at startup on Oracle, so an operator
  copying this snippet gets a config-validation error. Add `no_transaction: true` to both the
  `grant:` and `revoke:` blocks in the example so it matches the requirement now documented in
  the prose at lines 40-42.

In `docs/oracle.md`:
- Around lines 43, 53 and 66: the principal operand uses `?<principal_name|unquoted>`, and
  `|unquoted` goes through `SanitizeIdentifier` (`pkg/bsql/query.go:54-58`), which deletes every
  character outside `[A-Za-z0-9_]`, including `$` and `#` — both legal in Oracle usernames. A
  principal such as `C##ADMIN` is compared as `C##ADMIN` in the validation query but rendered as
  `CADMIN` in the DDL, so the grant/revoke targets the wrong (or a nonexistent) user. Either
  document that `|unquoted` principals must not contain `$` or `#`, or switch the DDL operand to
  a rendering that preserves those characters (e.g. `|identifier` with a case-exact validation
  comparison, or extend the unquoted sanitizer to allow `$` and `#`). Apply the same change to
  `examples/oracle-test.yml` (lines ~333, 341, 354-362, 437, 455).

In `pkg/bsql/query.go`:
- Around lines 442-447: `ProvisioningOptions` replaces the trailing `useTx bool` on the exported
  `RunProvisioningQueries`, `RunRevokeProvisioning`, `RunGrantProvisioning`, and
  `RunProvisioningQueriesWithExecutor`. That is a compile break for any out-of-repo importer of
  `pkg/bsql`. Call it out in the PR description / release notes (no code change required if the
  break is intended).

In `pkg/bsql/validate.go`:
- Around lines 112-116: the new rule makes any existing Db2 config that uses `validation_queries`
  without `no_transaction: true` fail at startup, since Db2 is default-on for
  `validationNoRowsMeansIdempotent`. Confirm that is intended and call it out as a breaking
  config change for Db2 deployments, or scope the requirement so it only fires for grant/revoke
  blocks whose queries are actually DDL.

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

…te, |keyword renderer, opt-in validation)

- validationNoRowsMeansIdempotent: keep Db2 on by engine (unchanged from #151,
  build-tag gated), require the validation_queries_signal_idempotency opt-in only
  for Oracle, which ships in every binary. Retarget the opt-in-off tests to Oracle
  and add Db2 default-on tests; make the engine-gate matrix explicit.
- Add a |keyword token renderer for multiword system privileges (e.g. CREATE
  SESSION): charset-allowlisted (letters/digits/single spaces), whitespace
  collapsed, injection values rejected. |unquoted stripped the space, |identifier
  would wrongly quote the clause.
- validate.go: run validation_queries through validateVarsInQuery, and when the
  opt-in is set require at least one validation query plus no_transaction on DDL
  engines. Warn when the opt-in is set on a non-DDL engine.
- examples/oracle-test.yml + docs/oracle.md: quote role/principal operands with
  |identifier and drop UPPER() so quoted case-sensitive identifiers match; render
  system privileges with |keyword.
- Add an Oracle grant_replace opt-in-off regression test.
Comment thread docs/db2.md Outdated
Comment on lines 226 to 232
Db2 is DDL-based: its `GRANT`/`REVOKE` don't report rows-affected. When you set
`validation_queries_signal_idempotency: true` on a grant or revoke, a `validation_query`
returning no rows is treated as an idempotent success rather than a failed precondition. The
flag is off by default, so you must opt in per entitlement; when it is on, you must not use
`validation_queries` as existence preconditions. See
[Provisioning: `validation_queries` semantics](provisioning.md) for the full explanation and
examples.

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 contradicts the code. validationNoRowsMeansIdempotent (pkg/bsql/query.go:676) returns true for database.DB2 unconditionally — the flag is not required on Db2, as TestGrant_Db2ValidationNoRowsIdempotentByDefault asserts. A Db2 config author following this paragraph will write validation_queries as a loud existence precondition without the flag, and instead get the no-rows result swallowed as GrantAlreadyExists/GrantAlreadyRevoked — the exact silent-access bug this section warns about. Same mismatch in docs/provisioning.md:8-9,38-40, README.md:19, pkg/bsql/config.go:430-434, and the grant_replace comment at pkg/bsql/query.go:1173.

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.

Nit, not blocking. Please align docs/db2.md (and the matching README / config.go comments) with the actual gate: Db2 still treats no-rows as idempotent by engine, without requiring the opt-in. Operators following this paragraph today will write existence preconditions that get swallowed.

Comment thread pkg/bsql/validate.go Outdated
if len(pq.ValidationQueries) < 1 {
return errors.New("validation_queries_signal_idempotency requires at least one validation_query")
}
if isDDLEngine(s.dbEngine) && !pq.NoTransaction {

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 no_transaction requirement is nested under if pq.ValidationQueriesSignalIdempotency, so it never fires for the Db2 path that gets no-rows-means-idempotent by engine with the flag unset. A Db2 config with validation_queries and no no_transaction still takes the idempotent reinterpretation inside a transaction — the case this check exists to prevent. Consider gating on s.validationNoRowsMeansIdempotent(pq.ValidationQueriesSignalIdempotency) instead of the raw flag.

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.

Nit, not blocking. Please gate the no_transaction check on the path that actually reinterprets no-rows (Db2 by engine, Oracle via the flag), not only when validation_queries_signal_idempotency is set. Otherwise a Db2 config with validation_queries inside a transaction still hits the case this check exists to prevent.

Comment thread docs/oracle.md Outdated
Comment on lines +34 to +41
# exact-match (no UPPER): the GRANT quotes identifiers, so the stored GRANTEE/GRANTED_ROLE
# are case-sensitive and must equal the bound value
validation_queries:
- |
SELECT 1 FROM dual WHERE NOT EXISTS (
SELECT 1 FROM DBA_ROLE_PRIVS
WHERE GRANTEE = ?<principal_name> AND GRANTED_ROLE = ?<role_name>
)

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: Quoting the principal with |identifier and dropping UPPER() makes the whole flow case-exact, which is only safe when principal.ID is byte-identical to Oracle's stored identifier. Oracle folds unquoted CREATE USER jdoe to JDOE, so a connector whose principal ID comes from an application table (as in examples/oracle-test.yml, where the user resource uses id: ".username") can end up with GRANTEE = 'jdoe' matching nothing: the revoke validation returns no rows and, with the opt-in on, that reports GrantAlreadyRevoked while the role is still granted. The privilege entitlement in the same example kept UPPER(...) + |unquoted for the principal; keeping the principal UPPER-normalized here too (roles from DBA_ROLES are already uppercase) would avoid the divergence.

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.

Nit, not blocking. Please keep the principal comparison UPPER-normalized (or otherwise match how Oracle stores unquoted CREATE USER names). With |identifier + exact match, a lowercase principal.ID from the app table can report GrantAlreadyRevoked while the role is still granted. The privilege entitlement in the same example already uses UPPER(...) + |unquoted for that reason.

@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

Copy link
Copy Markdown
Contributor

Approving. Please still look at the bot threads I replied to in this review — they are nits, not blockers, but they should be cleaned up (the new Warn, the Db2 docs vs code mismatch, no_transaction on the Db2 path, Oracle principal case, and the extra positional bool on the exported helpers).

- reject validation_queries_signal_idempotency on non-DDL engines at config
  validation instead of logging an ignored-flag Warn
- require no_transaction whenever a no-rows validation is reinterpreted as
  idempotent, so Db2's default-on path is covered, not just the raw flag
- UPPER-normalize the Oracle role-membership principal (|unquoted + UPPER())
  so default upper-folded users match
- correct docs: on Db2 no-rows idempotency is on by default; the
  validation_queries_signal_idempotency flag is Oracle-only
- replace the positional bools on the exported Run* provisioning functions
  with a ProvisioningOptions struct
Comment thread docs/provisioning.md Outdated
On these engines 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". On Db2 (default-on) this reinterpretation also
requires `no_transaction: true`, since Db2 reports no rows-affected under a transaction.

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: no_transaction: true is now required on Oracle too, not just Db2 — validateProvisioningIdempotency (pkg/bsql/validate.go:112-116) errors whenever validationNoRowsMeansIdempotent is true and no_transaction is unset, which includes Oracle with the opt-in on (see the signal on DDL engine without no_transaction fails Oracle case in validate_test.go). Related: the example block above (lines 23-36) omits no_transaction: true, so copying it verbatim produces a config that fails startup validation on both engines.

Comment thread docs/oracle.md
WHERE GRANTEE = UPPER(?<principal_name>) AND GRANTED_ROLE = ?<role_name>
)
queries:
- GRANT ?<role_name|identifier> TO ?<principal_name|unquoted>

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: |unquoted runs the value through SanitizeIdentifier (pkg/bsql/query.go:54-58), which drops every char outside [A-Za-z0-9_] — including $ and #, both legal in Oracle usernames (e.g. the CDB common-user prefix C##ADMIN). The validation query binds the raw UPPER(?<principal_name>), so for such a principal the check passes against C##ADMIN while the DDL emits GRANT ... TO CADMIN, which either fails with ORA-01917 or, if a CADMIN user exists, grants to the wrong principal. Consider keeping |identifier and upper-casing principal_name in vars instead, or documenting the special-character limitation. Same pattern in examples/oracle-test.yml (grant/revoke around lines 333, 341, 354-362 and the privilege blocks).

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

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

@al-conductorone
al-conductorone dismissed github-actions[bot]’s stale review September 11, 2026 18:09

Requested changes addressed in bf1d301; the bot's subsequent re-reviews (latest 2026-09-11 18:08) report no blocking issues and all checks pass.

@al-conductorone
al-conductorone merged commit 313b293 into main Sep 11, 2026
9 checks passed
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