Skip to content

Commit 3bdeecf

Browse files
Add grant runAs for Postgres grants (#47)
## Summary - add `grants[].runAs` for PostgreSQL support grants and YAML round-trip support - wrap runAs grants with a membership guard plus `SET LOCAL ROLE` / `RESET ROLE` so migration roles can apply grants owned by another schema role - preserve grant diff idempotency and add NAP-shaped Supabase auth grant E2E coverage - add mixed-case CHECK constraint regression coverage for the existing PostgreSQL identifier path Fixes #46 Refs #18 ## Validation - `dotnet test Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj --configuration Release --verbosity minimal` (484 passed) - `make lint`
1 parent b4b652e commit 3bdeecf

7 files changed

Lines changed: 398 additions & 2 deletions

File tree

Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ public sealed record PostgresGrantDefinition
121121

122122
/// <summary>Roles receiving the privileges.</summary>
123123
public IReadOnlyList<string> Roles { get; init; } = [];
124+
125+
/// <summary>
126+
/// PostgreSQL role used only while applying this grant. Useful when a migration role
127+
/// is a member of the schema owner role but is not itself the schema owner.
128+
/// </summary>
129+
public string? RunAs { get; init; }
124130
}
125131

126132
/// <summary>

Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportDdlGenerator.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ private static string GenerateCreateOrReplaceFunction(CreateOrReplaceFunctionOpe
8484
}
8585

8686
private static string GenerateGrantPrivileges(PostgresGrantDefinition grant) =>
87-
$"GRANT {PrivilegeList(grant.Privileges)} ON {GrantTarget(grant)} TO {QuoteIdentList(grant.Roles)}";
87+
WithGrantRunAs(
88+
grant,
89+
$"GRANT {PrivilegeList(grant.Privileges)} ON {GrantTarget(grant)} TO {QuoteIdentList(grant.Roles)}"
90+
);
8891

8992
private static string GenerateRevokePrivileges(PostgresGrantDefinition grant) =>
9093
$"REVOKE {PrivilegeList(grant.Privileges)} ON {GrantTarget(grant)} FROM {QuoteIdentList(grant.Roles)}";
@@ -130,6 +133,31 @@ private static string FunctionBody(PostgresFunctionDefinition function)
130133
private static string FunctionSignature(PostgresFunctionDefinition function) =>
131134
$"{QuoteIdent(function.Schema)}.{QuoteIdent(function.Name)}({string.Join(", ", function.Arguments.Select(a => a.Type))})";
132135

