From 3c1e0b3b68907f9c0d3a3de60a4fa5adc1fb151f Mon Sep 17 00:00:00 2001
From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com>
Date: Wed, 6 May 2026 10:58:11 +1000
Subject: [PATCH] Add grant runAs for Postgres grants
---
.../SchemaDefinition.cs | 6 +
.../PostgresSupportDdlGenerator.cs | 30 ++-
.../PostgresGrantRunAsE2ETests.cs | 196 ++++++++++++++++++
.../PostgresMigrationTests.cs | 52 +++++
.../PostgresSupportDdlTests.cs | 29 +++
.../SchemaDiffSupportTests.cs | 54 +++++
.../SchemaSupportYamlSerializerTests.cs | 33 ++-
7 files changed, 398 insertions(+), 2 deletions(-)
create mode 100644 Migration/Nimblesite.DataProvider.Migration.Tests/PostgresGrantRunAsE2ETests.cs
diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs
index 86fa60b..8c5be3e 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs
@@ -121,6 +121,12 @@ public sealed record PostgresGrantDefinition
/// Roles receiving the privileges.
public IReadOnlyList Roles { get; init; } = [];
+
+ ///
+ /// PostgreSQL role used only while applying this grant. Useful when a migration role
+ /// is a member of the schema owner role but is not itself the schema owner.
+ ///
+ public string? RunAs { get; init; }
}
///
diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportDdlGenerator.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportDdlGenerator.cs
index e9f1ac5..74f9944 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportDdlGenerator.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportDdlGenerator.cs
@@ -84,7 +84,10 @@ private static string GenerateCreateOrReplaceFunction(CreateOrReplaceFunctionOpe
}
private static string GenerateGrantPrivileges(PostgresGrantDefinition grant) =>
- $"GRANT {PrivilegeList(grant.Privileges)} ON {GrantTarget(grant)} TO {QuoteIdentList(grant.Roles)}";
+ WithGrantRunAs(
+ grant,
+ $"GRANT {PrivilegeList(grant.Privileges)} ON {GrantTarget(grant)} TO {QuoteIdentList(grant.Roles)}"
+ );
private static string GenerateRevokePrivileges(PostgresGrantDefinition grant) =>
$"REVOKE {PrivilegeList(grant.Privileges)} ON {GrantTarget(grant)} FROM {QuoteIdentList(grant.Roles)}";
@@ -130,6 +133,31 @@ private static string FunctionBody(PostgresFunctionDefinition function)
private static string FunctionSignature(PostgresFunctionDefinition function) =>
$"{QuoteIdent(function.Schema)}.{QuoteIdent(function.Name)}({string.Join(", ", function.Arguments.Select(a => a.Type))})";
+ private static string WithGrantRunAs(PostgresGrantDefinition grant, string ddl)
+ {
+ var runAs = grant.RunAs?.Trim();
+ return string.IsNullOrWhiteSpace(runAs)
+ ? ddl
+ : $"""
+ {GrantRunAsMembershipGuard(runAs)}
+ SET LOCAL ROLE {QuoteIdent(runAs)};
+ {ddl};
+ RESET ROLE
+ """;
+ }
+
+ private static string GrantRunAsMembershipGuard(string runAs) =>
+ $"""
+ DO $$
+ BEGIN
+ IF NOT pg_has_role(current_user, {QuoteLiteral(runAs)}, 'MEMBER') THEN
+ RAISE EXCEPTION 'MIG-E-PG-GRANT-RUN-AS-MISSING-MEMBERSHIP: connecting role "%" cannot SET LOCAL ROLE {QuoteIdent(
+ runAs
+ )}; run GRANT {QuoteIdent(runAs)} TO "%"', current_user, current_user;
+ END IF;
+ END $$;
+ """;
+
private static string GrantTarget(PostgresGrantDefinition grant) =>
grant.Target switch
{
diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresGrantRunAsE2ETests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresGrantRunAsE2ETests.cs
new file mode 100644
index 0000000..1103245
--- /dev/null
+++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresGrantRunAsE2ETests.cs
@@ -0,0 +1,196 @@
+namespace Nimblesite.DataProvider.Migration.Tests;
+
+[Collection(PostgresTestSuite.Name)]
+public sealed class PostgresGrantRunAsE2ETests(PostgresContainerFixture fixture)
+{
+ [Fact]
+ public void GrantRunAs_AppliesNapAuthShapeGrantsThroughSchemaOwner()
+ {
+ var suffix = Guid.NewGuid().ToString("N")[..8];
+ var owner = $"grant_owner_{suffix}";
+ var migrate = $"grant_migrate_{suffix}";
+ var appUser = $"grant_app_user_{suffix}";
+ var appAdmin = $"grant_app_admin_{suffix}";
+ var schema = $"auth_{suffix}";
+
+ using var connection = fixture.CreateDatabase("grant_run_as");
+ Exec(connection, $"CREATE ROLE {Q(owner)} NOLOGIN");
+ Exec(connection, $"CREATE ROLE {Q(migrate)} LOGIN PASSWORD 'test'");
+ Exec(connection, $"CREATE ROLE {Q(appUser)} NOLOGIN");
+ Exec(connection, $"CREATE ROLE {Q(appAdmin)} NOLOGIN");
+ Exec(connection, $"GRANT {Q(owner)} TO {Q("test")}");
+ Exec(connection, $"GRANT {Q(owner)} TO {Q(migrate)}");
+ Exec(connection, $"GRANT CONNECT ON DATABASE {Q(connection.Database)} TO {Q(migrate)}");
+ Exec(connection, $"CREATE SCHEMA {Q(schema)} AUTHORIZATION {Q(owner)}");
+ Exec(
+ connection,
+ $"SET ROLE {Q(owner)}; CREATE TABLE {Q(schema)}.{Q("users")}(id uuid PRIMARY KEY); RESET ROLE"
+ );
+ using var migrateConnection = OpenRoleConnection(connection, migrate);
+
+ var result = MigrationRunner.Apply(
+ migrateConnection,
+ NapAuthGrants(schema, owner, appUser, appAdmin),
+ PostgresDdlGenerator.Generate,
+ MigrationOptions.Default,
+ NullLogger.Instance
+ );
+
+ Assert.True(result is MigrationApplyResultOk);
+ Assert.True(HasSchemaPrivilege(connection, appUser, schema, "USAGE"));
+ Assert.True(HasSchemaPrivilege(connection, appAdmin, schema, "USAGE"));
+ Assert.True(HasTablePrivilege(connection, appUser, schema, "users", "SELECT"));
+ Assert.True(HasTablePrivilege(connection, appAdmin, schema, "users", "INSERT"));
+ }
+
+ [Fact]
+ public void GrantRunAs_MissingRoleMembership_ReturnsClearGrantToMessage()
+ {
+ var suffix = Guid.NewGuid().ToString("N")[..8];
+ var owner = $"grant_owner_{suffix}";
+ var migrate = $"grant_migrate_{suffix}";
+ var appUser = $"grant_app_user_{suffix}";
+
+ using var connection = fixture.CreateDatabase("grant_run_as_missing");
+ Exec(connection, $"CREATE ROLE {Q(owner)} NOLOGIN");
+ Exec(connection, $"CREATE ROLE {Q(migrate)} LOGIN PASSWORD 'test'");
+ Exec(connection, $"CREATE ROLE {Q(appUser)} NOLOGIN");
+ Exec(connection, $"GRANT CONNECT ON DATABASE {Q(connection.Database)} TO {Q(migrate)}");
+ using var migrateConnection = OpenRoleConnection(connection, migrate);
+
+ var result = MigrationRunner.Apply(
+ migrateConnection,
+ [
+ new GrantPrivilegesOperation(
+ new PostgresGrantDefinition
+ {
+ Schema = "public",
+ Target = PostgresGrantTarget.Schema,
+ Privileges = ["USAGE"],
+ Roles = [appUser],
+ RunAs = owner,
+ }
+ ),
+ ],
+ PostgresDdlGenerator.Generate,
+ MigrationOptions.Default,
+ NullLogger.Instance
+ );
+
+ Assert.True(result is MigrationApplyResultError);
+ var error = ((MigrationApplyResultError)result).Value;
+ Assert.Contains("MIG-E-PG-GRANT-RUN-AS-MISSING-MEMBERSHIP", error.Message);
+ Assert.Contains($"GRANT \"{owner}\" TO \"{migrate}\"", error.Message);
+ }
+
+ private static IReadOnlyList NapAuthGrants(
+ string schema,
+ string owner,
+ string appUser,
+ string appAdmin
+ ) =>
+ [
+ new GrantPrivilegesOperation(
+ new PostgresGrantDefinition
+ {
+ Schema = schema,
+ Target = PostgresGrantTarget.Schema,
+ Privileges = ["USAGE"],
+ Roles = [appUser, appAdmin],
+ RunAs = owner,
+ }
+ ),
+ new GrantPrivilegesOperation(
+ new PostgresGrantDefinition
+ {
+ Schema = schema,
+ Target = PostgresGrantTarget.Table,
+ ObjectName = "users",
+ Privileges = ["SELECT", "INSERT"],
+ Roles = [appAdmin],
+ RunAs = owner,
+ }
+ ),
+ new GrantPrivilegesOperation(
+ new PostgresGrantDefinition
+ {
+ Schema = schema,
+ Target = PostgresGrantTarget.Table,
+ ObjectName = "users",
+ Privileges = ["SELECT"],
+ Roles = [appUser],
+ RunAs = owner,
+ }
+ ),
+ ];
+
+ private static bool HasSchemaPrivilege(
+ NpgsqlConnection connection,
+ string role,
+ string schema,
+ string privilege
+ ) =>
+ ScalarBool(
+ connection,
+ "SELECT has_schema_privilege(@role, @schema, @privilege)",
+ ("role", role),
+ ("schema", schema),
+ ("privilege", privilege)
+ );
+
+ private static bool HasTablePrivilege(
+ NpgsqlConnection connection,
+ string role,
+ string schema,
+ string table,
+ string privilege
+ ) =>
+ ScalarBool(
+ connection,
+ "SELECT has_table_privilege(@role, @table, @privilege)",
+ ("role", role),
+ ("table", $"{schema}.{table}"),
+ ("privilege", privilege)
+ );
+
+ private static bool ScalarBool(
+ NpgsqlConnection connection,
+ string sql,
+ params (string Name, string Value)[] parameters
+ )
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ foreach (var parameter in parameters)
+ {
+ command.Parameters.AddWithValue(parameter.Name, parameter.Value);
+ }
+ return command.ExecuteScalar() is true;
+ }
+
+ private static void Exec(NpgsqlConnection connection, string sql)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = sql;
+ command.ExecuteNonQuery();
+ }
+
+ private static NpgsqlConnection OpenRoleConnection(
+ NpgsqlConnection adminConnection,
+ string role
+ )
+ {
+ var connectionString = new NpgsqlConnectionStringBuilder(adminConnection.ConnectionString)
+ {
+ Username = role,
+ Password = "test",
+ Pooling = false,
+ }.ConnectionString;
+ var connection = new NpgsqlConnection(connectionString);
+ connection.Open();
+ return connection;
+ }
+
+ private static string Q(string identifier) =>
+ $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
+}
diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs
index e50fc4a..227bdbe 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs
@@ -469,6 +469,48 @@ FROM information_schema.columns
Assert.Contains("timestamp", columns["timestamp_col"]);
}
+ [Fact]
+ public void CreateTable_MixedCaseColumnCheckConstraint_PreservesIdentifierCase()
+ {
+ var schema = Schema
+ .Define("Test")
+ .Table(
+ "public",
+ "fhir_patient",
+ t =>
+ t.Column("id", PortableTypes.Uuid, c => c.PrimaryKey())
+ .Column(
+ "Gender",
+ PortableTypes.Text,
+ c => c.NotNull().Check("\"Gender\" IN ('male', 'female', 'other')")
+ )
+ )
+ .Build();
+
+ var current = (
+ (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger)
+ ).Value;
+ var operations = (
+ (OperationsResultOk)SchemaDiff.Calculate(current, schema, logger: _logger)
+ ).Value;
+
+ var result = MigrationRunner.Apply(
+ _connection,
+ operations,
+ PostgresDdlGenerator.Generate,
+ MigrationOptions.Default,
+ _logger
+ );
+
+ Assert.True(
+ result is MigrationApplyResultOk,
+ $"Migration failed: {(result as MigrationApplyResultError)?.Value}"
+ );
+ InsertPatientGender("male");
+ var ex = Assert.Throws(() => InsertPatientGender("invalid"));
+ Assert.Equal("23514", ex.SqlState);
+ }
+
[Fact]
public void ExpressionIndex_CreateWithLowerFunction_Success()
{
@@ -1610,4 +1652,14 @@ public void CreateTableWithVectorColumn_OpenAiLargeDim_Success()
+ "WHERE c.relname = 'large_embeddings' AND a.attname = 'embedding'";
Assert.Equal("vector(3072)", (string?)typeCmd.ExecuteScalar());
}
+
+ private void InsertPatientGender(string gender)
+ {
+ using var command = _connection.CreateCommand();
+ command.CommandText =
+ "INSERT INTO \"fhir_patient\" (\"id\", \"Gender\") VALUES (@id, @gender)";
+ command.Parameters.AddWithValue("@id", Guid.NewGuid());
+ command.Parameters.AddWithValue("@gender", gender);
+ command.ExecuteNonQuery();
+ }
}
diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportDdlTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportDdlTests.cs
index 8aeab7f..fa541d7 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportDdlTests.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportDdlTests.cs
@@ -84,6 +84,35 @@ public void Generate_GrantPrivileges_EmitsAllTablesInSchemaGrant()
);
}
+ [Fact]
+ public void Generate_GrantPrivileges_WithRunAs_WrapsGrantInLocalRole()
+ {
+ var ddl = PostgresDdlGenerator.Generate(
+ new GrantPrivilegesOperation(
+ new PostgresGrantDefinition
+ {
+ Schema = "auth",
+ Target = PostgresGrantTarget.Table,
+ ObjectName = "users",
+ Privileges = ["select", "insert"],
+ Roles = ["app_admin"],
+ RunAs = "supabase_admin",
+ }
+ )
+ );
+
+ Assert.Contains("pg_has_role(current_user, 'supabase_admin', 'MEMBER')", ddl);
+ Assert.Contains("MIG-E-PG-GRANT-RUN-AS-MISSING-MEMBERSHIP", ddl);
+ Assert.Contains("GRANT \"supabase_admin\" TO", ddl, StringComparison.Ordinal);
+ Assert.Contains("SET LOCAL ROLE \"supabase_admin\";", ddl, StringComparison.Ordinal);
+ Assert.Contains(
+ "GRANT SELECT, INSERT ON TABLE \"auth\".\"users\" TO \"app_admin\";",
+ ddl,
+ StringComparison.Ordinal
+ );
+ Assert.EndsWith("RESET ROLE", ddl, StringComparison.Ordinal);
+ }
+
[Fact]
public void Generate_RevokePrivileges_EmitsTableRevoke()
{
diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffSupportTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffSupportTests.cs
index d6551f9..2b71d9d 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffSupportTests.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffSupportTests.cs
@@ -72,6 +72,60 @@ public void Calculate_StaleManagedGrant_AllowDestructive_EmitsRevoke()
Assert.Contains(((OperationsResultOk)result).Value, op => op is RevokePrivilegesOperation);
}
+ [Fact]
+ public void Calculate_GrantRunAsAlreadyApplied_HasNoOperations()
+ {
+ var current = SupportSchema();
+ var desired = current with
+ {
+ Grants =
+ [
+ new PostgresGrantDefinition
+ {
+ Schema = "public",
+ Target = PostgresGrantTarget.AllTablesInSchema,
+ Privileges = ["SELECT", "INSERT"],
+ Roles = ["app_user"],
+ RunAs = "schema_owner",
+ },
+ ],
+ };
+
+ var result = SchemaDiff.Calculate(current, desired);
+
+ Assert.True(result is OperationsResultOk);
+ Assert.Empty(((OperationsResultOk)result).Value);
+ }
+
+ [Fact]
+ public void Calculate_MissingGrantRunAs_PreservesRunAsOnOperation()
+ {
+ var current = SupportSchema() with { Grants = [] };
+ var desired = SupportSchema() with
+ {
+ Grants =
+ [
+ new PostgresGrantDefinition
+ {
+ Schema = "auth",
+ Target = PostgresGrantTarget.Table,
+ ObjectName = "users",
+ Privileges = ["SELECT"],
+ Roles = ["app_user"],
+ RunAs = "supabase_admin",
+ },
+ ],
+ };
+
+ var result = SchemaDiff.Calculate(current, desired);
+
+ Assert.True(result is OperationsResultOk);
+ var grant = Assert.IsType(
+ Assert.Single(((OperationsResultOk)result).Value)
+ );
+ Assert.Equal("supabase_admin", grant.Grant.RunAs);
+ }
+
private static SchemaDefinition SupportSchema() =>
new()
{
diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaSupportYamlSerializerTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaSupportYamlSerializerTests.cs
index 015c113..1b6e5e6 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaSupportYamlSerializerTests.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaSupportYamlSerializerTests.cs
@@ -27,6 +27,12 @@ public void FromYaml_PostgresSupportObjects_Deserializes()
target: AllTablesInSchema
privileges: [SELECT, INSERT, UPDATE, DELETE]
roles: [app_user, app_admin]
+ - schema: auth
+ target: Table
+ objectName: users
+ privileges: [SELECT]
+ roles: [app_user]
+ runAs: supabase_admin
tables: []
""";
@@ -37,8 +43,9 @@ public void FromYaml_PostgresSupportObjects_Deserializes()
Assert.Single(schema.Functions);
Assert.Equal("is_member", schema.Functions[0].Name);
Assert.Equal(2, schema.Functions[0].Arguments.Count);
- Assert.Single(schema.Grants);
+ Assert.Equal(2, schema.Grants.Count);
Assert.Equal(PostgresGrantTarget.AllTablesInSchema, schema.Grants[0].Target);
+ Assert.Equal("supabase_admin", schema.Grants[1].RunAs);
}
[Fact]
@@ -66,4 +73,28 @@ public void ToYaml_PostgresSupportObjects_OmitsSemanticDefaults()
Assert.DoesNotContain("volatility: stable", yaml, StringComparison.Ordinal);
Assert.DoesNotContain("revokePublicExecute: true", yaml, StringComparison.Ordinal);
}
+
+ [Fact]
+ public void ToYaml_PostgresGrantRunAs_EmitsRunAs()
+ {
+ var schema = new SchemaDefinition
+ {
+ Name = "nap",
+ Grants =
+ [
+ new PostgresGrantDefinition
+ {
+ Schema = "auth",
+ Target = PostgresGrantTarget.Schema,
+ Privileges = ["USAGE"],
+ Roles = ["app_user", "app_admin"],
+ RunAs = "supabase_admin",
+ },
+ ],
+ };
+
+ var yaml = SchemaYamlSerializer.ToYaml(schema);
+
+ Assert.Contains("runAs: supabase_admin", yaml, StringComparison.Ordinal);
+ }
}