Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ public sealed record PostgresGrantDefinition

/// <summary>Roles receiving the privileges.</summary>
public IReadOnlyList<string> Roles { get; init; } = [];

/// <summary>
/// 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.
/// </summary>
public string? RunAs { get; init; }
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}";
Expand Down Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SchemaOperation> 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)}\"";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<PostgresException>(() => InsertPatientGender("invalid"));
Assert.Equal("23514", ex.SqlState);
}

[Fact]
public void ExpressionIndex_CreateWithLowerFunction_Success()
{
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading
Loading