136+
private static string WithGrantRunAs(PostgresGrantDefinition grant, string ddl)
137+
{
138+
var runAs = grant.RunAs?.Trim();
139+
return string.IsNullOrWhiteSpace(runAs)
140+
? ddl
141+
: $"""
142+
{GrantRunAsMembershipGuard(runAs)}
143+
SET LOCAL ROLE {QuoteIdent(runAs)};
144+
{ddl};
145+
RESET ROLE
146+
""";
147+
}
148+
149+
private static string GrantRunAsMembershipGuard(string runAs) =>
150+
$"""
151+
DO $$
152+
BEGIN
153+
IF NOT pg_has_role(current_user, {QuoteLiteral(runAs)}, 'MEMBER') THEN
154+
RAISE EXCEPTION 'MIG-E-PG-GRANT-RUN-AS-MISSING-MEMBERSHIP: connecting role "%" cannot SET LOCAL ROLE {QuoteIdent(
155+
runAs
156+
)}; run GRANT {QuoteIdent(runAs)} TO "%"', current_user, current_user;
157+
END IF;
158+
END $$;
159+
""";
160+
133161
private static string GrantTarget(PostgresGrantDefinition grant) =>
134162
grant.Target switch
135163
{
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
namespace Nimblesite.DataProvider.Migration.Tests;
2+
3+
[Collection(PostgresTestSuite.Name)]
4+
public sealed class PostgresGrantRunAsE2ETests(PostgresContainerFixture fixture)
5+
{
6+
[Fact]
7+
public void GrantRunAs_AppliesNapAuthShapeGrantsThroughSchemaOwner()
8+
{
9+
var suffix = Guid.NewGuid().ToString("N")[..8];
10+
var owner = $"grant_owner_{suffix}";
11+
var migrate = $"grant_migrate_{suffix}";
12+
var appUser = $"grant_app_user_{suffix}";
13+
var appAdmin = $"grant_app_admin_{suffix}";
14+
var schema = $"auth_{suffix}";
15+
16+
using var connection = fixture.CreateDatabase("grant_run_as");
17+
Exec(connection, $"CREATE ROLE {Q(owner)} NOLOGIN");
18+
Exec(connection, $"CREATE ROLE {Q(migrate)} LOGIN PASSWORD 'test'");
19+
Exec(connection, $"CREATE ROLE {Q(appUser)} NOLOGIN");
20+
Exec(connection, $"CREATE ROLE {Q(appAdmin)} NOLOGIN");
21+
Exec(connection, $"GRANT {Q(owner)} TO {Q("test")}");
22+
Exec(connection, $"GRANT {Q(owner)} TO {Q(migrate)}");
23+
Exec(connection, $"GRANT CONNECT ON DATABASE {Q(connection.Database)} TO {Q(migrate)}");
24+
Exec(connection, $"CREATE SCHEMA {Q(schema)} AUTHORIZATION {Q(owner)}");
25+
Exec(
26+
connection,
27+
$"SET ROLE {Q(owner)}; CREATE TABLE {Q(schema)}.{Q("users")}(id uuid PRIMARY KEY); RESET ROLE"
28+
);
29+
using var migrateConnection = OpenRoleConnection(connection, migrate);
30+
31+
var result = MigrationRunner.Apply(
32+
migrateConnection,
33+
NapAuthGrants(schema, owner, appUser, appAdmin),
34+
PostgresDdlGenerator.Generate,
35+
MigrationOptions.Default,
36+
NullLogger.Instance
37+
);
38+
39+
Assert.True(result is MigrationApplyResultOk);
40+
Assert.True(HasSchemaPrivilege(connection, appUser, schema, "USAGE"));
41+
Assert.True(HasSchemaPrivilege(connection, appAdmin, schema, "USAGE"));
42+
Assert.True(HasTablePrivilege(connection, appUser, schema, "users", "SELECT"));
43+
Assert.True(HasTablePrivilege(connection, appAdmin, schema, "users", "INSERT"));
44+
}
45+
46+
[Fact]
47+
public void GrantRunAs_MissingRoleMembership_ReturnsClearGrantToMessage()
48+
{
49+
var suffix = Guid.NewGuid().ToString("N")[..8];
50+
var owner = $"grant_owner_{suffix}";
51+
var migrate = $"grant_migrate_{suffix}";
52+
var appUser = $"grant_app_user_{suffix}";
53+
54+
using var connection = fixture.CreateDatabase("grant_run_as_missing");
55+
Exec(connection, $"CREATE ROLE {Q(owner)} NOLOGIN");
56+
Exec(connection, $"CREATE ROLE {Q(migrate)} LOGIN PASSWORD 'test'");
57+
Exec(connection, $"CREATE ROLE {Q(appUser)} NOLOGIN");
58+
Exec(connection, $"GRANT CONNECT ON DATABASE {Q(connection.Database)} TO {Q(migrate)}");
59+
using var migrateConnection = OpenRoleConnection(connection, migrate);
60+
61+
var result = MigrationRunner.Apply(
62+
migrateConnection,
63+
[
64+
new GrantPrivilegesOperation(
65+
new PostgresGrantDefinition
66+
{
67+
Schema = "public",
68+
Target = PostgresGrantTarget.Schema,
69+
Privileges = ["USAGE"],
70+
Roles = [appUser],
71+
RunAs = owner,
72+
}
73+
),
74+
],
75+
PostgresDdlGenerator.Generate,
76+
MigrationOptions.Default,
77+
NullLogger.Instance
78+
);
79+
80+
Assert.True(result is MigrationApplyResultError);
81+
var error = ((MigrationApplyResultError)result).Value;
82+
Assert.Contains("MIG-E-PG-GRANT-RUN-AS-MISSING-MEMBERSHIP", error.Message);
83+
Assert.Contains($"GRANT \"{owner}\" TO \"{migrate}\"", error.Message);
84+
}
85+
86+
private static IReadOnlyList<SchemaOperation> NapAuthGrants(
87+
string schema,
88+
string owner,
89+
string appUser,
90+
string appAdmin
91+
) =>
92+
[
93+
new GrantPrivilegesOperation(
94+
new PostgresGrantDefinition
95+
{
96+
Schema = schema,
97+
Target = PostgresGrantTarget.Schema,
98+
Privileges = ["USAGE"],
99+
Roles = [appUser, appAdmin],
100+
RunAs = owner,
101+
}
102+
),
103+
new GrantPrivilegesOperation(
104+
new PostgresGrantDefinition
105+
{
106+
Schema = schema,
107+
Target = PostgresGrantTarget.Table,
108+
ObjectName = "users",
109+
Privileges = ["SELECT", "INSERT"],
110+
Roles = [appAdmin],
111+
RunAs = owner,
112+
}
113+
),
114+
new GrantPrivilegesOperation(
115+
new PostgresGrantDefinition
116+
{
117+
Schema = schema,
118+
Target = PostgresGrantTarget.Table,
119+
ObjectName = "users",
120+
Privileges = ["SELECT"],
121+
Roles = [appUser],
122+
RunAs = owner,
123+
}
124+
),
125+
];
126+
127+
private static bool HasSchemaPrivilege(
128+
NpgsqlConnection connection,
129+
string role,
130+
string schema,
131+
string privilege
132+
) =>
133+
ScalarBool(
134+
connection,
135+
"SELECT has_schema_privilege(@role, @schema, @privilege)",
136+
("role", role),
137+
("schema", schema),
138+
("privilege", privilege)
139+
);
140+
141+
private static bool HasTablePrivilege(
142+
NpgsqlConnection connection,
143+
string role,
144+
string schema,
145+
string table,
146+
string privilege
147+
) =>
148+
ScalarBool(
149+
connection,
150+
"SELECT has_table_privilege(@role, @table, @privilege)",
151+
("role", role),
152+
("table", $"{schema}.{table}"),
153+
("privilege", privilege)
154+
);
155+
156+
private static bool ScalarBool(
157+
NpgsqlConnection connection,
158+
string sql,
159+
params (string Name, string Value)[] parameters
160+
)
161+
{
162+
using var command = connection.CreateCommand();
163+
command.CommandText = sql;
164+
foreach (var parameter in parameters)
165+
{
166+
command.Parameters.AddWithValue(parameter.Name, parameter.Value);
167+
}
168+
return command.ExecuteScalar() is true;
169+
}
170+
171+
private static void Exec(NpgsqlConnection connection, string sql)
172+
{
173+
using var command = connection.CreateCommand();
174+
command.CommandText = sql;
175+
command.ExecuteNonQuery();
176+
}
177+
178+
private static NpgsqlConnection OpenRoleConnection(
179+
NpgsqlConnection adminConnection,
180+
string role
181+
)
182+
{
183+
var connectionString = new NpgsqlConnectionStringBuilder(adminConnection.ConnectionString)
184+
{
185+
Username = role,
186+
Password = "test",
187+
Pooling = false,
188+
}.ConnectionString;
189+
var connection = new NpgsqlConnection(connectionString);
190+
connection.Open();
191+
return connection;
192+
}
193+
194+
private static string Q(string identifier) =>
195+
$"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
196+
}

Migration/Nimblesite.DataProvider.Migration.Tests/PostgresMigrationTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,48 @@ FROM information_schema.columns
469469
Assert.Contains("timestamp", columns["timestamp_col"]);
470470
}
471471

