-
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
base: main
Are you sure you want to change the base?
Changes from all commits
a57262e
c178670
399c855
9765455
70f62ac
5024f30
12a6488
a439489
cf1847a
f3823b7
ddeebdf
7cf7eb0
9e82a69
8c0917e
1117fe8
97c6b5c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| # 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. | ||
|
|
||
| ## 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. | ||
|
|
||
| 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`. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -88,9 +88,15 @@ func (s *SQLSyncer) Grant(ctx context.Context, principal *v2.Resource, entitleme | |
| if err != nil { | ||
| if errors.Is(err, ErrQueryAffectedZeroRows) { | ||
| l.Debug("entitlement is already granted", zap.String("entitlement_id", entitlement.GetId())) | ||
| anno := annotations.Annotations{} | ||
| anno.Update(&v2.GrantAlreadyExists{}) | ||
| return anno, nil | ||
| // On the transactional path the zero-rows return rolls the tx back, undoing any | ||
| // grant_replace revoke, so a reused GrantReplaced would misreport a removal the DB | ||
| // no longer reflects. Keep the returned annotations only on the no_transaction path, | ||
| // where the replace already committed. | ||
| if provisioningConfig.Grant.NoTransaction { | ||
| anno.Update(&v2.GrantAlreadyExists{}) | ||
| return anno, nil | ||
| } | ||
| return annotations.New(&v2.GrantAlreadyExists{}), nil | ||
| } | ||
| return nil, err | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: The new comment above correctly reasons that on the |
||
| } | ||
|
|
||
| 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'`, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fixture encodes the config the new docs tell users not to write, and then asserts the result as correct.
Here the old membership is That is also why the test can assert two things that cannot both hold in a correct run: Concrete suggestion: keep the validation query pointed at the real membership ( |
||
| } | ||
| } | ||
|
|
||
| // 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")) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package bsql | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/conductorone/baton-sql/pkg/database" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // validationNoRowsMeansIdempotent is the DDL-engine gate: validation "no rows" is only | ||
| // treated as idempotency (not a failed precondition) for engines whose already-applied | ||
| // GRANT/REVOKE raises an error instead of affecting rows. Only Db2 qualifies today, and it | ||
| // ships opt-in behind the db2 build tag. Oracle and the other DDL engines stay false: they | ||
| // ship default-on, so flipping the gate would silently reinterpret existing configs that use | ||
| // validation_queries as loud existence preconditions. Adding one back needs a per-config | ||
| // opt-in first, so this test guards against re-enabling any of them by accident. | ||
| func TestValidationNoRowsMeansIdempotent_EngineGate(t *testing.T) { | ||
| ddl := map[database.DbEngine]bool{ | ||
| database.DB2: true, | ||
| database.Oracle: false, | ||
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Suggestion: This says the no-rows-means-idempotent behavior applies to "Db2 and Oracle", but
validationNoRowsMeansIdempotent()(pkg/bsql/query.go:625) gates ondatabase.DB2only,docs/provisioning.mdexplicitly states Oracle "still fail[s] loudly like everyone else", andTestValidationNoRowsMeansIdempotent_EngineGateassertsOracle: false. An Oracle operator reading this line would writevalidation_queriesexpecting idempotent no-rows and instead get a hard failure. Drop "and Oracle".