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); + } }