472+
[Fact]
473+
public void CreateTable_MixedCaseColumnCheckConstraint_PreservesIdentifierCase()
474+
{
475+
var schema = Schema
476+
.Define("Test")
477+
.Table(
478+
"public",
479+
"fhir_patient",
480+
t =>
481+
t.Column("id", PortableTypes.Uuid, c => c.PrimaryKey())
482+
.Column(
483+
"Gender",
484+
PortableTypes.Text,
485+
c => c.NotNull().Check("\"Gender\" IN ('male', 'female', 'other')")
486+
)
487+
)
488+
.Build();
489+
490+
var current = (
491+
(SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public", _logger)
492+
).Value;
493+
var operations = (
494+
(OperationsResultOk)SchemaDiff.Calculate(current, schema, logger: _logger)
495+
).Value;
496+
497+
var result = MigrationRunner.Apply(
498+
_connection,
499+
operations,
500+
PostgresDdlGenerator.Generate,
501+
MigrationOptions.Default,
502+
_logger
503+
);
504+
505+
Assert.True(
506+
result is MigrationApplyResultOk,
507+
$"Migration failed: {(result as MigrationApplyResultError)?.Value}"
508+
);
509+
InsertPatientGender("male");
510+
var ex = Assert.Throws<PostgresException>(() => InsertPatientGender("invalid"));
511+
Assert.Equal("23514", ex.SqlState);
512+
}
513+
472514
[Fact]
473515
public void ExpressionIndex_CreateWithLowerFunction_Success()
474516
{
@@ -1610,4 +1652,14 @@ public void CreateTableWithVectorColumn_OpenAiLargeDim_Success()
16101652
+ "WHERE c.relname = 'large_embeddings' AND a.attname = 'embedding'";
16111653
Assert.Equal("vector(3072)", (string?)typeCmd.ExecuteScalar());
16121654
}
1655+
1656+
private void InsertPatientGender(string gender)
1657+
{
1658+
using var command = _connection.CreateCommand();
1659+
command.CommandText =
1660+
"INSERT INTO \"fhir_patient\" (\"id\", \"Gender\") VALUES (@id, @gender)";
1661+
command.Parameters.AddWithValue("@id", Guid.NewGuid());
1662+
command.Parameters.AddWithValue("@gender", gender);
1663+
command.ExecuteNonQuery();
1664+
}
16131665
}

Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportDdlTests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,35 @@ public void Generate_GrantPrivileges_EmitsAllTablesInSchemaGrant()
8484
);
8585
}
8686

