Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

- MySQL
- Microsoft SQL Server
- Oracle
- Oracle — see [docs/oracle.md](docs/oracle.md) for idempotent grant/revoke
- PostgreSQL
- SAP HANA
- Vertica
Expand All @@ -37,7 +37,7 @@ The connector is configured using a YAML file that defines:
- **Resource Types**: Map database tables/queries to resources (users, roles, etc.)
- **Account Provisioning**: Define schemas and credential options for user creation
- **Entitlements**: Permissions and roles that can be granted to resources
- **Provisioning Actions**: SQL queries for granting/revoking entitlements; see [docs/provisioning.md](docs/provisioning.md) for `validation_queries` semantics (including the DDL-engine no-rows-means-idempotent behavior, currently Db2 only)
- **Provisioning Actions**: SQL queries for granting/revoking entitlements; see [docs/provisioning.md](docs/provisioning.md) for `validation_queries` semantics (a no-rows validation is treated as idempotent success on the DDL engines Db2 and Oracle: on by default for Db2, opt-in via `validation_queries_signal_idempotency` for Oracle)

For Postgres behind a transaction-mode pooler (PgBouncer, Supabase pooler on port 6543, etc.), set `default_query_exec_mode` to `simple_protocol` via the DSN query string or `connect.params` to avoid prepared-statement conflicts (SQLSTATE 42P05). When unset, baton-sql leaves the URL unchanged and pgx uses its default (`cache_statement`).

Expand Down
11 changes: 7 additions & 4 deletions docs/db2.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,13 @@ the clidriver headers too. Default-tag lint and vet need nothing.

## Provisioning: `validation_queries` semantics

Db2 is DDL-based: its `GRANT`/`REVOKE` don't report rows-affected, so a `validation_query`
returning no rows is treated as an idempotent success, not a failed precondition. Db2 is the
only engine with this behavior today, and it ships opt-in behind the `db2` build tag. It means
you must not use `validation_queries` as existence preconditions on Db2. See
Db2 is DDL-based: its `GRANT`/`REVOKE` don't report rows-affected. On Db2 a `validation_query`
returning no rows is treated as an idempotent success rather than a failed precondition **by
default** (Db2 ships behind a build tag, so this is on by engine and does not need
`validation_queries_signal_idempotency`; that flag is only relevant on Oracle, which ships in
every binary). Because no rows means "already in the desired state", you must not use
`validation_queries` as existence preconditions, and every Db2 grant/revoke that has a
`validation_query` must set `no_transaction: true`. See
[Provisioning: `validation_queries` semantics](provisioning.md) for the full explanation and
examples.

Expand Down
72 changes: 72 additions & 0 deletions docs/oracle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Oracle

baton-sql talks to Oracle through the pure-Go `go-ora` driver, so no native client is needed
(unlike Db2). Connect with an `oracle://` DSN:

```yaml
connect:
dsn: "oracle://${DB_HOST}:${DB_PORT}/${DB_SERVICE}"
user: "${DB_USER}"
password: "${DB_PASSWORD}"
```

See [examples/oracle-test.yml](../examples/oracle-test.yml) for a full config (users, roles,
privileges, account provisioning, enable/disable/update actions).

## Idempotent grant / revoke: `validation_queries_signal_idempotency`

Oracle applies `GRANT`/`REVOKE` of roles and privileges as DDL that does not report
rows-affected, and re-running an already-applied statement raises an error. A repeat revoke of a
role the user no longer has fails with `ORA-01951: ROLE '...' not granted to '...'`. So a resync
that re-issues a revoke, or a grant of a role the user already holds, would surface as a failure
even though nothing needs to change.

To make grant and revoke idempotent, set `validation_queries_signal_idempotency: true` on the
grant or revoke and add a `validation_query` that answers **"is there work to do?"**. When the
query returns no rows, the connector reports an idempotent success (`GrantAlreadyExists` on grant,
`GrantAlreadyRevoked` on revoke) instead of running the DDL and hitting the error.

