Skip to content

Fix MySQL PUT/PATCH regarding row-level update database policy#3734

Open
aaronburtle wants to merge 1 commit into
mainfrom
dev/aaronburtle/PUT-fix-mysql
Open

Fix MySQL PUT/PATCH regarding row-level update database policy#3734
aaronburtle wants to merge 1 commit into
mainfrom
dev/aaronburtle/PUT-fix-mysql

Conversation

@aaronburtle

Copy link
Copy Markdown
Contributor

Why make this change?

On MySQL / Azure Database for MySQL, the REST PUT (upsert) and PATCH (incremental upsert) paths did not enforce the row-level update database policy configured for an entity. We now respect that row level database policy.

What is this change?

MySqlQueryBuilder.Build(SqlUpsertQueryStructure) previously filtered rows by primary key only and never retrieved the update policy (GetDbPolicyForOperation(EntityActionOperation.Update)), in both the IsFallbackToUpdate and ON DUPLICATE KEY UPDATE branches. Since MySQL's ON DUPLICATE KEY UPDATE syntax cannot carry a WHERE clause, the upsert was restructured to mirror the MSSQL approach:

  • MySqlQueryBuilder.cs the upsert now emits:
    1. a pre-count of existing rows for the primary key (first result set),
    2. UPDATE … SET … WHERE <pk> AND <update-policy> so a policy-violating update matches zero rows, and
    3. an INSERT … SELECT … WHERE @cnt = 0 gated on the pre-count, so an existing record can never fall through into an insert.
  • MySqlQueryExecutor.cs a new GetMultipleResultSetsIfAnyAsync override interprets the result sets and returns the correct outcome: 200 (update), 201 (insert), 403 DatabasePolicyFailure (record exists but the update policy blocked it), or 404 (auto-generated-PK record not found).

Test coverage that was previously [Ignore]d for MySQL is now enabled (this disabled coverage is why the gap went undetected):

  • Enabled: PutOneUpdateWithDatabasePolicy, PatchOneUpdateWithDatabasePolicy, PatchOneUpdateWithUnsatisfiedDatabasePolicy.
  • Added the database_policy_tester role (update policy) to the Stock entity in config-generators/mysql-commands.txt, and updated the TestReadingRuntimeConfigForMySql snapshot to match.
  • Create-action database policies remain unsupported on MySQL (engine limitation, same as PostgreSQL), so the create-policy-dependent tests stay skipped.

How was this tested?

  • Integration Tests
  • Unit Tests

Validated end-to-end against a real MySQL 8.0.46 instance (.NET 10, SDK 10.0.301):

  • Targeted policy tests 3/3 passed, including the key negative case PatchOneUpdateWithUnsatisfiedDatabasePolicy, which sends the attack request (modify a row the caller isn't authorized for) and asserts 403 DatabasePolicyFailure with the row left unchanged. On the vulnerable code this returned 200 and overwrote the row.
  • Full regression MySqlPutApiTests + MySqlPatchApiTests: 68 passed / 0 failed / 12 skipped, confirming the upsert restructuring did not regress ordinary insert/update/idempotent behavior. (The 12 skips are create-policy and view tests unsupported on MySQL.)

Sample Request(s)

Entity commodities (table stocks) with an update policy for role database_policy_tester: @item.pieceid ne 1.

Blocked (unauthorized) — attempt to update a row that violates the policy (pieceid = 1):

PATCH /api/commodities/categoryid/0/pieceid/1
Authorization: Bearer <jwt>
X-MS-API-ROLE: database_policy_tester
Content-Type: application/json

{ "categoryName": "SciFi", "piecesRequired": 5, "piecesAvailable": 2 }
  • Before fix: 200 OK row overwritten.
  • After fix: 403 Forbidden (DatabasePolicyFailure) row unchanged.

Allowed (authorized) update a row that satisfies the policy (pieceid = 99):

PUT /api/commodities/categoryid/100/pieceid/99
Authorization: Bearer <jwt>
X-MS-API-ROLE: database_policy_tester
Content-Type: application/json

{ "categoryName": "SciFi", "piecesAvailable": 4, "piecesRequired": 5 }
  • Result: 200 OK row updated.

Copilot AI 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.

Pull request overview

This PR updates the MySQL REST upsert implementation (PUT/PATCH) to enforce the entity’s row-level update database policy, aligning MySQL behavior with other engines by restructuring the upsert into multiple statements/result sets and enabling previously skipped MySQL policy integration tests.

Changes:

  • Restructured MySQL upsert SQL generation to: pre-count existing PK rows, attempt an UPDATE ... WHERE <pk> AND <update-policy>, then conditionally INSERT only when the PK doesn’t exist.
  • Added MySQL executor logic to interpret multiple result sets and return the correct HTTP outcome (update vs insert vs policy failure vs not found).
  • Enabled MySQL integration tests for update-policy scenarios and updated MySQL config generation + snapshot to include the new policy-testing role.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Core/Resolvers/MySqlQueryBuilder.cs Reworked MySQL upsert SQL to enforce update policy and avoid ON DUPLICATE KEY UPDATE limitations.
src/Core/Resolvers/MySqlQueryExecutor.cs Added multi-result-set interpretation for MySQL upsert outcomes and policy failures.
src/Service.Tests/SqlTests/RestApiTests/Put/MySqlPutApiTests.cs Enabled PUT update-policy test and added MySQL validation query for it.
src/Service.Tests/SqlTests/RestApiTests/Patch/MySqlPatchApiTests.cs Enabled PATCH update-policy tests and added MySQL validation query for it.
src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt Updated snapshot to include database_policy_tester role with update DB policy.
config-generators/mysql-commands.txt Added commands to grant database_policy_tester permissions and update DB policy for Stock.

Comment on lines +135 to +150
// Capture whether a record already exists for the given primary key BEFORE attempting the
// update/insert. This count is surfaced as the first result set and is used by the query
// executor to distinguish an update from an insert and to detect database policy failures.
string countExistingRows =
$"SET @cnt := (SELECT COUNT(*) FROM {tableName} WHERE {pkPredicates}); " +
$"SELECT @cnt AS {QuoteIdentifier(COUNT_ROWS_WITH_GIVEN_PK)};";

// Update honoring the update database policy. When the policy is not satisfied, zero rows
// match and the subsequent select returns no rows.
string updateQuery =
$"UPDATE {tableName} " +
$"SET {Build(structure.UpdateOperations, ", ")} " +
", " + updates +
$" WHERE {updatePredicates}; " +
$"SET @ROWCOUNT=ROW_COUNT(); " +
$"SELECT " + select + $" WHERE @ROWCOUNT > 0;";
Comment on lines +159 to +169
// Insert honoring the create database policy, but only when no record already exists for
// the given primary key (@cnt = 0). Gating the insert on @cnt = 0 ensures the update
// policy enforced above cannot be bypassed by falling through to an insert on an
// existing record.
string createPredicates = JoinPredicateStrings(structure.GetDbPolicyForOperation(EntityActionOperation.Create));
string insertQuery =
$"INSERT INTO {tableName} ({Build(structure.InsertColumns)}) " +
$"SELECT {string.Join(", ", structure.Values)} FROM DUAL " +
$"WHERE @cnt = 0 AND ({createPredicates}); " +
$"SET @ROWCOUNT=ROW_COUNT(); " +
$"SELECT {MakeUpsertSelections(structure)} WHERE @ROWCOUNT = 1;";
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.

2 participants