diff --git a/Directory.Build.props b/Directory.Build.props
index 4380af0..1a040cb 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -4,7 +4,7 @@
false
disabled
false
- 0.9.7-beta
+ 0.9.8-beta
ChristianFindlay
Nimblesite
MIT
diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/MigrationRunner.cs b/Migration/Nimblesite.DataProvider.Migration.Core/MigrationRunner.cs
index 2184c43..a604504 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Core/MigrationRunner.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Core/MigrationRunner.cs
@@ -111,6 +111,8 @@ is DropTableOperation
or DropColumnOperation
or DropIndexOperation
or DropForeignKeyOperation
+ or DropFunctionOperation
+ or RevokePrivilegesOperation
or DropRlsPolicyOperation
or DisableRlsOperation
or DisableForceRlsOperation;
diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs
index 87ad6c8..28da82a 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDefinition.cs
@@ -10,6 +10,126 @@ public sealed record SchemaDefinition
/// Tables in this schema.
public IReadOnlyList Tables { get; init; } = [];
+
+ ///
+ /// PostgreSQL roles managed by this schema. Implements [RLS-PG-SUPPORT-DDL].
+ ///
+ public IReadOnlyList Roles { get; init; } = [];
+
+ ///
+ /// PostgreSQL helper functions managed by this schema. Implements [RLS-PG-SUPPORT-DDL].
+ ///
+ public IReadOnlyList Functions { get; init; } = [];
+
+ ///
+ /// PostgreSQL grants managed by this schema. Implements [RLS-PG-SUPPORT-DDL].
+ ///
+ public IReadOnlyList Grants { get; init; } = [];
+}
+
+///
+/// PostgreSQL role definition for migration-managed application roles.
+/// Implements [RLS-PG-SUPPORT-DDL].
+///
+public sealed record PostgresRoleDefinition
+{
+ /// Role name.
+ public string Name { get; init; } = string.Empty;
+
+ /// Whether the role can log in directly.
+ public bool Login { get; init; }
+
+ /// Whether the role can bypass row-level security.
+ public bool BypassRls { get; init; }
+
+ /// Roles or users that receive membership in this role.
+ public IReadOnlyList GrantTo { get; init; } = [];
+}
+
+///
+/// PostgreSQL SQL-language function definition for RLS helper functions.
+/// Implements [RLS-PG-SUPPORT-DDL].
+///
+public sealed record PostgresFunctionDefinition
+{
+ /// Function schema.
+ public string Schema { get; init; } = "public";
+
+ /// Function name.
+ public string Name { get; init; } = string.Empty;
+
+ /// Function arguments in declaration order.
+ public IReadOnlyList Arguments { get; init; } = [];
+
+ /// PostgreSQL return type, such as uuid or boolean.
+ public string Returns { get; init; } = "void";
+
+ /// Function language. NAP RLS helpers use sql.
+ public string Language { get; init; } = "sql";
+
+ /// PostgreSQL volatility keyword: volatile, stable, or immutable.
+ public string Volatility { get; init; } = "stable";
+
+ /// Whether to emit SECURITY DEFINER.
+ public bool SecurityDefiner { get; init; }
+
+ /// Function body placed between PostgreSQL dollar quotes.
+ public string Body { get; init; } = string.Empty;
+
+ /// Roles granted EXECUTE on this function.
+ public IReadOnlyList ExecuteRoles { get; init; } = [];
+
+ /// Whether PUBLIC execute must be revoked.
+ public bool RevokePublicExecute { get; init; } = true;
+}
+
+///
+/// PostgreSQL function argument definition.
+///
+public sealed record PostgresFunctionArgumentDefinition
+{
+ /// Argument name. Optional for inspected function identities.
+ public string Name { get; init; } = string.Empty;
+
+ /// PostgreSQL argument type.
+ public string Type { get; init; } = string.Empty;
+}
+
+///
+/// PostgreSQL grant definition for schema and table privileges.
+/// Implements [RLS-PG-SUPPORT-DDL].
+///
+public sealed record PostgresGrantDefinition
+{
+ /// Target schema.
+ public string Schema { get; init; } = "public";
+
+ /// Grant target kind.
+ public PostgresGrantTarget Target { get; init; } = PostgresGrantTarget.Table;
+
+ /// Table name when is .
+ public string? ObjectName { get; init; }
+
+ /// Privileges to grant, such as USAGE, SELECT, or INSERT.
+ public IReadOnlyList Privileges { get; init; } = [];
+
+ /// Roles receiving the privileges.
+ public IReadOnlyList Roles { get; init; } = [];
+}
+
+///
+/// PostgreSQL grant target kind.
+///
+public enum PostgresGrantTarget
+{
+ /// Target is a schema.
+ Schema,
+
+ /// Target is one table.
+ Table,
+
+ /// Target is every current table in the schema.
+ AllTablesInSchema,
}
///
diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.Support.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.Support.cs
new file mode 100644
index 0000000..9cb95e3
--- /dev/null
+++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.Support.cs
@@ -0,0 +1,272 @@
+namespace Nimblesite.DataProvider.Migration.Core;
+
+public static partial class SchemaDiff
+{
+ private static bool IsSupportCleanupOperation(SchemaOperation operation) =>
+ operation is DropFunctionOperation or RevokePrivilegesOperation;
+
+ private static IEnumerable CalculateRoleDiff(
+ SchemaDefinition current,
+ SchemaDefinition desired,
+ ILogger? logger
+ )
+ {
+ var currentRoles = current.Roles.ToDictionary(
+ r => r.Name,
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ foreach (var desiredRole in desired.Roles)
+ {
+ if (
+ !currentRoles.TryGetValue(desiredRole.Name, out var currentRole)
+ || RoleNeedsChange(currentRole, desiredRole)
+ )
+ {
+ logger?.LogDebug("Role {Role} will be created or altered", desiredRole.Name);
+ yield return new CreateOrAlterRoleOperation(desiredRole);
+ }
+ }
+ }
+
+ private static bool RoleNeedsChange(
+ PostgresRoleDefinition current,
+ PostgresRoleDefinition desired
+ )
+ {
+ var currentGrantTo = current.GrantTo.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ return current.Login != desired.Login
+ || current.BypassRls != desired.BypassRls
+ || desired.GrantTo.Any(g => !currentGrantTo.Contains(g));
+ }
+
+ private static IEnumerable CalculateFunctionDiff(
+ SchemaDefinition current,
+ SchemaDefinition desired,
+ bool allowDestructive,
+ ILogger? logger
+ )
+ {
+ var currentFunctions = current.Functions.ToDictionary(
+ FunctionKey,
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ foreach (var desiredFunction in desired.Functions)
+ {
+ var key = FunctionKey(desiredFunction);
+ if (
+ !currentFunctions.TryGetValue(key, out var currentFunction)
+ || FunctionNeedsChange(currentFunction, desiredFunction)
+ )
+ {
+ logger?.LogDebug(
+ "Function {Schema}.{Function} will be created or replaced",
+ desiredFunction.Schema,
+ desiredFunction.Name
+ );
+ yield return new CreateOrReplaceFunctionOperation(desiredFunction);
+ }
+ }
+
+ if (!allowDestructive)
+ {
+ yield break;
+ }
+
+ var desiredKeys = desired
+ .Functions.Select(FunctionKey)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var currentFunction in current.Functions)
+ {
+ if (!desiredKeys.Contains(FunctionKey(currentFunction)))
+ {
+ logger?.LogWarning(
+ "Function {Schema}.{Function} will be DROPPED",
+ currentFunction.Schema,
+ currentFunction.Name
+ );
+ yield return new DropFunctionOperation(
+ currentFunction.Schema,
+ currentFunction.Name,
+ currentFunction.Arguments.Select(a => a.Type).ToList()
+ );
+ }
+ }
+ }
+
+ private static bool FunctionNeedsChange(
+ PostgresFunctionDefinition current,
+ PostgresFunctionDefinition desired
+ )
+ {
+ var currentExecuteRoles = current.ExecuteRoles.ToHashSet(StringComparer.OrdinalIgnoreCase);
+ return !SameSqlToken(current.Returns, desired.Returns)
+ || !SameSqlToken(current.Language, desired.Language)
+ || !SameSqlToken(current.Volatility, desired.Volatility)
+ || current.SecurityDefiner != desired.SecurityDefiner
+ || current.RevokePublicExecute != desired.RevokePublicExecute
+ || current.Body.Trim() != desired.Body.Trim()
+ || !currentExecuteRoles.SetEquals(desired.ExecuteRoles);
+ }
+
+ private static string FunctionKey(PostgresFunctionDefinition function) =>
+ string.Join(
+ "|",
+ [
+ function.Schema.ToLowerInvariant(),
+ function.Name.ToLowerInvariant(),
+ .. function.Arguments.Select(a => a.Type.Trim().ToLowerInvariant()),
+ ]
+ );
+
+ private static bool SameSqlToken(string left, string right) =>
+ string.Equals(left.Trim(), right.Trim(), StringComparison.OrdinalIgnoreCase);
+
+ private static IEnumerable CalculateGrantDiff(
+ SchemaDefinition current,
+ SchemaDefinition desired,
+ bool allowDestructive,
+ ILogger? logger
+ )
+ {
+ var currentKeys = ExpandGrantKeys(current.Grants, current.Tables).ToHashSet();
+
+ foreach (var desiredGrant in desired.Grants)
+ {
+ var desiredKeys = ExpandGrantKeys([desiredGrant], desired.Tables).ToList();
+ if (desiredKeys.Count == 0 || desiredKeys.Any(k => !currentKeys.Contains(k)))
+ {
+ logger?.LogDebug(
+ "Grant on {Schema} {Target} will be applied",
+ desiredGrant.Schema,
+ desiredGrant.Target
+ );
+ yield return new GrantPrivilegesOperation(desiredGrant);
+ }
+ }
+
+ if (!allowDestructive)
+ {
+ yield break;
+ }
+
+ var managedRoles = desired
+ .Grants.SelectMany(g => g.Roles)
+ .Select(r => r.ToLowerInvariant())
+ .ToHashSet();
+ var desiredGrantKeys = ExpandGrantKeys(desired.Grants, desired.Tables).ToHashSet();
+
+ foreach (var currentKey in currentKeys)
+ {
+ if (managedRoles.Contains(currentKey.Role) && !desiredGrantKeys.Contains(currentKey))
+ {
+ logger?.LogWarning(
+ "Grant {Privilege} on {Schema}.{ObjectName} to {Role} will be REVOKED",
+ currentKey.Privilege,
+ currentKey.Schema,
+ currentKey.ObjectName,
+ currentKey.Role
+ );
+ yield return new RevokePrivilegesOperation(currentKey.ToGrant());
+ }
+ }
+ }
+
+ private static IEnumerable ExpandGrantKeys(
+ IEnumerable grants,
+ IReadOnlyList tables
+ )
+ {
+ foreach (var grant in grants)
+ {
+ foreach (var privilege in grant.Privileges)
+ {
+ foreach (var role in grant.Roles)
+ {
+ foreach (var key in ExpandGrantKey(grant, tables, privilege, role))
+ {
+ yield return key;
+ }
+ }
+ }
+ }
+ }
+
+ private static IEnumerable ExpandGrantKey(
+ PostgresGrantDefinition grant,
+ IReadOnlyList tables,
+ string privilege,
+ string role
+ )
+ {
+ if (grant.Target == PostgresGrantTarget.Schema)
+ {
+ yield return PostgresGrantKey.Create(
+ grant.Schema,
+ PostgresGrantTarget.Schema,
+ null,
+ role,
+ privilege
+ );
+ yield break;
+ }
+
+ if (grant.Target == PostgresGrantTarget.Table && grant.ObjectName is string objectName)
+ {
+ yield return PostgresGrantKey.Create(
+ grant.Schema,
+ PostgresGrantTarget.Table,
+ objectName,
+ role,
+ privilege
+ );
+ yield break;
+ }
+
+ foreach (var table in tables.Where(t => SameSqlToken(t.Schema, grant.Schema)))
+ {
+ yield return PostgresGrantKey.Create(
+ grant.Schema,
+ PostgresGrantTarget.Table,
+ table.Name,
+ role,
+ privilege
+ );
+ }
+ }
+
+ private sealed record PostgresGrantKey(
+ string Schema,
+ PostgresGrantTarget Target,
+ string? ObjectName,
+ string Role,
+ string Privilege
+ )
+ {
+ public static PostgresGrantKey Create(
+ string schema,
+ PostgresGrantTarget target,
+ string? objectName,
+ string role,
+ string privilege
+ ) =>
+ new(
+ schema.ToLowerInvariant(),
+ target,
+ objectName?.ToLowerInvariant(),
+ role.ToLowerInvariant(),
+ privilege.ToUpperInvariant()
+ );
+
+ public PostgresGrantDefinition ToGrant() =>
+ new()
+ {
+ Schema = Schema,
+ Target = Target,
+ ObjectName = ObjectName,
+ Privileges = [Privilege],
+ Roles = [Role],
+ };
+ }
+}
diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.cs
index 4fbe3f8..458c3e0 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaDiff.cs
@@ -31,7 +31,7 @@ namespace Nimblesite.DataProvider.Migration.Core;
/// currentSchema, desiredSchema, allowDestructive: true);
///
///
-public static class SchemaDiff
+public static partial class SchemaDiff
{
///
/// Calculate operations needed to transform current schema into desired schema.
@@ -52,6 +52,9 @@ public static OperationsResult Calculate(
try
{
var operations = new List();
+ var rlsOperations = new List();
+
+ operations.AddRange(CalculateRoleDiff(current, desired, logger));
// Use table name only for matching (schema-agnostic comparison)
// This handles differences between SQLite (main) and Postgres (public) default schemas
@@ -86,7 +89,7 @@ public static OperationsResult Calculate(
);
}
- operations.AddRange(
+ rlsOperations.AddRange(
CalculateRlsDiff(null, desiredTable, allowDestructive, logger)
);
}
@@ -119,7 +122,7 @@ public static OperationsResult Calculate(
);
operations.AddRange(fkOps);
- operations.AddRange(
+ rlsOperations.AddRange(
CalculateRlsDiff(currentTable, desiredTable, allowDestructive, logger)
);
}
@@ -151,6 +154,16 @@ public static OperationsResult Calculate(
}
}
+ var functionOps = CalculateFunctionDiff(current, desired, allowDestructive, logger)
+ .ToList();
+ var grantOps = CalculateGrantDiff(current, desired, allowDestructive, logger).ToList();
+
+ operations.AddRange(functionOps.Where(op => !IsSupportCleanupOperation(op)));
+ operations.AddRange(grantOps.Where(op => !IsSupportCleanupOperation(op)));
+ operations.AddRange(rlsOperations);
+ operations.AddRange(functionOps.Where(IsSupportCleanupOperation));
+ operations.AddRange(grantOps.Where(IsSupportCleanupOperation));
+
return new OperationsResult.Ok, MigrationError>(
operations.AsReadOnly()
);
diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaOperation.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaOperation.cs
index 345f567..9f24132 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaOperation.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaOperation.cs
@@ -56,6 +56,26 @@ public sealed record AddUniqueConstraintOperation(
UniqueConstraintDefinition UniqueConstraint
) : SchemaOperation;
+// ═══════════════════════════════════════════════════════════════════
+// POSTGRES SUPPORT OBJECTS - Implements [RLS-PG-SUPPORT-DDL]
+// ═══════════════════════════════════════════════════════════════════
+
+///
+/// Create or alter a PostgreSQL role and its declared membership grants.
+///
+public sealed record CreateOrAlterRoleOperation(PostgresRoleDefinition Role) : SchemaOperation;
+
+///
+/// Create or replace a PostgreSQL function and declared EXECUTE grants.
+///
+public sealed record CreateOrReplaceFunctionOperation(PostgresFunctionDefinition Function)
+ : SchemaOperation;
+
+///
+/// Apply a PostgreSQL schema or table grant.
+///
+public sealed record GrantPrivilegesOperation(PostgresGrantDefinition Grant) : SchemaOperation;
+
// ═══════════════════════════════════════════════════════════════════
// ROW-LEVEL SECURITY - Implements [RLS-CORE-OPS]
// ═══════════════════════════════════════════════════════════════════
@@ -107,6 +127,20 @@ public sealed record DropIndexOperation(string Schema, string TableName, string
public sealed record DropForeignKeyOperation(string Schema, string TableName, string ConstraintName)
: SchemaOperation;
+///
+/// Drop a PostgreSQL function. DESTRUCTIVE - requires explicit opt-in.
+///
+public sealed record DropFunctionOperation(
+ string Schema,
+ string Name,
+ IReadOnlyList ArgumentTypes
+) : SchemaOperation;
+
+///
+/// Revoke a PostgreSQL schema or table grant. DESTRUCTIVE - requires explicit opt-in.
+///
+public sealed record RevokePrivilegesOperation(PostgresGrantDefinition Grant) : SchemaOperation;
+
///
/// Drop a row-level security policy. DESTRUCTIVE - requires explicit opt-in.
///
diff --git a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs
index 59ac1a2..87e425c 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Core/SchemaYamlSerializer.cs
@@ -18,6 +18,7 @@ public static class SchemaYamlSerializer
.WithTypeConverter(new PortableTypeYamlConverter())
.WithTypeConverter(new ForeignKeyActionYamlConverter())
.WithTypeConverter(new RlsOperationYamlConverter())
+ .WithTypeConverter(new PostgresGrantTargetYamlConverter())
.ConfigureDefaultValuesHandling(
DefaultValuesHandling.OmitDefaults
| DefaultValuesHandling.OmitNull
@@ -34,7 +35,18 @@ public static class SchemaYamlSerializer
.WithTypeConverter(new PortableTypeYamlConverter())
.WithTypeConverter(new ForeignKeyActionYamlConverter())
.WithTypeConverter(new RlsOperationYamlConverter())
+ .WithTypeConverter(new PostgresGrantTargetYamlConverter())
.WithTypeMapping, List>()
+ .WithTypeMapping, List>()
+ .WithTypeMapping<
+ IReadOnlyList,
+ List
+ >()
+ .WithTypeMapping<
+ IReadOnlyList,
+ List
+ >()
+ .WithTypeMapping, List>()
.WithTypeMapping, List>()
.WithTypeMapping, List>()
.WithTypeMapping, List>()
@@ -314,6 +326,37 @@ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializ
}
}
+///
+/// YAML type converter for the enum.
+/// Implements [RLS-PG-SUPPORT-DDL].
+///
+internal sealed class PostgresGrantTargetYamlConverter : IYamlTypeConverter
+{
+ ///
+ public bool Accepts(Type type) => type == typeof(PostgresGrantTarget);
+
+ ///
+ public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
+ {
+ var scalar = parser.Consume();
+ return scalar.Value.ToUpperInvariant() switch
+ {
+ "SCHEMA" => PostgresGrantTarget.Schema,
+ "TABLE" => PostgresGrantTarget.Table,
+ "ALLTABLESINSCHEMA" or "ALL_TABLES_IN_SCHEMA" or "ALL TABLES IN SCHEMA" =>
+ PostgresGrantTarget.AllTablesInSchema,
+ _ => PostgresGrantTarget.Table,
+ };
+ }
+
+ ///
+ public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer)
+ {
+ var target = (PostgresGrantTarget)(value ?? PostgresGrantTarget.Table);
+ emitter.Emit(new Scalar(target.ToString()));
+ }
+}
+
///
/// Filters out properties that have their semantic default values.
/// This handles cases where the property initializer differs from the type default.
@@ -340,6 +383,10 @@ internal sealed class PropertyDefaultValueFilter(IObjectGraphVisitor n
// RlsPolicySetDefinition / RlsPolicyDefinition semantic defaults
{ "enabled", (typeof(bool), true) },
{ "isPermissive", (typeof(bool), true) },
+ // PostgreSQL support object semantic defaults
+ { "language", (typeof(string), "sql") },
+ { "volatility", (typeof(string), "stable") },
+ { "revokePublicExecute", (typeof(bool), true) },
};
///
diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs
index b859adc..34b5f97 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresDdlGenerator.cs
@@ -13,7 +13,7 @@ public sealed record MigrationResult(bool Success, int TablesCreated, IReadOnlyL
///
/// PostgreSQL DDL generator for schema operations.
///
-public static class PostgresDdlGenerator
+public static partial class PostgresDdlGenerator
{
///
/// Migrate a schema definition to PostgreSQL, creating all tables.
@@ -105,6 +105,9 @@ public static string Generate(SchemaOperation operation) =>
AddForeignKeyOperation op => GenerateAddForeignKey(op),
AddCheckConstraintOperation op => GenerateAddCheckConstraint(op),
AddUniqueConstraintOperation op => GenerateAddUniqueConstraint(op),
+ CreateOrAlterRoleOperation op => GenerateCreateOrAlterRole(op),
+ CreateOrReplaceFunctionOperation op => GenerateCreateOrReplaceFunction(op),
+ GrantPrivilegesOperation op => GenerateGrantPrivileges(op.Grant),
DropTableOperation op =>
$"DROP TABLE IF EXISTS \"{op.Schema}\".\"{op.TableName}\" CASCADE",
DropColumnOperation op =>
@@ -112,6 +115,8 @@ public static string Generate(SchemaOperation operation) =>
DropIndexOperation op => $"DROP INDEX IF EXISTS \"{op.Schema}\".\"{op.IndexName}\"",
DropForeignKeyOperation op =>
$"ALTER TABLE \"{op.Schema}\".\"{op.TableName}\" DROP CONSTRAINT \"{op.ConstraintName}\"",
+ DropFunctionOperation op => GenerateDropFunction(op),
+ RevokePrivilegesOperation op => GenerateRevokePrivileges(op.Grant),
EnableRlsOperation op =>
$"ALTER TABLE \"{op.Schema}\".\"{op.TableName}\" ENABLE ROW LEVEL SECURITY",
DisableRlsOperation op =>
diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSchemaInspector.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSchemaInspector.cs
index 368d744..12b1fdc 100644
--- a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSchemaInspector.cs
+++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSchemaInspector.cs
@@ -3,7 +3,7 @@ namespace Nimblesite.DataProvider.Migration.Postgres;
///
/// Inspects PostgreSQL database schema and returns a SchemaDefinition.
///
-public static class PostgresSchemaInspector
+public static partial class PostgresSchemaInspector
{
///
/// Inspect all tables in a PostgreSQL database.
@@ -62,7 +62,17 @@ tableResult is TableResult.Error tableError
}
return new SchemaResult.Ok(
- new SchemaDefinition { Name = schemaName, Tables = tables.AsReadOnly() }
+ new SchemaDefinition
+ {
+ Name = schemaName,
+ Tables = tables.AsReadOnly(),
+ Roles = PostgresSupportSchemaInspector.InspectRoles(connection),
+ Functions = PostgresSupportSchemaInspector.InspectFunctions(
+ connection,
+ schemaName
+ ),
+ Grants = PostgresSupportSchemaInspector.InspectGrants(connection, schemaName),
+ }
);
}
catch (Exception ex)
diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportDdlGenerator.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportDdlGenerator.cs
new file mode 100644
index 0000000..c9ffaa6
--- /dev/null
+++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportDdlGenerator.cs
@@ -0,0 +1,124 @@
+using System.Globalization;
+
+namespace Nimblesite.DataProvider.Migration.Postgres;
+
+public static partial class PostgresDdlGenerator
+{
+ private static string GenerateCreateOrAlterRole(CreateOrAlterRoleOperation op)
+ {
+ var roleName = QuoteIdent(op.Role.Name);
+ var roleLiteral = QuoteLiteral(op.Role.Name);
+ var login = op.Role.Login ? "LOGIN" : "NOLOGIN";
+ var bypass = op.Role.BypassRls ? "BYPASSRLS" : "NOBYPASSRLS";
+ var sb = new StringBuilder();
+
+ sb.AppendLine("DO $$");
+ sb.AppendLine("BEGIN");
+ sb.AppendLine(
+ CultureInfo.InvariantCulture,
+ $" IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = {roleLiteral}) THEN"
+ );
+ sb.AppendLine(
+ CultureInfo.InvariantCulture,
+ $" CREATE ROLE {roleName} {login} {bypass};"
+ );
+ sb.AppendLine(" END IF;");
+ sb.AppendLine("END $$;");
+ sb.Append(CultureInfo.InvariantCulture, $"ALTER ROLE {roleName} {login} {bypass}");
+
+ foreach (var grantee in op.Role.GrantTo)
+ {
+ sb.AppendLine(";");
+ sb.Append(CultureInfo.InvariantCulture, $"GRANT {roleName} TO {QuoteIdent(grantee)}");
+ }
+
+ return sb.ToString();
+ }
+
+ private static string GenerateCreateOrReplaceFunction(CreateOrReplaceFunctionOperation op)
+ {
+ var function = op.Function;
+ var functionName = $"{QuoteIdent(function.Schema)}.{QuoteIdent(function.Name)}";
+ var argumentDeclarations = string.Join(
+ ", ",
+ function.Arguments.Select(ArgumentDeclaration)
+ );
+ var signature = FunctionSignature(function);
+ var sb = new StringBuilder();
+
+ sb.AppendLine(
+ CultureInfo.InvariantCulture,
+ $"CREATE OR REPLACE FUNCTION {functionName}({argumentDeclarations})"
+ );
+ sb.AppendLine(CultureInfo.InvariantCulture, $"RETURNS {function.Returns}");
+ sb.AppendLine(CultureInfo.InvariantCulture, $"LANGUAGE {function.Language}");
+ sb.AppendLine(function.Volatility.ToUpperInvariant());
+ if (function.SecurityDefiner)
+ {
+ sb.AppendLine("SECURITY DEFINER");
+ }
+ sb.AppendLine("AS $function$");
+ sb.AppendLine(function.Body.Trim());
+ sb.Append("$function$");
+
+ if (function.RevokePublicExecute)
+ {
+ sb.AppendLine(";");
+ sb.Append(
+ CultureInfo.InvariantCulture,
+ $"REVOKE EXECUTE ON FUNCTION {signature} FROM PUBLIC"
+ );
+ }
+
+ if (function.ExecuteRoles.Count > 0)
+ {
+ sb.AppendLine(";");
+ sb.Append(
+ CultureInfo.InvariantCulture,
+ $"GRANT EXECUTE ON FUNCTION {signature} TO {QuoteIdentList(function.ExecuteRoles)}"
+ );
+ }
+
+ return sb.ToString();
+ }
+
+ private static string GenerateGrantPrivileges(PostgresGrantDefinition 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)}";
+
+ private static string GenerateDropFunction(DropFunctionOperation op) =>
+ $"DROP FUNCTION IF EXISTS {QuoteIdent(op.Schema)}.{QuoteIdent(op.Name)}({string.Join(", ", op.ArgumentTypes)})";
+
+ private static string ArgumentDeclaration(PostgresFunctionArgumentDefinition argument) =>
+ string.IsNullOrWhiteSpace(argument.Name)
+ ? argument.Type
+ : $"{QuoteIdent(argument.Name)} {argument.Type}";
+
+ private static string FunctionSignature(PostgresFunctionDefinition function) =>
+ $"{QuoteIdent(function.Schema)}.{QuoteIdent(function.Name)}({string.Join(", ", function.Arguments.Select(a => a.Type))})";
+
+ private static string GrantTarget(PostgresGrantDefinition grant) =>
+ grant.Target switch
+ {
+ PostgresGrantTarget.Schema => $"SCHEMA {QuoteIdent(grant.Schema)}",
+ PostgresGrantTarget.Table when grant.ObjectName is string objectName =>
+ $"TABLE {QuoteIdent(grant.Schema)}.{QuoteIdent(objectName)}",
+ PostgresGrantTarget.AllTablesInSchema =>
+ $"ALL TABLES IN SCHEMA {QuoteIdent(grant.Schema)}",
+ _ => $"TABLE {QuoteIdent(grant.Schema)}.{QuoteIdent(string.Empty)}",
+ };
+
+ private static string PrivilegeList(IReadOnlyList privileges) =>
+ string.Join(", ", privileges.Select(p => p.Trim().ToUpperInvariant()));
+
+ private static string QuoteIdentList(IReadOnlyList identifiers) =>
+ string.Join(", ", identifiers.Select(QuoteIdent));
+
+ private static string QuoteIdent(string identifier) =>
+ $"\"{identifier.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
+
+ private static string QuoteLiteral(string value) =>
+ $"'{value.Replace("'", "''", StringComparison.Ordinal)}'";
+}
diff --git a/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportSchemaInspector.cs b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportSchemaInspector.cs
new file mode 100644
index 0000000..1da3dbb
--- /dev/null
+++ b/Migration/Nimblesite.DataProvider.Migration.Postgres/PostgresSupportSchemaInspector.cs
@@ -0,0 +1,233 @@
+using System.Collections.ObjectModel;
+
+namespace Nimblesite.DataProvider.Migration.Postgres;
+
+internal static class PostgresSupportSchemaInspector
+{
+ public static IReadOnlyList InspectRoles(NpgsqlConnection connection)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT
+ r.rolname,
+ r.rolcanlogin,
+ r.rolbypassrls,
+ COALESCE(
+ array_agg(m.rolname ORDER BY m.rolname)
+ FILTER (WHERE m.rolname IS NOT NULL),
+ ARRAY[]::text[]
+ ) AS grant_to
+ FROM pg_roles r
+ LEFT JOIN pg_auth_members am ON am.roleid = r.oid
+ LEFT JOIN pg_roles m ON m.oid = am.member
+ WHERE r.rolname NOT LIKE 'pg\_%' ESCAPE '\'
+ GROUP BY r.rolname, r.rolcanlogin, r.rolbypassrls
+ ORDER BY r.rolname
+ """;
+
+ using var reader = command.ExecuteReader();
+ var roles = new List();
+ while (reader.Read())
+ {
+ roles.Add(
+ new PostgresRoleDefinition
+ {
+ Name = reader.GetString(0),
+ Login = reader.GetBoolean(1),
+ BypassRls = reader.GetBoolean(2),
+ GrantTo = reader.GetValue(3) as string[] ?? [],
+ }
+ );
+ }
+ return roles.AsReadOnly();
+ }
+
+ public static IReadOnlyList InspectFunctions(
+ NpgsqlConnection connection,
+ string schemaName
+ )
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT
+ n.nspname,
+ p.proname,
+ COALESCE(p.proargnames, ARRAY[]::text[]) AS arg_names,
+ ARRAY(
+ SELECT format_type(arg_type, NULL)
+ FROM unnest(p.proargtypes) WITH ORDINALITY AS a(arg_type, ord)
+ ORDER BY ord
+ ) AS arg_types,
+ pg_get_function_result(p.oid) AS returns,
+ l.lanname,
+ CASE p.provolatile
+ WHEN 'i' THEN 'immutable'
+ WHEN 's' THEN 'stable'
+ ELSE 'volatile'
+ END AS volatility,
+ p.prosecdef,
+ p.prosrc,
+ NOT EXISTS (
+ SELECT 1
+ FROM aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) acl
+ WHERE acl.grantee = 0 AND acl.privilege_type = 'EXECUTE'
+ ) AS revoke_public_execute,
+ COALESCE(
+ ARRAY(
+ SELECT grantee.rolname
+ FROM aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) acl
+ JOIN pg_roles grantee ON grantee.oid = acl.grantee
+ WHERE acl.privilege_type = 'EXECUTE'
+ AND acl.grantee <> p.proowner
+ ORDER BY grantee.rolname
+ ),
+ ARRAY[]::text[]
+ ) AS execute_roles
+ FROM pg_proc p
+ JOIN pg_namespace n ON n.oid = p.pronamespace
+ JOIN pg_language l ON l.oid = p.prolang
+ WHERE n.nspname = @schema
+ ORDER BY n.nspname, p.proname, p.oid
+ """;
+ command.Parameters.AddWithValue("@schema", schemaName);
+
+ using var reader = command.ExecuteReader();
+ var functions = new List();
+ while (reader.Read())
+ {
+ var argNames = reader.GetValue(2) as string[] ?? [];
+ var argTypes = reader.GetValue(3) as string[] ?? [];
+ functions.Add(
+ new PostgresFunctionDefinition
+ {
+ Schema = reader.GetString(0),
+ Name = reader.GetString(1),
+ Arguments = ToArguments(argNames, argTypes),
+ Returns = reader.GetString(4),
+ Language = reader.GetString(5),
+ Volatility = reader.GetString(6),
+ SecurityDefiner = reader.GetBoolean(7),
+ Body = reader.GetString(8),
+ RevokePublicExecute = reader.GetBoolean(9),
+ ExecuteRoles = reader.GetValue(10) as string[] ?? [],
+ }
+ );
+ }
+ return functions.AsReadOnly();
+ }
+
+ public static IReadOnlyList InspectGrants(
+ NpgsqlConnection connection,
+ string schemaName
+ )
+ {
+ var grants = new List();
+ grants.AddRange(InspectSchemaGrants(connection, schemaName));
+ grants.AddRange(InspectTableGrants(connection, schemaName));
+ return grants.AsReadOnly();
+ }
+
+ private static ReadOnlyCollection ToArguments(
+ string[] argNames,
+ string[] argTypes
+ ) =>
+ argTypes
+ .Select(
+ (argType, index) =>
+ new PostgresFunctionArgumentDefinition
+ {
+ Name = index < argNames.Length ? argNames[index] : string.Empty,
+ Type = argType,
+ }
+ )
+ .ToList()
+ .AsReadOnly();
+
+ private static ReadOnlyCollection InspectSchemaGrants(
+ NpgsqlConnection connection,
+ string schemaName
+ )
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT grantee.rolname, acl.privilege_type
+ FROM pg_namespace n
+ CROSS JOIN LATERAL aclexplode(COALESCE(n.nspacl, acldefault('n', n.nspowner))) acl
+ JOIN pg_roles grantee ON grantee.oid = acl.grantee
+ WHERE n.nspname = @schema
+ ORDER BY grantee.rolname, acl.privilege_type
+ """;
+ command.Parameters.AddWithValue("@schema", schemaName);
+ return ReadGrantRows(command, schemaName, PostgresGrantTarget.Schema, null);
+ }
+
+ private static ReadOnlyCollection InspectTableGrants(
+ NpgsqlConnection connection,
+ string schemaName
+ )
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT c.relname, grantee.rolname, acl.privilege_type
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ CROSS JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r', c.relowner))) acl
+ JOIN pg_roles grantee ON grantee.oid = acl.grantee
+ WHERE n.nspname = @schema
+ AND c.relkind IN ('r', 'p')
+ ORDER BY c.relname, grantee.rolname, acl.privilege_type
+ """;
+ command.Parameters.AddWithValue("@schema", schemaName);
+ return ReadGrantRows(command, schemaName, PostgresGrantTarget.Table, null);
+ }
+
+ private static ReadOnlyCollection ReadGrantRows(
+ NpgsqlCommand command,
+ string schemaName,
+ PostgresGrantTarget target,
+ string? objectName
+ )
+ {
+ using var reader = command.ExecuteReader();
+ var rows = new List();
+ while (reader.Read())
+ {
+ if (target == PostgresGrantTarget.Table)
+ {
+ rows.Add(
+ new PostgresGrantRow(
+ reader.GetString(0),
+ reader.GetString(1),
+ reader.GetString(2)
+ )
+ );
+ }
+ else
+ {
+ rows.Add(
+ new PostgresGrantRow(objectName, reader.GetString(0), reader.GetString(1))
+ );
+ }
+ }
+ return ToGrantDefinitions(schemaName, target, rows);
+ }
+
+ private static ReadOnlyCollection ToGrantDefinitions(
+ string schemaName,
+ PostgresGrantTarget target,
+ IReadOnlyList rows
+ ) =>
+ rows.GroupBy(r => new { r.ObjectName, r.Role })
+ .Select(g => new PostgresGrantDefinition
+ {
+ Schema = schemaName,
+ Target = target,
+ ObjectName = g.Key.ObjectName,
+ Roles = [g.Key.Role],
+ Privileges = g.Select(r => r.Privilege).Distinct().OrderBy(p => p).ToList(),
+ })
+ .ToList()
+ .AsReadOnly();
+
+ private sealed record PostgresGrantRow(string? ObjectName, string Role, string Privilege);
+}
diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportDdlTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportDdlTests.cs
new file mode 100644
index 0000000..8aeab7f
--- /dev/null
+++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportDdlTests.cs
@@ -0,0 +1,105 @@
+namespace Nimblesite.DataProvider.Migration.Tests;
+
+public sealed class PostgresSupportDdlTests
+{
+ [Fact]
+ public void Generate_CreateOrAlterRole_EmitsNoLoginNoBypassRlsAndMembershipGrant()
+ {
+ var ddl = PostgresDdlGenerator.Generate(
+ new CreateOrAlterRoleOperation(
+ new PostgresRoleDefinition { Name = "app_user", GrantTo = ["postgres"] }
+ )
+ );
+
+ Assert.Contains("CREATE ROLE \"app_user\" NOLOGIN NOBYPASSRLS;", ddl);
+ Assert.Contains("ALTER ROLE \"app_user\" NOLOGIN NOBYPASSRLS", ddl);
+ Assert.Contains("GRANT \"app_user\" TO \"postgres\"", ddl);
+ }
+
+ [Fact]
+ public void Generate_CreateOrReplaceFunction_EmitsSecurityDefinerAndExecuteGrants()
+ {
+ var ddl = PostgresDdlGenerator.Generate(
+ new CreateOrReplaceFunctionOperation(
+ new PostgresFunctionDefinition
+ {
+ Schema = "public",
+ Name = "is_member",
+ Arguments =
+ [
+ new PostgresFunctionArgumentDefinition
+ {
+ Name = "tenant_id",
+ Type = "uuid",
+ },
+ new PostgresFunctionArgumentDefinition { Name = "user_id", Type = "uuid" },
+ ],
+ Returns = "boolean",
+ SecurityDefiner = true,
+ Body = "SELECT true",
+ ExecuteRoles = ["app_user", "app_admin"],
+ }
+ )
+ );
+
+ Assert.Contains(
+ "CREATE OR REPLACE FUNCTION \"public\".\"is_member\"(\"tenant_id\" uuid, \"user_id\" uuid)",
+ ddl,
+ StringComparison.Ordinal
+ );
+ Assert.Contains("RETURNS boolean", ddl, StringComparison.Ordinal);
+ Assert.Contains("LANGUAGE sql", ddl, StringComparison.Ordinal);
+ Assert.Contains("STABLE", ddl, StringComparison.Ordinal);
+ Assert.Contains("SECURITY DEFINER", ddl, StringComparison.Ordinal);
+ Assert.Contains(
+ "REVOKE EXECUTE ON FUNCTION \"public\".\"is_member\"(uuid, uuid) FROM PUBLIC",
+ ddl,
+ StringComparison.Ordinal
+ );
+ Assert.Contains(
+ "GRANT EXECUTE ON FUNCTION \"public\".\"is_member\"(uuid, uuid) TO \"app_user\", \"app_admin\"",
+ ddl,
+ StringComparison.Ordinal
+ );
+ }
+
+ [Fact]
+ public void Generate_GrantPrivileges_EmitsAllTablesInSchemaGrant()
+ {
+ var ddl = PostgresDdlGenerator.Generate(
+ new GrantPrivilegesOperation(
+ new PostgresGrantDefinition
+ {
+ Schema = "public",
+ Target = PostgresGrantTarget.AllTablesInSchema,
+ Privileges = ["select", "insert", "update", "delete"],
+ Roles = ["app_user", "app_admin"],
+ }
+ )
+ );
+
+ Assert.Equal(
+ "GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA \"public\" TO \"app_user\", \"app_admin\"",
+ ddl
+ );
+ }
+
+ [Fact]
+ public void Generate_RevokePrivileges_EmitsTableRevoke()
+ {
+ var ddl = PostgresDdlGenerator.Generate(
+ new RevokePrivilegesOperation(
+ new PostgresGrantDefinition
+ {
+ Schema = "public",
+ Target = PostgresGrantTarget.Table,
+ ObjectName = "documents",
+ Privileges = ["select"],
+ Roles = ["app_user"],
+ }
+ )
+ );
+
+ Assert.Equal("REVOKE SELECT ON TABLE \"public\".\"documents\" FROM \"app_user\"", ddl);
+ }
+}
diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportE2ETests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportE2ETests.cs
new file mode 100644
index 0000000..9a49f08
--- /dev/null
+++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportE2ETests.cs
@@ -0,0 +1,312 @@
+using System.Globalization;
+
+namespace Nimblesite.DataProvider.Migration.Tests;
+
+[Collection(PostgresTestSuite.Name)]
+[System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Usage",
+ "CA1001:Types that own disposable fields should be disposable",
+ Justification = "Disposed via IAsyncLifetime.DisposeAsync"
+)]
+public sealed class PostgresSupportE2ETests(PostgresContainerFixture fixture) : IAsyncLifetime
+{
+ private NpgsqlConnection _connection = null!;
+ private readonly ILogger _logger = NullLogger.Instance;
+
+ public async Task InitializeAsync()
+ {
+ _connection = await fixture.CreateDatabaseAsync("support_objects").ConfigureAwait(false);
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _connection.DisposeAsync().ConfigureAwait(false);
+ }
+
+ [Fact]
+ public void DeclarativeRolesFunctionsAndGrants_UnblockNapStyleRls()
+ {
+ var suffix = Guid.NewGuid().ToString("N")[..8];
+ var names = SupportNames.Create(suffix);
+ var schema = SupportSchema(names);
+
+ Apply(schema);
+
+ var reappliedOps = (
+ (OperationsResultOk)
+ SchemaDiff.Calculate(
+ ((SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public")).Value,
+ schema,
+ logger: _logger
+ )
+ ).Value;
+ Assert.Empty(reappliedOps);
+
+ var tenantA = Guid.NewGuid();
+ var tenantB = Guid.NewGuid();
+ var userA = Guid.NewGuid();
+ SeedTenantData(tenantA, tenantB, userA);
+
+ using var tx = _connection.BeginTransaction();
+ SetAppRole(tx, names.AppUserRole, tenantA, userA);
+ Assert.Equal(1, CountVisibleDocuments(tx));
+ Assert.Throws(() => InsertDocument(tx, tenantB, "blocked"));
+ }
+
+ private void Apply(SchemaDefinition schema)
+ {
+ var current = (
+ (SchemaResultOk)PostgresSchemaInspector.Inspect(_connection, "public")
+ ).Value;
+ var ops = (
+ (OperationsResultOk)SchemaDiff.Calculate(current, schema, logger: _logger)
+ ).Value;
+ var apply = MigrationRunner.Apply(
+ _connection,
+ ops,
+ PostgresDdlGenerator.Generate,
+ MigrationOptions.Default,
+ _logger
+ );
+ Assert.True(
+ apply is MigrationApplyResultOk,
+ $"Migration failed: {(apply as MigrationApplyResultError)?.Value}"
+ );
+ }
+
+ private void SeedTenantData(Guid tenantA, Guid tenantB, Guid userA)
+ {
+ Exec(
+ $"INSERT INTO public.tenant_members(id, tenant_id, user_id, role) VALUES ('{Guid.NewGuid()}', '{tenantA}', '{userA}', 'writer')"
+ );
+ Exec(
+ $"INSERT INTO public.documents(id, tenant_id, title) VALUES ('{Guid.NewGuid()}', '{tenantA}', 'visible')"
+ );
+ Exec(
+ $"INSERT INTO public.documents(id, tenant_id, title) VALUES ('{Guid.NewGuid()}', '{tenantB}', 'hidden')"
+ );
+ }
+
+ private void SetAppRole(NpgsqlTransaction tx, string role, Guid tenant, Guid user)
+ {
+ Exec(tx, $"SET LOCAL ROLE {role}");
+ Exec(tx, $"SET LOCAL rls.tenant_id = '{tenant}'");
+ Exec(tx, $"SET LOCAL rls.user_id = '{user}'");
+ }
+
+ private int CountVisibleDocuments(NpgsqlTransaction tx)
+ {
+ using var command = _connection.CreateCommand();
+ command.Transaction = tx;
+ command.CommandText = "SELECT count(*) FROM public.documents";
+ return Convert.ToInt32(command.ExecuteScalar(), CultureInfo.InvariantCulture);
+ }
+
+ private void InsertDocument(NpgsqlTransaction tx, Guid tenant, string title) =>
+ Exec(
+ tx,
+ $"INSERT INTO public.documents(id, tenant_id, title) VALUES ('{Guid.NewGuid()}', '{tenant}', '{title}')"
+ );
+
+ private void Exec(string sql)
+ {
+ using var command = _connection.CreateCommand();
+ command.CommandText = sql;
+ command.ExecuteNonQuery();
+ }
+
+ private void Exec(NpgsqlTransaction tx, string sql)
+ {
+ using var command = _connection.CreateCommand();
+ command.Transaction = tx;
+ command.CommandText = sql;
+ command.ExecuteNonQuery();
+ }
+
+ private static SchemaDefinition SupportSchema(SupportNames names) =>
+ new()
+ {
+ Name = "support_objects",
+ Roles =
+ [
+ new PostgresRoleDefinition { Name = names.AppUserRole, GrantTo = ["test"] },
+ new PostgresRoleDefinition { Name = names.AppAdminRole, GrantTo = ["test"] },
+ ],
+ Tables = [TenantMembersTable(), DocumentsTable(names)],
+ Functions = SupportFunctions(names),
+ Grants =
+ [
+ new PostgresGrantDefinition
+ {
+ Schema = "public",
+ Target = PostgresGrantTarget.Schema,
+ Privileges = ["USAGE"],
+ Roles = [names.AppUserRole, names.AppAdminRole],
+ },
+ new PostgresGrantDefinition
+ {
+ Schema = "public",
+ Target = PostgresGrantTarget.AllTablesInSchema,
+ Privileges = ["SELECT", "INSERT", "UPDATE", "DELETE"],
+ Roles = [names.AppUserRole, names.AppAdminRole],
+ },
+ ],
+ };
+
+ private static TableDefinition TenantMembersTable() =>
+ new()
+ {
+ Schema = "public",
+ Name = "tenant_members",
+ Columns =
+ [
+ RequiredUuid("id"),
+ RequiredUuid("tenant_id"),
+ RequiredUuid("user_id"),
+ new ColumnDefinition
+ {
+ Name = "role",
+ Type = PortableTypes.Text,
+ IsNullable = false,
+ },
+ ],
+ PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] },
+ };
+
+ private static TableDefinition DocumentsTable(SupportNames names) =>
+ new()
+ {
+ Schema = "public",
+ Name = "documents",
+ Columns =
+ [
+ RequiredUuid("id"),
+ RequiredUuid("tenant_id"),
+ new ColumnDefinition
+ {
+ Name = "title",
+ Type = PortableTypes.Text,
+ IsNullable = false,
+ },
+ ],
+ PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] },
+ RowLevelSecurity = new RlsPolicySetDefinition
+ {
+ Forced = true,
+ Policies =
+ [
+ new RlsPolicyDefinition
+ {
+ Name = "documents_member",
+ Roles = [names.AppUserRole],
+ UsingSql =
+ $"tenant_id = {names.TenantFunction}() AND {names.MemberFunction}({names.TenantFunction}(), {names.UserFunction}())",
+ WithCheckSql =
+ $"tenant_id = {names.TenantFunction}() AND {names.MemberFunction}({names.TenantFunction}(), {names.UserFunction}())",
+ },
+ new RlsPolicyDefinition
+ {
+ Name = "documents_admin",
+ Roles = [names.AppAdminRole],
+ UsingSql = "true",
+ WithCheckSql = "true",
+ },
+ ],
+ },
+ };
+
+ private static IReadOnlyList SupportFunctions(SupportNames names) =>
+ [
+ Function(
+ names.TenantFunction,
+ "uuid",
+ "SELECT NULLIF(current_setting('rls.tenant_id', true), '')::uuid",
+ false,
+ names
+ ),
+ Function(
+ names.UserFunction,
+ "uuid",
+ "SELECT NULLIF(current_setting('rls.user_id', true), '')::uuid",
+ false,
+ names
+ ),
+ MembershipFunction(names.MemberFunction, "", names),
+ MembershipFunction(names.WriterFunction, "AND tm.role IN ('writer', 'owner')", names),
+ MembershipFunction(names.OwnerFunction, "AND tm.role = 'owner'", names),
+ ];
+
+ private static PostgresFunctionDefinition Function(
+ string name,
+ string returns,
+ string body,
+ bool securityDefiner,
+ SupportNames names
+ ) =>
+ new()
+ {
+ Name = name,
+ Returns = returns,
+ Body = body,
+ SecurityDefiner = securityDefiner,
+ ExecuteRoles = [names.AppUserRole, names.AppAdminRole],
+ };
+
+ private static PostgresFunctionDefinition MembershipFunction(
+ string name,
+ string roleClause,
+ SupportNames names
+ ) =>
+ Function(
+ name,
+ "boolean",
+ $"""
+ SELECT EXISTS (
+ SELECT 1
+ FROM public.tenant_members tm
+ WHERE tm.tenant_id = p_tenant_id
+ AND tm.user_id = p_user_id
+ {roleClause}
+ )
+ """,
+ true,
+ names
+ ) with
+ {
+ Arguments =
+ [
+ new PostgresFunctionArgumentDefinition { Name = "p_tenant_id", Type = "uuid" },
+ new PostgresFunctionArgumentDefinition { Name = "p_user_id", Type = "uuid" },
+ ],
+ };
+
+ private static ColumnDefinition RequiredUuid(string name) =>
+ new()
+ {
+ Name = name,
+ Type = PortableTypes.Uuid,
+ IsNullable = false,
+ };
+
+ private sealed record SupportNames(
+ string AppUserRole,
+ string AppAdminRole,
+ string TenantFunction,
+ string UserFunction,
+ string MemberFunction,
+ string WriterFunction,
+ string OwnerFunction
+ )
+ {
+ public static SupportNames Create(string suffix) =>
+ new(
+ $"dp_app_user_{suffix}",
+ $"dp_app_admin_{suffix}",
+ $"app_tenant_id_{suffix}",
+ $"app_user_id_{suffix}",
+ $"is_member_{suffix}",
+ $"is_tenant_writer_{suffix}",
+ $"is_tenant_owner_{suffix}"
+ );
+ }
+}
diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportReadbackE2ETests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportReadbackE2ETests.cs
new file mode 100644
index 0000000..2986599
--- /dev/null
+++ b/Migration/Nimblesite.DataProvider.Migration.Tests/PostgresSupportReadbackE2ETests.cs
@@ -0,0 +1,177 @@
+namespace Nimblesite.DataProvider.Migration.Tests;
+
+// Implements [RLS-PG-SUPPORT-DDL].
+
+///
+/// E2E coverage for PostgreSQL support-object inspection after real migrations.
+///
+[Collection(PostgresTestSuite.Name)]
+[System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Usage",
+ "CA1001:Types that own disposable fields should be disposable",
+ Justification = "Disposed via IAsyncLifetime.DisposeAsync"
+)]
+public sealed class PostgresSupportReadbackE2ETests(PostgresContainerFixture fixture)
+ : IAsyncLifetime
+{
+ private NpgsqlConnection? _connection;
+ private readonly ILogger _logger = NullLogger.Instance;
+
+ public async Task InitializeAsync()
+ {
+ _connection = await fixture
+ .CreateDatabaseAsync("pg_support_readback")
+ .ConfigureAwait(false);
+ }
+
+ public async Task DisposeAsync()
+ {
+ if (_connection is NpgsqlConnection connection)
+ {
+ await connection.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ [Fact]
+ public void SupportObjects_InspectAfterApply_DoesNotEmitSecondDiff()
+ {
+ if (_connection is not NpgsqlConnection connection)
+ {
+ Assert.Fail("PostgreSQL connection not initialized.");
+ return;
+ }
+
+ var desired = SupportSchema();
+ var current = Inspect(connection);
+ var operations = Diff(current, desired);
+
+ var apply = MigrationRunner.Apply(
+ connection,
+ operations,
+ PostgresDdlGenerator.Generate,
+ MigrationOptions.Default,
+ _logger
+ );
+
+ Assert.True(
+ apply is MigrationApplyResultOk,
+ $"Migration failed: {(apply as MigrationApplyResultError)?.Value}"
+ );
+
+ var inspected = Inspect(connection);
+ var secondDiff = Diff(inspected, desired);
+
+ Assert.Contains(
+ inspected.Roles,
+ role =>
+ role.Name == "nap_app_user"
+ && !role.Login
+ && !role.BypassRls
+ && role.GrantTo.Count == 0
+ );
+ Assert.Contains(
+ inspected.Functions,
+ function =>
+ function.Schema == "public"
+ && function.Name == "current_tenant_id"
+ && function.Returns == "uuid"
+ && function.Language == "sql"
+ && function.Volatility == "stable"
+ && function.SecurityDefiner
+ && function.RevokePublicExecute
+ && function.ExecuteRoles.SequenceEqual(["nap_app_user"])
+ );
+ Assert.Contains(
+ inspected.Grants,
+ grant =>
+ grant.Target == PostgresGrantTarget.Schema
+ && grant.Roles.SequenceEqual(["nap_app_user"])
+ && grant.Privileges.SequenceEqual(["USAGE"])
+ );
+ Assert.Contains(
+ inspected.Grants,
+ grant =>
+ grant.Target == PostgresGrantTarget.Table
+ && grant.ObjectName == "documents"
+ && grant.Roles.SequenceEqual(["nap_app_user"])
+ && grant.Privileges.SequenceEqual(["SELECT"])
+ );
+ Assert.DoesNotContain(secondDiff, op => op is CreateOrAlterRoleOperation);
+ Assert.DoesNotContain(secondDiff, op => op is CreateOrReplaceFunctionOperation);
+ Assert.DoesNotContain(secondDiff, op => op is GrantPrivilegesOperation);
+ }
+
+ private SchemaDefinition Inspect(NpgsqlConnection connection) =>
+ ((SchemaResultOk)PostgresSchemaInspector.Inspect(connection, "public", _logger)).Value;
+
+ private IReadOnlyList Diff(
+ SchemaDefinition current,
+ SchemaDefinition desired
+ ) => ((OperationsResultOk)SchemaDiff.Calculate(current, desired, logger: _logger)).Value;
+
+ private static SchemaDefinition SupportSchema() =>
+ new()
+ {
+ Name = "support_readback",
+ Roles =
+ [
+ new PostgresRoleDefinition
+ {
+ Name = "nap_app_user",
+ Login = false,
+ BypassRls = false,
+ },
+ ],
+ Functions =
+ [
+ new PostgresFunctionDefinition
+ {
+ Schema = "public",
+ Name = "current_tenant_id",
+ Returns = "uuid",
+ Language = "sql",
+ Volatility = "stable",
+ SecurityDefiner = true,
+ Body = "SELECT current_setting('app.tenant_id', true)::uuid",
+ ExecuteRoles = ["nap_app_user"],
+ RevokePublicExecute = true,
+ },
+ ],
+ Grants =
+ [
+ new PostgresGrantDefinition
+ {
+ Schema = "public",
+ Target = PostgresGrantTarget.Schema,
+ Privileges = ["USAGE"],
+ Roles = ["nap_app_user"],
+ },
+ new PostgresGrantDefinition
+ {
+ Schema = "public",
+ Target = PostgresGrantTarget.Table,
+ ObjectName = "documents",
+ Privileges = ["SELECT"],
+ Roles = ["nap_app_user"],
+ },
+ ],
+ Tables =
+ [
+ new TableDefinition
+ {
+ Schema = "public",
+ Name = "documents",
+ Columns =
+ [
+ new ColumnDefinition
+ {
+ Name = "id",
+ Type = new UuidType(),
+ IsNullable = false,
+ },
+ ],
+ PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] },
+ },
+ ],
+ };
+}
diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffSupportTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffSupportTests.cs
new file mode 100644
index 0000000..d6551f9
--- /dev/null
+++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaDiffSupportTests.cs
@@ -0,0 +1,133 @@
+namespace Nimblesite.DataProvider.Migration.Tests;
+
+public sealed class SchemaDiffSupportTests
+{
+ [Fact]
+ public void Calculate_DeclarativeSupportObjects_OrdersBeforeRlsPolicy()
+ {
+ var desired = SupportSchema();
+ var result = SchemaDiff.Calculate(Schema.Define("current").Build(), desired);
+
+ Assert.True(result is OperationsResultOk);
+ var ops = ((OperationsResultOk)result).Value;
+ Assert.Equal(
+ [
+ typeof(CreateOrAlterRoleOperation),
+ typeof(CreateTableOperation),
+ typeof(CreateOrReplaceFunctionOperation),
+ typeof(GrantPrivilegesOperation),
+ typeof(EnableRlsOperation),
+ typeof(CreateRlsPolicyOperation),
+ ],
+ ops.Select(o => o.GetType()).ToArray()
+ );
+ }
+
+ [Fact]
+ public void Calculate_SameSupportObjects_HasNoOperations()
+ {
+ var schema = SupportSchema();
+ var result = SchemaDiff.Calculate(schema, schema);
+
+ Assert.True(result is OperationsResultOk);
+ Assert.Empty(((OperationsResultOk)result).Value);
+ }
+
+ [Fact]
+ public void Calculate_RemovedFunction_RequiresAllowDestructive()
+ {
+ var current = SupportSchema();
+ var desired = current with { Functions = [] };
+
+ var safe = ((OperationsResultOk)SchemaDiff.Calculate(current, desired)).Value;
+ var destructive = (
+ (OperationsResultOk)SchemaDiff.Calculate(current, desired, allowDestructive: true)
+ ).Value;
+
+ Assert.DoesNotContain(safe, op => op is DropFunctionOperation);
+ Assert.Contains(destructive, op => op is DropFunctionOperation);
+ }
+
+ [Fact]
+ public void Calculate_StaleManagedGrant_AllowDestructive_EmitsRevoke()
+ {
+ var current = SupportSchema();
+ var desired = current with
+ {
+ Grants =
+ [
+ new PostgresGrantDefinition
+ {
+ Schema = "public",
+ Target = PostgresGrantTarget.AllTablesInSchema,
+ Privileges = ["SELECT"],
+ Roles = ["app_user"],
+ },
+ ],
+ };
+
+ var result = SchemaDiff.Calculate(current, desired, allowDestructive: true);
+
+ Assert.True(result is OperationsResultOk);
+ Assert.Contains(((OperationsResultOk)result).Value, op => op is RevokePrivilegesOperation);
+ }
+
+ private static SchemaDefinition SupportSchema() =>
+ new()
+ {
+ Name = "support",
+ Roles = [new PostgresRoleDefinition { Name = "app_user", GrantTo = ["postgres"] }],
+ Tables =
+ [
+ new TableDefinition
+ {
+ Schema = "public",
+ Name = "documents",
+ Columns =
+ [
+ new ColumnDefinition
+ {
+ Name = "id",
+ Type = PortableTypes.Uuid,
+ IsNullable = false,
+ },
+ ],
+ PrimaryKey = new PrimaryKeyDefinition { Columns = ["id"] },
+ RowLevelSecurity = new RlsPolicySetDefinition
+ {
+ Policies =
+ [
+ new RlsPolicyDefinition
+ {
+ Name = "documents_member",
+ Roles = ["app_user"],
+ UsingSql = "is_member()",
+ WithCheckSql = "is_member()",
+ },
+ ],
+ },
+ },
+ ],
+ Functions =
+ [
+ new PostgresFunctionDefinition
+ {
+ Name = "is_member",
+ Returns = "boolean",
+ SecurityDefiner = true,
+ Body = "SELECT true",
+ ExecuteRoles = ["app_user"],
+ },
+ ],
+ Grants =
+ [
+ new PostgresGrantDefinition
+ {
+ Schema = "public",
+ Target = PostgresGrantTarget.AllTablesInSchema,
+ Privileges = ["SELECT", "INSERT"],
+ Roles = ["app_user"],
+ },
+ ],
+ };
+}
diff --git a/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaSupportYamlSerializerTests.cs b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaSupportYamlSerializerTests.cs
new file mode 100644
index 0000000..015c113
--- /dev/null
+++ b/Migration/Nimblesite.DataProvider.Migration.Tests/SchemaSupportYamlSerializerTests.cs
@@ -0,0 +1,69 @@
+namespace Nimblesite.DataProvider.Migration.Tests;
+
+public sealed class SchemaSupportYamlSerializerTests
+{
+ [Fact]
+ public void FromYaml_PostgresSupportObjects_Deserializes()
+ {
+ var yaml = """
+ name: nap
+ roles:
+ - name: app_user
+ grantTo: [postgres]
+ functions:
+ - schema: public
+ name: is_member
+ arguments:
+ - name: tenant_id
+ type: uuid
+ - name: user_id
+ type: uuid
+ returns: boolean
+ securityDefiner: true
+ body: SELECT true
+ executeRoles: [app_user, app_admin]
+ grants:
+ - schema: public
+ target: AllTablesInSchema
+ privileges: [SELECT, INSERT, UPDATE, DELETE]
+ roles: [app_user, app_admin]
+ tables: []
+ """;
+
+ var schema = SchemaYamlSerializer.FromYaml(yaml);
+
+ Assert.Single(schema.Roles);
+ Assert.Equal("app_user", schema.Roles[0].Name);
+ 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(PostgresGrantTarget.AllTablesInSchema, schema.Grants[0].Target);
+ }
+
+ [Fact]
+ public void ToYaml_PostgresSupportObjects_OmitsSemanticDefaults()
+ {
+ var schema = new SchemaDefinition
+ {
+ Name = "nap",
+ Functions =
+ [
+ new PostgresFunctionDefinition
+ {
+ Name = "app_user_id",
+ Returns = "uuid",
+ Body = "SELECT NULL::uuid",
+ },
+ ],
+ };
+
+ var yaml = SchemaYamlSerializer.ToYaml(schema);
+
+ Assert.Contains("functions:", yaml, StringComparison.Ordinal);
+ Assert.Contains("name: app_user_id", yaml, StringComparison.Ordinal);
+ Assert.DoesNotContain("language: sql", yaml, StringComparison.Ordinal);
+ Assert.DoesNotContain("volatility: stable", yaml, StringComparison.Ordinal);
+ Assert.DoesNotContain("revokePublicExecute: true", yaml, StringComparison.Ordinal);
+ }
+}
diff --git a/docs/specs/rls-spec.md b/docs/specs/rls-spec.md
index c7e5dc9..c1a857a 100644
--- a/docs/specs/rls-spec.md
+++ b/docs/specs/rls-spec.md
@@ -216,6 +216,46 @@ CREATE POLICY "group_read_access" ON "public"."Documents"
Generation order per table: `EnableRlsOperation` always precedes `CreateRlsPolicyOperation`.
+### 7.1 Declarative Support Objects [RLS-PG-SUPPORT-DDL]
+
+PostgreSQL RLS schemas may declare helper roles, SQL functions, and grants at the top level of `schema.yaml`. This removes the need for application-side bootstrap SQL.
+
+```yaml
+roles:
+ - name: app_user
+ grantTo: [postgres]
+ - name: app_admin
+ grantTo: [postgres]
+functions:
+ - name: app_tenant_id
+ returns: uuid
+ body: SELECT NULLIF(current_setting('rls.tenant_id', true), '')::uuid
+ executeRoles: [app_user, app_admin]
+ - name: is_member
+ arguments:
+ - name: p_tenant_id
+ type: uuid
+ - name: p_user_id
+ type: uuid
+ returns: boolean
+ securityDefiner: true
+ body: |
+ SELECT EXISTS (
+ SELECT 1 FROM public.tenant_members tm
+ WHERE tm.tenant_id = p_tenant_id AND tm.user_id = p_user_id
+ )
+ executeRoles: [app_user, app_admin]
+grants:
+ - target: Schema
+ privileges: [USAGE]
+ roles: [app_user, app_admin]
+ - target: AllTablesInSchema
+ privileges: [SELECT, INSERT, UPDATE, DELETE]
+ roles: [app_user, app_admin]
+```
+
+Operation order is schema-safe: roles first, table DDL second, support functions third, table/schema grants fourth, RLS enable/policy DDL last. Function drops and grant revokes are destructive operations and require the same explicit destructive opt-in as table drops. Role drift is reconciled non-destructively: roles are created if missing, `NOLOGIN`/`NOBYPASSRLS` are reasserted by default, and declared membership grants are applied.
+
---
## 8. SQL Server Implementation [RLS-MSSQL]