```yaml
grant:
no_transaction: true
validation_queries_signal_idempotency: true
# returns a row only while the role is NOT yet granted (no rows => already granted)
# UPPER-normalize the principal: the GRANT inserts it |unquoted, so Oracle folds it to
# upper-case (CREATE USER jdoe => JDOE); DBA_ROLES roles are already upper-case
validation_queries:
- |
SELECT 1 FROM dual WHERE NOT EXISTS (
SELECT 1 FROM DBA_ROLE_PRIVS
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).

revoke:
no_transaction: true
validation_queries_signal_idempotency: true
# returns a row only while the role IS still granted (no rows => already revoked)
validation_queries:
- |
SELECT 1 FROM DBA_ROLE_PRIVS
WHERE GRANTEE = UPPER(?<principal_name>) AND GRANTED_ROLE = ?<role_name>
queries:
- REVOKE ?<role_name|identifier> FROM ?<principal_name|unquoted>
```

Use `|identifier` for role names so they are engine-quoted; they come from `DBA_ROLES`
already upper-cased. Insert the principal `|unquoted` so Oracle folds it to upper-case, and
compare it with `UPPER(?<principal_name>)` in the validation query so a lower-case
`principal.ID` still matches the stored `GRANTEE`. System-privilege entitlements are different:
privilege names are multiword keywords like `CREATE SESSION`, so their DDL operand must use
`|keyword`, not `|identifier` (quoting would break the clause and `|unquoted` would strip the
space):

```yaml
queries:
- GRANT ?<privilege_name|keyword> TO ?<principal_name|unquoted>
```

The flag is off by default, so without it Oracle keeps failing loudly on the repeat operation.
The same warning as Db2 applies: with the flag on, do **not** use `validation_queries` as
existence preconditions, since a no-rows result is swallowed as an idempotent success. See
[provisioning.md](provisioning.md) for the full explanation.
73 changes: 48 additions & 25 deletions docs/provisioning.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,60 @@
# Provisioning: `validation_queries` semantics

`validation_queries` run before the provisioning `queries` in a grant or revoke. What a
**no-rows** result means depends on the engine.
**no-rows** result means depends on the engine: on the DDL engines (Db2, Oracle) it can mean an
idempotent success (on by default for Db2, opt-in for Oracle); on every other engine it fails the
operation.

## Default: no rows fails the operation

On every engine except Db2, a `validation_query` returning no rows **fails the operation**.
It is an existence precondition that aborts loudly. This includes the DDL engines that don't
report rows-affected (Oracle): treating their no-rows as idempotency is a follow-up that needs
a per-config opt-in first, so today they still fail loudly like everyone else.

## Db2 (opt-in behind the `db2` build tag)

Db2 applies `GRANT`/`REVOKE` as DDL that does not report rows-affected, so the connector
cannot tell from the statement itself whether it changed anything, and an already-applied
statement raises an error. To make grant and revoke idempotent, 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". Db2 ships opt-in behind the `db2` build tag, so no default-build engine changes
behavior.

Because of this, on Db2 your `validation_queries` must answer **"is there work to do?"**, not
**"does this principal or role exist?"**.

**Do not use `validation_queries` as existence preconditions on Db2.** A no-rows result is
swallowed as idempotent success, so a missing, deleted, or mistyped principal or role is
reported as "already done" instead of erroring. For example, a validation query like
`SELECT 1 FROM users WHERE name = ?<user_id>` will silently mask a bad `user_id`: it returns
no rows, and the grant is reported as `GrantAlreadyExists` even though nothing was granted.
By default a `validation_query` returning no rows **fails the operation**. It is an existence
precondition that aborts loudly. This is the behavior on every engine except where the DDL
no-rows-means-idempotent behavior below is active.

## DDL engines: no rows means idempotent success

The DDL engines (Db2, Oracle) apply `GRANT`/`REVOKE` as DDL that does not report rows-affected, so
the connector cannot tell from the statement whether it changed anything, and re-running an
already-applied statement raises an error (Oracle `ORA-01951` on a repeat revoke, for example). On
these engines a `validation_query` returning no rows is reported as an idempotent success. This is
on by **default** for Db2; on Oracle you opt in per entitlement with
`validation_queries_signal_idempotency: true` on the grant or revoke:

```yaml
grant:
validation_queries_signal_idempotency: true
validation_queries:
- SELECT 1 FROM ... # returns a row only while the grant is MISSING
queries:
- GRANT ...
revoke:
validation_queries_signal_idempotency: true
validation_queries:
- SELECT 1 FROM ... # returns a row only while the grant is PRESENT
queries:
- REVOKE ...
```

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". Wherever this reinterpretation is active (Db2 always,
Oracle when the flag is on), the grant/revoke must also set `no_transaction: true`, since Db2 and
Oracle report no rows-affected under a transaction.

