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]