-
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
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,165 @@ | ||
| package bsql | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" | ||
| sdkGrant "github.com/conductorone/baton-sdk/pkg/types/grant" | ||
| "github.com/conductorone/baton-sql/pkg/database" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // grantValidationQuery returns a row only while the membership is absent, mirroring | ||
| // the DDL-dialect pattern where the validation query is the "is there work to do?" gate. | ||
| const grantValidationQuery = `SELECT 1 FROM users u WHERE u.id = ?<principal_id> AND NOT EXISTS (SELECT 1 FROM user_roles WHERE user_id = ?<principal_id> AND role = ?<role>)` | ||
|
|
||
| // revokeValidationQuery returns a row only while the membership is present. | ||
| const revokeValidationQuery = `SELECT 1 FROM user_roles WHERE user_id = ?<principal_id> AND role = ?<role>` | ||
|
|
||
| func withValidationQueryConfig(s *SQLSyncer) { | ||
| s.config = ResourceType{ | ||
| StaticEntitlements: []*EntitlementMapping{ | ||
| { | ||
| Id: "member", | ||
| Provisioning: &EntitlementProvisioning{ | ||
| Vars: map[string]string{ | ||
| "principal_id": "principal.ID", | ||
| "role": "resource.ID", | ||
| }, | ||
| Grant: &GrantEntitlementProvisioningQueries{ | ||
| EntitlementProvisioningQueries: EntitlementProvisioningQueries{ | ||
| ValidationQueries: []string{grantValidationQuery}, | ||
| Queries: []string{`INSERT INTO user_roles (user_id, role) VALUES (?<principal_id>, ?<role>)`}, | ||
| }, | ||
| }, | ||
| Revoke: &RevokeEntitlementProvisioningQueries{ | ||
| EntitlementProvisioningQueries: EntitlementProvisioningQueries{ | ||
| ValidationQueries: []string{revokeValidationQuery}, | ||
| Queries: []string{`DELETE FROM user_roles WHERE user_id = ?<principal_id> AND role = ?<role>`}, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| func memberEntitlementFor(role string) *v2.Entitlement { | ||
| roleResource := &v2.Resource{Id: &v2.ResourceId{ResourceType: "role", Resource: role}} | ||
| principal := &v2.Resource{Id: &v2.ResourceId{ResourceType: "user", Resource: "unused"}} | ||
| return sdkGrant.NewGrant(roleResource, "member", principal).GetEntitlement() | ||
| } | ||
|
|
||
| func userPrincipal(userID string) *v2.Resource { | ||
| return &v2.Resource{Id: &v2.ResourceId{ResourceType: "user", Resource: userID}} | ||
| } | ||
|
|
||
| func TestGrant_ValidationNoRowsReportsAlreadyExists(t *testing.T) { | ||
| s, db := newRevokeProvisioningTestSyncer(t) | ||
| withValidationQueryConfig(s) | ||
| // validation "no rows" only signals idempotency on DDL engines (Db2) | ||
| s.dbEngine = database.DB2 | ||
| // membership already present: the grant validation query returns no rows | ||
| seedUserWithRoles(t, db, "user-1", "admin") | ||
|
|
||
| annos, err := s.Grant(t.Context(), userPrincipal("user-1"), memberEntitlementFor("admin")) | ||
| require.NoError(t, err) | ||
|
|
||
| ok, err := annos.Pick(&v2.GrantAlreadyExists{}) | ||
| require.NoError(t, err) | ||
| require.True(t, ok) | ||
|
|
||
| // the INSERT never ran, so no duplicate row was created | ||
| require.Equal(t, 1, countRows(t, db, `SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role = ?`, "user-1", "admin")) | ||
| } | ||
|
|
||
| // On a non-DDL engine, validation "no rows" is a failed precondition, not idempotency: | ||
| // Grant must return an error rather than reporting GrantAlreadyExists. | ||
| func TestGrant_ValidationNoRowsOnNonDDLEngineFailsLoudly(t *testing.T) { | ||
| s, db := newRevokeProvisioningTestSyncer(t) | ||
| withValidationQueryConfig(s) | ||
| // membership already present: the grant validation query returns no rows | ||
| seedUserWithRoles(t, db, "user-1", "admin") | ||
|
|
||
| annos, err := s.Grant(t.Context(), userPrincipal("user-1"), memberEntitlementFor("admin")) | ||
| require.Error(t, err) | ||
| require.Nil(t, annos) | ||
|
|
||
| // the INSERT never ran, so no duplicate row was created | ||
| require.Equal(t, 1, countRows(t, db, `SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role = ?`, "user-1", "admin")) | ||
| } | ||
|
|
||
| func TestGrant_ValidationRowsAppliesGrant(t *testing.T) { | ||
| s, db := newRevokeProvisioningTestSyncer(t) | ||
| withValidationQueryConfig(s) | ||
| // user exists without the role: validation returns a row, grant proceeds | ||
| seedUserWithRoles(t, db, "user-1") | ||
|
|
||
| annos, err := s.Grant(t.Context(), userPrincipal("user-1"), memberEntitlementFor("admin")) | ||
| require.NoError(t, err) | ||
|
|
||
| ok, err := annos.Pick(&v2.GrantAlreadyExists{}) | ||
| require.NoError(t, err) | ||
| require.False(t, ok) | ||
|
|
||
| require.Equal(t, 1, countRows(t, db, `SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role = ?`, "user-1", "admin")) | ||
| } | ||
|
|
||
| func TestRevoke_ValidationNoRowsReportsAlreadyRevoked(t *testing.T) { | ||
| s, _ := newRevokeProvisioningTestSyncer(t) | ||
|
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 engine gate is covered asymmetrically — |
||
| withValidationQueryConfig(s) | ||
| // validation "no rows" only signals idempotency on DDL engines (Db2) | ||
| s.dbEngine = database.DB2 | ||
| // nothing seeded: the revoke validation query returns no rows | ||
|
|
||
| annos, err := s.Revoke(t.Context(), revokeGrantFor("user-1", "admin")) | ||
| require.NoError(t, err) | ||
|
|
||
| ok, err := annos.Pick(&v2.GrantAlreadyRevoked{}) | ||
| require.NoError(t, err) | ||
| require.True(t, ok) | ||
| } | ||
|
|
||
| // On a non-DDL engine, validation "no rows" is a failed precondition, not idempotency: | ||
| // Revoke must return an error rather than reporting GrantAlreadyRevoked. | ||
| func TestRevoke_ValidationNoRowsOnNonDDLEngineFailsLoudly(t *testing.T) { | ||
| s, _ := newRevokeProvisioningTestSyncer(t) | ||
| withValidationQueryConfig(s) | ||
| // nothing seeded: the revoke validation query returns no rows | ||
|
|
||
| annos, err := s.Revoke(t.Context(), revokeGrantFor("user-1", "admin")) | ||
| require.Error(t, err) | ||
| require.Nil(t, annos) | ||
| } | ||
|
|
||
| func TestRevoke_ValidationRowsAppliesRevoke(t *testing.T) { | ||
| s, db := newRevokeProvisioningTestSyncer(t) | ||
| withValidationQueryConfig(s) | ||
| seedUserWithRoles(t, db, "user-1", "admin") | ||
|
|
||
| annos, err := s.Revoke(t.Context(), revokeGrantFor("user-1", "admin")) | ||
| require.NoError(t, err) | ||
|
|
||
| ok, err := annos.Pick(&v2.GrantAlreadyRevoked{}) | ||
| require.NoError(t, err) | ||
| require.False(t, ok) | ||
|
|
||
| require.Equal(t, 0, countRows(t, db, `SELECT COUNT(*) FROM user_roles WHERE user_id = ? AND role = ?`, "user-1", "admin")) | ||
| } | ||
|
|
||
| // The revoke helper must map validation "no rows" onto the sentinel so the caller | ||
| // can detect idempotency with errors.Is. | ||
| func TestRunProvisioningQueriesWithExecutor_ValidationNoRowsWrapsSentinel(t *testing.T) { | ||
| s, db := newRevokeProvisioningTestSyncer(t) | ||
| // validation "no rows" only signals idempotency on DDL engines (Db2) | ||
| s.dbEngine = database.DB2 | ||
|
|
||
| err := s.RunProvisioningQueriesWithExecutor( | ||
| t.Context(), | ||
| []string{`DELETE FROM user_roles WHERE user_id = ?<principal_id>`}, | ||
| []string{revokeValidationQuery}, | ||
| map[string]any{"principal_id": "user-1", "role": "admin"}, | ||
| db, | ||
| ) | ||
| require.ErrorIs(t, err, ErrQueryAffectedZeroRows) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -602,9 +602,18 @@ func (s *SQLSyncer) runPrincipalExistsCheck( | |
| return exists, nil | ||
| } | ||
|
|
||
| func (s *SQLSyncer) RunProvisioningQueriesWithExecutor( | ||
| // validationNoRowsMeansIdempotent reports whether a validation query returning no | ||
| // rows should be treated as "already in the desired state" rather than a failed | ||
| // precondition. Only DDL-based engines (Db2) need this: their GRANT/REVOKE statements | ||
| // don't report rows-affected, so the validation query is the only zero-effect signal | ||
| // available. Engines that report rows-affected keep using validation queries as | ||
| // existence preconditions that fail loudly. | ||
| func (s *SQLSyncer) validationNoRowsMeansIdempotent() bool { | ||
|
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. [Review] this might mask real failures, not just idempotency So If someone copies that exact pattern into a DB2 config, a typo'd or dropped role would make the validation query return 0 rows too, and now that silently becomes Might be worth a flag/second list to distinguish "precondition" queries from "idempotency gate" queries, or at least a loud warning in the 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. [Review] why only DB2? The bug report and this PR's own description frame this as a general "DDL-based engines" problem, not a DB2-only thing — and Could totally be intentional (only fix what's actually verified, per the stability-first vibe of this repo) — if so no action needed, just curious if that's the reasoning or if it's worth a quick follow-up ticket for Oracle/MSSQL/HDB/Vertica too. |
||
| return s.dbEngine == database.DB2 | ||
| } | ||
|
|
||
| func (s *SQLSyncer) runValidationQueries( | ||
| ctx context.Context, | ||
| queries, | ||
| validationQueries []string, | ||
| vars map[string]any, | ||
| executor executor, | ||
|
|
@@ -632,19 +641,38 @@ func (s *SQLSyncer) RunProvisioningQueriesWithExecutor( | |
| valid := result.Next() | ||
|
|
||
| if err := result.Err(); err != nil { | ||
| _ = result.Close() | ||
| return fmt.Errorf("failed to read validation query result: %w", err) | ||
| } | ||
|
|
||
| err = result.Close() | ||
| if err != nil { | ||
| if err := result.Close(); err != nil { | ||
| return fmt.Errorf("failed to close validation query result: %w", err) | ||
| } | ||
|
|
||
| if !valid { | ||
| if s.validationNoRowsMeansIdempotent() { | ||
|
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. [Review] duplicated with the same block in RunGrantProvisioning This loop and the one in Not blocking, just a nit — might be worth pulling into a shared |
||
| return fmt.Errorf("validation query returned no rows: %w", ErrQueryAffectedZeroRows) | ||
| } | ||
| return fmt.Errorf("validation query returned no rows") | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func (s *SQLSyncer) RunProvisioningQueriesWithExecutor( | ||
| ctx context.Context, | ||
| queries, | ||
| validationQueries []string, | ||
| vars map[string]any, | ||
| executor executor, | ||
| ) error { | ||
| l := ctxzap.Extract(ctx) | ||
|
|
||
| if err := s.runValidationQueries(ctx, validationQueries, vars, executor); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| zeroRowCount := 0 | ||
|
|
||
| for idx, q := range queries { | ||
|
|
@@ -1047,32 +1075,8 @@ func (s *SQLSyncer) RunGrantProvisioning( | |
| } | ||
| } | ||
|
|
||
| for _, q := range validationQueries { | ||
| q, qArgs, err := s.prepareProvisioningQuery(q, vars) | ||
| if err != nil { | ||
| return anno, fmt.Errorf("failed to prepare validation query: %w", err) | ||
| } | ||
|
|
||
| result, err := executor.QueryContext(ctx, q, qArgs...) | ||
| if err != nil { | ||
| return anno, fmt.Errorf("failed to execute validation query: %w", err) | ||
| } | ||
|
|
||
| valid := result.Next() | ||
|
|
||
| if err := result.Err(); err != nil { | ||
| _ = result.Close() | ||
| return anno, fmt.Errorf("failed to read validation query result: %w", err) | ||
| } | ||
|
|
||
| err = result.Close() | ||
| if err != nil { | ||
| return anno, fmt.Errorf("failed to close validation query result: %w", err) | ||
| } | ||
|
|
||
| if !valid { | ||
| return anno, fmt.Errorf("grant provisioning: validation query returned no rows") | ||
| } | ||
| if err := s.runValidationQueries(ctx, validationQueries, vars, executor); err != nil { | ||
| return anno, err | ||
| } | ||
|
|
||
| zeroRowCount := 0 | ||
|
|
||
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.
🟠 Bug: reusing
annohere is only safe when the grant ran withno_transaction: true. In the default transactional path,RunGrantProvisioningreturnsanno, ErrQueryAffectedZeroRows(query.go:1079 / 1112) beforecommitted = true, so the deferredtx.Rollback()undoes thegrant_replacerevoke — yet theGrantReplacedannotation set at query.go:1070 now survives and is reported to C1 as if the old grant were removed. Gate the reuse onprovisioningConfig.Grant.NoTransaction(or dropGrantReplacedinRunGrantProvisioningwhen the tx is rolled back).