## Writing the query: "is there work to do?", not "does this exist?"

On the DDL engines (Db2, Oracle), your `validation_queries` must answer **"is there work to do?"**,
not **"does this principal or role exist?"**.

**Do not use them as existence preconditions.** A no-rows result is swallowed as idempotent
success, so a missing, deleted, or mistyped principal or role is reported as "already done"
instead of erroring. For example, a query like `SELECT 1 FROM users WHERE name = ?<user_id>`
silently masks a bad `user_id`: it returns no rows, and the grant is reported as
`GrantAlreadyExists` even though nothing was granted.

Write the query so no-rows genuinely means idempotent. For a grant, check whether the target
membership is **missing** (no rows => already granted); for a revoke, check whether it is
**present** (no rows => already revoked).

This mirrors the warning on `EntitlementProvisioningQueries.ValidationQueries` in
`pkg/bsql/config.go`.
`pkg/bsql/config.go`. See [oracle.md](oracle.md) and [db2.md](db2.md) for engine-specific notes.
75 changes: 59 additions & 16 deletions examples/oracle-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -301,15 +301,35 @@ resource_types:
grant:
# Indicates that no database transaction is needed for the grant operation
no_transaction: true
# Oracle GRANT is DDL with no rows-affected: opt into treating a no-rows validation
# result as an idempotent success so re-granting a held role reports GrantAlreadyExists.
validation_queries_signal_idempotency: true
# Returns a row only while the role is NOT yet granted (no rows => already granted).
# UPPER-normalize the principal: the GRANT inserts it |unquoted, so Oracle folds it to
# upper-case (CREATE USER jdoe => JDOE); DBA_ROLES roles are already upper-case.
validation_queries:
- |
SELECT 1 FROM dual WHERE NOT EXISTS (
SELECT 1 FROM DBA_ROLE_PRIVS
WHERE GRANTEE = UPPER(?<principal_name>) AND GRANTED_ROLE = ?<role_name>
)
queries:
- |
GRANT ?<role_name|unquoted> TO ?<principal_name|unquoted>
GRANT ?<role_name|identifier> TO ?<principal_name|unquoted>
revoke:
# Indicates that the revoke operation runs without a transaction
no_transaction: true
# Without this, a repeat revoke of an already-removed role fails with ORA-01951.
validation_queries_signal_idempotency: true
# Returns a row only while the role IS still granted (no rows => already revoked).
# UPPER-normalize the principal for the same reason as the grant validation.
validation_queries:
- |
SELECT 1 FROM DBA_ROLE_PRIVS
WHERE GRANTEE = UPPER(?<principal_name>) AND GRANTED_ROLE = ?<role_name>
queries:
- |
REVOKE ?<role_name|unquoted> FROM ?<principal_name|unquoted>
REVOKE ?<role_name|identifier> FROM ?<principal_name|unquoted>
- id: "admin" # Entitlement identifier for role administration privileges
# Dynamic display name for admin entitlement, appending ' Role Admin'
display_name: "resource.DisplayName + ' Role Admin'"
Expand All @@ -321,6 +341,8 @@ resource_types:
grantable_to:
- "user"
# Provisioning details for granting and revoking admin privileges
# Intentionally no validation_queries_signal_idempotency here: WITH ADMIN OPTION is left
# non-idempotent, so a repeat revoke still raises ORA-01951. Omitted on purpose, not missed.
provisioning:
vars:
principal_name: principal.ID # Maps to the user receiving the admin rights
Expand All @@ -329,14 +351,14 @@ resource_types:
no_transaction: true
queries:
- |
GRANT ?<role_name|unquoted> TO ?<principal_name|unquoted> WITH ADMIN OPTION
GRANT ?<role_name|identifier> TO ?<principal_name|unquoted> WITH ADMIN OPTION
revoke:
no_transaction: true
queries:
- |
REVOKE ?<role_name|unquoted> FROM ?<principal_name|unquoted>
REVOKE ?<role_name|identifier> FROM ?<principal_name|unquoted>
- |
GRANT ?<role_name|unquoted> TO ?<principal_name|unquoted>
GRANT ?<role_name|identifier> TO ?<principal_name|unquoted>
# Dynamic grants based on SQL queries to associate users with roles
grants:
- query: |
Expand Down Expand Up @@ -391,23 +413,44 @@ resource_types:
privilege_name: resource.ID # Maps the privilege identifier
grant:
no_transaction: true
# Oracle GRANT is DDL with no rows-affected: report a held privilege as GrantAlreadyExists.
validation_queries_signal_idempotency: true
# Returns a row only while the privilege is NOT yet granted (no rows => already granted).
validation_queries:
- |
SELECT 1 FROM dual WHERE NOT EXISTS (
SELECT 1 FROM DBA_SYS_PRIVS
WHERE GRANTEE = UPPER(?<principal_name>) AND PRIVILEGE = UPPER(?<privilege_name>)
Comment thread
mateoHernandez123 marked this conversation as resolved.
)
queries:
# System-privilege names are multiword keywords (e.g. CREATE SESSION), so they must
# use |keyword, not |identifier: quoting would break the clause and |unquoted would
# strip the space. The principal stays |unquoted to match the UPPER-based validation.
- |
GRANT ?<privilege_name|unquoted> TO ?<principal_name|unquoted>
GRANT ?<privilege_name|keyword> TO ?<principal_name|unquoted>
# Revoke section defines how to remove a privilege from a user
revoke:
# no_transaction indicates this should execute outside a transaction block
no_transaction: true
# Without this, a repeat revoke of an already-removed privilege fails loudly.
validation_queries_signal_idempotency: true
# Returns a row only while the privilege IS still granted (no rows => already revoked).
validation_queries:
- |
SELECT 1 FROM DBA_SYS_PRIVS
WHERE GRANTEE = UPPER(?<principal_name>) AND PRIVILEGE = UPPER(?<privilege_name>)
# SQL queries to execute when revoking the privilege
queries:
- |
REVOKE ?<privilege_name|unquoted> FROM ?<principal_name|unquoted>
REVOKE ?<privilege_name|keyword> FROM ?<principal_name|unquoted>
- id: "admin" # Entitlement identifier for administrative control over privileges
display_name: "resource.DisplayName + ' Privilege Admin'" # Dynamic display name for admin privileges on the resource
description: "'Can grant the ' + resource.DisplayName + ' privilege to other users'" # Describes the ability to manage privileges
purpose: "permission" # Indicates this entitlement is a permission setting
grantable_to:
- "user" # This permission can be granted to user resources
# Intentionally no validation_queries_signal_idempotency here: WITH ADMIN OPTION is left
# non-idempotent, so a repeat grant/revoke still errors. Omitted on purpose, not missed.
provisioning:
vars:
principal_name: principal.ID # User identifier for provisioning
Expand All @@ -416,21 +459,21 @@ resource_types:
no_transaction: true
queries:
- |
# The ?<privilege_name|unquoted> placeholder will be replaced with the raw value of the privilege_name variable
# defined in the vars section above. The |unquoted flag means the value will be inserted directly into the SQL
# rather than using a prepared statement parameter. This is needed for DDL statements like GRANT that require
# the actual identifier names.
# System-privilege names are multiword keywords (e.g. CREATE SESSION), so the
# ?<privilege_name|keyword> placeholder inlines the clause as-is (collapsing internal
# whitespace) instead of quoting it. Use |keyword here, not |identifier: quoting
# would break the clause and |unquoted would strip the space.
#
# Similarly, ?<principal_name|unquoted> inserts the principal_name variable's value directly into the query.
# Without |unquoted, the values would be passed as bind parameters like: GRANT ? TO ? WITH ADMIN OPTION
GRANT ?<privilege_name|unquoted> TO ?<principal_name|unquoted> WITH ADMIN OPTION
# ?<principal_name|unquoted> inserts the principal directly; DDL like GRANT cannot
# bind these as prepared-statement parameters.
GRANT ?<privilege_name|keyword> TO ?<principal_name|unquoted> WITH ADMIN OPTION
revoke:
no_transaction: true
queries:
- |
REVOKE ?<privilege_name|unquoted> FROM ?<principal_name|unquoted>
REVOKE ?<privilege_name|keyword> FROM ?<principal_name|unquoted>
- |
GRANT ?<privilege_name|unquoted> TO ?<principal_name|unquoted>
GRANT ?<privilege_name|keyword> TO ?<principal_name|unquoted>
# Dynamic grants to map privilege assignments based on database queries
grants:
- query: |
Expand Down
Loading
Loading