87+
[Fact]
88+
public void Generate_GrantPrivileges_WithRunAs_WrapsGrantInLocalRole()
89+
{
90+
var ddl = PostgresDdlGenerator.Generate(
91+
new GrantPrivilegesOperation(
92+
new PostgresGrantDefinition
93+
{
94+
Schema = "auth",
95+
Target = PostgresGrantTarget.Table,
96+
ObjectName = "users",
97+
Privileges = ["select", "insert"],
98+
Roles = ["app_admin"],
99+
RunAs = "supabase_admin",
100+
}
101+
)
102+
);
103+
104+
Assert.Contains("pg_has_role(current_user, 'supabase_admin', 'MEMBER')", ddl);
105+
Assert.Contains("MIG-E-PG-GRANT-RUN-AS-MISSING-MEMBERSHIP", ddl);
106+
Assert.Contains("GRANT \"supabase_admin\" TO", ddl, StringComparison.Ordinal);
107+
Assert.Contains("SET LOCAL ROLE \"supabase_admin\";", ddl, StringComparison.Ordinal);
108+
Assert.Contains(
109+
"GRANT SELECT, INSERT ON TABLE \"auth\".\"users\" TO \"app_admin\";",
110+
ddl,
111+
StringComparison.Ordinal
112+
);
113+
Assert.EndsWith("RESET ROLE", ddl, StringComparison.Ordinal);
114+
}
115+
87116
[Fact]
88117
public void Generate_RevokePrivileges_EmitsTableRevoke()
89118
{

0 commit comments

Comments
 (0)