-
Notifications
You must be signed in to change notification settings - Fork 2
CXH-2379: fix grant/revoke idempotency for DDL-based engines #151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
al-conductorone
wants to merge
14
commits into
main
Choose a base branch
from
cxh-2379-baton-sql-fix-grant-and-revoke-idempotency-for-ddl-based
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
a57262e
CXH-2379: fix grant/revoke idempotency for DDL-based engines
al-conductorone c178670
CXH-2379: bump sync-test and account-provisioning CI actions to v4
al-conductorone 399c855
CXH-2379: gate validation-query idempotency to DDL engines
al-conductorone 9765455
CXH-2379: test non-DDL revoke fails loudly on validation no-rows
al-conductorone 70f62ac
CXH-2379: address review feedback on validation-query idempotency
al-conductorone 5024f30
CXH-2379: don't report GrantReplaced when the grant tx rolled back
al-conductorone 12a6488
CXH-2379: document DB2 grant_replace no-rows semantics and add coverage
al-conductorone a439489
CXH-2379: extend DDL grant/revoke idempotency to Oracle
al-conductorone cf1847a
CXH-2379: exit with gRPC status code on error via exit.LogExit
al-conductorone f3823b7
CXH-2379: document DDL validation_queries semantics for Oracle
al-conductorone ddeebdf
Merge branch 'pr-review-fix-a439489e' into cxh-2379-baton-sql-fix-gra…
al-conductorone 7cf7eb0
CXH-2379: map DB auth failures to Unauthenticated in Validate
al-conductorone 9e82a69
CXH-2379: skip principal-exists probe on validation-sourced no-rows r…
al-conductorone 8c0917e
CXH-2379: include DB name in auth error and document driver coverage
al-conductorone File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| # Provisioning: `validation_queries` semantics | ||
|
al-conductorone marked this conversation as resolved.
|
||
|
|
||
| `validation_queries` run before the provisioning `queries` in a grant or revoke. What a | ||
| **no-rows** result means depends on the engine. | ||
|
|
||
| ## Engines that report rows-affected | ||
|
|
||
| On engines whose `GRANT`/`REVOKE` report how many rows they changed (SQLite, MySQL, | ||
| PostgreSQL, SQL Server, HANA, Vertica), a `validation_query` returning no rows **fails the | ||
| operation**. It is an existence precondition that aborts loudly. | ||
|
|
||
| ## DDL-based engines (Db2, Oracle) | ||
|
|
||
| Db2 and Oracle apply `GRANT`/`REVOKE` as DDL that does not report rows-affected, so the | ||
| connector cannot tell from the statement itself whether it changed anything. On Oracle a | ||
| repeat `GRANT` succeeds without changing anything and an already-applied `REVOKE` raises | ||
| `ORA-01951`; on Db2 an already-applied statement raises an error. To make grant and revoke | ||
| idempotent, 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". | ||
|
|
||
| Because of this, on Db2 and Oracle 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 or Oracle.** 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. | ||
|
|
||
| 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`. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| package bsql | ||
|
|
||
| import ( | ||
| "database/sql" | ||
| "testing" | ||
|
|
||
| v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" | ||
| "github.com/conductorone/baton-sql/pkg/bcel" | ||
| "github.com/conductorone/baton-sql/pkg/database" | ||
| "github.com/stretchr/testify/require" | ||
| _ "modernc.org/sqlite" | ||
| ) | ||
|
|
||
| // withGrantReplaceConfig wires a "member" entitlement whose grant replaces the | ||
| // principal's existing role: the grant_replace query finds the old membership and | ||
| // revokes it, then the main grant runs. The main grant uses INSERT OR IGNORE so a | ||
| // pre-existing target row makes it affect zero rows (the already-granted path). | ||
| func withGrantReplaceConfig(s *SQLSyncer, noTransaction bool) { | ||
| s.resourceType = &v2.ResourceType{Id: "role"} | ||
| s.config = ResourceType{ | ||
| StaticEntitlements: []*EntitlementMapping{ | ||
| { | ||
| Id: "member", | ||
| Provisioning: &EntitlementProvisioning{ | ||
| Vars: map[string]string{ | ||
| "user_id": "principal.ID", | ||
| "role": "resource.ID", | ||
| }, | ||
| Grant: &GrantEntitlementProvisioningQueries{ | ||
| EntitlementProvisioningQueries: EntitlementProvisioningQueries{ | ||
| NoTransaction: noTransaction, | ||
| Queries: []string{`INSERT OR IGNORE INTO user_roles (user_id, role) VALUES (?<user_id>, ?<role>)`}, | ||
| }, | ||
| GrantReplace: &GrantReplaceProvisioningQueries{ | ||
| Query: `SELECT user_id, role FROM user_roles WHERE user_id = ?<user_id> AND role = 'viewer'`, | ||
| Map: []*GrantMapping{ | ||
| { | ||
| EntitlementResourceId: ".role", | ||
| PrincipalId: ".user_id", | ||
| PrincipalType: "user", | ||
| Entitlement: "member", | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| Revoke: &RevokeEntitlementProvisioningQueries{ | ||
| EntitlementProvisioningQueries: EntitlementProvisioningQueries{ | ||
| Queries: []string{`DELETE FROM user_roles WHERE user_id = ?<user_id> AND role = ?<role>`}, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| func newGrantReplaceTestSyncer(t *testing.T) (*SQLSyncer, *sql.DB) { | ||
| t.Helper() | ||
|
|
||
| db, err := sql.Open("sqlite", ":memory:") | ||
| require.NoError(t, err) | ||
| db.SetMaxOpenConns(1) | ||
| t.Cleanup(func() { require.NoError(t, db.Close()) }) | ||
|
|
||
| _, err = db.ExecContext(t.Context(), `CREATE TABLE user_roles (user_id TEXT, role TEXT, UNIQUE(user_id, role))`) | ||
| require.NoError(t, err) | ||
|
|
||
| env, err := bcel.NewEnv(t.Context()) | ||
| require.NoError(t, err) | ||
|
|
||
| return &SQLSyncer{ | ||
| db: db, | ||
| dbs: map[string]*sql.DB{"primary": db}, | ||
| dbNames: []string{"primary"}, | ||
| primaryDBName: "primary", | ||
| currentDBName: "primary", | ||
| dbEngine: database.SQLite, | ||
| env: env, | ||
| }, db | ||
| } | ||
|
|
||
| // Transactional path: the target grant already exists, so the main grant hits the | ||
| // zero-rows sentinel and the tx rolls back, undoing the grant_replace revoke. The | ||
| // response must NOT claim GrantReplaced, and the old row must survive. | ||
| func TestGrant_ReplaceRolledBackDoesNotReportGrantReplaced(t *testing.T) { | ||
| s, db := newGrantReplaceTestSyncer(t) | ||
| withGrantReplaceConfig(s, false) // transactional | ||
| _, err := db.ExecContext(t.Context(), `INSERT INTO user_roles (user_id, role) VALUES ('user-1','viewer'), ('user-1','admin')`) | ||
| require.NoError(t, err) | ||
|
|
||
| annos, err := s.Grant(t.Context(), userPrincipal("user-1"), memberEntitlementFor("admin")) | ||
| require.NoError(t, err) | ||
|
|
||
| exists, err := annos.Pick(&v2.GrantAlreadyExists{}) | ||
| require.NoError(t, err) | ||
| require.True(t, exists) | ||
|
|
||
| replaced, err := annos.Pick(&v2.GrantReplaced{}) | ||
| require.NoError(t, err) | ||
| require.False(t, replaced, "GrantReplaced must not be reported when the tx rolled back") | ||
|
|
||
| // the replace revoke was rolled back, so the old membership survives | ||
| require.Equal(t, 1, countRows(t, db, `SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role = ?`, "user-1", "viewer")) | ||
| } | ||
|
|
||
| // no_transaction path: the grant_replace revoke commits immediately, so even when the | ||
| // main grant hits the zero-rows sentinel the removal really happened and GrantReplaced | ||
| // must be reported. | ||
| func TestGrant_ReplaceCommittedReportsGrantReplaced(t *testing.T) { | ||
| s, db := newGrantReplaceTestSyncer(t) | ||
| withGrantReplaceConfig(s, true) // no_transaction | ||
| _, err := db.ExecContext(t.Context(), `INSERT INTO user_roles (user_id, role) VALUES ('user-1','viewer'), ('user-1','admin')`) | ||
| require.NoError(t, err) | ||
|
|
||
| annos, err := s.Grant(t.Context(), userPrincipal("user-1"), memberEntitlementFor("admin")) | ||
| require.NoError(t, err) | ||
|
|
||
| exists, err := annos.Pick(&v2.GrantAlreadyExists{}) | ||
| require.NoError(t, err) | ||
| require.True(t, exists) | ||
|
|
||
| replaced, err := annos.Pick(&v2.GrantReplaced{}) | ||
| require.NoError(t, err) | ||
| require.True(t, replaced, "GrantReplaced must be reported when the replace committed") | ||
|
|
||
| // the replace revoke committed, so the old membership is gone | ||
| require.Equal(t, 0, countRows(t, db, `SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role = ?`, "user-1", "viewer")) | ||
| } | ||
|
|
||
| // withGrantReplaceDB2Config is the grant_replace config with a revoke validation | ||
| // query that never matches. On Db2 a no-rows validation means "nothing to revoke", | ||
| // so the revoke aborts before its DELETE runs but the flow still reports GrantReplaced. | ||
| func withGrantReplaceDB2Config(s *SQLSyncer) { | ||
| withGrantReplaceConfig(s, true) // no_transaction: the replace stands on its own | ||
| revoke := s.config.StaticEntitlements[0].Provisioning.Revoke | ||
| revoke.ValidationQueries = []string{ | ||
| `SELECT 1 FROM user_roles WHERE user_id = ?<user_id> AND role = 'does-not-exist'`, | ||
| } | ||
| } | ||
|
|
||
| // Db2 path: the revoke validation query returns no rows, so the revoke DELETE never | ||
| // runs, yet GrantReplaced is still reported because on Db2 a no-rows validation means | ||
| // the old grant is already gone. The old viewer row must survive (revoke never ran). | ||
| func TestGrant_ReplaceDB2RevokeValidationNoRowsStillReportsGrantReplaced(t *testing.T) { | ||
| s, db := newGrantReplaceTestSyncer(t) | ||
| s.dbEngine = database.DB2 | ||
| withGrantReplaceDB2Config(s) | ||
| _, err := db.ExecContext(t.Context(), `INSERT INTO user_roles (user_id, role) VALUES ('user-1','viewer')`) | ||
| require.NoError(t, err) | ||
|
|
||
| annos, err := s.Grant(t.Context(), userPrincipal("user-1"), memberEntitlementFor("admin")) | ||
| require.NoError(t, err) | ||
|
|
||
| replaced, err := annos.Pick(&v2.GrantReplaced{}) | ||
| require.NoError(t, err) | ||
| require.True(t, replaced, "GrantReplaced must be reported: on Db2 a no-rows revoke validation means the old grant is already gone") | ||
|
|
||
| // the revoke validation aborted the revoke before its DELETE ran, so viewer survives | ||
| require.Equal(t, 1, countRows(t, db, `SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role = ?`, "user-1", "viewer")) | ||
| // the main grant still ran | ||
| require.Equal(t, 1, countRows(t, db, `SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role = ?`, "user-1", "admin")) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
35 changes: 35 additions & 0 deletions
35
pkg/bsql/provisioning_validation_idempotency_oracle_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package bsql | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/conductorone/baton-sql/pkg/database" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // validationNoRowsMeansIdempotent is the DDL-engine gate: it must be true only for | ||
| // engines whose already-applied GRANT/REVOKE raises an error instead of affecting rows, | ||
| // so validation "no rows" means idempotency rather than a failed precondition. | ||
| // | ||
| // The behavioral grant/revoke wiring is covered by the Db2 tests in | ||
| // provisioning_validation_idempotency_test.go; Oracle can't reuse them because the | ||
| // Oracle driver rewrites ?<name> placeholders to bind syntax the sqlite test backend | ||
| // rejects. Oracle's end-to-end behavior was verified live against Oracle XE 21c on | ||
| // 2026-09-03 (grant/re-grant -> GrantAlreadyExists, revoke/re-revoke -> GrantAlreadyRevoked, | ||
| // ORA-01951 no longer surfaced). | ||
| func TestValidationNoRowsMeansIdempotent_EngineGate(t *testing.T) { | ||
| ddl := map[database.DbEngine]bool{ | ||
| database.DB2: true, | ||
| database.Oracle: true, | ||
| database.SQLite: false, | ||
| database.MySQL: false, | ||
| database.PostgreSQL: false, | ||
| database.MSSQL: false, | ||
| database.HDB: false, | ||
| database.Vertica: false, | ||
| } | ||
| for engine, want := range ddl { | ||
| s := &SQLSyncer{dbEngine: engine} | ||
| require.Equal(t, want, s.validationNoRowsMeansIdempotent(), "engine=%v", engine) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.