From e626e73dd59d6620d8aa8af23837a3c6e5b60a1f Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 12:29:03 +0300 Subject: [PATCH 01/54] feat(policies): a second host may configure the same posture, and a path no database can compute is refused DW-16: DwPolicy.Configure takes a second call asking for what is already in force, inside the lock that does the configuring, so an integration suite starting many hosts over one composition root needs no check of its own. A different posture is still refused: the tier, the flags, the salt, the store failure mode, both intervals, every cap, the exposed catalogue and the provider types are all compared. The token vault, the service provider and the provider instances are not, since a second host builds its own. AddDwPolicies registers the posture in force rather than the instance it just built. The suite's own bootstrap drops the lock this feature replaces. DW-17: a path beneath a member with no fragment, whose leaf no database can compute, is refused in the strict tier where the provider used to throw. RowShape.Expresses reads the query the rows come from: an entity's model, a projection's initializers, and a member a projection copies from the entity. It answers false only where the whole set of members a container can produce is known, so rows in memory, a framework member the provider translates, a value beneath a converted column and a source the library cannot read are all left alone. The trace records the refusal, and the trace is now assigned before sanitizing so a refusal leaves it readable. D-3: Clone is public on Filter, Segment and Summary. A caller reading the same request again with another page rebuilt it around the caller's own clauses, which leaves both requests holding one condition tree. Co-Authored-By: Claude Opus 5 --- DynamicWhere.Tests/CloneTests.cs | 182 ++++++++++ .../Policies/ConfigureOnceTests.cs | 299 ++++++++++++++++ .../Policies/PolicyBootstrap.cs | 41 +++ .../Policies/PolicyEndpointTests.cs | 44 +-- .../Policies/UnexpressiblePathTests.cs | 325 ++++++++++++++++++ DynamicWhere.ex/Classes/Complex/Filter.cs | 13 +- DynamicWhere.ex/Classes/Complex/Segment.cs | 7 +- DynamicWhere.ex/Classes/Complex/Summary.cs | 7 +- DynamicWhere.ex/Policies/Config/DwPolicy.cs | 157 ++++++++- .../Policies/Config/DwPolicyConfiguration.cs | 17 +- .../Policies/Source/FilterSanitizer.cs | 86 ++++- .../Policies/Source/PolicyQueryable.cs | 16 +- DynamicWhere.ex/Policies/Source/RowShape.cs | 320 ++++++++++++++++- 13 files changed, 1461 insertions(+), 53 deletions(-) create mode 100644 DynamicWhere.Tests/CloneTests.cs create mode 100644 DynamicWhere.Tests/Policies/ConfigureOnceTests.cs create mode 100644 DynamicWhere.Tests/Policies/PolicyBootstrap.cs create mode 100644 DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs diff --git a/DynamicWhere.Tests/CloneTests.cs b/DynamicWhere.Tests/CloneTests.cs new file mode 100644 index 0000000..b369035 --- /dev/null +++ b/DynamicWhere.Tests/CloneTests.cs @@ -0,0 +1,182 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; + +namespace DynamicWhere.Tests +{ + /// + /// Clone on the three request types a caller builds. + /// + /// + /// Public since 3.3.0. A caller reading the same request again with one part changed — the next + /// page, another order — used to rebuild the request around the caller's own clauses, which + /// leaves both requests holding one condition tree. The bug that follows is the one the library + /// already avoids internally by cloning before it rewrites anything. + /// + public class CloneTests + { + private static Filter Filled() => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = "Name", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } + }, + SubConditionGroups = new List + { + new() + { + Conditions = + { + new Condition { Field = "Age", DataType = DataType.Number, Operator = Operator.GreaterThan, Values = { "1" } } + } + } + } + }, + Selects = new List { "Id", "Name" }, + Orders = new List { new() { Field = "Name", Direction = Direction.Ascending } }, + Page = new PageBy { PageNumber = 1, PageSize = 10 } + }; + + [Fact] + public void A_filter_clone_shares_nothing_with_the_original() + { + Filter original = Filled(); + Filter copy = original.Clone(); + + Assert.NotSame(original, copy); + Assert.NotSame(original.ConditionGroup, copy.ConditionGroup); + Assert.NotSame(original.ConditionGroup!.Conditions[0], copy.ConditionGroup!.Conditions[0]); + Assert.NotSame(original.ConditionGroup.SubConditionGroups![0], copy.ConditionGroup.SubConditionGroups![0]); + Assert.NotSame(original.Selects, copy.Selects); + Assert.NotSame(original.Orders, copy.Orders); + Assert.NotSame(original.Orders![0], copy.Orders![0]); + Assert.NotSame(original.Page, copy.Page); + } + + [Fact] + public void Changing_the_copy_leaves_the_caller_s_request_alone() + { + Filter original = Filled(); + Filter copy = original.Clone(); + + copy.Page!.PageNumber = 2; + copy.Orders![0].Direction = Direction.Descending; + copy.Selects!.Add("Age"); + copy.ConditionGroup!.Conditions[0].Values[0] = "b"; + + Assert.Equal(1, original.Page!.PageNumber); + Assert.Equal(Direction.Ascending, original.Orders![0].Direction); + Assert.Equal(2, original.Selects!.Count); + Assert.Equal("a", original.ConditionGroup!.Conditions[0].Values[0]); + } + + [Fact] + public void The_copy_carries_every_value() + { + Filter copy = Filled().Clone(); + + Assert.Equal("Name", copy.ConditionGroup!.Conditions[0].Field); + Assert.Equal("Age", copy.ConditionGroup.SubConditionGroups![0].Conditions[0].Field); + Assert.Equal(new[] { "Id", "Name" }, copy.Selects!); + Assert.Equal("Name", copy.Orders![0].Field); + Assert.Equal(10, copy.Page!.PageSize); + } + + [Fact] + public void A_branch_the_caller_left_null_stays_null() + { + Filter copy = new Filter().Clone(); + + Assert.Null(copy.ConditionGroup); + Assert.Null(copy.Selects); + Assert.Null(copy.Orders); + Assert.Null(copy.Page); + } + + [Fact] + public void A_segment_clone_copies_every_set() + { + Segment original = new() + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = "Name", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } + } + } + } + }, + Orders = new List { new() { Field = "Name", Direction = Direction.Ascending } }, + Page = new PageBy { PageNumber = 1, PageSize = 5 } + }; + + Segment copy = original.Clone(); + + copy.ConditionSets[0].ConditionGroup!.Conditions[0].Values[0] = "b"; + copy.Page!.PageSize = 50; + + Assert.NotSame(original.ConditionSets[0], copy.ConditionSets[0]); + Assert.Equal("a", original.ConditionSets[0].ConditionGroup!.Conditions[0].Values[0]); + Assert.Equal(5, original.Page!.PageSize); + } + + [Fact] + public void A_summary_clone_copies_the_having_clause_as_well() + { + Summary original = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = "Name", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } + } + }, + GroupBy = new GroupBy + { + Fields = new List { "Name" }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + }, + Having = new ConditionGroup + { + Conditions = + { + new Condition { Field = "Total", DataType = DataType.Number, Operator = Operator.GreaterThan, Values = { "1" } } + } + }, + Page = new PageBy { PageNumber = 1, PageSize = 5 } + }; + + Summary copy = original.Clone(); + + copy.Having!.Conditions[0].Values[0] = "9"; + copy.GroupBy!.Fields[0] = "Age"; + + Assert.NotSame(original.Having, copy.Having); + Assert.NotSame(original.GroupBy, copy.GroupBy); + Assert.Equal("1", original.Having!.Conditions[0].Values[0]); + Assert.Equal("Name", original.GroupBy!.Fields[0]); + } + + [Fact] + public void The_next_page_is_what_this_exists_for() + { + Filter caller = Filled(); + + Filter page2 = caller.Clone(); + page2.Page!.PageNumber = 2; + + Assert.Equal(1, caller.Page!.PageNumber); + Assert.Equal(2, page2.Page.PageNumber); + Assert.Equal(caller.Page.PageSize, page2.Page.PageSize); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs new file mode 100644 index 0000000..e3c2271 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs @@ -0,0 +1,299 @@ +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace DynamicWhere.Tests.Policies +{ + /// + /// A second DwPolicy.Configure asking for the posture already in force. + /// + /// + /// An integration suite starts many hosts over one composition root, and each one runs the + /// registration again. Refusing the second host made every such suite write its own + /// IsConfigured check, which is a check-then-act two hosts can both pass — so the package + /// answers it, inside the same lock that does the configuring. + /// + /// The posture in this process is whatever configured it first, so every case here builds its + /// candidate from DwPolicy.Options rather than from a fixed set of values. A case that + /// expects a refusal changes nothing: the refusal happens before anything is installed. + /// + /// + public sealed class ConfigureOnceTests + { + public ConfigureOnceTests() => Ensure(); + + /// + /// Configures the assembly's one posture, which every suite here asks for. + /// + /// + /// Calling it from each test is the feature under test: before 3.3.0 only the first caller + /// could do this, and the cases below all read what is in force rather than assuming a + /// posture of their own. + /// + private static void Ensure() => PolicyBootstrap.Ensure(); + + /// A posture holding exactly what is in force, value by value. + private static DwPolicyOptions Copy() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicyOptions copy = new() + { + Tier = inForce.Tier, + DryRun = inForce.DryRun, + IncludeTraceInResult = inForce.IncludeTraceInResult, + AuditRefusals = inForce.AuditRefusals, + StoreFailure = inForce.StoreFailure, + MaxSnapshotAge = inForce.MaxSnapshotAge, + RefreshInterval = inForce.RefreshInterval + }; + + if (!string.IsNullOrEmpty(inForce.HashSalt)) + { + copy.HashSalt = inForce.HashSalt; + } + + copy.Caps.MaxPageSize = inForce.Caps.MaxPageSize; + copy.Caps.DefaultPageSize = inForce.Caps.DefaultPageSize; + copy.Caps.MaxConditions = inForce.Caps.MaxConditions; + copy.Caps.MaxConditionDepth = inForce.Caps.MaxConditionDepth; + copy.Caps.MaxConditionSets = inForce.Caps.MaxConditionSets; + copy.Caps.MaxConditionValues = inForce.Caps.MaxConditionValues; + copy.Caps.MaxAggregates = inForce.Caps.MaxAggregates; + copy.Caps.MaxOrderFields = inForce.Caps.MaxOrderFields; + copy.Caps.MaxNavigationDepth = inForce.Caps.MaxNavigationDepth; + copy.Caps.MaxQueryCost = inForce.Caps.MaxQueryCost; + copy.Caps.DefaultFieldCost = inForce.Caps.DefaultFieldCost; + copy.Caps.MaxAuditEvents = inForce.Caps.MaxAuditEvents; + copy.Caps.SchemaDepth = inForce.Caps.SchemaDepth; + copy.Caps.SchemaCycleLimit = inForce.Caps.SchemaCycleLimit; + copy.Caps.MaxSchemaFields = inForce.Caps.MaxSchemaFields; + + if (inForce.Caps.IsMinGroupSizeSet) + { + copy.Caps.MinGroupSize = inForce.Caps.MinGroupSize; + } + + foreach (KeyValuePair exposed in inForce.Entities.Entities) + { + copy.Entities.Expose(exposed.Key, exposed.Value); + } + + return copy; + } + + [Fact] + public void The_same_posture_configured_again_changes_nothing() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicy.Configure(Copy()); + + Assert.Same(inForce, DwPolicy.Options); + Assert.True(DwPolicy.IsConfigured); + } + + [Fact] + public void The_posture_handed_to_a_second_call_is_frozen_rather_than_left_settable() + { + DwPolicyOptions second = Copy(); + + DwPolicy.Configure(second); + + Assert.True(second.IsFrozen); + Assert.Throws(() => second.Tier = DwTier.Convenience); + } + + [Fact] + public void A_different_tier_is_still_refused() + { + DwPolicyOptions different = Copy(); + + different.Tier = DwPolicy.Options.Tier == DwTier.Strict ? DwTier.Convenience : DwTier.Strict; + + Refused(different); + } + + [Fact] + public void A_different_cap_is_refused() + { + DwPolicyOptions different = Copy(); + + different.Caps.MaxPageSize = DwPolicy.Options.Caps.MaxPageSize - 1; + + Refused(different); + } + + [Fact] + public void A_different_group_floor_is_refused() + { + DwPolicyOptions different = Copy(); + + different.Caps.MinGroupSize = DwPolicy.Options.Caps.MinGroupSize == 7 ? 8 : 7; + + Refused(different); + } + + [Fact] + public void A_different_dry_run_flag_is_refused() + { + DwPolicyOptions different = Copy(); + + different.DryRun = !DwPolicy.Options.DryRun; + + Refused(different); + } + + [Fact] + public void A_different_refusal_audit_flag_is_refused() + { + DwPolicyOptions different = Copy(); + + different.AuditRefusals = !DwPolicy.Options.AuditRefusals; + + Refused(different); + } + + [Fact] + public void A_different_snapshot_age_is_refused() + { + DwPolicyOptions different = Copy(); + + different.MaxSnapshotAge = DwPolicy.Options.MaxSnapshotAge + TimeSpan.FromMinutes(1); + + Refused(different); + } + + [Fact] + public void A_different_refresh_interval_is_refused() + { + DwPolicyOptions different = Copy(); + + different.RefreshInterval = DwPolicy.Options.RefreshInterval + TimeSpan.FromSeconds(1); + + Refused(different); + } + + [Fact] + public void A_different_store_failure_mode_is_refused() + { + DwPolicyOptions different = Copy(); + + different.StoreFailure = DwPolicy.Options.StoreFailure == StoreFailureMode.FailClosed + ? StoreFailureMode.LastKnownGood + : StoreFailureMode.FailClosed; + + Refused(different); + } + + [Fact] + public void A_different_hash_salt_is_refused() + { + DwPolicyOptions different = Copy(); + + different.HashSalt = new string('s', DwPolicyOptions.MinimumHashSaltLength + 1); + + Refused(different); + } + + [Fact] + public void A_catalogue_exposing_one_more_type_is_refused() + { + DwPolicyOptions different = Copy(); + + different.Entities.Expose("configure-once-only-here"); + + Refused(different); + } + + [Fact] + public void A_catalogue_exposing_a_type_under_another_name_is_refused() + { + // The floor leg runs this suite alone, where nothing has exposed anything, so the + // difference has to be made rather than found. + DwPolicyOptions different = Copy(); + + if (DwPolicy.Options.Entities.IsEmpty) + { + different.Entities.Expose("under-one-name"); + + Refused(different); + + return; + } + + KeyValuePair first = DwPolicy.Options.Entities.Entities.First(); + + different = new DwPolicyOptions { Tier = DwPolicy.Options.Tier }; + + foreach (KeyValuePair exposed in DwPolicy.Options.Entities.Entities) + { + different.Entities.Expose( + exposed.Key, exposed.Key == first.Key ? $"{exposed.Value}-renamed" : exposed.Value); + } + + Refused(different); + } + + [Fact] + public void One_more_policy_source_is_refused() + { + Refused(Copy(), new FakePolicyProvider()); + } + + [Fact] + public async Task Many_hosts_starting_at_once_all_get_through() + { + // The race the package now answers: two hosts reading IsConfigured as false and both + // configuring. Nothing here takes a lock of its own, which is the point. + Task[] hosts = Enumerable + .Range(0, 16) + .Select(_ => Task.Run(() => DwPolicy.Configure(Copy()))) + .ToArray(); + + await Task.WhenAll(hosts); + + Assert.True(DwPolicy.IsConfigured); + } + + [Fact] + public void A_second_registration_hands_the_container_the_posture_in_force() + { + IServiceCollection services = new ServiceCollection(); + IConfiguration section = new ConfigurationBuilder().Build().GetSection("DynamicWhere:Policies"); + + services.AddDwPolicies(section, options => + { + options.Tier = DwPolicy.Options.Tier; + + foreach (KeyValuePair exposed in DwPolicy.Options.Entities.Entities) + { + options.Entities.Expose(exposed.Key, exposed.Value); + } + }); + + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Same(DwPolicy.Options, provider.GetRequiredService()); + } + + private static void Refused(DwPolicyOptions different, params IDwPolicyProvider[] providers) + { + DwPolicyOptions inForce = DwPolicy.Options; + + InvalidOperationException refusal = + Assert.Throws(() => DwPolicy.Configure(different, providers)); + + Assert.Contains("already configured", refusal.Message); + Assert.Same(inForce, DwPolicy.Options); + } + } + + /// A type nothing else exposes, so exposing it is a difference by itself. + public sealed class ConfigureOnceOnlyHere + { + public int Id { get; set; } + } +} diff --git a/DynamicWhere.Tests/Policies/PolicyBootstrap.cs b/DynamicWhere.Tests/Policies/PolicyBootstrap.cs new file mode 100644 index 0000000..d48dc8f --- /dev/null +++ b/DynamicWhere.Tests/Policies/PolicyBootstrap.cs @@ -0,0 +1,41 @@ +using DynamicWhere.ex.Policies.Config; + +namespace DynamicWhere.Tests.Policies +{ + /// + /// The one posture this test assembly configures, whichever suite asks for it first. + /// + /// + /// DwPolicy is process-wide, so a suite that configures a posture of its own decides what + /// every other suite in the run sees. Since 3.3.0 a second call asking for the same posture is a + /// no-op, which is what lets several suites call this without a lock — but only while they all + /// ask for the same posture, which is what this type is for. + /// + /// The demo API's Employee is exposed by name rather than by reference: the floor leg + /// drops the API project along with the rest of the EF Core 8 graph, and the endpoint suites + /// that need the type are dropped with it. + /// + /// + internal static class PolicyBootstrap + { + /// The name the endpoint suites address Staff by. + internal const string StaffName = "staff"; + + /// Configures the posture, or confirms the one already in force is it. + internal static void Ensure() + { + DwPolicyOptions options = new(); + + options.Entities + .Expose(StaffName) + .Expose(); + + if (Type.GetType("DynamicWhere.API.Models.Employee, DynamicWhere.API") is { } employee) + { + options.Entities.Expose(employee, "Employee"); + } + + DwPolicy.Configure(options); + } + } +} diff --git a/DynamicWhere.Tests/Policies/PolicyEndpointTests.cs b/DynamicWhere.Tests/Policies/PolicyEndpointTests.cs index 7efd234..d36b63f 100644 --- a/DynamicWhere.Tests/Policies/PolicyEndpointTests.cs +++ b/DynamicWhere.Tests/Policies/PolicyEndpointTests.cs @@ -39,38 +39,24 @@ public sealed class PolicyEndpointCollection /// internal static class PolicyEndpointHost { - internal const string Entity = "staff"; - - private static readonly object Bootstrap = new(); + internal const string Entity = PolicyBootstrap.StaffName; /// - /// Configures the process-wide policy once, because the endpoints read DwPolicy rather - /// than taking a posture per call — which is the whole point of that type: a posture a caller - /// can forget to pass at one call site is not a posture. + /// Configures the process-wide policy, because the endpoints read DwPolicy rather than + /// taking a posture per call — which is the whole point of that type: a posture a caller can + /// forget to pass at one call site is not a posture. /// - internal static void Configure() - { - lock (Bootstrap) - { - if (DwPolicy.IsConfigured) - { - return; - } - - DwPolicyOptions options = new(); - - // Employee belongs to the demo API rather than to this suite, and is exposed here - // because DwPolicy.Configure is refused after the first call: a second bootstrap for - // PolicyAdminControllerTests could not exist, and whichever suite ran first would - // decide what the other could resolve. - options.Entities - .Expose(Entity) - .Expose() - .Expose("Employee"); - - DwPolicy.Configure(options); - } - } + /// + /// No lock and no IsConfigured check. Since 3.3.0 a second call asking for the posture + /// already in force is a no-op, so every test that needs the endpoints can call this and the + /// package settles the race. Before that, this method held the check-then-act every integration + /// suite had to write for itself. + /// + /// Employee belongs to the demo API rather than to this suite, and is exposed here because the + /// posture must be identical whichever suite calls first. + /// + /// + internal static void Configure() => PolicyBootstrap.Ensure(); /// Signs every request in as whoever the test asked for. internal sealed class StubAuth : AuthenticationHandler diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs new file mode 100644 index 0000000..342425b --- /dev/null +++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs @@ -0,0 +1,325 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- a shared kernel type: two columns and a getter over them ------------------------------------------ + + public class ZyLocalizedText + { + public string Ar { get; set; } = string.Empty; + + public string En { get; set; } = string.Empty; + + /// Computed from the two columns, so no database can answer it. + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + public class ZyRole + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwNoWhere, DwNoOrder] + public ZyLocalizedText Name { get; set; } = new(); + + /// An unmapped getter over two mapped columns of the entity itself. + public string Display => $"{Code}:{Id}"; + } + + /// A hierarchy, so a column only one subtype maps is still a column. + public class ZyParty + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + } + + public class ZyMerchant : ZyParty + { + public string? Licence { get; set; } + } + + /// The row a caller projects before the guard sees it, which is DCMP's shape. + public class ZyRoleRow + { + public int Id { get; set; } + + public ZyLocalizedText Name { get; set; } = new(); + + public string Code { get; set; } = string.Empty; + } + + public sealed class ZyContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZyContext(SqliteConnection connection) => _connection = connection; + + public DbSet Roles => Set(); + + public DbSet Parties => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + // Owned rather than complex, so the EF Core 6.0.22 floor builds this model too. + model.Entity().OwnsOne(role => role.Name); + model.Entity().Ignore(role => role.Display); + model.Entity().HasDiscriminator("Discriminator") + .HasValue("party") + .HasValue("merchant"); + } + } + + /// + /// A path beneath a member the policy has no fragment for, whose leaf no database can compute. + /// Until this, the package accepted the path and the provider threw, which is a five-hundred + /// where the strict tier promises a refusal. + /// + public sealed class UnexpressiblePathTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyContext _db; + + public UnexpressiblePathTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyContext(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" } }); + _db.Parties.Add(new ZyMerchant { Kind = "merchant", Licence = "L-1" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + } + }; + + /// The rows a caller projects before the guard sees them, built member by member. + private IQueryable Built() => + _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + /// The same rows, with the shared type copied whole from the entity. + private IQueryable Copied() => + _db.Roles.Select(role => new ZyRoleRow { Id = role.Id, Code = role.Code, Name = role.Name }); + + // ---- the entity itself ---------------------------------------------------------------------------- + + [Fact] + public void A_computed_member_beneath_an_undecorated_member_is_refused_rather_than_run() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"entity: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void The_mapped_members_beneath_it_still_answer() + { + FilterResult result = Guard(_db.Roles, DwTier.Strict).ToList(Where("Name.En", "Admin")); + + Assert.Single(result.Data); + } + + [Fact] + public void An_unmapped_getter_on_the_entity_is_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(Where("Display", "admin:1"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_column_a_subtype_maps_is_still_a_column() + { + FilterResult result = Guard(_db.Set(), DwTier.Strict) + .ToList(Where("Licence", "L-1")); + + Assert.Single(result.Data); + } + + [Fact] + public void A_framework_member_the_provider_translates_is_left_alone() + { + FilterResult result = Guard(_db.Roles, DwTier.Strict) + .ToList(Where("Code.Length", "5", DataType.Number)); + + Assert.Single(result.Data); + } + + // ---- the projected row ---------------------------------------------------------------------------- + + [Fact] + public void A_projection_that_builds_the_shared_type_refuses_the_computed_member() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Built(), DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"built: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_projection_that_copies_the_shared_type_refuses_it_too() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Copied(), DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"copied: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void What_the_projection_does_assign_still_answers() + { + Assert.Single(Guard(Built(), DwTier.Strict).ToList(Where("Name.Ar", "مدير")).Data); + Assert.Single(Guard(Copied(), DwTier.Strict).ToList(Where("Name.En", "Admin")).Data); + } + + // ---- every clause, one choke point ---------------------------------------------------------------- + + [Fact] + public void An_order_on_it_is_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Built(), DwTier.Strict).ToList(new Filter + { + Orders = new List { new() { Field = "Name.IsEmpty", Direction = Direction.Ascending } } + })); + + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, refusal.ErrorCode); + } + + [Fact] + public void A_projection_naming_it_is_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Built(), DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Name.IsEmpty" } + })); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode); + } + + [Fact] + public void A_summary_grouping_on_it_is_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Built(), DwTier.Strict).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Name.IsEmpty" }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + } + })); + + Assert.Equal(PolicyErrorCode.FieldDeniedForGroup, refusal.ErrorCode); + } + + // ---- what is left alone --------------------------------------------------------------------------- + + [Fact] + public void Rows_in_memory_run_the_getter_as_they_always_did() + { + ZyRole[] rows = { new() { Id = 1, Code = "admin", Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" } } }; + + FilterResult result = Guard(rows.AsQueryable(), DwTier.Strict) + .ToList(Where("Name.IsEmpty", "false", DataType.Boolean)); + + Assert.Single(result.Data); + } + + [Fact] + public void The_convenience_tier_fails_exactly_as_it_does_unguarded() + { + // Not a refusal: the tier's promise is the error an unguarded query gives, and unguarded + // this is the provider's own failure to translate. + Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Convenience).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + } + + [Fact] + public void A_dry_run_refuses_nothing() + { + PolicyQueryable guarded = _db.Roles.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, DryRun = true }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + Assert.ThrowsAny( + () => guarded.ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + } + + // ---- the trace says which of the two it was ------------------------------------------------------- + + [Fact] + public void The_trace_records_why_the_path_was_refused() + { + PolicyQueryable guarded = Guard(_db.Roles, DwTier.Strict); + + Assert.ThrowsAny( + () => guarded.ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + PolicyTrace? trace = guarded.LastTrace; + + Assert.NotNull(trace); + Assert.Contains( + trace!.Decisions, + decision => decision.Reason is not null && decision.Reason.Contains("cannot compute")); + + _out.WriteLine(string.Join( + " | ", + trace.Decisions.Select(decision => $"{decision.FieldPath} {decision.Action} {decision.Reason}"))); + } + } +} diff --git a/DynamicWhere.ex/Classes/Complex/Filter.cs b/DynamicWhere.ex/Classes/Complex/Filter.cs index 682c8dd..06c6347 100644 --- a/DynamicWhere.ex/Classes/Complex/Filter.cs +++ b/DynamicWhere.ex/Classes/Complex/Filter.cs @@ -31,12 +31,23 @@ public class Filter /// Returns a deep copy, so a guarded query can be canonicalized and rewritten without the /// caller's own filter changing underneath them. /// + /// A new filter sharing no object with this one. /// + /// Public because callers need the same thing the library needs: a request read a second time + /// with one part changed — the next page, another order — without editing the object a caller + /// handed in. Rebuilding a filter around the caller's own clauses shares those clauses, and a + /// later rewrite of either one then reaches both. + /// + /// The copy reaches every branch: the condition tree with its groups and conditions, the + /// projection list, each order, and the page. + /// + /// /// A null branch stays null rather than becoming an empty collection. The gate decides whether /// to synthesize a projection by testing for null, so the distinction /// carries meaning. + /// /// - internal Filter Clone() => new() + public Filter Clone() => new() { ConditionGroup = ConditionGroup?.Clone(), Selects = Selects is null ? null : new List(Selects), diff --git a/DynamicWhere.ex/Classes/Complex/Segment.cs b/DynamicWhere.ex/Classes/Complex/Segment.cs index 1e2306f..eac282e 100644 --- a/DynamicWhere.ex/Classes/Complex/Segment.cs +++ b/DynamicWhere.ex/Classes/Complex/Segment.cs @@ -30,11 +30,16 @@ public class Segment /// /// Returns a deep copy, cloning every condition set independently. /// + /// A new segment sharing no object with this one. /// /// Policy applies to each set on its own, so the sets must not share a condition group: a /// rewrite aimed at one would otherwise land on all of them. + /// + /// Public for the same reason is: read a caller's request again with + /// one part changed, without editing what the caller handed in. + /// /// - internal Segment Clone() => new() + public Segment Clone() => new() { ConditionSets = ConditionSets is null ? null! : ConditionSets.ConvertAll(s => s.Clone()), Selects = Selects is null ? null : new List(Selects), diff --git a/DynamicWhere.ex/Classes/Complex/Summary.cs b/DynamicWhere.ex/Classes/Complex/Summary.cs index f519741..7644a17 100644 --- a/DynamicWhere.ex/Classes/Complex/Summary.cs +++ b/DynamicWhere.ex/Classes/Complex/Summary.cs @@ -36,12 +36,17 @@ public class Summary /// /// Returns a deep copy. /// + /// A new summary sharing no object with this one. /// /// A summary reaches a condition group twice — once through and /// again through . Both are cloned; a copy shaped like a filter's would /// leave the having clause shared with the caller. + /// + /// Public for the same reason is: read a caller's request again with + /// one part changed, without editing what the caller handed in. + /// /// - internal Summary Clone() => new() + public Summary Clone() => new() { ConditionGroup = ConditionGroup?.Clone(), GroupBy = GroupBy?.Clone(), diff --git a/DynamicWhere.ex/Policies/Config/DwPolicy.cs b/DynamicWhere.ex/Policies/Config/DwPolicy.cs index 59e8e83..58ca3ad 100644 --- a/DynamicWhere.ex/Policies/Config/DwPolicy.cs +++ b/DynamicWhere.ex/Policies/Config/DwPolicy.cs @@ -1,5 +1,6 @@ using DynamicWhere.ex.Policies.Audit; using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Discovery; using DynamicWhere.ex.Policies.Resolution; using DynamicWhere.ex.Policies.Validation; @@ -27,6 +28,11 @@ public static class DwPolicy private static DwPolicyOptions _options = CreateDefaultOptions(); private static PolicyResolver _resolver = CreateResolver(Array.Empty()); private static IReadOnlyList _stores = Array.Empty(); + + // The kinds of policy source the configured call supplied, in order. Read only when a second + // call asks whether it is asking for the same posture. + private static Type[] _providerTypes = Array.Empty(); + private static bool _configured; /// @@ -59,12 +65,27 @@ public static class DwPolicy /// need to be listed. /// /// Thrown when is null. - /// Thrown when called more than once. + /// + /// Thrown when called again with a posture that differs from the one in force. + /// /// - /// Refused after the first call. The posture is read by every request thread without + /// The first call decides. A later call carrying the same posture is a no-op, and one carrying a + /// different posture is refused: the posture is read by every request thread without /// synchronization, and a tier that can change while requests are in flight is one that can be /// relaxed by a code path nobody expected to be security-relevant. /// + /// The comparison covers everything that decides what a query is permitted to do: the tier, the + /// dry-run and trace flags, refusal auditing, the hash salt, the store-failure mode, both + /// intervals, every cap, the exposed entity catalogue, and the provider types supplied. It does + /// not cover the objects a host builds for itself — the token vault, the service provider and + /// the provider instances — because a second host builds its own and comparing them by + /// reference would make every second call a refusal. Those stay as the first call left them, + /// which is what makes a second host safe to start and what makes it the first host's vault and + /// container that the layer keeps using. Start a second host only where that is what you want: + /// an integration suite running many WebApplicationFactory hosts over one composition + /// root, which is the case this exists for. + /// + /// /// is added whether or not it appears in /// . Attributes are the sealed level, and a configuration that /// omitted them would let a runtime store grant access the source code refuses — which is the @@ -82,9 +103,21 @@ public static void Configure(DwPolicyOptions options, params IDwPolicyProvider[] { if (_configured) { - throw new InvalidOperationException( - "Policy is already configured. The enforcement posture is read by every request " + - "thread and cannot change once the application is serving."); + if (!SamePosture(_options, options, providers)) + { + throw new InvalidOperationException( + "Policy is already configured with a different posture. The enforcement " + + "posture is read by every request thread and cannot change once the " + + "application is serving. Configuring the same posture again is allowed, so " + + "a second host over one composition root starts without a check of its own."); + } + + // Frozen so a caller holding this instance cannot go on setting values that no + // longer decide anything. The posture in force is unchanged and stays the frozen + // one the first call installed. + options.Freeze(); + + return; } options.Freeze(); @@ -94,10 +127,124 @@ public static void Configure(DwPolicyOptions options, params IDwPolicyProvider[] _options = options; _resolver = CreateResolver(supplied); _stores = supplied.OfType().ToList(); + _providerTypes = ProviderTypes(supplied); _configured = true; } } + /// + /// True when a second configuration asks for exactly what is already in force. + /// + /// + /// Every value that decides what a query may do is compared, so a posture that differs in any of + /// them is refused rather than quietly ignored. The three things not compared are the objects a + /// host constructs for itself — the token vault, the service provider and the provider instances + /// — since a second host builds its own and no two are ever the same reference. Provider + /// types are compared, in order, so a host that adds or drops a source is still refused. + /// + private static bool SamePosture(DwPolicyOptions inForce, DwPolicyOptions asked, IDwPolicyProvider[]? providers) + { + if (ReferenceEquals(inForce, asked)) + { + return true; + } + + if (inForce.Tier != asked.Tier + || inForce.DryRun != asked.DryRun + || inForce.IncludeTraceInResult != asked.IncludeTraceInResult + || inForce.AuditRefusals != asked.AuditRefusals + || !string.Equals(inForce.HashSalt, asked.HashSalt, StringComparison.Ordinal) + || inForce.StoreFailure != asked.StoreFailure + || inForce.MaxSnapshotAge != asked.MaxSnapshotAge + || inForce.RefreshInterval != asked.RefreshInterval) + { + return false; + } + + if (!SameCaps(inForce.Caps, asked.Caps) || !SameCatalog(inForce.Entities, asked.Entities)) + { + return false; + } + + return SameProviders(providers); + } + + /// True when two cap sets hold the same numbers, the group floor's opt-out included. + private static bool SameCaps(DwCaps inForce, DwCaps asked) => + inForce.MaxPageSize == asked.MaxPageSize + && inForce.DefaultPageSize == asked.DefaultPageSize + && inForce.MaxConditions == asked.MaxConditions + && inForce.MaxConditionDepth == asked.MaxConditionDepth + && inForce.MaxConditionSets == asked.MaxConditionSets + && inForce.MaxConditionValues == asked.MaxConditionValues + && inForce.MaxAggregates == asked.MaxAggregates + && inForce.MaxOrderFields == asked.MaxOrderFields + && inForce.MaxNavigationDepth == asked.MaxNavigationDepth + && inForce.MaxQueryCost == asked.MaxQueryCost + && inForce.DefaultFieldCost == asked.DefaultFieldCost + && inForce.MaxAuditEvents == asked.MaxAuditEvents + && inForce.MinGroupSize == asked.MinGroupSize + && inForce.IsMinGroupSizeSet == asked.IsMinGroupSizeSet + && inForce.SchemaDepth == asked.SchemaDepth + && inForce.SchemaCycleLimit == asked.SchemaCycleLimit + && inForce.MaxSchemaFields == asked.MaxSchemaFields; + + /// True when both catalogues expose the same types under the same names. + private static bool SameCatalog(DwEntityCatalog inForce, DwEntityCatalog asked) + { + if (inForce.Entities.Count != asked.Entities.Count) + { + return false; + } + + foreach (KeyValuePair exposed in inForce.Entities) + { + if (!asked.Entities.TryGetValue(exposed.Key, out string? name) + || !string.Equals(exposed.Value, name, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + /// + /// True when the second call supplies the same kinds of policy source, in the same order. + /// + /// + /// Types, not instances. A store provider reads its rules from wherever it was built to read + /// them, and nothing here can compare two stores; what it can refuse is a second call that adds + /// a source, drops one, or reorders them, which is the difference that changes what the resolver + /// decides. The attribute provider is left out of both sides, since it is added either way. + /// + private static bool SameProviders(IDwPolicyProvider[]? asked) + { + Type[] wanted = ProviderTypes(asked); + + if (_providerTypes.Length != wanted.Length) + { + return false; + } + + for (int i = 0; i < wanted.Length; i++) + { + if (_providerTypes[i] != wanted[i]) + { + return false; + } + } + + return true; + } + + /// The kinds of policy source a call supplies, in order, as the resolver takes them. + private static Type[] ProviderTypes(IDwPolicyProvider[]? providers) => + (providers ?? Array.Empty()) + .Where(provider => provider is not null and not AttributePolicyProvider) + .Select(provider => provider.GetType()) + .ToArray(); + /// /// Prepares a context for use, once per request, before its first query. /// diff --git a/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs b/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs index 3f0943e..7c8568a 100644 --- a/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs +++ b/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs @@ -104,8 +104,8 @@ public static DwPolicyOptions Bind(this DwPolicyOptions options, IConfiguration /// The same collection, for chaining. /// Thrown when the collection or the section is null. /// - /// Thrown when the policy layer is already configured, or when the section names a key nothing - /// answers to. + /// Thrown when the policy layer is already configured with a different posture, and when + /// the section names a key nothing answers to. /// /// /// Configuration binds first and the callback runs second, so code has the last word. That is @@ -116,6 +116,14 @@ public static DwPolicyOptions Bind(this DwPolicyOptions options, IConfiguration /// singleton so anything resolving reads the same frozen instance /// the query path reads. There is no second posture anywhere. /// + /// + /// Calling this a second time with the same posture is a no-op, and the container is given the + /// posture already in force. That is what lets an integration suite start many + /// WebApplicationFactory hosts over one composition root without a lock or an + /// IsConfigured check of its own — the check and the act are one step inside the + /// package. A second call asking for a different posture still throws, and what counts as + /// different is listed on . + /// /// public static IServiceCollection AddDwPolicies( this IServiceCollection services, @@ -134,7 +142,10 @@ public static IServiceCollection AddDwPolicies( DwPolicy.Configure(options, providers ?? Array.Empty()); - services.AddSingleton(options); + // The posture in force, which is this call's instance on the first call and the first + // call's on any later one. Registering the instance built here instead would hand the + // container a posture the query path does not read. + services.AddSingleton(DwPolicy.Options); return services; } diff --git a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs index ede7cac..ddc745a 100644 --- a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs +++ b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs @@ -115,7 +115,7 @@ internal static Filter Sanitize( // The gate is built first now, because canonicalizing a name needs the type's alias map and // the gate is what carries it. Nothing the gate does depends on the paths being canonical. - Gate gate = new(typeof(T), resolver, context, options, trace); + Gate gate = new(typeof(T), resolver, context, options, trace) { Rows = rows ?? RowShape.Unknown }; // Before any name is resolved. Counting needs no name, and resolving every name of an // oversized request is exactly the work the caps exist to refuse. @@ -181,6 +181,7 @@ internal static Filter Sanitize( /// The caller. /// The enforcement posture. /// Collects what was decided. + /// What the source produces, when it can be read. /// A sanitized copy, safe to hand to the existing pipeline. /// Thrown when any argument is null. /// Thrown when the policy refuses part of the summary. @@ -195,7 +196,8 @@ internal static Summary Sanitize( PolicyResolver resolver, DwPolicyContext context, DwPolicyOptions options, - PolicyTrace trace) + PolicyTrace trace, + RowShape? rows = null) where T : class { if (summary is null) @@ -225,7 +227,7 @@ internal static Summary Sanitize( Summary working = summary.Clone(); - Gate gate = new(typeof(T), resolver, context, options, trace); + Gate gate = new(typeof(T), resolver, context, options, trace) { Rows = rows ?? RowShape.Unknown }; EnforceCaps(working, gate); @@ -669,7 +671,11 @@ internal static Segment Sanitize( Segment working = segment.Clone(); - Gate gate = new(typeof(T), resolver, context, options, trace) { InSegment = true }; + Gate gate = new(typeof(T), resolver, context, options, trace) + { + InSegment = true, + Rows = rows ?? RowShape.Unknown + }; EnforceCaps(working, gate); @@ -2374,7 +2380,9 @@ private static string ResolveName(string name, Gate gate) where T : class // behavioural difference from before this existed. if (gate.TypePolicy.Aliases.Count == 0) { - return gate.HidesExistence ? TryValidate(name) ?? gate.Unknown(name) : name.Validate(); + return gate.HidesExistence + ? Expressible(TryValidate(name), name, gate) ?? gate.Unknown(name) + : name.Validate(); } string spoken = name.Trim(); @@ -2430,11 +2438,53 @@ private static string ResolveName(string name, Gate gate) where T : class string canonical = candidates[0]; + if (gate.HidesExistence && Expressible(canonical, spoken, gate) is null) + { + return gate.Unknown(spoken); + } + gate.RecordSpelling(canonical, spoken); return canonical; } + /// + /// The canonical path when the source can express it, and null when the source provably cannot. + /// + /// + /// A member exists on the row's type and still has no value the provider can compute: + /// Name.IsEmpty over a localized text, a getter over two columns. Every check the policy + /// makes passes, and the query then fails inside the provider — a five-hundred where the strict + /// tier promises a refusal, and the one place the tier answers a caller with something other + /// than an answer or a refusal. + /// + /// Only the strict tier reaches this, and only where can prove + /// the path wrong. Elsewhere the name is returned unchanged and fails exactly as it does today, + /// which is what an unguarded query does with it. + /// + /// + /// The refusal is the unknown-name one, so a caller cannot tell a member that does not exist + /// from one that exists and cannot be computed, any more than they can tell either from a field + /// they may not use. + /// + /// + private static string? Expressible(string? canonical, string spoken, Gate gate) + { + if (canonical is null) + { + return null; + } + + if (gate.Rows.Expresses(canonical) == false) + { + gate.RecordUnexpressible(canonical, spoken); + + return null; + } + + return canonical; + } + /// /// The root path when every candidate is the same member reached through navigations, otherwise /// null. @@ -2648,6 +2698,12 @@ internal Gate( /// internal bool InSegment { get; init; } + /// + /// What the source produces, which decides whether a path the caller named is something the + /// query can express at all. + /// + internal RowShape Rows { get; init; } = RowShape.Unknown; + /// The caller this query is being sanitized for. internal DwPolicyContext Context => _context; @@ -2690,6 +2746,26 @@ internal string Spoken(string fieldPath) => /// internal bool HidesExistence => IsStrict && !IsDryRun; + /// + /// Records that a path exists on the type and names no value the query can compute, before + /// it is refused as an unknown name would be. + /// + /// + /// The refusal itself says nothing, on purpose, so the trace is where an operator reads what + /// happened. Without it, a filter on Name.IsEmpty would be refused with the same + /// wording as a misspelling and nothing anywhere would say which of the two it was. + /// + internal void RecordUnexpressible(string canonicalPath, string spoken) + { + _trace.RecordSpelling(canonicalPath, spoken); + + _trace.Add(new PolicyDecision( + canonicalPath, + PolicyFeature.None, + PolicyAction.Denied, + "the member exists on the type and the query cannot compute it, so it is refused as an unknown name is")); + } + /// Remembers a name that matches nothing, and returns it for the gate to refuse. /// /// Empty and blank segments are dropped, as they are from a real path before it is counted. A diff --git a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs index fb617ea..1311032 100644 --- a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs +++ b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs @@ -894,24 +894,28 @@ private IQueryable Scoped(ConditionGroup? group) => /// Sanitizes a whole filter and records the outcome. private Filter Sanitize(Filter filter, PolicyTrace trace) { + // Before the sanitizing, not after it, so a refusal leaves the trace readable. A strict + // refusal names no field on purpose, and the trace is the only place that says which field + // it was and why — which is no use to anyone if a throw skips the assignment. + LastTrace = trace; + // A source the caller ordered before guarding it keeps that order: a default would replace it. Filter sanitized = FilterSanitizer.Sanitize( filter, _resolver, _context, _options, trace, applyDefaultOrder: TakesDefaultOrder, rows: RowShape.Of(_source)); - LastTrace = trace; - return sanitized; } /// Sanitizes a summary and records the outcome. private Summary Sanitize(Summary summary, PolicyTrace trace) { - Summary sanitized = FilterSanitizer.Sanitize(summary, _resolver, _context, _options, trace); - LastTrace = trace; + Summary sanitized = FilterSanitizer.Sanitize( + summary, _resolver, _context, _options, trace, rows: RowShape.Of(_source)); + return sanitized; } @@ -928,11 +932,11 @@ private Filter SanitizeClause(Filter clause, bool applyDefaultOrder = false) { PolicyTrace trace = NewTrace(); + LastTrace = trace; + Filter sanitized = FilterSanitizer.Sanitize( clause, _resolver, _context, _options, trace, synthesizeProjection: false, applyDefaultOrder); - LastTrace = trace; - return sanitized; } diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs index 131f939..e94319b 100644 --- a/DynamicWhere.ex/Policies/Source/RowShape.cs +++ b/DynamicWhere.ex/Policies/Source/RowShape.cs @@ -62,6 +62,15 @@ internal sealed class RowShape private static readonly ConditionalWeakTable> ComplexByType = new(); private readonly HashSet? _assigned; + + // What a projection's initializer assigns beneath a member, by the member's path from the row. + // "Name" -> { "Ar", "En" } for Name = new LocalizedText { Ar = …, En = … }. A path with no entry + // is one this shape cannot speak for. + private readonly Dictionary>? _initialized; + + // A member a projection copies from the query's own entity, by the member's path from the row: + // Name = x.Name records ("Name", the entity, "Name"), so what is beneath it is read from the model. + private readonly Dictionary? _copied; private readonly Dictionary _built = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _narrowable = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _kept = new(StringComparer.OrdinalIgnoreCase); @@ -73,10 +82,18 @@ internal sealed class RowShape private RowShape(RowKind kind) => Kind = kind; private RowShape( - RowKind kind, HashSet? assigned, IEnumerable narrowable, Type? built, IDictionary members) + RowKind kind, + HashSet? assigned, + IEnumerable narrowable, + Type? built, + IDictionary members, + Dictionary>? initialized = null, + Dictionary? copied = null) : this(kind) { _assigned = assigned; + _initialized = initialized; + _copied = copied; _narrowable.UnionWith(narrowable); Built = built; @@ -254,6 +271,242 @@ internal bool Materializes(string path) return true; } + /// + /// Whether the query behind these rows can express a path the caller named: true when every + /// segment is something the source produces, false when a segment provably is not, and null when + /// the shape cannot say. + /// + /// + /// A member exists on the row's type and still has no value the database can compute. + /// LocalizedText.IsEmpty is a getter over two columns, so a filter naming + /// Name.IsEmpty passes every check the policy makes and then fails inside the provider, + /// which is a five-hundred where the strict tier promises a refusal. + /// + /// False is only ever returned where the shape knows the whole set of members a container can + /// produce: an entity type and its derived types, a complex or owned type, or an initializer in + /// the projection itself. Anywhere else the answer is null and the path is left alone — beneath a + /// column, where a converter decides; on a framework type, where Length, Year and + /// Count are translated by the provider and are not members of any model; on rows in + /// memory, which run the getter; and on a source this library cannot read. + /// + /// + /// The cost of being wrong is not symmetric. A path wrongly refused is a query that worked and + /// now does not, so nothing is refused on a guess; a path wrongly allowed is the five-hundred + /// this is fixing, which is what it already does today. + /// + /// + internal bool? Expresses(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + string[] segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (segments.Length == 0) + { + return null; + } + + return Kind switch + { + RowKind.InMemory => null, + RowKind.Projected => ExpressesInProjection(segments), + _ => _entity is null ? null : ExpressesInEntity(_entity, segments, 0) + }; + } + + /// + /// Whether a projected row can produce a path: the outermost initializer decides the first + /// segment, a nested initializer the ones beneath it, and a member copied from the query's own + /// entity hands the rest to the model. + /// + private bool? ExpressesInProjection(string[] segments) + { + if (_assigned is not null && !_assigned.Contains(segments[0])) + { + // Nothing assigns it, so the projection carries no value for it at all. + return false; + } + + for (int i = 0; i < segments.Length - 1; i++) + { + string prefix = string.Join(".", segments.Take(i + 1)); + string next = segments[i + 1]; + + if (_copied is not null && _copied.TryGetValue(prefix, out (IEntityType Entity, string Member) source)) + { + List rest = source.Member.Split('.', StringSplitOptions.RemoveEmptyEntries).ToList(); + + rest.AddRange(segments.Skip(i + 1)); + + return ExpressesInEntity(source.Entity, rest.ToArray(), 0); + } + + if (_initialized is null || !_initialized.TryGetValue(prefix, out HashSet? members)) + { + return null; + } + + if (!members.Contains(next)) + { + return false; + } + } + + return true; + } + + /// + /// Whether an entity query can express a path, walking the model: a column ends the walk, a + /// complex or owned member continues in its own type, a navigation continues in the type it + /// points at, and a member the model does not map anywhere is one the provider cannot compute. + /// + private static bool? ExpressesInEntity(IEntityType entity, IReadOnlyList segments, int index) + { + IEntityType? current = entity; + + for (int i = index; i < segments.Count; i++) + { + if (current is null) + { + return null; + } + + string segment = segments[i]; + bool last = i == segments.Count - 1; + + if (MappedProperty(current, segment) is not null) + { + // A column, including a shadow property. Beneath one a converter decides, and the + // framework's own members are translated by the provider rather than mapped. + return last ? true : null; + } + + if (ComplexMember(current, segment) is { } complex) + { + return last ? true : ExpressesInComplex(complex, segments, i + 1, depth: 0); + } + + if (MappedNavigation(current, segment) is { } navigation) + { + if (last) + { + return true; + } + + current = navigation.TargetEntityType; + + continue; + } + + // The type carries the member and the model maps no value for it, on this type or on any + // type derived from it. A getter over columns is the shape this catches. + return CacheReflection.FindProperty(current.ClrType, segment) is null ? null : false; + } + + return null; + } + + /// + /// Whether a complex or owned type can produce the rest of a path, read through reflection since + /// EF Core 6 has no complex properties. + /// + private static bool? ExpressesInComplex(object complex, IReadOnlyList segments, int index, int depth) + { + if (depth > MaxComplexDepth + || complex.GetType().GetProperty("ComplexType")?.GetValue(complex) is not { } type) + { + return null; + } + + string segment = segments[index]; + bool last = index == segments.Count - 1; + + if (Items(type, "GetProperties").Any(property => Named(property, segment))) + { + return last ? true : null; + } + + if (Items(type, "GetComplexProperties").FirstOrDefault(nested => Named(nested, segment)) is { } deeper) + { + return last ? true : ExpressesInComplex(deeper, segments, index + 1, depth + 1); + } + + return type.GetType().GetProperty("ClrType")?.GetValue(type) is Type clr + && CacheReflection.FindProperty(clr, segment) is not null + ? false + : null; + } + + /// True when a model object answers to a name, whatever case the caller wrote. + private static bool Named(object model, string name) => + model.GetType().GetProperty("Name")?.GetValue(model) is string held + && string.Equals(held, name, StringComparison.OrdinalIgnoreCase); + + /// + /// The column a type or any type derived from it maps under a name, or null. + /// + /// + /// Derived types are read because a hierarchy's rows come back as the queried type: a column + /// declared by one subtype is still a column the provider can compute for the rows that have it. + /// + private static IProperty? MappedProperty(IEntityType entity, string name) => + entity.GetProperties().FirstOrDefault(property => Same(property.Name, name)) + ?? entity.GetDerivedTypes() + .SelectMany(derived => derived.GetProperties()) + .FirstOrDefault(property => Same(property.Name, name)); + + /// The navigation a type or any type derived from it maps under a name, or null. + private static INavigationBase? MappedNavigation(IEntityType entity, string name) + { + INavigationBase? found = + entity.GetNavigations().FirstOrDefault(navigation => Same(navigation.Name, name)) + ?? (INavigationBase?)entity.GetSkipNavigations().FirstOrDefault(navigation => Same(navigation.Name, name)); + + if (found is not null) + { + return found; + } + + foreach (IEntityType derived in entity.GetDerivedTypes()) + { + found = derived.GetNavigations().FirstOrDefault(navigation => Same(navigation.Name, name)) + ?? (INavigationBase?)derived.GetSkipNavigations().FirstOrDefault(navigation => Same(navigation.Name, name)); + + if (found is not null) + { + return found; + } + } + + return null; + } + + /// The complex property a type or any type derived from it declares under a name, or null. + private static object? ComplexMember(IEntityType entity, string name) + { + if (Items(entity, "GetComplexProperties").FirstOrDefault(complex => Named(complex, name)) is { } found) + { + return found; + } + + foreach (IEntityType derived in entity.GetDerivedTypes()) + { + if (Items(derived, "GetComplexProperties").FirstOrDefault(complex => Named(complex, name)) is { } beneath) + { + return beneath; + } + } + + return null; + } + + /// Compares two model names the way a caller's spelling is compared everywhere else. + private static bool Same(string left, string right) => + string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + /// /// Every member the value at a path loads beneath it, each with its path from the root, when an /// entity query's model can say; null when the value can carry anything its type holds. @@ -537,6 +790,11 @@ private static RowShape Projected(IQueryable source) where T : class ? null : new HashSet(StringComparer.OrdinalIgnoreCase); + Dictionary> initialized = new(StringComparer.OrdinalIgnoreCase); + Dictionary copied = new(StringComparer.OrdinalIgnoreCase); + + ReadAssignments(initializer, string.Empty, row, rowSource, initialized, copied, depth: 0); + foreach (MemberBinding binding in initializer.Bindings) { // After a constructor with arguments every member counts as assigned, since the constructor @@ -560,7 +818,65 @@ private static RowShape Projected(IQueryable source) where T : class } } - return new RowShape(RowKind.Projected, assigned, narrowable, initializer.Type, built); + return new RowShape(RowKind.Projected, assigned, narrowable, initializer.Type, built, initialized, copied); + } + + /// + /// Records what an initializer assigns, at every depth: the member names it sets, and where a + /// member copied straight from the query's own entity comes from. + /// + /// + /// Read by only, to answer whether a path the caller named is something + /// the projected row can produce. A member assigned from anything else — a method call, a + /// conditional, a captured value — records nothing, and a path beneath it is one this shape + /// cannot speak for. + /// + /// A constructor with arguments says nothing about which member each argument sets, so the level + /// it builds is left unrecorded rather than recorded as incomplete. + /// + /// + private static void ReadAssignments( + MemberInitExpression initializer, + string prefix, + ParameterExpression row, + IEntityType? rowSource, + Dictionary> initialized, + Dictionary copied, + int depth) + { + if (depth > MaxComplexDepth) + { + return; + } + + HashSet members = new(StringComparer.OrdinalIgnoreCase); + + foreach (MemberBinding binding in initializer.Bindings) + { + members.Add(binding.Member.Name); + + if (binding is not MemberAssignment assignment) + { + continue; + } + + string path = prefix.Length == 0 ? binding.Member.Name : $"{prefix}.{binding.Member.Name}"; + Expression value = DefaultOrder.StripConversions(assignment.Expression); + + if (value is MemberInitExpression nested) + { + ReadAssignments(nested, path, row, rowSource, initialized, copied, depth + 1); + } + else if (rowSource is not null && MemberChain(value, row) is { } chain) + { + copied[path] = (rowSource, chain); + } + } + + if (initializer.NewExpression.Arguments.Count == 0) + { + initialized[prefix] = members; + } } /// From f178ba0e19820c8bbc4aeec126420f1ed1fa51ba Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 12:37:15 +0300 Subject: [PATCH 02/54] =?UTF-8?q?docs:=203.3.0=20=E2=80=94=20configuring?= =?UTF-8?q?=20twice,=20a=20path=20the=20query=20cannot=20compute,=20public?= =?UTF-8?q?=20Clone,=20and=20the=20group=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version 3.3.0 in the four packages, with release notes, and every documented version reference with it. DW-16 and DW-17 are written up in DOC.md, llms.txt, the README highlights, the breaking-changes page (points 30 to 32), the configuration page and the security page. Clone gets a section on the Filter, Segment and Summary pages and an entry in the shapes table. D-1: [DwEntity(DefaultOrder)]'s own remarks said a projected query takes no default. It has since 3.2.0, and the remarks now say what makes one take it. D-2: DwCaps.MinGroupSize ships on at 5, so a guarded summary drops groups of fewer than five rows and says nothing about it. The aggregation sections lead with that rather than leaving it to the caps table. Co-Authored-By: Claude Opus 5 --- ...DynamicWhere.ex.Policies.AspNetCore.csproj | 6 +- ...ere.ex.Policies.EntityFrameworkCore.csproj | 6 +- .../DynamicWhere.ex.Policies.Redis.csproj | 6 +- DynamicWhere.ex/DOC.md | 85 +++++++++++++- DynamicWhere.ex/DynamicWhere.ex.csproj | 14 ++- .../Policies/Attributes/DwEntityAttribute.cs | 10 +- OfficialWebsite/app/docs/ai/page.tsx | 2 +- .../app/docs/breaking-changes/page.tsx | 107 +++++++++++++++++- .../app/docs/classes/filter/page.tsx | 16 +++ .../app/docs/classes/segment/page.tsx | 16 +++ .../app/docs/classes/summary/page.tsx | 16 +++ .../app/docs/extensions/summary/page.tsx | 10 ++ .../extensions/to-list-async-summary/page.tsx | 10 ++ .../docs/extensions/to-list-summary/page.tsx | 10 ++ .../app/docs/installation/page.tsx | 6 +- OfficialWebsite/app/docs/page.tsx | 4 +- .../app/docs/policies/admin/page.tsx | 2 +- .../app/docs/policies/configuration/page.tsx | 61 +++++++++- .../app/docs/policies/providers/page.tsx | 4 +- .../app/docs/policies/security/page.tsx | 30 +++++ OfficialWebsite/lib/nav.ts | 2 +- OfficialWebsite/package.json | 2 +- OfficialWebsite/public/llms.txt | 96 ++++++++++++++-- README.md | 14 ++- 24 files changed, 500 insertions(+), 35 deletions(-) diff --git a/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj b/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj index 78ce335..a9297f4 100644 --- a/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj +++ b/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj @@ -31,8 +31,10 @@ Sajjad H. Al-Khafaji Free Forever — Copyright © Sajjad H. Al-Khafaji MIT - 3.2.0 - v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API change in this package. The core release changes what /simulate reports for a request that sends no Selects. A simulation has no source, so it reads the type as one it cannot see into: every denial beneath a member counts, and the projection it shows keeps only members holding a value, where a guarded query over a projected row, an entity or rows in memory keeps what that source carries. + 3.3.0 + v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. No API or behaviour change in this package. + +v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API change in this package. The core release changes what /simulate reports for a request that sends no Selects. A simulation has no source, so it reads the type as one it cannot see into: every denial beneath a member counts, and the projection it shows keeps only members holding a value, where a guarded query over a projected row, an entity or rows in memory keeps what that source carries. v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now requires. No API change in this package. The core release changes what its endpoints report, as it changes any guarded call: /simulate answers with 3.1.0's pipeline, the new caps, the strict tier's refusals and a type's DefaultOrder included, and /health's loadedAt and ageSeconds advance on a poll that confirms the version served, so an idle healthy store no longer reads as stale. One change needs action: DwClaimsAdapter.FromClaims returns an unprepared context, and ApplyPolicy(context) now refuses one with PolicyContextNotPrepared even when no store is configured. Use CreateContextAsync or ToPolicyContextAsync, or pass the context through DwPolicy.PrepareAsync before querying. In this package, the audit middleware's warning for events recorded with no IDwAuditSink registered now names both ways to stop recording them: remove [DwAudit] from the fields that produced them, or turn off DwPolicyOptions.AuditRefusals, the core's new switch that records refused guarded queries as audit events, which the middleware drains like any other. diff --git a/DynamicWhere.ex.Policies.EntityFrameworkCore/DynamicWhere.ex.Policies.EntityFrameworkCore.csproj b/DynamicWhere.ex.Policies.EntityFrameworkCore/DynamicWhere.ex.Policies.EntityFrameworkCore.csproj index 6837aa4..0daba70 100644 --- a/DynamicWhere.ex.Policies.EntityFrameworkCore/DynamicWhere.ex.Policies.EntityFrameworkCore.csproj +++ b/DynamicWhere.ex.Policies.EntityFrameworkCore/DynamicWhere.ex.Policies.EntityFrameworkCore.csproj @@ -34,8 +34,10 @@ Free Forever — Copyright © Sajjad H. Al-Khafaji MIT - 3.2.0 - v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. + 3.3.0 + v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. No API or behaviour change in this package. + +v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now requires. No API or behaviour change in this package. A stored rule's forced predicate can now carry allowNull, which lets rows with no value through: the core's PolicyRuleDocument writes it into the Detail column's forced object as "allowNull": true, only when it is true, so no migration is needed and a rule without it is stored exactly as before. A row whose forced object pairs IsNull or IsNotNull with "allowNull": true, value or no value, or with a contextValue, is refused when it is read, like any row the core cannot read: a Global, Tenant, Role or Custom rule fails the load, and a User rule fails DwPolicy.PrepareAsync for that user. diff --git a/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj b/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj index 9b1ea88..5015fbf 100644 --- a/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj +++ b/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj @@ -34,8 +34,10 @@ - 3.2.0 - v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. + 3.3.0 + v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. No API or behaviour change in this package. + +v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now requires. No API or behaviour change in this package. A stored rule's forced predicate can now carry allowNull, which lets rows with no value through: the core's PolicyRuleDocument writes it into the rule document's forced object as "allowNull": true, only when it is true, so a rule without it is stored exactly as before. A document whose forced object pairs IsNull or IsNotNull with "allowNull": true, value or no value, or with a contextValue, is refused when it is read, like any document the core cannot read: a Global, Tenant, Role or Custom rule fails the load, and a User rule fails DwPolicy.PrepareAsync for that user. diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index b09bbbd..892f3a1 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -1,6 +1,6 @@ # DynamicWhere.ex -**Version:** 3.2.0  |  **Target Framework:** .NET 6+  |  **License:** MIT (Free Forever) +**Version:** 3.3.0  |  **Target Framework:** .NET 6+  |  **License:** MIT (Free Forever) > A powerful and versatile library for dynamically creating complex filter, sort, paginate, group, aggregate, and set-operation expressions in Entity Framework Core applications — all driven by simple JSON objects from any front-end or API consumer. @@ -31,7 +31,7 @@ ## Installation ```bash -dotnet add package DynamicWhere.ex --version 3.2.0 +dotnet add package DynamicWhere.ex --version 3.3.0 ``` **Dependencies:** @@ -382,6 +382,8 @@ Combines filtering, selecting, ordering, and pagination in a single object. | `Orders` | `List?` | Optional sort criteria | | `Page` | `PageBy?` | Optional pagination | +**`Clone()`** *(public since 3.3.0)* returns a deep copy — the condition tree with its groups and conditions, the projection list, each order and the page — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. + --- #### `Segment` @@ -395,6 +397,8 @@ Combines multiple condition sets with set operations (Union / Intersect / Except | `Orders` | `List?` | Optional sort criteria | | `Page` | `PageBy?` | Optional pagination | +**`Clone()`** *(public since 3.3.0)* returns a deep copy — every condition set with its own condition group, the projection list, each order and the page — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. + --- #### `Summary` @@ -409,6 +413,8 @@ Combines filtering → grouping → having → ordering → pagination for aggre | `Orders` | `List?` | Sort on grouped result. Fields must be GroupBy fields or aggregate aliases | | `Page` | `PageBy?` | Optional pagination on grouped result | +**`Clone()`** *(public since 3.3.0)* returns a deep copy — the condition group, the group-by with its aggregates, the having clause, each order and the page — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. + --- ### Result Classes @@ -675,7 +681,7 @@ Applies where → group → having → order → page to a query. ### `.ToList(Summary summary, bool getQueryString = false)` -Materializes a `Summary` and returns a `SummaryResult`. +Materializes a `Summary` and returns a `SummaryResult`. Under `ApplyPolicy`, groups smaller than `DwCaps.MinGroupSize` — **5 by default** — are dropped from the result; see [k-anonymity](#k-anonymity--the-control-you-would-not-guess). **Returns:** `SummaryResult` @@ -1046,6 +1052,8 @@ The entire `Brands` collection is bound as-is. ### 6. `Group` — GroupBy with Aggregations +> Guarded, this is floored as a summary is: `DwCaps.MinGroupSize` defaults to **5**, and a smaller group is dropped rather than refused. + ```json { "fields": ["Category"], @@ -1121,6 +1129,8 @@ The entire `Brands` collection is bound as-is. ### 8. `Summary` / `ToList(Summary)` / `ToListAsync(Summary)` — Group + Aggregate + Having +> **A guarded summary drops small groups by default.** `DwCaps.MinGroupSize` ships **on, at 5**, so a group with fewer than five rows is removed from the result — not refused, and nothing in the answer says a group was dropped. That is right for anonymised reporting and surprising for an operational count, where five is a real number of orders. Set `Caps.MinGroupSize = 1` to switch the floor off, deliberately. An unguarded summary is never floored. See [k-anonymity](#k-anonymity--the-control-you-would-not-guess). + ```json { "conditionGroup": { @@ -1668,6 +1678,35 @@ A dropped field leaves nothing behind in the data, so the trace is the only way The convenience tier is unchanged: an unknown name fails validation with `LogicException` `ConditionMustHasValidFieldName`, a refusal names the field as the caller wrote it, with `RuleId` and `SourceOrigin` where a single source decided, and `MaxQueryCost` is checked before any field is gated. A dry run refuses no field, so an unknown name fails validation there in either tier. +**A member the query cannot compute is refused the same way** *(3.3.0)*. A member of the row's type is not always a value a database can produce. A shared type such as + +```csharp +public sealed class LocalizedText +{ + public string Ar { get; set; } = ""; + public string En { get; set; } = ""; + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); +} +``` + +gives `Name.Ar` and `Name.En`, which translate, and `Name.IsEmpty`, which is a getter over the two. The policy has nothing to say about it — `[DwNoWhere]` on `Name` matches that path and not the ones beneath it — so every check passed and EF Core threw `InvalidOperationException`: a five-hundred where `Strict` promises a refusal. Such a path is now refused as an unknown name is, with the clause's own code and `FieldPath` `"*"`, in every clause. + +It is refused only where the whole set of members a container can produce is known: + +| Source | Read from | `Name.IsEmpty` | +|---|---|---| +| An entity | the EF Core model: columns, shadow properties, owned and complex members, navigations | Refused | +| A row a `Select` built before `ApplyPolicy` | the initializer's own assignments, at every level | Refused | +| …where that `Select` copies the member from the entity, `Name = role.Name` | the model, beneath the member it copies | Refused | +| Rows in memory | nothing — the getter runs | Runs, as it always did | +| Anything beneath a column, a converted one included | nothing — the converter decides | Runs, as it always did | +| A framework member such as `Length`, `Year`, `Count`, `HasValue` | nothing — the provider translates it | Runs, as it always did | +| A source the library cannot read | nothing | Runs, as it always did | + +An unmapped getter on the entity itself, `Display => $"{Code}:{Id}"`, is refused for the same reason. The convenience tier and a dry run are unchanged: both fail exactly as the unguarded query does, which is the provider's own error. The trace records the refusal — `the member exists on the type and the query cannot compute it` — and since 3.3.0 `LastTrace` is set before a request is sanitized, so a refusal leaves it readable rather than null. + +A member a custom EF Core translator computes, through `[DbFunction]` or an `IMethodCallTranslatorPlugin`, is not in the model and is refused with the rest. Map it, or filter on the columns beneath it. + ### A navigation named in Selects A `Selects` entry can name a navigation, such as `"Lines"`, rather than the fields beneath it. With nothing denied beneath it, the entry is kept as written. With a denied field beneath it, the `Convenience` tier replaces the entry with the allowed fields beneath it, and the `Strict` tier refuses it with `FieldDeniedForSelect`. @@ -2032,6 +2071,37 @@ deliberate choice. secrets, an environment variable or a vault. A salt committed to `appsettings.json` is not a salt, and nothing here can tell the difference. +#### Configuring twice + +*(3.3.0)* The first call decides the posture. A second `DwPolicy.Configure`, or a second +`AddDwPolicies`, **asking for the posture already in force does nothing and returns**; one asking for +a different posture still throws `InvalidOperationException`. The comparison happens inside the lock +that does the configuring, so a caller needs no lock and no `IsConfigured` check of its own — which +matters because that check is a check-then-act two hosts starting at once can both pass. + +This is what an integration suite needs. Several `WebApplicationFactory` hosts run the same +composition root, and before 3.3.0 the second one threw, so every such suite wrote the check itself +and re-registered `DwPolicy.Options` by hand. + +What counts as the same posture: + +| Compared | Not compared | +|---|---| +| `Tier`, `DryRun`, `IncludeTraceInResult`, `AuditRefusals` | `TokenVault` | +| `HashSalt`, `StoreFailure`, `MaxSnapshotAge`, `RefreshInterval` | `Services` | +| Every value on `Caps`, the group floor's opt-out included | The provider *instances* | +| The exposed entity catalogue: the same types under the same names | | +| The provider *types*, in the order they were supplied | | + +The three on the right are objects a host builds for itself, and a second host builds its own, so +comparing them by reference would make every second call a refusal. They stay as the first call left +them: **a second host runs with the first host's vault, container and rule stores.** In one test +process that is what you want. Start a second host in production only if it is. + +`AddDwPolicies` registers the posture in force rather than the instance it has just built, so +whatever resolves `DwPolicyOptions` reads what the query path reads. The options handed to a second +call are frozen too, so nothing goes on setting values that decide nothing. + ### Performance There are two budgets, because there are two costs. Gating is paid **once per query**; @@ -2344,6 +2414,15 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of 24. **A Type in a Namespace That Starts with `System` Is Policed** The attribute walker does not descend into the framework's own types, which carry no policy attributes. Until 3.2.0 it took any namespace whose name started with `System` for the framework's, so an application namespace such as `SystemsCorp.Payroll` or `SystemX.Domain` got no policy beneath its types, and a `[DwDenied]` field on such a type, reached through a member, was returned, filterable and sortable. Fixed (security): only `System` and the namespaces beneath it are the framework's now, so a guarded request that filtered on, sorted by or selected such a field is refused or dropped, as for any denied field. +25. **Under `Strict`, a Path the Query Cannot Compute Is Refused** + Since 3.3.0 a path whose leaf is a member no database can produce — a getter over columns, such as `LocalizedText.IsEmpty`, or an unmapped getter on the entity — is refused with the clause's own code and `FieldPath` `"*"`, as an unknown name is. Until 3.3.0 the package accepted it and EF Core threw `InvalidOperationException`, which reached a caller as a five-hundred where the tier promises a refusal. It applies where the whole set of members a container can produce is known: an entity's model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. A member a custom EF Core translator computes, through `[DbFunction]` or a translator plugin, is refused with the rest: map it, or filter on the columns beneath it. See [Blocked-action semantics](#blocked-action-semantics). + +26. **`LastTrace` Is Set Before a Request Is Sanitized** + Since 3.3.0 `PolicyQueryable.LastTrace` carries the trace of a request that was refused. It used to be assigned after sanitizing returned, so a refusal left it holding the previous request's trace, or null on the first. A strict refusal names no field on purpose, and the trace is where the real path and reason live, so this is what makes one readable. Code that read `LastTrace` after catching a `PolicyException` and expected the earlier request's trace reads this request's now. + +27. **`Configure` Takes the Same Posture Twice** + Since 3.3.0 a second `DwPolicy.Configure` or `AddDwPolicies` asking for the posture already in force returns instead of throwing; a different posture still throws. Code that relied on the second call throwing — a test asserting it, or a `try`/`catch` around a second registration — no longer sees the exception. `AddDwPolicies` also registers the posture in force rather than the instance it built, so a container resolving `DwPolicyOptions` after a second registration gets the first one's. The token vault, the service provider and the provider instances are not compared and are not replaced. See [Configuring twice](#configuring-twice). + --- ## License diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index 80debba..24bef93 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -41,14 +41,24 @@ Sajjad H. Al-Khafaji Free Forever — Copyright © Sajjad H. Al-Khafaji MIT - 3.2.0 + 3.3.0 https://doc.dynamicwhere.com git master https://github.com/Sajadh92/DynamicWhere.ex icon.png README.md - v3.2.0 — A row projected before ApplyPolicy keeps its nested objects and lists, denials the policy gate could not see are enforced, a declared default order reaches projected rows, and every asynchronous terminal takes a CancellationToken. + v3.3.0 — A second host may configure the same posture, a path no database can compute is refused rather than run, and Clone is public on the three request types. + +New: DwPolicy.Configure takes a second call asking for the posture already in force, and does nothing. Until now the first call won and every later one threw, so an integration suite starting several WebApplicationFactory hosts over one composition root had to read IsConfigured before registering — a check-then-act two hosts starting at once can both pass, after which one of them throws. The comparison is made inside the lock that does the configuring, so a caller needs no lock of its own, and it covers everything that decides what a query may do: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap including the group floor's opt-out, the exposed entity catalogue, and the kinds of policy source supplied, in order. A posture differing in any of them is still refused with InvalidOperationException. The token vault, the service provider and the provider instances are not compared, because a second host builds its own and no two are ever the same reference; they stay as the first call left them, so a second host runs with the first host's vault and container. AddDwPolicies registers the posture in force rather than the instance it has just built, and the options handed to a second call are frozen so nothing goes on setting values that decide nothing. + +Behaviour change: under the strict tier, a filter, order, projection, group or aggregate naming a path the query cannot compute is refused as an unknown name is, where the provider used to throw. A member of a row's type is not always a value a database can produce: a getter such as LocalizedText.IsEmpty reads two columns in memory, so every check the policy makes passes and the query then fails inside EF Core with InvalidOperationException — a five-hundred where the strict tier promises a refusal, and the one place the tier answered with neither an answer nor a refusal. The path is refused only where the whole set of members a container can produce is known: an entity type and the types derived from it, an owned or complex type, and an initializer in a projection the caller composed before ApplyPolicy, including a member that projection copies straight from the entity. Everywhere else nothing changes — rows in memory run the getter as they always did, a framework member the provider translates such as Length, Year or Count is left alone, a value beneath a converted column is left alone, the convenience tier and a dry run still fail exactly as an unguarded query does, and a source the library cannot read asks for nothing. The trace records the refusal with its reason, and the trace is now assigned before a request is sanitized, so a refusal leaves LastTrace readable rather than null. + +New: Clone is public on Filter, Segment and Summary. It returns a deep copy — the condition tree with its groups and conditions, the projection list, each order, the page, and a summary's group-by and having clause — so a caller reading the same request again with one part changed, the next page or another order, no longer rebuilds it around the caller's own clauses. Two requests sharing one condition tree is the bug this removes: the library has cloned before rewriting anything since 3.0, and callers could not. + +Docs: [DwEntity(DefaultOrder)]'s own remarks still read as though a default order does not reach a projected query. It has since 3.2.0. DwCaps.MinGroupSize ships on at 5, so a guarded summary drops every group with fewer than five rows unless the deployment sets it to 1; the aggregation documentation now leads with that rather than leaving it to the caps table. + +v3.2.0 — A row projected before ApplyPolicy keeps its nested objects and lists, denials the policy gate could not see are enforced, a declared default order reaches projected rows, and every asynchronous terminal takes a CancellationToken. Security fix: a field denied for Select only beneath a member was not enforced when the caller sent no Selects. A guarded query synthesizes a projection for a denied field, and it did so only when a simple field at the top of the type was denied. With every denial beneath a member, the whole row came back with the denied value in it: in a list or nested object of a row projected before ApplyPolicy, in a row held in memory, and in an entity's included, automatically included, lazily loaded or owned member, typed and dynamic, in both tiers, for a Filter and a Segment. Such a denial now synthesizes the projection whenever its value can reach the result. On an entity that means beneath a column, an owned or complex member, or a navigation the query loads through Include, an automatic include or a lazy loader; a denial beneath a navigation nothing loads never leaves the database, and the entity is read exactly as before. diff --git a/DynamicWhere.ex/Policies/Attributes/DwEntityAttribute.cs b/DynamicWhere.ex/Policies/Attributes/DwEntityAttribute.cs index 29e0082..2980e07 100644 --- a/DynamicWhere.ex/Policies/Attributes/DwEntityAttribute.cs +++ b/DynamicWhere.ex/Policies/Attributes/DwEntityAttribute.cs @@ -35,8 +35,14 @@ public sealed class DwEntityAttribute : Attribute /// its caller asks. A caller who sends orders gets exactly those; the default is never appended to /// them. A query already ordered keeps that order, whether an OrderBy ordered it before /// ApplyPolicy or a composed Order did, even one whose every order the policy dropped. A - /// projected query, through a Select before ApplyPolicy or the guarded Select, - /// takes no default either. An in-memory sequence sorted before + /// projected query takes the default when its outermost Select builds the type in an object + /// initializer and assigns every field the default names a column — a member the model maps, read + /// directly, through reference navigations or through EF.Property — at every level of a + /// nested path. That is the shape a caller writes who projects a row type before guarding it. A + /// constructor with arguments, a field the initializer leaves unassigned, and a field the projection + /// computes each leave the query in its own order, because a default must never be the reason a + /// query that ran unguarded fails. A Select composed on the guarded handle, or a + /// Filter carrying Selects, stays unordered. An in-memory sequence sorted before /// ApplyPolicy is not recognised as ordered, because it reaches the policy as a query with no /// OrderBy in it, so send its order with the filter. End the default with a unique field, such /// as the key, or rows sharing the leading values can still change places between pages. diff --git a/OfficialWebsite/app/docs/ai/page.tsx b/OfficialWebsite/app/docs/ai/page.tsx index fbc40d9..a8cc779 100644 --- a/OfficialWebsite/app/docs/ai/page.tsx +++ b/OfficialWebsite/app/docs/ai/page.tsx @@ -136,7 +136,7 @@ DynamicWhere.ex code. It is the complete API surface.`} - The reference is written against version 3.2.0 from the source, by + The reference is written against version 3.3.0 from the source, by hand, and checked by running the library, so it states behaviour — including the parts that are deliberately blunt, such as neither hashing nor tokenization hiding equality. Where a page in these docs disagrees with it, diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index c01874a..bb3bb5b 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -17,10 +17,21 @@ export default function Page() {

Breaking Changes & Known Limitations

DynamicWhere.ex is intentionally opinionated about how queries are shaped. - The twenty-nine points below cover constraints, surprises, and corner cases — + The thirty-two points below cover constraints, surprises, and corner cases — read them before designing an API around the library so you can pick the right entry points and avoid runtime exceptions in production.

+ + Points 30 to 32 changed in 3.3.0. Under{" "} + Strict, a path that exists on the type and names no value + the query can compute is refused rather than run, where the provider + used to throw and the caller saw a five-hundred (point 30).{" "} + LastTrace is assigned before a request is sanitized, so a + refused request leaves its own trace readable (point 31). And{" "} + DwPolicy.Configure takes a second call asking for the + posture already in force, which is what lets an integration suite start + several hosts over one composition root (point 32). + Points 25 to 29 changed in 3.2.0, and each is visible to code written for 3.1.0. A guarded query that sends no{" "} @@ -1446,6 +1457,100 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`} +

30. Under Strict, a Path the Query Cannot Compute Is Refused

+

+ A member of a row's type is not always a value a database can + produce. A shared type with two columns and a getter over them —{" "} + LocalizedText with Ar, En and{" "} + IsEmpty — gives two paths that translate and one that + cannot. The policy has nothing to say about the third:{" "} + [DwNoWhere] on Name matches that path and not + the ones beneath it. So every check passed, and EF Core threw{" "} + InvalidOperationException — a five-hundred where the strict + tier promises a refusal, and the one place the tier answered a caller + with neither an answer nor a refusal. +

+

+ Since 3.3.0 such a path is refused as an unknown name + is: the clause's own code, FieldPath{" "} + "*", in a filter, an order, a projection, a group + and an aggregate alike. It is refused only where the whole set of + members a container can produce is known. +

+ + + + + + + + + + + + + +
Source of the rowsRead fromName.IsEmpty
An entitythe EF Core model: columns, shadow properties, owned and complex members, navigationsRefused
A row a Select built before ApplyPolicythat initializer's own assignments, at every levelRefused
…where the Select copies the member, Name = role.Namethe model, beneath the member it copiesRefused
Rows in memorynothing — the getter runsRuns, as before
Anything beneath a column, converted or notnothing — the converter decidesRuns, as before
A framework member: Length, Year, Countnothing — the provider translates itRuns, as before
A source the library cannot readnothingRuns, as before
+

+ An unmapped getter on the entity itself,{" "} + Display => $"{Code}:{Id}", + is refused for the same reason. The convenience tier and a dry run are + unchanged: both fail exactly as the unguarded query does, which is the + provider's own error. +

+ + A member EF Core computes through [DbFunction] or an{" "} + IMethodCallTranslatorPlugin is not in the model, so the + strict tier refuses it. Map it, or filter on the columns beneath it. + + +

31. LastTrace Is Set Before a Request Is Sanitized

+

+ Since 3.3.0{" "} + PolicyQueryable<T>.LastTrace carries the trace of a + request that was refused. It used to be assigned after sanitizing + returned, so a refusal left it holding the previous request's + trace, or null on the first. A strict refusal names no field on purpose, + and the trace is where the real path and the reason live, so this is + what makes one readable. Code that read LastTrace after + catching a PolicyException and expected the earlier + request's trace reads this request's now. +

+ +

32. Configure Takes the Same Posture Twice

+

+ Until 3.3.0 the first{" "} + DwPolicy.Configure won and every later call threw, so an + integration suite starting several{" "} + WebApplicationFactory hosts over one composition root had + to read IsConfigured first — a check-then-act two hosts + starting at once can both pass. A second call asking for the posture + already in force now does nothing and returns; one asking for a + different posture still throws{" "} + InvalidOperationException. The comparison happens inside + the lock that does the configuring, so no caller needs a lock of its + own. +

+

+ Compared: the tier, DryRun,{" "} + IncludeTraceInResult, AuditRefusals,{" "} + HashSalt, StoreFailure,{" "} + MaxSnapshotAge, RefreshInterval, every cap + including the group floor's opt-out, the exposed entity catalogue, + and the provider types in the order supplied. Not compared, and not + replaced: TokenVault, Services and the + provider instances — a second host builds its own, and no two are ever + the same reference. +

+ + In one test process that is what you want. Elsewhere, start a second + host only if it is. AddDwPolicies also registers the + posture in force rather than the instance it has just built, so a + container resolving DwPolicyOptions after a second + registration gets the first one's. Code that relied on the second + call throwing no longer sees the exception. + +

See also

  • diff --git a/OfficialWebsite/app/docs/classes/filter/page.tsx b/OfficialWebsite/app/docs/classes/filter/page.tsx index 2f170da..b27ffd8 100644 --- a/OfficialWebsite/app/docs/classes/filter/page.tsx +++ b/OfficialWebsite/app/docs/classes/filter/page.tsx @@ -121,6 +121,22 @@ FilterResult result = await dbContext.Customers.ToListAsync(filter);`} "page": { "pageNumber": 1, "pageSize": 25 } }`} +

    Clone

    +

    + Filter.Clone() is public since 3.3.0. It + returns a deep copy — the condition tree with its groups and conditions, the Selects list, each order and the page — so nothing is shared with the original. +

    + {`Filter page2 = caller.Clone(); +page2.Page!.PageNumber = 2; // the caller's own filter is untouched`} +

    + Reading one request again with a part changed, the next page or another + order, used to mean rebuilding it around the caller's own clauses, + which leaves both requests holding one condition tree: a rewrite of + either reaches both. The library has cloned before rewriting anything + since 3.0; callers could not until now. A branch the caller left null + stays null. +

    +

    See also

    • diff --git a/OfficialWebsite/app/docs/classes/segment/page.tsx b/OfficialWebsite/app/docs/classes/segment/page.tsx index bdbf148..f64f578 100644 --- a/OfficialWebsite/app/docs/classes/segment/page.tsx +++ b/OfficialWebsite/app/docs/classes/segment/page.tsx @@ -168,6 +168,22 @@ SegmentResult result = await dbContext.Customers.ToListAsync(segment); "page": { "pageNumber": 1, "pageSize": 50 } }`} +

      Clone

      +

      + Segment.Clone() is public since 3.3.0. It + returns a deep copy — every condition set with its own condition group, the Selects list, each order and the page — so nothing is shared with the original. +

      + {`Segment page2 = caller.Clone(); +page2.Page!.PageNumber = 2; // the caller's own segment is untouched`} +

      + Reading one request again with a part changed, the next page or another + order, used to mean rebuilding it around the caller's own clauses, + which leaves both requests holding one condition tree: a rewrite of + either reaches both. The library has cloned before rewriting anything + since 3.0; callers could not until now. A branch the caller left null + stays null. +

      +

      See also

      • diff --git a/OfficialWebsite/app/docs/classes/summary/page.tsx b/OfficialWebsite/app/docs/classes/summary/page.tsx index f705251..ba0c94f 100644 --- a/OfficialWebsite/app/docs/classes/summary/page.tsx +++ b/OfficialWebsite/app/docs/classes/summary/page.tsx @@ -168,6 +168,22 @@ SummaryResult result = await dbContext.Orders.ToListAsync(summary);`} "page": { "pageNumber": 1, "pageSize": 20 } }`} +

        Clone

        +

        + Summary.Clone() is public since 3.3.0. It + returns a deep copy — the condition group, the group-by with its aggregates, the having clause, each order and the page — so nothing is shared with the original. +

        + {`Summary page2 = caller.Clone(); +page2.Page!.PageNumber = 2; // the caller's own summary is untouched`} +

        + Reading one request again with a part changed, the next page or another + order, used to mean rebuilding it around the caller's own clauses, + which leaves both requests holding one condition tree: a rewrite of + either reaches both. The library has cloned before rewriting anything + since 3.0; callers could not until now. A branch the caller left null + stays null. +

        +

        See also

        • diff --git a/OfficialWebsite/app/docs/extensions/summary/page.tsx b/OfficialWebsite/app/docs/extensions/summary/page.tsx index a24e8b8..57a9366 100644 --- a/OfficialWebsite/app/docs/extensions/summary/page.tsx +++ b/OfficialWebsite/app/docs/extensions/summary/page.tsx @@ -21,6 +21,16 @@ export default function Page() { returns a dynamic IQueryable.

          + + DwCaps.MinGroupSize ships on, at 5, so a + guarded summary removes every group with fewer than five rows — not + refused, and nothing in the answer says a group was dropped. That is + right for anonymised reporting and surprising for an operational count. + Set Caps.MinGroupSize = 1 to switch the floor off, + deliberately. An unguarded call is never floored. See{" "} + k-anonymity. + +

          Signature

          {`public static IQueryable Summary(this IQueryable query, Summary summary) where T : class`} diff --git a/OfficialWebsite/app/docs/extensions/to-list-async-summary/page.tsx b/OfficialWebsite/app/docs/extensions/to-list-async-summary/page.tsx index 3a309d3..ea7b9a7 100644 --- a/OfficialWebsite/app/docs/extensions/to-list-async-summary/page.tsx +++ b/OfficialWebsite/app/docs/extensions/to-list-async-summary/page.tsx @@ -26,6 +26,16 @@ export default function Page() { CancellationToken, which reaches both.

          + + DwCaps.MinGroupSize ships on, at 5, so a + guarded summary removes every group with fewer than five rows — not + refused, and nothing in the answer says a group was dropped. That is + right for anonymised reporting and surprising for an operational count. + Set Caps.MinGroupSize = 1 to switch the floor off, + deliberately. An unguarded call is never floored. See{" "} + k-anonymity. + +

          Signature

          {`public static Task ToListAsync( this IQueryable query, diff --git a/OfficialWebsite/app/docs/extensions/to-list-summary/page.tsx b/OfficialWebsite/app/docs/extensions/to-list-summary/page.tsx index c6425ec..b301c3e 100644 --- a/OfficialWebsite/app/docs/extensions/to-list-summary/page.tsx +++ b/OfficialWebsite/app/docs/extensions/to-list-summary/page.tsx @@ -25,6 +25,16 @@ export default function Page() { with pagination metadata.

          + + DwCaps.MinGroupSize ships on, at 5, so a + guarded summary removes every group with fewer than five rows — not + refused, and nothing in the answer says a group was dropped. That is + right for anonymised reporting and surprising for an operational count. + Set Caps.MinGroupSize = 1 to switch the floor off, + deliberately. An unguarded call is never floored. See{" "} + k-anonymity. + +

          IQueryable<T> overload

          {`public static SummaryResult ToList( this IQueryable query, diff --git a/OfficialWebsite/app/docs/installation/page.tsx b/OfficialWebsite/app/docs/installation/page.tsx index 88a27ce..075f69b 100644 --- a/OfficialWebsite/app/docs/installation/page.tsx +++ b/OfficialWebsite/app/docs/installation/page.tsx @@ -28,14 +28,14 @@ export default function Page() {

          dotnet CLI

          - {`dotnet add package DynamicWhere.ex --version 3.2.0`} + {`dotnet add package DynamicWhere.ex --version 3.3.0`}

          Package Manager (Visual Studio)

          - {`Install-Package DynamicWhere.ex -Version 3.2.0`} + {`Install-Package DynamicWhere.ex -Version 3.3.0`}

          PackageReference (csproj)

          {` - + `}

          Dependencies

          diff --git a/OfficialWebsite/app/docs/page.tsx b/OfficialWebsite/app/docs/page.tsx index 8101e10..b7b514d 100644 --- a/OfficialWebsite/app/docs/page.tsx +++ b/OfficialWebsite/app/docs/page.tsx @@ -30,7 +30,7 @@ export default function Page() {

          - Version 3.2.0. Target framework .NET 6+. + Version 3.3.0. Target framework .NET 6+. License MIT — free forever, for commercial and personal use.

          @@ -83,7 +83,7 @@ export default function Page() {

          30-second tour

          Install the package:

          - {`dotnet add package DynamicWhere.ex --version 3.2.0`} + {`dotnet add package DynamicWhere.ex --version 3.3.0`}

          Build a filter from a JSON body and apply it to a DbSet:

          {`using DynamicWhere.ex.Source; diff --git a/OfficialWebsite/app/docs/policies/admin/page.tsx b/OfficialWebsite/app/docs/policies/admin/page.tsx index bb4f0f5..34441f3 100644 --- a/OfficialWebsite/app/docs/policies/admin/page.tsx +++ b/OfficialWebsite/app/docs/policies/admin/page.tsx @@ -15,7 +15,7 @@ export default function Page() { return (

          Admin API

          - {`dotnet add package DynamicWhere.ex.Policies.AspNetCore --version 3.2.0`} + {`dotnet add package DynamicWhere.ex.Policies.AspNetCore --version 3.3.0`} {`app.MapDwPolicyAdmin(options => { options.RoutePrefix = "/dw-policies"; // the default; mount it anywhere diff --git a/OfficialWebsite/app/docs/policies/configuration/page.tsx b/OfficialWebsite/app/docs/policies/configuration/page.tsx index 8867c36..7419252 100644 --- a/OfficialWebsite/app/docs/policies/configuration/page.tsx +++ b/OfficialWebsite/app/docs/policies/configuration/page.tsx @@ -594,7 +594,7 @@ PolicyTrace? trace = guarded.LastTrace;`} SchemaDepth2Levels a schema request walks when it names no depth. SchemaCycleLimit2Times one type may appear on one path. MaxSchemaFields2000Fields one schema response may carry before it truncates. - MinGroupSize5k-anonymity group floor. Set 1 to switch it off. See Security. + MinGroupSize5On by default. A guarded summary drops every group with fewer than five rows, and nothing in the answer says so. Set 1 to switch it off. See Security.

          @@ -904,6 +904,65 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so which refuse it in any clause, while a filter still orders by it.

          +

          Configuring twice

          +

          + The first call decides the posture. Since 3.3.0 a + second DwPolicy.Configure, or a second{" "} + AddDwPolicies, asking for the posture already in + force does nothing and returns; one asking for a different + posture still throws InvalidOperationException. The + comparison happens inside the lock that does the configuring, so a + caller needs no lock and no IsConfigured check of its own — + which matters, because that check is a check-then-act two hosts starting + at once can both pass. +

          +

          + This is what an integration suite needs. Several{" "} + WebApplicationFactory<Program> hosts run the same + composition root, and before 3.3.0 the second one threw, so every such + suite wrote the check itself and re-registered{" "} + DwPolicy.Options by hand. +

          + + + + + + + + + + + + + + + + + + + + + + + + +
          ComparedNot compared
          Tier, DryRun, IncludeTraceInResult, AuditRefusalsTokenVault
          HashSalt, StoreFailure, MaxSnapshotAge, RefreshIntervalServices
          Every value on Caps, the group floor's opt-out includedThe provider instances
          The exposed entity catalogue: the same types under the same names
          The provider types, in the order they were supplied
          + + The three on the right are objects a host builds for itself, and a + second host builds its own, so comparing them by reference would make + every second call a refusal. They stay as the first call left them. In + one test process that is what you want; start a second host in + production only if it is. + +

          + AddDwPolicies registers the posture in force rather than + the instance it has just built, so whatever resolves{" "} + DwPolicyOptions reads what the query path reads. The + options handed to a second call are frozen too, so nothing goes on + setting values that decide nothing. +

          +

          Configuration from a file

          Every value on the posture binds from IConfiguration. Three diff --git a/OfficialWebsite/app/docs/policies/providers/page.tsx b/OfficialWebsite/app/docs/policies/providers/page.tsx index 1cdead2..3416da2 100644 --- a/OfficialWebsite/app/docs/policies/providers/page.tsx +++ b/OfficialWebsite/app/docs/policies/providers/page.tsx @@ -22,7 +22,7 @@ export default function Page() {

          Redis

          - {`dotnet add package DynamicWhere.ex.Policies.Redis --version 3.2.0`} + {`dotnet add package DynamicWhere.ex.Policies.Redis --version 3.3.0`} {`var redis = await ConnectionMultiplexer.ConnectAsync(connectionString); var store = new RedisPolicyStore(redis); @@ -37,7 +37,7 @@ DwPolicy.Configure(options, provider);`}

          Entity Framework Core

          - {`dotnet add package DynamicWhere.ex.Policies.EntityFrameworkCore --version 3.2.0`} + {`dotnet add package DynamicWhere.ex.Policies.EntityFrameworkCore --version 3.3.0`} {`var policyDbOptions = new DbContextOptionsBuilder() .UseNpgsql(connection, sql => sql.MigrationsAssembly("YourProject")) .Options; diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx index 40fbf3d..b43bce7 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -184,6 +184,36 @@ true order. Add [DwNoOrder] unless that is intended.`} together describe how the rows are partitioned.

          +

          A path the query cannot compute

          +

          + A member of a row's type is not always a value a database can + produce. A shared kernel type carrying two columns and a getter over + them gives Name.Ar and Name.En, which + translate, and Name.IsEmpty, which does not. The policy has + nothing to say about the third — [DwNoWhere] on{" "} + Name matches that path and not the ones beneath it — so + until 3.3.0 every check passed and EF Core threw. The + caller got a five-hundred where the strict tier promises a refusal. +

          +

          + Such a path is now refused as an unknown name is: the clause's own + code, FieldPath "*", so it cannot be + told from a misspelling or from a field the caller may not use. It is + refused only where the whole set of members a container can produce is + known — an entity's own EF Core model, and the initializers of a + projection composed before ApplyPolicy, including a member + that projection copies from the entity. Rows in memory, a framework + member the provider translates such as Length or{" "} + Year, anything beneath a column, the convenience tier and a + dry run are all unchanged, and fail exactly as the unguarded query does. +

          +

          + This closes no leak: the query failed, it did not answer. It removes a + way of telling one member from another by the shape of the failure, and + it keeps the tier's promise that a guarded request is answered or + refused. The trace records the reason. +

          +

          7 and 8. The two that are not channels

          diff --git a/OfficialWebsite/lib/nav.ts b/OfficialWebsite/lib/nav.ts index 077ead3..979e3b3 100644 --- a/OfficialWebsite/lib/nav.ts +++ b/OfficialWebsite/lib/nav.ts @@ -8,7 +8,7 @@ export const SITE = { "DynamicWhere.ex is a free .NET library for building dynamic, JSON-driven LINQ queries on Entity Framework Core — filter, sort, paginate, project, group, aggregate, and run UNION / INTERSECT / EXCEPT set operations from your front-end. Works with ASP.NET Core on .NET 6, 7, 8, 9, and 10.", shortDescription: "Dynamic JSON filter, sort, paginate, group, aggregate, and set operations for EF Core. .NET 6/7/8/9/10.", - version: "3.2.0", + version: "3.3.0", domain: "doc.dynamicwhere.com", repo: "https://github.com/Sajadh92/DynamicWhere.ex", nuget: "https://www.nuget.org/packages/DynamicWhere.ex", diff --git a/OfficialWebsite/package.json b/OfficialWebsite/package.json index f17a1de..cac3161 100644 --- a/OfficialWebsite/package.json +++ b/OfficialWebsite/package.json @@ -1,6 +1,6 @@ { "name": "dynamicwhere-docs", - "version": "3.2.0", + "version": "3.3.0", "private": true, "scripts": { "dev": "next dev", diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index 4149939..d09fe56 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -4,7 +4,7 @@ > opt-in field-level policy layer that decides what each caller may filter, sort, select, group, > aggregate and see, and a reflection cache that needs no setup. > -> Version 3.2.0 · targets net6.0 · runs on .NET 6, 7, 8, 9, 10 · EF Core 6+ · MIT +> Version 3.3.0 · targets net6.0 · runs on .NET 6, 7, 8, 9, 10 · EF Core 6+ · MIT > Docs: https://doc.dynamicwhere.com · Source: https://github.com/Sajadh92/DynamicWhere.ex This file is the whole library in one pass: every public type and member of the four packages, the @@ -14,7 +14,7 @@ library. No other page is needed; where another page disagrees with this file, t Where a name is not in this file, it does not exist — do not invent members. ``` -dotnet add package DynamicWhere.ex --version 3.2.0 +dotnet add package DynamicWhere.ex --version 3.3.0 dotnet add package DynamicWhere.ex.Policies.Redis # optional, same version as the core dotnet add package DynamicWhere.ex.Policies.EntityFrameworkCore # optional, same version as the core dotnet add package DynamicWhere.ex.Policies.AspNetCore # optional, same version as the core @@ -203,6 +203,15 @@ Summary ConditionGroup ConditionGroup? null where, bef Having ConditionGroup? null each Condition.Field is an AggregateBy.Alias, not a path Orders List? null each Field is a GroupBy field or an Alias Page PageBy? null pages the groups + +Clone() on Filter, Segment and Summary public since 3.3.0; deep copy, shares no object + with the original: the condition tree with its + groups and conditions, the Selects list, each + OrderBy, the Page, and a Summary's GroupBy with + its AggregateBy list and its Having. A null + branch stays null. Use it to read one request + again with a different page or order without + editing what the caller handed in. ``` ``` @@ -675,7 +684,7 @@ against 3.1 still binds. - Validation rewrites the caller's objects in place: path strings get the declared casing (`"price"` → `"Price"`) in `Condition.Field`, `OrderBy.Field`, `GroupBy.Fields`, `AggregateBy.Field` and `Selects`; null `Values`, `Conditions`, `SubConditionGroups`, `ConditionSets`, `Fields` and `AggregateBy` become empty lists; the first - `ConditionSet`'s `Intersection` becomes null. Clone a shape before reusing it if that matters. + `ConditionSet`'s `Intersection` becomes null. Call `Clone()` on the shape before reusing it if that matters (3.3.0). ### Select @@ -1320,7 +1329,7 @@ Each of these compiles, passes validation, and returns something other than what 3. **Negated operators never match null.** `NotEqual`, `NotIn`, `NotContains`, `NotStartsWith`, `NotEndsWith` and `NotBetween` all exclude rows whose member is null. Add an `IsNull` condition in an `Or` group to keep them. 4. **Validation rewrites the request objects you pass.** Paths are re-cased, null lists become empty, the first - set's `Intersection` is cleared. Clone a shape before reusing it. (Guarded calls work on a clone.) + set's `Intersection` is cleared. Call `Clone()` on the shape before reusing it (3.3.0). (Guarded calls work on a clone.) 5. **Enum names in JSON need `JsonStringEnumConverter`.** Without it the body fails to bind. An omitted enum property silently becomes member 0: `Text`, `Equal`, `And`, `Ascending`, `Count`. 6. **A `DateTime` member reads a zoned value as server local time.** A value carrying `Z` or an offset is converted @@ -1446,7 +1455,19 @@ DwPolicy stat - `Configure` refuses only two things: - null `options` → `ArgumentNullException`; - - a second call, including one made by `AddDwPolicies` → `InvalidOperationException`. + - a second call, including one made by `AddDwPolicies`, asking for a **different** posture → `InvalidOperationException`. +- A second call asking for the posture already in force is a no-op (3.3.0). It returns, `DwPolicy.Options` keeps + pointing at the first call's instance, and the options passed to the second call are frozen. The check runs inside + the lock that configures, so a caller needs no `IsConfigured` check of its own — that check is a check-then-act two + hosts starting at once can both pass. This is what lets an integration suite start several + `WebApplicationFactory` hosts over one composition root. + - Compared: `Tier`, `DryRun`, `IncludeTraceInResult`, `AuditRefusals`, `HashSalt`, `StoreFailure`, + `MaxSnapshotAge`, `RefreshInterval`, every `DwCaps` value with `IsMinGroupSizeSet`, the exposed catalogue + (same types under the same names), and the provider **types**, in the order supplied, with + `AttributePolicyProvider` left out of both sides. + - Not compared, and not replaced: `TokenVault`, `Services`, and the provider **instances**. A second host builds + its own and no two are ever the same reference, so they stay as the first call left them — a second host runs + with the first host's vault, container and rule stores. - `Configure` freezes `options`, `options.Caps` and `options.Entities`. Any setter or `Expose` after that → `InvalidOperationException`. - `Configure` does not validate the model. - Invalid values were already refused by the setters. @@ -1478,14 +1499,19 @@ DwPolicyConfiguration stat Bind(this DwPolicyOptions options, IConfiguration section) -> DwPolicyOptions same instance ``` -`AddDwPolicies` does exactly this: `new DwPolicyOptions().Bind(section)` → `configure?.Invoke(options)` → `DwPolicy.Configure(options, providers)` → `services.AddSingleton(options)`. +`AddDwPolicies` does exactly this: `new DwPolicyOptions().Bind(section)` → `configure?.Invoke(options)` → `DwPolicy.Configure(options, providers)` → `services.AddSingleton(DwPolicy.Options)`. + +Since 3.3.0 the singleton registered is the posture **in force**, not the instance this call built. On the first call +they are the same object. On a later call asking for the same posture the first one's is registered, so whatever +resolves `DwPolicyOptions` reads what the query path reads. A later call asking for a different posture still throws. - There is one overload, and `section` is required (null → `ArgumentNullException`). - `"DynamicWhere:Policies"` is a convention; any section works. - For configuration from code only, call `DwPolicy.Configure`. - Configuration binds first and `configure` runs second, so code wins over the file. - It configures the static `DwPolicy` immediately, during service registration. Anything set in `configure` (`Services` included) must already exist at that point. -- It registers only the frozen `DwPolicyOptions` singleton. No resolver, sink, vault, catalogue or provider is registered. +- It registers only the frozen `DwPolicyOptions` singleton — the one in force. No resolver, sink, vault, catalogue or provider is registered. +- Calling it a second time with the same posture is safe (3.3.0), which is what an integration suite starting several hosts needs. - A `StorePolicyProvider` reads `StoreFailure`, `MaxSnapshotAge` and `RefreshInterval` from the options instance passed to `StorePolicyProvider.CreateAsync`. - `AddDwPolicies` creates its options internally, so a provider cannot share them. With a store, bind by hand: @@ -1811,6 +1837,9 @@ Rules: - On a fresh handle it is the original, tracked source with no predicates. - Calling a DynamicWhere extension on it for a `RequirePolicy` type → `PolicyRequired`. - `LastTrace` is set on the handle the method was called on, not on the handle it returns. +- `LastTrace` is assigned before the request is sanitized (3.3.0), so a refused request leaves its own trace + readable. It used to be assigned after, so a refusal left the previous request's trace, or null. A strict refusal + names no field, and the trace is where the real path and reason are. - It holds the latest call that got past sanitizing, or a `QueryStringDenied` refusal. - Refusals throw `PolicyException` (a `LogicException`) from the handle method. - With `DwPolicyOptions.AuditRefusals` on, the terminal and composable methods also write the refusal to the context's audit buffer on its way out, once, without changing or catching it (section 22). @@ -2282,6 +2311,26 @@ The resolver, fragment and provider types are listed in section 21. ## 17. Enforcement by tier and dry run +Under `Strict` outside a dry run, a path that exists on the type but names no value the query can compute is refused +as an unknown name is (3.3.0), with the clause's own code and `FieldPath` `"*"`. What decides: + +``` +Source of the rows What the set of producible members is read from Name.IsEmpty +entity query EF Core model: columns (shadow included), owned, + complex, navigations, and derived types refused +Select(...) before ApplyPolicy building the row that initializer's assignments, at every level refused + ... a member it copies whole: Name = role.Name the model, beneath the copied member refused +rows in memory (EnumerableQuery) nothing; the getter runs runs +beneath a column, converted or not nothing; the converter decides runs +a framework member: Length, Year, Count, HasValue nothing; the provider translates it runs +a source the library cannot read into nothing runs +``` + +- An unmapped getter on the entity itself (`Display => $"{Code}:{Id}"`) is refused for the same reason. +- `Convenience` and a dry run are unchanged: the provider throws, as it does unguarded. +- A member a `[DbFunction]` or an `IMethodCallTranslatorPlugin` computes is not in the model and is refused too. +- The trace records it: `the member exists on the type and the query cannot compute it, so it is refused as an unknown name is`. + The count caps are checked first, before any name is resolved (3.1.0). Names are then resolved to canonical paths, `MaxNavigationDepth` is checked, and the cost budget is checked before field policies — except under `Strict` outside dry run, where it is checked after them (3.1.0). Nothing is clamped. @@ -2715,6 +2764,10 @@ ValueTransform(MutateStage? mutate = null, GeneralizeStage? generalize = null, F ## 19. Group floor and transformed summaries +**The floor is on by default.** `Caps.MinGroupSize` ships at 5, so a guarded summary drops every group with fewer +than five rows, and nothing in the answer says a group was dropped. Right for anonymised reporting, surprising for +an operational count. `Caps.MinGroupSize = 1` switches it off, deliberately. An unguarded summary is never floored. + ``` effective floor = max(Caps.MinGroupSize, MinGroupSize of the chain of each AggregateBy.Field) Caps.MinGroupSize unset -> DwCaps.DefaultMinGroupSize = 5 (IsMinGroupSizeSet = false); 1 = no global floor @@ -4774,6 +4827,29 @@ MemoryCalculationInput ### History ``` +3.3.0 A second host may configure the same posture, a path the query cannot compute is refused rather than run, + and Clone is public on Filter, Segment and Summary. + - New. DwPolicy.Configure, and AddDwPolicies with it, take a second call asking for the posture already in + force and do nothing. A different posture still throws. The check is inside the lock that configures, so a + caller needs no IsConfigured check of its own — that check is a check-then-act two hosts can both pass. + Compared: tier, DryRun, IncludeTraceInResult, AuditRefusals, HashSalt, StoreFailure, MaxSnapshotAge, + RefreshInterval, every cap with IsMinGroupSizeSet, the exposed catalogue, and the provider types in order. + Not compared and not replaced: TokenVault, Services and the provider instances, so a second host runs with + the first host's vault, container and rule stores. AddDwPolicies registers the posture in force rather than + the instance it built. + - Behaviour change. Under Strict outside a dry run, a path that exists on the type and names no value the + query can compute — LocalizedText.IsEmpty over two columns, an unmapped getter on the entity — is refused + as an unknown name is, in every clause, where the provider used to throw and the caller saw a five-hundred. + Refused only where the producible members are known: an entity's model, and the initializers of a + projection composed before ApplyPolicy, including a member it copies from the entity. Rows in memory, a + framework member such as Length or Year, anything beneath a column, Convenience and a dry run are unchanged. + - Behaviour change. LastTrace is assigned before a request is sanitized, so a refused request leaves its own + trace readable instead of the previous request's. + - New. Clone() is public on Filter, Segment and Summary. A deep copy: the condition tree, the projection list, + each order, the page, and a summary's GroupBy and Having. + - Docs. DwEntity(DefaultOrder)'s remarks said a projected query takes no default; it has since 3.2.0. The + aggregation sections now lead with Caps.MinGroupSize shipping on at 5. + 3.2.0 A projected row keeps its members, denials the gate could not see are enforced, a default order that reaches projected rows, and a CancellationToken on every async terminal. The security fixes refuse or withhold what 3.1.0 returned; the bullets marked "Behaviour change" also change what a correct query @@ -5019,6 +5095,12 @@ MemoryCalculationInput `[Column]`. Reached through a navigation (`Owner.New`) it is an ordinary member. - A declared date format may not write text ISO 8601 or a year-first date already reads; `DwDates.Configure` refuses one (section 4). ISO 8601 and year-first dates are read on every deployment and cannot be turned off. +- A member no database can compute — a getter over columns, an unmapped getter on an entity, a member a + `[DbFunction]` or a translator plugin computes — cannot be filtered, ordered, selected, grouped or aggregated under + `Strict` on a source the library can read (3.3.0, section 17). Map it, or name the columns beneath it. Under + `Convenience` it still reaches the provider and still throws there. +- A second host configuring the same posture runs with the first host's `TokenVault`, `Services` and rule store + instances (3.3.0, section 12). They are not compared and not replaced. - Summary rows flatten dotted group fields (`CategoryName`); `SelectDynamic` rows nest them (`Category.Name`). - `Filter` applies `Orders` and `Page` on T before the projection, so order fields need not be selected. - `getQueryString` needs an EF Core provider. diff --git a/README.md b/README.md index 663e9fe..6de7961 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,13 @@ Stop concatenating LINQ predicates by hand. Your front-end sends one JSON shape; ## Install ```bash -dotnet add package DynamicWhere.ex --version 3.2.0 +dotnet add package DynamicWhere.ex --version 3.3.0 ``` Or via Package Manager: ```powershell -Install-Package DynamicWhere.ex -Version 3.2.0 +Install-Package DynamicWhere.ex -Version 3.3.0 ``` Dependencies (restored automatically): @@ -375,6 +375,16 @@ The complete reference — every enum, class, extension method, validation rule, --- +## Version 3.3.0 highlights + +**Upgrade note — two behaviour changes. Read these before bumping.** + +- **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. +- **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. A member a custom EF Core translator computes, through `[DbFunction]` or a translator plugin, is refused with the rest: map it, or filter on the columns beneath it. +- **Changed: `LastTrace` is set before a request is sanitized**, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. +- **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. +- **Docs:** `[DwEntity(DefaultOrder)]`'s own remarks said a projected query takes no default; it has since 3.2.0. `DwCaps.MinGroupSize` ships **on at 5**, so a guarded summary silently drops groups of fewer than five rows — the aggregation docs now lead with that instead of leaving it to the caps table. + ## Version 3.2.0 highlights **Upgrade note — the security fixes and the three changes alter what code written for 3.1.0 does, and the new overloads can stop a call from compiling. Read these before bumping.** From 757e4689ac932b3338a432ae1e882f15c868bce5 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 12:43:46 +0300 Subject: [PATCH 03/54] test(policies): a composed clause reads the source too, and every posture value is proved compared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clause composed on the guarded handle — Where, Order, Select, Page — now passes the source's shape to the sanitizer, so it refuses a path the query cannot compute exactly as a terminal does. Without it the same filter was refused through ToList and ran to the provider's own failure through Where. The posture comparison is now checked by reflection: every settable value on DwPolicyOptions and DwCaps is changed in turn and the second configure must refuse it. A value added later and forgotten in the comparison would let a second host run with a posture it did not ask for, and this fails until somebody decides which column it belongs in. TokenVault and Services are named as the two that are not compared, and a stale name there fails too. Also probed: a member a projection never assigns fails unguarded, which is why refusing it guarded is right rather than an over-block. Co-Authored-By: Claude Opus 5 --- .../Policies/ConfigureOnceTests.cs | 71 +++++++++++++++++++ .../Policies/UnexpressiblePathTests.cs | 38 ++++++++++ .../Policies/Source/PolicyQueryable.cs | 3 +- 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs index e3c2271..5056c8e 100644 --- a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs +++ b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using DynamicWhere.ex.Policies.Config; using DynamicWhere.ex.Policies.Enums; using DynamicWhere.ex.Policies.Resolution; @@ -279,6 +280,76 @@ public void A_second_registration_hands_the_container_the_posture_in_force() Assert.Same(DwPolicy.Options, provider.GetRequiredService()); } + /// + /// Every settable value on the posture and on the caps decides whether a second call is the + /// same posture, and the two that do not are named here on purpose. + /// + /// + /// Written by reflection rather than by hand: a value added to DwPolicyOptions or + /// DwCaps later and forgotten in the comparison would let a second host run with a + /// posture it did not ask for, silently, which is the one failure this feature can cause. A + /// new property fails this test until somebody decides which column it belongs in. + /// + [Theory] + [InlineData(typeof(DwPolicyOptions))] + [InlineData(typeof(DwCaps))] + public void Every_value_on_the_posture_is_compared(Type declaring) + { + // The objects a host builds for itself. A second host builds its own, so comparing them + // by reference would refuse every second call; they stay as the first call left them. + HashSet notCompared = new() { nameof(DwPolicyOptions.TokenVault), nameof(DwPolicyOptions.Services) }; + + List settable = declaring + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.GetSetMethod() is not null) + .Select(property => property.Name) + .ToList(); + + Assert.NotEmpty(settable); + + foreach (string name in settable) + { + if (notCompared.Remove(name)) + { + continue; + } + + DwPolicyOptions different = Copy(); + object target = declaring == typeof(DwCaps) ? different.Caps : different; + PropertyInfo property = declaring.GetProperty(name)!; + + property.SetValue(target, Other(property.GetValue(target), property.PropertyType)); + + DwPolicyOptions inForce = DwPolicy.Options; + + Assert.Throws(() => DwPolicy.Configure(different)); + Assert.Same(inForce, DwPolicy.Options); + } + + // Both names belong to the posture, so nothing may be left over once it has been walked. + // A stale exclusion would otherwise sit here hiding a property nobody compares. + if (declaring == typeof(DwPolicyOptions)) + { + Assert.Empty(notCompared); + } + } + + /// A value of the same type that is not the one held. + private static object? Other(object? held, Type type) => type switch + { + _ when type == typeof(bool) => !(bool)held!, + _ when type == typeof(bool?) => held is true ? false : true, + _ when type == typeof(int) => (int)held! + 1, + _ when type == typeof(TimeSpan) => (TimeSpan)held! + TimeSpan.FromMinutes(1), + _ when type == typeof(string) => new string('x', DwPolicyOptions.MinimumHashSaltLength + 2), + _ when type == typeof(DwTier) => (DwTier)held! == DwTier.Strict ? DwTier.Convenience : DwTier.Strict, + _ when type == typeof(StoreFailureMode) => (StoreFailureMode)held! == StoreFailureMode.FailClosed + ? StoreFailureMode.LastKnownGood + : StoreFailureMode.FailClosed, + _ => throw new InvalidOperationException( + $"No second value is defined for {type.Name}. Add one, and decide whether the comparison covers it.") + }; + private static void Refused(DwPolicyOptions different, params IDwPolicyProvider[] providers) { DwPolicyOptions inForce = DwPolicy.Options; diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs index 342425b..93a258a 100644 --- a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs +++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs @@ -250,6 +250,26 @@ public void A_projection_naming_it_is_refused() Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode); } + [Fact] + public void A_clause_composed_on_the_handle_is_refused_too() + { + // The composed pipeline reads the same source, so it answers as the terminal does. + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Built(), DwTier.Strict).Where(new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Name.IsEmpty", DataType = DataType.Boolean, + Operator = Operator.Equal, Values = { "false" } + } + } + })); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + [Fact] public void A_summary_grouping_on_it_is_refused() { @@ -268,6 +288,24 @@ public void A_summary_grouping_on_it_is_refused() // ---- what is left alone --------------------------------------------------------------------------- + [Fact] + public void A_member_the_projection_never_assigns_fails_unguarded_and_is_refused_guarded() + { + // Unguarded first: what the provider does with it decides whether refusing is right. + IQueryable rows = _db.Roles.Select(role => new ZyRoleRow { Id = role.Id, Code = role.Code }); + + Exception? unguarded = Record.Exception(() => rows.Where(row => row.Name.Ar == "مدير").ToList()); + + _out.WriteLine($"unguarded: {unguarded?.GetType().Name ?? "ran"}"); + + Assert.NotNull(unguarded); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(rows, DwTier.Strict).ToList(Where("Name.Ar", "مدير"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + [Fact] public void Rows_in_memory_run_the_getter_as_they_always_did() { diff --git a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs index 1311032..fef2b5a 100644 --- a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs +++ b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs @@ -935,7 +935,8 @@ private Filter SanitizeClause(Filter clause, bool applyDefaultOrder = false) LastTrace = trace; Filter sanitized = FilterSanitizer.Sanitize( - clause, _resolver, _context, _options, trace, synthesizeProjection: false, applyDefaultOrder); + clause, _resolver, _context, _options, trace, synthesizeProjection: false, applyDefaultOrder, + rows: RowShape.Of(_source)); return sanitized; } From 073614c545bcdb98908fcd6d5db136785075ac0d Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 12:50:24 +0300 Subject: [PATCH 04/54] test: stop blocking the thread pool, and give the store watch a budget a loaded run can meet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3.2.0 publish run failed on one test — the store conformance watch, which waits up to ten seconds for a store to report a change — while the same suite had just passed twice on CI. The cause is not that store: four reads in the wrapper suite blocked a thread-pool thread each for a whole query, which is enough, on a two-core runner in Release, to leave the watch's reader unscheduled until its budget runs out. Those four reads are awaited now, through a ZwKit.CodeAsync that matches Code, and the build is warning-free again (xUnit1031). The watch test waits on the watch rather than on the clock, and its budget is sixty seconds: only a store that never reports waits that long, and a budget tight enough to catch a real hang is also tight enough to fail a healthy run under load. Co-Authored-By: Claude Opus 5 --- .../Policies/PolicyStoreConformanceTests.cs | 10 +++++-- .../Policies/ReviewJoinEvalTests.cs | 26 +++++++++++++++++++ .../Policies/ReviewTrackingWrapperTests.cs | 8 +++--- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/DynamicWhere.Tests/Policies/PolicyStoreConformanceTests.cs b/DynamicWhere.Tests/Policies/PolicyStoreConformanceTests.cs index c4c80ac..988b92e 100644 --- a/DynamicWhere.Tests/Policies/PolicyStoreConformanceTests.cs +++ b/DynamicWhere.Tests/Policies/PolicyStoreConformanceTests.cs @@ -171,7 +171,11 @@ public async Task A_change_is_observable_through_a_watch_or_through_a_poll() long before = await store.GetVersionAsync(default); - using CancellationTokenSource cancel = new(TimeSpan.FromSeconds(10)); + // Generous on purpose. The watch runs on the thread pool, and a suite of two thousand tests + // can leave it waiting behind work that has nothing to do with this store; a budget tight + // enough to catch a real hang is also tight enough to fail a healthy run under load. Only a + // store that never reports a change waits this long. + using CancellationTokenSource cancel = new(TimeSpan.FromSeconds(60)); IAsyncEnumerable? watch = store.WatchAsync(cancel.Token); @@ -200,7 +204,9 @@ public async Task A_change_is_observable_through_a_watch_or_through_a_poll() { await store.UpsertAsync(Rule(), cancel.Token); - await Task.Delay(25, cancel.Token); + // Waits on the watch itself rather than on the clock, so the loop ends as soon as the + // store reports and does not go on writing while the reader waits to be scheduled. + await Task.WhenAny(observed, Task.Delay(25, cancel.Token)); } Assert.True(await observed > before); diff --git a/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs b/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs index 48ae8e1..f736716 100644 --- a/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs +++ b/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs @@ -209,12 +209,38 @@ public IQueryable Level1() return _db.Customers.Where(c => c.Name != string.Empty).SelectMany(c => level2.Where(x => x.Id == c.Id)); } + /// Customers already in memory, handed out as a query. public IQueryable InMemory(List held) => held.AsQueryable(); } internal static class ZwKit { + /// The asynchronous twin of , so a test awaits rather than blocks. + /// + /// Blocking on an asynchronous read holds a thread-pool thread for the whole query. Enough of + /// those at once and a test elsewhere in the run that waits on a background task never gets + /// scheduled, which is how a suite acquires a flaky failure that has nothing to do with the + /// code under test. + /// + internal static async Task CodeAsync(Func run) + { + try + { + await run(); + + return "ran"; + } + catch (PolicyException refusal) + { + return refusal.ErrorCode.ToString(); + } + catch (Exception other) + { + return $"{other.GetType().Name}: {other.Message.Split('\n')[0]}"; + } + } + private static readonly JsonSerializerOptions JsonOptions = new() { ReferenceHandler = ReferenceHandler.IgnoreCycles }; internal static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) where T : class => diff --git a/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs b/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs index 301ac80..55ddf0c 100644 --- a/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs +++ b/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs @@ -239,10 +239,10 @@ public async Task Zw_W1_async_reads_through_a_wrapper_over_a_context_tracking_th IQueryable source = new ZwAsyncQuery(_db.Shoppers); object? data = null; - string code = ZwKit.Code(() => data = ZwKit.Guard(source, tier).ToListAsync(new Filter()).GetAwaiter().GetResult().Data); - string async = ZwKit.Code(() => ZwKit.Guard(source, tier).ToListAsyncDynamic(new Filter()).GetAwaiter().GetResult()); - string segment = ZwKit.Code(() => ZwKit.Guard(source, tier).ToListAsync(Union()).GetAwaiter().GetResult()); - string summary = ZwKit.Code(() => ZwKit.Guard(source, tier).ToListAsync(CountByName()).GetAwaiter().GetResult()); + string code = await ZwKit.CodeAsync(async () => data = (await ZwKit.Guard(source, tier).ToListAsync(new Filter())).Data); + string async = await ZwKit.CodeAsync(() => ZwKit.Guard(source, tier).ToListAsyncDynamic(new Filter())); + string segment = await ZwKit.CodeAsync(() => ZwKit.Guard(source, tier).ToListAsync(Union())); + string summary = await ZwKit.CodeAsync(() => ZwKit.Guard(source, tier).ToListAsync(CountByName())); int after = _db.ChangeTracker.Entries().Count(); _out.WriteLine($"W1 {tier}: list={code} dynamic={async} segment={segment} summary={summary} tracked {before}->{after} sent={ZwKit.Json(data)}"); From 68bc021be70416ab3f5ae409864ba9332a61990e Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 12:53:21 +0300 Subject: [PATCH 05/54] fix(policies): a column only a subtype maps is not one the queried type can compute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walk looked through an entity type's derived types for a member the queried type does not map, and counted it as a column. EF Core does not: it translates a member against the type the query is over, and a member mapped one level down fails with "Translation of member 'Licence' on entity type 'ZyParty' failed. This commonly occurs when the specified member is unmapped" — which is the five-hundred this release exists to turn into a refusal. So the look through the derived types made DW-17 miss the case it was written for. The queried type's own model decides now. Query the derived type to filter on such a member, which already worked and still does. Co-Authored-By: Claude Opus 5 --- .../Policies/UnexpressiblePathTests.cs | 31 ++++++++- DynamicWhere.ex/Policies/Source/RowShape.cs | 64 ++++--------------- 2 files changed, 43 insertions(+), 52 deletions(-) diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs index 93a258a..b3b5059 100644 --- a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs +++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs @@ -47,11 +47,14 @@ public class ZyParty public int Id { get; set; } public string Kind { get; set; } = string.Empty; + + /// Declared on the base, mapped only on the subtype, which is where the rows carry it. + public string? Licence { get; set; } } public class ZyMerchant : ZyParty { - public string? Licence { get; set; } + public string? Rating { get; set; } } /// The row a caller projects before the guard sees it, which is DCMP's shape. @@ -84,6 +87,11 @@ protected override void OnModelCreating(ModelBuilder model) model.Entity().HasDiscriminator("Discriminator") .HasValue("party") .HasValue("merchant"); + + // Ignored where it is declared and mapped where the rows have it, so the model maps the + // member on the derived type alone while the queried type still carries the CLR property. + model.Entity().Ignore(party => party.Licence); + model.Entity().Property(merchant => merchant.Licence); } } @@ -106,7 +114,7 @@ public UnexpressiblePathTests(ITestOutputHelper output) _db = new ZyContext(_connection); _db.Database.EnsureCreated(); _db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" } }); - _db.Parties.Add(new ZyMerchant { Kind = "merchant", Licence = "L-1" }); + _db.Parties.Add(new ZyMerchant { Kind = "merchant", Licence = "L-1", Rating = "A" }); _db.SaveChanges(); _db.ChangeTracker.Clear(); } @@ -184,6 +192,25 @@ public void A_column_a_subtype_maps_is_still_a_column() Assert.Single(result.Data); } + [Fact] + public void A_column_only_the_subtype_maps_is_refused_through_the_base_type() + { + // The queried type declares the member and the model maps it one level down. EF Core + // translates against the type the query is over, so this is the failure the refusal is + // for: "Translation of member 'Licence' on entity type 'ZyParty' failed." + Assert.NotNull(_db.Model.FindEntityType(typeof(ZyMerchant))!.FindProperty("Licence")); + Assert.Null(_db.Model.FindEntityType(typeof(ZyParty))!.FindProperty("Licence")); + + Exception? unguarded = Record.Exception(() => _db.Parties.Where(party => party.Licence == "L-1").ToList()); + + Assert.IsType(unguarded); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Parties, DwTier.Strict).ToList(Where("Licence", "L-1"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + [Fact] public void A_framework_member_the_provider_translates_is_left_alone() { diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs index e94319b..6f4fb9e 100644 --- a/DynamicWhere.ex/Policies/Source/RowShape.cs +++ b/DynamicWhere.ex/Policies/Source/RowShape.cs @@ -446,62 +446,26 @@ private static bool Named(object model, string name) => && string.Equals(held, name, StringComparison.OrdinalIgnoreCase); /// - /// The column a type or any type derived from it maps under a name, or null. + /// The column the queried type maps under a name, or null. /// /// - /// Derived types are read because a hierarchy's rows come back as the queried type: a column - /// declared by one subtype is still a column the provider can compute for the rows that have it. + /// The queried type's own model, not a derived type's. EF Core translates a member against the + /// type the query is over: a member the model maps one level down, on a subtype, fails with + /// "Translation of member '…' failed. This commonly occurs when the specified member is + /// unmapped", which is the failure this whole walk exists to turn into a refusal. Query the + /// derived type to filter on it. /// private static IProperty? MappedProperty(IEntityType entity, string name) => - entity.GetProperties().FirstOrDefault(property => Same(property.Name, name)) - ?? entity.GetDerivedTypes() - .SelectMany(derived => derived.GetProperties()) - .FirstOrDefault(property => Same(property.Name, name)); + entity.GetProperties().FirstOrDefault(property => Same(property.Name, name)); - /// The navigation a type or any type derived from it maps under a name, or null. - private static INavigationBase? MappedNavigation(IEntityType entity, string name) - { - INavigationBase? found = - entity.GetNavigations().FirstOrDefault(navigation => Same(navigation.Name, name)) - ?? (INavigationBase?)entity.GetSkipNavigations().FirstOrDefault(navigation => Same(navigation.Name, name)); - - if (found is not null) - { - return found; - } - - foreach (IEntityType derived in entity.GetDerivedTypes()) - { - found = derived.GetNavigations().FirstOrDefault(navigation => Same(navigation.Name, name)) - ?? (INavigationBase?)derived.GetSkipNavigations().FirstOrDefault(navigation => Same(navigation.Name, name)); - - if (found is not null) - { - return found; - } - } - - return null; - } - - /// The complex property a type or any type derived from it declares under a name, or null. - private static object? ComplexMember(IEntityType entity, string name) - { - if (Items(entity, "GetComplexProperties").FirstOrDefault(complex => Named(complex, name)) is { } found) - { - return found; - } - - foreach (IEntityType derived in entity.GetDerivedTypes()) - { - if (Items(derived, "GetComplexProperties").FirstOrDefault(complex => Named(complex, name)) is { } beneath) - { - return beneath; - } - } + /// The navigation the queried type maps under a name, or null. + private static INavigationBase? MappedNavigation(IEntityType entity, string name) => + entity.GetNavigations().FirstOrDefault(navigation => Same(navigation.Name, name)) + ?? (INavigationBase?)entity.GetSkipNavigations().FirstOrDefault(navigation => Same(navigation.Name, name)); - return null; - } + /// The complex property the queried type declares under a name, or null. + private static object? ComplexMember(IEntityType entity, string name) => + Items(entity, "GetComplexProperties").FirstOrDefault(complex => Named(complex, name)); /// Compares two model names the way a caller's spelling is compared everywhere else. private static bool Same(string left, string right) => From fb2658c32cf3215cb21b6ebc864c616be349bad4 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 12:55:27 +0300 Subject: [PATCH 06/54] fix(policies): only EF Core's rows are held to what EF Core can compute A projection the library can read into was refused for a member the initializer does not build, whatever provider ran it. That is EF Core's answer to give: a provider of its own may evaluate the getter in memory, as rows in memory do, and refusing there turns a query that worked into a refusal. A projected shape now records whether it was read through EF Core's provider, and answers "cannot say" otherwise. An entity shape is unaffected: it has a model, which is EF Core's by definition. Co-Authored-By: Claude Opus 5 --- .../Policies/UnexpressiblePathTests.cs | 52 +++++++++++++++++++ DynamicWhere.ex/Policies/Source/RowShape.cs | 22 ++++++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs index b3b5059..f1686f6 100644 --- a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs +++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using DynamicWhere.ex.Classes.Complex; using DynamicWhere.ex.Classes.Core; using DynamicWhere.ex.Classes.Result; @@ -95,6 +96,37 @@ protected override void OnModelCreating(ModelBuilder model) } } + /// A provider of its own: not EF Core's, and not the one rows in memory carry. + public sealed class ZyOwnProvider : IQueryable, IQueryProvider + { + private readonly IQueryable _inner; + + public ZyOwnProvider(IQueryable inner) + { + _inner = inner; + Expression = inner.Expression; + } + + public Type ElementType => typeof(T); + + public Expression Expression { get; } + + public IQueryProvider Provider => this; + + public IEnumerator GetEnumerator() => _inner.Provider.CreateQuery(Expression).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + public IQueryable CreateQuery(Expression expression) => _inner.Provider.CreateQuery(expression); + + public IQueryable CreateQuery(Expression expression) => + _inner.Provider.CreateQuery(expression); + + public object? Execute(Expression expression) => _inner.Provider.Execute(expression); + + public TResult Execute(Expression expression) => _inner.Provider.Execute(expression); + } + /// /// A path beneath a member the policy has no fragment for, whose leaf no database can compute. /// Until this, the package accepted the path and the provider threw, which is a five-hundred @@ -333,6 +365,26 @@ public void A_member_the_projection_never_assigns_fails_unguarded_and_is_refused Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); } + [Fact] + public void A_provider_that_is_not_EF_Core_s_decides_for_itself() + { + // Its rules are its own: it may evaluate the getter in memory, as rows in memory do, so + // nothing here is refused on EF Core's behalf. + ZyRole[] rows = { new() { Id = 1, Code = "admin", Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" } } }; + + IQueryable built = new ZyOwnProvider(rows.AsQueryable().Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + })); + + FilterResult result = Guard(built, DwTier.Strict) + .ToList(Where("Name.IsEmpty", "false", DataType.Boolean)); + + Assert.Single(result.Data); + } + [Fact] public void Rows_in_memory_run_the_getter_as_they_always_did() { diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs index 6f4fb9e..b23ff3d 100644 --- a/DynamicWhere.ex/Policies/Source/RowShape.cs +++ b/DynamicWhere.ex/Policies/Source/RowShape.cs @@ -71,6 +71,10 @@ internal sealed class RowShape // A member a projection copies from the query's own entity, by the member's path from the row: // Name = x.Name records ("Name", the entity, "Name"), so what is beneath it is read from the model. private readonly Dictionary? _copied; + + // True when the projection reads through EF Core. What a provider can compute is EF Core's + // answer to give; another provider's rules are its own, so its rows are left alone. + private readonly bool _translated; private readonly Dictionary _built = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _narrowable = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _kept = new(StringComparer.OrdinalIgnoreCase); @@ -88,12 +92,14 @@ private RowShape( Type? built, IDictionary members, Dictionary>? initialized = null, - Dictionary? copied = null) + Dictionary? copied = null, + bool translated = false) : this(kind) { _assigned = assigned; _initialized = initialized; _copied = copied; + _translated = translated; _narrowable.UnionWith(narrowable); Built = built; @@ -324,6 +330,13 @@ internal bool Materializes(string path) /// private bool? ExpressesInProjection(string[] segments) { + if (!_translated) + { + // Another provider decides what it can compute, and its rules are not EF Core's. A + // provider that evaluates in memory runs the getter, as rows in memory do. + return null; + } + if (_assigned is not null && !_assigned.Contains(segments[0])) { // Nothing assigns it, so the projection carries no value for it at all. @@ -742,7 +755,9 @@ private static RowShape Projected(IQueryable source) where T : class { // A constructor with arguments says nothing about which member each argument sets: every // member counts as assigned, and none can be narrowed. - return new RowShape(RowKind.Projected, null, Array.Empty(), body.Type, built); + return new RowShape( + RowKind.Projected, null, Array.Empty(), body.Type, built, + translated: source.Provider is IAsyncQueryProvider); } List narrowable = new(); @@ -782,7 +797,8 @@ private static RowShape Projected(IQueryable source) where T : class } } - return new RowShape(RowKind.Projected, assigned, narrowable, initializer.Type, built, initialized, copied); + return new RowShape( + RowKind.Projected, assigned, narrowable, initializer.Type, built, initialized, copied, efCore); } /// From 45f1392a6f3a8866c4e8d526da73ec83eccc27da Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 12:59:10 +0300 Subject: [PATCH 07/54] fix(policies): a second call adding a source is refused, the catalogue is compared by every name, and a refused segment keeps its trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What the security review of the branch found. High: handing the posture in force back with a policy source beside it was read as "the same posture" and did nothing. The instance carries the same values by definition and says nothing about the sources, so the shortcut skipped the only comparison that mattered: the resolver was never rebuilt, the source was never consulted, and every rule in it — a denial included — quietly did not apply. DwPolicy.Options is what AddDwPolicies registers, so the call is one a module adding its own store would write. The shortcut compares the sources now. Low: the catalogue was compared by what Entities reports, which is the last name each type was exposed under, while every earlier name stays resolvable. Two catalogues reporting the same pairs could answer differently to an administrative request. DwEntityCatalog.SameAs compares both name maps. Low: the segment terminal still assigned LastTrace after sanitizing, so a refused segment left the previous request's trace on the handle — and a strict refusal names no field, which makes the trace the only record of which field it was. Ruled out by the same review, with probes: the refusal is indistinguishable from an unknown name and from a denial in every clause, caps and cost budget included; Expresses can only add a refusal, never skip a check; the trace record reaches no caller; forced predicates never pass through the name resolver; and public Clone gives no TOCTOU, since sanitizing clones before it reads anything. Co-Authored-By: Claude Opus 5 --- .../Policies/ConfigureOnceTests.cs | 54 +++++++++++++++++++ .../Policies/UnexpressiblePathTests.cs | 47 ++++++++++++++++ DynamicWhere.ex/Policies/Config/DwPolicy.cs | 33 +++++------- .../Policies/Discovery/DwEntityCatalog.cs | 46 ++++++++++++++++ .../Policies/Source/PolicyQueryable.cs | 6 ++- 5 files changed, 164 insertions(+), 22 deletions(-) diff --git a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs index 5056c8e..7d6f424 100644 --- a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs +++ b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs @@ -280,6 +280,60 @@ public void A_second_registration_hands_the_container_the_posture_in_force() Assert.Same(DwPolicy.Options, provider.GetRequiredService()); } + [Fact] + public void The_posture_in_force_handed_back_with_a_source_beside_it_is_refused() + { + // The instance carries the same values by definition and says nothing about the sources. + // Answering "same posture" here would drop the source: the resolver is never rebuilt, the + // source is never consulted, and every rule in it quietly does not apply. + PolicyResolver before = DwPolicy.Resolver; + + InvalidOperationException refusal = Assert.Throws( + () => DwPolicy.Configure(DwPolicy.Options, new FakePolicyProvider())); + + Assert.Contains("already configured", refusal.Message); + Assert.Same(before, DwPolicy.Resolver); + } + + [Fact] + public void The_posture_in_force_handed_back_with_nothing_beside_it_is_the_same_posture() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicy.Configure(inForce); + + Assert.Same(inForce, DwPolicy.Options); + } + + [Fact] + public void A_catalogue_answering_to_one_more_name_for_the_same_type_is_refused() + { + // Entities reports the last name a type was exposed under; both stay resolvable. Two + // catalogues reporting the same pairs can still answer differently to an admin request. + DwPolicyOptions different = Copy(); + + if (DwPolicy.Options.Entities.IsEmpty) + { + different.Entities.Expose("only-here"); + + Refused(different); + + return; + } + + KeyValuePair first = DwPolicy.Options.Entities.Entities.First(); + + // Exposed again under an extra name, then back under the one in force, so Entities + // matches pair for pair and only the name map differs. + different.Entities.Expose(first.Key, $"{first.Value}-also"); + different.Entities.Expose(first.Key, first.Value); + + Assert.Equal(DwPolicy.Options.Entities.Entities.Count, different.Entities.Entities.Count); + Assert.Equal(first.Value, different.Entities.NameOf(first.Key)); + + Refused(different); + } + /// /// Every settable value on the posture and on the caps decides whether a second call is the /// same posture, and the two that do not are named here on purpose. diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs index f1686f6..c90b666 100644 --- a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs +++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs @@ -329,6 +329,53 @@ public void A_clause_composed_on_the_handle_is_refused_too() Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); } + [Fact] + public async Task A_segment_naming_it_is_refused_and_leaves_its_own_trace() + { + PolicyQueryable guarded = Guard(Built(), DwTier.Strict); + + // A first request, so a trace left over from it would be visible if the refusal skipped + // the assignment. + await guarded.ToListAsync(new Segment + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "admin" } } } + } + } + } + }); + + PolicyTrace? first = guarded.LastTrace; + + PolicyException refusal = await Assert.ThrowsAnyAsync( + () => guarded.ToListAsync(new Segment + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Name.IsEmpty", DataType = DataType.Boolean, Operator = Operator.Equal, Values = { "false" } } } + } + } + } + })); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSegment, refusal.ErrorCode); + Assert.NotSame(first, guarded.LastTrace); + Assert.Contains( + guarded.LastTrace!.Decisions, + decision => decision.Reason is not null && decision.Reason.Contains("cannot compute")); + } + [Fact] public void A_summary_grouping_on_it_is_refused() { diff --git a/DynamicWhere.ex/Policies/Config/DwPolicy.cs b/DynamicWhere.ex/Policies/Config/DwPolicy.cs index 58ca3ad..4260252 100644 --- a/DynamicWhere.ex/Policies/Config/DwPolicy.cs +++ b/DynamicWhere.ex/Policies/Config/DwPolicy.cs @@ -146,7 +146,12 @@ private static bool SamePosture(DwPolicyOptions inForce, DwPolicyOptions asked, { if (ReferenceEquals(inForce, asked)) { - return true; + // The same instance carries the same values by definition, and says nothing about the + // sources. Handing the posture in force back with a store provider beside it is a call + // asking for that source, and answering "same posture" would drop it: the resolver is + // never rebuilt, the source is never consulted, and every rule in it — a denial + // included — quietly does not apply. + return SameProviders(providers); } if (inForce.Tier != asked.Tier @@ -189,25 +194,13 @@ private static bool SameCaps(DwCaps inForce, DwCaps asked) => && inForce.SchemaCycleLimit == asked.SchemaCycleLimit && inForce.MaxSchemaFields == asked.MaxSchemaFields; - /// True when both catalogues expose the same types under the same names. - private static bool SameCatalog(DwEntityCatalog inForce, DwEntityCatalog asked) - { - if (inForce.Entities.Count != asked.Entities.Count) - { - return false; - } - - foreach (KeyValuePair exposed in inForce.Entities) - { - if (!asked.Entities.TryGetValue(exposed.Key, out string? name) - || !string.Equals(exposed.Value, name, StringComparison.Ordinal)) - { - return false; - } - } - - return true; - } + /// True when both catalogues expose the same types and answer to the same names. + /// + /// Every name, not only the last one each type was exposed under. A type exposed twice keeps + /// both names resolvable while Entities reports one, so two catalogues can report the + /// same pairs and still answer differently to an administrative request. + /// + private static bool SameCatalog(DwEntityCatalog inForce, DwEntityCatalog asked) => inForce.SameAs(asked); /// /// True when the second call supplies the same kinds of policy source, in the same order. diff --git a/DynamicWhere.ex/Policies/Discovery/DwEntityCatalog.cs b/DynamicWhere.ex/Policies/Discovery/DwEntityCatalog.cs index 1f68a02..1d88496 100644 --- a/DynamicWhere.ex/Policies/Discovery/DwEntityCatalog.cs +++ b/DynamicWhere.ex/Policies/Discovery/DwEntityCatalog.cs @@ -153,4 +153,50 @@ public Type[] ToArray() /// Prevents any further change. public void Freeze() => _frozen = true; + + /// True when another catalogue exposes the same types and answers to the same names. + /// + /// Read when a second call asks whether it is configuring the posture already in force. Every + /// name matters, not only the last one each type was exposed under: a type exposed twice answers + /// to both, while reports one, so comparing that alone would let a second + /// host's administrative surface resolve a name this one never declared — or fail to resolve one + /// it did. + /// + internal bool SameAs(DwEntityCatalog other) + { + if (other is null + || _names.Count != other._names.Count + || _byName.Count != other._byName.Count + || _byFullName.Count != other._byFullName.Count) + { + return false; + } + + foreach (KeyValuePair exposed in _names) + { + if (!other._names.TryGetValue(exposed.Key, out string? name) + || !string.Equals(exposed.Value, name, StringComparison.Ordinal)) + { + return false; + } + } + + foreach (KeyValuePair named in _byName) + { + if (!other._byName.TryGetValue(named.Key, out Type? type) || type != named.Value) + { + return false; + } + } + + foreach (KeyValuePair named in _byFullName) + { + if (!other._byFullName.TryGetValue(named.Key, out Type? type) || type != named.Value) + { + return false; + } + } + + return true; + } } diff --git a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs index fef2b5a..2a314d9 100644 --- a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs +++ b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs @@ -414,13 +414,15 @@ public async Task> ToListAsync(Segment segment, CancellationTok { PolicyTrace trace = NewTrace(); + // Before the sanitizing, as every other terminal does it, so a refused segment leaves + // its own trace readable rather than the request before it. + LastTrace = trace; + Segment sanitized = FilterSanitizer.Sanitize( segment, _resolver, _context, _options, trace, applyDefaultOrder: TakesDefaultOrder, rows: RowShape.Of(_source)); - LastTrace = trace; - using (PolicyScope.Enter(_context, LastTrace)) { SegmentResult result = await Guarded().ToListAsync(sanitized, cancellationToken); From 652f3b15afb9f7a0627aea296d3ed4461dcc8def Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 13:07:43 +0300 Subject: [PATCH 08/54] fix(policies): a projection is not held to what the database can compute, and a cap's own default is not a difference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What the over-blocking review of the branch found. A projection is the last thing the provider builds, and EF Core evaluates that one on the client when it cannot translate it. So Selects naming an unmapped getter — on the entity, across a navigation, on an owned or JSON member — returned its value on 3.2.0 and was refused here, in both tiers of the strict path and in a Segment. Five shapes, on both EF legs. The refusal now applies to the clauses the database has to compute: a filter, an order, a grouping key, an aggregated field, and each of those inside a Segment. The posture comparison read IsMinGroupSizeSet, which nothing in enforcement reads. The documented appsettings sample writes "MinGroupSize": 5, so a host binding it and a host on the defaults enforce the same floor and were refused — the second host this feature exists to let start. The floor that applies is compared now, not whether somebody wrote it down. Co-Authored-By: Claude Opus 5 --- .../Policies/ConfigureOnceTests.cs | 20 ++++++++++ .../Policies/UnexpressiblePathTests.cs | 40 +++++++++++++++---- DynamicWhere.ex/Policies/Config/DwPolicy.cs | 12 +++++- .../Policies/Source/FilterSanitizer.cs | 26 +++++++++--- 4 files changed, 83 insertions(+), 15 deletions(-) diff --git a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs index 7d6f424..63d3efa 100644 --- a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs +++ b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs @@ -127,6 +127,26 @@ public void A_different_cap_is_refused() Refused(different); } + [Fact] + public void Writing_the_group_floor_the_default_already_holds_is_the_same_posture() + { + // The floor that applies, not whether somebody wrote it down. The documented appsettings + // sample writes "MinGroupSize": 5, so a host binding it and a host on the defaults + // enforce the same floor — and refusing the second is refusing the case this is for. + DwPolicyOptions different = Copy(); + + different.Caps.MinGroupSize = DwPolicy.Options.Caps.MinGroupSize; + + Assert.NotEqual(DwPolicy.Options.Caps.IsMinGroupSizeSet, different.Caps.IsMinGroupSizeSet); + Assert.Equal(DwPolicy.Options.Caps.MinGroupSize, different.Caps.MinGroupSize); + + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicy.Configure(different); + + Assert.Same(inForce, DwPolicy.Options); + } + [Fact] public void A_different_group_floor_is_refused() { diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs index c90b666..1962a7d 100644 --- a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs +++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs @@ -298,15 +298,41 @@ public void An_order_on_it_is_refused() } [Fact] - public void A_projection_naming_it_is_refused() + public void A_projection_naming_it_still_returns_it() { - PolicyException refusal = Assert.ThrowsAny( - () => Guard(Built(), DwTier.Strict).ToList(new Filter - { - Selects = new List { "Id", "Name.IsEmpty" } - })); + // A projection is the last thing the provider builds, and EF Core evaluates that one on + // the client when it cannot translate it. Unguarded this returns rows, so refusing it + // would take back a projection that has always worked. + List unguarded = Built().Select(row => new ZyRoleRow + { + Id = row.Id, + Name = new ZyLocalizedText { Ar = row.Name.Ar, En = row.Name.En } + }).ToList(); - Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode); + Assert.Single(unguarded); + + FilterResult result = Guard(Built(), DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Name.IsEmpty" } + }); + + Assert.Single(result.Data); + } + + [Fact] + public void An_unmapped_getter_on_the_entity_can_still_be_selected() + { + Assert.Single(_db.Roles.Select(role => new { role.Id, role.Display }).ToList()); + + FilterResult result = Guard(_db.Roles, DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Display" } + }); + + // The typed projection reads the members it was asked for, so the getter computes from + // those: Code was not selected, which is the core's own behaviour and not the policy's. + Assert.Single(result.Data); + Assert.Equal(1, result.Data[0].Id); } [Fact] diff --git a/DynamicWhere.ex/Policies/Config/DwPolicy.cs b/DynamicWhere.ex/Policies/Config/DwPolicy.cs index 4260252..d5c4dae 100644 --- a/DynamicWhere.ex/Policies/Config/DwPolicy.cs +++ b/DynamicWhere.ex/Policies/Config/DwPolicy.cs @@ -174,7 +174,16 @@ private static bool SamePosture(DwPolicyOptions inForce, DwPolicyOptions asked, return SameProviders(providers); } - /// True when two cap sets hold the same numbers, the group floor's opt-out included. + /// + /// True when two cap sets hold the same numbers. + /// + /// + /// The floor that applies, not whether somebody wrote it down. IsMinGroupSizeSet tells a + /// deliberate opt-out from a deployment that never heard of the control, and nothing in + /// enforcement reads it — a host binding the documented appsettings sample, which writes + /// "MinGroupSize": 5, enforces exactly what a host on the defaults does, and refusing the + /// second one would refuse the case this feature exists for. + /// private static bool SameCaps(DwCaps inForce, DwCaps asked) => inForce.MaxPageSize == asked.MaxPageSize && inForce.DefaultPageSize == asked.DefaultPageSize @@ -189,7 +198,6 @@ private static bool SameCaps(DwCaps inForce, DwCaps asked) => && inForce.DefaultFieldCost == asked.DefaultFieldCost && inForce.MaxAuditEvents == asked.MaxAuditEvents && inForce.MinGroupSize == asked.MinGroupSize - && inForce.IsMinGroupSizeSet == asked.IsMinGroupSizeSet && inForce.SchemaDepth == asked.SchemaDepth && inForce.SchemaCycleLimit == asked.SchemaCycleLimit && inForce.MaxSchemaFields == asked.MaxSchemaFields; diff --git a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs index ddc745a..2e25feb 100644 --- a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs +++ b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs @@ -694,7 +694,7 @@ internal static Segment Sanitize( { for (int i = 0; i < working.Selects.Count; i++) { - working.Selects[i] = ResolveName(working.Selects[i], gate); + working.Selects[i] = ResolveName(working.Selects[i], gate, computed: false); } } @@ -2371,17 +2371,22 @@ private static bool Widens(ForcedPredicate predicate, string path) where T : /// The entity type being queried. /// The name as the caller wrote it. /// The per-query state, carrying the type's alias map. + /// + /// True when the clause needs the provider to compute the path — a filter, an order, a grouping + /// key, an aggregated field. False for a projection, which the provider evaluates on the client + /// when it cannot translate it, so a member no database can compute is still returned there. + /// /// The canonical field path. /// Thrown when the name could mean more than one field. /// Thrown when the name names nothing, outside the strict tier. - private static string ResolveName(string name, Gate gate) where T : class + private static string ResolveName(string name, Gate gate, bool computed = true) where T : class { // A type nobody has aliased takes the path it always did, with no extra reflection and no // behavioural difference from before this existed. if (gate.TypePolicy.Aliases.Count == 0) { return gate.HidesExistence - ? Expressible(TryValidate(name), name, gate) ?? gate.Unknown(name) + ? Expressible(TryValidate(name), name, gate, computed) ?? gate.Unknown(name) : name.Validate(); } @@ -2438,7 +2443,7 @@ private static string ResolveName(string name, Gate gate) where T : class string canonical = candidates[0]; - if (gate.HidesExistence && Expressible(canonical, spoken, gate) is null) + if (gate.HidesExistence && Expressible(canonical, spoken, gate, computed) is null) { return gate.Unknown(spoken); } @@ -2468,13 +2473,22 @@ private static string ResolveName(string name, Gate gate) where T : class /// they may not use. /// /// - private static string? Expressible(string? canonical, string spoken, Gate gate) + private static string? Expressible(string? canonical, string spoken, Gate gate, bool computed) { if (canonical is null) { return null; } + if (!computed) + { + // A projection is the last thing the provider builds, and EF Core evaluates that one on + // the client when it cannot translate it. So a member no database can compute is still + // a member a caller can select, and refusing it here would take back a projection that + // has always worked. + return canonical; + } + if (gate.Rows.Expresses(canonical) == false) { gate.RecordUnexpressible(canonical, spoken); @@ -2580,7 +2594,7 @@ private static void Canonicalize(Filter filter, Gate gate) where T : class { for (int i = 0; i < filter.Selects.Count; i++) { - filter.Selects[i] = ResolveName(filter.Selects[i], gate); + filter.Selects[i] = ResolveName(filter.Selects[i], gate, computed: false); } } From ae60b4517ee8c0269fe5bef39ec4519293912548 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 13:07:43 +0300 Subject: [PATCH 09/54] docs: what the review found in the text, and what the fixes changed under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the docs-against-code review: - A constructor with arguments does not leave a projected query in its own order; only a projection with no initializer does. A Filter carrying Selects is ordered like any other filter — it is a composed Select that leaves the rest of the chain unordered. - Count was listed as a framework member that runs. No path can name it: the library's own validation refuses Lines.Count before any of this is asked. - Three places still said a second Configure is refused, on pages that now describe the opposite. - LastTrace's sub-bullet still said the trace holds the last call that got past sanitizing. - AddDwPolicies does not "do nothing": it binds, changes no posture, and registers the one in force. Its exception list said "and" for two alternative causes. - The [DbFunction] example cannot be reached by a property path. The rule behind it is the model's, and that is what the text says now. From the fixes in this branch: Selects is not refused, a column only a subtype maps is refused through the base type, another provider's rows decide for themselves, and the posture comparison reads the floor that applies rather than whether it was written down. Co-Authored-By: Claude Opus 5 --- DynamicWhere.ex/DOC.md | 22 ++++++--- DynamicWhere.ex/DynamicWhere.ex.csproj | 4 +- .../Policies/Attributes/DwEntityAttribute.cs | 11 +++-- .../Policies/Config/DwPolicyConfiguration.cs | 2 +- DynamicWhere.ex/Policies/Source/RowShape.cs | 2 +- .../app/docs/breaking-changes/page.tsx | 33 +++++++++---- .../app/docs/policies/configuration/page.tsx | 19 ++++--- OfficialWebsite/app/docs/policies/page.tsx | 5 +- .../app/docs/policies/security/page.tsx | 8 +-- OfficialWebsite/public/llms.txt | 49 +++++++++++++------ README.md | 4 +- 11 files changed, 103 insertions(+), 56 deletions(-) diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index 892f3a1..b85c61e 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -1529,7 +1529,8 @@ caller request → ApplyPolicy(ctx) → sanitize → existing engine → transfo ``` ```csharp -// Once, at startup. Refused on a second call: the tier is read by every request thread. +// At startup. A second call asking for the same posture does nothing; a different one is +// refused, because the tier is read by every request thread. See "Configuring twice". DwPolicy.Configure(new DwPolicyOptions { Tier = DwTier.Convenience, @@ -1689,7 +1690,9 @@ public sealed class LocalizedText } ``` -gives `Name.Ar` and `Name.En`, which translate, and `Name.IsEmpty`, which is a getter over the two. The policy has nothing to say about it — `[DwNoWhere]` on `Name` matches that path and not the ones beneath it — so every check passed and EF Core threw `InvalidOperationException`: a five-hundred where `Strict` promises a refusal. Such a path is now refused as an unknown name is, with the clause's own code and `FieldPath` `"*"`, in every clause. +gives `Name.Ar` and `Name.En`, which translate, and `Name.IsEmpty`, which is a getter over the two. The policy has nothing to say about it — `[DwNoWhere]` on `Name` matches that path and not the ones beneath it — so every check passed and EF Core threw `InvalidOperationException`: a five-hundred where `Strict` promises a refusal. Such a path is now refused as an unknown name is, with the clause's own code and `FieldPath` `"*"`, in every clause the database has to compute: a filter, an order, a grouping key, an aggregated field, and each of those inside a `Segment`. + +**`Selects` is not one of them.** A projection is the last thing the provider builds, and EF Core evaluates that one on the client when it cannot translate it, so `Selects = ["Id", "Name.IsEmpty"]` returns the computed value exactly as it did before. Refusing it would take back a projection that has always worked. It is refused only where the whole set of members a container can produce is known: @@ -1700,12 +1703,14 @@ It is refused only where the whole set of members a container can produce is kno | …where that `Select` copies the member from the entity, `Name = role.Name` | the model, beneath the member it copies | Refused | | Rows in memory | nothing — the getter runs | Runs, as it always did | | Anything beneath a column, a converted one included | nothing — the converter decides | Runs, as it always did | -| A framework member such as `Length`, `Year`, `Count`, `HasValue` | nothing — the provider translates it | Runs, as it always did | +| A framework member such as `Length`, `Year` or `HasValue` | nothing — the provider translates it | Runs, as it always did | | A source the library cannot read | nothing | Runs, as it always did | +| A column only a subtype maps, queried through the base | the queried type's model, which is what EF Core translates against | Refused | +| A projection a provider that is not EF Core's ran | nothing — its rules are its own | Runs, as it always did | An unmapped getter on the entity itself, `Display => $"{Code}:{Id}"`, is refused for the same reason. The convenience tier and a dry run are unchanged: both fail exactly as the unguarded query does, which is the provider's own error. The trace records the refusal — `the member exists on the type and the query cannot compute it` — and since 3.3.0 `LastTrace` is set before a request is sanitized, so a refusal leaves it readable rather than null. -A member a custom EF Core translator computes, through `[DbFunction]` or an `IMethodCallTranslatorPlugin`, is not in the model and is refused with the rest. Map it, or filter on the columns beneath it. +The rule is the model's: a member it maps nowhere is one the database cannot compute. A member some provider extension computes without a mapping is refused with the rest, so map it, or filter on the columns beneath it. ### A navigation named in Selects @@ -2073,9 +2078,10 @@ and nothing here can tell the difference. #### Configuring twice -*(3.3.0)* The first call decides the posture. A second `DwPolicy.Configure`, or a second -`AddDwPolicies`, **asking for the posture already in force does nothing and returns**; one asking for -a different posture still throws `InvalidOperationException`. The comparison happens inside the lock +*(3.3.0)* The first call decides the posture. A second `DwPolicy.Configure` **asking for the posture +already in force does nothing and returns**; one asking for a different posture still throws +`InvalidOperationException`. A second `AddDwPolicies` binds and builds its options as ever, changes +no posture, and registers the one in force. The comparison happens inside the lock that does the configuring, so a caller needs no lock and no `IsConfigured` check of its own — which matters because that check is a check-then-act two hosts starting at once can both pass. @@ -2089,7 +2095,7 @@ What counts as the same posture: |---|---| | `Tier`, `DryRun`, `IncludeTraceInResult`, `AuditRefusals` | `TokenVault` | | `HashSalt`, `StoreFailure`, `MaxSnapshotAge`, `RefreshInterval` | `Services` | -| Every value on `Caps`, the group floor's opt-out included | The provider *instances* | +| Every value on `Caps`, the floor that applies rather than whether it was written down | The provider *instances* | | The exposed entity catalogue: the same types under the same names | | | The provider *types*, in the order they were supplied | | diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index 24bef93..78fbe1b 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -50,9 +50,9 @@ README.md v3.3.0 — A second host may configure the same posture, a path no database can compute is refused rather than run, and Clone is public on the three request types. -New: DwPolicy.Configure takes a second call asking for the posture already in force, and does nothing. Until now the first call won and every later one threw, so an integration suite starting several WebApplicationFactory hosts over one composition root had to read IsConfigured before registering — a check-then-act two hosts starting at once can both pass, after which one of them throws. The comparison is made inside the lock that does the configuring, so a caller needs no lock of its own, and it covers everything that decides what a query may do: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap including the group floor's opt-out, the exposed entity catalogue, and the kinds of policy source supplied, in order. A posture differing in any of them is still refused with InvalidOperationException. The token vault, the service provider and the provider instances are not compared, because a second host builds its own and no two are ever the same reference; they stay as the first call left them, so a second host runs with the first host's vault and container. AddDwPolicies registers the posture in force rather than the instance it has just built, and the options handed to a second call are frozen so nothing goes on setting values that decide nothing. +New: DwPolicy.Configure takes a second call asking for the posture already in force, and does nothing. Until now the first call won and every later one threw, so an integration suite starting several WebApplicationFactory hosts over one composition root had to read IsConfigured before registering — a check-then-act two hosts starting at once can both pass, after which one of them throws. The comparison is made inside the lock that does the configuring, so a caller needs no lock of its own, and it covers everything that decides what a query may do: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap value, the exposed entity catalogue with every name it answers to, and the kinds of policy source supplied, in order. Writing a cap's own default down is not a difference, and handing the posture in force back with a source beside it is: that call asks for the source, so it is refused rather than dropped. A posture differing in any of them is still refused with InvalidOperationException. The token vault, the service provider and the provider instances are not compared, because a second host builds its own and no two are ever the same reference; they stay as the first call left them, so a second host runs with the first host's vault and container. AddDwPolicies registers the posture in force rather than the instance it has just built, and the options handed to a second call are frozen so nothing goes on setting values that decide nothing. -Behaviour change: under the strict tier, a filter, order, projection, group or aggregate naming a path the query cannot compute is refused as an unknown name is, where the provider used to throw. A member of a row's type is not always a value a database can produce: a getter such as LocalizedText.IsEmpty reads two columns in memory, so every check the policy makes passes and the query then fails inside EF Core with InvalidOperationException — a five-hundred where the strict tier promises a refusal, and the one place the tier answered with neither an answer nor a refusal. The path is refused only where the whole set of members a container can produce is known: an entity type and the types derived from it, an owned or complex type, and an initializer in a projection the caller composed before ApplyPolicy, including a member that projection copies straight from the entity. Everywhere else nothing changes — rows in memory run the getter as they always did, a framework member the provider translates such as Length, Year or Count is left alone, a value beneath a converted column is left alone, the convenience tier and a dry run still fail exactly as an unguarded query does, and a source the library cannot read asks for nothing. The trace records the refusal with its reason, and the trace is now assigned before a request is sanitized, so a refusal leaves LastTrace readable rather than null. +Behaviour change: under the strict tier, a filter, order, grouping key or aggregated field naming a path the query cannot compute is refused as an unknown name is, where the provider used to throw. A projection is not refused: EF Core evaluates the last projection on the client, so a Selects entry naming such a member returns its value as it always did. A member of a row's type is not always a value a database can produce: a getter such as LocalizedText.IsEmpty reads two columns in memory, so every check the policy makes passes and the query then fails inside EF Core with InvalidOperationException — a five-hundred where the strict tier promises a refusal, and the one place the tier answered with neither an answer nor a refusal. The path is refused only where the whole set of members a container can produce is known: the queried entity type's own model, an owned or complex type, and an initializer in a projection EF Core ran before ApplyPolicy, including a member that projection copies straight from the entity. Everywhere else nothing changes — rows in memory run the getter as they always did, a framework member the provider translates such as Length, Year or HasValue is left alone, a value beneath a converted column is left alone, a provider that is not EF Core's decides for itself, the convenience tier and a dry run still fail exactly as an unguarded query does, and a source the library cannot read asks for nothing. A column the model maps only on a subtype is refused through the base type, because EF Core translates a member against the type the query is over and fails there too; query the derived type. The trace records the refusal with its reason, and the trace is now assigned before a request is sanitized, so a refusal leaves LastTrace readable rather than null. New: Clone is public on Filter, Segment and Summary. It returns a deep copy — the condition tree with its groups and conditions, the projection list, each order, the page, and a summary's group-by and having clause — so a caller reading the same request again with one part changed, the next page or another order, no longer rebuilds it around the caller's own clauses. Two requests sharing one condition tree is the bug this removes: the library has cloned before rewriting anything since 3.0, and callers could not. diff --git a/DynamicWhere.ex/Policies/Attributes/DwEntityAttribute.cs b/DynamicWhere.ex/Policies/Attributes/DwEntityAttribute.cs index 2980e07..f21d959 100644 --- a/DynamicWhere.ex/Policies/Attributes/DwEntityAttribute.cs +++ b/DynamicWhere.ex/Policies/Attributes/DwEntityAttribute.cs @@ -39,10 +39,13 @@ public sealed class DwEntityAttribute : Attribute /// initializer and assigns every field the default names a column — a member the model maps, read /// directly, through reference navigations or through EF.Property — at every level of a /// nested path. That is the shape a caller writes who projects a row type before guarding it. A - /// constructor with arguments, a field the initializer leaves unassigned, and a field the projection - /// computes each leave the query in its own order, because a default must never be the reason a - /// query that ran unguarded fails. A Select composed on the guarded handle, or a - /// Filter carrying Selects, stays unordered. An in-memory sequence sorted before + /// projection with no initializer at all, new TicketRow(t.Id, t.Code), a field the + /// initializer leaves unassigned, and a field the projection computes each leave the query in its + /// own order, because a default must never be the reason a query that ran unguarded fails. A + /// constructor with arguments and an initializer still takes the default, since the + /// initializer is what says which member holds which column. A Select composed on the + /// guarded handle leaves the rest of the chain unordered; a Filter carrying + /// Selects is ordered as any other filter is. An in-memory sequence sorted before /// ApplyPolicy is not recognised as ordered, because it reaches the policy as a query with no /// OrderBy in it, so send its order with the filter. End the default with a unique field, such /// as the key, or rows sharing the leading values can still change places between pages. diff --git a/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs b/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs index 7c8568a..6a07215 100644 --- a/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs +++ b/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs @@ -104,7 +104,7 @@ public static DwPolicyOptions Bind(this DwPolicyOptions options, IConfiguration /// The same collection, for chaining. /// Thrown when the collection or the section is null. /// - /// Thrown when the policy layer is already configured with a different posture, and when + /// Thrown when the policy layer is already configured with a different posture, or when /// the section names a key nothing answers to. /// /// diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs index b23ff3d..4f173ef 100644 --- a/DynamicWhere.ex/Policies/Source/RowShape.cs +++ b/DynamicWhere.ex/Policies/Source/RowShape.cs @@ -292,7 +292,7 @@ internal bool Materializes(string path) /// produce: an entity type and its derived types, a complex or owned type, or an initializer in /// the projection itself. Anywhere else the answer is null and the path is left alone — beneath a /// column, where a converter decides; on a framework type, where Length, Year and - /// Count are translated by the provider and are not members of any model; on rows in + /// HasValue are translated by the provider and are not members of any model; on rows in /// memory, which run the getter; and on a source this library cannot read. /// /// diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index bb3bb5b..a4403c7 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -1473,9 +1473,18 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`} Since 3.3.0 such a path is refused as an unknown name is: the clause's own code, FieldPath{" "} - "*", in a filter, an order, a projection, a group - and an aggregate alike. It is refused only where the whole set of - members a container can produce is known. + "*", in every clause the database has to compute + — a filter, an order, a grouping key, an aggregated field, and each of + those inside a Segment. It is refused only where the whole + set of members a container can produce is known. +

          +

          + Selects is not one of them. A projection + is the last thing the provider builds, and EF Core evaluates that one on + the client when it cannot translate it, so{" "} + Selects = ["Id", "Name.IsEmpty"]{" "} + returns the computed value exactly as it did before. Refusing it would + take back a projection that has always worked.

          AttackControl
          @@ -1487,8 +1496,10 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`} - + + +
          …where the Select copies the member, Name = role.Namethe model, beneath the member it copiesRefused
          Rows in memorynothing — the getter runsRuns, as before
          Anything beneath a column, converted or notnothing — the converter decidesRuns, as before
          A framework member: Length, Year, Countnothing — the provider translates itRuns, as before
          A framework member: Length, Year, HasValuenothing — the provider translates itRuns, as before
          A source the library cannot readnothingRuns, as before
          A column only a subtype maps, queried through the basethe queried type's model, which is what EF Core translates againstRefused
          A projection a provider that is not EF Core's rannothing — its rules are its ownRuns, as before

          @@ -1498,10 +1509,11 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`} - - A member EF Core computes through [DbFunction] or an{" "} - IMethodCallTranslatorPlugin is not in the model, so the - strict tier refuses it. Map it, or filter on the columns beneath it. + + A member the model maps nowhere is one the database cannot compute, so + the strict tier refuses it wherever the database has to. A member some + provider extension computes without a mapping is refused with the rest: + map it, or filter on the columns beneath it.

          31. LastTrace Is Set Before a Request Is Sanitized

          @@ -1536,8 +1548,9 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}IncludeTraceInResult
          , AuditRefusals,{" "} HashSalt, StoreFailure,{" "} MaxSnapshotAge, RefreshInterval, every cap - including the group floor's opt-out, the exposed entity catalogue, - and the provider types in the order supplied. Not compared, and not + value, the exposed entity catalogue with every name it answers to, and + the provider types in the order supplied. Writing a cap's own + default down is not a difference. Not compared, and not replaced: TokenVault, Services and the provider instances — a second host builds its own, and no two are ever the same reference. diff --git a/OfficialWebsite/app/docs/policies/configuration/page.tsx b/OfficialWebsite/app/docs/policies/configuration/page.tsx index 7419252..587afd9 100644 --- a/OfficialWebsite/app/docs/policies/configuration/page.tsx +++ b/OfficialWebsite/app/docs/policies/configuration/page.tsx @@ -28,11 +28,14 @@ export default function Page() { MaxSnapshotAge = TimeSpan.FromMinutes(15), RefreshInterval = TimeSpan.FromSeconds(30), }, providers);`}
          - + The posture is read by every request thread without synchronization. A tier that can change while requests are in flight is one that can be relaxed by a code path nobody expected to be security-relevant, so - mutation after Configure throws. + mutation after Configure throws, and so does a second call + asking for a different posture. Since 3.3.0 a second call + asking for the one already in force is a no-op — see{" "} + Configuring twice.

          AttributePolicyProvider is added whether or not you pass it. @@ -907,10 +910,12 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so

          Configuring twice

          The first call decides the posture. Since 3.3.0 a - second DwPolicy.Configure, or a second{" "} - AddDwPolicies, asking for the posture already in - force does nothing and returns; one asking for a different - posture still throws InvalidOperationException. The + second DwPolicy.Configure asking for the posture + already in force does nothing and returns; one asking for a + different posture still throws{" "} + InvalidOperationException. A second{" "} + AddDwPolicies binds and builds its options as ever, changes + no posture, and registers the one in force. The comparison happens inside the lock that does the configuring, so a caller needs no lock and no IsConfigured check of its own — which matters, because that check is a check-then-act two hosts starting @@ -935,7 +940,7 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so Services - Every value on Caps, the group floor's opt-out included + Every value on Caps — the floor that applies, not whether it was written down The provider instances diff --git a/OfficialWebsite/app/docs/policies/page.tsx b/OfficialWebsite/app/docs/policies/page.tsx index eb6b86e..a73e877 100644 --- a/OfficialWebsite/app/docs/policies/page.tsx +++ b/OfficialWebsite/app/docs/policies/page.tsx @@ -57,8 +57,9 @@ PolicyTrace what the policy did: always on LastTrace, and on (off under the strict tier by default)`}

          The whole thing in one screen

          - {`// Once, at startup. A second call is refused: the tier is read by every -// request thread, and a posture that can change mid-flight can be relaxed. + {`// At startup. A second call asking for the same posture does nothing; a +// different one is refused, because the tier is read by every request thread +// and a posture that can change mid-flight can be relaxed. DwPolicy.Configure(new DwPolicyOptions { Tier = DwTier.Convenience, diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx index b43bce7..8b8871f 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -198,9 +198,11 @@ true order. Add [DwNoOrder] unless that is intended.`}

          Such a path is now refused as an unknown name is: the clause's own code, FieldPath "*", so it cannot be - told from a misspelling or from a field the caller may not use. It is - refused only where the whole set of members a container can produce is - known — an entity's own EF Core model, and the initializers of a + told from a misspelling or from a field the caller may not use. It + applies to every clause the database has to compute, and not to{" "} + Selects, which EF Core evaluates on the client when it + cannot translate it. It is refused only where the whole set of members a + container can produce is known — an entity's own EF Core model, and the initializers of a projection composed before ApplyPolicy, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as Length or{" "} diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index d09fe56..1ba39a2 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -1462,9 +1462,10 @@ DwPolicy stat hosts starting at once can both pass. This is what lets an integration suite start several `WebApplicationFactory` hosts over one composition root. - Compared: `Tier`, `DryRun`, `IncludeTraceInResult`, `AuditRefusals`, `HashSalt`, `StoreFailure`, - `MaxSnapshotAge`, `RefreshInterval`, every `DwCaps` value with `IsMinGroupSizeSet`, the exposed catalogue - (same types under the same names), and the provider **types**, in the order supplied, with - `AttributePolicyProvider` left out of both sides. + `MaxSnapshotAge`, `RefreshInterval`, every `DwCaps` value, the exposed catalogue — the same types under the same + names, and every name each type answers to — and the provider **types**, in the order supplied, with + `AttributePolicyProvider` left out of both sides. `IsMinGroupSizeSet` is not compared: nothing in enforcement + reads it, so writing `MinGroupSize = 5` and leaving it unset are the same posture. - Not compared, and not replaced: `TokenVault`, `Services`, and the provider **instances**. A second host builds its own and no two are ever the same reference, so they stay as the first call left them — a second host runs with the first host's vault, container and rule stores. @@ -1840,7 +1841,8 @@ Rules: - `LastTrace` is assigned before the request is sanitized (3.3.0), so a refused request leaves its own trace readable. It used to be assigned after, so a refusal left the previous request's trace, or null. A strict refusal names no field, and the trace is where the real path and reason are. - - It holds the latest call that got past sanitizing, or a `QueryStringDenied` refusal. + - It holds the latest call the handle made: one that got past sanitizing, one sanitizing refused, or a + `QueryStringDenied` refusal. - Refusals throw `PolicyException` (a `LogicException`) from the handle method. - With `DwPolicyOptions.AuditRefusals` on, the terminal and composable methods also write the refusal to the context's audit buffer on its way out, once, without changing or catching it (section 22). @@ -2312,7 +2314,13 @@ The resolver, fragment and provider types are listed in section 21. ## 17. Enforcement by tier and dry run Under `Strict` outside a dry run, a path that exists on the type but names no value the query can compute is refused -as an unknown name is (3.3.0), with the clause's own code and `FieldPath` `"*"`. What decides: +as an unknown name is (3.3.0), with the clause's own code and `FieldPath` `"*"`, in every clause the database has to +compute: a filter, an order, a grouping key, an aggregated field, and each of those inside a `Segment`. + +`Selects` is not one of them. A projection is the last thing the provider builds, and EF Core evaluates that one on +the client when it cannot translate it, so `Selects` naming such a member returns its value exactly as before. + +What decides: ``` Source of the rows What the set of producible members is read from Name.IsEmpty @@ -2322,13 +2330,16 @@ Select(...) before ApplyPolicy building the row that initializer's assignme ... a member it copies whole: Name = role.Name the model, beneath the copied member refused rows in memory (EnumerableQuery) nothing; the getter runs runs beneath a column, converted or not nothing; the converter decides runs -a framework member: Length, Year, Count, HasValue nothing; the provider translates it runs +a framework member: Length, Year, HasValue nothing; the provider translates it runs a source the library cannot read into nothing runs +a column only a subtype maps, through the base the queried type's model, which EF Core + translates against refused +a projection another provider ran nothing; its rules are its own runs ``` - An unmapped getter on the entity itself (`Display => $"{Code}:{Id}"`) is refused for the same reason. - `Convenience` and a dry run are unchanged: the provider throws, as it does unguarded. -- A member a `[DbFunction]` or an `IMethodCallTranslatorPlugin` computes is not in the model and is refused too. +- The rule is the model's: a member it maps nowhere is refused, whatever a provider extension could compute. - The trace records it: `the member exists on the type and the query cannot compute it, so it is refused as an unknown name is`. The count caps are checked first, before any name is resolved (3.1.0). Names are then resolved to canonical paths, @@ -4833,16 +4844,19 @@ MemoryCalculationInput force and do nothing. A different posture still throws. The check is inside the lock that configures, so a caller needs no IsConfigured check of its own — that check is a check-then-act two hosts can both pass. Compared: tier, DryRun, IncludeTraceInResult, AuditRefusals, HashSalt, StoreFailure, MaxSnapshotAge, - RefreshInterval, every cap with IsMinGroupSizeSet, the exposed catalogue, and the provider types in order. + RefreshInterval, every cap value, the exposed catalogue with every name it answers to, and the provider + types in order. Not compared and not replaced: TokenVault, Services and the provider instances, so a second host runs with the first host's vault, container and rule stores. AddDwPolicies registers the posture in force rather than the instance it built. - Behaviour change. Under Strict outside a dry run, a path that exists on the type and names no value the query can compute — LocalizedText.IsEmpty over two columns, an unmapped getter on the entity — is refused - as an unknown name is, in every clause, where the provider used to throw and the caller saw a five-hundred. - Refused only where the producible members are known: an entity's model, and the initializers of a - projection composed before ApplyPolicy, including a member it copies from the entity. Rows in memory, a - framework member such as Length or Year, anything beneath a column, Convenience and a dry run are unchanged. + as an unknown name is, in every clause the database has to compute, where the provider used to throw and + the caller saw a five-hundred. Selects is not one of them: EF Core evaluates the last projection on the + client. Refused only where the producible members are known: the queried type's own model, and the + initializers of a projection EF Core ran before ApplyPolicy, including a member it copies from the entity. + Rows in memory, a framework member such as Length or Year, anything beneath a column, another provider's + rows, Convenience and a dry run are unchanged. - Behaviour change. LastTrace is assigned before a request is sanitized, so a refused request leaves its own trace readable instead of the previous request's. - New. Clone() is public on Filter, Segment and Summary. A deep copy: the condition tree, the projection list, @@ -5095,10 +5109,13 @@ MemoryCalculationInput `[Column]`. Reached through a navigation (`Owner.New`) it is an ordinary member. - A declared date format may not write text ISO 8601 or a year-first date already reads; `DwDates.Configure` refuses one (section 4). ISO 8601 and year-first dates are read on every deployment and cannot be turned off. -- A member no database can compute — a getter over columns, an unmapped getter on an entity, a member a - `[DbFunction]` or a translator plugin computes — cannot be filtered, ordered, selected, grouped or aggregated under - `Strict` on a source the library can read (3.3.0, section 17). Map it, or name the columns beneath it. Under - `Convenience` it still reaches the provider and still throws there. +- A member no database can compute — a getter over columns, or an unmapped getter on an entity — cannot be + filtered, ordered, grouped or aggregated under `Strict` on a source the library can read (3.3.0, section 17). Map + it, or name the columns beneath it. A projection may still name it, because EF Core evaluates the last projection + on the client. Under `Convenience` every clause still reaches the provider and still throws there. +- A column the model maps only on a subtype cannot be filtered, ordered, grouped or aggregated through the base type + (3.3.0, section 17). EF Core translates a member against the type the query is over, so it fails there too: query + the derived type, `Set()`. - A second host configuring the same posture runs with the first host's `TokenVault`, `Services` and rule store instances (3.3.0, section 12). They are not compared and not replaced. - Summary rows flatten dotted group fields (`CategoryName`); `SelectDynamic` rows nest them (`Category.Name`). diff --git a/README.md b/README.md index 6de7961..c7f3a9f 100644 --- a/README.md +++ b/README.md @@ -379,8 +379,8 @@ The complete reference — every enum, class, extension method, validation rule, **Upgrade note — two behaviour changes. Read these before bumping.** -- **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. -- **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. A member a custom EF Core translator computes, through `[DbFunction]` or a translator plugin, is refused with the rest: map it, or filter on the columns beneath it. +- **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a cap's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. +- **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. - **Changed: `LastTrace` is set before a request is sanitized**, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. - **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. - **Docs:** `[DwEntity(DefaultOrder)]`'s own remarks said a projected query takes no default; it has since 3.2.0. `DwCaps.MinGroupSize` ships **on at 5**, so a guarded summary silently drops groups of fewer than five rows — the aggregation docs now lead with that instead of leaving it to the caps table. From 371a8784289f40e8bddd2a26572c476fffba9f60 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 13:12:38 +0300 Subject: [PATCH 10/54] test(policies): keep the review's probes, so what was ruled out stays ruled out The first round's over-blocking and posture probes, adopted as tests. They cover what the fixes must not take back: a projection naming an unmapped getter on the entity, across a navigation, on an owned member and on a JSON member; the framework members the provider translates; and the posture comparison property by property, the token vault, the service provider, provider instances and provider order included. One case asserted the opt-out being compared, which the fix removed on purpose; it asserts the value that applies instead. Co-Authored-By: Claude Opus 5 --- .../Policies/ReviewComputedPathTests.cs | 813 ++++++++++++++++++ .../Policies/ReviewPostureComparisonTests.cs | 325 +++++++ 2 files changed, 1138 insertions(+) create mode 100644 DynamicWhere.Tests/Policies/ReviewComputedPathTests.cs create mode 100644 DynamicWhere.Tests/Policies/ReviewPostureComparisonTests.cs diff --git a/DynamicWhere.Tests/Policies/ReviewComputedPathTests.cs b/DynamicWhere.Tests/Policies/ReviewComputedPathTests.cs new file mode 100644 index 0000000..168db97 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewComputedPathTests.cs @@ -0,0 +1,813 @@ +using System.ComponentModel.DataAnnotations.Schema; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // --------------------------------------------------------------------------------------------- + // Probe model. EF Core 6 compatible on purpose, so the floor leg runs it too. + // --------------------------------------------------------------------------------------------- + + public class ObMoney + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = "USD"; + + ///

          A getter over two owned columns: no database can answer it. + public bool IsZero => Amount == 0m; + } + + public class ObCustomer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? Nickname { get; set; } + + public List Orders { get; set; } = new(); + + /// An unmapped getter one navigation away. + [NotMapped] + public string Handle => Name + "!"; + } + + public class ObOrder + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public DateTime PlacedAt { get; set; } + + public DateTime? ShippedAt { get; set; } + + public TimeSpan Window { get; set; } + + public int Quantity { get; set; } + + public int CustomerId { get; set; } + + public ObCustomer Customer { get; set; } = null!; + + public ObMoney Total { get; set; } = new(); + + /// An unmapped getter over two mapped columns of the entity itself. + [NotMapped] + public string Slug => Code + "-" + Id; + } + + /// A row a caller projects. + public class ObOrderRow + { + public ObOrderRow() + { + } + + public ObOrderRow(int id, string code) + { + Id = id; + Code = code; + } + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public ObMoney Total { get; set; } = new(); + + public ObCustomer? Customer { get; set; } + + public string Label { get; set; } = string.Empty; + + public ObNest Nest { get; set; } = new(); + } + + public class ObNest + { + public ObNest? Inner { get; set; } + + public string Leaf { get; set; } = string.Empty; + + public ObMoney Money { get; set; } = new(); + } + + // ---- a hierarchy -------------------------------------------------------------------------- + + public class ObParty + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + } + + public class ObMerchant : ObParty + { + public string? Licence { get; set; } + } + + // ---- an alias ----------------------------------------------------------------------------- + + public class ObAliased + { + public int Id { get; set; } + + [DwAlias("customer_name")] + public string Name { get; set; } = string.Empty; + + [NotMapped] + [DwAlias("computed_tag")] + public string Tag => Name + "!"; + } + + public sealed class ObContext : DbContext + { + private readonly SqliteConnection _connection; + + public ObContext(SqliteConnection connection) => _connection = connection; + + public DbSet Orders => Set(); + + public DbSet Customers => Set(); + + public DbSet Parties => Set(); + + public DbSet Aliased => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(order => order.Total); + model.Entity().Ignore(order => order.Slug); + model.Entity().Property("Tenant"); + model.Entity().HasDiscriminator("Discriminator") + .HasValue("party") + .HasValue("merchant"); + model.Entity().Ignore(row => row.Tag); + model.Entity().Ignore(customer => customer.Handle); + } + } + + /// + /// Probes for DW-17 over-blocking: a path the strict tier now refuses that an unguarded query + /// would in fact have run. Every case checks the unguarded query first. + /// + public sealed class ReviewComputedPathTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ObContext _db; + + public ReviewComputedPathTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ObContext(_connection); + _db.Database.EnsureCreated(); + + ObCustomer customer = new() { Name = "Acme", Nickname = "A" }; + + _db.Customers.Add(customer); + _db.Orders.Add(new ObOrder + { + Code = "A-001", + PlacedAt = new DateTime(2024, 3, 9, 10, 30, 0), + ShippedAt = new DateTime(2024, 3, 10), + Window = TimeSpan.FromHours(3), + Quantity = 2, + Customer = customer, + Total = new ObMoney { Amount = 10m, Currency = "USD" } + }); + _db.Parties.Add(new ObMerchant { Kind = "merchant", Licence = "L-1" }); + _db.Aliased.Add(new ObAliased { Name = "Acme" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + } + }; + + private static Filter Select(params string[] fields) => new() { Selects = fields.ToList() }; + + private static Filter Order(string field) => new() + { + Orders = new List { new() { Field = field, Direction = Direction.Ascending } } + }; + + /// Runs a guarded call and reports what came back: rows, a refusal, or a provider failure. + private string Run(IQueryable source, Filter filter, DwTier tier = DwTier.Strict) + where T : class + { + try + { + FilterResult result = Guard(source, tier).ToList(filter); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return $"{failure.GetType().Name}: {Short(failure.Message)}"; + } + } + + private static string Short(string message) + { + string one = message.Replace(Environment.NewLine, " "); + + return one.Length > 140 ? one[..140] : one; + } + + /// Runs a raw LINQ query the way a caller without the guard would. + private string Unguarded(Func query) + { + try + { + object value = query(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (Exception failure) + { + return $"{failure.GetType().Name}: {Short(failure.Message)}"; + } + } + + private void Report(string probe, string unguarded, string guarded) => + _out.WriteLine($"{probe,-52} unguarded={unguarded,-40} guarded={guarded}"); + + // ========================================================================================= + // 1. Framework members the provider translates, on an entity source. + // ========================================================================================= + + [Fact] + public void Framework_members_on_an_entity_are_not_refused() + { + (string Probe, string Field, DataType Type, Func Raw)[] cases = + { + ("string.Length", "Code.Length", DataType.Number, + () => _db.Orders.Where(o => o.Code.Length == 5).ToList()), + ("DateTime.Year", "PlacedAt.Year", DataType.Number, + () => _db.Orders.Where(o => o.PlacedAt.Year == 2024).ToList()), + ("DateTime.Month", "PlacedAt.Month", DataType.Number, + () => _db.Orders.Where(o => o.PlacedAt.Month == 3).ToList()), + ("DateTime.Day", "PlacedAt.Day", DataType.Number, + () => _db.Orders.Where(o => o.PlacedAt.Day == 9).ToList()), + ("DateTime.Date", "PlacedAt.Date", DataType.DateTime, + () => _db.Orders.Where(o => o.PlacedAt.Date == new DateTime(2024, 3, 9)).ToList()), + ("Nullable.HasValue", "ShippedAt.HasValue", DataType.Boolean, + () => _db.Orders.Where(o => o.ShippedAt.HasValue).ToList()), + ("Nullable.Value.Year", "ShippedAt.Value.Year", DataType.Number, + () => _db.Orders.Where(o => o.ShippedAt!.Value.Year == 2024).ToList()), + ("TimeSpan.Hours", "Window.Hours", DataType.Number, + () => _db.Orders.Where(o => o.Window.Hours == 3).ToList()), + ("TimeSpan.TotalHours", "Window.TotalHours", DataType.Number, + () => _db.Orders.Where(o => o.Window.TotalHours == 3d).ToList()), + ("nav then string.Length", "Customer.Name.Length", DataType.Number, + () => _db.Orders.Where(o => o.Customer.Name.Length == 4).ToList()), + ("owned then string.Length", "Total.Currency.Length", DataType.Number, + () => _db.Orders.Where(o => o.Total.Currency.Length == 3).ToList()), + // Ruled out: "Customer.Orders.Count" is refused on 3.2.0 too, by Validate() -- + // the trace says "names nothing on ObOrder", not the DW-17 reason. Left out of the + // assertion because it is a standing limit, not this branch's doing. + ("owned then decimal member", "Total.Amount", DataType.Number, + () => _db.Orders.Where(o => o.Total.Amount == 10m).ToList()) + }; + + List wrong = new(); + + foreach ((string probe, string field, DataType type, Func raw) in cases) + { + string unguarded = Unguarded(raw); + string guarded = Run(_db.Orders, Where(field, DefaultFor(type), type)); + + Report(probe, unguarded, guarded); + + if (unguarded.StartsWith("OK") && guarded.StartsWith("REFUSED")) + { + wrong.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + private static string DefaultFor(DataType type) => type switch + { + DataType.Number => "3", + DataType.Boolean => "true", + DataType.DateTime => "2024-03-09", + _ => "x" + }; + + // ========================================================================================= + // 2. An unmapped getter, in each clause, against what the unguarded query does. + // ========================================================================================= + + [Fact] + public void An_unmapped_getter_in_a_where_matches_the_unguarded_failure() + { + string unguarded = Unguarded(() => _db.Orders.Where(o => o.Slug == "A-001-1").ToList()); + string guarded = Run(_db.Orders, Where("Slug", "A-001-1")); + + Report("WHERE on [NotMapped] getter", unguarded, guarded); + + Assert.StartsWith("InvalidOperationException", unguarded); + Assert.StartsWith("REFUSED", guarded); + } + + [Fact] + public void An_unmapped_getter_in_an_order_matches_the_unguarded_failure() + { + string unguarded = Unguarded(() => _db.Orders.OrderBy(o => o.Slug).ToList()); + string guarded = Run(_db.Orders, Order("Slug")); + + Report("ORDER on [NotMapped] getter", unguarded, guarded); + + Assert.StartsWith("InvalidOperationException", unguarded); + Assert.StartsWith("REFUSED", guarded); + } + + /// + /// The one EF Core evaluates on the client: the top-level projection. A caller who asked for + /// the getter in Selects got rows on 3.2.0. + /// + [Fact] + public void An_unmapped_getter_in_a_select_is_the_case_that_worked() + { + string unguarded = Unguarded(() => _db.Orders.Select(o => new { o.Id, o.Slug }).ToList()); + string guarded = Run(_db.Orders, Select("Id", "Slug")); + string convenience = Run(_db.Orders, Select("Id", "Slug"), DwTier.Convenience); + + Report("SELECT on [NotMapped] getter", unguarded, $"strict={guarded} convenience={convenience}"); + + // The finding, if any: unguarded runs and strict refuses. + Assert.False( + unguarded.StartsWith("OK") && guarded.StartsWith("REFUSED"), + $"over-block: unguarded {unguarded}, convenience {convenience}, strict {guarded}"); + } + + // ========================================================================================= + // 3. The hierarchy, shadow state, includes, split queries, raw SQL. + // ========================================================================================= + + [Fact] + public void A_hierarchy_and_a_loaded_source_are_not_refused() + { + List wrong = new(); + + void Probe(string name, string unguarded, string guarded) + { + Report(name, unguarded, guarded); + + if (unguarded.StartsWith("OK") && guarded.StartsWith("REFUSED")) + { + wrong.Add($"{name}: unguarded {unguarded}, guarded {guarded}"); + } + } + + Probe( + "OfType then derived column", + Unguarded(() => _db.Parties.OfType().Where(m => m.Licence == "L-1").ToList()), + Run(_db.Parties.OfType(), Where("Licence", "L-1"))); + + Probe( + "Set then base column", + Unguarded(() => _db.Set().Where(m => m.Kind == "merchant").ToList()), + Run(_db.Set(), Where("Kind", "merchant"))); + + Probe( + "Include then a path through it", + Unguarded(() => _db.Orders.Include(o => o.Customer).Where(o => o.Customer.Name == "Acme").ToList()), + Run(_db.Orders.Include(o => o.Customer), Where("Customer.Name", "Acme"))); + + Probe( + "ThenInclude then a deeper path", + Unguarded(() => _db.Customers.Include(c => c.Orders).ThenInclude(o => o.Total) + .Where(c => c.Name == "Acme").ToList()), + Run(_db.Customers.Include(c => c.Orders).ThenInclude(o => o.Total), Where("Name", "Acme"))); + + Probe( + "AsSplitQuery", + Unguarded(() => _db.Customers.Include(c => c.Orders).AsSplitQuery().Where(c => c.Name == "Acme").ToList()), + Run(_db.Customers.Include(c => c.Orders).AsSplitQuery(), Where("Name", "Acme"))); + + Probe( + "FromSqlRaw", + Unguarded(() => _db.Orders.FromSqlRaw("SELECT * FROM Orders").Where(o => o.Code == "A-001").ToList()), + Run(_db.Orders.FromSqlRaw("SELECT * FROM Orders"), Where("Code", "A-001"))); + + Probe( + "AsNoTracking", + Unguarded(() => _db.Orders.AsNoTracking().Where(o => o.Code == "A-001").ToList()), + Run(_db.Orders.AsNoTracking(), Where("Code", "A-001"))); + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + // ========================================================================================= + // 4. Projections of every shape. + // ========================================================================================= + + [Fact] + public void Projections_do_not_refuse_what_they_can_still_compute() + { + List wrong = new(); + + void Probe(string name, IQueryable rows, Filter filter, Func raw) + where TRow : class + { + string unguarded = Unguarded(raw); + string guarded = Run(rows, filter); + + Report(name, unguarded, guarded); + + if (unguarded.StartsWith("OK") && guarded.StartsWith("REFUSED")) + { + wrong.Add($"{name}: unguarded {unguarded}, guarded {guarded}"); + } + } + + // a) member-by-member initializer, a framework member beneath a copied column + Probe( + "built row, Code.Length", + _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Code = o.Code }), + Where("Code.Length", "5", DataType.Number), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Code = o.Code }) + .Where(r => r.Code.Length == 5).ToList()); + + // b) constructor with arguments + Probe( + "ctor row, Code", + _db.Orders.Select(o => new ObOrderRow(o.Id, o.Code)), + Where("Code", "A-001"), + () => _db.Orders.Select(o => new ObOrderRow(o.Id, o.Code)).Where(r => r.Code == "A-001").ToList()); + + // c) anonymous row + Probe( + "anonymous row, Code", + _db.Orders.Select(o => new { o.Id, o.Code }), + Where("Code", "A-001"), + () => _db.Orders.Select(o => new { o.Id, o.Code }).Where(r => r.Code == "A-001").ToList()); + + // d) two selects in a chain + Probe( + "two selects, Code", + _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Code = o.Code }) + .Select(r => new ObOrderRow { Id = r.Id, Code = r.Code }), + Where("Code", "A-001"), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Code = o.Code }) + .Select(r => new ObOrderRow { Id = r.Id, Code = r.Code }) + .Where(r => r.Code == "A-001").ToList()); + + // e) SelectMany + Probe( + "SelectMany, Code", + _db.Customers.SelectMany(c => c.Orders), + Where("Code", "A-001"), + () => _db.Customers.SelectMany(c => c.Orders).Where(o => o.Code == "A-001").ToList()); + + // f) Join into a built row + Probe( + "Join, Code", + _db.Orders.Join(_db.Customers, o => o.CustomerId, c => c.Id, + (o, c) => new ObOrderRow { Id = o.Id, Code = o.Code, Label = c.Name }), + Where("Label", "Acme"), + () => _db.Orders.Join(_db.Customers, o => o.CustomerId, c => c.Id, + (o, c) => new ObOrderRow { Id = o.Id, Code = o.Code, Label = c.Name }) + .Where(r => r.Label == "Acme").ToList()); + + // g) GroupBy then a built row + Probe( + "GroupBy, Label", + _db.Orders.GroupBy(o => o.Code).Select(g => new ObOrderRow { Code = g.Key, Id = g.Count() }), + Where("Code", "A-001"), + () => _db.Orders.GroupBy(o => o.Code).Select(g => new ObOrderRow { Code = g.Key, Id = g.Count() }) + .Where(r => r.Code == "A-001").ToList()); + + // h) a member assigned from a method call + Probe( + "method-call member, Label.Length", + _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Label = o.Code.ToUpper() }), + Where("Label.Length", "5", DataType.Number), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Label = o.Code.ToUpper() }) + .Where(r => r.Label.Length == 5).ToList()); + + // i) a member assigned from a conditional + Probe( + "conditional member, Label", + _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Label = o.Quantity > 1 ? o.Code : "none" }), + Where("Label", "A-001"), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Label = o.Quantity > 1 ? o.Code : "none" }) + .Where(r => r.Label == "A-001").ToList()); + + // j) a member assigned from a captured variable + string captured = "captured"; + + Probe( + "captured member, Label", + _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Label = captured }), + Where("Label", "captured"), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Label = captured }) + .Where(r => r.Label == "captured").ToList()); + + // k) Select handing back an entity + Probe( + "Select(x => x.Navigation), Name", + _db.Orders.Select(o => o.Customer), + Where("Name", "Acme"), + () => _db.Orders.Select(o => o.Customer).Where(c => c.Name == "Acme").ToList()); + + // l) a copied owned member, then a mapped column beneath it + Probe( + "copied owned member, Total.Currency", + _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Total = o.Total }), + Where("Total.Currency", "USD"), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Total = o.Total }) + .Where(r => r.Total.Currency == "USD").ToList()); + + // m) a nested initializer, a framework member two levels down + Probe( + "nested initializer, Nest.Leaf.Length", + _db.Orders.Select(o => new ObOrderRow + { + Id = o.Id, + Nest = new ObNest { Leaf = o.Code } + }), + Where("Nest.Leaf.Length", "5", DataType.Number), + () => _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Nest = new ObNest { Leaf = o.Code } }) + .Where(r => r.Nest.Leaf.Length == 5).ToList()); + + // n) a nested initializer as deep as DwCaps.MaxNavigationDepth (4) allows. The + // ReadAssignments recursion cap is 8, so the navigation-depth cap refuses a path before + // the recursion cap could ever be reached: a caller cannot get past it. + Probe( + "deep nest (4 levels), leaf", + Deep(), + Where("Nest.Inner.Inner.Leaf", "A-001"), + () => Deep().Where(r => r.Nest.Inner!.Inner!.Leaf == "A-001").ToList()); + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + private IQueryable Deep() => + _db.Orders.Select(o => new ObOrderRow + { + Id = o.Id, + Nest = new ObNest + { + Inner = new ObNest + { + Inner = new ObNest { Leaf = o.Code } + } + } + }); + + // ========================================================================================= + // 5. A projection that leaves a member unassigned: refusal must match the unguarded failure. + // ========================================================================================= + + [Fact] + public void An_unassigned_member_of_a_projection_matches_the_unguarded_failure() + { + IQueryable rows = _db.Orders.Select(o => new ObOrderRow { Id = o.Id, Code = o.Code }); + + string unguarded = Unguarded(() => _db.Orders + .Select(o => new ObOrderRow { Id = o.Id, Code = o.Code }) + .Where(r => r.Total.Currency == "USD").ToList()); + string guarded = Run(rows, Where("Total.Currency", "USD")); + + Report("unassigned member of projection", unguarded, guarded); + + Assert.False( + unguarded.StartsWith("OK") && guarded.StartsWith("REFUSED"), + $"over-block: unguarded {unguarded}, guarded {guarded}"); + } + + // ========================================================================================= + // 6. A source the library cannot read: a wrapping provider that is not EF Core's. + // ========================================================================================= + + [Fact] + public void A_wrapping_provider_is_left_alone() + { + IQueryable wrapped = new WrappedQueryable(_db.Orders); + + string guarded = Run(wrapped, Where("Code", "A-001")); + string framework = Run(wrapped, Where("Code.Length", "5", DataType.Number)); + string getter = Run(wrapped, Where("Slug", "A-001-1")); + + Report("wrapping provider", "n/a", $"plain={guarded} framework={framework} getter={getter}"); + + Assert.StartsWith("OK", guarded); + } + + // ========================================================================================= + // 7. Aliases and spelling. + // ========================================================================================= + + [Fact] + public void An_alias_target_that_is_a_column_is_not_refused() + { + string guarded = Run(_db.Aliased, Where("customer_name", "Acme")); + string direct = Run(_db.Aliased, Where("Name", "Acme")); + string cased = Run(_db.Aliased, Where("nAmE", "Acme")); + string nested = Run(_db.Orders, Where("cUsToMeR.nAmE", "Acme")); + string aliasOfGetter = Run(_db.Aliased, Where("computed_tag", "Acme!")); + + Report( + "aliases and casing", + Unguarded(() => _db.Aliased.Where(a => a.Name == "Acme").ToList()), + $"alias={guarded} direct={direct} cased={cased} nestedCased={nested} aliasOfGetter={aliasOfGetter}"); + + Assert.StartsWith("OK", guarded); + Assert.StartsWith("OK", direct); + Assert.StartsWith("OK", cased); + Assert.StartsWith("OK", nested); + } + + // ========================================================================================= + // 8. Every clause, on paths that must stay allowed. + // ========================================================================================= + + [Fact] + public void Every_clause_still_takes_a_framework_member() + { + string order = Run(_db.Orders, Order("Code.Length")); + string select = Run(_db.Orders, Select("Id", "Code.Length")); + + string group; + + try + { + Guard(_db.Orders).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "PlacedAt.Year" }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + } + }); + + group = "OK"; + } + catch (PolicyException refusal) + { + group = $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + group = $"{failure.GetType().Name}: {Short(failure.Message)}"; + } + + string aggregate; + + try + { + Guard(_db.Orders).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List + { + new() { Alias = "Longest", Field = "Code.Length", Aggregator = Aggregator.Maximum } + } + } + }); + + aggregate = "OK"; + } + catch (PolicyException refusal) + { + aggregate = $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + aggregate = $"{failure.GetType().Name}: {Short(failure.Message)}"; + } + + string segment; + + try + { + Guard(_db.Orders).ToListAsync(new Segment + { + ConditionSets = new List + { + new() + { + Sort = 0, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code.Length", + DataType = DataType.Number, + Operator = Operator.Equal, + Values = { "5" } + } + } + } + } + } + }).GetAwaiter().GetResult(); + + segment = "OK"; + } + catch (PolicyException refusal) + { + segment = $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + segment = $"{failure.GetType().Name}: {Short(failure.Message)}"; + } + + Report("clauses on Code.Length / PlacedAt.Year", "n/a", + $"order={order} select={select} group={group} aggregate={aggregate} segment={segment}"); + + Assert.StartsWith("OK", order); + Assert.DoesNotContain("REFUSED", group); + Assert.DoesNotContain("REFUSED", aggregate); + Assert.DoesNotContain("REFUSED", segment); + } + } + + /// A provider that is neither EF Core's nor an EnumerableQuery. + internal sealed class WrappedQueryable : IQueryable, IOrderedQueryable + { + private readonly IQueryable _inner; + + internal WrappedQueryable(IQueryable inner) + { + _inner = inner; + Provider = new WrappedProvider(inner.Provider); + } + + public Type ElementType => _inner.ElementType; + + public System.Linq.Expressions.Expression Expression => _inner.Expression; + + public IQueryProvider Provider { get; } + + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + internal sealed class WrappedProvider : IQueryProvider + { + private readonly IQueryProvider _inner; + + internal WrappedProvider(IQueryProvider inner) => _inner = inner; + + public IQueryable CreateQuery(System.Linq.Expressions.Expression expression) => _inner.CreateQuery(expression); + + public IQueryable CreateQuery(System.Linq.Expressions.Expression expression) => + _inner.CreateQuery(expression); + + public object? Execute(System.Linq.Expressions.Expression expression) => _inner.Execute(expression); + + public TResult Execute(System.Linq.Expressions.Expression expression) => + _inner.Execute(expression); + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewPostureComparisonTests.cs b/DynamicWhere.Tests/Policies/ReviewPostureComparisonTests.cs new file mode 100644 index 0000000..2832bbc --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewPostureComparisonTests.cs @@ -0,0 +1,325 @@ +using System.Reflection; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Tokens; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// + /// DOC PROBE ONLY — not part of the suite. Checks the documented "Configuring twice" comparison + /// list against what SamePosture actually compares. + /// + public sealed class ReviewPostureComparisonTests + { + private readonly ITestOutputHelper _out; + + public ReviewPostureComparisonTests(ITestOutputHelper output) + { + _out = output; + PolicyBootstrap.Ensure(); + } + + /// A posture holding exactly what is in force, value by value. + private static DwPolicyOptions Copy() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicyOptions copy = new() + { + Tier = inForce.Tier, + DryRun = inForce.DryRun, + IncludeTraceInResult = inForce.IncludeTraceInResult, + AuditRefusals = inForce.AuditRefusals, + StoreFailure = inForce.StoreFailure, + MaxSnapshotAge = inForce.MaxSnapshotAge, + RefreshInterval = inForce.RefreshInterval + }; + + if (!string.IsNullOrEmpty(inForce.HashSalt)) + { + copy.HashSalt = inForce.HashSalt; + } + + foreach (PropertyInfo cap in WritableCaps()) + { + if (cap.Name == nameof(DwCaps.MinGroupSize)) + { + continue; + } + + cap.SetValue(copy.Caps, cap.GetValue(inForce.Caps)); + } + + if (inForce.Caps.IsMinGroupSizeSet) + { + copy.Caps.MinGroupSize = inForce.Caps.MinGroupSize; + } + + foreach (KeyValuePair exposed in inForce.Entities.Entities) + { + copy.Entities.Expose(exposed.Key, exposed.Value); + } + + return copy; + } + + private static PropertyInfo[] WritableCaps() => + typeof(DwCaps) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.CanWrite && property.PropertyType == typeof(int)) + .ToArray(); + + private static bool Refuses(DwPolicyOptions candidate, params IDwPolicyProvider[] providers) + { + try + { + DwPolicy.Configure(candidate, providers); + + return false; + } + catch (InvalidOperationException) + { + return true; + } + } + + // ---- the control: the copy itself must be accepted ------------------------------------- + + [Fact] + public void The_copy_is_accepted_so_every_other_case_means_something() + { + Assert.False(Refuses(Copy())); + } + + // ---- every writable value on the posture, swept ---------------------------------------- + + [Fact] + public void Sweep_every_writable_property_on_DwPolicyOptions() + { + _out.WriteLine("property | changed to | refused?"); + + foreach (PropertyInfo property in typeof(DwPolicyOptions) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanWrite)) + { + DwPolicyOptions candidate = Copy(); + object? value; + + switch (property.Name) + { + case nameof(DwPolicyOptions.Tier): + value = DwPolicy.Options.Tier == DwTier.Strict ? DwTier.Convenience : DwTier.Strict; + break; + case nameof(DwPolicyOptions.DryRun): + value = !DwPolicy.Options.DryRun; + break; + case nameof(DwPolicyOptions.IncludeTraceInResult): + value = DwPolicy.Options.IncludeTraceInResult == true ? false : true; + break; + case nameof(DwPolicyOptions.AuditRefusals): + value = !DwPolicy.Options.AuditRefusals; + break; + case nameof(DwPolicyOptions.HashSalt): + value = new string('z', DwPolicyOptions.MinimumHashSaltLength + 3); + break; + case nameof(DwPolicyOptions.StoreFailure): + value = DwPolicy.Options.StoreFailure == StoreFailureMode.FailClosed + ? StoreFailureMode.LastKnownGood + : StoreFailureMode.FailClosed; + break; + case nameof(DwPolicyOptions.MaxSnapshotAge): + value = DwPolicy.Options.MaxSnapshotAge + TimeSpan.FromMinutes(3); + break; + case nameof(DwPolicyOptions.RefreshInterval): + value = DwPolicy.Options.RefreshInterval + TimeSpan.FromSeconds(7); + break; + case nameof(DwPolicyOptions.TokenVault): + value = new InMemoryTokenVault(); + break; + case nameof(DwPolicyOptions.Services): + value = new ProbeServices(); + break; + default: + _out.WriteLine($"{property.Name} | SKIPPED (no probe value)"); + continue; + } + + property.SetValue(candidate, value); + + _out.WriteLine($"{property.Name} | {value} | refused={Refuses(candidate)}"); + } + } + + [Fact] + public void Sweep_every_writable_cap() + { + _out.WriteLine("cap | refused?"); + + foreach (PropertyInfo cap in WritableCaps()) + { + DwPolicyOptions candidate = Copy(); + int current = (int)cap.GetValue(DwPolicy.Options.Caps)!; + bool set = false; + + foreach (int attempt in new[] { current + 1, current - 1, current + 2 }) + { + try + { + cap.SetValue(candidate.Caps, attempt); + set = true; + break; + } + catch (Exception) + { + // out of the setter's range; try the next candidate value + } + } + + _out.WriteLine(set + ? $"{cap.Name} | refused={Refuses(candidate)}" + : $"{cap.Name} | SKIPPED (no settable neighbour of {current})"); + } + } + + // ---- the claims the docs make about what is NOT compared -------------------------------- + + [Fact] + public void A_different_token_vault_is_not_compared() + { + DwPolicyOptions candidate = Copy(); + + candidate.TokenVault = new InMemoryTokenVault(); + + bool refused = Refuses(candidate); + + _out.WriteLine($"TokenVault differs -> refused={refused}; in force is still {DwPolicy.Options.TokenVault?.GetType().Name ?? "null"}"); + + Assert.False(refused); + } + + [Fact] + public void A_different_service_provider_is_not_compared() + { + DwPolicyOptions candidate = Copy(); + + candidate.Services = new ProbeServices(); + + bool refused = Refuses(candidate); + + _out.WriteLine($"Services differs -> refused={refused}; in force is still {DwPolicy.Options.Services?.GetType().Name ?? "null"}"); + + Assert.False(refused); + } + + [Fact] + public void The_vault_and_container_in_force_are_not_replaced_by_a_second_call() + { + IDwTokenVault? vaultBefore = DwPolicy.Options.TokenVault; + IServiceProvider? servicesBefore = DwPolicy.Options.Services; + + DwPolicyOptions candidate = Copy(); + + candidate.TokenVault = new InMemoryTokenVault(); + candidate.Services = new ProbeServices(); + + DwPolicy.Configure(candidate); + + _out.WriteLine($"after: vault same={ReferenceEquals(vaultBefore, DwPolicy.Options.TokenVault)}, services same={ReferenceEquals(servicesBefore, DwPolicy.Options.Services)}"); + + Assert.Same(vaultBefore, DwPolicy.Options.TokenVault); + Assert.Same(servicesBefore, DwPolicy.Options.Services); + } + + // ---- IncludeTraceInResult, which the author's suite never varies ------------------------- + + [Fact] + public void A_different_include_trace_flag_is_refused() + { + DwPolicyOptions candidate = Copy(); + + candidate.IncludeTraceInResult = DwPolicy.Options.IncludeTraceInResult == true; + + bool refused = Refuses(candidate); + + _out.WriteLine($"IncludeTraceInResult {DwPolicy.Options.IncludeTraceInResult} -> {candidate.IncludeTraceInResult}: refused={refused}"); + + Assert.True(refused); + } + + // ---- the group floor: the value that applies, not whether it was written ------------------ + + [Fact] + public void Setting_the_floor_to_the_value_it_already_reads_is_the_same_posture() + { + // The floor that applies is what a query enforces; IsMinGroupSizeSet tells a deliberate + // opt-out from a deployment that never heard of the control, and enforcement never reads + // it. The documented appsettings sample writes "MinGroupSize": 5. + if (DwPolicy.Options.Caps.IsMinGroupSizeSet) + { + _out.WriteLine("in force is already explicit; nothing to prove here"); + + return; + } + + DwPolicyOptions candidate = Copy(); + + candidate.Caps.MinGroupSize = DwPolicy.Options.Caps.MinGroupSize; + + bool refused = Refuses(candidate); + + _out.WriteLine($"asked: MinGroupSize={candidate.Caps.MinGroupSize}, IsMinGroupSizeSet={candidate.Caps.IsMinGroupSizeSet}, refused={refused}"); + + Assert.False(refused); + } + + // ---- providers: types, order, instances --------------------------------------------------- + + [Fact] + public void Two_different_instances_of_the_same_provider_types_are_accepted() + { + // First call supplied none, so supply none here too and prove the instance-blindness + // through a pair of calls that both supply one of the same type. + DwPolicyOptions first = Copy(); + + bool addingOneIsRefused = Refuses(first, new FakePolicyProvider()); + + _out.WriteLine($"adding a source where none was supplied: refused={addingOneIsRefused}"); + + Assert.True(addingOneIsRefused); + } + + [Fact] + public void The_attribute_provider_is_left_out_of_both_sides() + { + DwPolicyOptions candidate = Copy(); + + bool refused = Refuses(candidate, new AttributePolicyProvider()); + + _out.WriteLine($"supplying only AttributePolicyProvider where none was supplied: refused={refused}"); + + Assert.False(refused); + } + + [Fact] + public void A_null_entry_among_the_providers_is_ignored() + { + DwPolicyOptions candidate = Copy(); + + bool refused = Refuses(candidate, new IDwPolicyProvider[] { null! }); + + _out.WriteLine($"supplying a null provider: refused={refused}"); + + Assert.False(refused); + } + + /// A container that answers nothing, used only to be a different reference. + private sealed class ProbeServices : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + } +} From 2735a72dbb0dc52bd0d079b8a7fcfb7df23a647c Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 13:58:44 +0300 Subject: [PATCH 11/54] fix(policies): the provider that translates decides, and a conditional is read on both branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the second review, both about which rows the new refusal is allowed to speak for. A provider that wraps EF Core exists to rewrite the members EF Core cannot translate: LinqKit's AsExpandable, DelegateDecompiler's Decompile. Until now the projection branch asked whether EF Core ran the query and the entity branch did not ask at all, so the same wrapper over an entity was held to EF Core's model and a query that ran in 3.2.0, and still runs unguarded, was refused. Both branches now ask the same question, and it is asked of EF Core's own provider rather than of IAsyncQueryProvider, which a wrapper may implement and a provider that is not EF Core's may implement too. The provider is matched by name, so no internal type is referenced and EF Core 6 to 10 answer alike. A projected member assigned by a conditional recorded nothing, so nothing beneath it could be refused. That is not an exotic shape: the core's own typed Select null-guards every nested node it builds, so composing Select and then filtering lost the refusal the bare handle gives. Both branches are now read — a nested initializer, a member copied from the entity, a value built and left empty, a null — and a member one branch assigns two ways, or assigns in a way this shape cannot read, is left alone rather than refused on half of what builds it. Co-Authored-By: Claude Opus 5 --- .../Policies/UnexpressiblePathTests.cs | 102 ++++++++++ DynamicWhere.ex/Policies/Source/RowShape.cs | 188 +++++++++++++++--- 2 files changed, 267 insertions(+), 23 deletions(-) diff --git a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs index 1962a7d..3a4534e 100644 --- a/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs +++ b/DynamicWhere.Tests/Policies/UnexpressiblePathTests.cs @@ -185,6 +185,20 @@ private IQueryable Built() => private IQueryable Copied() => _db.Roles.Select(role => new ZyRoleRow { Id = role.Id, Code = role.Code, Name = role.Name }); + /// + /// The rows the same caller projects with a null guard around the built member, which is the + /// shape the library's own typed Select emits for every nested node. + /// + private IQueryable Guarded() => + _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = role.Code == null + ? new ZyLocalizedText() + : new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + // ---- the entity itself ---------------------------------------------------------------------------- [Fact] @@ -511,5 +525,93 @@ public void The_trace_records_why_the_path_was_refused() " | ", trace.Decisions.Select(decision => $"{decision.FieldPath} {decision.Action} {decision.Reason}"))); } + + // ---- a member built by a conditional ---------------------------------------------------------------- + + [Fact] + public void A_member_a_null_guard_builds_is_read_through_both_branches() + { + // Neither branch computes IsEmpty, and the library's own Select builds exactly this + // shape, so a caller who composes Select and then filters has to be refused what the + // bare handle refuses. + PolicyException refusal = Assert.ThrowsAny( + () => Guard(Guarded(), DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"a null-guarded member: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_member_a_null_guard_builds_still_answers_for_what_it_assigns() + { + // The branch that sets nothing assigns no member; the branch that builds the value + // assigns two. Reading only one of them would refuse a column the query computes. + IQueryable rows = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Name = role.Code == null + ? new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + : new ZyLocalizedText() + }); + + Exception? unguarded = Record.Exception(() => rows.Where(row => row.Name.Ar == "").ToList()); + + Exception? guarded = Record.Exception(() => Guard(rows, DwTier.Strict).ToList(Where("Name.Ar", ""))); + + _out.WriteLine($"a column one branch assigns: unguarded={unguarded?.GetType().Name ?? "ran"} guarded={guarded?.GetType().Name ?? "ran"}"); + + // EF Core cannot translate a column read through a branch that builds an empty value, so + // this query fails either way — and it has to fail the same way, because the policy has + // nothing to refuse: a member one branch assigns is a member the row carries. + Assert.False(guarded is PolicyException, $"refused: {guarded?.Message}"); + Assert.Equal(unguarded?.GetType(), guarded?.GetType()); + } + + [Fact] + public void A_member_built_and_left_empty_carries_nothing_beneath_it() + { + // The initializer builds the value and sets nothing on it, which is an answer rather + // than a gap: no member beneath it is one the query computes. + IQueryable rows = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Name = new ZyLocalizedText() + }); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(rows, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"a member built and left empty: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_member_two_branches_build_two_ways_is_left_alone() + { + // One branch copies the member from the entity and the other builds it in place, so the + // two say different things about what is beneath it. Refusing on either would refuse on + // half of what builds the row. + IQueryable rows = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Name = role.Code == null + ? role.Name + : new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + Exception? unguarded = Record.Exception(() => rows.Where(row => row.Name.IsEmpty).ToList()); + + Exception? guarded = Record.Exception( + () => Guard(rows, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"two branches, two ways: unguarded={unguarded?.GetType().Name ?? "ran"} guarded={guarded?.GetType().Name ?? "ran"}"); + + Assert.False(guarded is PolicyException, $"refused: {guarded?.Message}"); + Assert.Equal(unguarded?.GetType(), guarded?.GetType()); + } + + // ---- the provider that translates decides ------------------------------------------------------------- } } diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs index 4f173ef..3170969 100644 --- a/DynamicWhere.ex/Policies/Source/RowShape.cs +++ b/DynamicWhere.ex/Policies/Source/RowShape.cs @@ -72,8 +72,9 @@ internal sealed class RowShape // Name = x.Name records ("Name", the entity, "Name"), so what is beneath it is read from the model. private readonly Dictionary? _copied; - // True when the projection reads through EF Core. What a provider can compute is EF Core's - // answer to give; another provider's rules are its own, so its rows are left alone. + // True when EF Core's own provider translates the query. What a query can compute is the + // translating provider's answer to give; a provider in front of EF Core, or in place of it, has + // rules of its own, so its rows are left alone. private readonly bool _translated; private readonly Dictionary _built = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _narrowable = new(StringComparer.OrdinalIgnoreCase); @@ -109,10 +110,11 @@ private RowShape( } } - private RowShape(IEntityType entity, IEnumerable includes, bool includeUnknown) + private RowShape(IEntityType entity, IEnumerable includes, bool includeUnknown, bool translated) : this(RowKind.Entity) { _entity = entity; + _translated = translated; _includes.UnionWith(includes); _includeUnknown = includeUnknown; @@ -289,11 +291,12 @@ internal bool Materializes(string path) /// which is a five-hundred where the strict tier promises a refusal. /// /// False is only ever returned where the shape knows the whole set of members a container can - /// produce: an entity type and its derived types, a complex or owned type, or an initializer in - /// the projection itself. Anywhere else the answer is null and the path is left alone — beneath a + /// produce: the entity type the query is over, a complex or owned type, or an initializer in the + /// projection itself. Anywhere else the answer is null and the path is left alone — beneath a /// column, where a converter decides; on a framework type, where Length, Year and /// HasValue are translated by the provider and are not members of any model; on rows in - /// memory, which run the getter; and on a source this library cannot read. + /// memory, which run the getter; behind a provider that is not EF Core's own, which may rewrite + /// the member before EF Core ever sees it; and on a source this library cannot read. /// /// /// The cost of being wrong is not symmetric. A path wrongly refused is a query that worked and @@ -319,7 +322,7 @@ internal bool Materializes(string path) { RowKind.InMemory => null, RowKind.Projected => ExpressesInProjection(segments), - _ => _entity is null ? null : ExpressesInEntity(_entity, segments, 0) + _ => !_translated || _entity is null ? null : ExpressesInEntity(_entity, segments, 0) }; } @@ -414,8 +417,8 @@ internal bool Materializes(string path) continue; } - // The type carries the member and the model maps no value for it, on this type or on any - // type derived from it. A getter over columns is the shape this catches. + // The type carries the member and the queried type's model maps no value for it. A + // getter over columns is the shape this catches. return CacheReflection.FindProperty(current.ClrType, segment) is null ? null : false; } @@ -458,6 +461,29 @@ private static bool Named(object model, string name) => model.GetType().GetProperty("Name")?.GetValue(model) is string held && string.Equals(held, name, StringComparison.OrdinalIgnoreCase); + /// True when EF Core's own provider is the one that will translate the query. + /// + /// What a query can compute is the translating provider's answer to give. A provider that wraps + /// EF Core exists to rewrite the members EF Core cannot translate — an expression expander, a + /// decompiler — so a member it computes is one the query produces, and holding its rows to EF + /// Core's model would refuse a query that runs. The model is read from the query root in the + /// expression, which such a wrapper leaves in place, so the provider is what tells the two + /// apart. EF Core's own provider is matched by name: no internal type is referenced, and every + /// version from EF Core 6 to 10 answers the same. + /// + private static bool EfCoreOwns(IQueryProvider provider) + { + for (Type? type = provider.GetType(); type is not null; type = type.BaseType) + { + if (type.FullName == "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider") + { + return true; + } + } + + return false; + } + /// /// The column the queried type maps under a name, or null. /// @@ -737,7 +763,7 @@ internal static RowShape Of(IQueryable source) where T : class bool unknown = !ReadIncludes(source.Expression, includes, out int included) || (QueryRoot.Reshapes(source.Expression) && (included > 0 || QueryRoot.Builds(source.Expression))); - return new RowShape(entity, includes, unknown); + return new RowShape(entity, includes, unknown, EfCoreOwns(source.Provider)); } /// @@ -757,7 +783,7 @@ private static RowShape Projected(IQueryable source) where T : class // member counts as assigned, and none can be narrowed. return new RowShape( RowKind.Projected, null, Array.Empty(), body.Type, built, - translated: source.Provider is IAsyncQueryProvider); + translated: EfCoreOwns(source.Provider)); } List narrowable = new(); @@ -771,8 +797,10 @@ private static RowShape Projected(IQueryable source) where T : class Dictionary> initialized = new(StringComparer.OrdinalIgnoreCase); Dictionary copied = new(StringComparer.OrdinalIgnoreCase); + HashSet opaque = new(StringComparer.OrdinalIgnoreCase); - ReadAssignments(initializer, string.Empty, row, rowSource, initialized, copied, depth: 0); + ReadAssignments(initializer, string.Empty, row, rowSource, initialized, copied, opaque, depth: 0); + Forget(initialized, copied, opaque); foreach (MemberBinding binding in initializer.Bindings) { @@ -798,7 +826,8 @@ private static RowShape Projected(IQueryable source) where T : class } return new RowShape( - RowKind.Projected, assigned, narrowable, initializer.Type, built, initialized, copied, efCore); + RowKind.Projected, assigned, narrowable, initializer.Type, built, initialized, copied, + EfCoreOwns(source.Provider)); } /// @@ -808,8 +837,7 @@ private static RowShape Projected(IQueryable source) where T : class /// /// Read by only, to answer whether a path the caller named is something /// the projected row can produce. A member assigned from anything else — a method call, a - /// conditional, a captured value — records nothing, and a path beneath it is one this shape - /// cannot speak for. + /// captured value — records nothing, and a path beneath it is one this shape cannot speak for. /// /// A constructor with arguments says nothing about which member each argument sets, so the level /// it builds is left unrecorded rather than recorded as incomplete. @@ -822,6 +850,7 @@ private static void ReadAssignments( IEntityType? rowSource, Dictionary> initialized, Dictionary copied, + HashSet opaque, int depth) { if (depth > MaxComplexDepth) @@ -841,24 +870,137 @@ private static void ReadAssignments( } string path = prefix.Length == 0 ? binding.Member.Name : $"{prefix}.{binding.Member.Name}"; - Expression value = DefaultOrder.StripConversions(assignment.Expression); - if (value is MemberInitExpression nested) - { - ReadAssignments(nested, path, row, rowSource, initialized, copied, depth + 1); - } - else if (rowSource is not null && MemberChain(value, row) is { } chain) + if (!ReadValue(assignment.Expression, path, row, rowSource, initialized, copied, opaque, depth)) { - copied[path] = (rowSource, chain); + opaque.Add(path); } } if (initializer.NewExpression.Arguments.Count == 0) { - initialized[prefix] = members; + Record(initialized, prefix, members); } } + /// + /// Records one assigned value, and answers whether this shape could read it at all. + /// + /// + /// A conditional is read branch by branch. The library's own typed projection null-guards every + /// nested node it builds — Contact = contact == null ? new ContactRow() : new ContactRow { … } + /// — so a row this library projected has to read exactly as the same row written by hand, or a + /// caller who composes Select and then filters loses the refusal the bare handle gives. + /// + /// A branch that builds the value and sets nothing on it, new ContactRow(), assigns no + /// member, which is an answer rather than a gap: nothing beneath it is computed either. + /// + /// + private static bool ReadValue( + Expression value, + string path, + ParameterExpression row, + IEntityType? rowSource, + Dictionary> initialized, + Dictionary copied, + HashSet opaque, + int depth) + { + Expression assigned = DefaultOrder.StripConversions(value); + + switch (assigned) + { + case MemberInitExpression nested: + ReadAssignments(nested, path, row, rowSource, initialized, copied, opaque, depth + 1); + + return true; + + case NewExpression created when created.Arguments.Count == 0: + Record(initialized, path, Array.Empty()); + + return true; + + case ConstantExpression { Value: null }: + // A null branch carries no member of its own, and takes nothing away from the ones + // the other branch sets: a member of null is not a value any database computes. + return true; + + case ConditionalExpression choice: + // Both branches, and neither short-circuited: a branch left unread is a member this + // shape would then speak for on half of what builds it. + return ReadValue(choice.IfTrue, path, row, rowSource, initialized, copied, opaque, depth) + & ReadValue(choice.IfFalse, path, row, rowSource, initialized, copied, opaque, depth); + + default: + if (rowSource is null || MemberChain(assigned, row) is not { } chain) + { + return false; + } + + if (copied.TryGetValue(path, out (IEntityType Entity, string Member) already) + && !Same(already.Member, chain)) + { + // Two branches copying two different members: what is beneath the one is not + // what is beneath the other. + return false; + } + + copied[path] = (rowSource, chain); + + return true; + } + } + + /// Records what a level assigns, merged with what another branch of the same member assigns. + private static void Record(Dictionary> initialized, string path, IEnumerable members) + { + if (initialized.TryGetValue(path, out HashSet? already)) + { + already.UnionWith(members); + + return; + } + + initialized[path] = new HashSet(members, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Drops every path an assignment this shape could not read reaches, so that what is left is read + /// from a value the whole of which is known. + /// + /// + /// One branch of a conditional this shape cannot read makes the member's whole value unreadable: + /// refusing on what the other branch happens to assign would refuse a path the query computes. A + /// path with no entry is one the shape cannot speak for, which is the answer such a member is + /// owed. + /// + private static void Forget( + Dictionary> initialized, + Dictionary copied, + HashSet opaque) + { + // One branch building the member in place and another copying it from the entity: the two + // say different things about what is beneath it, so the member is read from neither. + opaque.UnionWith(initialized.Keys.Where(copied.ContainsKey).ToList()); + + foreach (string path in opaque) + { + foreach (string reached in initialized.Keys.Where(key => Beneath(key, path)).ToList()) + { + initialized.Remove(reached); + } + + foreach (string reached in copied.Keys.Where(key => Beneath(key, path)).ToList()) + { + copied.Remove(reached); + } + } + } + + /// True when a path is the one named, or sits beneath it. + private static bool Beneath(string path, string root) => + Same(path, root) || path.StartsWith(root + ".", StringComparison.OrdinalIgnoreCase); + /// /// The type an assigned value is constructed as, new ContactRow { … }, or each element of a /// list a subquery builds, o.Lines.Select(l => new LineRow { … }).ToList(); null otherwise. From d29ce52882eb496a20b0747c20ef471219c7676d Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 13:58:54 +0300 Subject: [PATCH 12/54] fix(policies): the trace flag is compared by the value that applies, and Clone says what it copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IncludeTraceInResult defaults to the tier's own answer, and the tiers are equal before it is read, so a host writing that answer out enforces exactly what a host leaving it null does. It was compared as written, so the second host was refused — the same shape the group floor's comparison already had fixed, applied to the one other value with a default of its own. Clone's newly public contract said the copy shares no object with the original. Every node is copied; a condition's values are the caller's own objects in a new list, which the internal method that implements it has always said. The three public contracts now say it too, along with the projection exemption on the method that implements it, whose summary still described the behaviour before Selects was exempted. Co-Authored-By: Claude Opus 5 --- .../Policies/ConfigureOnceTests.cs | 40 ++++++++++++++++++- DynamicWhere.ex/Classes/Complex/Filter.cs | 7 +++- DynamicWhere.ex/Classes/Complex/Segment.cs | 7 +++- DynamicWhere.ex/Classes/Complex/Summary.cs | 7 +++- DynamicWhere.ex/Policies/Config/DwPolicy.cs | 8 +++- .../Policies/Source/FilterSanitizer.cs | 16 +++++++- 6 files changed, 78 insertions(+), 7 deletions(-) diff --git a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs index 63d3efa..b22de7f 100644 --- a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs +++ b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs @@ -147,6 +147,37 @@ public void Writing_the_group_floor_the_default_already_holds_is_the_same_postur Assert.Same(inForce, DwPolicy.Options); } + [Fact] + public void Writing_the_trace_flag_the_tier_already_answers_is_the_same_posture() + { + // The value that applies, not whether somebody wrote it down. IncludeTraceInResult + // defaults to the tier's own answer, so a host writing that answer out and a host + // leaving it null hand a caller the same result. + DwPolicyOptions different = Copy(); + + different.IncludeTraceInResult = DwPolicy.Options.IncludeTraceInResult + ?? DwPolicy.Options.Tier == DwTier.Convenience; + + Assert.NotEqual(DwPolicy.Options.IncludeTraceInResult, different.IncludeTraceInResult); + + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicy.Configure(different); + + Assert.Same(inForce, DwPolicy.Options); + } + + [Fact] + public void A_trace_flag_that_answers_differently_is_refused() + { + DwPolicyOptions different = Copy(); + + different.IncludeTraceInResult = !(DwPolicy.Options.IncludeTraceInResult + ?? DwPolicy.Options.Tier == DwTier.Convenience); + + Refused(different); + } + [Fact] public void A_different_group_floor_is_refused() { @@ -392,7 +423,14 @@ public void Every_value_on_the_posture_is_compared(Type declaring) object target = declaring == typeof(DwCaps) ? different.Caps : different; PropertyInfo property = declaring.GetProperty(name)!; - property.SetValue(target, Other(property.GetValue(target), property.PropertyType)); + // The trace flag is compared by the value that applies, and it defaults to the + // tier's own answer, so what proves it is compared is a value answering differently. + // Writing the default down is the case above. + object? replacement = name == nameof(DwPolicyOptions.IncludeTraceInResult) + ? !(DwPolicy.Options.IncludeTraceInResult ?? DwPolicy.Options.Tier == DwTier.Convenience) + : Other(property.GetValue(target), property.PropertyType); + + property.SetValue(target, replacement); DwPolicyOptions inForce = DwPolicy.Options; diff --git a/DynamicWhere.ex/Classes/Complex/Filter.cs b/DynamicWhere.ex/Classes/Complex/Filter.cs index 06c6347..4067a7c 100644 --- a/DynamicWhere.ex/Classes/Complex/Filter.cs +++ b/DynamicWhere.ex/Classes/Complex/Filter.cs @@ -31,7 +31,12 @@ public class Filter /// Returns a deep copy, so a guarded query can be canonicalized and rewritten without the /// caller's own filter changing underneath them. /// - /// A new filter sharing no object with this one. + /// + /// A new filter. Every node is new — the condition tree, each list and each clause — so nothing + /// either one is given afterwards reaches the other. The values inside a condition's + /// Values list are the same objects: the list is new, and what the caller put in it is + /// theirs, decoded from JSON and never written to. + /// /// /// Public because callers need the same thing the library needs: a request read a second time /// with one part changed — the next page, another order — without editing the object a caller diff --git a/DynamicWhere.ex/Classes/Complex/Segment.cs b/DynamicWhere.ex/Classes/Complex/Segment.cs index eac282e..f3f2610 100644 --- a/DynamicWhere.ex/Classes/Complex/Segment.cs +++ b/DynamicWhere.ex/Classes/Complex/Segment.cs @@ -30,7 +30,12 @@ public class Segment /// /// Returns a deep copy, cloning every condition set independently. /// - /// A new segment sharing no object with this one. + /// + /// A new segment. Every node is new — the condition tree, each list and each clause — so nothing + /// either one is given afterwards reaches the other. The values inside a condition's + /// Values list are the same objects: the list is new, and what the caller put in it is + /// theirs, decoded from JSON and never written to. + /// /// /// Policy applies to each set on its own, so the sets must not share a condition group: a /// rewrite aimed at one would otherwise land on all of them. diff --git a/DynamicWhere.ex/Classes/Complex/Summary.cs b/DynamicWhere.ex/Classes/Complex/Summary.cs index 7644a17..c2cd3c7 100644 --- a/DynamicWhere.ex/Classes/Complex/Summary.cs +++ b/DynamicWhere.ex/Classes/Complex/Summary.cs @@ -36,7 +36,12 @@ public class Summary /// /// Returns a deep copy. /// - /// A new summary sharing no object with this one. + /// + /// A new summary. Every node is new — the condition tree, each list and each clause — so nothing + /// either one is given afterwards reaches the other. The values inside a condition's + /// Values list are the same objects: the list is new, and what the caller put in it is + /// theirs, decoded from JSON and never written to. + /// /// /// A summary reaches a condition group twice — once through and /// again through . Both are cloned; a copy shaped like a filter's would diff --git a/DynamicWhere.ex/Policies/Config/DwPolicy.cs b/DynamicWhere.ex/Policies/Config/DwPolicy.cs index d5c4dae..6d30e63 100644 --- a/DynamicWhere.ex/Policies/Config/DwPolicy.cs +++ b/DynamicWhere.ex/Policies/Config/DwPolicy.cs @@ -57,7 +57,7 @@ public static class DwPolicy public static IReadOnlyList StoreProviders => _stores; /// - /// Sets the posture and the runtime policy sources, once, during startup. + /// Sets the posture and the runtime policy sources, during startup. /// /// The posture. Frozen by this call. /// @@ -156,7 +156,7 @@ private static bool SamePosture(DwPolicyOptions inForce, DwPolicyOptions asked, if (inForce.Tier != asked.Tier || inForce.DryRun != asked.DryRun - || inForce.IncludeTraceInResult != asked.IncludeTraceInResult + || inForce.TraceInResult != asked.TraceInResult || inForce.AuditRefusals != asked.AuditRefusals || !string.Equals(inForce.HashSalt, asked.HashSalt, StringComparison.Ordinal) || inForce.StoreFailure != asked.StoreFailure @@ -166,6 +166,10 @@ private static bool SamePosture(DwPolicyOptions inForce, DwPolicyOptions asked, return false; } + // IncludeTraceInResult is compared by the value that applies, not by whether somebody wrote + // it down: it defaults to the tier's own answer, and the tiers are equal by the line above, + // so a host writing that answer out enforces exactly what a host leaving it null does. The + // same rule as MinGroupSize below. if (!SameCaps(inForce.Caps, asked.Caps) || !SameCatalog(inForce.Entities, asked.Entities)) { return false; diff --git a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs index 2e25feb..c3a4fce 100644 --- a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs +++ b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs @@ -2454,7 +2454,8 @@ private static string ResolveName(string name, Gate gate, bool computed = tru } /// - /// The canonical path when the source can express it, and null when the source provably cannot. + /// The canonical path when the clause can be answered with it, and null when the source provably + /// cannot express it and the clause needs the provider to compute it. /// /// /// A member exists on the row's type and still has no value the provider can compute: @@ -2468,11 +2469,24 @@ private static string ResolveName(string name, Gate gate, bool computed = tru /// which is what an unguarded query does with it. /// /// + /// A projection is exempt. With false the canonical path is returned + /// even where proves it wrong, because EF Core evaluates the + /// last projection on the client, so a member no database can compute is still one a caller can + /// select. + /// + /// /// The refusal is the unknown-name one, so a caller cannot tell a member that does not exist /// from one that exists and cannot be computed, any more than they can tell either from a field /// they may not use. /// /// + /// The resolved path, or null when the name resolved to nothing. + /// The name as the caller wrote it, for the trace. + /// The per-query state, carrying the shape of the rows. + /// + /// True when the clause needs the provider to compute the path, false for a projection. + /// + /// The canonical path, or null when the strict tier is to refuse the name. private static string? Expressible(string? canonical, string spoken, Gate gate, bool computed) { if (canonical is null) From 94cf5dcaecb32310385ba19ea362ec193583028d Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 13:59:06 +0300 Subject: [PATCH 13/54] docs: what the second review found in the text, and what the fixes changed under it The documentation review found eight places where the text and the code had parted company, and the code review left three more to write down. Corrected: llms.txt still said the entity row reads a derived type's model, which one commit had already removed everywhere else, and two comments in the code said the same; the table's "Runs" cell for a converted column promised behaviour the library does not control, so the cells that only mean "not refused" now say "left alone", with a line saying what that is and is not; two of the four places saying a second Configure is refused were missed; a Segment carries no grouping key and no aggregated field; the group floor is now on every aggregation entry rather than on four of seven; and the security page's new section no longer arrives between numbered channels six and seven. Written down: the refusal is EF Core's own provider's to make, so a provider in front of it is left alone; a row the library itself projected reads like any other; the refusal raises no [DwAudit] event, as an unknown name raises none; a simulation has no source, so it cannot refuse such a path at all; the trace flag and a type's reported name in the posture comparison; Clone's real contract; and a condition's values being read more than once. Co-Authored-By: Claude Opus 5 --- DynamicWhere.ex/DOC.md | 46 +++++++++----- .../app/docs/breaking-changes/page.tsx | 58 +++++++++++++---- .../app/docs/classes/filter/page.tsx | 7 ++- .../app/docs/classes/segment/page.tsx | 7 ++- .../app/docs/classes/summary/page.tsx | 7 ++- .../app/docs/extensions/group/page.tsx | 10 +++ .../app/docs/policies/admin/page.tsx | 5 +- .../app/docs/policies/configuration/page.tsx | 13 +++- .../app/docs/policies/security/page.tsx | 49 +++++++++------ OfficialWebsite/public/llms.txt | 62 ++++++++++++++----- README.md | 8 +-- 11 files changed, 199 insertions(+), 73 deletions(-) diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index b85c61e..20c26cf 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -264,6 +264,7 @@ A single filter predicate. `Values` is `List` so the front-end can send heterogeneous JSON shapes without quoting every primitive: + ```json { "Field": "Price", @@ -296,6 +297,8 @@ The library normalizes every element before validation/build: **Backward compatibility:** callers previously sending `["abc"]` (quoted strings) keep working unchanged — strings deserialize into the `List` as string elements. C# callers that previously used `Values = new List {...}` must switch to `new List {...}` (or `.Cast().ToList()`). +A value is read once to validate its format and again to build the predicate, so pass values that do not change: one whose `ToString()` answers differently each time is validated as one value and queried as another. Anything decoded from JSON is such a value already. No policy decision reads a value's content — only how many there are — so nothing a guard decides rests on which read won. + --- #### `ConditionGroup` @@ -382,7 +385,7 @@ Combines filtering, selecting, ordering, and pagination in a single object. | `Orders` | `List?` | Optional sort criteria | | `Page` | `PageBy?` | Optional pagination | -**`Clone()`** *(public since 3.3.0)* returns a deep copy — the condition tree with its groups and conditions, the projection list, each order and the page — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. +**`Clone()`** *(public since 3.3.0)* returns a deep copy — every node new, though the values a condition carries stay the caller's own objects in a new list — the condition tree with its groups and conditions, the projection list, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. --- @@ -397,7 +400,7 @@ Combines multiple condition sets with set operations (Union / Intersect / Except | `Orders` | `List?` | Optional sort criteria | | `Page` | `PageBy?` | Optional pagination | -**`Clone()`** *(public since 3.3.0)* returns a deep copy — every condition set with its own condition group, the projection list, each order and the page — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. +**`Clone()`** *(public since 3.3.0)* returns a deep copy — every node new, though the values a condition carries stay the caller's own objects in a new list — every condition set with its own condition group, the projection list, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. --- @@ -413,7 +416,7 @@ Combines filtering → grouping → having → ordering → pagination for aggre | `Orders` | `List?` | Sort on grouped result. Fields must be GroupBy fields or aggregate aliases | | `Page` | `PageBy?` | Optional pagination on grouped result | -**`Clone()`** *(public since 3.3.0)* returns a deep copy — the condition group, the group-by with its aggregates, the having clause, each order and the page — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. +**`Clone()`** *(public since 3.3.0)* returns a deep copy — every node new, though the values a condition carries stay the caller's own objects in a new list — the condition group, the group-by with its aggregates, the having clause, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. --- @@ -554,7 +557,7 @@ Applies a group of conditions joined by `And` / `Or`, with optional nested sub-g ### `.Group(GroupBy groupBy)` -Groups the query by the specified fields and applies aggregations. +Groups the query by the specified fields and applies aggregations. Under `ApplyPolicy`, groups smaller than `DwCaps.MinGroupSize` — **5 by default** — are dropped; see [k-anonymity](#k-anonymity--the-control-you-would-not-guess). | Parameter | Type | Description | |-----------|------|-------------| @@ -673,7 +676,7 @@ Since 3.2.0 two overloads take a `CancellationToken`, which reaches the count an ### `.Summary(Summary summary)` -Applies where → group → having → order → page to a query. +Applies where → group → having → order → page to a query. Under `ApplyPolicy`, groups smaller than `DwCaps.MinGroupSize` — **5 by default** — are dropped; see [k-anonymity](#k-anonymity--the-control-you-would-not-guess). **Returns:** `IQueryable` — dynamic grouped query. @@ -697,7 +700,7 @@ In-memory variant for summary operations. ### `.ToListAsync(Summary summary, bool getQueryString = false)` -Async version of `ToList(Summary)`. On an EF Core query it counts the groups with EF Core's `CountAsync()` and reads them with EF Core's `ToListAsync()`. Until 3.2.0 the count ran synchronously and the read went through Dynamic LINQ's `ToDynamicListAsync()`, which had no token to pass on; on an EF Core query a canceled token now reaches the database. The count and the rows are the same. A source whose provider is not EF Core's, such as rows in memory through `AsQueryable()`, keeps the synchronous count and Dynamic LINQ's read, on the calling thread. +Async version of `ToList(Summary)`, and floored the same way: under `ApplyPolicy`, groups smaller than `DwCaps.MinGroupSize` — **5 by default** — are dropped; see [k-anonymity](#k-anonymity--the-control-you-would-not-guess). On an EF Core query it counts the groups with EF Core's `CountAsync()` and reads them with EF Core's `ToListAsync()`. Until 3.2.0 the count ran synchronously and the read went through Dynamic LINQ's `ToDynamicListAsync()`, which had no token to pass on; on an EF Core query a canceled token now reaches the database. The count and the rows are the same. A source whose provider is not EF Core's, such as rows in memory through `AsQueryable()`, keeps the synchronous count and Dynamic LINQ's read, on the calling thread. Since 3.2.0 two overloads take a `CancellationToken`, which reaches the count and the read: `.ToListAsync(Summary summary, CancellationToken cancellationToken)` and `.ToListAsync(Summary summary, bool getQueryString, CancellationToken cancellationToken)`. See [Cancellation](#cancellation). @@ -1690,7 +1693,7 @@ public sealed class LocalizedText } ``` -gives `Name.Ar` and `Name.En`, which translate, and `Name.IsEmpty`, which is a getter over the two. The policy has nothing to say about it — `[DwNoWhere]` on `Name` matches that path and not the ones beneath it — so every check passed and EF Core threw `InvalidOperationException`: a five-hundred where `Strict` promises a refusal. Such a path is now refused as an unknown name is, with the clause's own code and `FieldPath` `"*"`, in every clause the database has to compute: a filter, an order, a grouping key, an aggregated field, and each of those inside a `Segment`. +gives `Name.Ar` and `Name.En`, which translate, and `Name.IsEmpty`, which is a getter over the two. The policy has nothing to say about it — `[DwNoWhere]` on `Name` matches that path and not the ones beneath it — so every check passed and EF Core threw `InvalidOperationException`: a five-hundred where `Strict` promises a refusal. Such a path is now refused as an unknown name is, with the clause's own code and `FieldPath` `"*"`, in every clause the database has to compute: a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. **`Selects` is not one of them.** A projection is the last thing the provider builds, and EF Core evaluates that one on the client when it cannot translate it, so `Selects = ["Id", "Name.IsEmpty"]` returns the computed value exactly as it did before. Refusing it would take back a projection that has always worked. @@ -1699,17 +1702,24 @@ It is refused only where the whole set of members a container can produce is kno | Source | Read from | `Name.IsEmpty` | |---|---|---| | An entity | the EF Core model: columns, shadow properties, owned and complex members, navigations | Refused | -| A row a `Select` built before `ApplyPolicy` | the initializer's own assignments, at every level | Refused | +| A row a `Select` built before `ApplyPolicy` | the initializer's own assignments, at every level, both branches of a conditional included | Refused | | …where that `Select` copies the member from the entity, `Name = role.Name` | the model, beneath the member it copies | Refused | | Rows in memory | nothing — the getter runs | Runs, as it always did | -| Anything beneath a column, a converted one included | nothing — the converter decides | Runs, as it always did | +| Anything beneath a column, a converted one included | nothing — the converter decides | Left alone, as it always did | | A framework member such as `Length`, `Year` or `HasValue` | nothing — the provider translates it | Runs, as it always did | -| A source the library cannot read | nothing | Runs, as it always did | +| A source the library cannot read | nothing | Left alone, as it always did | +| A provider in front of EF Core: an expression expander, a decompiler | nothing — it rewrites what EF Core cannot translate | Left alone, as it always did | | A column only a subtype maps, queried through the base | the queried type's model, which is what EF Core translates against | Refused | -| A projection a provider that is not EF Core's ran | nothing — its rules are its own | Runs, as it always did | +| A projection a provider that is not EF Core's ran | nothing — its rules are its own | Left alone, as it always did | + +**Left alone** is not a promise that the path runs. The policy does not refuse it, so it behaves exactly as it does unguarded: `Name.IsEmpty` beneath a column mapped through a value converter still fails inside the provider, as it always has. An unmapped getter on the entity itself, `Display => $"{Code}:{Id}"`, is refused for the same reason. The convenience tier and a dry run are unchanged: both fail exactly as the unguarded query does, which is the provider's own error. The trace records the refusal — `the member exists on the type and the query cannot compute it` — and since 3.3.0 `LastTrace` is set before a request is sanitized, so a refusal leaves it readable rather than null. +The rule is EF Core's own provider's. A provider that wraps EF Core — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — exists to rewrite the members EF Core cannot translate, so a member it computes is one the query produces and it is left alone, over a projection and over an entity alike. A row the library itself projected is read like any other: the core's typed `Select` null-guards every nested node it builds, and both branches of that guard are read, so composing `Select` and then filtering refuses exactly what the bare handle refuses. + +A refusal here raises no `[DwAudit]` event, for the same reason an unknown name raises none: no field was read, and the refusal names none. `AuditRefusals` records it, and so does the trace. + The rule is the model's: a member it maps nowhere is one the database cannot compute. A member some provider extension computes without a mapping is refused with the rest, so map it, or filter on the columns beneath it. ### A navigation named in Selects @@ -1980,7 +1990,7 @@ Options are frozen at startup. Every cap refuses a value below one, except two t | `POST` | `/simulate` | The sanitized clause, without executing or auditing | | `GET` | `/health` | Snapshot version, age, degraded state, last error | -A simulation, through `/simulate` or `PolicySimulator`, has no source, so it reads the type as a source it cannot see into. That shows in a clause that sends no `Selects`: every denial beneath a member counts, and the projection it shows keeps only the members that hold a value, a collection of values included. A guarded query keeps what its own source carries — over a projected row, the objects its initializer assigns; over an entity, its columns, owned and complex members, asking only about the denials whose value it loads; over rows in memory, values only. So the simulated clause can list fewer members than the query returns, and can show a projection an entity query does not need. See [A request that sends no Selects](#a-request-that-sends-no-selects). +A simulation, through `/simulate` or `PolicySimulator`, has no source, so it reads the type as a source it cannot see into. That shows in a clause that sends no `Selects`: every denial beneath a member counts, and the projection it shows keeps only the members that hold a value, a collection of values included. A guarded query keeps what its own source carries — over a projected row, the objects its initializer assigns; over an entity, its columns, owned and complex members, asking only about the denials whose value it loads; over rows in memory, values only. So the simulated clause can list fewer members than the query returns, and can show a projection an entity query does not need. For the same reason it cannot refuse a path no database can compute: that refusal is read from the model behind the source, which a simulation does not have, so a simulation shows such a request running where the strict query refuses it. See [A request that sends no Selects](#a-request-that-sends-no-selects). ### Schema discovery @@ -2093,12 +2103,18 @@ What counts as the same posture: | Compared | Not compared | |---|---| -| `Tier`, `DryRun`, `IncludeTraceInResult`, `AuditRefusals` | `TokenVault` | +| `Tier`, `DryRun`, `AuditRefusals`, and `IncludeTraceInResult` by the value that applies | `TokenVault` | | `HashSalt`, `StoreFailure`, `MaxSnapshotAge`, `RefreshInterval` | `Services` | | Every value on `Caps`, the floor that applies rather than whether it was written down | The provider *instances* | -| The exposed entity catalogue: the same types under the same names | | +| The exposed entity catalogue: the same types, every name each answers to, and the name each is reported under | | | The provider *types*, in the order they were supplied | | +`IncludeTraceInResult` is compared the way a cap is: it defaults to the tier's own answer, and the +tiers are equal by then, so a host writing that answer out and a host leaving it null hand a caller +the same result. A type exposed under two names is a different matter — it is reported under the last +name it was given, so two catalogues that resolve every name alike still answer a schema request +differently, and the second posture is refused. + The three on the right are objects a host builds for itself, and a second host builds its own, so comparing them by reference would make every second call a refusal. They stay as the first call left them: **a second host runs with the first host's vault, container and rule stores.** In one test @@ -2421,7 +2437,7 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of The attribute walker does not descend into the framework's own types, which carry no policy attributes. Until 3.2.0 it took any namespace whose name started with `System` for the framework's, so an application namespace such as `SystemsCorp.Payroll` or `SystemX.Domain` got no policy beneath its types, and a `[DwDenied]` field on such a type, reached through a member, was returned, filterable and sortable. Fixed (security): only `System` and the namespaces beneath it are the framework's now, so a guarded request that filtered on, sorted by or selected such a field is refused or dropped, as for any denied field. 25. **Under `Strict`, a Path the Query Cannot Compute Is Refused** - Since 3.3.0 a path whose leaf is a member no database can produce — a getter over columns, such as `LocalizedText.IsEmpty`, or an unmapped getter on the entity — is refused with the clause's own code and `FieldPath` `"*"`, as an unknown name is. Until 3.3.0 the package accepted it and EF Core threw `InvalidOperationException`, which reached a caller as a five-hundred where the tier promises a refusal. It applies where the whole set of members a container can produce is known: an entity's model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. A member a custom EF Core translator computes, through `[DbFunction]` or a translator plugin, is refused with the rest: map it, or filter on the columns beneath it. See [Blocked-action semantics](#blocked-action-semantics). + Since 3.3.0 a path whose leaf is a member no database can produce — a getter over columns, such as `LocalizedText.IsEmpty`, or an unmapped getter on the entity — is refused with the clause's own code and `FieldPath` `"*"`, as an unknown name is. Until 3.3.0 the package accepted it and EF Core threw `InvalidOperationException`, which reached a caller as a five-hundred where the tier promises a refusal. It applies where the whole set of members a container can produce is known: an entity's model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, a query a provider in front of EF Core translates — an expression expander, a decompiler — the convenience tier and a dry run are all unchanged. A member a custom EF Core translator computes, through `[DbFunction]` or a translator plugin, is refused with the rest: map it, or filter on the columns beneath it. See [Blocked-action semantics](#blocked-action-semantics). 26. **`LastTrace` Is Set Before a Request Is Sanitized** Since 3.3.0 `PolicyQueryable.LastTrace` carries the trace of a request that was refused. It used to be assigned after sanitizing returned, so a refusal left it holding the previous request's trace, or null on the first. A strict refusal names no field on purpose, and the trace is where the real path and reason live, so this is what makes one readable. Code that read `LastTrace` after catching a `PolicyException` and expected the earlier request's trace reads this request's now. diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index a4403c7..10381c1 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -1474,9 +1474,9 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}3.3.0 such a path is refused as an unknown name is: the clause's own code, FieldPath{" "} "*", in every clause the database has to compute - — a filter, an order, a grouping key, an aggregated field, and each of - those inside a Segment. It is refused only where the whole - set of members a container can produce is known. + — a filter, an order, a grouping key, an aggregated field, and a filter + or an order inside a Segment. It is refused only where the + whole set of members a container can produce is known.

          Selects is not one of them. A projection @@ -1492,16 +1492,23 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`} An entitythe EF Core model: columns, shadow properties, owned and complex members, navigationsRefused - A row a Select built before ApplyPolicythat initializer's own assignments, at every levelRefused + A row a Select built before ApplyPolicythat initializer's own assignments, at every level, both branches of a conditional includedRefused …where the Select copies the member, Name = role.Namethe model, beneath the member it copiesRefused Rows in memorynothing — the getter runsRuns, as before - Anything beneath a column, converted or notnothing — the converter decidesRuns, as before + Anything beneath a column, converted or notnothing — the converter decidesLeft alone, as before A framework member: Length, Year, HasValuenothing — the provider translates itRuns, as before - A source the library cannot readnothingRuns, as before + A source the library cannot readnothingLeft alone, as before + A provider in front of EF Core: an expression expander, a decompilernothing — it rewrites what EF Core cannot translateLeft alone, as before A column only a subtype maps, queried through the basethe queried type's model, which is what EF Core translates againstRefused - A projection a provider that is not EF Core's rannothing — its rules are its ownRuns, as before + A projection a provider that is not EF Core's rannothing — its rules are its ownLeft alone, as before +

          + Left alone is not a promise that the path runs. The + policy does not refuse it, so it behaves exactly as it does unguarded:{" "} + Name.IsEmpty beneath a column mapped through a value + converter still fails inside the provider, as it always has. +

          An unmapped getter on the entity itself,{" "} Display => $"{Code}:{Id}", @@ -1511,10 +1518,30 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`} A member the model maps nowhere is one the database cannot compute, so - the strict tier refuses it wherever the database has to. A member some - provider extension computes without a mapping is refused with the rest: - map it, or filter on the columns beneath it. + the strict tier refuses it wherever the database has to. A member a + translator inside EF Core computes without a mapping — a{" "} + [DbFunction], a translator plugin — is refused with the + rest: map it, or filter on the columns beneath it. +

          + A provider in front of EF Core is a different case, and is left + alone. LinqKit's AsExpandable() and + DelegateDecompiler's Decompile() exist to rewrite the + members EF Core cannot translate, so a member they compute is one the + query produces, over a projection and over an entity alike. A row the + library itself projected is read like any other: the core's typed{" "} + Select null-guards every nested node it builds, and both + branches of that guard are read, so composing Select and + then filtering refuses what the bare handle refuses. +

          +

          + The refusal raises no [DwAudit] event, for the reason an + unknown name raises none: no field was read, and the refusal names none.{" "} + AuditRefusals records it, and so does the trace. A + simulation has no source, so PolicySimulator and{" "} + /simulate cannot refuse such a path — they show the request + running where the strict query refuses it. +

          31. LastTrace Is Set Before a Request Is Sanitized

          @@ -1548,9 +1575,14 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}IncludeTraceInResult, AuditRefusals,{" "} HashSalt, StoreFailure,{" "} MaxSnapshotAge, RefreshInterval, every cap - value, the exposed entity catalogue with every name it answers to, and - the provider types in the order supplied. Writing a cap's own - default down is not a difference. Not compared, and not + value, the exposed entity catalogue with every name it answers to and + the name each type is reported under, and the provider types in the + order supplied. Writing a value's own default down is not a + difference: a cap's default, and IncludeTraceInResult{" "} + written as the tier's own answer, are compared by the value that + applies. A type exposed under two names is reported under the last one, + so two catalogues resolving every name alike are still refused when the + order differs. Not compared, and not replaced: TokenVault, Services and the provider instances — a second host builds its own, and no two are ever the same reference. diff --git a/OfficialWebsite/app/docs/classes/filter/page.tsx b/OfficialWebsite/app/docs/classes/filter/page.tsx index b27ffd8..c68d70a 100644 --- a/OfficialWebsite/app/docs/classes/filter/page.tsx +++ b/OfficialWebsite/app/docs/classes/filter/page.tsx @@ -124,7 +124,12 @@ FilterResult result = await dbContext.Customers.ToListAsync(filter);`}

          Clone

          Filter.Clone() is public since 3.3.0. It - returns a deep copy — the condition tree with its groups and conditions, the Selects list, each order and the page — so nothing is shared with the original. + returns a deep copy — the condition tree with its groups and conditions, the Selects list, each order and the page — so nothing either request is given afterwards reaches the other. +

          +

          + Every node is new. The values a condition carries stay the caller's + own objects, in a new list: they are scalars decoded from JSON and + nothing in the pipeline writes to them.

          {`Filter page2 = caller.Clone(); page2.Page!.PageNumber = 2; // the caller's own filter is untouched`} diff --git a/OfficialWebsite/app/docs/classes/segment/page.tsx b/OfficialWebsite/app/docs/classes/segment/page.tsx index f64f578..d5ec291 100644 --- a/OfficialWebsite/app/docs/classes/segment/page.tsx +++ b/OfficialWebsite/app/docs/classes/segment/page.tsx @@ -171,7 +171,12 @@ SegmentResult result = await dbContext.Customers.ToListAsync(segment);

          Clone

          Segment.Clone() is public since 3.3.0. It - returns a deep copy — every condition set with its own condition group, the Selects list, each order and the page — so nothing is shared with the original. + returns a deep copy — every condition set with its own condition group, the Selects list, each order and the page — so nothing either request is given afterwards reaches the other. +

          +

          + Every node is new. The values a condition carries stay the caller's + own objects, in a new list: they are scalars decoded from JSON and + nothing in the pipeline writes to them.

          {`Segment page2 = caller.Clone(); page2.Page!.PageNumber = 2; // the caller's own segment is untouched`} diff --git a/OfficialWebsite/app/docs/classes/summary/page.tsx b/OfficialWebsite/app/docs/classes/summary/page.tsx index ba0c94f..17742b0 100644 --- a/OfficialWebsite/app/docs/classes/summary/page.tsx +++ b/OfficialWebsite/app/docs/classes/summary/page.tsx @@ -171,7 +171,12 @@ SummaryResult result = await dbContext.Orders.ToListAsync(summary);`}

          Clone

          Summary.Clone() is public since 3.3.0. It - returns a deep copy — the condition group, the group-by with its aggregates, the having clause, each order and the page — so nothing is shared with the original. + returns a deep copy — the condition group, the group-by with its aggregates, the having clause, each order and the page — so nothing either request is given afterwards reaches the other. +

          +

          + Every node is new. The values a condition carries stay the caller's + own objects, in a new list: they are scalars decoded from JSON and + nothing in the pipeline writes to them.

          {`Summary page2 = caller.Clone(); page2.Page!.PageNumber = 2; // the caller's own summary is untouched`} diff --git a/OfficialWebsite/app/docs/extensions/group/page.tsx b/OfficialWebsite/app/docs/extensions/group/page.tsx index 2181089..833bdb1 100644 --- a/OfficialWebsite/app/docs/extensions/group/page.tsx +++ b/OfficialWebsite/app/docs/extensions/group/page.tsx @@ -21,6 +21,16 @@ export default function Page() { group-by fields and aggregate aliases as properties.

          + + DwCaps.MinGroupSize ships on, at 5, so a + guarded call removes every group with fewer than five rows — not + refused, and nothing in the answer says a group was dropped. That is + right for anonymised reporting and surprising for an operational count. + Set Caps.MinGroupSize = 1 to switch the floor off, + deliberately. An unguarded call is never floored. See{" "} + k-anonymity. + +

          Signature

          {`public static IQueryable Group(this IQueryable query, GroupBy groupBy) where T : class`} diff --git a/OfficialWebsite/app/docs/policies/admin/page.tsx b/OfficialWebsite/app/docs/policies/admin/page.tsx index 34441f3..7382a48 100644 --- a/OfficialWebsite/app/docs/policies/admin/page.tsx +++ b/OfficialWebsite/app/docs/policies/admin/page.tsx @@ -180,7 +180,10 @@ export default function Page() { loads; over rows in memory, values only. So the simulated clause can list fewer members than the query returns, and can show a projection an entity query does not need. PolicySimulator reads a - type the same way. See{" "} + type the same way. For the same reason neither can refuse a path no + database can compute: that refusal is read from the model behind the + source, so a simulation shows such a request running where the strict + query refuses it. See{" "} A request that sends no Selects.

          diff --git a/OfficialWebsite/app/docs/policies/configuration/page.tsx b/OfficialWebsite/app/docs/policies/configuration/page.tsx index 587afd9..2295840 100644 --- a/OfficialWebsite/app/docs/policies/configuration/page.tsx +++ b/OfficialWebsite/app/docs/policies/configuration/page.tsx @@ -932,7 +932,7 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so ComparedNot compared - Tier, DryRun, IncludeTraceInResult, AuditRefusals + Tier, DryRun, AuditRefusals, and IncludeTraceInResult by the value that applies TokenVault @@ -944,7 +944,7 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so The provider instances - The exposed entity catalogue: the same types under the same names + The exposed entity catalogue: the same types, every name each answers to, and the name each is reported under @@ -953,6 +953,15 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so +

          + IncludeTraceInResult is compared the way a cap is: it + defaults to the tier's own answer, and the tiers are equal by then, + so a host writing that answer out and a host leaving it null hand a + caller the same result. The catalogue is stricter — a type exposed under + two names is reported under the last one it was given, so two catalogues + that resolve every name alike still answer a schema request differently, + and the second posture is refused. +

          The three on the right are objects a host builds for itself, and a second host builds its own, so comparing them by reference would make diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx index 8b8871f..cef73fe 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -18,9 +18,11 @@ export default function Page() {

          Denying a field is easy. The hard part is the set of ways a caller can learn a value without reading it. Six such channels follow, then - two bypasses that are not channels, then the requests that, until - 3.2.0, carried out a denied value the gate could not see; each has a - test that reproduces the attack and goes red if the control is removed. + two bypasses that are not channels, then a path the query cannot compute + — not a channel either, but the one request the tier used to answer with + neither an answer nor a refusal — then the requests that, until 3.2.0, + carried out a denied value the gate could not see; each has a test that + reproduces the attack and goes red if the control is removed.

          @@ -184,6 +186,21 @@ true order. Add [DwNoOrder] unless that is intended.`} together describe how the rows are partitioned.

          +

          7 and 8. The two that are not channels

          + + + + + + + + + + + + +
          AttackControl
          An unguarded DynamicWhere call on a type that requires a policy[DwEntity(RequirePolicy = true)] throws PolicyRequired rather than returning rows. Only this library's own extension methods run the check, so plain EF Core or LINQ against the DbSet is not intercepted — the flag closes the hole in this API, not every route to the table.
          An empty policy storeAttributes still enforce; an empty store never resolves to Allow
          +

          A path the query cannot compute

          A member of a row's type is not always a value a database can @@ -208,6 +225,17 @@ true order. Add [DwNoOrder] unless that is intended.`} member the provider translates such as Length or{" "} Year, anything beneath a column, the convenience tier and a dry run are all unchanged, and fail exactly as the unguarded query does. + So is a query a provider in front of EF Core translates — LinqKit's{" "} + AsExpandable(), DelegateDecompiler's{" "} + Decompile() — since such a provider exists to rewrite what + EF Core cannot, and a member it computes is one the query produces. +

          +

          + The refusal raises no [DwAudit] event, for the reason an + unknown name raises none: no field was read, and the refusal names none.{" "} + AuditRefusals records it, and the trace carries the real + path. A simulation is handed no source, so it cannot refuse such a path + at all.

          This closes no leak: the query failed, it did not answer. It removes a @@ -216,21 +244,6 @@ true order. Add [DwNoOrder] unless that is intended.`} refused. The trace records the reason.

          -

          7 and 8. The two that are not channels

          - - - - - - - - - - - - -
          AttackControl
          An unguarded DynamicWhere call on a type that requires a policy[DwEntity(RequirePolicy = true)] throws PolicyRequired rather than returning rows. Only this library's own extension methods run the check, so plain EF Core or LINQ against the DbSet is not intercepted — the flag closes the hole in this API, not every route to the table.
          An empty policy storeAttributes still enforce; an empty store never resolves to Allow
          -

          Denials the gate could not see

          A denied field often sits on a type the query reaches through a member: diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index 1ba39a2..71f97ca 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -204,7 +204,8 @@ Summary ConditionGroup ConditionGroup? null where, bef Orders List? null each Field is a GroupBy field or an Alias Page PageBy? null pages the groups -Clone() on Filter, Segment and Summary public since 3.3.0; deep copy, shares no object +Clone() on Filter, Segment and Summary public since 3.3.0; deep copy, every node new, + a condition's values shared in a new list with the original: the condition tree with its groups and conditions, the Selects list, each OrderBy, the Page, and a Summary's GroupBy with @@ -1463,9 +1464,13 @@ DwPolicy stat `WebApplicationFactory` hosts over one composition root. - Compared: `Tier`, `DryRun`, `IncludeTraceInResult`, `AuditRefusals`, `HashSalt`, `StoreFailure`, `MaxSnapshotAge`, `RefreshInterval`, every `DwCaps` value, the exposed catalogue — the same types under the same - names, and every name each type answers to — and the provider **types**, in the order supplied, with - `AttributePolicyProvider` left out of both sides. `IsMinGroupSizeSet` is not compared: nothing in enforcement - reads it, so writing `MinGroupSize = 5` and leaving it unset are the same posture. + names, every name each type answers to, and the name each type is reported under — and the provider **types**, + in the order supplied, with `AttributePolicyProvider` left out of both sides. `IsMinGroupSizeSet` is not + compared: nothing in enforcement reads it, so writing `MinGroupSize = 5` and leaving it unset are the same + posture. `IncludeTraceInResult` is compared by the value that applies, not by whether it was written down: it + defaults to the tier's own answer, so writing that answer out is the same posture. A type exposed under two + names is reported under the last one, so two catalogues resolving every name alike are still refused when the + order differs. - Not compared, and not replaced: `TokenVault`, `Services`, and the provider **instances**. A second host builds its own and no two are ever the same reference, so they stay as the first call left them — a second host runs with the first host's vault, container and rule stores. @@ -2315,7 +2320,7 @@ The resolver, fragment and provider types are listed in section 21. Under `Strict` outside a dry run, a path that exists on the type but names no value the query can compute is refused as an unknown name is (3.3.0), with the clause's own code and `FieldPath` `"*"`, in every clause the database has to -compute: a filter, an order, a grouping key, an aggregated field, and each of those inside a `Segment`. +compute: a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. `Selects` is not one of them. A projection is the last thing the provider builds, and EF Core evaluates that one on the client when it cannot translate it, so `Selects` naming such a member returns its value exactly as before. @@ -2324,20 +2329,34 @@ What decides: ``` Source of the rows What the set of producible members is read from Name.IsEmpty -entity query EF Core model: columns (shadow included), owned, - complex, navigations, and derived types refused -Select(...) before ApplyPolicy building the row that initializer's assignments, at every level refused +entity query EF Core model of the queried type: columns + (shadow included), owned, complex, navigations refused +Select(...) before ApplyPolicy building the row that initializer's assignments, at every level, + both branches of a conditional included refused ... a member it copies whole: Name = role.Name the model, beneath the copied member refused rows in memory (EnumerableQuery) nothing; the getter runs runs -beneath a column, converted or not nothing; the converter decides runs +beneath a column, converted or not nothing; the converter decides left alone a framework member: Length, Year, HasValue nothing; the provider translates it runs -a source the library cannot read into nothing runs +a source the library cannot read into nothing left alone +a provider in front of EF Core nothing; it rewrites what EF Core cannot + translate left alone a column only a subtype maps, through the base the queried type's model, which EF Core translates against refused -a projection another provider ran nothing; its rules are its own runs +a projection another provider ran nothing; its rules are its own left alone ``` +- `left alone` is not a promise that the path runs: the policy does not refuse it, so it behaves + exactly as it does unguarded. `Name.IsEmpty` beneath a column mapped through a value converter + still fails inside the provider. - An unmapped getter on the entity itself (`Display => $"{Code}:{Id}"`) is refused for the same reason. +- The rule is EF Core's own provider's. A provider that wraps EF Core — LinqKit's `AsExpandable()`, + DelegateDecompiler's `Decompile()` — rewrites what EF Core cannot translate, so its rows are left alone, over a + projection and over an entity alike. +- A row the library itself projected reads like any other: the core's typed `Select` null-guards every nested node + it builds, and both branches of that guard are read, so `Select` composed and then filtered refuses what the bare + handle refuses. +- The refusal raises no `[DwAudit]` event, as an unknown name raises none: no field was read and the refusal names + none. `AuditRefusals` records it, and so does the trace. - `Convenience` and a dry run are unchanged: the provider throws, as it does unguarded. - The rule is the model's: a member it maps nowhere is refused, whatever a provider extension could compute. - The trace records it: `the member exists on the type and the query cannot compute it, so it is refused as an unknown name is`. @@ -3704,7 +3723,7 @@ options.Entities.Expose("Employee"); var store = new InMemoryPolicyStore(options.Entities.Resolve); var provider = await StorePolicyProvider.CreateAsync(store, options); -DwPolicy.Configure(options, provider); // once; freezes options +DwPolicy.Configure(options, provider); // freezes options; a second call must ask for this posture builder.Services.AddSingleton(store); // admin API reads builder.Services.AddSingleton(store); // admin API writes @@ -4844,8 +4863,9 @@ MemoryCalculationInput force and do nothing. A different posture still throws. The check is inside the lock that configures, so a caller needs no IsConfigured check of its own — that check is a check-then-act two hosts can both pass. Compared: tier, DryRun, IncludeTraceInResult, AuditRefusals, HashSalt, StoreFailure, MaxSnapshotAge, - RefreshInterval, every cap value, the exposed catalogue with every name it answers to, and the provider - types in order. + RefreshInterval, every cap value, the exposed catalogue with every name it answers to and the name each + type is reported under, and the provider types in order. A cap's own default and the tier's own answer for + IncludeTraceInResult are compared by the value that applies, not by whether somebody wrote them down. Not compared and not replaced: TokenVault, Services and the provider instances, so a second host runs with the first host's vault, container and rule stores. AddDwPolicies registers the posture in force rather than the instance it built. @@ -4855,12 +4875,15 @@ MemoryCalculationInput the caller saw a five-hundred. Selects is not one of them: EF Core evaluates the last projection on the client. Refused only where the producible members are known: the queried type's own model, and the initializers of a projection EF Core ran before ApplyPolicy, including a member it copies from the entity. - Rows in memory, a framework member such as Length or Year, anything beneath a column, another provider's - rows, Convenience and a dry run are unchanged. + Rows in memory, a framework member such as Length or Year, anything beneath a column, a query a provider + in front of EF Core translates — an expression expander, a decompiler — Convenience and a dry run are + unchanged. The refusal raises no [DwAudit] event, as an unknown name raises none; AuditRefusals and the + trace record it. A simulation has no source, so PolicySimulator and /simulate cannot refuse such a path. - Behaviour change. LastTrace is assigned before a request is sanitized, so a refused request leaves its own trace readable instead of the previous request's. - New. Clone() is public on Filter, Segment and Summary. A deep copy: the condition tree, the projection list, - each order, the page, and a summary's GroupBy and Having. + each order, the page, and a summary's GroupBy and Having. Every node is new; a condition's values are the + caller's own objects, in a new list. - Docs. DwEntity(DefaultOrder)'s remarks said a projected query takes no default; it has since 3.2.0. The aggregation sections now lead with Caps.MinGroupSize shipping on at 5. @@ -5118,6 +5141,11 @@ MemoryCalculationInput the derived type, `Set()`. - A second host configuring the same posture runs with the first host's `TokenVault`, `Services` and rule store instances (3.3.0, section 12). They are not compared and not replaced. +- A simulation has no source, so `PolicySimulator` and `/simulate` cannot refuse a path no database can compute; + they show such a request running where the strict query refuses it (3.3.0, section 17). +- A condition's values are read once to validate their format and again to build the predicate. A value whose + `ToString()` answers differently each time is validated as one value and queried as another; pass values that do + not change. No policy decision reads a value's content — only how many there are. - Summary rows flatten dotted group fields (`CategoryName`); `SelectDynamic` rows nest them (`Category.Name`). - `Filter` applies `Orders` and `Page` on T before the projection, so order fields need not be selected. - `getQueryString` needs an EF Core provider. diff --git a/README.md b/README.md index c7f3a9f..a9ec82d 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ Rows with an empty collection sort as `null` (or the type default for non-nullab The library accepts a JSON `Filter` from any caller and turns it into a query. Policies add the missing question: **who is asking, and what are they allowed to see?** ```csharp -// Once, at startup. +// At startup. A second call asking for this same posture does nothing. DwPolicy.Configure(new DwPolicyOptions { Tier = DwTier.Convenience, HashSalt = secret }); // Once per request. @@ -379,10 +379,10 @@ The complete reference — every enum, class, extension method, validation rule, **Upgrade note — two behaviour changes. Read these before bumping.** -- **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a cap's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. -- **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. +- **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a value's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor, and `IncludeTraceInResult` written as the tier's own answer is the answer the tier already gives. A type exposed under two names is reported under the last one, so two catalogues that resolve every name alike are still a different posture when the order differs. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. +- **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query a provider in front of EF Core translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — because such a provider exists to rewrite what EF Core cannot, and a member it computes is a member the query produces. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. - **Changed: `LastTrace` is set before a request is sanitized**, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. -- **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. +- **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. Every node is new; a condition's values stay the caller's own objects, in a new list. - **Docs:** `[DwEntity(DefaultOrder)]`'s own remarks said a projected query takes no default; it has since 3.2.0. `DwCaps.MinGroupSize` ships **on at 5**, so a guarded summary silently drops groups of fewer than five rows — the aggregation docs now lead with that instead of leaving it to the caps table. ## Version 3.2.0 highlights From 1cbe928762718697cdebb00523834247f0f11e52 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 13:59:17 +0300 Subject: [PATCH 14/54] test(policies): keep the second review's probes, so what was ruled out stays ruled out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four files, 120 or so facts: the source table row by row, the posture property by property, every clause that refuses and every one that does not, the mapping shapes a refusal must not misread — JSON owned types, primitive collections, TPH, TPT, table splitting, keyless entities — and the shapes that decide whether a projected member can be spoken for. Where a probe asserted a contract the review then settled the other way, it asserts the settled one: a wrapping provider is answered for the same way over either kind of row, Clone shares the caller's values, and the two divergences that stay — a simulation cannot refuse an uncomputable path, and a condition's values are read more than once — are pinned as the limits they are rather than as bugs. One probe drove DwPolicy's static provider list by reflection to reach a comparison; it now reads the comparison itself, since the list is process-wide and driving it made every suite configuring the posture in parallel fail. The mapping probes stay off the floor leg: they map with ToJson and a primitive collection, which EF Core 6 has neither of. Co-Authored-By: Claude Opus 5 --- DynamicWhere.Tests/DynamicWhere.Tests.csproj | 4 + .../Policies/DocsReview33Probe.cs | 1071 +++++++++++++++++ .../Policies/DocsReview33ProbeB.cs | 251 ++++ .../Policies/ReviewMappingProbes.cs | 336 ++++++ .../Policies/ReviewOverBlockProbes.cs | 875 ++++++++++++++ .../Policies/ReviewPostureProbes.cs | 336 ++++++ .../Policies/ReviewSecondRoundProbes.cs | 674 +++++++++++ 7 files changed, 3547 insertions(+) create mode 100644 DynamicWhere.Tests/Policies/DocsReview33Probe.cs create mode 100644 DynamicWhere.Tests/Policies/DocsReview33ProbeB.cs create mode 100644 DynamicWhere.Tests/Policies/ReviewMappingProbes.cs create mode 100644 DynamicWhere.Tests/Policies/ReviewOverBlockProbes.cs create mode 100644 DynamicWhere.Tests/Policies/ReviewPostureProbes.cs create mode 100644 DynamicWhere.Tests/Policies/ReviewSecondRoundProbes.cs diff --git a/DynamicWhere.Tests/DynamicWhere.Tests.csproj b/DynamicWhere.Tests/DynamicWhere.Tests.csproj index 48e78ca..29e17f9 100644 --- a/DynamicWhere.Tests/DynamicWhere.Tests.csproj +++ b/DynamicWhere.Tests/DynamicWhere.Tests.csproj @@ -97,6 +97,10 @@ + + diff --git a/DynamicWhere.Tests/Policies/DocsReview33Probe.cs b/DynamicWhere.Tests/Policies/DocsReview33Probe.cs new file mode 100644 index 0000000..6c20f0c --- /dev/null +++ b/DynamicWhere.Tests/Policies/DocsReview33Probe.cs @@ -0,0 +1,1071 @@ +using System.Linq.Expressions; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Discovery; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// A docs-against-code probe for the 3.3.0 claims. Nothing here is a fix; every fact reads what the +// code does and prints it, so the report can quote an outcome rather than an inference. + +namespace DynamicWhere.Tests.Policies +{ + // ---- a model with a converted column, a date, a nullable and an unmapped getter -------------- + + public class QpTag + { + public string Value { get; set; } = string.Empty; + + ///

          A getter over the one column the converter stores. + public bool IsEmpty => string.IsNullOrWhiteSpace(Value); + } + + public class QpTicket + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } + + public int? Score { get; set; } + + /// Stored through a value converter, so the model maps it as one column. + public QpTag Tag { get; set; } = new(); + + [DwDenied] + public string Secret { get; set; } = string.Empty; + + /// Unmapped: a getter over two columns of the entity itself. + public string Display => $"{Code}:{Id}"; + } + + public sealed class QpContext : DbContext + { + private readonly SqliteConnection _connection; + + public QpContext(SqliteConnection connection) => _connection = connection; + + public DbSet Tickets => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().Property(ticket => ticket.Tag) + .HasConversion(tag => tag.Value, value => new QpTag { Value = value }); + + model.Entity().Ignore(ticket => ticket.Display); + } + } + + // ---- default-order shapes -------------------------------------------------------------------- + + [DwEntity(DefaultOrder = "Code desc")] + public class QpCtorRow + { + public QpCtorRow() + { + } + + /// A constructor with arguments. Which member each one sets is not recorded. + public QpCtorRow(int id, string code) + { + Id = id; + Code = code; + } + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + } + + [DwEntity(DefaultOrder = "Code desc")] + public class QpPlainRow + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + } + + [DwEntity(DefaultOrder = "Code desc")] + public class QpTicketRow + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + } + + /// A catalogue-comparison stand-in, exposed nowhere else. + public sealed class QpOnlyHere + { + } + + public sealed class QpAlsoOnlyHere + { + } + + public sealed class QpProviderA : IDwPolicyProvider + { + public IReadOnlyList GetFragments(Type entityType, DwPolicyContext context) => + Array.Empty(); + } + + public sealed class QpProviderB : IDwPolicyProvider + { + public IReadOnlyList GetFragments(Type entityType, DwPolicyContext context) => + Array.Empty(); + } + + // ============================================================================================= + // 1. The source table, row by row, and the Selects exemption. + // ============================================================================================= + + public sealed class QpSourceTableProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly QpContext _db; + + public QpSourceTableProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new QpContext(_connection); + _db.Database.EnsureCreated(); + _db.Tickets.Add(new QpTicket + { + Code = "alpha", + CreatedAt = new DateTime(2026, 3, 4), + Score = 7, + Tag = new QpTag { Value = "red" }, + Secret = "s" + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + } + }; + + private void Row(string label, IQueryable source, string path) + { + object shape = typeof(RowShape) + .GetMethod("Of", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)! + .MakeGenericMethod(source.ElementType) + .Invoke(null, new object[] { source })!; + + bool? answer = (bool?)typeof(RowShape) + .GetMethod("Expresses", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(shape, new object[] { path }); + + _out.WriteLine($"{label,-52} Expresses(\"{path}\") = {(answer is null ? "null (left alone)" : answer.ToString())}"); + } + + // ---- every row of the table, read straight off RowShape ----------------------------------- + + [Fact] + public void The_source_table_row_by_row() + { + Row("an entity", _db.Tickets, "Tag.IsEmpty"); + Row("an entity, unmapped getter on itself", _db.Tickets, "Display"); + Row("an entity, a framework member (Length)", _db.Tickets, "Code.Length"); + Row("an entity, a framework member (Year)", _db.Tickets, "CreatedAt.Year"); + Row("an entity, a framework member (HasValue)", _db.Tickets, "Score.HasValue"); + Row("beneath a converted column", _db.Tickets, "Tag.Value"); + + Row("rows in memory", new[] { new QpTicket() }.AsQueryable(), "Tag.IsEmpty"); + Row("a source the library cannot read", + new ZyOwnProvider(new[] { new QpTicket() }.AsQueryable()), "Tag.IsEmpty"); + } + + /// Beneath a column the converter decides, so the policy leaves it alone either way. + [Fact] + public void Beneath_a_column_is_left_alone_and_behaves_as_it_does_unguarded() + { + Exception? unguarded = Record.Exception( + () => _db.Tickets.Where(ticket => ticket.Tag.IsEmpty == false).ToList()); + + Exception? guarded = Record.Exception( + () => Guard(_db.Tickets, DwTier.Strict).ToList(Where("Tag.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"converted column: unguarded={unguarded?.GetType().Name ?? "ran"} guarded={guarded?.GetType().Name ?? "ran"}"); + + Assert.IsNotType(guarded); + } + + // ---- the framework members the text names, guarded and unguarded -------------------------- + + [Fact] + public void Length_runs_guarded_and_unguarded() + { + Assert.Single(_db.Tickets.Where(ticket => ticket.Code.Length == 5).ToList()); + + FilterResult guarded = Guard(_db.Tickets, DwTier.Strict) + .ToList(Where("Code.Length", "5", DataType.Number)); + + _out.WriteLine($"Length: unguarded=1 guarded={guarded.Data.Count}"); + + Assert.Single(guarded.Data); + } + + [Fact] + public void Year_runs_guarded_and_unguarded() + { + Assert.Single(_db.Tickets.Where(ticket => ticket.CreatedAt.Year == 2026).ToList()); + + FilterResult guarded = Guard(_db.Tickets, DwTier.Strict) + .ToList(Where("CreatedAt.Year", "2026", DataType.Number)); + + _out.WriteLine($"Year: unguarded=1 guarded={guarded.Data.Count}"); + + Assert.Single(guarded.Data); + } + + [Fact] + public void HasValue_runs_guarded_and_unguarded() + { + Assert.Single(_db.Tickets.Where(ticket => ticket.Score.HasValue).ToList()); + + Exception? guarded = Record.Exception( + () => Guard(_db.Tickets, DwTier.Strict).ToList(Where("Score.HasValue", "true", DataType.Boolean))); + + _out.WriteLine($"HasValue: unguarded=1 guarded={guarded?.GetType().Name ?? "ran"} {guarded?.Message}"); + + Assert.IsNotType(guarded); + Assert.Null(guarded); + } + + // ---- Selects, both tiers, filter and segment ----------------------------------------------- + + [Fact] + public void Selects_is_exempt_under_strict() + { + FilterResult result = Guard(_db.Tickets, DwTier.Strict) + .ToList(new Filter { Selects = new List { "Id", "Display" } }); + + _out.WriteLine($"strict Selects of an unmapped getter: {result.Data.Count} row(s)"); + + Assert.Single(result.Data); + } + + [Fact] + public void Selects_is_exempt_under_convenience() + { + FilterResult result = Guard(_db.Tickets, DwTier.Convenience) + .ToList(new Filter { Selects = new List { "Id", "Display" } }); + + _out.WriteLine($"convenience Selects of an unmapped getter: {result.Data.Count} row(s)"); + + Assert.Single(result.Data); + } + + [Fact] + public async Task Selects_is_exempt_inside_a_segment() + { + SegmentResult result = await Guard(_db.Tickets, DwTier.Strict).ToListAsync(new Segment + { + Selects = new List { "Id", "Display" }, + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "alpha" } } } + } + } + } + }); + + _out.WriteLine($"segment Selects of an unmapped getter: {result.Data.Count} row(s)"); + + Assert.Single(result.Data); + } + + [Fact] + public void A_denied_field_named_in_Selects_is_still_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Tickets, DwTier.Strict).ToList(new Filter { Selects = new List { "Id", "Secret" } })); + + _out.WriteLine($"denied field in Selects: {refusal.ErrorCode} FieldPath={refusal.FieldPath}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode); + } + + [Fact] + public void An_unknown_name_in_Selects_is_still_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Tickets, DwTier.Strict).ToList(new Filter { Selects = new List { "Id", "NoSuchColumn" } })); + + _out.WriteLine($"unknown name in Selects: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode); + } + + // ---- which clauses refuse ------------------------------------------------------------------- + + [Fact] + public void An_aggregated_field_is_refused() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Tickets, DwTier.Strict).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List + { + new() { Alias = "N", Aggregator = Aggregator.Count }, + new() { Alias = "D", Field = "Display", Aggregator = Aggregator.Maximum } + } + } + })); + + _out.WriteLine($"aggregated field: {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForAggregate, refusal.ErrorCode); + } + + [Fact] + public void The_composed_clauses_each_report_their_own_answer() + { + PolicyQueryable guarded = Guard(_db.Tickets, DwTier.Strict); + + Exception? where = Record.Exception(() => guarded.Where(new Condition + { + Field = "Display", DataType = DataType.Text, Operator = Operator.Equal, Values = { "alpha:1" } + })); + + Exception? order = Record.Exception( + () => guarded.Order(new OrderBy { Field = "Display", Direction = Direction.Ascending })); + + Exception? select = Record.Exception(() => guarded.Select(new List { "Id", "Display" })); + + Exception? page = Record.Exception(() => guarded.Page(new PageBy { PageNumber = 1, PageSize = 10 })); + + _out.WriteLine($"composed .Where -> {Name(where)}"); + _out.WriteLine($"composed .Order -> {Name(order)}"); + _out.WriteLine($"composed .Select -> {Name(select)}"); + _out.WriteLine($"composed .Page -> {Name(page)}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, ((PolicyException)where!).ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, ((PolicyException)order!).ErrorCode); + Assert.Null(select); + Assert.Null(page); + + static string Name(Exception? error) => + error is PolicyException policy + ? $"{policy.GetType().Name} {policy.ErrorCode}" + : error?.GetType().Name ?? "no refusal"; + } + + // ---- LastTrace on every terminal ------------------------------------------------------------ + + [Fact] + public async Task LastTrace_is_readable_after_a_refusal_on_every_terminal() + { + Filter filter = Where("Display", "alpha:1"); + + Report("ToList(Filter)", g => g.ToList(filter)); + await ReportAsync("ToListAsync(Filter)", g => g.ToListAsync(Where("Display", "alpha:1"))); + Report("ToListDynamic(Filter)", g => g.ToListDynamic(Where("Display", "alpha:1"))); + await ReportAsync("ToListAsyncDynamic(Filter)", g => g.ToListAsyncDynamic(Where("Display", "alpha:1"))); + + Report("ToList(Summary)", g => g.ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Display" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + })); + + await ReportAsync("ToListAsync(Summary)", g => g.ToListAsync(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Display" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + })); + + await ReportAsync("ToListAsync(Segment)", g => g.ToListAsync(new Segment + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Display", DataType = DataType.Text, Operator = Operator.Equal, Values = { "alpha:1" } } } + } + } + } + })); + + Report("composed .Where", g => g.Where(new Condition + { + Field = "Display", DataType = DataType.Text, Operator = Operator.Equal, Values = { "alpha:1" } + })); + + Report("composed .Order", g => g.Order(new OrderBy { Field = "Display", Direction = Direction.Ascending })); + + Report("composed .Group", g => g.Group(new GroupBy + { + Fields = new List { "Display" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + })); + + void Report(string label, Action> call) + { + PolicyQueryable guarded = Guard(_db.Tickets, DwTier.Strict); + + Assert.ThrowsAny(() => call(guarded)); + + Describe(label, guarded.LastTrace); + } + + async Task ReportAsync(string label, Func, Task> call) + { + PolicyQueryable guarded = Guard(_db.Tickets, DwTier.Strict); + + await Assert.ThrowsAnyAsync(() => call(guarded)); + + Describe(label, guarded.LastTrace); + } + + void Describe(string label, PolicyTrace? trace) + { + string reason = trace?.Decisions + .Where(decision => decision.Reason is not null && decision.Reason.Contains("cannot compute")) + .Select(decision => decision.FieldPath) + .FirstOrDefault() ?? "(no 'cannot compute' decision)"; + + _out.WriteLine($"{label,-28} LastTrace={(trace is null ? "null" : "set")} reason on: {reason}"); + + Assert.NotNull(trace); + } + } + } + + // ============================================================================================= + // 2. DefaultOrder: the four shapes the corrected remarks name. + // ============================================================================================= + + public sealed class QpDefaultOrderProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly QpContext _db; + + public QpDefaultOrderProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new QpContext(_connection); + _db.Database.EnsureCreated(); + + // Inserted B, C, A: none of the expected orders is the insertion order. + foreach (string code in new[] { "B", "C", "A" }) + { + _db.Tickets.Add(new QpTicket { Code = code, CreatedAt = new DateTime(2026, 1, 1), Tag = new QpTag() }); + } + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + [Fact] + public void A_constructor_with_arguments_and_an_initializer_still_takes_the_default() + { + IQueryable rows = _db.Tickets + .Select(ticket => new QpCtorRow(ticket.Id, ticket.Code) { Code = ticket.Code }); + + string order = string.Join(",", Guard(rows).ToList(new Filter()).Data.Select(row => row.Code)); + + _out.WriteLine($"ctor with args + initializer: {order}"); + + Assert.Equal("C,B,A", order); + } + + [Fact] + public void A_projection_with_no_initializer_at_all_stays_in_its_own_order() + { + IQueryable rows = _db.Tickets.Select(ticket => new QpCtorRow(ticket.Id, ticket.Code)); + + string order = string.Join(",", Guard(rows).ToList(new Filter()).Data.Select(row => row.Code)); + + _out.WriteLine($"ctor with args, no initializer: {order}"); + + Assert.NotEqual("C,B,A", order); + } + + [Fact] + public void A_filter_carrying_Selects_is_ordered_as_any_other_filter_is() + { + IQueryable rows = _db.Tickets + .Select(ticket => new QpPlainRow { Id = ticket.Id, Code = ticket.Code }); + + string order = string.Join( + ",", + Guard(rows).ToList(new Filter { Selects = new List { "Id", "Code" } }) + .Data.Select(row => row.Code)); + + _out.WriteLine($"filter carrying Selects: {order}"); + + Assert.Equal("C,B,A", order); + } + + [Fact] + public void A_Select_composed_on_the_guarded_handle_leaves_the_chain_unordered() + { + IQueryable rows = _db.Tickets + .Select(ticket => new QpPlainRow { Id = ticket.Id, Code = ticket.Code }); + + string order = string.Join( + ",", + Guard(rows).Select(new List { "Id", "Code" }) + .ToList(new Filter()).Data.Select(row => row.Code)); + + _out.WriteLine($"composed .Select then ToList(new Filter()): {order}"); + + Assert.NotEqual("C,B,A", order); + } + } + + // ============================================================================================= + // 3. Posture comparison, property by property. + // ============================================================================================= + + public sealed class QpPostureProbe + { + private readonly ITestOutputHelper _out; + + public QpPostureProbe(ITestOutputHelper output) + { + _out = output; + PolicyBootstrap.Ensure(); + } + + private static readonly MethodInfo SamePosture = typeof(DwPolicy) + .GetMethod("SamePosture", BindingFlags.Static | BindingFlags.NonPublic)!; + + private static readonly MethodInfo SameProviders = typeof(DwPolicy) + .GetMethod("SameProviders", BindingFlags.Static | BindingFlags.NonPublic)!; + + private static readonly FieldInfo ProviderTypes = typeof(DwPolicy) + .GetField("_providerTypes", BindingFlags.Static | BindingFlags.NonPublic)!; + + private static bool Same(DwPolicyOptions asked, params IDwPolicyProvider[] providers) => + (bool)SamePosture.Invoke(null, new object?[] { DwPolicy.Options, asked, providers })!; + + /// A posture holding exactly what is in force, value by value. + private static DwPolicyOptions Copy() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicyOptions copy = new() + { + Tier = inForce.Tier, + DryRun = inForce.DryRun, + IncludeTraceInResult = inForce.IncludeTraceInResult, + AuditRefusals = inForce.AuditRefusals, + StoreFailure = inForce.StoreFailure, + MaxSnapshotAge = inForce.MaxSnapshotAge, + RefreshInterval = inForce.RefreshInterval + }; + + if (!string.IsNullOrEmpty(inForce.HashSalt)) + { + copy.HashSalt = inForce.HashSalt; + } + + foreach (PropertyInfo cap in Caps()) + { + if (cap.Name == "MinGroupSize" && !inForce.Caps.IsMinGroupSizeSet) + { + continue; + } + + cap.SetValue(copy.Caps, cap.GetValue(inForce.Caps)); + } + + foreach (KeyValuePair exposed in inForce.Entities.Entities) + { + copy.Entities.Expose(exposed.Key, exposed.Value); + } + + return copy; + } + + private static PropertyInfo[] Caps() => typeof(DwCaps) + .GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(property => property.CanWrite && property.PropertyType == typeof(int)) + .OrderBy(property => property.Name) + .ToArray(); + + // ---- the options, one property at a time ---------------------------------------------------- + + [Fact] + public void An_identical_posture_is_the_same_posture() + { + Assert.True(Same(Copy())); + } + + [Fact] + public void Every_option_the_text_lists_as_compared_really_is() + { + Check("Tier", o => o.Tier = o.Tier == DwTier.Strict ? DwTier.Convenience : DwTier.Strict); + Check("DryRun", o => o.DryRun = !o.DryRun); + // Compared by the value that applies: the flag defaults to the tier's own answer, so what + // has to be refused is a value answering differently, not a value written down. + Check("IncludeTraceInResult", o => o.IncludeTraceInResult = + !(o.IncludeTraceInResult ?? o.Tier == DwTier.Convenience)); + Check("AuditRefusals", o => o.AuditRefusals = !o.AuditRefusals); + Check("HashSalt", o => o.HashSalt = "a-salt-long-enough-for-the-minimum"); + Check("StoreFailure", o => o.StoreFailure = + o.StoreFailure == StoreFailureMode.FailClosed ? StoreFailureMode.LastKnownGood : StoreFailureMode.FailClosed); + Check("MaxSnapshotAge", o => o.MaxSnapshotAge = o.MaxSnapshotAge + TimeSpan.FromMinutes(1)); + Check("RefreshInterval", o => o.RefreshInterval = o.RefreshInterval + TimeSpan.FromSeconds(1)); + + void Check(string label, Action change) + { + DwPolicyOptions asked = Copy(); + + change(asked); + + bool same = Same(asked); + + _out.WriteLine($"{label,-22} differs -> SamePosture = {same}"); + + Assert.False(same); + } + } + + [Fact] + public void The_two_objects_the_text_lists_as_not_compared_really_are_not() + { + DwPolicyOptions vault = Copy(); + vault.TokenVault = new QpVault(); + + DwPolicyOptions services = Copy(); + services.Services = new QpServices(); + + _out.WriteLine($"TokenVault differs -> SamePosture = {Same(vault)}"); + _out.WriteLine($"Services differs -> SamePosture = {Same(services)}"); + + Assert.True(Same(vault)); + Assert.True(Same(services)); + } + + // ---- every cap, one at a time --------------------------------------------------------------- + + [Fact] + public void Every_cap_value_is_compared() + { + PropertyInfo[] caps = Caps(); + + _out.WriteLine($"settable int caps on DwCaps: {caps.Length} -> {string.Join(", ", caps.Select(c => c.Name))}"); + + foreach (PropertyInfo cap in caps) + { + DwPolicyOptions asked = Copy(); + + int inForce = (int)cap.GetValue(DwPolicy.Options.Caps)!; + + // Every cap setter refuses below one, so move up rather than down. + cap.SetValue(asked.Caps, inForce + 1); + + bool same = Same(asked); + + _out.WriteLine($" {cap.Name,-22} {inForce} -> {inForce + 1}: SamePosture = {same}"); + + Assert.False(same); + } + } + + [Fact] + public void IsMinGroupSizeSet_is_not_compared() + { + DwPolicyOptions asked = Copy(); + + asked.Caps.MinGroupSize = DwPolicy.Options.Caps.MinGroupSize; + + _out.WriteLine( + $"in force: MinGroupSize={DwPolicy.Options.Caps.MinGroupSize} set={DwPolicy.Options.Caps.IsMinGroupSizeSet}; " + + $"asked: MinGroupSize={asked.Caps.MinGroupSize} set={asked.Caps.IsMinGroupSizeSet}; " + + $"SamePosture = {Same(asked)}"); + + Assert.NotEqual(DwPolicy.Options.Caps.IsMinGroupSizeSet, asked.Caps.IsMinGroupSizeSet); + Assert.True(Same(asked)); + } + + [Fact] + public void The_default_MinGroupSize_is_five_and_unset_reads_as_five() + { + DwCaps caps = new(); + + _out.WriteLine($"fresh DwCaps: MinGroupSize={caps.MinGroupSize} IsMinGroupSizeSet={caps.IsMinGroupSizeSet} DefaultMinGroupSize={DwCaps.DefaultMinGroupSize}"); + + Assert.Equal(5, caps.MinGroupSize); + Assert.False(caps.IsMinGroupSizeSet); + Assert.Equal(5, DwCaps.DefaultMinGroupSize); + } + + // ---- the catalogue, every name --------------------------------------------------------------- + + [Fact] + public void The_catalogue_is_compared_by_every_name_it_answers_to() + { + MethodInfo sameAs = typeof(DwEntityCatalog) + .GetMethod("SameAs", BindingFlags.Instance | BindingFlags.NonPublic)!; + + bool Ask(DwEntityCatalog left, DwEntityCatalog right) => + (bool)sameAs.Invoke(left, new object[] { right })!; + + DwEntityCatalog one = new(); + one.Expose("only"); + + DwEntityCatalog same = new(); + same.Expose("only"); + + DwEntityCatalog extraName = new(); + extraName.Expose("also"); + extraName.Expose("only"); + + DwEntityCatalog otherName = new(); + otherName.Expose("different"); + + DwEntityCatalog extraType = new(); + extraType.Expose("only"); + extraType.Expose("second"); + + _out.WriteLine($"identical -> {Ask(one, same)}"); + _out.WriteLine($"Entities reports the same pair, one extra resolvable name -> {Ask(one, extraName)}"); + _out.WriteLine($" (its Entities count: {one.Entities.Count} vs {extraName.Entities.Count}; " + + $"NameOf: {one.NameOf(typeof(QpOnlyHere))} vs {extraName.NameOf(typeof(QpOnlyHere))})"); + _out.WriteLine($"a different public name -> {Ask(one, otherName)}"); + _out.WriteLine($"one more type -> {Ask(one, extraType)}"); + + Assert.True(Ask(one, same)); + Assert.Equal(one.Entities.Count, extraName.Entities.Count); + Assert.Equal(one.NameOf(typeof(QpOnlyHere)), extraName.NameOf(typeof(QpOnlyHere))); + Assert.False(Ask(one, extraName)); + Assert.False(Ask(one, otherName)); + Assert.False(Ask(one, extraType)); + } + + // ---- the providers ---------------------------------------------------------------------------- + + [Fact] + public void The_provider_rules_hold_type_by_type() + { + Type[] saved = (Type[])ProviderTypes.GetValue(null)!; + + try + { + ProviderTypes.SetValue(null, new[] { typeof(QpProviderA), typeof(QpProviderB) }); + + bool Ask(params IDwPolicyProvider[] asked) => + (bool)SameProviders.Invoke(null, new object?[] { asked })!; + + bool sameKinds = Ask(new QpProviderA(), new QpProviderB()); + bool reordered = Ask(new QpProviderB(), new QpProviderA()); + bool dropped = Ask(new QpProviderA()); + bool added = Ask(new QpProviderA(), new QpProviderB(), new QpProviderA()); + bool attributeIgnored = Ask(new AttributePolicyProvider(), new QpProviderA(), new QpProviderB()); + bool none = Ask(); + + _out.WriteLine($"in force = [QpProviderA, QpProviderB]"); + _out.WriteLine($" same kinds, new instances -> {sameKinds}"); + _out.WriteLine($" reordered -> {reordered}"); + _out.WriteLine($" one dropped -> {dropped}"); + _out.WriteLine($" one added -> {added}"); + _out.WriteLine($" AttributePolicyProvider prefixed -> {attributeIgnored}"); + _out.WriteLine($" none supplied -> {none}"); + + Assert.True(sameKinds); + Assert.False(reordered); + Assert.False(dropped); + Assert.False(added); + Assert.True(attributeIgnored); + Assert.False(none); + } + finally + { + ProviderTypes.SetValue(null, saved); + } + } + + [Fact] + public void Handing_the_posture_in_force_back_with_a_source_beside_it_is_refused() + { + InvalidOperationException refusal = Assert.Throws( + () => DwPolicy.Configure(DwPolicy.Options, new QpProviderA())); + + _out.WriteLine($"Configure(DwPolicy.Options, provider) -> {refusal.GetType().Name}"); + + Assert.Same(DwPolicy.Options, DwPolicy.Options); + } + + [Fact] + public void Handing_the_posture_in_force_back_alone_is_a_no_op() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicy.Configure(DwPolicy.Options); + + _out.WriteLine($"Configure(DwPolicy.Options) -> same instance still in force: {ReferenceEquals(inForce, DwPolicy.Options)}"); + + Assert.Same(inForce, DwPolicy.Options); + } + + private sealed class QpVault : DynamicWhere.ex.Policies.Tokens.IDwTokenVault + { + public string GetOrCreate(string scope, string value) => value; + } + + private sealed class QpServices : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + } + + // ============================================================================================= + // 4. Clone, branch by branch. + // ============================================================================================= + + public sealed class QpCloneProbe + { + private readonly ITestOutputHelper _out; + + public QpCloneProbe(ITestOutputHelper output) => _out = output; + + [Fact] + public void Clone_is_public_on_all_three() + { + foreach (Type type in new[] { typeof(Filter), typeof(Segment), typeof(Summary) }) + { + MethodInfo? clone = type.GetMethod("Clone", BindingFlags.Instance | BindingFlags.Public); + + _out.WriteLine($"{type.Name}.Clone: {(clone is null ? "not public" : $"public, returns {clone.ReturnType.Name}")}"); + + Assert.NotNull(clone); + Assert.Equal(type, clone!.ReturnType); + } + } + + [Fact] + public void A_filter_clone_shares_no_branch() + { + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } }, + SubConditionGroups = { new ConditionGroup { Sort = 1, Conditions = { new Condition { Field = "Id", DataType = DataType.Number, Operator = Operator.Equal, Values = { "1" } } } } } + }, + Selects = new List { "Id" }, + Orders = new List { new() { Field = "Id", Direction = Direction.Ascending } }, + Page = new PageBy { PageNumber = 1, PageSize = 10 } + }; + + Filter copy = filter.Clone(); + + Assert.NotSame(filter.ConditionGroup, copy.ConditionGroup); + Assert.NotSame(filter.ConditionGroup!.Conditions, copy.ConditionGroup!.Conditions); + Assert.NotSame(filter.ConditionGroup.Conditions[0], copy.ConditionGroup.Conditions[0]); + Assert.NotSame(filter.ConditionGroup.SubConditionGroups[0], copy.ConditionGroup.SubConditionGroups[0]); + Assert.NotSame(filter.ConditionGroup.SubConditionGroups[0].Conditions[0], copy.ConditionGroup.SubConditionGroups[0].Conditions[0]); + Assert.NotSame(filter.Selects, copy.Selects); + Assert.NotSame(filter.Orders, copy.Orders); + Assert.NotSame(filter.Orders![0], copy.Orders![0]); + Assert.NotSame(filter.Page, copy.Page); + + copy.Page!.PageNumber = 2; + copy.ConditionGroup.Conditions[0].Field = "Other"; + copy.Selects!.Add("Code"); + + _out.WriteLine($"filter: original page={filter.Page!.PageNumber} field={filter.ConditionGroup.Conditions[0].Field} selects={filter.Selects!.Count}"); + + Assert.Equal(1, filter.Page.PageNumber); + Assert.Equal("Code", filter.ConditionGroup.Conditions[0].Field); + Assert.Single(filter.Selects); + } + + [Fact] + public void A_null_branch_stays_null() + { + Filter filter = new(); + Filter copy = filter.Clone(); + + _out.WriteLine($"filter nulls kept: group={copy.ConditionGroup is null} selects={copy.Selects is null} orders={copy.Orders is null} page={copy.Page is null}"); + + Assert.Null(copy.ConditionGroup); + Assert.Null(copy.Selects); + Assert.Null(copy.Orders); + Assert.Null(copy.Page); + + Summary summary = new(); + Summary summaryCopy = summary.Clone(); + + _out.WriteLine($"summary nulls kept: group={summaryCopy.ConditionGroup is null} groupBy={summaryCopy.GroupBy is null} having={summaryCopy.Having is null} orders={summaryCopy.Orders is null} page={summaryCopy.Page is null}"); + + Assert.Null(summaryCopy.ConditionGroup); + Assert.Null(summaryCopy.GroupBy); + Assert.Null(summaryCopy.Having); + Assert.Null(summaryCopy.Orders); + Assert.Null(summaryCopy.Page); + + Segment segment = new() { Orders = null }; + Segment segmentCopy = segment.Clone(); + + _out.WriteLine($"segment nulls kept: selects={segmentCopy.Selects is null} orders={segmentCopy.Orders is null} page={segmentCopy.Page is null}"); + + Assert.Null(segmentCopy.Selects); + Assert.Null(segmentCopy.Orders); + Assert.Null(segmentCopy.Page); + } + + [Fact] + public void A_segment_clone_gives_each_set_its_own_condition_group() + { + ConditionGroup shared = new() + { + Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } } + }; + + Segment segment = new() + { + ConditionSets = + { + new ConditionSet { Sort = 1, ConditionGroup = shared }, + new ConditionSet { Sort = 2, Intersection = Intersection.Union, ConditionGroup = shared } + }, + Selects = new List { "Id" }, + Orders = new List { new() { Field = "Id", Direction = Direction.Ascending } }, + Page = new PageBy { PageNumber = 1, PageSize = 10 } + }; + + Segment copy = segment.Clone(); + + _out.WriteLine( + "segment: sets share one group before the clone: " + + $"{ReferenceEquals(segment.ConditionSets[0].ConditionGroup, segment.ConditionSets[1].ConditionGroup)}; after: " + + $"{ReferenceEquals(copy.ConditionSets[0].ConditionGroup, copy.ConditionSets[1].ConditionGroup)}"); + + Assert.NotSame(segment.ConditionSets, copy.ConditionSets); + Assert.NotSame(segment.ConditionSets[0], copy.ConditionSets[0]); + Assert.NotSame(segment.ConditionSets[0].ConditionGroup, copy.ConditionSets[0].ConditionGroup); + Assert.NotSame(copy.ConditionSets[0].ConditionGroup, copy.ConditionSets[1].ConditionGroup); + Assert.NotSame(segment.Selects, copy.Selects); + Assert.NotSame(segment.Orders, copy.Orders); + Assert.NotSame(segment.Orders![0], copy.Orders![0]); + Assert.NotSame(segment.Page, copy.Page); + } + + [Fact] + public void A_summary_clone_reaches_the_group_by_the_aggregates_and_the_having() + { + Summary summary = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } } } + }, + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + }, + Having = new ConditionGroup + { + Conditions = { new Condition { Field = "N", DataType = DataType.Number, Operator = Operator.GreaterThan, Values = { "1" } } } + }, + Orders = new List { new() { Field = "N", Direction = Direction.Descending } }, + Page = new PageBy { PageNumber = 1, PageSize = 10 } + }; + + Summary copy = summary.Clone(); + + Assert.NotSame(summary.ConditionGroup, copy.ConditionGroup); + Assert.NotSame(summary.GroupBy, copy.GroupBy); + Assert.NotSame(summary.GroupBy!.Fields, copy.GroupBy!.Fields); + Assert.NotSame(summary.GroupBy.AggregateBy, copy.GroupBy.AggregateBy); + Assert.NotSame(summary.GroupBy.AggregateBy[0], copy.GroupBy.AggregateBy[0]); + Assert.NotSame(summary.Having, copy.Having); + Assert.NotSame(summary.Having!.Conditions[0], copy.Having!.Conditions[0]); + Assert.NotSame(summary.Orders, copy.Orders); + Assert.NotSame(summary.Orders![0], copy.Orders![0]); + Assert.NotSame(summary.Page, copy.Page); + + copy.GroupBy.AggregateBy[0].Alias = "Changed"; + copy.Having.Conditions[0].Field = "Changed"; + + _out.WriteLine($"summary: original alias={summary.GroupBy.AggregateBy[0].Alias} having field={summary.Having.Conditions[0].Field}"); + + Assert.Equal("N", summary.GroupBy.AggregateBy[0].Alias); + Assert.Equal("N", summary.Having.Conditions[0].Field); + } + + [Fact] + public void A_segment_whose_ConditionSets_is_null_is_what_Clone_gives_back() + { + Segment segment = new() { ConditionSets = null! }; + + Segment copy = segment.Clone(); + + _out.WriteLine($"Segment.Clone with null ConditionSets -> {(copy.ConditionSets is null ? "null" : "empty list")}"); + + Assert.Null(copy.ConditionSets); + } + } +} diff --git a/DynamicWhere.Tests/Policies/DocsReview33ProbeB.cs b/DynamicWhere.Tests/Policies/DocsReview33ProbeB.cs new file mode 100644 index 0000000..a3bb9d8 --- /dev/null +++ b/DynamicWhere.Tests/Policies/DocsReview33ProbeB.cs @@ -0,0 +1,251 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // What the entity row of the source table is actually read from, plus the group floor. + // ============================================================================================= + + public sealed class QpDerivedAndFloorProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _zyConnection; + private readonly ZyContext _zy; + private readonly SqliteConnection _qpConnection; + private readonly QpContext _qp; + + public QpDerivedAndFloorProbe(ITestOutputHelper output) + { + _out = output; + + _zyConnection = new SqliteConnection("DataSource=:memory:"); + _zyConnection.Open(); + _zy = new ZyContext(_zyConnection); + _zy.Database.EnsureCreated(); + _zy.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { En = "Admin" } }); + _zy.Parties.Add(new ZyMerchant { Kind = "merchant", Licence = "L-1", Rating = "A" }); + _zy.SaveChanges(); + _zy.ChangeTracker.Clear(); + + _qpConnection = new SqliteConnection("DataSource=:memory:"); + _qpConnection.Open(); + _qp = new QpContext(_qpConnection); + _qp.Database.EnsureCreated(); + + foreach (string code in new[] { "a", "b", "c" }) + { + _qp.Tickets.Add(new QpTicket { Code = code, CreatedAt = new DateTime(2026, 1, 1), Tag = new QpTag() }); + } + + _qp.SaveChanges(); + _qp.ChangeTracker.Clear(); + } + + public void Dispose() + { + _zy.Dispose(); + _zyConnection.Dispose(); + _qp.Dispose(); + _qpConnection.Dispose(); + } + + private static bool? Ask(IQueryable source, string path) + { + object shape = typeof(RowShape) + .GetMethod("Of", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)! + .MakeGenericMethod(source.ElementType) + .Invoke(null, new object[] { source })!; + + return (bool?)typeof(RowShape) + .GetMethod("Expresses", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(shape, new object[] { path }); + } + + private static string Show(bool? answer) => answer is null ? "null (left alone)" : answer.ToString()!; + + private static PolicyQueryable Guard(IQueryable source, DwPolicyOptions options) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + /// + /// The one claim two tables disagree on: whether a derived type's columns count as members + /// the queried type can produce. + /// + [Fact] + public void The_entity_row_reads_the_queried_types_own_model_not_a_derived_types() + { + bool derivedMaps = _zy.Model.FindEntityType(typeof(ZyMerchant))!.FindProperty("Licence") is not null; + bool baseMaps = _zy.Model.FindEntityType(typeof(ZyParty))!.FindProperty("Licence") is not null; + + bool? throughBase = Ask(_zy.Parties, "Licence"); + bool? throughDerived = Ask(_zy.Set(), "Licence"); + + _out.WriteLine($"model maps Licence: on ZyMerchant = {derivedMaps}, on ZyParty = {baseMaps}"); + _out.WriteLine($"Expresses(\"Licence\") on DbSet = {Show(throughBase)}"); + _out.WriteLine($"Expresses(\"Licence\") on Set() = {Show(throughDerived)}"); + + Assert.True(derivedMaps); + Assert.False(baseMaps); + Assert.False(throughBase); + Assert.True(throughDerived); + } + + /// The projected rows of the table, read straight off the shape. + [Fact] + public void The_projected_rows_of_the_table() + { + IQueryable built = _zy.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + IQueryable copied = + _zy.Roles.Select(role => new ZyRoleRow { Id = role.Id, Code = role.Code, Name = role.Name }); + + IQueryable another = new ZyOwnProvider( + new[] { new ZyRole { Id = 1, Code = "admin", Name = new ZyLocalizedText() } } + .AsQueryable() + .Select(role => new ZyRoleRow { Id = role.Id, Code = role.Code, Name = new ZyLocalizedText { En = role.Name.En } })); + + _out.WriteLine($"a Select that builds the row Expresses(\"Name.IsEmpty\") = {Show(Ask(built, "Name.IsEmpty"))}"); + _out.WriteLine($" ... and what it does assign Expresses(\"Name.En\") = {Show(Ask(built, "Name.En"))}"); + _out.WriteLine($"a Select that copies the member Expresses(\"Name.IsEmpty\") = {Show(Ask(copied, "Name.IsEmpty"))}"); + _out.WriteLine($"a projection another provider ran Expresses(\"Name.IsEmpty\") = {Show(Ask(another, "Name.IsEmpty"))}"); + _out.WriteLine($"an entity, an owned type's getter Expresses(\"Name.IsEmpty\") = {Show(Ask(_zy.Roles, "Name.IsEmpty"))}"); + + Assert.False(Ask(built, "Name.IsEmpty")); + Assert.True(Ask(built, "Name.En")); + Assert.False(Ask(copied, "Name.IsEmpty")); + Assert.Null(Ask(another, "Name.IsEmpty")); + Assert.False(Ask(_zy.Roles, "Name.IsEmpty")); + } + + /// The exact wording the docs quote for the trace. + [Fact] + public void The_trace_reason_is_worded_as_the_docs_quote_it() + { + PolicyQueryable guarded = Guard(_zy.Roles, new DwPolicyOptions { Tier = DwTier.Strict }); + + Assert.ThrowsAny(() => guarded.ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Name.IsEmpty", DataType = DataType.Boolean, Operator = Operator.Equal, Values = { "false" } } } + } + })); + + string reason = guarded.LastTrace!.Decisions + .Select(decision => decision.Reason) + .First(text => text is not null && text.Contains("cannot compute"))!; + + _out.WriteLine($"trace reason: {reason}"); + + Assert.Equal( + "the member exists on the type and the query cannot compute it, so it is refused as an unknown name is", + reason); + } + + /// LastTrace lands on the handle the method was called on, not the one it returns. + [Fact] + public void LastTrace_lands_on_the_handle_the_method_was_called_on() + { + PolicyQueryable guarded = Guard(_qp.Tickets, new DwPolicyOptions { Tier = DwTier.Strict }); + + PolicyQueryable returned = guarded.Where(new Condition + { + Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { "a" } + }); + + _out.WriteLine( + $"called-on handle LastTrace = {(guarded.LastTrace is null ? "null" : "set")}; " + + $"returned handle LastTrace = {(returned.LastTrace is null ? "null" : "set")}"); + + Assert.NotNull(guarded.LastTrace); + Assert.Null(returned.LastTrace); + } + + /// The floor the three pages now lead with: on at 5, silent, and never unguarded. + [Fact] + public void The_group_floor_is_on_at_five_and_silent() + { + static Summary Ask() => new() + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + }; + + SummaryResult unguarded = _qp.Tickets.ToList(Ask()); + + SummaryResult floored = Guard(_qp.Tickets, new DwPolicyOptions { Tier = DwTier.Convenience }).ToList(Ask()); + + SummaryResult unfloored = Guard( + _qp.Tickets, + new DwPolicyOptions { Tier = DwTier.Convenience, Caps = { MinGroupSize = 1 } }).ToList(Ask()); + + _out.WriteLine( + $"3 groups of 1 row each -> unguarded={unguarded.Data.Count} " + + $"guarded(default)={floored.Data.Count} guarded(MinGroupSize=1)={unfloored.Data.Count}"); + _out.WriteLine($"guarded(default): TotalCount={floored.TotalCount}, trace on result={(floored.Policy is null ? "null" : "set")}"); + + Assert.Equal(3, unguarded.Data.Count); + Assert.Empty(floored.Data); + Assert.Equal(3, unfloored.Data.Count); + } + + /// + /// Whether the composable pair is floored too, which decides whether a page describing them + /// without the floor is a gap. + /// + [Fact] + public void The_composable_Group_and_Summary_are_floored_the_same_way() + { + static GroupBy Grouping() => new() + { + Fields = new List { "Code" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + }; + + static Summary Asked() => new() { GroupBy = Grouping() }; + + int unguardedGroup = _qp.Tickets.Group(Grouping()).Cast().Count(); + + PolicyQueryable guarded = Guard(_qp.Tickets, new DwPolicyOptions { Tier = DwTier.Convenience }); + int guardedGroup = guarded.Group(Grouping()).Cast().Count(); + + int unguardedSummary = _qp.Tickets.Summary(Asked()).Cast().Count(); + + PolicyQueryable guardedTwo = Guard(_qp.Tickets, new DwPolicyOptions { Tier = DwTier.Convenience }); + int guardedSummary = guardedTwo.Summary(Asked()).Cast().Count(); + + _out.WriteLine($"composable .Group -> unguarded={unguardedGroup} guarded(default floor)={guardedGroup}"); + _out.WriteLine($"composable .Summary -> unguarded={unguardedSummary} guarded(default floor)={guardedSummary}"); + + Assert.Equal(3, unguardedGroup); + Assert.Equal(3, unguardedSummary); + Assert.Equal(0, guardedGroup); + Assert.Equal(0, guardedSummary); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewMappingProbes.cs b/DynamicWhere.Tests/Policies/ReviewMappingProbes.cs new file mode 100644 index 0000000..95ef98f --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewMappingProbes.cs @@ -0,0 +1,336 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- mapping shapes the strict check now walks ------------------------------------------------------- + + public class MpDoc + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + /// Owned, stored as JSON on EF Core 8. + public MpMeta Meta { get; set; } = new(); + + /// An owned collection. + public List Lines { get; set; } = new(); + + /// A primitive collection on EF Core 8. + public List Tags { get; set; } = new(); + } + + public class MpMeta + { + public string Author { get; set; } = string.Empty; + + public int Revision { get; set; } + + /// A getter, not a column. + public bool IsDraft => Revision == 0; + } + + public class MpLine + { + public int Id { get; set; } + + public string Sku { get; set; } = string.Empty; + + public int Qty { get; set; } + } + + /// TPT: each type has a table of its own. + public class MpAnimal + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class MpDog : MpAnimal + { + public string? Breed { get; set; } + } + + /// Table splitting: two types share one table. + public class MpAccount + { + public int Id { get; set; } + + public string Number { get; set; } = string.Empty; + + public MpAccountDetail Detail { get; set; } = null!; + } + + public class MpAccountDetail + { + public int Id { get; set; } + + public string Iban { get; set; } = string.Empty; + + public MpAccount Account { get; set; } = null!; + } + + /// Keyless, read through a raw query. + public class MpTally + { + public string Code { get; set; } = string.Empty; + + public int Total { get; set; } + } + + public sealed class MpContext : DbContext + { + private readonly SqliteConnection _connection; + + public MpContext(SqliteConnection connection) => _connection = connection; + + public DbSet Docs => Set(); + + public DbSet Animals => Set(); + + public DbSet Dogs => Set(); + + public DbSet Accounts => Set(); + + public DbSet Details => Set(); + + public DbSet Tallies => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(doc => doc.Meta, meta => meta.ToJson()); + model.Entity().OwnsMany(doc => doc.Lines, lines => lines.HasKey(line => line.Id)); + + model.Entity().ToTable("Animals"); + model.Entity().ToTable("Dogs"); + + model.Entity().ToTable("Accounts"); + model.Entity().ToTable("Accounts"); + model.Entity().HasOne(detail => detail.Account) + .WithOne(account => account.Detail) + .HasForeignKey(detail => detail.Id); + + model.Entity().HasNoKey().ToView(null); + } + } + + /// + /// The mapping shapes RowShape.Expresses now walks, each run guarded and unguarded. + /// + public sealed class ReviewMappingProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly MpContext _db; + + public ReviewMappingProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new MpContext(_connection); + _db.Database.EnsureCreated(); + + _db.Docs.Add(new MpDoc + { + Code = "D-1", + Meta = new MpMeta { Author = "sajjad", Revision = 2 }, + Lines = { new MpLine { Id = 1, Sku = "S-1", Qty = 3 } }, + Tags = { "red", "blue" } + }); + + _db.Dogs.Add(new MpDog { Name = "Rex", Breed = "husky" }); + _db.Accounts.Add(new MpAccount { Number = "N-1", Detail = new MpAccountDetail { Iban = "IQ-1" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + } + }; + + private string Probe(IQueryable source, Filter filter) where T : class + { + try + { + FilterResult result = Guard(source).ToList(filter); + + return $"ran, {result.Data.Count} row(s)"; + } + catch (PolicyException refusal) + { + return $"REFUSED {refusal.ErrorCode}"; + } + catch (Exception other) + { + return $"threw {other.GetType().Name}"; + } + } + + private string Unguarded(Func> run) + { + try + { + return $"ran, {run().Count()} row(s)"; + } + catch (Exception other) + { + return $"threw {other.GetType().Name}"; + } + } + + [Fact] + public void A_member_of_a_JSON_owned_type_still_filters() + { + string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Meta.Author == "sajjad")); + string guarded = Probe(_db.Docs, Where("Meta.Author", "sajjad")); + + _out.WriteLine($"JSON member unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_getter_on_a_JSON_owned_type_matches_the_unguarded_failure() + { + string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Meta.IsDraft)); + string guarded = Probe(_db.Docs, Where("Meta.IsDraft", "false", DataType.Boolean)); + + _out.WriteLine($"JSON getter unguarded: {unguarded} | guarded: {guarded}"); + } + + [Fact] + public void A_member_of_an_owned_collection_still_filters() + { + string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Lines.Any(line => line.Sku == "S-1"))); + string guarded = Probe(_db.Docs, Where("Lines.Sku", "S-1")); + + _out.WriteLine($"owned collection unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_primitive_collection_still_filters() + { + string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Tags.Contains("red"))); + string guarded = Probe(_db.Docs, new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Tags", DataType = DataType.Text, + Operator = Operator.Contains, Values = { "red" } + } + } + } + }); + + _out.WriteLine($"primitive collection unguarded: {unguarded} | guarded: {guarded}"); + } + + [Fact] + public void A_TPT_subtype_column_still_filters_through_its_own_set() + { + string unguarded = Unguarded(() => _db.Dogs.Where(dog => dog.Breed == "husky")); + string guarded = Probe(_db.Dogs, Where("Breed", "husky")); + + _out.WriteLine($"TPT own set unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_TPT_subtype_column_through_OfType_still_filters() + { + string unguarded = Unguarded(() => _db.Animals.OfType().Where(dog => dog.Breed == "husky")); + string guarded = Probe(_db.Animals.OfType(), Where("Breed", "husky")); + + _out.WriteLine($"TPT OfType unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_table_split_sibling_still_filters() + { + string unguarded = Unguarded(() => _db.Accounts.Where(account => account.Detail.Iban == "IQ-1")); + string guarded = Probe(_db.Accounts, Where("Detail.Iban", "IQ-1")); + + _out.WriteLine($"table splitting unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_keyless_type_read_through_FromSql_still_filters() + { + IQueryable rows = _db.Tallies.FromSqlRaw("SELECT \"Code\" AS \"Code\", 1 AS \"Total\" FROM \"Docs\""); + + string unguarded = Unguarded(() => rows.Where(tally => tally.Code == "D-1")); + string guarded = Probe(rows, Where("Code", "D-1")); + + _out.WriteLine($"FromSql keyless unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_split_query_still_filters() + { + IQueryable rows = _db.Docs.Include(doc => doc.Lines).AsSplitQuery(); + + string unguarded = Unguarded(() => rows.Where(doc => doc.Code == "D-1")); + string guarded = Probe(rows, Where("Code", "D-1")); + + _out.WriteLine($"split query unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_string_length_over_a_JSON_member_still_filters() + { + string unguarded = Unguarded(() => _db.Docs.Where(doc => doc.Meta.Author.Length == 6)); + string guarded = Probe(_db.Docs, Where("Meta.Author.Length", "6", DataType.Number)); + + _out.WriteLine($"JSON member length unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewOverBlockProbes.cs b/DynamicWhere.Tests/Policies/ReviewOverBlockProbes.cs new file mode 100644 index 0000000..2dd78ac --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewOverBlockProbes.cs @@ -0,0 +1,875 @@ +using System.Linq.Expressions; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- model ------------------------------------------------------------------------------------------- + + public class RbText + { + public string Ar { get; set; } = string.Empty; + + public string En { get; set; } = string.Empty; + + /// A getter over two columns: no database can answer it. + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + public class RbCustomer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public RbText Title { get; set; } = new(); + + public List Orders { get; set; } = new(); + + /// Unmapped getter over mapped columns. + public string Display => $"{Name}#{Id}"; + } + + public class RbOrder + { + public int Id { get; set; } + + /// An alias whose target is a member the model does not map. + [DwAlias("ticket")] + public string AliasedLabel => $"{Code}!"; + + /// An alias over a mapped column, for the control case. + [DwAlias("ref")] + public string Reference { get; set; } = string.Empty; + + /// Denied outright, so a Selects naming it must still be refused. + [DwDenied] + public string Secret { get; set; } = string.Empty; + + public int CustomerId { get; set; } + + public RbCustomer Customer { get; set; } = null!; + + public string Code { get; set; } = string.Empty; + + public DateTime PlacedAt { get; set; } + + public DateTime? ShippedAt { get; set; } + + public decimal Amount { get; set; } + + /// Mapped with a computed column. + public decimal Doubled { get; set; } + + /// Unmapped getter, ignored in the model. + public string Label => $"{Code}/{Id}"; + } + + /// TPH. + public class RbParty + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + } + + public class RbVendor : RbParty + { + public string? Vat { get; set; } + } + + // ---- rows a caller projects -------------------------------------------------------------------------- + + public class RbRow + { + public RbRow() + { + } + + public RbRow(int id) => Id = id; + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public RbText Title { get; set; } = new(); + + public string Tag { get; set; } = string.Empty; + + public RbCustomer? Customer { get; set; } + } + + public sealed class RbContext : DbContext + { + private readonly SqliteConnection _connection; + + public RbContext(SqliteConnection connection) => _connection = connection; + + public DbSet Customers => Set(); + + public DbSet Orders => Set(); + + public DbSet Parties => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(customer => customer.Title); + model.Entity().Ignore(customer => customer.Display); + + model.Entity().Ignore(order => order.Label); + model.Entity().Ignore(order => order.AliasedLabel); + model.Entity().Property(order => order.Doubled).HasComputedColumnSql("\"Amount\" * 2"); + model.Entity().Property("Tenant"); + + model.Entity().HasDiscriminator("Discriminator") + .HasValue("party") + .HasValue("vendor"); + } + } + + /// + /// A provider layered over EF Core, the way LinqKit's AsExpandable and DelegateDecompiler's + /// Decompile are: the expression still carries EF Core's query root, and the provider + /// rewrites what EF Core alone could not translate before handing it on. + /// + public sealed class RbDecompilingProvider : IQueryable, IQueryProvider + { + private readonly IQueryProvider _inner; + + public RbDecompilingProvider(IQueryable inner) + : this(inner.Provider, inner.Expression) + { + } + + public RbDecompilingProvider(IQueryProvider inner, Expression expression) + { + _inner = inner; + Expression = expression; + } + + public Type ElementType => typeof(T); + + public Expression Expression { get; } + + public IQueryProvider Provider => this; + + public IEnumerator GetEnumerator() => + _inner.CreateQuery(Rewritten(Expression)).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + // Composition stays wrapped, as LinqKit's ExpandableQuery does: the expansion happens once, + // at execution, over the whole expression the caller built. + public IQueryable CreateQuery(Expression expression) => + (IQueryable)Activator.CreateInstance( + typeof(RbDecompilingProvider<>).MakeGenericType( + expression.Type.GetGenericArguments().Length == 1 + ? expression.Type.GetGenericArguments()[0] + : typeof(object)), + new object[] { _inner, expression }, + null)!; + + public IQueryable CreateQuery(Expression expression) => + new RbDecompilingProvider(_inner, expression); + + public object? Execute(Expression expression) => _inner.Execute(Rewritten(expression)); + + public TResult Execute(Expression expression) => _inner.Execute(Rewritten(expression)); + + /// Expands the getters EF Core cannot translate into the columns behind them. + private static Expression Rewritten(Expression expression) => new RbDecompiler().Visit(expression); + } + + /// Replaces Label and Display with the expression their getter computes. + public sealed class RbDecompiler : ExpressionVisitor + { + protected override Expression VisitMember(MemberExpression node) + { + if (node.Member.Name == "Label" && node.Member.DeclaringType == typeof(RbOrder)) + { + Expression order = Visit(node.Expression)!; + + return Expression.Call( + typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!, + Expression.Property(order, nameof(RbOrder.Code)), + Expression.Constant("/")); + } + + if (node.Member.Name == "Display" && node.Member.DeclaringType == typeof(RbCustomer)) + { + Expression customer = Visit(node.Expression)!; + + return Expression.Property(customer, nameof(RbCustomer.Name)); + } + + return base.VisitMember(node); + } + } + + public sealed class ReviewOverBlockProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly RbContext _db; + + public ReviewOverBlockProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new RbContext(_connection); + _db.Database.EnsureCreated(); + + RbCustomer customer = new() { Name = "Acme", Title = new RbText { Ar = "AR", En = "EN" } }; + + RbOrder order = new() + { + Customer = customer, + Code = "AB123", + PlacedAt = new DateTime(2024, 3, 9, 14, 30, 0), + ShippedAt = new DateTime(2024, 3, 11, 9, 0, 0), + Amount = 10m, + Reference = "R-1", + Secret = "s" + }; + + _db.Customers.Add(customer); + _db.Orders.Add(order); + _db.Entry(order).Property("Tenant").CurrentValue = "t1"; + _db.Parties.Add(new RbVendor { Kind = "vendor", Vat = "V-1" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + } + }; + + private static ConditionGroup Group(string field, string value, DataType type = DataType.Text) => new() + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + }; + + /// Runs a guarded filter and says what happened, for the probe log. + private string Probe(IQueryable source, Filter filter) where T : class + { + try + { + FilterResult result = Guard(source).ToList(filter); + + return $"ran, {result.Data.Count} row(s)"; + } + catch (PolicyException refusal) + { + return $"REFUSED {refusal.ErrorCode}"; + } + catch (Exception other) + { + return $"threw {other.GetType().Name}"; + } + } + + private string Unguarded(Func> run) + { + try + { + return $"ran, {run().Count()} row(s)"; + } + catch (Exception other) + { + return $"threw {other.GetType().Name}"; + } + } + + // ---- framework members the provider translates, on every clause ---------------------------------- + + [Theory] + [InlineData("Code.Length", "5", DataType.Number)] + [InlineData("PlacedAt.Year", "2024", DataType.Number)] + [InlineData("PlacedAt.Month", "3", DataType.Number)] + [InlineData("PlacedAt.Date", "2024-03-09", DataType.Date)] + [InlineData("ShippedAt.Value.Year", "2024", DataType.Number)] + [InlineData("Doubled", "20", DataType.Number)] + [InlineData("Customer.Name", "Acme", DataType.Text)] + [InlineData("Customer.Title.En", "EN", DataType.Text)] + public void A_translatable_member_still_filters(string field, string value, DataType type) + { + string guarded = Probe(_db.Orders, Where(field, value, type)); + + _out.WriteLine($"where {field}: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_shadow_property_is_refused_on_both_versions() + { + // Ruled out, not a finding. A shadow property has no CLR member, so the path never + // resolved: 3.2.0 refuses it with the same code, before Expresses existed. + string guarded = Probe(_db.Orders, Where("Tenant", "t1")); + + _out.WriteLine($"shadow property: {guarded}"); + + Assert.StartsWith("REFUSED", guarded); + } + + [Theory] + [InlineData("Code.Length")] + [InlineData("PlacedAt.Year")] + [InlineData("Customer.Name")] + public void A_translatable_member_still_orders(string field) + { + PolicyQueryable guarded = Guard(_db.Orders); + + Exception? failed = Record.Exception(() => guarded.Order(new OrderBy { Field = field, Direction = Direction.Ascending })); + + _out.WriteLine($"order {field}: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}"); + + Assert.Null(failed); + } + + [Theory] + [InlineData("Code.Length")] + [InlineData("PlacedAt.Year")] + public void A_translatable_member_still_groups(string field) + { + Exception? failed = Record.Exception(() => Guard(_db.Orders).Group(new GroupBy + { + Fields = new List { field }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + })); + + _out.WriteLine($"group {field}: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}"); + + Assert.Null(failed); + } + + [Fact] + public void A_summary_over_an_entity_still_aggregates_a_navigation_path() + { + Exception? failed = Record.Exception(() => Guard(_db.Orders).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Customer.Name" }, + AggregateBy = new List { new() { Alias = "Total", Field = "Id", Aggregator = Aggregator.Sumation } } + } + })); + + _out.WriteLine($"summary: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode} :: {failed?.InnerException?.GetType().Name} :: {failed?.InnerException?.Message}"); + + Assert.Null(failed); + } + + // ---- hierarchies --------------------------------------------------------------------------------- + + [Fact] + public void OfType_reaches_the_subtype_s_own_column() + { + string unguarded = Unguarded(() => _db.Parties.OfType().Where(vendor => vendor.Vat == "V-1")); + string guarded = Probe(_db.Parties.OfType(), Where("Vat", "V-1")); + + _out.WriteLine($"OfType unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void Cast_reaches_the_subtype_s_own_column() + { + string guarded = Probe(_db.Set().Cast(), Where("Vat", "V-1")); + + _out.WriteLine($"Cast guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + // ---- projections --------------------------------------------------------------------------------- + + [Fact] + public void An_anonymous_projection_still_filters() + { + var rows = _db.Orders.Select(order => new { order.Id, order.Code, Title = order.Customer.Title }); + + string unguarded = Unguarded(() => rows.Where(row => row.Code == "AB123")); + + _out.WriteLine($"anon unguarded: {unguarded}"); + + // The guarded handle needs a class, so the same shape is probed through a named row. + Assert.StartsWith("ran", unguarded); + } + + [Fact] + public void A_constructor_with_arguments_plus_an_initializer_still_filters() + { + IQueryable rows = _db.Orders.Select(order => new RbRow(order.Id) { Code = order.Code }); + + string unguarded = Unguarded(() => rows.Where(row => row.Code == "AB123")); + string guarded = Probe(rows, Where("Code", "AB123")); + + _out.WriteLine($"ctor+init unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_member_a_projection_computes_still_filters() + { + IQueryable rows = _db.Orders.Select(order => new RbRow + { + Id = order.Id, + Tag = order.Code.Substring(0, 2) + }); + + string unguarded = Unguarded(() => rows.Where(row => row.Tag == "AB")); + string guarded = Probe(rows, Where("Tag", "AB")); + + _out.WriteLine($"computed member unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_member_a_projection_assigns_conditionally_still_filters() + { + IQueryable rows = _db.Orders.Select(order => new RbRow + { + Id = order.Id, + Tag = order.Amount > 5m ? order.Code : "none" + }); + + string unguarded = Unguarded(() => rows.Where(row => row.Tag == "AB123")); + string guarded = Probe(rows, Where("Tag", "AB123")); + + _out.WriteLine($"conditional member unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_length_beneath_a_member_a_projection_copies_still_filters() + { + IQueryable rows = _db.Orders.Select(order => new RbRow { Id = order.Id, Code = order.Code }); + + string unguarded = Unguarded(() => rows.Where(row => row.Code.Length == 5)); + string guarded = Probe(rows, Where("Code.Length", "5", DataType.Number)); + + _out.WriteLine($"copied.Length unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_length_beneath_a_member_a_projection_builds_still_filters() + { + IQueryable rows = _db.Customers.Select(customer => new RbRow + { + Id = customer.Id, + Title = new RbText { Ar = customer.Title.Ar, En = customer.Title.En } + }); + + string unguarded = Unguarded(() => rows.Where(row => row.Title.En.Length == 2)); + string guarded = Probe(rows, Where("Title.En.Length", "2", DataType.Number)); + + _out.WriteLine($"built.En.Length unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_projection_of_a_navigation_still_filters() + { + IQueryable rows = _db.Orders.Select(order => order.Customer); + + string unguarded = Unguarded(() => rows.Where(customer => customer.Name == "Acme")); + string guarded = Probe(rows, Where("Name", "Acme")); + + _out.WriteLine($"Select(nav) unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void Two_selects_still_filter() + { + IQueryable rows = _db.Orders + .Select(order => new RbRow { Id = order.Id, Code = order.Code }) + .Select(row => new RbRow { Id = row.Id, Tag = row.Code }); + + string unguarded = Unguarded(() => rows.Where(row => row.Tag == "AB123")); + string guarded = Probe(rows, Where("Tag", "AB123")); + + _out.WriteLine($"two selects unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_selectmany_still_filters() + { + IQueryable rows = _db.Customers.SelectMany(customer => customer.Orders); + + string unguarded = Unguarded(() => rows.Where(order => order.Code == "AB123")); + string guarded = Probe(rows, Where("Code", "AB123")); + + _out.WriteLine($"SelectMany unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_join_still_filters() + { + IQueryable rows = _db.Orders.Join( + _db.Customers, + order => order.CustomerId, + customer => customer.Id, + (order, customer) => new RbRow { Id = order.Id, Code = order.Code, Tag = customer.Name }); + + string unguarded = Unguarded(() => rows.Where(row => row.Tag == "Acme")); + string guarded = Probe(rows, Where("Tag", "Acme")); + + _out.WriteLine($"Join unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_let_clause_still_filters() + { + IQueryable rows = + from order in _db.Orders + let tag = order.Code + select new RbRow { Id = order.Id, Tag = tag }; + + string unguarded = Unguarded(() => rows.Where(row => row.Tag == "AB123")); + string guarded = Probe(rows, Where("Tag", "AB123")); + + _out.WriteLine($"let unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_groupby_projection_still_filters() + { + IQueryable rows = _db.Orders + .GroupBy(order => order.CustomerId) + .Select(group => new RbRow { Id = group.Key, Tag = group.Count().ToString() }); + + string unguarded = Unguarded(() => rows.Where(row => row.Id == 1)); + string guarded = Probe(rows, Where("Id", "1", DataType.Number)); + + _out.WriteLine($"GroupBy unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_projection_that_copies_an_owned_member_still_filters_beneath_it() + { + IQueryable rows = _db.Customers.Select(customer => new RbRow + { + Id = customer.Id, + Title = customer.Title + }); + + string unguarded = Unguarded(() => rows.Where(row => row.Title.En == "EN")); + string guarded = Probe(rows, Where("Title.En", "EN")); + + _out.WriteLine($"copied owned unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + // ---- the wrapping provider ----------------------------------------------------------------------- + + [Fact] + public void A_decompiling_provider_over_EF_Core_is_not_held_to_EF_Core_s_model() + { + IQueryable rows = new RbDecompilingProvider(_db.Orders); + + string unguarded = Unguarded(() => rows.Where(order => order.Label == "AB123/")); + string guarded = Probe(rows, Where("Label", "AB123/")); + + _out.WriteLine($"decompiling provider unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", unguarded); + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_decompiling_provider_over_a_navigation_getter_is_not_held_to_EF_Core_s_model() + { + IQueryable rows = new RbDecompilingProvider(_db.Customers); + + string unguarded = Unguarded(() => rows.Where(customer => customer.Display == "Acme")); + string guarded = Probe(rows, Where("Display", "Acme")); + + _out.WriteLine($"decompiling nav unguarded: {unguarded} | guarded: {guarded}"); + + Assert.StartsWith("ran", unguarded); + Assert.StartsWith("ran", guarded); + } + + + [Fact] + public void The_same_wrapping_provider_is_answered_for_the_same_way_over_either_kind_of_row() + { + // Both branches ask the same question — is EF Core's own provider the one translating + // this? — so the same provider over a projection and over an entity gets the same + // answer. Until 3.3.0's review only the projection branch asked. + IQueryable rows = new RbDecompilingProvider( + _db.Customers.Select(customer => new RbRow { Id = customer.Id, Tag = customer.Name })); + + string guarded = Probe(rows, Where("Tag.Length", "4", DataType.Number)); + + _out.WriteLine($"wrapper over a projection: {guarded}"); + + IQueryable entities = new RbDecompilingProvider(_db.Orders); + + string overEntity = Probe(entities, Where("Label", "AB123/")); + + _out.WriteLine($"same wrapper over an entity: {overEntity}"); + + Assert.DoesNotContain("REFUSED", guarded); + Assert.DoesNotContain("REFUSED", overEntity); + } + + // ---- composed clauses ---------------------------------------------------------------------------- + + [Fact] + public void A_composed_chain_over_a_projection_still_runs() + { + IQueryable rows = _db.Orders.Select(order => new RbRow + { + Id = order.Id, + Code = order.Code, + Tag = order.Code.Substring(0, 2) + }); + + Exception? failed = Record.Exception(() => Guard(rows) + .Where(Group("Tag", "AB")) + .Order(new OrderBy { Field = "Code", Direction = Direction.Ascending }) + .Select(new List { "Id", "Code" }) + .Page(new PageBy { PageNumber = 1, PageSize = 10 }) + .ToList(new Filter())); + + _out.WriteLine($"composed: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}"); + + Assert.Null(failed); + } + + [Fact] + public void A_composed_select_of_a_navigation_still_runs() + { + Exception? failed = Record.Exception(() => + Guard(_db.Orders).Select(new List { "Id", "Customer" }).ToList(new Filter())); + + _out.WriteLine($"composed select nav: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode} :: {failed?.Message}"); + + // Ruled out, not a finding. Customer.Orders reaches RbOrder.Secret, which is denied, and + // 3.2.0 refuses this identically: giving the composed Select the real row shape did not + // change it. + Assert.IsType(failed); + } + + [Fact] + public void A_composed_select_of_an_owned_member_still_runs() + { + Exception? failed = Record.Exception(() => + Guard(_db.Customers).Select(new List { "Id", "Title" }).ToList(new Filter())); + + _out.WriteLine($"composed select owned: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}"); + + Assert.Null(failed); + } + + [Fact] + public void A_composed_filter_and_filterdynamic_still_run() + { + Exception? filtered = Record.Exception(() => Guard(_db.Orders).Filter(Where("Code.Length", "5", DataType.Number)).ToList(new Filter())); + Exception? dynamic_ = Record.Exception(() => Guard(_db.Orders).FilterDynamic(Where("Code.Length", "5", DataType.Number))); + + _out.WriteLine($"Filter: {filtered?.GetType().Name ?? "ran"} | FilterDynamic: {dynamic_?.GetType().Name ?? "ran"}"); + + Assert.Null(filtered); + Assert.Null(dynamic_); + } + + // ---- casing --------------------------------------------------------------------------------------- + + [Fact] + public void A_path_whose_casing_differs_still_filters() + { + string guarded = Probe(_db.Orders, Where("customer.title.en", "EN")); + + _out.WriteLine($"casing: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + + // ---- batch 2: aliases, denials, copied members, postures ------------------------------------------ + + [Fact] + public void An_alias_over_a_mapped_column_still_filters() + { + string guarded = Probe(_db.Orders, Where("ref", "R-1")); + + _out.WriteLine($"alias over column: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void An_alias_whose_target_is_unmapped_matches_the_unguarded_failure() + { + string unguarded = Unguarded(() => _db.Orders.Where(order => order.AliasedLabel == "AB123!")); + string guarded = Probe(_db.Orders, Where("ticket", "AB123!")); + + _out.WriteLine($"alias to unmapped unguarded: {unguarded} | guarded: {guarded}"); + } + + [Fact] + public void A_member_copied_from_an_unmapped_getter_matches_the_unguarded_failure() + { + IQueryable rows = _db.Customers.Select(customer => new RbRow + { + Id = customer.Id, + Tag = customer.Display + }); + + string unguarded = Unguarded(() => rows.Where(row => row.Tag.Length == 4)); + string guarded = Probe(rows, Where("Tag.Length", "4", DataType.Number)); + + _out.WriteLine($"copied-from-getter unguarded: {unguarded} | guarded: {guarded}"); + } + + [Fact] + public void A_member_copied_from_an_unmapped_getter_still_filters_on_itself() + { + IQueryable rows = _db.Customers.Select(customer => new RbRow + { + Id = customer.Id, + Tag = customer.Display + }); + + string unguarded = Unguarded(() => rows.Where(row => row.Tag == "Acme#1")); + string guarded = Probe(rows, Where("Tag", "Acme#1")); + + _out.WriteLine($"copied-from-getter itself unguarded: {unguarded} | guarded: {guarded}"); + } + + [Fact] + public void An_identity_select_is_exempted_rather_than_checked() + { + IQueryable rows = _db.Orders.Select(order => order); + + string unguarded = Unguarded(() => rows.Where(order => order.Label == "AB123/1")); + string guarded = Probe(rows, Where("Label", "AB123/1")); + + _out.WriteLine($"identity select unguarded: {unguarded} | guarded: {guarded}"); + } + + [Fact] + public void A_selects_naming_a_denied_field_is_still_refused() + { + string guarded = Probe(_db.Orders, new Filter { Selects = new List { "Id", "Secret" } }); + + _out.WriteLine($"denied select: {guarded}"); + + Assert.StartsWith("REFUSED", guarded); + } + + [Fact] + public void A_selects_naming_an_unmapped_getter_is_still_allowed() + { + string guarded = Probe(_db.Orders, new Filter { Selects = new List { "Id", "Label" } }); + + _out.WriteLine($"select unmapped getter: {guarded}"); + + Assert.StartsWith("ran", guarded); + } + + [Fact] + public void A_summary_over_a_projection_still_groups_on_an_assigned_member() + { + IQueryable rows = _db.Orders.Select(order => new RbRow { Id = order.Id, Code = order.Code }); + + Exception? failed = Record.Exception(() => Guard(rows).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + } + })); + + _out.WriteLine($"summary over projection: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode}"); + + Assert.Null(failed); + } + + [Fact] + public void A_summary_over_an_entity_still_groups_on_a_date_part() + { + Exception? failed = Record.Exception(() => Guard(_db.Orders).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "PlacedAt.Year" }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + } + })); + + _out.WriteLine($"summary date part: {failed?.GetType().Name ?? "ran"} {(failed as PolicyException)?.ErrorCode} {(failed as PolicyException)?.Message}"); + + Assert.Null(failed); + } + + // ---- what must still be refused --------------------------------------------------------------------- + + [Fact] + public void A_filter_on_an_unmapped_getter_is_still_refused() + { + string guarded = Probe(_db.Orders, Where("Label", "AB123/1")); + + _out.WriteLine($"unmapped getter: {guarded}"); + + Assert.StartsWith("REFUSED", guarded); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewPostureProbes.cs b/DynamicWhere.Tests/Policies/ReviewPostureProbes.cs new file mode 100644 index 0000000..5aed468 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewPostureProbes.cs @@ -0,0 +1,336 @@ +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Discovery; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Resolution; +using System.Reflection; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A store source of its own kind, so a second host's instance can be compared by type. + public sealed class RbNoRules : IDwPolicyProvider + { + public IReadOnlyList GetFragments(Type entityType, DwPolicyContext context) => + Array.Empty(); + } + + /// + /// DW-16, second pass: pairs of postures a reasonable host would call identical, and what + /// DwPolicy.Configure does with the second one. + /// + public sealed class ReviewPostureProbes + { + private readonly ITestOutputHelper _out; + + public ReviewPostureProbes(ITestOutputHelper output) + { + _out = output; + PolicyBootstrap.Ensure(); + } + + /// A posture holding exactly what is in force, value by value. + private static DwPolicyOptions Copy() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicyOptions copy = new() + { + Tier = inForce.Tier, + DryRun = inForce.DryRun, + IncludeTraceInResult = inForce.IncludeTraceInResult, + AuditRefusals = inForce.AuditRefusals, + StoreFailure = inForce.StoreFailure, + MaxSnapshotAge = inForce.MaxSnapshotAge, + RefreshInterval = inForce.RefreshInterval + }; + + if (!string.IsNullOrEmpty(inForce.HashSalt)) + { + copy.HashSalt = inForce.HashSalt; + } + + copy.Caps.MaxPageSize = inForce.Caps.MaxPageSize; + copy.Caps.DefaultPageSize = inForce.Caps.DefaultPageSize; + copy.Caps.MaxConditions = inForce.Caps.MaxConditions; + copy.Caps.MaxConditionDepth = inForce.Caps.MaxConditionDepth; + copy.Caps.MaxConditionSets = inForce.Caps.MaxConditionSets; + copy.Caps.MaxConditionValues = inForce.Caps.MaxConditionValues; + copy.Caps.MaxAggregates = inForce.Caps.MaxAggregates; + copy.Caps.MaxOrderFields = inForce.Caps.MaxOrderFields; + copy.Caps.MaxNavigationDepth = inForce.Caps.MaxNavigationDepth; + copy.Caps.MaxQueryCost = inForce.Caps.MaxQueryCost; + copy.Caps.DefaultFieldCost = inForce.Caps.DefaultFieldCost; + copy.Caps.MaxAuditEvents = inForce.Caps.MaxAuditEvents; + copy.Caps.SchemaDepth = inForce.Caps.SchemaDepth; + copy.Caps.SchemaCycleLimit = inForce.Caps.SchemaCycleLimit; + copy.Caps.MaxSchemaFields = inForce.Caps.MaxSchemaFields; + + if (inForce.Caps.IsMinGroupSizeSet) + { + copy.Caps.MinGroupSize = inForce.Caps.MinGroupSize; + } + + foreach (KeyValuePair exposed in inForce.Entities.Entities) + { + copy.Entities.Expose(exposed.Key, exposed.Value); + } + + return copy; + } + + private string Ask(DwPolicyOptions options, params IDwPolicyProvider[] providers) + { + try + { + DwPolicy.Configure(options, providers); + + return "accepted"; + } + catch (InvalidOperationException refused) + { + return $"REFUSED: {refused.Message.Split('.')[0]}"; + } + } + + // ---- caps written as their own default ------------------------------------------------------------ + + [Fact] + public void MinGroupSize_written_as_its_own_default_is_accepted() + { + DwPolicyOptions copy = Copy(); + + copy.Caps.MinGroupSize = DwCaps.DefaultMinGroupSize; + + string outcome = Ask(copy); + + _out.WriteLine($"MinGroupSize = {DwCaps.DefaultMinGroupSize}: {outcome}"); + + Assert.Equal("accepted", outcome); + } + + [Fact] + public void Every_other_cap_written_as_its_own_default_is_accepted() + { + DwPolicyOptions copy = Copy(); + + // Each already holds the value in force; writing it again is what a host binding the + // documented appsettings sample does. + copy.Caps.MaxPageSize = 1000; + copy.Caps.DefaultPageSize = 0; + copy.Caps.MaxConditions = 50; + copy.Caps.MaxConditionDepth = 10; + copy.Caps.MaxConditionSets = 10; + copy.Caps.MaxConditionValues = 1000; + copy.Caps.MaxAggregates = 50; + copy.Caps.MaxOrderFields = 10; + copy.Caps.MaxNavigationDepth = 4; + copy.Caps.MaxQueryCost = 1000; + copy.Caps.DefaultFieldCost = 1; + copy.Caps.MaxAuditEvents = 10_000; + copy.Caps.SchemaDepth = 2; + copy.Caps.SchemaCycleLimit = 2; + copy.Caps.MaxSchemaFields = 2000; + + string outcome = Ask(copy); + + _out.WriteLine($"every cap at its default: {outcome}"); + + Assert.Equal("accepted", outcome); + } + + // ---- the trace flag, null against the tier's own value -------------------------------------------- + + [Fact] + public void IncludeTraceInResult_written_as_the_tier_s_own_value_is_accepted() + { + DwPolicyOptions inForce = DwPolicy.Options; + + Assert.Null(inForce.IncludeTraceInResult); + + DwPolicyOptions copy = Copy(); + + // The value the tier already follows: identical in effect, value for value. + copy.IncludeTraceInResult = inForce.Tier == DwTier.Convenience; + + string outcome = Ask(copy); + + _out.WriteLine($"IncludeTraceInResult = {copy.IncludeTraceInResult} under tier {inForce.Tier}: {outcome}"); + + Assert.Equal("accepted", outcome); + } + + // ---- the hash salt --------------------------------------------------------------------------------- + + [Fact] + public void The_same_hash_salt_in_both_is_accepted() + { + DwPolicyOptions copy = Copy(); + + string outcome = Ask(copy); + + _out.WriteLine($"same salt '{DwPolicy.Options.HashSalt}': {outcome}"); + + Assert.Equal("accepted", outcome); + } + + // ---- the catalogue --------------------------------------------------------------------------------- + + [Fact] + public void A_catalogue_built_in_a_different_order_is_accepted() + { + DwPolicyOptions copy = Copy(); + DwPolicyOptions reversed = Copy(); + + // Rebuilt back to front. The pairs are the same; only the order of the Expose calls differs. + DwPolicyOptions other = new() + { + Tier = copy.Tier, + DryRun = copy.DryRun, + IncludeTraceInResult = copy.IncludeTraceInResult, + AuditRefusals = copy.AuditRefusals, + StoreFailure = copy.StoreFailure, + MaxSnapshotAge = copy.MaxSnapshotAge, + RefreshInterval = copy.RefreshInterval + }; + + foreach (KeyValuePair exposed in reversed.Entities.Entities.Reverse()) + { + other.Entities.Expose(exposed.Key, exposed.Value); + } + + string outcome = Ask(other); + + _out.WriteLine($"catalogue reversed ({other.Entities.Entities.Count} types): {outcome}"); + + Assert.Equal("accepted", outcome); + } + + [Fact] + public void A_type_exposed_under_two_names_in_a_different_order_is_compared_by_the_last_one() + { + // Not a refusal to fix on its own: it says what the two catalogues really do differ in. + // Recorded because a host that lists its aliases in a different order is a plausible + // second host, and the difference it is refused for is one Entities does not show. + DwEntityCatalog first = new(); + DwEntityCatalog second = new(); + + first.Expose("party").Expose("counterparty"); + second.Expose("counterparty").Expose("party"); + + _out.WriteLine($"first names: {string.Join(",", first.Entities.Values)} | resolves party: {first.Resolve("party") is not null}, counterparty: {first.Resolve("counterparty") is not null}"); + _out.WriteLine($"second names: {string.Join(",", second.Entities.Values)} | resolves party: {second.Resolve("party") is not null}, counterparty: {second.Resolve("counterparty") is not null}"); + + Assert.NotEqual(first.Entities[typeof(RbParty)], second.Entities[typeof(RbParty)]); + } + + // ---- the providers --------------------------------------------------------------------------------- + + [Fact] + public void The_posture_in_force_with_no_provider_is_accepted() + { + string outcome = Ask(DwPolicy.Options); + + _out.WriteLine($"Configure(DwPolicy.Options): {outcome}"); + + Assert.Equal("accepted", outcome); + } + + [Fact] + public void The_posture_in_force_with_a_provider_is_refused() + { + string outcome = Ask(DwPolicy.Options, new RbNoRules()); + + _out.WriteLine($"Configure(DwPolicy.Options, provider): {outcome}"); + + Assert.StartsWith("REFUSED", outcome); + } + + [Fact] + public void A_copy_with_a_provider_the_first_call_never_had_is_refused() + { + string outcome = Ask(Copy(), new RbNoRules()); + + _out.WriteLine($"copy + provider: {outcome}"); + + Assert.StartsWith("REFUSED", outcome); + } + + [Fact] + public void The_attribute_provider_alone_is_not_a_difference() + { + string outcome = Ask(Copy(), new AttributePolicyProvider()); + + _out.WriteLine($"copy + AttributePolicyProvider: {outcome}"); + + Assert.Equal("accepted", outcome); + } + + + // ---- DwEntityCatalog.SameAs, read directly --------------------------------------------------------- + + private static bool SameAs(DwEntityCatalog left, DwEntityCatalog right) => + (bool)typeof(DwEntityCatalog) + .GetMethod("SameAs", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(left, new object[] { right })!; + + [Fact] + public void Two_catalogues_exposing_the_same_types_under_the_same_names_are_the_same() + { + DwEntityCatalog first = new(); + DwEntityCatalog second = new(); + + first.Expose("party").Expose("order").Expose(); + second.Expose().Expose("order").Expose("party"); + + _out.WriteLine($"same types, same names, different order: {SameAs(first, second)}"); + + Assert.True(SameAs(first, second)); + } + + [Fact] + public void Two_catalogues_answering_to_the_same_names_are_refused_when_the_alias_order_differs() + { + DwEntityCatalog first = new(); + DwEntityCatalog second = new(); + + first.Expose("party").Expose("counterparty"); + second.Expose("counterparty").Expose("party"); + + // Every name resolves to the same type on both sides, so an administrative request is + // answered identically whichever one is in force. + Assert.Equal(first.Resolve("party"), second.Resolve("party")); + Assert.Equal(first.Resolve("counterparty"), second.Resolve("counterparty")); + Assert.Equal(first.Resolve(typeof(RbParty).FullName), second.Resolve(typeof(RbParty).FullName)); + + _out.WriteLine($"same names, alias order differs: SameAs = {SameAs(first, second)}"); + + // Resolve answers the same, and NameOf does not: a type exposed twice is reported under + // the last name it was given, so a schema request is answered differently by the two. + // That is a posture a second host may not hand over silently. + Assert.NotEqual(first.NameOf(typeof(RbParty)), second.NameOf(typeof(RbParty))); + Assert.False(SameAs(first, second)); + } + + [Fact] + public void Two_catalogues_that_differ_only_in_the_full_name_map_are_refused() + { + // The only way _byFullName can differ while the public names agree: two types with the + // same Type.FullName, exposed in a different order. Nothing else writes that map. + _out.WriteLine("nothing in a single assembly builds this pair; recorded as reasoned, not run"); + } + + // ---- a plain second call --------------------------------------------------------------------------- + + [Fact] + public void A_plain_copy_is_accepted() + { + string outcome = Ask(Copy()); + + _out.WriteLine($"plain copy: {outcome}"); + + Assert.Equal("accepted", outcome); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewSecondRoundProbes.cs b/DynamicWhere.Tests/Policies/ReviewSecondRoundProbes.cs new file mode 100644 index 0000000..6485400 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewSecondRoundProbes.cs @@ -0,0 +1,674 @@ +using System.Linq.Expressions; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Discovery; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// + /// A provider that is not EF Core's, is not IAsyncQueryProvider, and keeps wrapping + /// itself through composition while handing every execution to the query it wraps. + /// + /// + /// The shape LinqKit's AsExpandable and DelegateDecompiler's Decompile have: a + /// wrapper in front of EF Core, which still answers the query. + /// + public sealed class ZzWrapped : IQueryable, IQueryProvider + { + private readonly IQueryable _inner; + + public ZzWrapped(IQueryable inner) + { + _inner = inner; + Expression = inner.Expression; + } + + public Type ElementType => typeof(T); + + public Expression Expression { get; } + + public IQueryProvider Provider => this; + + public IEnumerator GetEnumerator() => _inner.Provider.CreateQuery(Expression).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + public IQueryable CreateQuery(Expression expression) => _inner.Provider.CreateQuery(expression); + + public IQueryable CreateQuery(Expression expression) => + new ZzWrapped(_inner.Provider.CreateQuery(expression)); + + public object? Execute(Expression expression) => _inner.Provider.Execute(expression); + + public TResult Execute(Expression expression) => _inner.Provider.Execute(expression); + } + + /// A value object whose text changes between reads, for the clone contract probe. + public sealed class ZzShifting + { + private int _reads; + + public int Reads => _reads; + + public override string ToString() => ++_reads == 1 ? "admin" : "root"; + } + + /// + /// Second-round probes: what the 3.3.0 fixes themselves opened. + /// + public sealed class ReviewSecondRoundProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyContext _db; + + public ReviewSecondRoundProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyContext(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { Ar = "AR", En = "Admin" } }); + _db.Parties.Add(new ZyMerchant { Kind = "merchant", Licence = "L-1", Rating = "A" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier, bool traceInResult = false) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + traceInResult + ? new DwPolicyOptions { Tier = tier, IncludeTraceInResult = true } + : new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } } + } + }; + + private IQueryable Built() => + _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + // ==== 1. the computed:false exemption where the projection is NOT last ========================== + + /// + /// The exemption's premise is "a projection is the last thing the provider builds". The + /// composable handle lets a caller put something after it. + /// + [Fact] + public void P1_Composed_select_then_order_on_the_handle() + { + PolicyQueryable projected = + Guard(Built(), DwTier.Strict).Select(new List { "Id", "Name.IsEmpty" }); + + Exception? raised = Record.Exception( + () => projected.Order(new OrderBy { Field = "Id", Direction = Direction.Ascending }) + .ToList(new Filter())); + + _out.WriteLine($"P1 select-then-order: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}"); + + Assert.True(raised is null or PolicyException, $"got {raised?.GetType().Name}: {raised?.Message}"); + } + + [Fact] + public void P2_Composed_select_then_page_on_the_handle() + { + PolicyQueryable projected = + Guard(Built(), DwTier.Strict).Select(new List { "Id", "Name.IsEmpty" }); + + Exception? raised = Record.Exception( + () => projected.Page(new PageBy { PageNumber = 1, PageSize = 10 }).ToList(new Filter())); + + _out.WriteLine($"P2 select-then-page: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}"); + + Assert.True(raised is null or PolicyException, $"got {raised?.GetType().Name}: {raised?.Message}"); + } + + [Fact] + public void P3_Composed_select_then_where_on_the_handle() + { + PolicyQueryable projected = + Guard(Built(), DwTier.Strict).Select(new List { "Id", "Name.IsEmpty" }); + + Exception? raised = Record.Exception( + () => projected.ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"P3 select-then-where: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}"); + + Assert.IsAssignableFrom(raised); + } + + /// The entity's own unmapped getter, taken through the same composable route. + [Fact] + public void P4_Composed_select_of_an_unmapped_getter_then_order() + { + PolicyQueryable projected = + Guard(_db.Roles, DwTier.Strict).Select(new List { "Id", "Display" }); + + Exception? raised = Record.Exception( + () => projected.Order(new OrderBy { Field = "Id", Direction = Direction.Ascending }) + .ToList(new Filter())); + + _out.WriteLine($"P4 getter select-then-order: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}"); + + Assert.True(raised is null or PolicyException, $"got {raised?.GetType().Name}: {raised?.Message}"); + } + + // ==== 2. RowShape._translated: a wrapper in front of EF Core ==================================== + + /// + /// A projected EF Core query behind a wrapping provider. EF Core still answers it, so the + /// refusal should be the same one a bare EF Core query gets. + /// + [Fact] + public void P5_A_wrapper_in_front_of_EF_Core_is_left_to_answer_for_its_own_rows() + { + PolicyException bare = Assert.ThrowsAny( + () => Guard(Built(), DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"P5 bare EF: {bare.ErrorCode}"); + + IQueryable wrapped = new ZzWrapped(Built()); + + Exception? raised = Record.Exception( + () => Guard(wrapped, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + Exception? unguarded = Record.Exception( + () => new ZzWrapped(Built()).Where(row => row.Name!.IsEmpty).ToList()); + + _out.WriteLine($"P5 wrapped: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}"); + _out.WriteLine($"P5 unguarded: {unguarded?.GetType().Name ?? "ran"}"); + + // A provider that wraps EF Core exists to rewrite what EF Core cannot translate, so its + // rows are left to it: the guarded query does what the unguarded one does, which for a + // pass-through wrapper is EF Core's own failure rather than a refusal. + Assert.False(raised is PolicyException, $"refused: {raised?.Message}"); + Assert.Equal(unguarded?.GetType(), raised?.GetType()); + } + + /// The same wrapper over an entity query, which the walk holds to EF Core's rules anyway. + [Fact] + public void P6_The_same_wrapper_over_an_entity_query_is_still_held_to_EF_Cores_rules() + { + IQueryable wrapped = new ZzWrapped(_db.Roles); + + Exception? raised = Record.Exception( + () => Guard(wrapped, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"P6 wrapped entity: {raised?.GetType().Name ?? "ran"} :: {raised?.Message}"); + + // Documented rule: "another provider's rules are its own, so its rows are left alone." + // If this is a PolicyException the rule is applied to one shape and not the other. + Assert.False( + raised is PolicyException, + $"the entity shape refuses what the projected shape leaves alone: {raised?.Message}"); + } + + // ==== 3. the unknown-name disguise, through the exempted clause ================================= + + /// A name that matches nothing and a field denied for select refuse alike. + [Fact] + public void P7_A_nonexistent_select_and_a_denied_select_refuse_alike() + { + PolicyException missing = Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(new Filter + { + Selects = new List { "NoSuchMemberAnywhere" } + })); + + _out.WriteLine($"P7 nonexistent: {missing.ErrorCode} field='{missing.FieldPath}' rule='{missing.RuleId}'"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, missing.ErrorCode); + } + + /// + /// The exempted clause answers an uncomputable member with rows and a nonexistent one with a + /// refusal, so the two are told apart there. + /// + [Fact] + public void P8_An_uncomputable_select_returns_rows_where_a_nonexistent_one_refuses() + { + FilterResult uncomputable = Guard(_db.Roles, DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Display" } + }); + + Assert.Single(uncomputable.Data); + + Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Displayy" } + })); + + _out.WriteLine("P8 uncomputable select returned rows; nonexistent select refused"); + } + + /// What an uncomputable leaf actually carries once the builder has skipped it. + [Fact] + public void P9_What_the_exempted_projection_puts_in_the_row() + { + FilterResult result = Guard(Built(), DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Name.IsEmpty" } + }); + + ZyRoleRow row = Assert.Single(result.Data); + + _out.WriteLine($"P9 Id={row.Id} Name.Ar='{row.Name?.Ar}' Name.En='{row.Name?.En}' IsEmpty={row.Name?.IsEmpty}"); + + // Nothing beside the asked-for leaf may come back with it. + Assert.True(string.IsNullOrEmpty(row.Name?.Ar), $"Ar leaked: '{row.Name?.Ar}'"); + Assert.True(string.IsNullOrEmpty(row.Name?.En), $"En leaked: '{row.Name?.En}'"); + } + + // ==== 4. the new trace entry, and where it can be read ========================================== + + /// + /// A strict refusal names no field. The new trace entry names the canonical path, so it must + /// not travel with the refusal. + /// + [Fact] + public void P10_The_refusal_carries_no_canonical_path_with_the_trace_switched_on() + { + PolicyQueryable guarded = Guard(_db.Roles, DwTier.Strict, traceInResult: true); + + PolicyException refusal = Assert.ThrowsAny( + () => guarded.ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine( + $"P10 code={refusal.ErrorCode} field='{refusal.FieldPath}' rule='{refusal.RuleId}' " + + $"origin='{refusal.SourceOrigin}' audit='{refusal.AuditPath}' msg='{refusal.Message}'"); + + Assert.DoesNotContain("IsEmpty", refusal.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("IsEmpty", refusal.FieldPath ?? string.Empty, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("IsEmpty", refusal.SourceOrigin ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } + + /// + /// No answered query may carry the new decision, since the clause that produces it is always + /// refused. + /// + [Fact] + public void P11_No_answered_query_carries_the_unexpressible_decision() + { + FilterResult answered = Guard(_db.Roles, DwTier.Strict, traceInResult: true) + .ToList(Where("Code", "admin")); + + Assert.NotNull(answered.Policy); + Assert.DoesNotContain( + answered.Policy!.Decisions, + decision => decision.Reason is not null && decision.Reason.Contains("cannot compute")); + + _out.WriteLine($"P11 decisions on an answered strict query: {answered.Policy.Decisions.Count}"); + } + + // ==== 5. SamePosture: the provider comparison =================================================== + + /// + /// Two policy sources of one type, holding different rules, compare equal, so a second host + /// carrying its own rule store hands it over and has it dropped. + /// + /// + /// Read through the comparison itself rather than through a second Configure call: + /// the list the first call recorded is process-wide static state, and driving it from a test + /// would make every suite configuring the assembly's posture in parallel fail. The refusal + /// side — a source where the first call supplied none — is a real second call, in + /// ReviewPostureComparisonTests. + /// + [Fact] + public void P12_Two_sources_of_one_type_compare_by_type_rather_than_by_instance() + { + MethodInfo providerTypes = typeof(DwPolicy) + .GetMethod("ProviderTypes", BindingFlags.NonPublic | BindingFlags.Static)!; + + Type[] plain = (Type[])providerTypes.Invoke( + null, new object[] { new IDwPolicyProvider[] { new FakePolicyProvider() } })!; + + Type[] denying = (Type[])providerTypes.Invoke( + null, + new object[] + { + new IDwPolicyProvider[] + { + new FakePolicyProvider() + .Add("Salary", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicTenant) + } + })!; + + _out.WriteLine($"P12 an empty source and a denying one compare as: {string.Join(", ", denying.Select(type => type.Name))}"); + + Assert.Equal(plain, denying); + } + + /// + /// Two catalogues that compare equal must answer every administrative lookup alike. + /// + [Fact] + public void P13_Two_catalogues_comparing_equal_resolve_alike() + { + DwEntityCatalogProbe.AssertEqualCataloguesResolveAlike(_out); + } + + // ==== 6. public Clone, and what a caller can still change under the guard ======================= + + /// + /// Clone's contract says the copy shares no object with the original. + /// + [Fact] + public void P14_The_clone_copies_every_node_and_shares_the_values_the_caller_supplied() + { + object value = new ZzShifting(); + + Filter original = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { value } + } + } + } + }; + + Filter copy = original.Clone(); + + _out.WriteLine( + "P14 values list same: " + + ReferenceEquals(original.ConditionGroup!.Conditions[0].Values, copy.ConditionGroup!.Conditions[0].Values) + + "; value element same: " + + ReferenceEquals(original.ConditionGroup.Conditions[0].Values[0], copy.ConditionGroup.Conditions[0].Values[0])); + + // The list is the request's own node and is copied; what the caller put in it is the + // caller's, decoded from JSON and never written to, so it is the same object. + Assert.NotSame(original.ConditionGroup.Conditions[0].Values, copy.ConditionGroup.Conditions[0].Values); + Assert.NotSame(original.ConditionGroup.Conditions[0], copy.ConditionGroup.Conditions[0]); + Assert.Same( + original.ConditionGroup.Conditions[0].Values[0], + copy.ConditionGroup.Conditions[0].Values[0]); + } + + /// + /// A caller editing their own request after the guard has read it must not change what runs. + /// + [Fact] + public void P15_Editing_the_request_after_the_guard_read_it_changes_nothing() + { + Filter filter = Where("Code", "admin"); + + PolicyQueryable guarded = Guard(_db.Roles, DwTier.Strict); + + // The guard clones, so the edit below lands on the caller's object alone. + FilterResult first = guarded.ToList(filter); + + filter.ConditionGroup!.Conditions[0].Field = "NoSuchMember"; + filter.ConditionGroup.Conditions[0].Values[0] = "nothing"; + + Assert.Single(first.Data); + + Exception? raised = Record.Exception(() => guarded.ToList(filter)); + + _out.WriteLine($"P15 after the edit: {raised?.GetType().Name ?? "ran"}"); + + Assert.IsAssignableFrom(raised); + } + + /// + /// A value whose text changes between reads. The gate must decide on the same text the query + /// runs on, or a caller passes a check with one value and queries with another. + /// + [Fact] + public void P16_A_value_is_read_more_than_once_so_an_unstable_one_is_not_the_one_queried() + { + ZzShifting shifting = new(); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { shifting } + } + } + } + }; + + FilterResult result = Guard(_db.Roles, DwTier.Strict).ToList(filter); + + _out.WriteLine($"P16 reads={shifting.Reads} rows={result.Data.Count}"); + + // A condition's value is read once to validate its format and again to build the + // predicate, so a value whose ToString answers differently each time is validated as one + // value and queried as another. Documented as a limit: pass values that do not change. + // The policy layer reads no value's content — only Values.Count — so nothing it decides + // rests on which read won. + Assert.True(shifting.Reads > 1, $"the value was read {shifting.Reads} times"); + } + + // ==== 7. the new trace entry cannot travel to a later answered query ============================ + + /// + /// The refusal writes the canonical path onto the handle's trace before it throws. A query + /// answered afterwards on the same handle must not carry it out with the result. + /// + [Fact] + public void P17_A_refused_call_leaves_nothing_on_the_next_answered_one() + { + PolicyQueryable guarded = Guard(_db.Roles, DwTier.Strict, traceInResult: true); + + Assert.ThrowsAny( + () => guarded.ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + Assert.Contains( + guarded.LastTrace!.Decisions, + decision => decision.Reason is not null && decision.Reason.Contains("cannot compute")); + + FilterResult answered = guarded.ToList(Where("Code", "admin")); + + _out.WriteLine($"P17 answered decisions: {answered.Policy?.Decisions.Count}"); + + Assert.NotNull(answered.Policy); + Assert.DoesNotContain( + answered.Policy!.Decisions, + decision => decision.Reason is not null && decision.Reason.Contains("cannot compute")); + } + + /// + /// The shape the library's own guarded Select leaves behind, read back through the + /// same walk the gate uses. + /// + [Fact] + public void P18_The_shape_the_guarded_select_leaves_cannot_answer_for_its_own_path() + { + IQueryable once = Built(); + IQueryable twice = + Guard(once, DwTier.Strict).Select(new List { "Id", "Name.IsEmpty" }).AsUnguardedQueryable(); + + MethodInfo of = typeof(RowShape) + .GetMethod("Of", BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public)! + .MakeGenericMethod(typeof(ZyRoleRow)); + + MethodInfo expresses = typeof(RowShape) + .GetMethod("Expresses", BindingFlags.NonPublic | BindingFlags.Instance)!; + + object first = of.Invoke(null, new object[] { once })!; + object second = of.Invoke(null, new object[] { twice })!; + + object? before = expresses.Invoke(first, new object[] { "Name.IsEmpty" }); + object? after = expresses.Invoke(second, new object[] { "Name.IsEmpty" }); + + _out.WriteLine($"P18 Expresses before the guarded Select: {before?.ToString() ?? "null"}"); + _out.WriteLine($"P18 Expresses after the guarded Select: {after?.ToString() ?? "null"}"); + + Assert.Equal(before, after); + } + + /// + /// The simulator answers the same question the query answers, or an operator reading it is + /// told a request would be allowed that the query refuses. + /// + [Fact] + public void P19_The_simulator_cannot_answer_for_a_path_the_query_cannot_compute() + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + PolicySimulation simulated = PolicySimulator.Simulate( + Where("Name.IsEmpty", "false", DataType.Boolean), + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + bool refusedByQuery = Record.Exception( + () => Guard(_db.Roles, DwTier.Strict) + .ToList(Where("Name.IsEmpty", "false", DataType.Boolean))) is PolicyException; + + _out.WriteLine($"P19 simulator refused: {(simulated.Refusal is not null)}; query refused: {refusedByQuery}"); + + // The simulator is handed a type and a request, never a source, so it cannot read the + // model a query would be translated against. It answers every other refusal; this one it + // cannot, and the documentation says so rather than the simulator guessing. + Assert.True(refusedByQuery); + Assert.Null(simulated.Refusal); + } + + /// + /// A segment's projection really is the last thing built, which is what the exemption rests + /// on there. + /// + [Fact] + public async Task P20_A_segments_exempted_projection_is_answered_rather_than_failing() + { + SegmentResult result = await Guard(_db.Roles, DwTier.Strict).ToListAsync(new Segment + { + Selects = new List { "Id", "Display" }, + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "admin" } + } + } + } + } + } + }); + + _out.WriteLine($"P20 segment rows: {result.Data.Count}"); + + Assert.Single(result.Data); + } + + /// + /// A caller's own projection that assigns the shared type through a conditional rather than + /// a bare initializer. The same member, the same database, the same tier. + /// + [Fact] + public void P21_A_conditional_assignment_loses_the_refusal() + { + IQueryable plain = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + IQueryable conditional = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Name = role.Code == null + ? new ZyLocalizedText() + : new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + Exception? bare = Record.Exception( + () => Guard(plain, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + Exception? shaped = Record.Exception( + () => Guard(conditional, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"P21 plain initializer: {bare?.GetType().Name}"); + _out.WriteLine($"P21 conditional: {shaped?.GetType().Name}"); + + Assert.IsAssignableFrom(bare); + Assert.IsAssignableFrom(shaped); + } + } + + /// Catalogue comparison, kept out of the probe class so the assertion reads plainly. + internal static class DwEntityCatalogProbe + { + internal static void AssertEqualCataloguesResolveAlike(ITestOutputHelper output) + { + DwPolicyOptions left = new(); + DwPolicyOptions right = new(); + + // The same type, reached under names differing only in case, exposed in two orders. + left.Entities.Expose("role").Expose("Role"); + right.Entities.Expose("Role"); + + MethodInfo same = typeof(DwEntityCatalog).GetMethod( + "SameAs", BindingFlags.NonPublic | BindingFlags.Instance)!; + + bool equal = (bool)same.Invoke(left.Entities, new object[] { right.Entities })!; + + output.WriteLine($"P13 catalogues compare equal: {equal}"); + + if (!equal) + { + return; + } + + foreach (string asked in new[] { "role", "Role", "ROLE", typeof(ZyRole).FullName! }) + { + Assert.Equal(left.Entities.Resolve(asked), right.Entities.Resolve(asked)); + } + + Assert.Equal(left.Entities.NameOf(typeof(ZyRole)), right.Entities.NameOf(typeof(ZyRole))); + Assert.Equal(left.Entities.ToArray().Length, right.Entities.ToArray().Length); + } + } +} From bc21ca6a516b526a73c432cac92db58d81a61bca Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 14:30:45 +0300 Subject: [PATCH 15/54] fix(policies): EF Core's own provider is that type, and a member read from nothing is claimed as nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third review found the base-type walk in the provider test catching the very thing the test exists to exclude. A provider built by deriving from EF Core's own rewrites what EF Core cannot translate exactly as one built by wrapping it does, and was held to EF Core's model while the wrapping one was left alone. EF Core's own provider derives from object in 6, 7, 8, 9 and 10, and a DbSet's queryable carries that exact type through every operator, so an exact comparison matches every EF Core query the walk matched and stops matching a subclass. A projected member assigned from something the shape cannot read — a method call, a captured value, a subquery, two branches building it two ways — had its name recorded by the level above it, so the shape answered "yes, the query can express this" for a path no database computes. Only a proven no is acted on, so no query behaves differently; the shape now says it cannot say, rather than making a claim it cannot support. The review's probes are kept, including the one that drives EF Core's own in-memory provider: it is EF Core's provider, so its rows are EF Core's to speak for, and the getter fails there as it fails in SQLite. A probe adopted in round 2 drove DwPolicy's static provider list by reflection, which refused the bootstrap of any suite configuring the posture in parallel — about one floor-leg run in eight. It reads the comparison key directly now. Co-Authored-By: Claude Opus 5 --- DynamicWhere.Tests/DynamicWhere.Tests.csproj | 4 + .../Policies/DocsReview33Probe.cs | 72 +- .../Policies/ReviewThirdRoundProbes.cs | 1076 ++++++++++++++++ .../Policies/Rv3InMemoryProviderProbe.cs | 172 +++ .../Policies/Rv3SecurityProbes.cs | 1083 +++++++++++++++++ DynamicWhere.ex/Policies/Source/RowShape.cs | 42 +- 6 files changed, 2395 insertions(+), 54 deletions(-) create mode 100644 DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Rv3InMemoryProviderProbe.cs create mode 100644 DynamicWhere.Tests/Policies/Rv3SecurityProbes.cs diff --git a/DynamicWhere.Tests/DynamicWhere.Tests.csproj b/DynamicWhere.Tests/DynamicWhere.Tests.csproj index 29e17f9..6a5adfa 100644 --- a/DynamicWhere.Tests/DynamicWhere.Tests.csproj +++ b/DynamicWhere.Tests/DynamicWhere.Tests.csproj @@ -30,6 +30,10 @@ + + diff --git a/DynamicWhere.Tests/Policies/DocsReview33Probe.cs b/DynamicWhere.Tests/Policies/DocsReview33Probe.cs index 6c20f0c..9b0b9b0 100644 --- a/DynamicWhere.Tests/Policies/DocsReview33Probe.cs +++ b/DynamicWhere.Tests/Policies/DocsReview33Probe.cs @@ -598,11 +598,12 @@ public QpPostureProbe(ITestOutputHelper output) private static readonly MethodInfo SamePosture = typeof(DwPolicy) .GetMethod("SamePosture", BindingFlags.Static | BindingFlags.NonPublic)!; - private static readonly MethodInfo SameProviders = typeof(DwPolicy) - .GetMethod("SameProviders", BindingFlags.Static | BindingFlags.NonPublic)!; + private static readonly MethodInfo ProviderKinds = typeof(DwPolicy) + .GetMethod("ProviderTypes", BindingFlags.Static | BindingFlags.NonPublic)!; - private static readonly FieldInfo ProviderTypes = typeof(DwPolicy) - .GetField("_providerTypes", BindingFlags.Static | BindingFlags.NonPublic)!; + /// The comparison key a list of sources reduces to, which is what a second call is compared by. + private static Type[] Kinds(params IDwPolicyProvider[] providers) => + (Type[])ProviderKinds.Invoke(null, new object?[] { providers })!; private static bool Same(DwPolicyOptions asked, params IDwPolicyProvider[] providers) => (bool)SamePosture.Invoke(null, new object?[] { DwPolicy.Options, asked, providers })!; @@ -808,41 +809,34 @@ bool Ask(DwEntityCatalog left, DwEntityCatalog right) => [Fact] public void The_provider_rules_hold_type_by_type() { - Type[] saved = (Type[])ProviderTypes.GetValue(null)!; - - try - { - ProviderTypes.SetValue(null, new[] { typeof(QpProviderA), typeof(QpProviderB) }); - - bool Ask(params IDwPolicyProvider[] asked) => - (bool)SameProviders.Invoke(null, new object?[] { asked })!; - - bool sameKinds = Ask(new QpProviderA(), new QpProviderB()); - bool reordered = Ask(new QpProviderB(), new QpProviderA()); - bool dropped = Ask(new QpProviderA()); - bool added = Ask(new QpProviderA(), new QpProviderB(), new QpProviderA()); - bool attributeIgnored = Ask(new AttributePolicyProvider(), new QpProviderA(), new QpProviderB()); - bool none = Ask(); - - _out.WriteLine($"in force = [QpProviderA, QpProviderB]"); - _out.WriteLine($" same kinds, new instances -> {sameKinds}"); - _out.WriteLine($" reordered -> {reordered}"); - _out.WriteLine($" one dropped -> {dropped}"); - _out.WriteLine($" one added -> {added}"); - _out.WriteLine($" AttributePolicyProvider prefixed -> {attributeIgnored}"); - _out.WriteLine($" none supplied -> {none}"); - - Assert.True(sameKinds); - Assert.False(reordered); - Assert.False(dropped); - Assert.False(added); - Assert.True(attributeIgnored); - Assert.False(none); - } - finally - { - ProviderTypes.SetValue(null, saved); - } + // Read through the comparison key rather than by driving DwPolicy's static list: that + // list is process-wide, and a test holding it hostage refuses every suite configuring + // the assembly's posture in parallel. + Type[] inForce = Kinds(new QpProviderA(), new QpProviderB()); + + bool Ask(params IDwPolicyProvider[] asked) => inForce.SequenceEqual(Kinds(asked)); + + bool sameKinds = Ask(new QpProviderA(), new QpProviderB()); + bool reordered = Ask(new QpProviderB(), new QpProviderA()); + bool dropped = Ask(new QpProviderA()); + bool added = Ask(new QpProviderA(), new QpProviderB(), new QpProviderA()); + bool attributeIgnored = Ask(new AttributePolicyProvider(), new QpProviderA(), new QpProviderB()); + bool none = Ask(); + + _out.WriteLine($"in force = [QpProviderA, QpProviderB]"); + _out.WriteLine($" same kinds, new instances -> {sameKinds}"); + _out.WriteLine($" reordered -> {reordered}"); + _out.WriteLine($" one dropped -> {dropped}"); + _out.WriteLine($" one added -> {added}"); + _out.WriteLine($" AttributePolicyProvider prefixed -> {attributeIgnored}"); + _out.WriteLine($" none supplied -> {none}"); + + Assert.True(sameKinds); + Assert.False(reordered); + Assert.False(dropped); + Assert.False(added); + Assert.True(attributeIgnored); + Assert.False(none); } [Fact] diff --git a/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs b/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs new file mode 100644 index 0000000..7569f43 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs @@ -0,0 +1,1076 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq.Expressions; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query.Internal; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 3 over-block probes. EF Core 6 compatible on purpose, so the floor leg runs them too: + // no complex properties, no ToJson, no primitive collections, no DateOnly/TimeOnly. + // + // Every probe runs the shape BOTH unguarded and guarded. The finding rule is one line: + // unguarded ran AND guarded REFUSED -> over-block. + // ============================================================================================= + + public class R3Money + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = "USD"; + + /// A getter over two owned columns: no database computes it. + public bool IsZero => Amount == 0m; + } + + public class R3Customer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? Nickname { get; set; } + + public List Orders { get; set; } = new(); + + [NotMapped] + public string Handle => Name + "!"; + } + + public class R3Line + { + public int Id { get; set; } + + public int OrderId { get; set; } + + public decimal Price { get; set; } + + /// Unmapped on the entity; the projected row type assigns a member of the same name. + [NotMapped] + public string Label => Price + "!"; + } + + public class R3Order + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string? Note { get; set; } + + public int Qty { get; set; } + + public int CustomerId { get; set; } + + public R3Customer Customer { get; set; } = null!; + + public R3Money Total { get; set; } = new(); + + public List Lines { get; set; } = new(); + + [NotMapped] + public string Slug => Code + "-" + Id; + } + + // ---- row types a caller projects into -------------------------------------------------------- + + public class R3Nest + { + public string A { get; set; } = string.Empty; + + public string B { get; set; } = string.Empty; + + public R3Nest? Deep { get; set; } + + public R3Money Money { get; set; } = new(); + } + + public class R3LineRow + { + public decimal Price { get; set; } + + public string Label { get; set; } = string.Empty; + } + + public class R3Row + { + public R3Row() + { + } + + public R3Row(int id) => Id = id; + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string? Tag { get; set; } + + public R3Nest Nest { get; set; } = new(); + + public R3Money Money { get; set; } = new(); + + public R3Customer? Customer { get; set; } + + public List Lines { get; set; } = new(); + } + + public sealed class R3Context : DbContext + { + private readonly SqliteConnection _connection; + + public R3Context(SqliteConnection connection) => _connection = connection; + + public DbSet Orders => Set(); + + public DbSet Customers => Set(); + + public DbSet Lines => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(order => order.Total); + model.Entity().Ignore(order => order.Slug); + model.Entity().Ignore(customer => customer.Handle); + model.Entity().Ignore(line => line.Label); + } + } + + /// + /// Rewrites the entity's own unmapped getters into expressions EF Core translates, and does it + /// from a provider that derives from EF Core's own rather than wrapping it. That is the + /// shape EfCoreOwns's base-type walk cannot tell from EF Core itself. + /// + internal sealed class R3ExpandingVisitor : ExpressionVisitor + { + protected override Expression VisitMember(MemberExpression node) + { + if (node.Member.Name == nameof(R3Order.Slug) && node.Member.DeclaringType == typeof(R3Order)) + { + Expression order = Visit(node.Expression)!; + + return Expression.Call( + typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!, + Expression.Property(order, nameof(R3Order.Code)), + Expression.Constant("-x")); + } + + if (node.Member.Name == nameof(R3Customer.Handle) && node.Member.DeclaringType == typeof(R3Customer)) + { + return Expression.Property(Visit(node.Expression)!, nameof(R3Customer.Name)); + } + + return base.VisitMember(node); + } + } + + /// A provider that derives from EF Core's own and rewrites before handing over. + internal sealed class R3DerivedProvider : EntityQueryProvider + { + public R3DerivedProvider(IQueryCompiler compiler) + : base(compiler) + { + } + + public override IQueryable CreateQuery(Expression expression) => + base.CreateQuery(new R3ExpandingVisitor().Visit(expression)!); + + public override object? Execute(Expression expression) => + base.Execute(new R3ExpandingVisitor().Visit(expression)!); + + public override TResult Execute(Expression expression) => + base.Execute(new R3ExpandingVisitor().Visit(expression)!); + } + + public sealed class ReviewThirdRoundProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly R3Context _db; + private readonly List _findings = new(); + + public ReviewThirdRoundProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new R3Context(_connection); + _db.Database.EnsureCreated(); + + R3Customer customer = new() { Name = "Acme", Nickname = "A" }; + + R3Order order = new() + { + Code = "AB123", + Note = "n", + Qty = 2, + Customer = customer, + Total = new R3Money { Amount = 10m, Currency = "USD" } + }; + + order.Lines.Add(new R3Line { Price = 4m }); + order.Lines.Add(new R3Line { Price = 6m }); + + _db.Customers.Add(customer); + _db.Orders.Add(order); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness --------------------------------------------------------------------------- + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + private static string Short(string message) + { + string one = message.Replace(Environment.NewLine, " "); + + return one.Length > 90 ? one[..90] : one; + } + + private static string Guarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = Guard(source).ToList(Where(field, type == DataType.Number ? value : value, type)); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return $"{failure.GetType().Name}"; + } + } + + private static string Raw(Func query) + { + try + { + object value = query(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (Exception failure) + { + return $"{failure.GetType().Name}"; + } + } + + /// Records one probe line, and flags it when the guard refuses what the query runs. + private void Case(string probe, string unguarded, string guarded) + { + _out.WriteLine($"{probe,-58} unguarded={unguarded,-28} guarded={guarded}"); + + if (unguarded.StartsWith("OK", StringComparison.Ordinal) + && guarded.StartsWith("REFUSED", StringComparison.Ordinal)) + { + _findings.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + /// + /// A probe whose refusal is a standing limit rather than this branch's doing: the same file + /// run against 3.2.0 (commit a7b06e1) refuses it identically, so nothing here regressed it. + /// Logged, never flagged. + /// + private void Known(string probe, string unguarded, string guarded) => + _out.WriteLine($"{probe,-58} unguarded={unguarded,-28} guarded={guarded} [same on 3.2.0]"); + + private void Done() => Assert.True(_findings.Count == 0, string.Join(" || ", _findings)); + + // ========================================================================================= + // R3-A. The member a projection's initializer does not assign at all. + // ========================================================================================= + + [Fact] + public void R3_A_A_member_the_initializer_never_assigns() + { + IQueryable rows = _db.Orders.Select(order => new R3Row { Id = order.Id }); + + Case("A1 unassigned scalar member", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id }).Where(r => r.Code == "AB123").ToList()), + Guarded(rows, "Code", DataType.Text, "AB123")); + + Case("A2 framework member beneath an unassigned member", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id }).Where(r => r.Code.Length == 5).ToList()), + Guarded(rows, "Code.Length", DataType.Number, "5")); + + Case("A3 assigned member (control)", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id }).Where(r => r.Id == 1).ToList()), + Guarded(rows, "Id", DataType.Number, "1")); + + Done(); + } + + // ========================================================================================= + // R3-B. A nested initializer: the sibling member it does not assign. + // ========================================================================================= + + [Fact] + public void R3_B_A_nested_initializer_and_the_sibling_it_leaves_out() + { + IQueryable rows = _db.Orders.Select(order => new R3Row + { + Id = order.Id, + Nest = new R3Nest { A = order.Code } + }); + + Case("B1 nested member the initializer assigns (control)", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = new R3Nest { A = o.Code } }) + .Where(r => r.Nest.A == "AB123").ToList()), + Guarded(rows, "Nest.A", DataType.Text, "AB123")); + + Case("B2 nested sibling the initializer leaves out", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = new R3Nest { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()), + Guarded(rows, "Nest.B", DataType.Text, "x")); + + Case("B3 framework member beneath the sibling left out", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = new R3Nest { A = o.Code } }) + .Where(r => r.Nest.B.Length == 1).ToList()), + Guarded(rows, "Nest.B.Length", DataType.Number, "1")); + + Done(); + } + + // ========================================================================================= + // R3-C. Values ReadValue cannot read: coalesce, cast, concatenation, a method call. + // Each one must leave the path ALONE (null), never refuse it. + // ========================================================================================= + + [Fact] + public void R3_C_A_value_the_shape_cannot_read_is_left_alone() + { + IQueryable coalesced = _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Note ?? o.Code }); + IQueryable concatenated = _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Code + "-" + o.Qty }); + IQueryable converted = _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = (string)o.Code }); + IQueryable called = _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Code.ToUpper() }); + IQueryable ternaryScalar = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Tag = o.Qty > 1 ? o.Code : o.Note + }); + + Case("C1 ?? assignment, the member itself", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Note ?? o.Code }) + .Where(r => r.Tag == "n").ToList()), + Guarded(coalesced, "Tag", DataType.Text, "n")); + + Case("C2 ?? assignment, a framework member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Note ?? o.Code }) + .Where(r => r.Tag!.Length == 1).ToList()), + Guarded(coalesced, "Tag.Length", DataType.Number, "1")); + + Case("C3 concatenation, a framework member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Code + "-" + o.Qty }) + .Where(r => r.Tag!.Length == 7).ToList()), + Guarded(concatenated, "Tag.Length", DataType.Number, "7")); + + Case("C4 cast, a framework member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = (string)o.Code }) + .Where(r => r.Tag!.Length == 5).ToList()), + Guarded(converted, "Tag.Length", DataType.Number, "5")); + + Case("C5 method call, a framework member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Code.ToUpper() }) + .Where(r => r.Tag!.Length == 5).ToList()), + Guarded(called, "Tag.Length", DataType.Number, "5")); + + Case("C6 scalar ternary, a framework member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Tag = o.Qty > 1 ? o.Code : o.Note }) + .Where(r => r.Tag!.Length == 5).ToList()), + Guarded(ternaryScalar, "Tag.Length", DataType.Number, "5")); + + Done(); + } + + // ========================================================================================= + // R3-D. Conditionals around a NESTED initializer: the branch shapes ReadValue now reads. + // ========================================================================================= + + [Fact] + public void R3_D_A_conditional_around_a_nested_initializer() + { + IQueryable emptyBranch = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Nest = o.Note == null ? new R3Nest() : new R3Nest { A = o.Code } + }); + + IQueryable nullBranch = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Nest = o.Note == null ? null! : new R3Nest { A = o.Code } + }); + + IQueryable bothAssign = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Nest = o.Note == null ? new R3Nest { B = o.Code } : new R3Nest { A = o.Code } + }); + + IQueryable opaqueBranch = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Nest = o.Note == null ? new R3Nest { A = o.Code } : new R3Nest { A = o.Code.ToUpper() } + }); + + Case("D1 empty branch + assigning branch, the assigned member", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = o.Note == null ? new R3Nest() : new R3Nest { A = o.Code } }) + .Where(r => r.Nest.A == "AB123").ToList()), + Guarded(emptyBranch, "Nest.A", DataType.Text, "AB123")); + + Case("D2 empty branch + assigning branch, the member neither sets", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = o.Note == null ? new R3Nest() : new R3Nest { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()), + Guarded(emptyBranch, "Nest.B", DataType.Text, "x")); + + Case("D3 null branch + assigning branch, the assigned member", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = o.Note == null ? null! : new R3Nest { A = o.Code } }) + .Where(r => r.Nest.A == "AB123").ToList()), + Guarded(nullBranch, "Nest.A", DataType.Text, "AB123")); + + Case("D4 null branch + assigning branch, the member it does not set", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Nest = o.Note == null ? null! : new R3Nest { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()), + Guarded(nullBranch, "Nest.B", DataType.Text, "x")); + + Case("D5 two branches assigning two different members, one of them", + Raw(() => _db.Orders + .Select(o => new R3Row + { + Id = o.Id, + Nest = o.Note == null ? new R3Nest { B = o.Code } : new R3Nest { A = o.Code } + }) + .Where(r => r.Nest.A == "AB123").ToList()), + Guarded(bothAssign, "Nest.A", DataType.Text, "AB123")); + + Case("D6 both branches build in place, a member neither sets", + Raw(() => _db.Orders + .Select(o => new R3Row + { + Id = o.Id, + Nest = o.Note == null ? new R3Nest { A = o.Code } : new R3Nest { A = o.Code.ToUpper() } + }) + .Where(r => r.Nest.B == "x").ToList()), + Guarded(opaqueBranch, "Nest.B", DataType.Text, "x")); + + Done(); + } + + // ========================================================================================= + // R3-E. A constructor with arguments, and the levels beneath it. + // ========================================================================================= + + [Fact] + public void R3_E_A_constructor_with_arguments_and_what_is_beneath_it() + { + IQueryable built = _db.Orders.Select(o => new R3Row(o.Id) { Code = o.Code }); + IQueryable nestedUnderCtor = _db.Orders.Select(o => new R3Row(o.Id) + { + Nest = new R3Nest { A = o.Code } + }); + + Case("E1 ctor arg member", + Raw(() => _db.Orders.Select(o => new R3Row(o.Id) { Code = o.Code }).Where(r => r.Id == 1).ToList()), + Guarded(built, "Id", DataType.Number, "1")); + + Case("E2 member the ctor may have set, never in the initializer", + Raw(() => _db.Orders.Select(o => new R3Row(o.Id) { Code = o.Code }).Where(r => r.Tag == "x").ToList()), + Guarded(built, "Tag", DataType.Text, "x")); + + Case("E3 nested initializer under a ctor, the assigned member", + Raw(() => _db.Orders.Select(o => new R3Row(o.Id) { Nest = new R3Nest { A = o.Code } }) + .Where(r => r.Nest.A == "AB123").ToList()), + Guarded(nestedUnderCtor, "Nest.A", DataType.Text, "AB123")); + + Case("E4 nested initializer under a ctor, the member it leaves out", + Raw(() => _db.Orders.Select(o => new R3Row(o.Id) { Nest = new R3Nest { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()), + Guarded(nestedUnderCtor, "Nest.B", DataType.Text, "x")); + + Done(); + } + + // ========================================================================================= + // R3-F. Members copied from the entity, and EF Core's own owned-type rewriting. + // ========================================================================================= + + [Fact] + public void R3_F_A_copied_member_and_an_owned_type() + { + IQueryable copiedOwned = _db.Orders.Select(o => new R3Row { Id = o.Id, Money = o.Total }); + IQueryable builtOwned = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Money = new R3Money { Amount = o.Total.Amount } + }); + IQueryable copiedNav = _db.Orders.Select(o => new R3Row { Id = o.Id, Customer = o.Customer }); + + Case("F1 owned member copied whole, a mapped member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Money = o.Total }) + .Where(r => r.Money.Amount == 10m).ToList()), + Guarded(copiedOwned, "Money.Amount", DataType.Number, "10")); + + Case("F2 owned member copied whole, the getter over its columns", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Money = o.Total }) + .Where(r => r.Money.IsZero).ToList()), + Guarded(copiedOwned, "Money.IsZero", DataType.Boolean, "false")); + + Case("F3 owned member built in place, the member it assigns", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Money = new R3Money { Amount = o.Total.Amount } }) + .Where(r => r.Money.Amount == 10m).ToList()), + Guarded(builtOwned, "Money.Amount", DataType.Number, "10")); + + Case("F4 owned member built in place, the member it leaves out", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Money = new R3Money { Amount = o.Total.Amount } }) + .Where(r => r.Money.Currency == "USD").ToList()), + Guarded(builtOwned, "Money.Currency", DataType.Text, "USD")); + + Case("F5 navigation copied whole, a mapped member beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Customer = o.Customer }) + .Where(r => r.Customer!.Name == "Acme").ToList()), + Guarded(copiedNav, "Customer.Name", DataType.Text, "Acme")); + + Case("F6 navigation copied whole, the unmapped getter beneath it", + Raw(() => _db.Orders.Select(o => new R3Row { Id = o.Id, Customer = o.Customer }) + .Where(r => r.Customer!.Handle == "Acme!").ToList()), + Guarded(copiedNav, "Customer.Handle", DataType.Text, "Acme!")); + + Done(); + } + + // ========================================================================================= + // R3-G. A subquery collection: MemberChain strips ToList/Select, so the member is recorded + // as COPIED from the entity's navigation. What is beneath it is then read off the + // ENTITY's element type, not the row type the subquery actually builds. + // ========================================================================================= + + [Fact] + public void R3_G_A_subquery_collection_is_read_through_the_entity() + { + IQueryable withLines = _db.Orders.Select(o => new R3Row + { + Id = o.Id, + Lines = o.Lines.Select(line => new R3LineRow { Price = line.Price, Label = line.Price + "!" }).ToList() + }); + + // Refused on 3.2.0 too, by the same standing collection-path limit as K2/K4. What this + // probe pins is that the new walk did not make it worse: MemberChain strips ToList and + // Select, so the member is recorded as COPIED from o.Lines and the rest of the path is + // read off the ENTITY's element type rather than R3LineRow. Nothing reaches that today + // because the path is refused earlier; it is the shape to re-probe if the limit lifts. + Known("G1 Count of a subquery-built collection", + Raw(() => _db.Orders + .Select(o => new R3Row + { + Id = o.Id, + Lines = o.Lines.Select(l => new R3LineRow { Price = l.Price, Label = l.Price + "!" }).ToList() + }) + .Where(r => r.Lines.Count == 2).ToList()), + Guarded(withLines, "Lines.Count", DataType.Number, "2")); + + Done(); + } + + // ========================================================================================= + // R3-H. A second projection whose members come from the first projection's members. + // ========================================================================================= + + [Fact] + public void R3_H_A_member_assigned_from_another_projected_member() + { + IQueryable twice = _db.Orders + .Select(o => new R3Row { Id = o.Id, Code = o.Code }) + .Select(r => new R3Row { Id = r.Id, Nest = new R3Nest { A = r.Code } }); + + IQueryable copiedAcross = _db.Orders + .Select(o => new R3Row { Id = o.Id, Customer = o.Customer }) + .Select(r => new R3Row { Id = r.Id, Customer = r.Customer }); + + Case("H1 nested member from the first projection's member", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Code = o.Code }) + .Select(r => new R3Row { Id = r.Id, Nest = new R3Nest { A = r.Code } }) + .Where(x => x.Nest.A == "AB123").ToList()), + Guarded(twice, "Nest.A", DataType.Text, "AB123")); + + Case("H2 the sibling the second projection leaves out", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Code = o.Code }) + .Select(r => new R3Row { Id = r.Id, Nest = new R3Nest { A = r.Code } }) + .Where(x => x.Nest.B == "x").ToList()), + Guarded(twice, "Nest.B", DataType.Text, "x")); + + Case("H3 a navigation carried through two projections", + Raw(() => _db.Orders + .Select(o => new R3Row { Id = o.Id, Customer = o.Customer }) + .Select(r => new R3Row { Id = r.Id, Customer = r.Customer }) + .Where(x => x.Customer!.Name == "Acme").ToList()), + Guarded(copiedAcross, "Customer.Name", DataType.Text, "Acme")); + + Done(); + } + + // ========================================================================================= + // R3-I. EfCoreOwns: every ordinary EF Core shape must still reach EF Core's own provider, + // or the refusal is silently lost. + // ========================================================================================= + + [Fact] + public void R3_I_Every_ordinary_EF_Core_shape_reaches_EF_Cores_own_provider() + { + (string Name, IQueryable Source)[] shapes = + { + ("DbSet", _db.Orders), + ("Where", _db.Orders.Where(o => o.Id > 0)), + ("AsNoTracking", _db.Orders.AsNoTracking()), + ("AsNoTrackingWithIdentityResolution", _db.Orders.AsNoTrackingWithIdentityResolution()), + ("AsTracking", _db.Orders.AsTracking()), + ("Include", _db.Orders.Include(o => o.Customer)), + ("Include+ThenInclude", _db.Customers.Include(c => c.Orders).ThenInclude(o => o.Lines)), + ("AsSplitQuery", _db.Orders.Include(o => o.Lines).AsSplitQuery()), + ("AsSingleQuery", _db.Orders.Include(o => o.Lines).AsSingleQuery()), + ("IgnoreQueryFilters", _db.Orders.IgnoreQueryFilters()), + ("IgnoreAutoIncludes", _db.Orders.IgnoreAutoIncludes()), + ("TagWith", _db.Orders.TagWith("t")), + ("FromSqlRaw", _db.Orders.FromSqlRaw("SELECT * FROM Orders")), + ("Select(row type)", _db.Orders.Select(o => new R3Row { Id = o.Id })), + ("Select(anonymous)", _db.Orders.Select(o => new { o.Id, o.Code })), + ("Select(navigation)", _db.Orders.Select(o => o.Customer)), + ("OfType", _db.Orders.OfType()), + ("Distinct", _db.Orders.Distinct()), + ("OrderBy+Skip+Take", _db.Orders.OrderBy(o => o.Id).Skip(0).Take(5)), + ("SelectMany", _db.Orders.SelectMany(o => o.Lines)), + ("GroupBy+Select", _db.Orders.GroupBy(o => o.CustomerId).Select(g => new R3Row { Id = g.Key })), + ("Join", _db.Orders.Join(_db.Customers, o => o.CustomerId, c => c.Id, (o, c) => o)) + }; + + List lost = new(); + + foreach ((string name, IQueryable source) in shapes) + { + bool owned = WalksToEfCore(source.Provider); + + _out.WriteLine($"{name,-38} provider={source.Provider.GetType().Name,-24} EfCoreOwns={owned}"); + + if (!owned) + { + lost.Add($"{name} -> {source.Provider.GetType().FullName}"); + } + } + + Assert.True(lost.Count == 0, "refusal silently lost on: " + string.Join(", ", lost)); + } + + /// The exact walk RowShape.EfCoreOwns performs. + private static bool WalksToEfCore(IQueryProvider provider) + { + for (Type? type = provider.GetType(); type is not null; type = type.BaseType) + { + if (type.FullName == "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider") + { + return true; + } + } + + return false; + } + + // ========================================================================================= + // R3-J. OPEN FINDING (round 3). The over-block direction of EfCoreOwns. + // + // EfCoreOwns walks BASE TYPES for the EntityQueryProvider name. A provider that rewrites the + // members EF Core cannot translate — the expander/decompiler shape the method's own remarks + // say must NOT be held to EF Core's model — is caught by that walk whenever it is built by + // DERIVING from EntityQueryProvider rather than by wrapping it. + // + // 3.2.0 (a7b06e1): both cases ran, 1 row each. + // This commit: both cases REFUSED(FieldDeniedForWhere). + // Unguarded: both cases still run, 1 row each. + // + // The base walk buys nothing for EF Core itself: EntityQueryProvider derives directly from + // System.Object in EF Core 6, 7, 8, 9 and 10 (verified against the assemblies), so an exact + // FullName comparison matches every real EF Core provider and stops catching subclasses. + // ========================================================================================= + + [Fact] + public void R3_J_A_provider_deriving_from_EF_Cores_own_is_held_to_EF_Cores_model() + { + IQueryCompiler compiler = + (IQueryCompiler)((IInfrastructure)_db).Instance.GetService(typeof(IQueryCompiler))!; + + R3DerivedProvider provider = new(compiler); + + IQueryable expanding = + provider.CreateQuery(((IQueryable)_db.Orders).Expression); + + _out.WriteLine($"derived provider = {provider.GetType().FullName}"); + _out.WriteLine($"EfCoreOwns walk = {WalksToEfCore(provider)}"); + + Case("J1 rewritten entity getter, on a provider derived from EF Core's", + Raw(() => expanding.Where(o => o.Slug == "AB123-x").ToList()), + Guarded(expanding, "Slug", DataType.Text, "AB123-x")); + + Case("J2 rewritten getter one navigation away", + Raw(() => expanding.Where(o => o.Customer.Handle == "Acme").ToList()), + Guarded(expanding, "Customer.Handle", DataType.Text, "Acme")); + + // The projection branch consults EfCoreOwns too, so it is caught by the same walk. + IQueryable projected = expanding.Select(o => new R3Row + { + Id = o.Id, + Customer = o.Customer + }); + + Case("J3 the same provider, over a projection", + Raw(() => expanding.Select(o => new R3Row { Id = o.Id, Customer = o.Customer }) + .Where(r => r.Customer!.Handle == "Acme").ToList()), + Guarded(projected, "Customer.Handle", DataType.Text, "Acme")); + + Done(); + } + + // ========================================================================================= + // R3-K. The entity branch: members EF Core translates that the model maps nothing for. + // ========================================================================================= + + [Fact] + public void R3_K_The_entity_branch_leaves_translatable_members_alone() + { + Case("K1 string.Length on a column", + Raw(() => _db.Orders.Where(o => o.Code.Length == 5).ToList()), + Guarded(_db.Orders, "Code.Length", DataType.Number, "5")); + + // A standing limit, not DW-17's: Validate() has always refused a path that walks + // through a collection, and 3.2.0 refuses these two exactly as this commit does. + Known("K2 collection navigation Count", + Raw(() => _db.Orders.Where(o => o.Lines.Count == 2).ToList()), + Guarded(_db.Orders, "Lines.Count", DataType.Number, "2")); + + Case("K3 owned member then string.Length", + Raw(() => _db.Orders.Where(o => o.Total.Currency.Length == 3).ToList()), + Guarded(_db.Orders, "Total.Currency.Length", DataType.Number, "3")); + + Known("K4 navigation then collection Count", + Raw(() => _db.Orders.Where(o => o.Customer.Orders.Count == 1).ToList()), + Guarded(_db.Orders, "Customer.Orders.Count", DataType.Number, "1")); + + Case("K5 nullable column HasValue", + Raw(() => _db.Orders.Where(o => o.Note != null).ToList()), + Guarded(_db.Orders, "Note", DataType.Text, "n")); + + Done(); + } + + // ========================================================================================= + // R3-L. Clone: nothing it copies is shallower than the docs claim, and nothing is dropped. + // Reflection throughout, so this file compiles against 3.2.0 where Clone is internal. + // ========================================================================================= + + [Fact] + public void R3_L_Clone_copies_every_declared_member_of_every_node() + { + Filter filter = new() + { + Selects = new List { "Id", "Code" }, + Orders = new List { new() { Sort = 1, Field = "Id", Direction = Direction.Descending } }, + Page = new PageBy { PageNumber = 2, PageSize = 20 }, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 1, Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "AB123" } + } + }, + SubConditionGroups = new List + { + new() + { + Conditions = + { + new Condition + { + Sort = 2, Field = "Id", DataType = DataType.Number, + Operator = Operator.Equal, Values = { 1 } + } + } + } + } + } + }; + + Filter copy = (Filter)Invoke(filter, "Clone"); + + List shared = new(); + + // Every reference node must be a different object. + Same(shared, "Filter.ConditionGroup", filter.ConditionGroup, copy.ConditionGroup); + Same(shared, "Filter.Selects", filter.Selects, copy.Selects); + Same(shared, "Filter.Orders", filter.Orders, copy.Orders); + Same(shared, "Filter.Orders[0]", filter.Orders![0], copy.Orders![0]); + Same(shared, "Filter.Page", filter.Page, copy.Page); + Same(shared, "Group.Conditions", filter.ConditionGroup!.Conditions, copy.ConditionGroup!.Conditions); + Same(shared, "Group.Conditions[0]", filter.ConditionGroup.Conditions[0], copy.ConditionGroup.Conditions[0]); + Same(shared, "Group.Conditions[0].Values", + filter.ConditionGroup.Conditions[0].Values, copy.ConditionGroup.Conditions[0].Values); + Same(shared, "Group.SubGroups", filter.ConditionGroup.SubConditionGroups, copy.ConditionGroup.SubConditionGroups); + Same(shared, "Group.SubGroups[0]", + filter.ConditionGroup.SubConditionGroups![0], copy.ConditionGroup.SubConditionGroups![0]); + Same(shared, "Group.SubGroups[0].Conditions[0]", + filter.ConditionGroup.SubConditionGroups[0].Conditions[0], + copy.ConditionGroup.SubConditionGroups[0].Conditions[0]); + + // Every value must survive the copy. + List lost = new(); + + Carried(lost, "Selects", string.Join(",", filter.Selects!), string.Join(",", copy.Selects!)); + Carried(lost, "Orders[0].Sort", filter.Orders[0].Sort, copy.Orders[0].Sort); + Carried(lost, "Orders[0].Field", filter.Orders[0].Field, copy.Orders[0].Field); + Carried(lost, "Orders[0].Direction", filter.Orders[0].Direction, copy.Orders[0].Direction); + Carried(lost, "Page.PageNumber", filter.Page!.PageNumber, copy.Page!.PageNumber); + Carried(lost, "Page.PageSize", filter.Page.PageSize, copy.Page.PageSize); + Carried(lost, "Group.Sort", filter.ConditionGroup.Sort, copy.ConditionGroup.Sort); + Carried(lost, "Group.Connector", filter.ConditionGroup.Connector, copy.ConditionGroup.Connector); + Carried(lost, "Condition.Sort", + filter.ConditionGroup.Conditions[0].Sort, copy.ConditionGroup.Conditions[0].Sort); + Carried(lost, "Condition.Field", + filter.ConditionGroup.Conditions[0].Field, copy.ConditionGroup.Conditions[0].Field); + Carried(lost, "Condition.DataType", + filter.ConditionGroup.Conditions[0].DataType, copy.ConditionGroup.Conditions[0].DataType); + Carried(lost, "Condition.Operator", + filter.ConditionGroup.Conditions[0].Operator, copy.ConditionGroup.Conditions[0].Operator); + Carried(lost, "Condition.Values", + string.Join(",", filter.ConditionGroup.Conditions[0].Values), + string.Join(",", copy.ConditionGroup.Conditions[0].Values)); + Carried(lost, "SubGroup.Condition.Field", + filter.ConditionGroup.SubConditionGroups[0].Conditions[0].Field, + copy.ConditionGroup.SubConditionGroups[0].Conditions[0].Field); + + // No public settable property of any node may be left uncopied. + lost.AddRange(Uncopied(typeof(Filter), filter, copy)); + + foreach (string line in shared.Concat(lost)) + { + _out.WriteLine(line); + } + + Assert.True(shared.Count == 0 && lost.Count == 0, string.Join(" || ", shared.Concat(lost))); + } + + [Fact] + public void R3_L_Clone_of_a_segment_and_a_summary_copies_every_node() + { + Segment segment = new() + { + Selects = new List { "Id" }, + Orders = new List { new() { Field = "Id" } }, + Page = new PageBy { PageNumber = 1, PageSize = 5 }, + ConditionSets = + { + new ConditionSet + { + Sort = 1, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Code", Values = { "AB123" } } } + } + } + } + }; + + Summary summary = new() + { + ConditionGroup = new ConditionGroup { Conditions = { new Condition { Field = "Code" } } }, + Having = new ConditionGroup { Conditions = { new Condition { Field = "n" } } }, + GroupBy = new GroupBy + { + Fields = { "CustomerId" }, + AggregateBy = { new AggregateBy { Field = "Id", Alias = "n", Aggregator = Aggregator.Count } } + }, + Orders = new List { new() { Field = "n" } }, + Page = new PageBy { PageNumber = 1, PageSize = 5 } + }; + + Segment segmentCopy = (Segment)Invoke(segment, "Clone"); + Summary summaryCopy = (Summary)Invoke(summary, "Clone"); + + List shared = new(); + + Same(shared, "Segment.ConditionSets", segment.ConditionSets, segmentCopy.ConditionSets); + Same(shared, "Segment.ConditionSets[0]", segment.ConditionSets[0], segmentCopy.ConditionSets[0]); + Same(shared, "Segment.Sets[0].Group", + segment.ConditionSets[0].ConditionGroup, segmentCopy.ConditionSets[0].ConditionGroup); + Same(shared, "Segment.Sets[0].Group.Conditions[0]", + segment.ConditionSets[0].ConditionGroup.Conditions[0], + segmentCopy.ConditionSets[0].ConditionGroup.Conditions[0]); + Same(shared, "Segment.Selects", segment.Selects, segmentCopy.Selects); + Same(shared, "Segment.Orders[0]", segment.Orders![0], segmentCopy.Orders![0]); + Same(shared, "Segment.Page", segment.Page, segmentCopy.Page); + + Same(shared, "Summary.ConditionGroup", summary.ConditionGroup, summaryCopy.ConditionGroup); + Same(shared, "Summary.Having", summary.Having, summaryCopy.Having); + Same(shared, "Summary.GroupBy", summary.GroupBy, summaryCopy.GroupBy); + Same(shared, "Summary.GroupBy.Fields", summary.GroupBy!.Fields, summaryCopy.GroupBy!.Fields); + Same(shared, "Summary.GroupBy.AggregateBy", summary.GroupBy.AggregateBy, summaryCopy.GroupBy.AggregateBy); + Same(shared, "Summary.GroupBy.AggregateBy[0]", + summary.GroupBy.AggregateBy[0], summaryCopy.GroupBy.AggregateBy[0]); + Same(shared, "Summary.Orders[0]", summary.Orders![0], summaryCopy.Orders![0]); + Same(shared, "Summary.Page", summary.Page, summaryCopy.Page); + + List lost = new(); + + Carried(lost, "Segment.Sets[0].Sort", segment.ConditionSets[0].Sort, segmentCopy.ConditionSets[0].Sort); + Carried(lost, "Segment.Sets[0].Intersection", + segment.ConditionSets[0].Intersection, segmentCopy.ConditionSets[0].Intersection); + Carried(lost, "Summary.GroupBy.Fields", + string.Join(",", summary.GroupBy.Fields), string.Join(",", summaryCopy.GroupBy.Fields)); + Carried(lost, "Summary.Agg.Alias", summary.GroupBy.AggregateBy[0].Alias, summaryCopy.GroupBy.AggregateBy[0].Alias); + Carried(lost, "Summary.Agg.Aggregator", + summary.GroupBy.AggregateBy[0].Aggregator, summaryCopy.GroupBy.AggregateBy[0].Aggregator); + Carried(lost, "Summary.Having.Conditions[0].Field", + summary.Having!.Conditions[0].Field, summaryCopy.Having!.Conditions[0].Field); + + lost.AddRange(Uncopied(typeof(Segment), segment, segmentCopy)); + lost.AddRange(Uncopied(typeof(Summary), summary, summaryCopy)); + + foreach (string line in shared.Concat(lost)) + { + _out.WriteLine(line); + } + + Assert.True(shared.Count == 0 && lost.Count == 0, string.Join(" || ", shared.Concat(lost))); + } + + private static object Invoke(object target, string method) => + target.GetType() + .GetMethod(method, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, Type.EmptyTypes, null)! + .Invoke(target, null)!; + + private static void Same(List shared, string what, object? left, object? right) + { + if (left is not null && ReferenceEquals(left, right)) + { + shared.Add($"SHARED NODE: {what}"); + } + } + + private static void Carried(List lost, string what, object? left, object? right) + { + if (!Equals(left, right)) + { + lost.Add($"NOT COPIED: {what} ({left} -> {right})"); + } + } + + /// Every public settable property of the type whose value the copy does not carry. + private static IEnumerable Uncopied(Type type, object original, object copy) + { + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!property.CanRead) + { + continue; + } + + object? left = property.GetValue(original); + object? right = property.GetValue(copy); + + if (left is null != right is null) + { + yield return $"UNCOPIED BRANCH: {type.Name}.{property.Name} ({left} -> {right})"; + } + } + } + + // ========================================================================================= + // R3-M. SamePosture: every public knob, and the effective-value comparison. + // ========================================================================================= + + [Fact] + public void R3_M_Every_posture_value_is_compared() + { + MethodInfo? same = typeof(DwPolicy).GetMethod( + "SamePosture", BindingFlags.Static | BindingFlags.NonPublic); + + if (same is null) + { + _out.WriteLine("SamePosture not present on this version; nothing to compare."); + + return; + } + + // Every public settable property of DwPolicyOptions, and of DwCaps, reported with + // whether SamePosture notices a change to it. + List uncompared = new(); + + foreach (PropertyInfo property in typeof(DwPolicyOptions) + .GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + _out.WriteLine($"DwPolicyOptions.{property.Name,-24} settable={property.CanWrite}"); + } + + foreach (PropertyInfo property in typeof(DwCaps).GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + _out.WriteLine($"DwCaps.{property.Name,-31} settable={property.CanWrite}"); + } + + _out.WriteLine($"uncompared: {uncompared.Count}"); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Rv3InMemoryProviderProbe.cs b/DynamicWhere.Tests/Policies/Rv3InMemoryProviderProbe.cs new file mode 100644 index 0000000..4af01a8 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rv3InMemoryProviderProbe.cs @@ -0,0 +1,172 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// The same model on EF Core's own in-memory provider. + public sealed class Rv3MemoryContext : DbContext + { + private readonly string _name; + + public Rv3MemoryContext(string name) => _name = name; + + public DbSet Roles => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => + options.UseInMemoryDatabase(_name); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(role => role.Name); + model.Entity().OwnsOne(role => role.Other); + model.Entity().Ignore(role => role.Display); + } + } + + /// + /// REVIEW PROBE ONLY (round 3), and a ruling-out rather than a finding. + /// + /// + /// The provider test behind the 3.3.0 refusal asks "is this EF Core's own provider", not "does + /// this provider have to translate the path". The strongest candidate for a provider that is EF + /// Core's own and yet answers a member the model does not map is EF Core's in-memory provider, + /// which has no SQL to generate. It does not: it runs the same translating visitor and refuses + /// the same members a relational provider refuses, so holding its rows to EF Core's model + /// refuses nothing that would have run. + /// + /// This suite is the reason the project references Microsoft.EntityFrameworkCore.InMemory. + /// Drop both together if the ruling-out is not worth keeping. + /// + /// + public sealed class Rv3InMemoryProviderProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly Rv3MemoryContext _db; + + public Rv3InMemoryProviderProbe(ITestOutputHelper output) + { + _out = output; + _db = new Rv3MemoryContext(Guid.NewGuid().ToString("N")); + _db.Roles.Add(new Rv3Role + { + Id = 1, + Code = "admin", + Secret = "S-TOP", + Cost = "C-9", + Name = new Rv3Text { Ar = "AR", En = "Admin" }, + Other = new Rv3Text { Ar = "ar2", En = "Other" } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() => _db.Dispose(); + + private static PolicyQueryable Guard(IQueryable source, DwTier tier) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + /// + /// The provider with no SQL to write still refuses a getter over two columns, an unmapped + /// getter on the entity, and an order by one. So the shape's answer for it is right. + /// + [Fact] + public void G1_The_in_memory_provider_refuses_the_same_members_a_relational_one_does() + { + string Try(Func read) + { + try + { + return read().ToString(); + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + string owned = Try(() => _db.Roles.Where(role => !role.Name.IsEmpty).Count()); + string entity = Try(() => _db.Roles.Where(role => role.Display == "admin:1").Count()); + string ordered = Try(() => _db.Roles.OrderBy(role => role.Display).Count()); + + _out.WriteLine($"G1 unguarded owned={owned} entity={entity} ordered={ordered}"); + + Assert.Equal(nameof(InvalidOperationException), owned); + Assert.Equal(nameof(InvalidOperationException), entity); + Assert.Equal(nameof(InvalidOperationException), ordered); + } + + /// + /// So the strict tier's refusal replaces a failure rather than taking away an answer, which + /// is the whole premise of the check. + /// + [Fact] + public void G2_The_refusal_replaces_a_failure_rather_than_an_answer() + { + Exception? owned = Record.Exception( + () => Guard(_db.Roles, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + Exception? entity = Record.Exception( + () => Guard(_db.Roles, DwTier.Strict).ToList(Where("Display", "admin:1"))); + + Exception? order = Record.Exception( + () => Guard(_db.Roles, DwTier.Strict).ToList(new Filter + { + Orders = new List { new() { Field = "Display", Direction = Direction.Ascending } } + })); + + _out.WriteLine($"G2 owned={owned?.GetType().Name} entity={entity?.GetType().Name} " + + $"order={order?.GetType().Name}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, Assert.IsAssignableFrom(owned).ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, Assert.IsAssignableFrom(entity).ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, Assert.IsAssignableFrom(order).ErrorCode); + } + + /// The mapped members still answer, so nothing usable was taken away. + [Fact] + public void G3_The_mapped_members_still_answer() + { + Assert.Single(Guard(_db.Roles, DwTier.Strict).ToList(Where("Code", "admin")).Data); + Assert.Single(Guard(_db.Roles, DwTier.Strict).ToList(Where("Name.En", "Admin")).Data); + } + + /// A denied field is refused and withheld here exactly as anywhere else. + [Fact] + public void G4_Denials_are_unaffected_on_the_in_memory_provider() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(Where("Secret", "S-TOP"))); + + Rv3Role row = Assert.Single(Guard(_db.Roles, DwTier.Strict).ToList(new Filter()).Data); + + _out.WriteLine($"G4 {refusal.ErrorCode} secret='{row.Secret}' cost='{row.Cost}'"); + + Assert.True(string.IsNullOrEmpty(row.Secret), $"Secret leaked: '{row.Secret}'"); + Assert.True(string.IsNullOrEmpty(row.Cost), $"Cost leaked: '{row.Cost}'"); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Rv3SecurityProbes.cs b/DynamicWhere.Tests/Policies/Rv3SecurityProbes.cs new file mode 100644 index 0000000..9a5362b --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rv3SecurityProbes.cs @@ -0,0 +1,1083 @@ +using System.Linq.Expressions; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query.Internal; +using Xunit.Abstractions; + +#pragma warning disable EF1001 // the probe is about EF Core's own provider type, which is internal by design + +namespace DynamicWhere.Tests.Policies +{ + // ==== the model ================================================================================= + + /// Two columns and a getter over them: a member no relational database can compute. + public class Rv3Text + { + public string Ar { get; set; } = string.Empty; + + public string En { get; set; } = string.Empty; + + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + public class Rv3Role + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public Rv3Text Name { get; set; } = new(); + + public Rv3Text Other { get; set; } = new(); + + /// Refused for every feature. + [DwDenied] + public string Secret { get; set; } = string.Empty; + + /// Refused for projection only, so a synthesized projection has to leave it out. + [DwNoSelect] + public string Cost { get; set; } = string.Empty; + + /// An unmapped getter over two mapped columns. + public string Display => $"{Code}:{Id}"; + } + + /// The row a caller projects before the guard sees it. + public class Rv3Row + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public Rv3Text Name { get; set; } = new(); + + public Rv3Text Other { get; set; } = new(); + } + + /// Two members of one type, to see whether one level's member set leaks into another's. + public class Rv3Pair + { + public int Id { get; set; } + + public Rv3Text A { get; set; } = new(); + + public Rv3Text B { get; set; } = new(); + } + + /// Nests inside itself, for the initializer depth cap. + public class Rv3Deep + { + public string V { get; set; } = string.Empty; + + public string W { get; set; } = string.Empty; + + public Rv3Deep? Next { get; set; } + } + + public class Rv3DeepRow + { + public int Id { get; set; } + + public Rv3Deep Deep { get; set; } = new(); + } + + public sealed class Rv3Context : DbContext + { + private readonly SqliteConnection? _connection; + + public Rv3Context(SqliteConnection connection) => _connection = connection; + + public Rv3Context(DbContextOptions options) + : base(options) + { + } + + public DbSet Roles => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + if (_connection is not null) + { + options.UseSqlite(_connection); + } + } + + protected override void OnModelCreating(ModelBuilder model) + { + // Owned rather than complex, so the EF Core 6.0.22 floor builds this model too. + model.Entity().OwnsOne(role => role.Name); + model.Entity().OwnsOne(role => role.Other); + model.Entity().Ignore(role => role.Display); + } + } + + /// The same model with one options constructor, which is what pooling requires. + public sealed class Rv3PooledContext : DbContext + { + public Rv3PooledContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Roles => Set(); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(role => role.Name); + model.Entity().OwnsOne(role => role.Other); + model.Entity().Ignore(role => role.Display); + } + } + + // ==== providers ================================================================================= + + /// + /// A provider in front of EF Core's: the shape LinqKit's AsExpandable and + /// DelegateDecompiler's Decompile have. + /// + public sealed class Rv3Wrapping : IQueryable, IQueryProvider + { + private readonly IQueryable _inner; + + public Rv3Wrapping(IQueryable inner) + { + _inner = inner; + Expression = inner.Expression; + } + + public Type ElementType => typeof(T); + + public Expression Expression { get; } + + public IQueryProvider Provider => this; + + public IEnumerator GetEnumerator() => _inner.Provider.CreateQuery(Expression).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + public IQueryable CreateQuery(Expression expression) => _inner.Provider.CreateQuery(expression); + + public IQueryable CreateQuery(Expression expression) => + new Rv3Wrapping(_inner.Provider.CreateQuery(expression)); + + public object? Execute(Expression expression) => _inner.Provider.Execute(expression); + + public TResult Execute(Expression expression) => _inner.Provider.Execute(expression); + } + + /// + /// A provider that derives from EF Core's rather than wrapping it: the shape a + /// second-level cache or an expression-rewriting provider takes when it subclasses. + /// + public sealed class Rv3Derived : EntityQueryProvider + { + public Rv3Derived(Microsoft.EntityFrameworkCore.Query.Internal.IQueryCompiler compiler) + : base(compiler) + { + } + } + + /// Static helpers a projection can call, which no provider translates. + public static class Rv3Util + { + public static string Tag(string value) => value + "!"; + } + + // ==== the probes ================================================================================ + + /// + /// Third review round. What the second round's fixes left reachable: the provider test that + /// decides whether a path is held to EF Core's model, the assignment reader behind it, the + /// posture comparison, and the refusal surface. + /// + public sealed class Rv3SecurityProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Rv3Context _db; + + public Rv3SecurityProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Rv3Context(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new Rv3Role + { + Code = "admin", + Secret = "S-TOP", + Cost = "C-9", + Name = new Rv3Text { Ar = "AR", En = "Admin" }, + Other = new Rv3Text { Ar = "ar2", En = "Other" } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier, bool traceInResult = false) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + traceInResult + ? new DwPolicyOptions { Tier = tier, IncludeTraceInResult = true } + : new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + private static Filter WhereEmpty(string field) => Where(field, "false", DataType.Boolean); + + /// Calls the private provider test directly, so its answer can be read rather than inferred. + private static bool EfCoreOwns(IQueryProvider provider) => + (bool)typeof(RowShape) + .GetMethod("EfCoreOwns", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, new object[] { provider })!; + + /// Reads a shape's answer for a path directly, rather than inferring it from a refusal. + private static bool? Expresses(IQueryable source, string path) where T : class + { + object shape = typeof(RowShape) + .GetMethod("Of", BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(typeof(T)) + .Invoke(null, new object[] { source })!; + + return (bool?)typeof(RowShape) + .GetMethod("Expresses", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(shape, new object[] { path }); + } + + // ==== A. the provider test ================================================================== + + /// + /// EF Core's own provider, and not a provider built by deriving from it: one that derives + /// rewrites what EF Core cannot translate exactly as one that wraps it does, so the two are + /// answered for the same way. + /// + [Fact] + public void A1_The_provider_test_is_EF_Cores_own_type_and_not_a_type_derived_from_it() + { + IQueryProvider own = _db.Roles.AsQueryable().Provider; + + object compiler = typeof(EntityQueryProvider) + .GetField("_queryCompiler", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(own)!; + + Rv3Derived derived = new((Microsoft.EntityFrameworkCore.Query.Internal.IQueryCompiler)compiler); + + _out.WriteLine($"A1 own={own.GetType().Name} owns={EfCoreOwns(own)}"); + _out.WriteLine($"A1 derived={derived.GetType().Name} owns={EfCoreOwns(derived)}"); + + // A provider built by deriving from EF Core's rewrites what EF Core cannot translate, + // exactly as one built by wrapping it does, so the two are answered for the same way. + // EF Core's own provider derives from object in every version, so nothing real is lost. + Assert.True(EfCoreOwns(own)); + Assert.False(EfCoreOwns(derived)); + } + + /// A provider in front of EF Core is not EF Core's, by the same test. + [Fact] + public void A2_The_provider_test_rejects_a_wrapping_provider() + { + Assert.False(EfCoreOwns(new Rv3Wrapping(_db.Roles).Provider)); + Assert.False(EfCoreOwns(new[] { new Rv3Role() }.AsQueryable().Provider)); + } + + /// + /// A raw-SQL root is still EF Core's to translate what is composed on it, so the refusal + /// stands and the mapped members still answer. + /// + [Fact] + public void A3_A_raw_sql_root_is_still_held_to_the_model() + { + IQueryable raw = _db.Roles.FromSqlRaw("SELECT * FROM Roles"); + + Assert.True(EfCoreOwns(raw.Provider)); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(raw, DwTier.Strict).ToList(WhereEmpty("Name.IsEmpty"))); + + _out.WriteLine($"A3 {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + Assert.Single(Guard(_db.Roles.FromSqlRaw("SELECT * FROM Roles"), DwTier.Strict) + .ToList(Where("Code", "admin")).Data); + } + + /// + /// The tracking, split-query and query-filter knobs leave the provider alone, so none of + /// them can turn the check off. + /// + [Fact] + public void A4_The_query_knobs_do_not_change_the_provider() + { + IQueryable[] shapes = + { + _db.Roles.AsNoTracking(), + _db.Roles.AsNoTrackingWithIdentityResolution(), + _db.Roles.AsSplitQuery(), + _db.Roles.IgnoreQueryFilters(), + _db.Roles.AsTracking(), + _db.Roles.TagWith("probe") + }; + + foreach (IQueryable shape in shapes) + { + Assert.True(EfCoreOwns(shape.Provider), shape.Expression.ToString()); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(shape, DwTier.Strict).ToList(WhereEmpty("Name.IsEmpty"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + _out.WriteLine($"A4 {shapes.Length} shapes, all EF Core's own provider"); + } + + /// A pooled context hands out the same provider a plain one does. + [Fact] + public void A5_A_pooled_context_is_still_EF_Cores_own_provider() + { + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + PooledDbContextFactory factory = new(options); + + using Rv3PooledContext pooled = factory.CreateDbContext(); + + Assert.True(EfCoreOwns(pooled.Roles.AsQueryable().Provider)); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(pooled.Roles, DwTier.Strict).ToList(WhereEmpty("Name.IsEmpty"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + Assert.Single(Guard(pooled.Roles, DwTier.Strict).ToList(Where("Code", "admin")).Data); + } + + // ==== B. the wrapping exemption cannot weaken any other decision ============================= + + /// A field denied for everything is refused behind a wrapping provider too. + [Fact] + public void B1_A_denied_field_is_still_refused_behind_a_wrapping_provider() + { + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + IQueryable wrapped = new Rv3Wrapping(_db.Roles); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(wrapped, tier).ToList(Where("Secret", "S-TOP"))); + + _out.WriteLine($"B1 {tier}: {refusal.ErrorCode}"); + } + } + + /// So is one named in a projection. + [Fact] + public void B2_A_denied_select_is_still_refused_behind_a_wrapping_provider() + { + IQueryable wrapped = new Rv3Wrapping(_db.Roles); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(wrapped, DwTier.Strict).ToList(new Filter + { + Selects = new List { "Id", "Secret" } + })); + + _out.WriteLine($"B2 {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode); + } + + /// + /// The projection the library synthesizes for a caller who sent none still withholds the + /// denied members when the rows arrive through a wrapping provider. + /// + [Fact] + public void B3_The_synthesized_projection_still_withholds_behind_a_wrapping_provider() + { + IQueryable wrapped = new Rv3Wrapping(_db.Roles); + + FilterResult guarded = Guard(wrapped, DwTier.Strict).ToList(new Filter()); + + Rv3Role row = Assert.Single(guarded.Data); + + _out.WriteLine($"B3 secret='{row.Secret}' cost='{row.Cost}' code='{row.Code}'"); + + Assert.True(string.IsNullOrEmpty(row.Secret), $"Secret leaked: '{row.Secret}'"); + Assert.True(string.IsNullOrEmpty(row.Cost), $"Cost leaked: '{row.Cost}'"); + Assert.Equal("admin", row.Code); + } + + /// + /// Bare and wrapped answer the same query with the same decisions, so the provider test + /// changes nothing but whether a path is held to EF Core's model. + /// + [Fact] + public void B4_The_provider_test_changes_only_the_expressible_check() + { + FilterResult bare = Guard(_db.Roles, DwTier.Strict, traceInResult: true) + .ToList(Where("Code", "admin")); + + FilterResult wrapped = Guard(new Rv3Wrapping(_db.Roles), DwTier.Strict, traceInResult: true) + .ToList(Where("Code", "admin")); + + string Render(FilterResult result) => string.Join( + " | ", + result.Policy!.Decisions.Select(d => $"{d.FieldPath}/{d.Feature}/{d.Action}/{d.Reason}")); + + _out.WriteLine($"B4 bare: {Render(bare)}"); + _out.WriteLine($"B4 wrapped: {Render(wrapped)}"); + + Assert.Equal(Render(bare), Render(wrapped)); + Assert.True(string.IsNullOrEmpty(Assert.Single(wrapped.Data).Secret)); + } + + // ==== C. the assignment reader =============================================================== + + private IQueryable Project(Expression> selector) => _db.Roles.Select(selector); + + private Exception? Filtering(IQueryable rows, string field) => + Record.Exception(() => Guard(rows, DwTier.Strict).ToList(WhereEmpty(field))); + + /// A conditional one level down, inside a nested initializer, keeps the refusal. + [Fact] + public void C1_A_conditional_inside_a_nested_initializer_keeps_the_refusal() + { + IQueryable rows = Project(role => new Rv3Row + { + Id = role.Id, + Name = new Rv3Text + { + Ar = role.Code == null ? role.Name.Ar : role.Name.En, + En = role.Name.En + } + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C1 {raised?.GetType().Name}: {raised?.Message}"); + + Assert.IsAssignableFrom(raised); + } + + /// A conditional inside a conditional, both arms building in place, keeps it too. + [Fact] + public void C2_A_conditional_nested_in_a_conditional_keeps_the_refusal() + { + IQueryable rows = Project(role => new Rv3Row + { + Id = role.Id, + Name = role.Code == null + ? new Rv3Text() + : role.Code == "admin" + ? new Rv3Text { Ar = role.Name.Ar } + : new Rv3Text { En = role.Name.En } + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C2 {raised?.GetType().Name}: {raised?.Message}"); + + Assert.IsAssignableFrom(raised); + } + + /// Two arms copying the same member of the entity keep the refusal the entity gives. + [Fact] + public void C3_Two_arms_copying_one_member_keep_the_refusal() + { + IQueryable rows = Project(role => new Rv3Row + { + Id = role.Id, + Name = role.Code == null ? role.Name : role.Name + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C3 {raised?.GetType().Name}: {raised?.Message}"); + + Assert.IsAssignableFrom(raised); + } + + /// + /// Two arms copying two different members: the shape refuses to speak, so the refusal is + /// lost and EF Core answers with its own failure. + /// + [Fact] + public void C4_Two_arms_copying_two_members_lose_the_refusal() + { + IQueryable rows = Project(role => new Rv3Row + { + Id = role.Id, + Name = role.Code == null ? role.Name : role.Other + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C4 {raised?.GetType().Name}: {raised?.Message}"); + + Assert.False(raised is PolicyException, "refused, so the shape did speak for it"); + Assert.NotNull(raised); + } + + /// A coalesce beneath the member leaves the level's own member set intact. + [Fact] + public void C5_A_coalesce_beneath_the_member_keeps_the_refusal() + { + IQueryable rows = Project(role => new Rv3Row + { + Id = role.Id, + Name = new Rv3Text { Ar = role.Code ?? role.Name.Ar, En = role.Name.En } + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C5 {raised?.GetType().Name}: {raised?.Message}"); + + Assert.IsAssignableFrom(raised); + } + + /// A conversion around a copied member is stripped, so the copy is still read. + [Fact] + public void C6_A_conversion_around_a_copy_keeps_the_refusal() + { + IQueryable rows = _db.Roles.Select(role => new Rv3Row + { + Id = role.Id, + Name = (Rv3Text)(object)role.Name + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C6 {raised?.GetType().Name}: {raised?.Message}"); + + Assert.IsAssignableFrom(raised); + } + + /// A captured value is an assignment the shape cannot read, so it speaks for nothing. + [Fact] + public void C7_A_captured_value_is_left_alone() + { + Rv3Text captured = new() { Ar = "x", En = "y" }; + + IQueryable rows = _db.Roles.Select(role => new Rv3Row { Id = role.Id, Name = captured }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C7 {raised?.GetType().Name ?? "ran"}: {raised?.Message}"); + + Assert.False(raised is PolicyException, "refused a member the shape cannot speak for"); + } + + /// + /// A member the shape records as assigned, whose value is a call no provider translates. The + /// level says it produces the member, the database cannot compute it, and the strict tier + /// answers with neither an answer nor a refusal. + /// + [Fact] + public void C8_A_method_call_beneath_a_member_is_claimed_and_then_fails() + { + IQueryable rows = _db.Roles.Select(role => new Rv3Row + { + Id = role.Id, + Name = new Rv3Text { Ar = Rv3Util.Tag(role.Code), En = role.Name.En } + }); + + // Unguarded, the projection itself is answered: EF Core evaluates the last one on the client. + Assert.Single(rows.ToList()); + + Exception? computed = Record.Exception( + () => Guard(rows, DwTier.Strict).ToList(Where("Name.Ar", "admin!"))); + + Exception? unassigned = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C8 filter on the claimed member: {computed?.GetType().Name ?? "ran"}: {computed?.Message}"); + _out.WriteLine($"C8 filter on the unassigned one: {unassigned?.GetType().Name}"); + + // The unassigned member is still refused, so the level was read. + Assert.IsAssignableFrom(unassigned); + + // The claimed one is not, and it is not answered either. + Assert.NotNull(computed); + Assert.False(computed is PolicyException, "the claimed member was refused after all"); + + // And the shape says so rather than claiming the member: the initializer assigns it from + // something the shape cannot read, so the answer for it is "cannot say". Only false is + // acted on, so this changes no query today; it stops the shape making a claim it cannot + // support. + _out.WriteLine($"C8 Expresses(\"Name.Ar\") = {Expresses(rows, "Name.Ar")?.ToString() ?? "null"}"); + + Assert.Null(Expresses(rows, "Name.Ar")); + Assert.False(Expresses(rows, "Name.IsEmpty")); + } + + /// A constructor with arguments one level down records nothing, so nothing is refused. + [Fact] + public void C9_A_constructor_with_arguments_one_level_down_is_left_alone() + { + IQueryable rows = _db.Roles.Select(role => new Rv3Row + { + Id = role.Id, + Name = new Rv3Text(), + Other = new Rv3Text { Ar = role.Other.Ar, En = role.Other.En } + }); + + // The level that is read still refuses. + Assert.IsAssignableFrom(Filtering(rows, "Other.IsEmpty")); + + // An empty new() records "assigns nothing", so every member beneath it is refused. + Exception? empty = Filtering(rows, "Name.Ar"); + + _out.WriteLine($"C9 empty new(): {empty?.GetType().Name}: {empty?.Message}"); + + Assert.IsAssignableFrom(empty); + } + + /// A subquery beneath a member is an assignment the shape cannot read. + [Fact] + public void C10_A_subquery_beneath_a_member_is_left_alone() + { + IQueryable rows = _db.Roles.Select(role => new Rv3Row + { + Id = role.Id, + Name = _db.Roles.Where(other => other.Id == role.Id).Select(other => other.Name).First() + }); + + Exception? raised = Filtering(rows, "Name.IsEmpty"); + + _out.WriteLine($"C10 {raised?.GetType().Name ?? "ran"}: {raised?.Message}"); + + Assert.False(raised is PolicyException, "refused a member the shape cannot speak for"); + } + + /// + /// Past the initializer depth cap the shape stops recording, so it refuses nothing there — + /// and nothing above the cap is affected. + /// + [Fact] + public void C11_Past_the_depth_cap_the_shape_stops_speaking() + { + IQueryable rows = _db.Roles.Select(role => new Rv3DeepRow + { + Id = role.Id, + Deep = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep + { + V = role.Code, + Next = new Rv3Deep { V = role.Code } + } + } + } + } + } + } + } + } + } + }); + + // The navigation cap would refuse a long path before the shape ever looked at it. + PolicyQueryable Deep(IQueryable source) + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + options.Caps.MaxNavigationDepth = 24; + + return source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + } + + Exception? shallow = Record.Exception(() => Deep(rows).ToList(Where("Deep.W", "x"))); + + string deep = "Deep." + string.Join(".", Enumerable.Repeat("Next", 9)) + ".W"; + + Exception? beyond = Record.Exception(() => Deep(rows).ToList(Where(deep, "x"))); + + _out.WriteLine($"C11 shallow unassigned: {shallow?.GetType().Name ?? "ran"}"); + _out.WriteLine($"C11 beyond the cap: {beyond?.GetType().Name ?? "ran"}: {beyond?.Message}"); + + Assert.IsAssignableFrom(shallow); + Assert.False(beyond is PolicyException, "the shape spoke past its own cap"); + } + + /// + /// Two members built at one level. What one level's initializer assigns must not widen what + /// another's is read as assigning: Record unions into an existing set, so a set + /// handed out by reference would make every member of A a member of B. + /// + [Fact] + public void C12_One_levels_member_set_does_not_widen_another() + { + IQueryable rows = _db.Roles.Select(role => new Rv3Pair + { + Id = role.Id, + A = role.Code == null + ? new Rv3Text { Ar = role.Name.Ar } + : new Rv3Text { En = role.Name.En }, + B = new Rv3Text { Ar = role.Other.Ar } + }); + + // A's two arms union to { Ar, En }, so neither is refused. + Exception? aAr = Record.Exception(() => Guard(rows, DwTier.Strict).ToList(Where("A.Ar", "AR"))); + Exception? aEn = Record.Exception(() => Guard(rows, DwTier.Strict).ToList(Where("A.En", "Admin"))); + + // B assigns Ar alone. If the sets were shared, En would be there too and this would run. + Exception? bEn = Record.Exception(() => Guard(rows, DwTier.Strict).ToList(Where("B.En", "Other"))); + + _out.WriteLine($"C12 A.Ar={aAr?.GetType().Name ?? "ran"} A.En={aEn?.GetType().Name ?? "ran"} " + + $"B.En={bEn?.GetType().Name ?? "ran"}"); + + Assert.False(aAr is PolicyException, "an arm's own member was refused"); + Assert.False(aEn is PolicyException, "the other arm's member was refused"); + Assert.IsAssignableFrom(bEn); + } + + // ==== D. the posture comparison ============================================================= + + /// + /// Writing the trace flag out as the value the tier already answers is accepted, and the + /// posture the query path reads is still the first call's instance, unchanged. + /// + [Fact] + public void D1_Writing_the_trace_flag_the_tier_answers_leaves_the_posture_untouched() + { + PolicyBootstrap.Ensure(); + + DwPolicyOptions before = DwPolicy.Options; + bool effectiveBefore = TraceInResult(before); + + DwPolicyOptions copy = CopyOfInForce(); + + copy.IncludeTraceInResult = effectiveBefore; + + DwPolicy.Configure(copy); + + _out.WriteLine($"D1 written={copy.IncludeTraceInResult} in-force-written={DwPolicy.Options.IncludeTraceInResult} " + + $"effective={TraceInResult(DwPolicy.Options)}"); + + Assert.Same(before, DwPolicy.Options); + Assert.Equal(effectiveBefore, TraceInResult(DwPolicy.Options)); + + // The instance the second host built is frozen, so it cannot go on deciding anything. + Assert.True(copy.IsFrozen); + Assert.Throws(() => copy.IncludeTraceInResult = !effectiveBefore); + } + + /// + /// Two postures that differ only in whether the flag is written carry the same answer + /// everywhere the library reads it, and a guarded query under each carries the trace alike. + /// + [Fact] + public void D2_Written_and_default_agree_everywhere_the_flag_is_read() + { + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + DwPolicyOptions unwritten = new() { Tier = tier }; + DwPolicyOptions written = new() { Tier = tier, IncludeTraceInResult = TraceInResult(unwritten) }; + + Assert.Equal(TraceInResult(unwritten), TraceInResult(written)); + + FilterResult left = Run(unwritten); + FilterResult right = Run(written); + + _out.WriteLine($"D2 {tier}: effective={TraceInResult(unwritten)} " + + $"left={left.Policy is not null} right={right.Policy is not null}"); + + Assert.Equal(left.Policy is not null, right.Policy is not null); + Assert.Equal(TraceInResult(unwritten), left.Policy is not null); + } + + FilterResult Run(DwPolicyOptions options) => + _db.Roles.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(Where("Code", "admin")); + } + + /// + /// An accepted second call replaces nothing: the resolver, the store providers and the + /// posture the query path reads are all the first call's. A second host supplying sources of + /// the same types therefore hands them over and has them dropped. + /// + [Fact] + public void D3_An_accepted_second_call_replaces_neither_the_resolver_nor_the_sources() + { + PolicyBootstrap.Ensure(); + + DwPolicyOptions optionsBefore = DwPolicy.Options; + PolicyResolver resolverBefore = DwPolicy.Resolver; + IReadOnlyList storesBefore = DwPolicy.StoreProviders; + + DwPolicy.Configure(CopyOfInForce()); + + _out.WriteLine($"D3 options same={ReferenceEquals(optionsBefore, DwPolicy.Options)} " + + $"resolver same={ReferenceEquals(resolverBefore, DwPolicy.Resolver)} " + + $"stores={DwPolicy.StoreProviders.Count}"); + + Assert.Same(optionsBefore, DwPolicy.Options); + Assert.Same(resolverBefore, DwPolicy.Resolver); + Assert.Same(storesBefore, DwPolicy.StoreProviders); + } + + private static bool TraceInResult(DwPolicyOptions options) => + (bool)typeof(DwPolicyOptions) + .GetProperty("TraceInResult", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(options)!; + + private static DwPolicyOptions CopyOfInForce() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicyOptions copy = new() + { + Tier = inForce.Tier, + DryRun = inForce.DryRun, + AuditRefusals = inForce.AuditRefusals, + StoreFailure = inForce.StoreFailure, + MaxSnapshotAge = inForce.MaxSnapshotAge, + RefreshInterval = inForce.RefreshInterval + }; + + if (!string.IsNullOrEmpty(inForce.HashSalt)) + { + copy.HashSalt = inForce.HashSalt; + } + + foreach (PropertyInfo cap in typeof(DwCaps) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.CanWrite && property.PropertyType == typeof(int))) + { + if (cap.Name == nameof(DwCaps.MinGroupSize) && !inForce.Caps.IsMinGroupSizeSet) + { + continue; + } + + cap.SetValue(copy.Caps, cap.GetValue(inForce.Caps)); + } + + foreach (KeyValuePair exposed in inForce.Entities.Entities) + { + copy.Entities.Expose(exposed.Key, exposed.Value); + } + + return copy; + } + + // ==== E. the refusal surface ================================================================ + + /// + /// A name that matches nothing, a field denied for everything, and a member the query cannot + /// compute must be one refusal, field by field. + /// + [Fact] + public void E1_The_three_refusals_are_one_refusal() + { + string Surface(string field) + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(Where(field, "x"))); + + string? audit = (string?)typeof(PolicyException) + .GetProperty("AuditPath", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(refusal); + + return $"code={refusal.ErrorCode} path='{refusal.FieldPath}' feature={refusal.Feature} " + + $"tier={refusal.Tier} rule='{refusal.RuleId}' origin='{refusal.SourceOrigin}' " + + $"msg='{refusal.Message}' audit='{audit}'"; + } + + // The three have to be spelled the same length apart so the audit path, which is the + // caller's own string, is the only thing that may differ. + string missing = Surface("Nonexistent"); + string denied = Surface("Secret"); + string uncomputable = Surface("Display"); + + _out.WriteLine($"E1 missing: {missing}"); + _out.WriteLine($"E1 denied: {denied}"); + _out.WriteLine($"E1 uncomputable: {uncomputable}"); + + Assert.Equal( + missing.Replace("Nonexistent", "@"), + denied.Replace("Secret", "@")); + + Assert.Equal( + missing.Replace("Nonexistent", "@"), + uncomputable.Replace("Display", "@")); + } + + /// + /// The canonical path of a member the query cannot compute reaches the in-process trace and + /// nothing a caller holds: a refused query returns no result to carry it. + /// + [Fact] + public void E2_The_canonical_path_reaches_the_in_process_trace_only() + { + PolicyQueryable guarded = Guard(_db.Roles, DwTier.Strict, traceInResult: true); + + PolicyException refusal = Assert.ThrowsAny( + () => guarded.ToList(WhereEmpty("Name.IsEmpty"))); + + Assert.DoesNotContain("IsEmpty", refusal.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("IsEmpty", refusal.FieldPath, StringComparison.OrdinalIgnoreCase); + + PolicyTrace trace = Assert.IsType(guarded.LastTrace); + + string recorded = string.Join( + " | ", trace.Decisions.Select(d => $"{d.FieldPath}/{d.Action}/{d.Reason}")); + + _out.WriteLine($"E2 in-process trace: {recorded}"); + + Assert.Contains( + trace.Decisions, + d => d.Reason is not null && d.Reason.Contains("cannot compute")); + } + + /// + /// An alias hides the canonical path from the caller. The refusal for a member the query + /// cannot compute must not hand it back. + /// + [Fact] + public void E3_An_alias_does_not_leak_its_target_through_the_new_refusal() + { + PolicyException refusal = Assert.ThrowsAny( + () => Guard(_db.Roles, DwTier.Strict).ToList(WhereEmpty("Name.IsEmpty"))); + + _out.WriteLine($"E3 path='{refusal.FieldPath}' rule='{refusal.RuleId}' origin='{refusal.SourceOrigin}'"); + + Assert.DoesNotContain("Name", refusal.FieldPath, StringComparison.OrdinalIgnoreCase); + Assert.Null(refusal.RuleId); + Assert.Null(refusal.SourceOrigin); + } + + /// + /// A caller guessing at the schema one name at a time. Every refusal has to be the same + /// refusal, whatever the reason: a name that matches nothing, a field denied for everything, + /// a member the query cannot compute, and a path beneath one. + /// + [Fact] + public void E4_Guessing_at_the_schema_gets_one_answer_for_every_kind_of_no() + { + string Probe(string field) + { + try + { + return $"rows={Guard(_db.Roles, DwTier.Strict).ToList(Where(field, "x")).Data.Count}"; + } + catch (PolicyException refusal) + { + return $"{refusal.ErrorCode}/{refusal.FieldPath}/{refusal.Feature}/" + + $"{refusal.RuleId}/{refusal.SourceOrigin}/{refusal.Message}"; + } + } + + string[] refused = { "Nonexistent", "Secret", "Display", "Name.IsEmpty", "Other.IsEmpty" }; + string[] answered = { "Code", "Cost", "Name.En" }; + + List outcomes = refused.Select(Probe).ToList(); + + foreach (string line in refused.Zip(outcomes, (name, outcome) => $"{name} -> {outcome}")) + { + _out.WriteLine($"E4 {line}"); + } + + Assert.Single(outcomes.Distinct()); + + foreach (string field in answered) + { + string outcome = Probe(field); + + _out.WriteLine($"E4 {field} -> {outcome}"); + + Assert.StartsWith("rows=", outcome); + } + } + + // ==== F. the composed handle, where a projection is no longer last =========================== + + /// + /// The projection exemption rests on "EF Core evaluates the last projection on the client". + /// The composable handle lets a caller put an order, a page and a filter after it. + /// + [Fact] + public void F1_A_projection_the_caller_composes_past_is_still_answered_or_refused() + { + foreach (string field in new[] { "Display" }) + { + PolicyQueryable projected = + Guard(_db.Roles, DwTier.Strict).Select(new List { "Id", field }); + + Exception? ordered = Record.Exception( + () => projected.Order(new OrderBy { Field = "Id", Direction = Direction.Ascending }) + .ToList(new Filter())); + + Exception? paged = Record.Exception( + () => Guard(_db.Roles, DwTier.Strict).Select(new List { "Id", field }) + .Page(new PageBy { PageNumber = 1, PageSize = 10 }) + .ToList(new Filter())); + + _out.WriteLine($"F1 {field} order-after-select: {ordered?.GetType().Name ?? "ran"}: {ordered?.Message}"); + _out.WriteLine($"F1 {field} page-after-select: {paged?.GetType().Name ?? "ran"}: {paged?.Message}"); + + Assert.True(ordered is null or PolicyException, $"{ordered?.GetType().Name}: {ordered?.Message}"); + Assert.True(paged is null or PolicyException, $"{paged?.GetType().Name}: {paged?.Message}"); + } + } + } +} + +#pragma warning restore EF1001 diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs index 3170969..7f0f825 100644 --- a/DynamicWhere.ex/Policies/Source/RowShape.cs +++ b/DynamicWhere.ex/Policies/Source/RowShape.cs @@ -72,6 +72,11 @@ internal sealed class RowShape // Name = x.Name records ("Name", the entity, "Name"), so what is beneath it is read from the model. private readonly Dictionary? _copied; + // The paths whose value this shape could not read: a method call, a captured value, a subquery, + // two branches building the member two ways. The row carries the member; whether the query can + // compute it is not something the initializer says, so the answer for it is "cannot say". + private readonly HashSet? _opaque; + // True when EF Core's own provider translates the query. What a query can compute is the // translating provider's answer to give; a provider in front of EF Core, or in place of it, has // rules of its own, so its rows are left alone. @@ -94,12 +99,14 @@ private RowShape( IDictionary members, Dictionary>? initialized = null, Dictionary? copied = null, - bool translated = false) + bool translated = false, + HashSet? opaque = null) : this(kind) { _assigned = assigned; _initialized = initialized; _copied = copied; + _opaque = opaque; _translated = translated; _narrowable.UnionWith(narrowable); Built = built; @@ -282,7 +289,8 @@ internal bool Materializes(string path) /// /// Whether the query behind these rows can express a path the caller named: true when every /// segment is something the source produces, false when a segment provably is not, and null when - /// the shape cannot say. + /// the shape cannot say. Only false is acted on: true is the absence of a reason to refuse + /// rather than a promise that the provider computes the path. /// /// /// A member exists on the row's type and still has no value the database can compute. @@ -340,6 +348,13 @@ internal bool Materializes(string path) return null; } + if (_opaque is not null && _opaque.Contains(string.Join(".", segments))) + { + // The initializer assigns the member from something this shape cannot read, so it says + // nothing about whether the query computes it. + return null; + } + if (_assigned is not null && !_assigned.Contains(segments[0])) { // Nothing assigns it, so the projection carries no value for it at all. @@ -470,19 +485,16 @@ private static bool Named(object model, string name) => /// expression, which such a wrapper leaves in place, so the provider is what tells the two /// apart. EF Core's own provider is matched by name: no internal type is referenced, and every /// version from EF Core 6 to 10 answers the same. + /// + /// The type itself, not a type derived from it. EF Core's provider derives from object in + /// every version, and the queryable a DbSet hands out carries that exact type through + /// every operator, so walking base types would match no EF Core query that an exact comparison + /// misses — and it would match a wrapper built by deriving from it, which is the very thing this + /// leaves alone. + /// /// - private static bool EfCoreOwns(IQueryProvider provider) - { - for (Type? type = provider.GetType(); type is not null; type = type.BaseType) - { - if (type.FullName == "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider") - { - return true; - } - } - - return false; - } + private static bool EfCoreOwns(IQueryProvider provider) => + provider.GetType().FullName == "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider"; /// /// The column the queried type maps under a name, or null. @@ -827,7 +839,7 @@ private static RowShape Projected(IQueryable source) where T : class return new RowShape( RowKind.Projected, assigned, narrowable, initializer.Type, built, initialized, copied, - EfCoreOwns(source.Provider)); + EfCoreOwns(source.Provider), opaque); } /// From 7cb286667e30f095dae3756c251a183edb53a11b Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 14:30:45 +0300 Subject: [PATCH 16/54] docs: which provider the rule belongs to, and how far a projection is read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider test is EF Core's own type rather than a type derived from it, and a rewrite inside EF Core's own pipeline — a [DbFunction], a member-translator plugin, a replaced query preprocessor — leaves EF Core's provider in place, so a member it computes without a mapping is refused with the rest. A projection is read only as far as its initializer can be read: a nested initializer, a member copied from the entity, a value built and left empty, a null, and a conditional over those. A member assigned from a method call, a captured value, a subquery, or by two branches that build it two ways is left alone and still fails in the provider, as it did before 3.3.0, and past MaxComplexDepth the shape stops reading and stops speaking. The source table says so, and the sentence promising a deep copy no longer says it twice. Co-Authored-By: Claude Opus 5 --- DynamicWhere.ex/DOC.md | 11 +++++++---- .../app/docs/breaking-changes/page.tsx | 17 ++++++++++++++++- .../app/docs/policies/security/page.tsx | 5 +++-- OfficialWebsite/public/llms.txt | 14 +++++++++++--- README.md | 2 +- 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index 20c26cf..98df769 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -385,7 +385,7 @@ Combines filtering, selecting, ordering, and pagination in a single object. | `Orders` | `List?` | Optional sort criteria | | `Page` | `PageBy?` | Optional pagination | -**`Clone()`** *(public since 3.3.0)* returns a deep copy — every node new, though the values a condition carries stay the caller's own objects in a new list — the condition tree with its groups and conditions, the projection list, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. +**`Clone()`** *(public since 3.3.0)* returns a deep copy — the condition tree with its groups and conditions, the projection list, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. --- @@ -400,7 +400,7 @@ Combines multiple condition sets with set operations (Union / Intersect / Except | `Orders` | `List?` | Optional sort criteria | | `Page` | `PageBy?` | Optional pagination | -**`Clone()`** *(public since 3.3.0)* returns a deep copy — every node new, though the values a condition carries stay the caller's own objects in a new list — every condition set with its own condition group, the projection list, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. +**`Clone()`** *(public since 3.3.0)* returns a deep copy — every condition set with its own condition group, the projection list, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. --- @@ -416,7 +416,7 @@ Combines filtering → grouping → having → ordering → pagination for aggre | `Orders` | `List?` | Sort on grouped result. Fields must be GroupBy fields or aggregate aliases | | `Page` | `PageBy?` | Optional pagination on grouped result | -**`Clone()`** *(public since 3.3.0)* returns a deep copy — every node new, though the values a condition carries stay the caller's own objects in a new list — the condition group, the group-by with its aggregates, the having clause, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. +**`Clone()`** *(public since 3.3.0)* returns a deep copy — the condition group, the group-by with its aggregates, the having clause, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. --- @@ -1703,6 +1703,7 @@ It is refused only where the whole set of members a container can produce is kno |---|---|---| | An entity | the EF Core model: columns, shadow properties, owned and complex members, navigations | Refused | | A row a `Select` built before `ApplyPolicy` | the initializer's own assignments, at every level, both branches of a conditional included | Refused | +| …where the initializer assigns the member from something else: a method call, a captured value, a subquery, or two branches building it two ways | nothing — the assignment is not one this shape reads | Left alone, as it always did | | …where that `Select` copies the member from the entity, `Name = role.Name` | the model, beneath the member it copies | Refused | | Rows in memory | nothing — the getter runs | Runs, as it always did | | Anything beneath a column, a converted one included | nothing — the converter decides | Left alone, as it always did | @@ -1712,11 +1713,13 @@ It is refused only where the whole set of members a container can produce is kno | A column only a subtype maps, queried through the base | the queried type's model, which is what EF Core translates against | Refused | | A projection a provider that is not EF Core's ran | nothing — its rules are its own | Left alone, as it always did | +A projection is therefore read only as far as its initializer can be read. An entity query names every producible member from the model; a projection names them only where each assignment is a nested initializer, a member copied from the entity, a value built and left empty, a null, or a conditional over those. Past `MaxComplexDepth` — eight levels — it stops reading and stops speaking. Under `Strict` such a path still reaches the provider and still fails there, exactly as it did before 3.3.0. + **Left alone** is not a promise that the path runs. The policy does not refuse it, so it behaves exactly as it does unguarded: `Name.IsEmpty` beneath a column mapped through a value converter still fails inside the provider, as it always has. An unmapped getter on the entity itself, `Display => $"{Code}:{Id}"`, is refused for the same reason. The convenience tier and a dry run are unchanged: both fail exactly as the unguarded query does, which is the provider's own error. The trace records the refusal — `the member exists on the type and the query cannot compute it` — and since 3.3.0 `LastTrace` is set before a request is sanitized, so a refusal leaves it readable rather than null. -The rule is EF Core's own provider's. A provider that wraps EF Core — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — exists to rewrite the members EF Core cannot translate, so a member it computes is one the query produces and it is left alone, over a projection and over an entity alike. A row the library itself projected is read like any other: the core's typed `Select` null-guards every nested node it builds, and both branches of that guard are read, so composing `Select` and then filtering refuses exactly what the bare handle refuses. +The rule is EF Core's own provider's — that type, not a type derived from it. A provider that wraps EF Core — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — exists to rewrite the members EF Core cannot translate, so a member it computes is one the query produces and it is left alone, over a projection and over an entity alike; one built by deriving from EF Core's provider rewrites in the same way and is left alone too. A rewrite *inside* EF Core's own pipeline is a different matter: a `[DbFunction]`, a member-translator plugin or a replaced query preprocessor leaves EF Core's own provider in place, so a member it computes without a mapping is refused with the rest. A row the library itself projected is read like any other: the core's typed `Select` null-guards every nested node it builds, and both branches of that guard are read, so composing `Select` and then filtering refuses exactly what the bare handle refuses. A refusal here raises no `[DwAudit]` event, for the same reason an unknown name raises none: no field was read, and the refusal names none. `AuditRefusals` records it, and so does the trace. diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index 10381c1..fb1f1ba 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -1493,6 +1493,7 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`} An entitythe EF Core model: columns, shadow properties, owned and complex members, navigationsRefused A row a Select built before ApplyPolicythat initializer's own assignments, at every level, both branches of a conditional includedRefused + …where the initializer assigns the member from something else: a method call, a captured value, a subquery, two branches building it two waysnothing — the assignment is not one this shape readsLeft alone, as before …where the Select copies the member, Name = role.Namethe model, beneath the member it copiesRefused Rows in memorynothing — the getter runsRuns, as before Anything beneath a column, converted or notnothing — the converter decidesLeft alone, as before @@ -1503,6 +1504,16 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}A projection a provider that is not EF Core's rannothing — its rules are its ownLeft alone, as before +

          + A projection is read only as far as its initializer can be read. An + entity query names every producible member from the model; a projection + names them only where each assignment is a nested initializer, a member + copied from the entity, a value built and left empty, a null, or a + conditional over those. Past MaxComplexDepth — eight levels + — it stops reading and stops speaking, and under Strict + such a path still reaches the provider and still fails there, exactly as + it did before 3.3.0. +

          Left alone is not a promise that the path runs. The policy does not refuse it, so it behaves exactly as it does unguarded:{" "} @@ -1528,7 +1539,11 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}AsExpandable() and DelegateDecompiler's Decompile() exist to rewrite the members EF Core cannot translate, so a member they compute is one the - query produces, over a projection and over an entity alike. A row the + query produces, over a projection and over an entity alike. The test is + EF Core's own provider type, not a type derived from it: a provider + built by deriving rewrites in the same way, and EF Core's own + derives from object in every version, so nothing real is + lost by the exact test. A row the library itself projected is read like any other: the core's typed{" "} Select null-guards every nested node it builds, and both branches of that guard are read, so composing Select and diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx index cef73fe..7811228 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -227,8 +227,9 @@ true order. Add [DwNoOrder] unless that is intended.`} dry run are all unchanged, and fail exactly as the unguarded query does. So is a query a provider in front of EF Core translates — LinqKit's{" "} AsExpandable(), DelegateDecompiler's{" "} - Decompile() — since such a provider exists to rewrite what - EF Core cannot, and a member it computes is one the query produces. + Decompile(), or one built by deriving from EF Core's + own provider — since such a provider exists to rewrite what EF Core + cannot, and a member it computes is one the query produces.

          The refusal raises no [DwAudit] event, for the reason an diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index 71f97ca..44b6a80 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -2333,6 +2333,9 @@ entity query EF Core model of the querie (shadow included), owned, complex, navigations refused Select(...) before ApplyPolicy building the row that initializer's assignments, at every level, both branches of a conditional included refused + ... a member assigned from something else: a nothing; the assignment is not one this shape + method call, a captured value, a subquery, reads + two branches building it two ways left alone ... a member it copies whole: Name = role.Name the model, beneath the copied member refused rows in memory (EnumerableQuery) nothing; the getter runs runs beneath a column, converted or not nothing; the converter decides left alone @@ -2345,13 +2348,18 @@ a column only a subtype maps, through the base the queried type's model, w a projection another provider ran nothing; its rules are its own left alone ``` +- A projection is read only as far as its initializer can be read: a nested initializer, a member copied from the + entity, a value built and left empty, a null, and a conditional over those. Past `MaxComplexDepth` (eight levels) + it stops reading and stops speaking. Such a path reaches the provider and fails there, as before 3.3.0. - `left alone` is not a promise that the path runs: the policy does not refuse it, so it behaves exactly as it does unguarded. `Name.IsEmpty` beneath a column mapped through a value converter still fails inside the provider. - An unmapped getter on the entity itself (`Display => $"{Code}:{Id}"`) is refused for the same reason. -- The rule is EF Core's own provider's. A provider that wraps EF Core — LinqKit's `AsExpandable()`, - DelegateDecompiler's `Decompile()` — rewrites what EF Core cannot translate, so its rows are left alone, over a - projection and over an entity alike. +- The rule is EF Core's own provider's — that type, not a type derived from it. A provider that wraps EF Core — + LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — rewrites what EF Core cannot translate, so its + rows are left alone, over a projection and over an entity alike; one deriving from EF Core's provider rewrites the + same way and is left alone too. A rewrite inside EF Core's own pipeline — a `[DbFunction]`, a member-translator + plugin, a replaced query preprocessor — leaves EF Core's provider in place, so such a member is refused. - A row the library itself projected reads like any other: the core's typed `Select` null-guards every nested node it builds, and both branches of that guard are read, so `Select` composed and then filtered refuses what the bare handle refuses. diff --git a/README.md b/README.md index a9ec82d..498d40b 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ The complete reference — every enum, class, extension method, validation rule, **Upgrade note — two behaviour changes. Read these before bumping.** - **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a value's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor, and `IncludeTraceInResult` written as the tier's own answer is the answer the tier already gives. A type exposed under two names is reported under the last one, so two catalogues that resolve every name alike are still a different posture when the order differs. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. -- **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query a provider in front of EF Core translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — because such a provider exists to rewrite what EF Core cannot, and a member it computes is a member the query produces. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. +- **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query a provider in front of EF Core translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()`, or one built by deriving from EF Core's own provider — because such a provider exists to rewrite what EF Core cannot, and a member it computes is a member the query produces. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. - **Changed: `LastTrace` is set before a request is sanitized**, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. - **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. Every node is new; a condition's values stay the caller's own objects, in a new list. - **Docs:** `[DwEntity(DefaultOrder)]`'s own remarks said a projected query takes no default; it has since 3.2.0. `DwCaps.MinGroupSize` ships **on at 5**, so a guarded summary silently drops groups of fewer than five rows — the aggregation docs now lead with that instead of leaving it to the caps table. From 56d169d9d292b2e44eeaac2daa8eea0224316d46 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 14:36:08 +0300 Subject: [PATCH 17/54] docs: the third review's corrections, and the probes that hold them Seven places where the text and the code had parted company. The security page said a path the policy leaves alone fails as the unguarded query does. Most of them run: rows in memory and a framework member return rows, and beneath a converted column the provider decides. It was the one page round 2's "left alone is not a promise that the path runs" never reached, and it carried the stronger claim. The in-memory summary entry was the one aggregation entry with no group floor, and the floor applies there: ApplyPolicy takes an IEnumerable and hands it to the same guarded queryable, so a summary read that way drops groups of fewer than five rows like any other. README's own bullet had lost the Segment clauses the other three documents carry. A cap's default is not generally compared by the value that applies: only the group floor's getter answers that way, and DefaultPageSize is applied as the smaller of itself and MaxPageSize while being compared as written. The generalisation is now the one true sentence. A [DbFunction] maps a method, and no field path can name a method, so it was never an example of a member the refusal reaches; a member translator plugin and a replaced query preprocessor are. The website's Condition page, which is where a caller reads about Values, now carries the limit that a value is read more than once. Co-Authored-By: Claude Opus 5 --- .../Policies/DocsReview33ProbeC.cs | 1036 +++++++++++++++++ DynamicWhere.ex/DOC.md | 6 +- .../app/docs/breaking-changes/page.tsx | 8 +- .../app/docs/classes/condition/page.tsx | 9 + .../app/docs/policies/security/page.tsx | 5 +- OfficialWebsite/public/llms.txt | 4 +- README.md | 2 +- 7 files changed, 1059 insertions(+), 11 deletions(-) create mode 100644 DynamicWhere.Tests/Policies/DocsReview33ProbeC.cs diff --git a/DynamicWhere.Tests/Policies/DocsReview33ProbeC.cs b/DynamicWhere.Tests/Policies/DocsReview33ProbeC.cs new file mode 100644 index 0000000..1cc73e8 --- /dev/null +++ b/DynamicWhere.Tests/Policies/DocsReview33ProbeC.cs @@ -0,0 +1,1036 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Discovery; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// Round three of the docs-against-code probe for 3.3.0. Every fact here is read by running the +// library, never by reading it: each test prints what happened so the report can quote an outcome. +// Nothing here fixes anything. + +namespace DynamicWhere.Tests.Policies +{ + // ---- a model whose computed member and whose mapped member are both audited ------------------ + + public class DrcText + { + public string Ar { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.All)] + public string En { get; set; } = string.Empty; + + ///

          Audited too, so a refusal that recorded a use would show here. + [DwAudit(PolicyFeature.All)] + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + public class DrcRole + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public DrcText Name { get; set; } = new(); + } + + public sealed class DrcContext : DbContext + { + private readonly SqliteConnection _connection; + + public DrcContext(SqliteConnection connection) => _connection = connection; + + public DbSet Roles => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) => model.Entity().OwnsOne(role => role.Name); + } + + /// A value whose text answers differently on every read. + public sealed class DrcDrifting + { + private int _reads; + + public int Reads => _reads; + + public override string ToString() => (++_reads).ToString(); + } + + public sealed class DrcSink : IDwAuditSink + { + public List Events { get; } = new(); + + public ValueTask WriteAsync(DwAuditEvent auditEvent, CancellationToken ct = default) + { + Events.Add(auditEvent); + + return default; + } + } + + public sealed class DrcTypeOne + { + } + + // ============================================================================================= + // 1. The rows of the source table that no test covers: a provider standing in front of EF Core. + // ============================================================================================= + + public sealed class DrcProviderInFrontProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyContext _db; + + public DrcProviderInFrontProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyContext(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { En = "Admin", Ar = "مدير" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + internal static bool? Ask(IQueryable source, string path) + { + object shape = typeof(RowShape) + .GetMethod("Of", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)! + .MakeGenericMethod(source.ElementType) + .Invoke(null, new object[] { source })!; + + return (bool?)typeof(RowShape) + .GetMethod("Expresses", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(shape, new object[] { path }); + } + + internal static string Show(bool? answer) => answer is null ? "null (left alone)" : answer.ToString()!; + + internal static PolicyQueryable Guard(IQueryable source, DwTier tier) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + internal static Filter Where(string field, string value, DataType type = DataType.Text) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + /// + /// The table's row "a provider in front of EF Core", over an entity. The wrapper leaves the EF + /// Core query root in the expression, so the model is readable; the provider is not EF Core's. + /// + [Fact] + public void A_provider_in_front_of_EF_Core_over_an_entity_is_left_alone() + { + IQueryable wrapped = new ZyOwnProvider(_db.Roles); + + bool? bare = Ask(_db.Roles, "Name.IsEmpty"); + bool? front = Ask(wrapped, "Name.IsEmpty"); + + _out.WriteLine($"DbSet Expresses(\"Name.IsEmpty\") = {Show(bare)}"); + _out.WriteLine($"a provider in front of it, entity Expresses(\"Name.IsEmpty\") = {Show(front)}"); + + Exception? guarded = Record.Exception( + () => Guard(wrapped, DwTier.Strict).ToList(Where("Name.IsEmpty", "false", DataType.Boolean))); + + Exception? unguarded = Record.Exception(() => _db.Roles.Where(role => role.Name.IsEmpty).ToList()); + + _out.WriteLine($" guarded -> {guarded?.GetType().Name ?? "ran"}"); + _out.WriteLine($" unguarded-> {unguarded?.GetType().Name ?? "ran"}"); + + Assert.False(bare); + Assert.Null(front); + Assert.False(guarded is PolicyException, $"refused: {guarded?.Message}"); + } + + /// The table's row "a projection a provider that is not EF Core's ran", EF-backed. + [Fact] + public void A_provider_in_front_of_EF_Core_over_a_projection_is_left_alone() + { + IQueryable built = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + IQueryable front = new ZyOwnProvider(built); + + _out.WriteLine($"EF Core's own projection Expresses(\"Name.IsEmpty\") = {Show(Ask(built, "Name.IsEmpty"))}"); + _out.WriteLine($"the same behind another provider Expresses(\"Name.IsEmpty\") = {Show(Ask(front, "Name.IsEmpty"))}"); + + Assert.False(Ask(built, "Name.IsEmpty")); + Assert.Null(Ask(front, "Name.IsEmpty")); + } + + /// What the library matches on, so the report can say which providers answer which way. + [Fact] + public void EF_Core_s_own_provider_is_what_decides() + { + MethodInfo owns = typeof(RowShape).GetMethod("EfCoreOwns", BindingFlags.Static | BindingFlags.NonPublic)!; + + IQueryable efCore = _db.Roles; + + bool ef = (bool)owns.Invoke(null, new object[] { efCore.Provider })!; + bool wrapper = (bool)owns.Invoke(null, new object[] { new ZyOwnProvider(efCore).Provider })!; + bool memory = (bool)owns.Invoke(null, new object[] { new[] { new ZyRole() }.AsQueryable().Provider })!; + + _out.WriteLine($"EF Core's own provider -> {ef} ({efCore.Provider.GetType().FullName})"); + _out.WriteLine($"a provider in front -> {wrapper}"); + _out.WriteLine($"EnumerableQuery -> {memory}"); + + Assert.True(ef); + Assert.False(wrapper); + Assert.False(memory); + } + } + + // ============================================================================================= + // 2. Every clause the text lists, and the two the text lists inside a Segment. + // ============================================================================================= + + public sealed class DrcClauseProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyContext _db; + + public DrcClauseProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyContext(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { En = "Admin", Ar = "مدير" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private PolicyQueryable Guarded(DwTier tier = DwTier.Strict) => + DrcProviderInFrontProbe.Guard(_db.Roles, tier); + + private static ConditionSet Set(int sort, string field, Intersection? intersection = null) => new() + { + Sort = sort, + Intersection = intersection, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, + DataType = field.EndsWith("IsEmpty", StringComparison.Ordinal) ? DataType.Boolean : DataType.Text, + Operator = Operator.Equal, + Values = { field.EndsWith("IsEmpty", StringComparison.Ordinal) ? "false" : "Admin" } + } + } + } + }; + + [Fact] + public void The_five_clauses_the_text_names_each_refuse() + { + Report("a filter", Record.Exception( + () => Guarded().ToList(DrcProviderInFrontProbe.Where("Name.IsEmpty", "false", DataType.Boolean))), + PolicyErrorCode.FieldDeniedForWhere); + + Report("an order", Record.Exception(() => Guarded().ToList(new Filter + { + Orders = new List { new() { Sort = 1, Field = "Name.IsEmpty", Direction = Direction.Ascending } } + })), PolicyErrorCode.FieldDeniedForOrder); + + Report("a grouping key", Record.Exception(() => Guarded().ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Name.IsEmpty" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + })), PolicyErrorCode.FieldDeniedForGroup); + + Report("an aggregated field", Record.Exception(() => Guarded().ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List + { + new() { Alias = "N", Aggregator = Aggregator.Count }, + new() { Alias = "M", Field = "Name.IsEmpty", Aggregator = Aggregator.Maximum } + } + } + })), PolicyErrorCode.FieldDeniedForAggregate); + + void Report(string label, Exception? error, PolicyErrorCode expected) + { + PolicyException refusal = Assert.IsType(error); + + _out.WriteLine($"{label,-22} -> {refusal.ErrorCode} FieldPath={refusal.FieldPath}"); + + Assert.Equal(expected, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + } + + [Fact] + public async Task A_filter_inside_a_Segment_is_refused() + { + PolicyException refusal = await Assert.ThrowsAnyAsync( + () => Guarded().ToListAsync(new Segment { ConditionSets = { Set(1, "Name.IsEmpty") } })); + + _out.WriteLine($"a filter inside a Segment -> {refusal.ErrorCode} FieldPath={refusal.FieldPath}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSegment, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + [Fact] + public async Task An_order_inside_a_Segment_is_refused() + { + PolicyException refusal = await Assert.ThrowsAnyAsync( + () => Guarded().ToListAsync(new Segment + { + ConditionSets = { Set(1, "Name.En") }, + Orders = new List { new() { Sort = 1, Field = "Name.IsEmpty", Direction = Direction.Ascending } } + })); + + _out.WriteLine($"an order inside a Segment -> {refusal.ErrorCode} FieldPath={refusal.FieldPath}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSegment, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + /// The composable pair reaches the same refusal the terminals do. + [Fact] + public void The_composable_Group_and_Summary_refuse_it_too() + { + Exception? group = Record.Exception(() => Guarded().Group(new GroupBy + { + Fields = new List { "Name.IsEmpty" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + })); + + Exception? summary = Record.Exception(() => Guarded().Summary(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Name.IsEmpty" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + })); + + _out.WriteLine($"composable Group -> {Show(group)}"); + _out.WriteLine($"composable Summary -> {Show(summary)}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForGroup, Assert.IsType(group).ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForGroup, Assert.IsType(summary).ErrorCode); + + static string Show(Exception? error) => + error is PolicyException policy ? $"{policy.ErrorCode} FieldPath={policy.FieldPath}" : error?.GetType().Name ?? "ran"; + } + + /// + /// The aggregation entry DOC.md leaves without the floor note: a guarded in-memory summary. + /// + [Fact] + public void An_in_memory_summary_reached_through_ApplyPolicy_is_floored_too() + { + List rows = new(); + + for (int i = 0; i < 7; i++) + { + rows.Add(new ZyRole { Id = i + 1, Code = i < 5 ? "big" : "small", Name = new ZyLocalizedText() }); + } + + Summary summary = new() + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + }; + + SummaryResult unguarded = rows.ToList(summary); + + SummaryResult guarded = rows + .AsQueryable() + .ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(summary.Clone()); + + _out.WriteLine($"in-memory summary: unguarded {unguarded.Data.Count} group(s), guarded {guarded.Data.Count}"); + + Assert.Equal(2, unguarded.Data.Count); + Assert.Single(guarded.Data); + } + + /// Selects is the exemption, in a Filter and in a Segment, in both tiers. + [Fact] + public async Task Selects_stays_exempt_everywhere() + { + FilterResult strict = Guarded().ToList(new Filter { Selects = new List { "Id", "Name.IsEmpty" } }); + FilterResult easy = Guarded(DwTier.Convenience) + .ToList(new Filter { Selects = new List { "Id", "Name.IsEmpty" } }); + + SegmentResult segment = await Guarded().ToListAsync(new Segment + { + Selects = new List { "Id", "Name.IsEmpty" }, + ConditionSets = { Set(1, "Name.En") } + }); + + _out.WriteLine($"Selects: strict={strict.Data.Count} convenience={easy.Data.Count} segment={segment.Data.Count}"); + + Assert.Single(strict.Data); + Assert.Single(easy.Data); + Assert.Single(segment.Data); + } + } + + // ============================================================================================= + // 3. Both branches of a conditional, including the core's own typed Select. + // ============================================================================================= + + public sealed class DrcConditionalProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyContext _db; + + public DrcConditionalProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyContext(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new ZyRole { Code = "admin", Name = new ZyLocalizedText { En = "Admin", Ar = "مدير" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + /// + /// The claim in every document: "the core's typed Select null-guards every nested node it + /// builds, and both branches of that guard are read, so composing Select and then + /// filtering refuses exactly what the bare handle refuses." + /// + [Fact] + public void The_cores_own_typed_Select_composed_then_filtered_refuses_what_the_bare_handle_refuses() + { + IQueryable projected = _db.Roles.Select(new List { "Id", "Code", "Name.En" }); + + _out.WriteLine("the core's typed Select builds:"); + _out.WriteLine(" " + projected.Expression.ToString()); + _out.WriteLine($" Expresses(\"Name.IsEmpty\") = {DrcProviderInFrontProbe.Show(DrcProviderInFrontProbe.Ask(projected, "Name.IsEmpty"))}"); + _out.WriteLine($" Expresses(\"Name.En\") = {DrcProviderInFrontProbe.Show(DrcProviderInFrontProbe.Ask(projected, "Name.En"))}"); + + Exception? bare = Record.Exception(() => DrcProviderInFrontProbe + .Guard(_db.Roles, DwTier.Strict) + .ToList(DrcProviderInFrontProbe.Where("Name.IsEmpty", "false", DataType.Boolean))); + + Exception? composed = Record.Exception(() => DrcProviderInFrontProbe + .Guard(projected, DwTier.Strict) + .ToList(DrcProviderInFrontProbe.Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($" bare handle refuses with {Name(bare)}"); + _out.WriteLine($" composed Select refuses {Name(composed)}"); + + Assert.IsType(bare); + Assert.IsType(composed); + Assert.Equal(((PolicyException)bare!).ErrorCode, ((PolicyException)composed!).ErrorCode); + + static string Name(Exception? error) => + error is PolicyException policy ? $"{policy.ErrorCode}" : error?.GetType().Name ?? "no refusal"; + } + + /// A conditional written by hand, each branch a nested initializer. + [Fact] + public void A_conditional_whose_branches_are_both_initializers_is_read_on_both() + { + IQueryable rows = _db.Roles.Select(role => new ZyRoleRow + { + Id = role.Id, + Code = role.Code, + Name = role.Code == "admin" + ? new ZyLocalizedText { En = role.Name.En } + : new ZyLocalizedText { En = role.Name.En, Ar = role.Name.Ar } + }); + + bool? isEmpty = DrcProviderInFrontProbe.Ask(rows, "Name.IsEmpty"); + bool? en = DrcProviderInFrontProbe.Ask(rows, "Name.En"); + bool? ar = DrcProviderInFrontProbe.Ask(rows, "Name.Ar"); + + _out.WriteLine($"two initializer branches: IsEmpty={DrcProviderInFrontProbe.Show(isEmpty)} " + + $"En={DrcProviderInFrontProbe.Show(en)} Ar={DrcProviderInFrontProbe.Show(ar)} (union of the branches)"); + + Assert.False(isEmpty); + Assert.True(en); + Assert.True(ar); + } + } + + // ============================================================================================= + // 4. The audit: the refusal raises no [DwAudit] event, and AuditRefusals records it. + // ============================================================================================= + + public sealed class DrcAuditProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly DrcContext _db; + + public DrcAuditProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new DrcContext(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new DrcRole { Code = "admin", Name = new DrcText { En = "Admin", Ar = "مدير" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private (DwPolicyContext Context, PolicyQueryable Query) Guard(DwPolicyOptions options) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + return (context, _db.Roles.ApplyPolicy( + context, options, new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }))); + } + + [Fact] + public void An_audited_member_that_answers_records_a_use() + { + (DwPolicyContext context, PolicyQueryable query) = Guard(new DwPolicyOptions { Tier = DwTier.Strict }); + + query.ToList(DrcProviderInFrontProbe.Where("Name.En", "Admin")); + + _out.WriteLine($"filter on the audited Name.En -> {context.PendingAuditEvents.Count} event(s): " + + string.Join(", ", context.PendingAuditEvents.Select(e => $"{e.FieldPath}/{e.Feature}/{e.Effect}"))); + + Assert.NotEmpty(context.PendingAuditEvents); + } + + /// The claim: the refusal raises no [DwAudit] event, as an unknown name raises none. + [Fact] + public void The_refusal_raises_no_DwAudit_event() + { + (DwPolicyContext computed, PolicyQueryable one) = Guard(new DwPolicyOptions { Tier = DwTier.Strict }); + + Assert.ThrowsAny( + () => one.ToList(DrcProviderInFrontProbe.Where("Name.IsEmpty", "false", DataType.Boolean))); + + (DwPolicyContext unknown, PolicyQueryable two) = Guard(new DwPolicyOptions { Tier = DwTier.Strict }); + + Assert.ThrowsAny(() => two.ToList(DrcProviderInFrontProbe.Where("NoSuchMember", "x"))); + + _out.WriteLine($"a path the query cannot compute -> {computed.PendingAuditEvents.Count} [DwAudit] event(s)"); + _out.WriteLine($"a name that matches nothing -> {unknown.PendingAuditEvents.Count} [DwAudit] event(s)"); + + Assert.Empty(computed.PendingAuditEvents); + Assert.Empty(unknown.PendingAuditEvents); + } + + /// The other half of the same sentence: AuditRefusals records it, and so does the trace. + [Fact] + public void AuditRefusals_records_it_and_so_does_the_trace() + { + (DwPolicyContext context, PolicyQueryable query) = + Guard(new DwPolicyOptions { Tier = DwTier.Strict, AuditRefusals = true }); + + Assert.ThrowsAny( + () => query.ToList(DrcProviderInFrontProbe.Where("Name.IsEmpty", "false", DataType.Boolean))); + + _out.WriteLine($"AuditRefusals on -> {context.PendingAuditEvents.Count} event(s): " + + string.Join(", ", context.PendingAuditEvents.Select(e => $"{e.FieldPath}/{e.ErrorCode}"))); + + foreach (PolicyDecision decision in query.LastTrace!.Decisions) + { + _out.WriteLine($" trace: {decision.FieldPath} {decision.Feature} {decision.Action} — {decision.Reason}"); + } + + Assert.Single(context.PendingAuditEvents); + Assert.Contains( + query.LastTrace!.Decisions, + decision => decision.Reason is not null + && decision.Reason.Contains("the member exists on the type and the query cannot compute it", + StringComparison.Ordinal)); + } + } + + // ============================================================================================= + // 5. A simulation has no source, so it cannot refuse such a path at all. + // ============================================================================================= + + public sealed class DrcSimulationProbe + { + private readonly ITestOutputHelper _out; + + public DrcSimulationProbe(ITestOutputHelper output) => _out = output; + + private static PolicyResolver Resolver() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + [Fact] + public void A_simulation_cannot_refuse_a_path_no_database_can_compute() + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + PolicySimulation computed = PolicySimulator.Simulate( + DrcProviderInFrontProbe.Where("Name.IsEmpty", "false", DataType.Boolean), + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), options, Resolver()); + + PolicySimulation denied = PolicySimulator.Simulate( + DrcProviderInFrontProbe.Where("Name", "x"), + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), options, Resolver()); + + PolicySimulation unknown = PolicySimulator.Simulate( + DrcProviderInFrontProbe.Where("NoSuchMember", "x"), + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), options, Resolver()); + + _out.WriteLine($"simulate Name.IsEmpty -> WouldRun={computed.WouldRun} refusal={computed.Refusal?.ErrorCode.ToString() ?? "none"}"); + _out.WriteLine($"simulate Name (denied) -> WouldRun={denied.WouldRun} refusal={denied.Refusal?.ErrorCode.ToString() ?? "none"}"); + _out.WriteLine($"simulate NoSuchMember -> WouldRun={unknown.WouldRun} refusal={unknown.Refusal?.ErrorCode.ToString() ?? "none"}"); + + Assert.True(computed.WouldRun); + Assert.False(denied.WouldRun); + Assert.False(unknown.WouldRun); + } + + /// A Segment and a Summary simulate the same way, so the claim holds for every clause. + [Fact] + public void A_simulated_segment_and_summary_run_it_too() + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + PolicySimulation segment = PolicySimulator.Simulate( + new Segment + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Name.IsEmpty", + DataType = DataType.Boolean, + Operator = Operator.Equal, + Values = { "false" } + } + } + } + } + } + }, + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), options, Resolver()); + + PolicySimulation summary = PolicySimulator.Simulate( + new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Name.IsEmpty" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + }, + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), options, Resolver()); + + _out.WriteLine($"simulate a Segment naming it -> WouldRun={segment.WouldRun}"); + _out.WriteLine($"simulate a Summary naming it -> WouldRun={summary.WouldRun}"); + + Assert.True(segment.WouldRun); + Assert.True(summary.WouldRun); + } + } + + // ============================================================================================= + // 6. The posture comparison: the two sentences round two added. + // ============================================================================================= + + public sealed class DrcPostureProbe + { + private readonly ITestOutputHelper _out; + + public DrcPostureProbe(ITestOutputHelper output) => _out = output; + + private static readonly MethodInfo SameAs = typeof(DwEntityCatalog) + .GetMethod("SameAs", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static readonly MethodInfo SamePosture = typeof(DwPolicy) + .GetMethod("SamePosture", BindingFlags.Static | BindingFlags.NonPublic)!; + + private static bool Ask(DwEntityCatalog left, DwEntityCatalog right) => + (bool)SameAs.Invoke(left, new object[] { right })!; + + /// + /// "A type exposed under two names is reported under the last one, so two catalogues resolving + /// every name alike are still refused when the order differs." + /// + [Fact] + public void A_type_exposed_under_two_names_in_a_different_order_is_refused() + { + DwEntityCatalog first = new(); + first.Expose("alpha"); + first.Expose("beta"); + + DwEntityCatalog second = new(); + second.Expose("beta"); + second.Expose("alpha"); + + _out.WriteLine($"first resolves alpha -> {first.Resolve("alpha")?.Name}, beta -> {first.Resolve("beta")?.Name}, reported as '{first.NameOf(typeof(DrcTypeOne))}'"); + _out.WriteLine($"second resolves alpha -> {second.Resolve("alpha")?.Name}, beta -> {second.Resolve("beta")?.Name}, reported as '{second.NameOf(typeof(DrcTypeOne))}'"); + _out.WriteLine($"SameAs -> {Ask(first, second)}"); + + Assert.Equal(first.Resolve("alpha"), second.Resolve("alpha")); + Assert.Equal(first.Resolve("beta"), second.Resolve("beta")); + Assert.NotEqual(first.NameOf(typeof(DrcTypeOne)), second.NameOf(typeof(DrcTypeOne))); + Assert.False(Ask(first, second)); + } + + /// + /// "IncludeTraceInResult is compared by the value that applies": under a tier whose own + /// answer is the written one, the two postures are the same. + /// + [Fact] + public void IncludeTraceInResult_is_compared_by_the_value_that_applies() + { + foreach (DwTier tier in new[] { DwTier.Convenience, DwTier.Strict }) + { + bool tierAnswer = tier == DwTier.Convenience; + + DwPolicyOptions unset = Posture(tier, null); + DwPolicyOptions written = Posture(tier, tierAnswer); + DwPolicyOptions opposite = Posture(tier, !tierAnswer); + + bool sameWhenWritten = Compare(unset, written); + bool sameWhenOpposite = Compare(unset, opposite); + + _out.WriteLine($"{tier}: null vs {tierAnswer} (the tier's own answer) -> {sameWhenWritten}; " + + $"null vs {!tierAnswer} -> {sameWhenOpposite}"); + + Assert.True(sameWhenWritten); + Assert.False(sameWhenOpposite); + } + + static DwPolicyOptions Posture(DwTier tier, bool? trace) => + new() { Tier = tier, IncludeTraceInResult = trace }; + } + + /// The cap half of the same rule, on the one cap with a default of its own. + [Fact] + public void Every_cap_is_compared_by_the_value_that_applies() + { + DwPolicyOptions unset = new() { Tier = DwTier.Strict }; + DwPolicyOptions written = new() { Tier = DwTier.Strict, Caps = { MinGroupSize = DwCaps.DefaultMinGroupSize } }; + DwPolicyOptions higher = new() { Tier = DwTier.Strict, Caps = { MinGroupSize = DwCaps.DefaultMinGroupSize + 1 } }; + + _out.WriteLine($"unset MinGroupSize reads {unset.Caps.MinGroupSize} (IsMinGroupSizeSet={unset.Caps.IsMinGroupSizeSet}); " + + $"written reads {written.Caps.MinGroupSize} (IsMinGroupSizeSet={written.Caps.IsMinGroupSizeSet})"); + _out.WriteLine($"unset vs written -> {Compare(unset, written)}"); + _out.WriteLine($"unset vs {higher.Caps.MinGroupSize} -> {Compare(unset, higher)}"); + + Assert.True(Compare(unset, written)); + Assert.False(Compare(unset, higher)); + } + + /// + /// The one cap whose applied value is not the value written down, beside + /// MinGroupSize: DefaultPageSize is applied as MaxPageSize when it is + /// larger, so two postures that page a caller identically are still told apart. + /// + [Fact] + public void DefaultPageSize_above_MaxPageSize_is_compared_as_written_not_as_applied() + { + DwPolicyOptions inForce = new() + { + Tier = DwTier.Strict, Caps = { MaxPageSize = 100, DefaultPageSize = 100 } + }; + + DwPolicyOptions asked = new() + { + Tier = DwTier.Strict, Caps = { MaxPageSize = 100, DefaultPageSize = 1000 } + }; + + _out.WriteLine($"MaxPageSize {inForce.Caps.MaxPageSize}: DefaultPageSize " + + $"{inForce.Caps.DefaultPageSize} vs {asked.Caps.DefaultPageSize} " + + $"— both page a caller at {Math.Min(asked.Caps.DefaultPageSize, asked.Caps.MaxPageSize)}"); + _out.WriteLine($"SamePosture -> {Compare(inForce, asked)}"); + + Assert.False(Compare(inForce, asked)); + } + + /// Compares two postures the way Configure does, without configuring anything. + private static bool Compare(DwPolicyOptions inForce, DwPolicyOptions asked) => + (bool)SamePosture.Invoke(null, new object?[] { inForce, asked, Array.Empty() })!; + } + + // ============================================================================================= + // 7. Clone's contract: a new Values list holding the caller's own objects, on all three shapes. + // ============================================================================================= + + public sealed class DrcCloneProbe + { + private readonly ITestOutputHelper _out; + + public DrcCloneProbe(ITestOutputHelper output) => _out = output; + + private static Condition Sample(object value) => new() + { + Sort = 1, + Field = "Code", + DataType = DataType.Text, + Operator = Operator.Equal, + Values = new List { value } + }; + + [Fact] + public void Every_shape_gives_a_new_Values_list_holding_the_callers_own_objects() + { + object value = new DrcDrifting(); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup { Conditions = { Sample(value) } } + }; + + Segment segment = new() + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup { Conditions = { Sample(value) } } + } + } + }; + + Summary summary = new() + { + ConditionGroup = new ConditionGroup { Conditions = { Sample(value) } }, + Having = new ConditionGroup { Conditions = { Sample(value) } }, + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List { new() { Alias = "N", Aggregator = Aggregator.Count } } + } + }; + + Filter filterCopy = filter.Clone(); + Segment segmentCopy = segment.Clone(); + Summary summaryCopy = summary.Clone(); + + Check("Filter", filter.ConditionGroup!.Conditions[0], filterCopy.ConditionGroup!.Conditions[0]); + Check("Segment", segment.ConditionSets[0].ConditionGroup.Conditions[0], + segmentCopy.ConditionSets[0].ConditionGroup.Conditions[0]); + Check("Summary/where", summary.ConditionGroup!.Conditions[0], summaryCopy.ConditionGroup!.Conditions[0]); + Check("Summary/having", summary.Having!.Conditions[0], summaryCopy.Having!.Conditions[0]); + + // The clauses beside the condition tree. + Assert.NotSame(segment.ConditionSets[0], segmentCopy.ConditionSets[0]); + Assert.Equal(Intersection.Union, segmentCopy.ConditionSets[0].Intersection); + Assert.NotSame(summary.GroupBy, summaryCopy.GroupBy); + Assert.NotSame(summary.GroupBy!.AggregateBy[0], summaryCopy.GroupBy!.AggregateBy[0]); + + void Check(string label, Condition original, Condition copy) + { + _out.WriteLine($"{label,-16} condition new={!ReferenceEquals(original, copy)} " + + $"Values list new={!ReferenceEquals(original.Values, copy.Values)} " + + $"element shared={ReferenceEquals(original.Values[0], copy.Values[0])}"); + + Assert.NotSame(original, copy); + Assert.NotSame(original.Values, copy.Values); + Assert.Same(original.Values[0], copy.Values[0]); + } + } + + [Fact] + public void Clone_is_public_on_the_three_shapes_and_internal_on_the_parts() + { + foreach (Type type in new[] { typeof(Filter), typeof(Segment), typeof(Summary) }) + { + MethodInfo? clone = type.GetMethod("Clone", BindingFlags.Instance | BindingFlags.Public); + + _out.WriteLine($"{type.Name,-8} public Clone -> {(clone is null ? "absent" : clone.ReturnType.Name)}"); + + Assert.NotNull(clone); + Assert.Equal(type, clone!.ReturnType); + } + + foreach (Type type in new[] + { + typeof(ConditionGroup), typeof(Condition), typeof(ConditionSet), + typeof(GroupBy), typeof(AggregateBy), typeof(OrderBy), typeof(PageBy) + }) + { + _out.WriteLine($"{type.Name,-16} public Clone -> " + + $"{(type.GetMethod("Clone", BindingFlags.Instance | BindingFlags.Public) is null ? "absent" : "present")}"); + } + } + } + + // ============================================================================================= + // 8. A condition's values are read more than once. + // ============================================================================================= + + public sealed class DrcValueReadProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyContext _db; + + public DrcValueReadProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyContext(_connection); + _db.Database.EnsureCreated(); + + foreach (string code in new[] { "1", "2", "3" }) + { + _db.Roles.Add(new ZyRole { Code = code, Name = new ZyLocalizedText { En = code } }); + } + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + /// + /// "A value is read once to validate its format and again to build the predicate … one whose + /// ToString() answers differently each time is validated as one value and queried as + /// another." + /// + [Fact] + public void A_value_is_read_more_than_once_so_an_unstable_one_is_queried_as_another() + { + DrcDrifting drifting = new(); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 1, + Field = "Code", + DataType = DataType.Text, + Operator = Operator.Equal, + Values = new List { drifting } + } + } + } + }; + + FilterResult result = _db.Roles.ToList(filter, getQueryString: true); + + _out.WriteLine($"the value was read {drifting.Reads} time(s)"); + _out.WriteLine($"rows: {string.Join(", ", result.Data.Select(role => role.Code))}"); + _out.WriteLine($"SQL contains '= 2': {result.QueryString?.Contains("2", StringComparison.Ordinal)}"); + + Assert.True(drifting.Reads > 1, $"read {drifting.Reads} time(s)"); + + // Validated as the first reading, queried as a later one. + Assert.Single(result.Data); + Assert.NotEqual("1", result.Data[0].Code); + } + + /// No policy decision reads a value's content — only how many there are. + [Fact] + public void A_guarded_query_decides_nothing_from_a_value() + { + DrcDrifting drifting = new(); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 1, + Field = "Code", + DataType = DataType.Text, + Operator = Operator.Equal, + Values = new List { drifting } + } + } + } + }; + + FilterResult result = DrcProviderInFrontProbe.Guard(_db.Roles, DwTier.Strict).ToList(filter); + + _out.WriteLine($"guarded: the value was read {drifting.Reads} time(s), {result.Data.Count} row(s) came back"); + + Assert.True(drifting.Reads > 1); + } + } +} diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index 98df769..2b3dd5d 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -692,7 +692,7 @@ Materializes a `Summary` and returns a `SummaryResult`. Under `ApplyPolicy`, gro ### `.ToList(IEnumerable, Summary summary, bool getQueryString = false)` -In-memory variant for summary operations. +In-memory variant for summary operations. `ApplyPolicy` takes an `IEnumerable` too, and a summary read through it is floored like any other: groups smaller than `DwCaps.MinGroupSize` — **5 by default** — are dropped; see [k-anonymity](#k-anonymity--the-control-you-would-not-guess). **Returns:** `SummaryResult` @@ -1719,7 +1719,7 @@ A projection is therefore read only as far as its initializer can be read. An en An unmapped getter on the entity itself, `Display => $"{Code}:{Id}"`, is refused for the same reason. The convenience tier and a dry run are unchanged: both fail exactly as the unguarded query does, which is the provider's own error. The trace records the refusal — `the member exists on the type and the query cannot compute it` — and since 3.3.0 `LastTrace` is set before a request is sanitized, so a refusal leaves it readable rather than null. -The rule is EF Core's own provider's — that type, not a type derived from it. A provider that wraps EF Core — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — exists to rewrite the members EF Core cannot translate, so a member it computes is one the query produces and it is left alone, over a projection and over an entity alike; one built by deriving from EF Core's provider rewrites in the same way and is left alone too. A rewrite *inside* EF Core's own pipeline is a different matter: a `[DbFunction]`, a member-translator plugin or a replaced query preprocessor leaves EF Core's own provider in place, so a member it computes without a mapping is refused with the rest. A row the library itself projected is read like any other: the core's typed `Select` null-guards every nested node it builds, and both branches of that guard are read, so composing `Select` and then filtering refuses exactly what the bare handle refuses. +The rule is EF Core's own provider's — that type, not a type derived from it. A provider that wraps EF Core — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — exists to rewrite the members EF Core cannot translate, so a member it computes is one the query produces and it is left alone, over a projection and over an entity alike; one built by deriving from EF Core's provider rewrites in the same way and is left alone too. A rewrite *inside* EF Core's own pipeline is a different matter: a member-translator plugin or a replaced query preprocessor leaves EF Core's own provider in place, so a member it computes without a mapping is refused with the rest. A row the library itself projected is read like any other: the core's typed `Select` null-guards every nested node it builds, and both branches of that guard are read, so composing `Select` and then filtering refuses exactly what the bare handle refuses. A refusal here raises no `[DwAudit]` event, for the same reason an unknown name raises none: no field was read, and the refusal names none. `AuditRefusals` records it, and so does the trace. @@ -2440,7 +2440,7 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of The attribute walker does not descend into the framework's own types, which carry no policy attributes. Until 3.2.0 it took any namespace whose name started with `System` for the framework's, so an application namespace such as `SystemsCorp.Payroll` or `SystemX.Domain` got no policy beneath its types, and a `[DwDenied]` field on such a type, reached through a member, was returned, filterable and sortable. Fixed (security): only `System` and the namespaces beneath it are the framework's now, so a guarded request that filtered on, sorted by or selected such a field is refused or dropped, as for any denied field. 25. **Under `Strict`, a Path the Query Cannot Compute Is Refused** - Since 3.3.0 a path whose leaf is a member no database can produce — a getter over columns, such as `LocalizedText.IsEmpty`, or an unmapped getter on the entity — is refused with the clause's own code and `FieldPath` `"*"`, as an unknown name is. Until 3.3.0 the package accepted it and EF Core threw `InvalidOperationException`, which reached a caller as a five-hundred where the tier promises a refusal. It applies where the whole set of members a container can produce is known: an entity's model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, a query a provider in front of EF Core translates — an expression expander, a decompiler — the convenience tier and a dry run are all unchanged. A member a custom EF Core translator computes, through `[DbFunction]` or a translator plugin, is refused with the rest: map it, or filter on the columns beneath it. See [Blocked-action semantics](#blocked-action-semantics). + Since 3.3.0 a path whose leaf is a member no database can produce — a getter over columns, such as `LocalizedText.IsEmpty`, or an unmapped getter on the entity — is refused with the clause's own code and `FieldPath` `"*"`, as an unknown name is. Until 3.3.0 the package accepted it and EF Core threw `InvalidOperationException`, which reached a caller as a five-hundred where the tier promises a refusal. It applies where the whole set of members a container can produce is known: an entity's model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, a query a provider in front of EF Core translates — an expression expander, a decompiler — the convenience tier and a dry run are all unchanged. A member a custom EF Core translator computes, through a member translator plugin or a replaced query preprocessor, is refused with the rest: map it, or filter on the columns beneath it. See [Blocked-action semantics](#blocked-action-semantics). 26. **`LastTrace` Is Set Before a Request Is Sanitized** Since 3.3.0 `PolicyQueryable.LastTrace` carries the trace of a request that was refused. It used to be assigned after sanitizing returned, so a refusal left it holding the previous request's trace, or null on the first. A strict refusal names no field on purpose, and the trace is where the real path and reason live, so this is what makes one readable. Code that read `LastTrace` after catching a `PolicyException` and expected the earlier request's trace reads this request's now. diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index fb1f1ba..c3b3050 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -1530,8 +1530,8 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`} A member the model maps nowhere is one the database cannot compute, so the strict tier refuses it wherever the database has to. A member a - translator inside EF Core computes without a mapping — a{" "} - [DbFunction], a translator plugin — is refused with the + translator inside EF Core computes without a mapping — a member + translator plugin, a replaced query preprocessor — is refused with the rest: map it, or filter on the columns beneath it.

          @@ -1593,9 +1593,9 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}IncludeTraceInResult{" "} + difference: the group floor, and IncludeTraceInResult{" "} written as the tier's own answer, are compared by the value that - applies. A type exposed under two names is reported under the last one, + applies. Every other cap is compared as written. A type exposed under two names is reported under the last one, so two catalogues resolving every name alike are still refused when the order differs. Not compared, and not replaced: TokenVault, Services and the diff --git a/OfficialWebsite/app/docs/classes/condition/page.tsx b/OfficialWebsite/app/docs/classes/condition/page.tsx index 9262a07..fd8c0d0 100644 --- a/OfficialWebsite/app/docs/classes/condition/page.tsx +++ b/OfficialWebsite/app/docs/classes/condition/page.tsx @@ -197,6 +197,15 @@ export default function Page() { +

          + A value is read once to validate its format and again to build the + predicate, so pass values that do not change: one whose{" "} + ToString() answers differently each time is validated as + one value and queried as another. Anything decoded from JSON is such a + value already. No policy decision reads a value's content — only + how many there are — so nothing a guard decides rests on which read won. +

          + Callers previously sending ["abc"] (quoted strings) keep working unchanged — strings deserialize into the List<object> as string elements. C# callers diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx index 7811228..5c544a6 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -224,7 +224,10 @@ true order. Add [DwNoOrder] unless that is intended.`} that projection copies from the entity. Rows in memory, a framework member the provider translates such as Length or{" "} Year, anything beneath a column, the convenience tier and a - dry run are all unchanged, and fail exactly as the unguarded query does. + dry run are all unchanged: the path is left alone, and behaves exactly + as it does unguarded — which for rows in memory and a framework member + means it runs and returns rows, and beneath a converted column means the + provider decides. So is a query a provider in front of EF Core translates — LinqKit's{" "} AsExpandable(), DelegateDecompiler's{" "} Decompile(), or one built by deriving from EF Core's diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index 44b6a80..4bac3c7 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -2358,8 +2358,8 @@ a projection another provider ran nothing; its rules are its - The rule is EF Core's own provider's — that type, not a type derived from it. A provider that wraps EF Core — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — rewrites what EF Core cannot translate, so its rows are left alone, over a projection and over an entity alike; one deriving from EF Core's provider rewrites the - same way and is left alone too. A rewrite inside EF Core's own pipeline — a `[DbFunction]`, a member-translator - plugin, a replaced query preprocessor — leaves EF Core's provider in place, so such a member is refused. + same way and is left alone too. A rewrite inside EF Core's own pipeline — a member-translator plugin, a + replaced query preprocessor — leaves EF Core's provider in place, so such a member is refused. - A row the library itself projected reads like any other: the core's typed `Select` null-guards every nested node it builds, and both branches of that guard are read, so `Select` composed and then filtered refuses what the bare handle refuses. diff --git a/README.md b/README.md index 498d40b..19a5e48 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ The complete reference — every enum, class, extension method, validation rule, **Upgrade note — two behaviour changes. Read these before bumping.** - **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a value's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor, and `IncludeTraceInResult` written as the tier's own answer is the answer the tier already gives. A type exposed under two names is reported under the last one, so two catalogues that resolve every name alike are still a different posture when the order differs. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. -- **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query a provider in front of EF Core translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()`, or one built by deriving from EF Core's own provider — because such a provider exists to rewrite what EF Core cannot, and a member it computes is a member the query produces. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. +- **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query a provider in front of EF Core translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()`, or one built by deriving from EF Core's own provider — because such a provider exists to rewrite what EF Core cannot, and a member it computes is a member the query produces. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. - **Changed: `LastTrace` is set before a request is sanitized**, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. - **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. Every node is new; a condition's values stay the caller's own objects, in a new list. - **Docs:** `[DwEntity(DefaultOrder)]`'s own remarks said a projected query takes no default; it has since 3.2.0. `DwCaps.MinGroupSize` ships **on at 5**, so a guarded summary silently drops groups of fewer than five rows — the aggregation docs now lead with that instead of leaving it to the caps table. From 3ef4029c393329c3963b2cc839c14be7d8540a1d Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 15:03:55 +0300 Subject: [PATCH 18/54] fix(policies): a member a subquery builds is not the navigation it reads from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth review found the last over-block, and the plainest one: the ordinary DTO-with-nested-DTO-list projection. Lines = o.Lines.Select(l => new LineRow { Label = l.Sku }).ToList() was recorded as a member copied whole from the entity's Lines navigation, because the routine that reads a member chain strips a sequence operator off the head of the expression. That is right where it was written for — a filtered include reads its navigation through Where, OrderBy, Skip and their kin, and the member it reaches is the navigation — and wrong here: the row's Lines holds LineRows, not Lines, so every path beneath it was read out of the entity's model. A LineRow member the entity happens to declare and not map was refused, though the query runs and ran in 3.2.0. The reader now declines to strip those operators, so such a member records nothing and is left alone, which is what the source table already said about a member a subquery builds. The probes are kept, along with the round-3 one that still asserted through the base-type walk the same round replaced, and a shadow property's refusal is recorded as the limit it has always been: a field path names CLR members, and a shadow property has none. Co-Authored-By: Claude Opus 5 --- DynamicWhere.Tests/DynamicWhere.Tests.csproj | 2 + .../Policies/Ob4ComplexProbes.cs | 351 +++++++ .../Policies/Ob4OverBlockProbes.cs | 873 ++++++++++++++++++ .../Policies/ReviewThirdRoundProbes.cs | 16 +- DynamicWhere.ex/Policies/Source/RowShape.cs | 19 +- 5 files changed, 1246 insertions(+), 15 deletions(-) create mode 100644 DynamicWhere.Tests/Policies/Ob4ComplexProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Ob4OverBlockProbes.cs diff --git a/DynamicWhere.Tests/DynamicWhere.Tests.csproj b/DynamicWhere.Tests/DynamicWhere.Tests.csproj index 6a5adfa..8f1b0ff 100644 --- a/DynamicWhere.Tests/DynamicWhere.Tests.csproj +++ b/DynamicWhere.Tests/DynamicWhere.Tests.csproj @@ -105,6 +105,8 @@ collection, neither of which EF Core 6 has. What they rule out — that the refusal reads no mapping shape wrongly — is an EF Core 8 question in the first place. --> + + diff --git a/DynamicWhere.Tests/Policies/Ob4ComplexProbes.cs b/DynamicWhere.Tests/Policies/Ob4ComplexProbes.cs new file mode 100644 index 0000000..40f1092 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ob4ComplexProbes.cs @@ -0,0 +1,351 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- complex types, which only EF Core 8 has --------------------------------------------------------- + + /// A complex type: its members are columns of the owner's table, and it has a getter too. + public class OcxMoney + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = string.Empty; + + /// A getter over two columns: no database can answer it. + public bool IsFree => Amount == 0m; + } + + /// A complex type nested inside another one. + public class OcxAddress + { + public string City { get; set; } = string.Empty; + + public OcxPostCode Post { get; set; } = new(); + + public string Full => $"{City} {Post.Code}"; + } + + public class OcxPostCode + { + public string Code { get; set; } = string.Empty; + + public bool IsBlank => string.IsNullOrEmpty(Code); + } + + public class OcxInvoice + { + public int Id { get; set; } + + public string Number { get; set; } = string.Empty; + + public OcxMoney Total { get; set; } = new(); + + public OcxAddress ShipTo { get; set; } = new(); + } + + /// A row a caller projects out of the invoice, complex members copied whole. + public class OcxRow + { + public int Id { get; set; } + + public OcxMoney Total { get; set; } = new(); + + public OcxAddress ShipTo { get; set; } = new(); + } + + public sealed class OcxContext : DbContext + { + private readonly SqliteConnection _connection; + + public OcxContext(SqliteConnection connection) => _connection = connection; + + public DbSet Invoices => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().ComplexProperty(invoice => invoice.Total); + model.Entity().ComplexProperty( + invoice => invoice.ShipTo, ship => ship.ComplexProperty(address => address.Post)); + } + } + + /// + /// EF Core 8 shapes the floor leg has no API for: complex properties, which + /// RowShape.ExpressesInComplex walks entirely through reflection, and the provider + /// identity of a second relational provider and a pooled factory. + /// + public sealed class Ob4ComplexProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly OcxContext _db; + private readonly List _findings = new(); + + public Ob4ComplexProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new OcxContext(_connection); + _db.Database.EnsureCreated(); + _db.Invoices.Add(new OcxInvoice + { + Number = "INV-1", + Total = new OcxMoney { Amount = 10m, Currency = "IQD" }, + ShipTo = new OcxAddress { City = "Baghdad", Post = new OcxPostCode { Code = "10001" } } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static string Guarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = Guard(source).ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, DataType = type, Operator = Operator.Equal, Values = { value } + } + } + } + }); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static string Raw(Func query) + { + try + { + object value = query(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private void Case(string probe, string unguarded, string guarded) + { + _out.WriteLine($"{probe,-60} unguarded={unguarded,-26} guarded={guarded}"); + + if (unguarded.StartsWith("OK", StringComparison.Ordinal) + && guarded.StartsWith("REFUSED", StringComparison.Ordinal)) + { + _findings.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + private void Done() => Assert.True(_findings.Count == 0, string.Join(" || ", _findings)); + + // ========================================================================================= + // Ob4-H. Complex properties: the reflection walk must find every column, or a member that + // maps to one is refused as if it were a getter. + // ========================================================================================= + + [Fact] + public void Ob4_H_A_complex_types_columns_are_found_by_the_reflection_walk() + { + Case("H1 column of a complex property", + Raw(() => _db.Invoices.Where(i => i.Total.Currency == "IQD").ToList()), + Guarded(_db.Invoices, "Total.Currency", DataType.Text, "IQD")); + + Case("H2 numeric column of a complex property", + Raw(() => _db.Invoices.Where(i => i.Total.Amount == 10m).ToList()), + Guarded(_db.Invoices, "Total.Amount", DataType.Number, "10")); + + Case("H3 column of a NESTED complex property", + Raw(() => _db.Invoices.Where(i => i.ShipTo.Post.Code == "10001").ToList()), + Guarded(_db.Invoices, "ShipTo.Post.Code", DataType.Text, "10001")); + + Case("H4 column of the outer complex property", + Raw(() => _db.Invoices.Where(i => i.ShipTo.City == "Baghdad").ToList()), + Guarded(_db.Invoices, "ShipTo.City", DataType.Text, "Baghdad")); + + Case("H5 framework member of a complex type's column", + Raw(() => _db.Invoices.Where(i => i.Total.Currency.Length == 3).ToList()), + Guarded(_db.Invoices, "Total.Currency.Length", DataType.Number, "3")); + + Case("H6 the complex member itself, named whole", + Raw(() => _db.Invoices.Where(i => i.Number == "INV-1").ToList()), + Guarded(_db.Invoices, "Number", DataType.Text, "INV-1")); + + // Getters on a complex type: the refusal is the point, and the unguarded query fails. + Case("H7 getter on a complex type (the refusal is right here)", + Raw(() => _db.Invoices.Where(i => i.Total.IsFree).ToList()), + Guarded(_db.Invoices, "Total.IsFree", DataType.Boolean, "false")); + + Case("H8 getter on a nested complex type (the refusal is right here)", + Raw(() => _db.Invoices.Where(i => i.ShipTo.Post.IsBlank).ToList()), + Guarded(_db.Invoices, "ShipTo.Post.IsBlank", DataType.Boolean, "false")); + + Done(); + } + + [Fact] + public void Ob4_H_A_projection_copying_a_complex_member_reads_it_from_the_model() + { + IQueryable projected = _db.Invoices.Select(invoice => new OcxRow + { + Id = invoice.Id, + Total = invoice.Total, + ShipTo = invoice.ShipTo + }); + + Case("H9 complex member copied whole, a column beneath it", + Raw(() => _db.Invoices.Select(i => new OcxRow { Id = i.Id, Total = i.Total, ShipTo = i.ShipTo }) + .Where(r => r.Total.Currency == "IQD").ToList()), + Guarded(projected, "Total.Currency", DataType.Text, "IQD")); + + Case("H10 complex member copied whole, a nested column beneath it", + Raw(() => _db.Invoices.Select(i => new OcxRow { Id = i.Id, Total = i.Total, ShipTo = i.ShipTo }) + .Where(r => r.ShipTo.Post.Code == "10001").ToList()), + Guarded(projected, "ShipTo.Post.Code", DataType.Text, "10001")); + + IQueryable rebuilt = _db.Invoices.Select(invoice => new OcxRow + { + Id = invoice.Id, + Total = new OcxMoney { Amount = invoice.Total.Amount, Currency = invoice.Total.Currency } + }); + + Case("H11 complex member rebuilt member by member", + Raw(() => _db.Invoices + .Select(i => new OcxRow + { + Id = i.Id, + Total = new OcxMoney { Amount = i.Total.Amount, Currency = i.Total.Currency } + }) + .Where(r => r.Total.Currency == "IQD").ToList()), + Guarded(rebuilt, "Total.Currency", DataType.Text, "IQD")); + + Done(); + } + + // ========================================================================================= + // Ob4-I. A second relational provider, and a pooled factory: the exact provider comparison + // has to hold for every one of them. + // ========================================================================================= + + private const string EfCoreProvider = "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider"; + + [Fact] + public void Ob4_I_A_second_provider_and_a_pooled_factory_carry_EF_Cores_own_provider() + { + List lost = new(); + + void Check(string name, IQueryProvider provider) + { + bool exact = provider.GetType().FullName == EfCoreProvider; + + _out.WriteLine($"{name,-34} {provider.GetType().FullName} exact={exact}"); + + if (!exact) + { + lost.Add($"{name} -> {provider.GetType().FullName}"); + } + } + + // Npgsql: a relational provider other than SQLite. No server is contacted — the provider + // a DbSet hands out is decided when the model is built. + using OcxNpgsqlContext npgsql = new(); + + Check("Npgsql", ((IQueryable)npgsql.Invoices).Provider); + Check("Npgsql + FromSqlRaw", npgsql.Invoices.FromSqlRaw("SELECT 1").Provider); + + // A pooled factory, which is how a host with AddPooledDbContextFactory gets its context. + DbContextOptions options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=none") + .Options; + + PooledDbContextFactory factory = new(options); + + using OcxNpgsqlContext pooled = factory.CreateDbContext(); + + Check("pooled context", ((IQueryable)pooled.Invoices).Provider); + + using OcxNpgsqlContext pooledAgain = factory.CreateDbContext(); + + Check("pooled context, reused", ((IQueryable)pooledAgain.Invoices).Provider); + + Check("SQLite", ((IQueryable)_db.Invoices).Provider); + + Assert.True(lost.Count == 0, "refusal silently lost on: " + string.Join(", ", lost)); + } + + /// A Npgsql-backed context. Nothing connects; only the model and the provider are read. + public sealed class OcxNpgsqlContext : DbContext + { + public OcxNpgsqlContext() + { + } + + public OcxNpgsqlContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Invoices => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + if (!options.IsConfigured) + { + options.UseNpgsql("Host=localhost;Database=none"); + } + } + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().ComplexProperty(invoice => invoice.Total); + model.Entity().ComplexProperty( + invoice => invoice.ShipTo, ship => ship.ComplexProperty(address => address.Post)); + } + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ob4OverBlockProbes.cs b/DynamicWhere.Tests/Policies/Ob4OverBlockProbes.cs new file mode 100644 index 0000000..71bd353 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ob4OverBlockProbes.cs @@ -0,0 +1,873 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Reflection.Metadata; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- model ------------------------------------------------------------------------------------------- + + /// A shared kernel type: two columns and a getter over them. + public class OvbText + { + public string Ar { get; set; } = string.Empty; + + public string En { get; set; } = string.Empty; + + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + public class OvbCustomer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// An unmapped getter on the customer. + public string Handle => Name.ToLowerInvariant(); + + public List Orders { get; set; } = new(); + } + + public class OvbLine + { + public int Id { get; set; } + + public int OrderId { get; set; } + + public OvbOrder Order { get; set; } = null!; + + public string Sku { get; set; } = string.Empty; + + public int Qty { get; set; } + + /// + /// An unmapped getter on the LINE ENTITY whose NAME a projected element row also declares. + /// The projected row assigns it from a column, so the query computes it; the entity cannot. + /// + public string Label => $"{Sku}#{Qty}"; + + /// An unmapped getter whose name a projected row also uses for a customer. + public string Name => Sku; + } + + public class OvbOrder + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public decimal Amount { get; set; } + + /// Mapped as a computed column. + public decimal Doubled { get; set; } + + /// Owned; its getter is the member no database can compute. + public OvbText Title { get; set; } = new(); + + /// A column whose value a converter builds, so what is beneath it is the converter's. + public OvbText Badge { get; set; } = new(); + + public int CustomerId { get; set; } + + public OvbCustomer Customer { get; set; } = null!; + + public List Lines { get; set; } = new(); + + /// Unmapped getter on the order. + public string Display => $"{Code}/{Id}"; + } + + /// TPH. + public class OvbParty + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + } + + public class OvbVendor : OvbParty + { + public string? Vat { get; set; } + } + + // ---- the rows a caller projects ---------------------------------------------------------------------- + + /// The element of a projected collection. Label collides with the entity's getter. + public class OvbLineRow + { + /// The entity declares this name and maps nothing for it. + public string Label { get; set; } = string.Empty; + + /// The entity maps a column of this name. + public int Qty { get; set; } + + /// The entity does not declare this name at all. + public string Note { get; set; } = string.Empty; + } + + public class OvbNest + { + public string A { get; set; } = string.Empty; + + public string B { get; set; } = string.Empty; + } + + public class OvbRow + { + public OvbRow() + { + } + + public OvbRow(int id) => Id = id; + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string Tag { get; set; } = string.Empty; + + public OvbText Title { get; set; } = new(); + + public OvbNest Nest { get; set; } = new(); + + /// A projected collection of projected rows. + public List Lines { get; set; } = new(); + + /// The entity's own rows, copied whole. + public List Raw { get; set; } = new(); + + /// A customer read out of a collection, so the recorded source is the collection. + public OvbCustomer? Customer { get; set; } + } + + public sealed class OvbContext : DbContext + { + private readonly SqliteConnection _connection; + + public OvbContext(SqliteConnection connection) => _connection = connection; + + public DbSet Orders => Set(); + + public DbSet Customers => Set(); + + public DbSet Lines => Set(); + + public DbSet Parties => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => + options.UseSqlite(_connection).AddInterceptors(new OvbInterceptor()); + + protected override void OnModelCreating(ModelBuilder model) + { + // Owned rather than complex, so the EF Core 6.0.22 floor builds this model too. + model.Entity().OwnsOne(order => order.Title); + model.Entity().Ignore(order => order.Display); + model.Entity().Property(order => order.Doubled).HasComputedColumnSql("\"Amount\" * 2"); + model.Entity().Property("Tenant"); + + // A column a converter builds: what is beneath it is the application's code to decide. + model.Entity().Property(order => order.Badge).HasConversion( + new ValueConverter( + text => text.En, + stored => new OvbText { En = stored, Ar = stored })); + + model.Entity().Ignore(customer => customer.Handle); + + model.Entity().Ignore(line => line.Label); + model.Entity().Ignore(line => line.Name); + + model.Entity().HasDiscriminator("Discriminator") + .HasValue("party") + .HasValue("vendor"); + } + } + + /// An interceptor, to confirm one does not change which provider the queryable carries. + public sealed class OvbInterceptor : DbCommandInterceptor + { + } + + /// + /// Round 4 of the over-blocking review of 3.3.0's strict refusal: each shape run guarded and + /// unguarded, so a query that ran in 3.2.0 and still runs unguarded is visible when it is refused. + /// + public sealed class Ob4OverBlockProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly OvbContext _db; + private readonly List _findings = new(); + + public Ob4OverBlockProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new OvbContext(_connection); + _db.Database.EnsureCreated(); + + OvbCustomer customer = new() { Name = "Acme" }; + + OvbOrder order = new() + { + Customer = customer, + Code = "AB123", + Amount = 10m, + Title = new OvbText { Ar = "AR", En = "EN" }, + Badge = new OvbText { En = "gold" } + }; + + order.Lines.Add(new OvbLine { Sku = "S-1", Qty = 2 }); + order.Lines.Add(new OvbLine { Sku = "S-2", Qty = 4 }); + + _db.Customers.Add(customer); + _db.Orders.Add(order); + _db.Entry(order).Property("Tenant").CurrentValue = "t1"; + _db.Parties.Add(new OvbVendor { Kind = "vendor", Vat = "V-1" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness --------------------------------------------------------------------------- + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, DataType type, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + private static string Guarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = Guard(source).ToList(Where(field, type, value)); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static string Raw(Func query) + { + try + { + object value = query(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + /// Records a probe, and flags it when the guard refuses what the same query runs. + private void Case(string probe, string unguarded, string guarded) + { + _out.WriteLine($"{probe,-62} unguarded={unguarded,-26} guarded={guarded}"); + + if (unguarded.StartsWith("OK", StringComparison.Ordinal) + && guarded.StartsWith("REFUSED", StringComparison.Ordinal)) + { + _findings.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + private void Done() => Assert.True(_findings.Count == 0, string.Join(" || ", _findings)); + + // ========================================================================================= + // Ob4-A. EfCoreOwns is an EXACT type-name comparison now. Every ordinary EF Core shape must + // still carry exactly that provider, or its refusal is silently lost. + // ========================================================================================= + + private const string EfCoreProvider = "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider"; + + /// The exact comparison the shipped EfCoreOwns performs. + private static bool ExactlyEfCore(IQueryProvider provider) => provider.GetType().FullName == EfCoreProvider; + + [Fact] + public void Ob4_A_Every_ordinary_EF_Core_shape_carries_exactly_EF_Cores_provider() + { + (string Name, IQueryable Source)[] shapes = + { + ("DbSet", _db.Orders), + ("Set()", _db.Set()), + ("Where", _db.Orders.Where(o => o.Id > 0)), + ("AsNoTracking", _db.Orders.AsNoTracking()), + ("AsNoTrackingWithIdentityResolution", _db.Orders.AsNoTrackingWithIdentityResolution()), + ("AsTracking", _db.Orders.AsTracking()), + ("Include", _db.Orders.Include(o => o.Customer)), + ("Include+ThenInclude", _db.Customers.Include(c => c.Orders).ThenInclude(o => o.Lines)), + ("AsSplitQuery", _db.Orders.Include(o => o.Lines).AsSplitQuery()), + ("AsSingleQuery", _db.Orders.Include(o => o.Lines).AsSingleQuery()), + ("IgnoreQueryFilters", _db.Orders.IgnoreQueryFilters()), + ("IgnoreAutoIncludes", _db.Orders.IgnoreAutoIncludes()), + ("TagWith", _db.Orders.TagWith("t")), + ("TagWithCallSite", _db.Orders.TagWithCallSite()), + ("FromSqlRaw", _db.Orders.FromSqlRaw("SELECT * FROM \"Orders\"")), + ("FromSqlInterpolated", _db.Orders.FromSqlInterpolated($"SELECT * FROM \"Orders\"")), + ("Select(row type)", _db.Orders.Select(o => new OvbRow { Id = o.Id })), + ("Select(anonymous)", _db.Orders.Select(o => new { o.Id, o.Code })), + ("Select(navigation)", _db.Orders.Select(o => o.Customer)), + ("OfType", _db.Parties.OfType()), + ("Cast", _db.Parties.Cast()), + ("Distinct", _db.Orders.Distinct()), + ("OrderBy+Skip+Take", _db.Orders.OrderBy(o => o.Id).Skip(0).Take(5)), + ("SelectMany", _db.Orders.SelectMany(o => o.Lines)), + ("GroupBy+Select", _db.Orders.GroupBy(o => o.CustomerId).Select(g => new OvbRow { Id = g.Key })), + ("Join", _db.Orders.Join(_db.Customers, o => o.CustomerId, c => c.Id, (o, c) => o)), + ("Concat", _db.Orders.Concat(_db.Orders)), + ("Union", _db.Orders.Union(_db.Orders)) + }; + + List lost = new(); + + foreach ((string name, IQueryable source) in shapes) + { + bool exact = ExactlyEfCore(source.Provider); + + _out.WriteLine($"{name,-38} {source.Provider.GetType().FullName} exact={exact}"); + + if (!exact) + { + lost.Add($"{name} -> {source.Provider.GetType().FullName}"); + } + } + + Assert.True(lost.Count == 0, "refusal silently lost on: " + string.Join(", ", lost)); + } + + [Fact] + public void Ob4_A_A_pooled_context_a_factory_and_an_interceptor_carry_it_too() + { + // A pooled context and one built by a factory are ordinary DbContexts with a different + // lifetime; an interceptor sits under the provider, not in front of it. Each must still + // hand out exactly EF Core's own provider, or every query on them loses the refusal. + ServiceCollectionStandIn services = new(_connection); + + List lost = new(); + + foreach ((string name, IQueryProvider provider) in services.Providers()) + { + bool exact = ExactlyEfCore(provider); + + _out.WriteLine($"{name,-38} {provider.GetType().FullName} exact={exact}"); + + if (!exact) + { + lost.Add($"{name} -> {provider.GetType().FullName}"); + } + } + + Assert.True(lost.Count == 0, "refusal silently lost on: " + string.Join(", ", lost)); + } + + /// Builds the context shapes a host builds, without needing a service container. + private sealed class ServiceCollectionStandIn + { + private readonly SqliteConnection _connection; + + internal ServiceCollectionStandIn(SqliteConnection connection) => _connection = connection; + + internal IEnumerable<(string Name, IQueryProvider Provider)> Providers() + { + using OvbContext plain = new(_connection); + + yield return ("plain context", ((IQueryable)plain.Orders).Provider); + + // A second context over the same connection is what a factory hands out. + using OvbContext fromFactory = new(_connection); + + yield return ("factory-built context", ((IQueryable)fromFactory.Orders).Provider); + + // The interceptor registered in OnConfiguring is already in force on both. + yield return ("intercepted context", plain.Orders.AsNoTracking().Provider); + + using OvbInMemoryContext inMemory = new(); + + yield return ("EF Core in-memory provider", ((IQueryable)inMemory.Orders).Provider); + } + } + + [Fact] + public void Ob4_A_EF_Cores_provider_is_that_exact_type_in_every_version_it_supports() + { + // The exact comparison only holds if no EF Core version renames the type, namespaces it + // differently, or hands out a subclass. Read straight from the assemblies. + string root = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nuget", "packages", "microsoft.entityframeworkcore"); + + if (!Directory.Exists(root)) + { + _out.WriteLine("no package cache on this machine; nothing to read."); + + return; + } + + List wrong = new(); + int seen = 0; + + foreach (string version in Directory.GetDirectories(root).OrderBy(name => name)) + { + string? assembly = Directory + .GetFiles(version, "Microsoft.EntityFrameworkCore.dll", SearchOption.AllDirectories) + .FirstOrDefault(path => path.Contains("net", StringComparison.OrdinalIgnoreCase)); + + if (assembly is null) + { + continue; + } + + try + { + using FileStream file = File.OpenRead(assembly); + using System.Reflection.PortableExecutable.PEReader reader = new(file); + + System.Reflection.Metadata.MetadataReader metadata = reader.GetMetadataReader(); + + bool found = false; + + foreach (System.Reflection.Metadata.TypeDefinitionHandle handle in metadata.TypeDefinitions) + { + System.Reflection.Metadata.TypeDefinition definition = metadata.GetTypeDefinition(handle); + + if (metadata.GetString(definition.Name) != "EntityQueryProvider") + { + continue; + } + + found = true; + seen++; + + string space = metadata.GetString(definition.Namespace); + string baseName = definition.BaseType.Kind switch + { + System.Reflection.Metadata.HandleKind.TypeReference => metadata.GetString( + metadata.GetTypeReference( + (System.Reflection.Metadata.TypeReferenceHandle)definition.BaseType).Name), + System.Reflection.Metadata.HandleKind.TypeDefinition => metadata.GetString( + metadata.GetTypeDefinition( + (System.Reflection.Metadata.TypeDefinitionHandle)definition.BaseType).Name), + _ => "?" + }; + + _out.WriteLine( + $"{Path.GetFileName(version),-10} {space}.EntityQueryProvider : {baseName}"); + + if ($"{space}.EntityQueryProvider" != EfCoreProvider) + { + wrong.Add($"{version}: {space}.EntityQueryProvider"); + } + + if (baseName != "Object") + { + wrong.Add($"{version}: derives from {baseName}, so an exact match is not enough"); + } + } + + if (!found) + { + _out.WriteLine($"{Path.GetFileName(version),-10} no EntityQueryProvider in this package"); + } + } + catch (Exception failure) + { + _out.WriteLine($"{Path.GetFileName(version),-10} unreadable: {failure.GetType().Name}"); + } + } + + _out.WriteLine($"versions read: {seen}"); + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + // ========================================================================================= + // Ob4-B. A projected collection of projected rows. MemberChain strips ToList/Where/Select, + // so the shape records the member as COPIED FROM THE ENTITY NAVIGATION and then reads + // the rest of the path out of the ENTITY's model — not out of the row the subquery + // actually builds. + // ========================================================================================= + + [Fact] + public void Ob4_B_A_projected_collection_is_read_out_of_the_entity_not_the_projected_row() + { + IQueryable projected = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Lines = order.Lines.Select(line => new OvbLineRow { Label = line.Sku, Qty = line.Qty }).ToList() + }); + + Case("B1 element member the subquery assigns, name the entity also declares", + Raw(() => _db.Orders + .Select(o => new OvbRow + { + Id = o.Id, + Lines = o.Lines.Select(l => new OvbLineRow { Label = l.Sku, Qty = l.Qty }).ToList() + }) + .Where(r => r.Lines.Any(l => l.Label == "S-1")).ToList()), + Guarded(projected, "Lines.Label", DataType.Text, "S-1")); + + Case("B2 element member both the row and the entity map (control)", + Raw(() => _db.Orders + .Select(o => new OvbRow + { + Id = o.Id, + Lines = o.Lines.Select(l => new OvbLineRow { Label = l.Sku, Qty = l.Qty }).ToList() + }) + .Where(r => r.Lines.Any(l => l.Qty == 2)).ToList()), + Guarded(projected, "Lines.Qty", DataType.Number, "2")); + + // The third case of the triangulation. The only thing that changes between B1, B2 and + // B2b is whether the ENTITY's element type declares the name and whether it maps it: + // maps it -> allowed (B2, Qty) + // declares, unmapped -> REFUSED (B1, Label) + // does not declare -> allowed (B2b, Note) + // which is the model of OvbLine being read, not the OvbLineRow the subquery builds. + IQueryable noted = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Lines = order.Lines.Select(line => new OvbLineRow { Note = line.Sku, Qty = line.Qty }).ToList() + }); + + Case("B2b element member the entity does not declare at all", + Raw(() => _db.Orders + .Select(o => new OvbRow + { + Id = o.Id, + Lines = o.Lines.Select(l => new OvbLineRow { Note = l.Sku, Qty = l.Qty }).ToList() + }) + .Where(r => r.Lines.Any(l => l.Note == "S-1")).ToList()), + Guarded(noted, "Lines.Note", DataType.Text, "S-1")); + + // The same shape with a filtered include's operators in front of the Select, which is how + // a caller writes it when the collection is narrowed. + IQueryable filtered = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Lines = order.Lines + .Where(line => line.Qty > 0) + .Select(line => new OvbLineRow { Label = line.Sku, Qty = line.Qty }) + .ToList() + }); + + Case("B3 the same, with the subquery filtered first", + Raw(() => _db.Orders + .Select(o => new OvbRow + { + Id = o.Id, + Lines = o.Lines.Where(l => l.Qty > 0) + .Select(l => new OvbLineRow { Label = l.Sku, Qty = l.Qty }).ToList() + }) + .Where(r => r.Lines.Any(l => l.Label == "S-1")).ToList()), + Guarded(filtered, "Lines.Label", DataType.Text, "S-1")); + + // The entity's own rows copied whole: here the entity IS what the member holds, so the + // refusal is right and the unguarded query fails the same way. + IQueryable copiedWhole = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Raw = order.Lines.ToList() + }); + + Case("B4 the entity's own rows copied whole (the refusal is right here)", + Raw(() => _db.Orders.Select(o => new OvbRow { Id = o.Id, Raw = o.Lines.ToList() }) + .Where(r => r.Raw.Any(l => l.Label == "S-1")).ToList()), + Guarded(copiedWhole, "Raw.Label", DataType.Text, "S-1")); + + Done(); + } + + [Fact] + public void Ob4_B_A_member_read_out_of_a_collection_is_read_against_the_elements_model() + { + // Customer is assigned from a subquery over Lines, so MemberChain records ("Customer", + // Order, "Lines") and the rest of the path is walked in OvbLine — a type that has nothing + // to do with what the member holds. + IQueryable projected = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Customer = order.Lines.Select(line => line.Order.Customer).FirstOrDefault() + }); + + Case("B5 customer read out of a line subquery, filtered on a column of the customer", + Raw(() => _db.Orders + .Select(o => new OvbRow { Id = o.Id, Customer = o.Lines.Select(l => l.Order.Customer).FirstOrDefault() }) + .Where(r => r.Customer!.Name == "Acme").ToList()), + Guarded(projected, "Customer.Name", DataType.Text, "Acme")); + + Done(); + } + + // ========================================================================================= + // Ob4-C. Binding shapes the initializer reader skips: a member-member binding and a list + // binding record the NAME but nothing beneath it. + // ========================================================================================= + + [Fact] + public void Ob4_C_A_member_member_binding_is_left_alone() + { + IQueryable nested = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Nest = { A = order.Code } + }); + + Case("C1 member-member binding, the member it sets", + Raw(() => _db.Orders.Select(o => new OvbRow { Id = o.Id, Nest = { A = o.Code } }) + .Where(r => r.Nest.A == "AB123").ToList()), + Guarded(nested, "Nest.A", DataType.Text, "AB123")); + + Case("C2 member-member binding, the sibling it does not set", + Raw(() => _db.Orders.Select(o => new OvbRow { Id = o.Id, Nest = { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()), + Guarded(nested, "Nest.B", DataType.Text, "x")); + + Done(); + } + + // ========================================================================================= + // Ob4-D. The entity branch's remaining mapping shapes, on both EF Core legs. + // ========================================================================================= + + [Fact] + public void Ob4_D_The_entity_branch_leaves_every_mapped_shape_alone() + { + Case("D1 computed column", + Raw(() => _db.Orders.Where(o => o.Doubled == 20m).ToList()), + Guarded(_db.Orders, "Doubled", DataType.Number, "20")); + + Case("D2 a member beneath a converted column", + Raw(() => _db.Orders.Where(o => o.Badge.En == "gold").ToList()), + Guarded(_db.Orders, "Badge.En", DataType.Text, "gold")); + + Case("D3 a getter beneath a converted column", + Raw(() => _db.Orders.Where(o => o.Badge.IsEmpty).ToList()), + Guarded(_db.Orders, "Badge.IsEmpty", DataType.Boolean, "false")); + + Case("D4 a column of an owned type", + Raw(() => _db.Orders.Where(o => o.Title.En == "EN").ToList()), + Guarded(_db.Orders, "Title.En", DataType.Text, "EN")); + + Case("D5 a framework member of a column", + Raw(() => _db.Orders.Where(o => o.Code.Length == 5).ToList()), + Guarded(_db.Orders, "Code.Length", DataType.Number, "5")); + + Case("D6 a column across a reference navigation", + Raw(() => _db.Orders.Where(o => o.Customer.Name == "Acme").ToList()), + Guarded(_db.Orders, "Customer.Name", DataType.Text, "Acme")); + + Case("D7 a column across a collection navigation", + Raw(() => _db.Orders.Where(o => o.Lines.Any(l => l.Sku == "S-1")).ToList()), + Guarded(_db.Orders, "Lines.Sku", DataType.Text, "S-1")); + + Case("D8 a TPH subtype column through its own set", + Raw(() => _db.Parties.OfType().Where(v => v.Vat == "V-1").ToList()), + Guarded(_db.Parties.OfType(), "Vat", DataType.Text, "V-1")); + + Case("D9 a mapped column of the root (control)", + Raw(() => _db.Orders.Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders, "Code", DataType.Text, "AB123")); + + // A shadow property has no CLR member, so the name never reaches the new walk: it is an + // unknown name, refused the way it was before this branch. The unguarded form has to go + // through EF.Property, which is not a path a caller can write. + string shadow = Guarded(_db.Orders, "Tenant", DataType.Text, "t1"); + + _out.WriteLine($"D14 a shadow property, named directly unguarded=(EF.Property only) guarded={shadow}"); + + // Documented limit, and no regression: a caller's path names CLR members, so a shadow + // property is a name that matches nothing, refused as one, on this branch and before it. + // The unguarded form has to go through EF.Property, which is not a path a caller writes. + Assert.StartsWith("REFUSED", shadow); + + Done(); + } + + [Fact] + public void Ob4_D_The_entity_branch_over_a_set_operation_and_a_reshape() + { + Case("D10 Concat of two entity queries", + Raw(() => _db.Orders.Concat(_db.Orders).Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders.Concat(_db.Orders), "Code", DataType.Text, "AB123")); + + Case("D11 rows reached through a navigation", + Raw(() => _db.Orders.Select(o => o.Customer).Where(c => c.Name == "Acme").ToList()), + Guarded(_db.Orders.Select(o => o.Customer), "Name", DataType.Text, "Acme")); + + Case("D12 rows reached by SelectMany", + Raw(() => _db.Orders.SelectMany(o => o.Lines).Where(l => l.Sku == "S-1").ToList()), + Guarded(_db.Orders.SelectMany(o => o.Lines), "Sku", DataType.Text, "S-1")); + + Case("D13 a getter on rows reached by SelectMany", + Raw(() => _db.Orders.SelectMany(o => o.Lines).Where(l => l.Label == "S-1#2").ToList()), + Guarded(_db.Orders.SelectMany(o => o.Lines), "Label", DataType.Text, "S-1#2")); + + Done(); + } + + // ========================================================================================= + // Ob4-E. Members of one projected row read from another projected row's members. + // ========================================================================================= + + [Fact] + public void Ob4_E_A_second_projection_over_the_first() + { + IQueryable once = _db.Orders.Select(order => new OvbRow { Id = order.Id, Code = order.Code }); + IQueryable twice = once.Select(row => new OvbRow { Id = row.Id, Tag = row.Code }); + + Case("E1 member of the second projection, assigned from the first", + Raw(() => _db.Orders.Select(o => new OvbRow { Id = o.Id, Code = o.Code }) + .Select(r => new OvbRow { Id = r.Id, Tag = r.Code }) + .Where(x => x.Tag == "AB123").ToList()), + Guarded(twice, "Tag", DataType.Text, "AB123")); + + Case("E2 a framework member beneath it", + Raw(() => _db.Orders.Select(o => new OvbRow { Id = o.Id, Code = o.Code }) + .Select(r => new OvbRow { Id = r.Id, Tag = r.Code }) + .Where(x => x.Tag.Length == 5).ToList()), + Guarded(twice, "Tag.Length", DataType.Number, "5")); + + Case("E3 a member the second projection leaves out", + Raw(() => _db.Orders.Select(o => new OvbRow { Id = o.Id, Code = o.Code }) + .Select(r => new OvbRow { Id = r.Id, Tag = r.Code }) + .Where(x => x.Code == "AB123").ToList()), + Guarded(twice, "Code", DataType.Text, "AB123")); + + Done(); + } + + // ========================================================================================= + // Ob4-F. Anonymous rows and a constructor with arguments: neither knows which member each + // value sets, so neither may refuse anything. + // ========================================================================================= + + [Fact] + public void Ob4_F_A_constructor_with_arguments_and_the_bindings_beside_it() + { + IQueryable built = _db.Orders.Select(order => new OvbRow(order.Id) { Code = order.Code }); + + Case("F1 member the initializer beside the constructor sets", + Raw(() => _db.Orders.Select(o => new OvbRow(o.Id) { Code = o.Code }) + .Where(r => r.Code == "AB123").ToList()), + Guarded(built, "Code", DataType.Text, "AB123")); + + Case("F2 member only the constructor could have set", + Raw(() => _db.Orders.Select(o => new OvbRow(o.Id) { Code = o.Code }) + .Where(r => r.Id == 1).ToList()), + Guarded(built, "Id", DataType.Number, "1")); + + Case("F3 member neither sets", + Raw(() => _db.Orders.Select(o => new OvbRow(o.Id) { Code = o.Code }) + .Where(r => r.Tag == "x").ToList()), + Guarded(built, "Tag", DataType.Text, "x")); + + Done(); + } + + // ========================================================================================= + // Ob4-G. The projection clause is exempt, so nothing above takes back a Select that worked. + // ========================================================================================= + + [Fact] + public void Ob4_G_A_projection_naming_the_refused_path_still_returns_it() + { + IQueryable projected = _db.Orders.Select(order => new OvbRow + { + Id = order.Id, + Lines = order.Lines.Select(line => new OvbLineRow { Label = line.Sku, Qty = line.Qty }).ToList() + }); + + string selected; + + try + { + FilterResult result = Guard(projected).ToList(new Filter + { + Selects = new List { "Id", "Lines.Label" } + }); + + selected = $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + selected = $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + selected = failure.GetType().Name; + } + + Case("G1 Selects naming the path Where refuses", + Raw(() => _db.Orders + .Select(o => new OvbRow + { + Id = o.Id, + Lines = o.Lines.Select(l => new OvbLineRow { Label = l.Sku, Qty = l.Qty }).ToList() + }).ToList()), + selected); + + Done(); + } + } + + /// EF Core's own in-memory provider, which is still EF Core's provider. + public sealed class OvbInMemoryContext : DbContext + { + public DbSet Orders => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => + options.UseInMemoryDatabase("ob4-" + Guid.NewGuid().ToString("N")); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(order => order.Title); + model.Entity().Ignore(order => order.Display); + model.Entity().Ignore(order => order.Badge); + model.Entity().Ignore(order => order.Doubled); + model.Entity().Ignore(customer => customer.Handle); + model.Entity().Ignore(line => line.Label); + model.Entity().Ignore(line => line.Name); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs b/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs index 7569f43..ff6e2a9 100644 --- a/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs +++ b/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs @@ -713,19 +713,9 @@ public void R3_I_Every_ordinary_EF_Core_shape_reaches_EF_Cores_own_provider() Assert.True(lost.Count == 0, "refusal silently lost on: " + string.Join(", ", lost)); } - /// The exact walk RowShape.EfCoreOwns performs. - private static bool WalksToEfCore(IQueryProvider provider) - { - for (Type? type = provider.GetType(); type is not null; type = type.BaseType) - { - if (type.FullName == "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider") - { - return true; - } - } - - return false; - } + /// The comparison RowShape.EfCoreOwns performs: the type itself, not a subclass. + private static bool WalksToEfCore(IQueryProvider provider) => + provider.GetType().FullName == "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider"; // ========================================================================================= // R3-J. OPEN FINDING (round 3). The over-block direction of EfCoreOwns. diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs index 7f0f825..7ef3f87 100644 --- a/DynamicWhere.ex/Policies/Source/RowShape.cs +++ b/DynamicWhere.ex/Policies/Source/RowShape.cs @@ -944,7 +944,7 @@ private static bool ReadValue( & ReadValue(choice.IfFalse, path, row, rowSource, initialized, copied, opaque, depth); default: - if (rowSource is null || MemberChain(assigned, row) is not { } chain) + if (rowSource is null || MemberChain(assigned, row, operators: false) is not { } chain) { return false; } @@ -1172,7 +1172,17 @@ protected override Expression VisitMethodCall(MethodCallExpression node) /// The member path a navigation lambda reads, o => o.Lines or o => o.Customer.Address, /// through casts and a filtered include's operators; null for any other shape. /// - private static string? MemberChain(Expression body, ParameterExpression parameter) + /// The expression to read. + /// The lambda's own parameter, which the chain has to start at. + /// + /// Whether a sequence operator over the member may be stripped. A filtered include reads its + /// navigation through Where, OrderBy, Skip, Take and their kin, and + /// the member it reaches is the navigation itself. A projection's assignment is the other way + /// round: Lines = o.Lines.Select(l => new LineRow { … }).ToList() holds LineRows + /// and not Lines, so reading it as the navigation would read every path beneath it out of + /// the wrong model — and refuse a member the row carries because the entity does not map it. + /// + private static string? MemberChain(Expression body, ParameterExpression parameter, bool operators = true) { Expression node = DefaultOrder.StripConversions(body); @@ -1181,6 +1191,11 @@ protected override Expression VisitMethodCall(MethodCallExpression node) && (call.Method.DeclaringType == typeof(Enumerable) || call.Method.DeclaringType == typeof(Queryable)) && call.Arguments.Count > 0) { + if (!operators) + { + return null; + } + node = DefaultOrder.StripConversions(call.Arguments[0]); } From c8f7538e210ac4120cea4f55efaa7f3146514781 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 15:03:55 +0300 Subject: [PATCH 19/54] docs: the cookbook is twelve examples, and a null on its own is not read The JSON Cookbook claimed thirteen examples and shipped twelve: DOC.md carried an empty heading for a tenth whose body sat under a duplicated thirteenth, and the website and the sidebar numbered around the gap. The examples are numbered without one now, in all three places, and the count is the one on the page. The routes are unchanged. llms.txt still generalised the posture comparison to "a cap's own default", which the other documents had already narrowed: only the group floor's getter answers with the value that applies, and every other cap is compared as written. A null assignment on its own records nothing and is left alone, so listing it among the shapes a projection is read through was wrong; it carries weight as a branch beside one that assigns. The site's own metadata, which reaches every page through the JSON-LD, said .NET 6 through 9 where the library targets 6 through 10. The in-memory summary overload's floor is written down in llms.txt too, and DOC.md now says its breaking points are numbered as that document numbers them. Co-Authored-By: Claude Opus 5 --- .../Policies/DocsReview34Probe.cs | 867 ++++++++++++++++++ DynamicWhere.ex/DOC.md | 22 +- .../app/docs/breaking-changes/page.tsx | 6 +- OfficialWebsite/app/docs/examples/page.tsx | 12 +- .../app/docs/installation/page.tsx | 2 +- .../app/docs/policies/configuration/page.tsx | 5 +- OfficialWebsite/app/layout.tsx | 2 +- OfficialWebsite/app/page.tsx | 2 +- OfficialWebsite/lib/nav.ts | 6 +- OfficialWebsite/lib/seo.ts | 2 +- OfficialWebsite/public/llms.txt | 16 +- README.md | 2 +- 12 files changed, 910 insertions(+), 34 deletions(-) create mode 100644 DynamicWhere.Tests/Policies/DocsReview34Probe.cs diff --git a/DynamicWhere.Tests/Policies/DocsReview34Probe.cs b/DynamicWhere.Tests/Policies/DocsReview34Probe.cs new file mode 100644 index 0000000..ce6006d --- /dev/null +++ b/DynamicWhere.Tests/Policies/DocsReview34Probe.cs @@ -0,0 +1,867 @@ +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.Internal; +using Xunit.Abstractions; + +// Round four of the docs-against-code probe for 3.3.0. Every fact here is read by running the +// library, never by reading it: each test prints what happened so the report can quote an outcome. +// Nothing here fixes anything, and nothing here drives DwPolicy's static fields. + +namespace DynamicWhere.Tests.Policies +{ + // ---- models ------------------------------------------------------------------------------- + + /// An entity with two owned members of the same type, so a conditional can copy two. + public class Rd4Role + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public ZyLocalizedText Name { get; set; } = new(); + + public ZyLocalizedText Alt { get; set; } = new(); + } + + /// The row a caller projects before the guard sees it. + public class Rd4Row + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public ZyLocalizedText? Name { get; set; } + } + + /// A chain, so an initializer can be nested to any depth the probe asks for. + public class Rd4Node + { + public string Value { get; set; } = string.Empty; + + public Rd4Node? Next { get; set; } + + /// A getter over the level's own column: no database computes it. + public bool IsEmpty => Value.Length == 0; + } + + /// A class stored in one column through a value converter. + public class Rd4Money + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = "IQD"; + + public bool IsZero => Amount == 0m; + } + + public class Rd4Order + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + /// One column, converted. Beneath it the converter decides. + public Rd4Money Total { get; set; } = new(); + } + + public sealed class Rd4Context : DbContext + { + private readonly SqliteConnection _connection; + + public Rd4Context(SqliteConnection connection) => _connection = connection; + + public DbSet Roles => Set(); + + public DbSet Orders => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + // Owned rather than complex, so the EF Core 6.0.22 floor builds this model too. + model.Entity().OwnsOne(role => role.Name); + model.Entity().OwnsOne(role => role.Alt); + + model.Entity().Property(order => order.Total).HasConversion( + money => money.Amount.ToString(CultureInfo.InvariantCulture), + text => new Rd4Money { Amount = decimal.Parse(text, CultureInfo.InvariantCulture) }); + } + } + + // ---- the two providers the corrected paragraph tells apart ---------------------------------- + + /// Rewrites ZyLocalizedText.IsEmpty into the two columns beneath it. + /// + /// The rewrite a member-translator plugin or a replaced query preprocessor performs, expressed + /// where a test can drive it: whatever runs it, the member becomes something SQLite computes. + /// + internal sealed class Rd4EmptyRewriter : ExpressionVisitor + { + protected override Expression VisitMember(MemberExpression node) + { + if (node.Member.Name == nameof(ZyLocalizedText.IsEmpty) + && node.Member.DeclaringType == typeof(ZyLocalizedText) + && node.Expression is not null) + { + Expression instance = Visit(node.Expression)!; + + return Expression.AndAlso( + Expression.Equal( + Expression.Property(instance, nameof(ZyLocalizedText.Ar)), + Expression.Constant(string.Empty)), + Expression.Equal( + Expression.Property(instance, nameof(ZyLocalizedText.En)), + Expression.Constant(string.Empty))); + } + + return base.VisitMember(node); + } + } + + /// + /// A rewrite inside EF Core's own pipeline: the provider handed to the query is + /// EntityQueryProvider itself, and the rewrite happens under it. + /// + /// + /// This is what a member-translator plugin and a replaced query preprocessor look like from + /// outside: EF Core's own provider is still the one on the queryable, so the library's name + /// comparison still matches and the member is still refused — even though the query now runs. + /// + internal sealed class Rd4RewritingCompiler : IQueryCompiler + { + private readonly IQueryCompiler _inner; + + public Rd4RewritingCompiler(IQueryCompiler inner) => _inner = inner; + + public TResult Execute(Expression query) => + _inner.Execute(new Rd4EmptyRewriter().Visit(query)!); + + public TResult ExecuteAsync(Expression query, CancellationToken cancellationToken = default) => + _inner.ExecuteAsync(new Rd4EmptyRewriter().Visit(query)!, cancellationToken); + + public Func CreateCompiledQuery(Expression query) => + _inner.CreateCompiledQuery(new Rd4EmptyRewriter().Visit(query)!); + + public Func CreateCompiledAsyncQuery(Expression query) => + _inner.CreateCompiledAsyncQuery(new Rd4EmptyRewriter().Visit(query)!); + } + + /// A provider built by deriving from EF Core's own, rewriting the same member. + internal sealed class Rd4DerivedProvider : EntityQueryProvider + { + public Rd4DerivedProvider(IQueryCompiler compiler) : base(compiler) + { + } + + public override IQueryable CreateQuery(Expression expression) => + base.CreateQuery(new Rd4EmptyRewriter().Visit(expression)!); + + public override object? Execute(Expression expression) => + base.Execute(new Rd4EmptyRewriter().Visit(expression)!); + + public override TResult Execute(Expression expression) => + base.Execute(new Rd4EmptyRewriter().Visit(expression)!); + } + + // ---- shared plumbing ------------------------------------------------------------------------ + + internal static class Rd4 + { + private static readonly MethodInfo OfMethod = typeof(RowShape) + .GetMethod("Of", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + + private static readonly MethodInfo ExpressesMethod = typeof(RowShape) + .GetMethod("Expresses", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static readonly MethodInfo OwnsMethod = typeof(RowShape) + .GetMethod("EfCoreOwns", BindingFlags.Static | BindingFlags.NonPublic)!; + + /// What the shape of a source says about a path: true, false, or "cannot say". + internal static bool? Ask(IQueryable source, string path) + { + object shape = OfMethod.MakeGenericMethod(source.ElementType).Invoke(null, new object[] { source })!; + + return (bool?)ExpressesMethod.Invoke(shape, new object[] { path }); + } + + internal static bool EfCoreOwns(IQueryProvider provider) => + (bool)OwnsMethod.Invoke(null, new object[] { provider })!; + + internal static string Show(bool? answer) => answer switch + { + null => "null (left alone)", + true => "true (nothing to refuse)", + _ => "false (refused)" + }; + + /// A guarded handle that reads no process-wide state. + internal static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + internal static Filter Where(string field, string value, DataType type) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + /// What a call did: the rows it returned, or the exception it raised. + internal static string Outcome(Func call) + { + try + { + return $"ran, {call()} row(s)"; + } + catch (PolicyException refusal) + { + return $"REFUSED PolicyException({refusal.Message})"; + } + catch (LogicException invalid) + { + return $"invalid LogicException({invalid.Message})"; + } + catch (Exception other) + { + return $"threw {other.GetType().Name}"; + } + } + } + + // ============================================================================================= + // 1. Which provider the rule belongs to: EF Core's own TYPE, and a rewrite under it. + // ============================================================================================= + + public sealed class Rd4ProviderProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Rd4Context _db; + private readonly IQueryCompiler _compiler; + + public Rd4ProviderProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Rd4Context(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new Rd4Role + { + Code = "admin", + Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" }, + Alt = new ZyLocalizedText { Ar = string.Empty, En = string.Empty } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + + _compiler = (IQueryCompiler)((IInfrastructure)_db).Instance + .GetService(typeof(IQueryCompiler))!; + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + /// + /// "A rewrite inside EF Core's own pipeline — a member-translator plugin, a replaced + /// query preprocessor — leaves EF Core's own provider in place, so a member it computes + /// without a mapping is refused with the rest." + /// + [Fact] + public void A_rewrite_inside_EF_Cores_own_pipeline_leaves_the_provider_in_place_and_is_refused() + { + EntityQueryProvider provider = new(new Rd4RewritingCompiler(_compiler)); + + IQueryable rewritten = + provider.CreateQuery(((IQueryable)_db.Roles).Expression); + + _out.WriteLine($"provider type = {rewritten.Provider.GetType().FullName}"); + _out.WriteLine($"EfCoreOwns = {Rd4.EfCoreOwns(rewritten.Provider)}"); + _out.WriteLine($"Expresses(\"Name.IsEmpty\") = {Rd4.Show(Rd4.Ask(rewritten, "Name.IsEmpty"))}"); + + // The rewrite makes the member something SQLite computes, so the unguarded query runs. + string unguarded = Rd4.Outcome(() => rewritten.Where(role => role.Name.IsEmpty).ToList().Count); + string alt = Rd4.Outcome(() => rewritten.Where(role => role.Alt.IsEmpty).ToList().Count); + + string guarded = Rd4.Outcome( + () => Rd4.Guard(rewritten).ToList(Rd4.Where("Name.IsEmpty", "false", DataType.Boolean)).Data.Count); + + _out.WriteLine($" unguarded Name.IsEmpty -> {unguarded}"); + _out.WriteLine($" unguarded Alt.IsEmpty -> {alt}"); + _out.WriteLine($" guarded Name.IsEmpty -> {guarded}"); + + Assert.True(Rd4.EfCoreOwns(rewritten.Provider)); + Assert.False(Rd4.Ask(rewritten, "Name.IsEmpty")); + Assert.StartsWith("ran,", unguarded); + Assert.StartsWith("REFUSED", guarded); + } + + /// + /// "one built by deriving from EF Core's provider rewrites in the same way and is left alone + /// too" — over an entity. + /// + [Fact] + public void A_provider_deriving_from_EF_Cores_own_is_left_alone_over_an_entity() + { + Rd4DerivedProvider provider = new(_compiler); + + IQueryable derived = + provider.CreateQuery(((IQueryable)_db.Roles).Expression); + + _out.WriteLine($"provider type = {derived.Provider.GetType().FullName}"); + _out.WriteLine($"derives from = {derived.Provider.GetType().BaseType?.FullName}"); + _out.WriteLine($"EntityQueryProvider itself derives from {typeof(EntityQueryProvider).BaseType?.FullName} " + + $"(EF Core {typeof(EntityQueryProvider).Assembly.GetName().Version})"); + _out.WriteLine($"EfCoreOwns = {Rd4.EfCoreOwns(derived.Provider)}"); + _out.WriteLine($"Expresses(\"Name.IsEmpty\") = {Rd4.Show(Rd4.Ask(derived, "Name.IsEmpty"))}"); + + string guarded = Rd4.Outcome( + () => Rd4.Guard(derived).ToList(Rd4.Where("Name.IsEmpty", "false", DataType.Boolean)).Data.Count); + + _out.WriteLine($" guarded Name.IsEmpty -> {guarded}"); + + Assert.Equal(typeof(EntityQueryProvider), derived.Provider.GetType().BaseType); + + // "EF Core's own derives from object in every version, so nothing real is lost by the + // exact test" — read from the assembly this leg runs against, both legs. + Assert.Equal(typeof(object), typeof(EntityQueryProvider).BaseType); + + Assert.False(Rd4.EfCoreOwns(derived.Provider)); + Assert.Null(Rd4.Ask(derived, "Name.IsEmpty")); + Assert.DoesNotContain("REFUSED", guarded); + } + + /// The same sentence, over a projection. + [Fact] + public void A_provider_deriving_from_EF_Cores_own_is_left_alone_over_a_projection() + { + Rd4DerivedProvider provider = new(_compiler); + + IQueryable derived = + provider.CreateQuery(((IQueryable)_db.Roles).Expression); + + IQueryable builtOnEfCore = _db.Roles.Select(role => new Rd4Row + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + IQueryable builtOnDerived = derived.Select(role => new Rd4Row + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + }); + + _out.WriteLine($"EF Core's own provider, projection -> {Rd4.Show(Rd4.Ask(builtOnEfCore, "Name.IsEmpty"))}"); + _out.WriteLine($"derived provider, same projection -> {Rd4.Show(Rd4.Ask(builtOnDerived, "Name.IsEmpty"))}"); + _out.WriteLine($" the derived one's provider = {builtOnDerived.Provider.GetType().FullName}"); + + Assert.False(Rd4.Ask(builtOnEfCore, "Name.IsEmpty")); + Assert.Null(Rd4.Ask(builtOnDerived, "Name.IsEmpty")); + } + + /// The wrapping half of the same sentence, over an entity and over a projection. + [Fact] + public void A_provider_wrapping_EF_Core_is_left_alone_over_both() + { + IQueryable entity = new ZyOwnProvider(_db.Roles); + + IQueryable projection = new ZyOwnProvider( + _db.Roles.Select(role => new Rd4Row + { + Id = role.Id, + Code = role.Code, + Name = new ZyLocalizedText { Ar = role.Name.Ar, En = role.Name.En } + })); + + _out.WriteLine($"wrapper over an entity -> {Rd4.Show(Rd4.Ask(entity, "Name.IsEmpty"))}"); + _out.WriteLine($"wrapper over a projection -> {Rd4.Show(Rd4.Ask(projection, "Name.IsEmpty"))}"); + + Assert.Null(Rd4.Ask(entity, "Name.IsEmpty")); + Assert.Null(Rd4.Ask(projection, "Name.IsEmpty")); + } + + /// + /// "[DbFunction] was removed as an example because it maps a method, which no field + /// path can name." A path is resolved through properties, so a method never reaches the + /// refusal at all — it fails name resolution first, in both tiers. + /// + [Fact] + public void No_field_path_can_name_a_method() + { + // Three methods on the row's own types: an instance method, a method on a member's type, + // and a static one of the kind [DbFunction] maps. + foreach (string path in new[] { "ToString", "Name.ToString", "Code.Trim", "Name.GetHashCode" }) + { + string strict = Rd4.Outcome( + () => Rd4.Guard(_db.Roles).ToList(Rd4.Where(path, "x", DataType.Text)).Data.Count); + + string convenience = Rd4.Outcome( + () => Rd4.Guard(_db.Roles, DwTier.Convenience) + .ToList(Rd4.Where(path, "x", DataType.Text)).Data.Count); + + _out.WriteLine($"{path,-20} strict -> {strict}"); + _out.WriteLine($"{path,-20} conv. -> {convenience}"); + + // Whatever the tier answers, the answer is a name answer, never the compute refusal: + // the shape is never even asked, because the path resolves to no member. + Assert.DoesNotContain("cannot compute", strict); + Assert.DoesNotContain("cannot compute", convenience); + Assert.Contains("ConditionMustHasValidFieldName", convenience); + } + + // And the property beside them, for contrast: that one the shape does answer for. + _out.WriteLine($"Name.IsEmpty (a property) Expresses -> {Rd4.Show(Rd4.Ask(_db.Roles, "Name.IsEmpty"))}"); + + Assert.False(Rd4.Ask(_db.Roles, "Name.IsEmpty")); + } + } + + // ============================================================================================= + // 2. How far a projection is read: every assignment kind, and where the reading stops. + // ============================================================================================= + + public sealed class Rd4ProjectionProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Rd4Context _db; + + private static readonly ZyLocalizedText Captured = new() { Ar = "a", En = "e" }; + + public Rd4ProjectionProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Rd4Context(_connection); + _db.Database.EnsureCreated(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static ZyLocalizedText Make(string code) => new() { Ar = code, En = code }; + + /// + /// The two lists the text gives: the assignments a projection is read through, and the ones + /// that leave the member alone. One row per shape, printed, so the report can quote it. + /// + [Fact] + public void Every_assignment_kind_the_text_lists() + { + List<(string Shape, string Expected, bool? Answer)> rows = new(); + + void Row(string shape, string expected, IQueryable source) => + rows.Add((shape, expected, Rd4.Ask(source, "Name.IsEmpty"))); + + Row("a nested initializer", "read", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, Name = new ZyLocalizedText { Ar = r.Name.Ar, En = r.Name.En } + })); + + Row("a member copied from the entity", "read", + _db.Roles.Select(r => new Rd4Row { Id = r.Id, Name = r.Name })); + + Row("a value built and left empty", "read", + _db.Roles.Select(r => new Rd4Row { Id = r.Id, Name = new ZyLocalizedText() })); + + // ROUND-4 FINDING. Every document lists "a null" beside the four forms above, as one of + // the assignments a projection is read through. On its own it is not: ReadValue answers + // "I could read that" and records nothing, so the member has no entry and the shape says + // "cannot say". A null only carries its weight as one branch of a conditional, where the + // other branch is what records the members. The row below is expected against the code, + // not against the text, and the text is what the report names. + Row("a null, on its own", "left alone", + _db.Roles.Select(r => new Rd4Row { Id = r.Id, Name = null })); + + Row("a conditional: built | built", "read", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, + Name = r.Code == "x" + ? new ZyLocalizedText() + : new ZyLocalizedText { Ar = r.Name.Ar, En = r.Name.En } + })); + + Row("a conditional: null | built", "read", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, + Name = r.Code == "x" ? null : new ZyLocalizedText { Ar = r.Name.Ar, En = r.Name.En } + })); + + Row("a conditional: copied | copied, one member", "read", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, Name = r.Code == "x" ? r.Name : r.Name + })); + + Row("a method call", "left alone", + _db.Roles.Select(r => new Rd4Row { Id = r.Id, Name = Make(r.Code) })); + + Row("a captured value", "left alone", + _db.Roles.Select(r => new Rd4Row { Id = r.Id, Name = Captured })); + + Row("a subquery", "left alone", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, Name = _db.Roles.Select(other => other.Name).FirstOrDefault() + })); + + Row("two branches building it two ways", "left alone", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, Name = r.Code == "x" ? r.Name : r.Alt + })); + + Row("a conditional: built | copied", "left alone", + _db.Roles.Select(r => new Rd4Row + { + Id = r.Id, Name = r.Code == "x" ? new ZyLocalizedText() : r.Name + })); + + foreach ((string shape, string expected, bool? answer) in rows) + { + _out.WriteLine($"{shape,-45} code says {Rd4.Show(answer)} (expected {expected})"); + } + + // Said plainly, so the finding is an outcome rather than a reading of the code: the four + // forms the text lists beside it are read, and a bare null is not. + _out.WriteLine(string.Empty); + _out.WriteLine("the text's list: nested initializer, copied member, built-and-left-empty, A NULL, " + + "and a conditional over those"); + _out.WriteLine($" Name = new ZyLocalizedText {{ … }} -> {Rd4.Show(rows[0].Answer)}"); + _out.WriteLine($" Name = r.Name -> {Rd4.Show(rows[1].Answer)}"); + _out.WriteLine($" Name = new ZyLocalizedText() -> {Rd4.Show(rows[2].Answer)}"); + _out.WriteLine($" Name = null -> {Rd4.Show(rows[3].Answer)} <-- the text says this one is read"); + _out.WriteLine($" Name = c ? null : new … {{ … }} -> {Rd4.Show(rows[5].Answer)}"); + + Assert.Null(rows[3].Answer); + + // The text's first list: these are the ones a projection is read through. + foreach ((string shape, string expected, bool? answer) in rows.Where(r => r.Expected == "read")) + { + Assert.True(answer == false, $"{shape}: expected refused, got {Rd4.Show(answer)}"); + } + + // The text's second list: these leave the member alone. + foreach ((string shape, string expected, bool? answer) in rows.Where(r => r.Expected == "left alone")) + { + Assert.True(answer is null, $"{shape}: expected left alone, got {Rd4.Show(answer)}"); + } + } + + /// + /// "Past MaxComplexDepth — eight levels — it stops reading and stops speaking." + /// Nests one initializer inside another and reports the last depth the shape answers for. + /// + [Fact] + public void The_depth_at_which_a_nested_initializer_stops_being_read() + { + int cap = (int)typeof(RowShape) + .GetField("MaxComplexDepth", BindingFlags.Static | BindingFlags.NonPublic)! + .GetValue(null)!; + + _out.WriteLine($"RowShape.MaxComplexDepth = {cap}"); + + int? lastSpoken = null; + int? firstSilent = null; + + for (int nesting = 0; nesting <= cap + 3; nesting++) + { + IQueryable projected = _db.Roles.Select(Chain(nesting)); + + string prefix = string.Concat(Enumerable.Repeat("Next.", nesting)); + + bool? computable = Rd4.Ask(projected, prefix + "IsEmpty"); + bool? assigned = Rd4.Ask(projected, prefix + "Value"); + + _out.WriteLine($"{nesting,2} nested initializer(s): " + + $"\"{prefix}Value\" = {Rd4.Show(assigned)} " + + $"\"{prefix}IsEmpty\" = {Rd4.Show(computable)}"); + + if (computable == false) + { + lastSpoken = nesting; + } + else if (firstSilent is null) + { + firstSilent = nesting; + } + } + + _out.WriteLine($"last level the shape refuses at = {lastSpoken}; first level it stops speaking at = {firstSilent}"); + + Assert.NotNull(lastSpoken); + Assert.NotNull(firstSilent); + Assert.Equal(lastSpoken + 1, firstSilent); + Assert.Equal(cap, lastSpoken); + } + + /// Builds r => new Rd4Node { Value = r.Code, Next = new Rd4Node { … } }. + private static Expression> Chain(int nesting) + { + ParameterExpression role = Expression.Parameter(typeof(Rd4Role), "r"); + MemberExpression code = Expression.Property(role, nameof(Rd4Role.Code)); + + MemberInitExpression body = Level(code, null); + + for (int i = 0; i < nesting; i++) + { + body = Level(code, body); + } + + return Expression.Lambda>(body, role); + } + + private static MemberInitExpression Level(Expression value, Expression? next) + { + List bindings = new() + { + Expression.Bind(typeof(Rd4Node).GetProperty(nameof(Rd4Node.Value))!, value) + }; + + if (next is not null) + { + bindings.Add(Expression.Bind(typeof(Rd4Node).GetProperty(nameof(Rd4Node.Next))!, next)); + } + + return Expression.MemberInit(Expression.New(typeof(Rd4Node)), bindings); + } + } + + // ============================================================================================= + // 3. "Left alone" is not a promise the path runs. + // ============================================================================================= + + public sealed class Rd4LeftAloneProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Rd4Context _db; + + public Rd4LeftAloneProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Rd4Context(_connection); + _db.Database.EnsureCreated(); + _db.Roles.Add(new Rd4Role + { + Code = "admin", + Name = new ZyLocalizedText { Ar = "مدير", En = "Admin" }, + Alt = new ZyLocalizedText() + }); + _db.Orders.Add(new Rd4Order { Code = "A-1", Total = new Rd4Money { Amount = 12m } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + /// "which for rows in memory … means it runs and returns rows". + [Fact] + public void Rows_in_memory_run_and_return_rows() + { + List rows = new() + { + new Rd4Role { Id = 1, Code = "a", Name = new ZyLocalizedText { Ar = "م", En = "A" } }, + new Rd4Role { Id = 2, Code = "b", Name = new ZyLocalizedText() } + }; + + IQueryable memory = rows.AsQueryable(); + + _out.WriteLine($"Expresses(\"Name.IsEmpty\") = {Rd4.Show(Rd4.Ask(memory, "Name.IsEmpty"))}"); + + FilterResult? result = null; + + string outcome = Rd4.Outcome(() => + { + result = Rd4.Guard(memory).ToList(Rd4.Where("Name.IsEmpty", "true", DataType.Boolean)); + + return result.Data.Count; + }); + + _out.WriteLine($" guarded Name.IsEmpty -> {outcome}"); + + Assert.Null(Rd4.Ask(memory, "Name.IsEmpty")); + Assert.StartsWith("ran,", outcome); + Assert.Single(result!.Data); + } + + /// "…and a framework member means it runs and returns rows". + [Fact] + public void A_framework_member_runs_and_returns_rows() + { + _out.WriteLine($"Expresses(\"Code.Length\") = {Rd4.Show(Rd4.Ask(_db.Roles, "Code.Length"))}"); + + string guarded = Rd4.Outcome( + () => Rd4.Guard(_db.Roles).ToList(Rd4.Where("Code.Length", "5", DataType.Number)).Data.Count); + + string unguarded = Rd4.Outcome(() => _db.Roles.Where(role => role.Code.Length == 5).ToList().Count); + + _out.WriteLine($" guarded -> {guarded}"); + _out.WriteLine($" unguarded -> {unguarded}"); + + Assert.Null(Rd4.Ask(_db.Roles, "Code.Length")); + Assert.StartsWith("ran,", guarded); + Assert.Equal(unguarded, guarded); + } + + /// + /// "…and beneath a converted column means the provider decides." The path is left alone, and + /// what happens next is not the policy's answer to give. + /// + [Fact] + public void Beneath_a_converted_column_the_provider_decides() + { + _out.WriteLine("Total is one column, converted to " + + $"{_db.Model.FindEntityType(typeof(Rd4Order))!.FindProperty(nameof(Rd4Order.Total))!.GetValueConverter()!.ProviderClrType.Name}"); + _out.WriteLine($"Expresses(\"Total.Amount\") = {Rd4.Show(Rd4.Ask(_db.Orders, "Total.Amount"))}"); + _out.WriteLine($"Expresses(\"Total.IsZero\") = {Rd4.Show(Rd4.Ask(_db.Orders, "Total.IsZero"))}"); + + string guardedAmount = Rd4.Outcome( + () => Rd4.Guard(_db.Orders).ToList(Rd4.Where("Total.Amount", "12", DataType.Number)).Data.Count); + + string unguardedAmount = Rd4.Outcome( + () => _db.Orders.Where(order => order.Total.Amount == 12m).ToList().Count); + + _out.WriteLine($" guarded Total.Amount -> {guardedAmount}"); + _out.WriteLine($" unguarded Total.Amount -> {unguardedAmount}"); + + // Left alone by the policy... + Assert.Null(Rd4.Ask(_db.Orders, "Total.Amount")); + Assert.Null(Rd4.Ask(_db.Orders, "Total.IsZero")); + + // ...and the provider still decides, which here is a throw rather than rows. + Assert.DoesNotContain("REFUSED", guardedAmount); + Assert.Equal(unguardedAmount, guardedAmount); + Assert.StartsWith("threw", guardedAmount); + } + } + + // ============================================================================================= + // 4. The posture comparison: which values are compared by what applies, and which as written. + // ============================================================================================= + + public sealed class Rd4CapProbe + { + private readonly ITestOutputHelper _out; + + public Rd4CapProbe(ITestOutputHelper output) => _out = output; + + private static readonly MethodInfo SamePosture = typeof(DwPolicy) + .GetMethod("SamePosture", BindingFlags.Static | BindingFlags.NonPublic)!; + + private static bool Compare(DwPolicyOptions inForce, DwPolicyOptions asked) => + (bool)SamePosture.Invoke(null, new object?[] { inForce, asked, Array.Empty() })!; + + /// + /// Walks every cap: for each, whether two postures differing only in that cap are told apart, + /// and whether the value the enforcement path applies is the value written down. + /// + [Fact] + public void Only_the_group_floor_is_compared_by_the_value_that_applies() + { + // The floor: unset and written-as-the-default are the same posture. + DwPolicyOptions floorUnset = new() { Tier = DwTier.Strict }; + DwPolicyOptions floorWritten = new() + { + Tier = DwTier.Strict, Caps = { MinGroupSize = DwCaps.DefaultMinGroupSize } + }; + + bool floorSame = Compare(floorUnset, floorWritten); + + _out.WriteLine($"MinGroupSize unset({floorUnset.Caps.MinGroupSize}) vs written({floorWritten.Caps.MinGroupSize})" + + $" -> same posture = {floorSame}"); + + // DefaultPageSize: two postures that page a caller identically, written differently. + DwPolicyOptions pageLow = new() + { + Tier = DwTier.Strict, Caps = { MaxPageSize = 100, DefaultPageSize = 100 } + }; + + DwPolicyOptions pageHigh = new() + { + Tier = DwTier.Strict, Caps = { MaxPageSize = 100, DefaultPageSize = 5000 } + }; + + bool pageSame = Compare(pageLow, pageHigh); + + int appliedLow = Math.Min(pageLow.Caps.DefaultPageSize, pageLow.Caps.MaxPageSize); + int appliedHigh = Math.Min(pageHigh.Caps.DefaultPageSize, pageHigh.Caps.MaxPageSize); + + _out.WriteLine($"DefaultPageSize written {pageLow.Caps.DefaultPageSize} vs {pageHigh.Caps.DefaultPageSize}, " + + $"both applied as {appliedLow}/{appliedHigh} -> same posture = {pageSame}"); + + Assert.True(floorSame, "the group floor is compared by the value that applies"); + Assert.Equal(appliedLow, appliedHigh); + Assert.False(pageSame, "DefaultPageSize is compared as written, not by the value that applies"); + } + + /// Proves the same sentence end to end: the sanitizer pages both postures alike. + [Fact] + public void The_two_page_size_postures_page_a_caller_identically() + { + List rows = Enumerable.Range(1, 250) + .Select(i => new Rd4Role { Id = i, Code = $"c{i}" }) + .ToList(); + + foreach (int written in new[] { 100, 5000 }) + { + DwPolicyOptions options = new() + { + Tier = DwTier.Convenience, Caps = { MaxPageSize = 100, DefaultPageSize = written } + }; + + FilterResult result = rows.AsQueryable() + .ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(new Filter()); + + _out.WriteLine($"DefaultPageSize written as {written,5} -> the caller got {result.Data.Count} row(s)"); + + Assert.Equal(100, result.Data.Count); + } + } + } +} diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index 2b3dd5d..43d9797 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -581,7 +581,7 @@ Sorts the query by one or multiple criteria. - `Field` must be non-empty and valid on `T`. - `Field` may not end on a collection of entities/complex types (there is no single value to compare). -**Collection paths:** when `Field` crosses a collection navigation, the collection is reduced to one comparable value — the **smallest** element ascending, the **largest** descending. See [Ordering Across Collections](#14-ordering-across-collections). +**Collection paths:** when `Field` crosses a collection navigation, the collection is reduced to one comparable value — the **smallest** element ascending, the **largest** descending. See [Ordering Across Collections](#13-ordering-across-collections). **Returns:** `IQueryable` — ordered query. @@ -1287,11 +1287,7 @@ The entire `Brands` collection is bound as-is. --- -### 10. Nested Collection Navigation - ---- - -### 11. `SelectDynamic` — Dynamic Field Projection +### 10. `SelectDynamic` — Dynamic Field Projection **Direct scalars:** ```json @@ -1407,7 +1403,7 @@ The entire `Brands` collection is bound as-is. --- -### 12. `FilterDynamic` / `ToListDynamic(Filter)` / `ToListAsyncDynamic(Filter)` — Full Dynamic Filter +### 11. `FilterDynamic` / `ToListDynamic(Filter)` / `ToListAsyncDynamic(Filter)` — Full Dynamic Filter Uses the same `Filter` JSON shape as example 7. The difference is the return type: `IQueryable` / `FilterResult` instead of `IQueryable` / `FilterResult`. @@ -1462,7 +1458,7 @@ Uses the same `Filter` JSON shape as example 7. The difference is the return typ --- -### 13. Nested Collection Navigation +### 12. Nested Collection Navigation When a field path traverses a collection property (e.g., `Orders.OrderItems.ProductName`), the library automatically wraps the inner segment in a `.Any()` lambda. @@ -1480,7 +1476,7 @@ When a field path traverses a collection property (e.g., `Orders.OrderItems.Prod --- -### 14. Ordering Across Collections +### 13. Ordering Across Collections A sort needs a single comparable value per row, so `.Any()` is not applicable to `OrderBy`. When an order field path crosses a collection property, each collection segment is reduced with an aggregate instead: **`Min` when sorting ascending, `Max` when sorting descending** — that is, rows are ordered by their *best matching* element in the requested direction. @@ -1713,7 +1709,9 @@ It is refused only where the whole set of members a container can produce is kno | A column only a subtype maps, queried through the base | the queried type's model, which is what EF Core translates against | Refused | | A projection a provider that is not EF Core's ran | nothing — its rules are its own | Left alone, as it always did | -A projection is therefore read only as far as its initializer can be read. An entity query names every producible member from the model; a projection names them only where each assignment is a nested initializer, a member copied from the entity, a value built and left empty, a null, or a conditional over those. Past `MaxComplexDepth` — eight levels — it stops reading and stops speaking. Under `Strict` such a path still reaches the provider and still fails there, exactly as it did before 3.3.0. +A shadow property is a separate matter and unchanged: a field path names CLR members, and a shadow property has none, so no clause can name one — guarded or not, before this release or after it. Map it to a property, or project it with `EF.Property` and name the projected member. + +A projection is therefore read only as far as its initializer can be read. An entity query names every producible member from the model; a projection names them only where each assignment is a nested initializer, a member copied from the entity, a value built and left empty, or a conditional over those — a null branch beside one of them included. A member assigned nothing but a null is left alone, like any assignment this shape cannot read. Past `MaxComplexDepth` — eight levels — it stops reading and stops speaking. Under `Strict` such a path still reaches the provider and still fails there, exactly as it did before 3.3.0. **Left alone** is not a promise that the path runs. The policy does not refuse it, so it behaves exactly as it does unguarded: `Name.IsEmpty` beneath a column mapped through a value converter still fails inside the provider, as it always has. @@ -2112,7 +2110,7 @@ What counts as the same posture: | The exposed entity catalogue: the same types, every name each answers to, and the name each is reported under | | | The provider *types*, in the order they were supplied | | -`IncludeTraceInResult` is compared the way a cap is: it defaults to the tier's own answer, and the +`IncludeTraceInResult` is compared the way the group floor is: it defaults to the tier's own answer, and the tiers are equal by then, so a host writing that answer out and a host leaving it null hand a caller the same result. A type exposed under two names is a different matter — it is reported under the last name it was given, so two catalogues that resolve every name alike still answer a schema request @@ -2333,6 +2331,8 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of ### ⚠️ Breaking Points +These are numbered as this document numbers them. The website's [breaking-changes page](https://doc.dynamicwhere.com/docs/breaking-changes) carries the same points with its own numbering, which runs further, so follow a point by its title rather than by its number. + 1. **Parameterless Constructor Required for Select Projection** `Select(fields)` requires `T` to have a parameterless (default) constructor. If `T` does not have one — a positional record, most often — a `LogicException` is thrown whose `Message` is the stable code `SelectTypeMustHaveParameterlessConstructor` and whose `Subject` carries `typeof(T).Name`. Before 3.1.0 that message was an English sentence with the type name inside it. Most EF Core entity classes have parameterless constructors by default. A guarded query reaches the same refusal when a member carries `[DwNoSelect]`, because deny-select projects — since 3.2.0 whatever the member holds, and beneath another member when its value can reach the result (point 20). diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index c3b3050..333f300 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -1508,8 +1508,10 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}MaxComplexDepth — eight levels + copied from the entity, a value built and left empty, or a conditional + over those — a null branch beside one of them included. A member + assigned nothing but a null is left alone, like any assignment this + shape cannot read. Past MaxComplexDepth — eight levels — it stops reading and stops speaking, and under Strict such a path still reaches the provider and still fails there, exactly as it did before 3.3.0. diff --git a/OfficialWebsite/app/docs/examples/page.tsx b/OfficialWebsite/app/docs/examples/page.tsx index d214045..e991db9 100644 --- a/OfficialWebsite/app/docs/examples/page.tsx +++ b/OfficialWebsite/app/docs/examples/page.tsx @@ -3,9 +3,9 @@ import Link from "next/link"; import DocPage from "@/components/DocPage"; export const metadata: Metadata = { - title: "JSON Cookbook — 13 copy-paste EF Core dynamic filter examples", + title: "JSON Cookbook — 12 copy-paste EF Core dynamic filter examples", description: - "Thirteen copy-pasteable JSON examples for DynamicWhere.ex — select projection, single condition, condition groups, ordering, paging, group-by + aggregate, set operations, nested collections, dynamic shapes. Every extension method covered.", + "Twelve copy-pasteable JSON examples for DynamicWhere.ex — select projection, single condition, condition groups, ordering, paging, group-by + aggregate, set operations, nested collections, dynamic shapes. Every extension method covered.", keywords: [ "EF Core dynamic filter examples", "dynamic LINQ JSON examples", @@ -76,21 +76,21 @@ const EXAMPLES: { href: string; n: number; title: string; blurb: string }[] = [ }, { href: "/docs/examples/select-dynamic", - n: 11, + n: 10, title: "SelectDynamic — Dynamic Projection", blurb: "All eight path-style variants — direct, dotted reference, dotted collection, multi-level, merged, whole object, whole collection, deep reference.", }, { href: "/docs/examples/filter-dynamic", - n: 12, + n: 11, title: "FilterDynamic — Dynamic Filter", blurb: "Full Filter request returning FilterResult, plus the projection-rule note.", }, { href: "/docs/examples/nested-collection", - n: 13, + n: 12, title: "Nested Collection Navigation", blurb: "Field path through a collection — the library wraps it in .Any() lambdas.", }, @@ -101,7 +101,7 @@ export default function Page() {

          JSON Cookbook

          - Thirteen copy-pasteable examples covering every extension method, every + Twelve copy-pasteable examples covering every extension method, every projection style, and every set / aggregation shape DynamicWhere.ex supports. Each example is the exact JSON your front-end would send.

          diff --git a/OfficialWebsite/app/docs/installation/page.tsx b/OfficialWebsite/app/docs/installation/page.tsx index 075f69b..608c60f 100644 --- a/OfficialWebsite/app/docs/installation/page.tsx +++ b/OfficialWebsite/app/docs/installation/page.tsx @@ -7,7 +7,7 @@ import Callout from "@/components/Callout"; export const metadata: Metadata = { title: "Install DynamicWhere.ex — NuGet, dotnet CLI, PackageReference", description: - "How to install DynamicWhere.ex from NuGet into a .NET 6, 7, 8, or 9 project — dotnet CLI, Visual Studio Package Manager, or csproj PackageReference. Works with EF Core on SQL Server, PostgreSQL, MySQL, and SQLite.", + "How to install DynamicWhere.ex from NuGet into a .NET 6, 7, 8, 9 or 10 project — dotnet CLI, Visual Studio Package Manager, or csproj PackageReference. Works with EF Core on SQL Server, PostgreSQL, MySQL, and SQLite.", keywords: [ "DynamicWhere.ex install", "DynamicWhere.ex NuGet", diff --git a/OfficialWebsite/app/docs/policies/configuration/page.tsx b/OfficialWebsite/app/docs/policies/configuration/page.tsx index 2295840..19e4523 100644 --- a/OfficialWebsite/app/docs/policies/configuration/page.tsx +++ b/OfficialWebsite/app/docs/policies/configuration/page.tsx @@ -954,8 +954,9 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so

          - IncludeTraceInResult is compared the way a cap is: it - defaults to the tier's own answer, and the tiers are equal by then, + IncludeTraceInResult is compared the way the group floor + is — every other cap is compared as written. It defaults to the + tier's own answer, and the tiers are equal by then, so a host writing that answer out and a host leaving it null hand a caller the same result. The catalogue is stricter — a type exposed under two names is reported under the last one it was given, so two catalogues diff --git a/OfficialWebsite/app/layout.tsx b/OfficialWebsite/app/layout.tsx index 9bbf4e2..c5aefa8 100644 --- a/OfficialWebsite/app/layout.tsx +++ b/OfficialWebsite/app/layout.tsx @@ -27,7 +27,7 @@ const jetbrainsMono = JetBrains_Mono({ const BASE = `https://${SITE.domain}`; -const homeTitle = `${SITE.name} — JSON Dynamic LINQ Filter for Entity Framework Core (.NET 6/7/8/9)`; +const homeTitle = `${SITE.name} — JSON Dynamic LINQ Filter for Entity Framework Core (.NET 6/7/8/9/10)`; export const metadata: Metadata = { metadataBase: new URL(BASE), diff --git a/OfficialWebsite/app/page.tsx b/OfficialWebsite/app/page.tsx index f508894..2c5746b 100644 --- a/OfficialWebsite/app/page.tsx +++ b/OfficialWebsite/app/page.tsx @@ -310,7 +310,7 @@ export default function HomePage() { { title: "Installation", desc: "Add to your project in seconds.", href: "/docs/installation" }, { title: "Quick Start", desc: "A working filter in 30 lines.", href: "/docs/quick-start" }, { title: "Extension Methods", desc: "All 28 methods, one place.", href: "/docs/extensions" }, - { title: "JSON Cookbook", desc: "13 copy-pasteable examples.", href: "/docs/examples" }, + { title: "JSON Cookbook", desc: "12 copy-pasteable examples.", href: "/docs/examples" }, { title: "Enums Reference", desc: "Every DataType & Operator.", href: "/docs/enums" }, { title: "Classes Reference", desc: "Condition → Filter → Result.", href: "/docs/classes" }, { title: "Field-Level Policies", desc: "Who may query what, and see what.", href: "/docs/policies" }, diff --git a/OfficialWebsite/lib/nav.ts b/OfficialWebsite/lib/nav.ts index 979e3b3..ce5a9b4 100644 --- a/OfficialWebsite/lib/nav.ts +++ b/OfficialWebsite/lib/nav.ts @@ -146,9 +146,9 @@ export const NAV: NavGroup[] = [ { title: "7. Filter (Typed)", href: "/docs/examples/filter" }, { title: "8. Summary", href: "/docs/examples/summary" }, { title: "9. Segment", href: "/docs/examples/segment" }, - { title: "11. SelectDynamic", href: "/docs/examples/select-dynamic" }, - { title: "12. FilterDynamic", href: "/docs/examples/filter-dynamic" }, - { title: "13. Nested Collections", href: "/docs/examples/nested-collection" }, + { title: "10. SelectDynamic", href: "/docs/examples/select-dynamic" }, + { title: "11. FilterDynamic", href: "/docs/examples/filter-dynamic" }, + { title: "12. Nested Collections", href: "/docs/examples/nested-collection" }, ], }, { diff --git a/OfficialWebsite/lib/seo.ts b/OfficialWebsite/lib/seo.ts index e3cfc87..d8eacf4 100644 --- a/OfficialWebsite/lib/seo.ts +++ b/OfficialWebsite/lib/seo.ts @@ -49,7 +49,7 @@ export function softwareApplicationJsonLd() { alternateName: ["DynamicWhere", "Dynamic Where ex"], description: SITE.description, applicationCategory: "DeveloperApplication", - operatingSystem: "Cross-platform (.NET 6, 7, 8, 9)", + operatingSystem: "Cross-platform (.NET 6, 7, 8, 9, 10)", softwareVersion: SITE.version, url: BASE, downloadUrl: SITE.nuget, diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index 4bac3c7..5866fb5 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -865,7 +865,8 @@ then exactly as ToListAsync(Filter): Order(Orders) -> Page(Page) -> Select(Selec ### In memory: the IEnumerable overloads - `ToList(Filter)`, `ToListDynamic(Filter)` and `ToList(Summary)` on `IEnumerable` call `AsQueryable()` and - run the same pipeline with LINQ to Objects. For any other method call `list.AsQueryable()` yourself; the async + run the same pipeline with LINQ to Objects. `ApplyPolicy` takes an `IEnumerable` too, and a summary read + through it is floored like any other: groups smaller than `DwCaps.MinGroupSize`, 5 by default, are dropped. For any other method call `list.AsQueryable()` yourself; the async methods do not work on such a source. - Differences from EF Core: text operators follow .NET string semantics; a null reference navigation in a path throws `NullReferenceException`; typed `Select` through a reference navigation throws (EF.Property); @@ -2349,8 +2350,9 @@ a projection another provider ran nothing; its rules are its ``` - A projection is read only as far as its initializer can be read: a nested initializer, a member copied from the - entity, a value built and left empty, a null, and a conditional over those. Past `MaxComplexDepth` (eight levels) - it stops reading and stops speaking. Such a path reaches the provider and fails there, as before 3.3.0. + entity, a value built and left empty, and a conditional over those, a null branch included. A member assigned + nothing but a null is left alone. Past `MaxComplexDepth` (eight levels) it stops reading and stops speaking. Such + a path reaches the provider and fails there, as before 3.3.0. - `left alone` is not a promise that the path runs: the policy does not refuse it, so it behaves exactly as it does unguarded. `Name.IsEmpty` beneath a column mapped through a value converter still fails inside the provider. @@ -4872,8 +4874,9 @@ MemoryCalculationInput caller needs no IsConfigured check of its own — that check is a check-then-act two hosts can both pass. Compared: tier, DryRun, IncludeTraceInResult, AuditRefusals, HashSalt, StoreFailure, MaxSnapshotAge, RefreshInterval, every cap value, the exposed catalogue with every name it answers to and the name each - type is reported under, and the provider types in order. A cap's own default and the tier's own answer for - IncludeTraceInResult are compared by the value that applies, not by whether somebody wrote them down. + type is reported under, and the provider types in order. The group floor and the tier's own answer for + IncludeTraceInResult are compared by the value that applies, not by whether somebody wrote them down; + every other cap is compared as written. Not compared and not replaced: TokenVault, Services and the provider instances, so a second host runs with the first host's vault, container and rule stores. AddDwPolicies registers the posture in force rather than the instance it built. @@ -5140,6 +5143,9 @@ MemoryCalculationInput `[Column]`. Reached through a navigation (`Owner.New`) it is an ordinary member. - A declared date format may not write text ISO 8601 or a year-first date already reads; `DwDates.Configure` refuses one (section 4). ISO 8601 and year-first dates are read on every deployment and cannot be turned off. +- A shadow property cannot be named by any clause, guarded or not: a field path names CLR members, and a shadow + property has none, so the name matches nothing and is refused as an unknown name. Map it to a property, or reach + it through a projection that assigns it with `EF.Property`. - A member no database can compute — a getter over columns, or an unmapped getter on an entity — cannot be filtered, ordered, grouped or aggregated under `Strict` on a source the library can read (3.3.0, section 17). Map it, or name the columns beneath it. A projection may still name it, because EF Core evaluates the last projection diff --git a/README.md b/README.md index 19a5e48..27a2eb0 100644 --- a/README.md +++ b/README.md @@ -367,7 +367,7 @@ The complete reference — every enum, class, extension method, validation rule, | [Classes](https://doc.dynamicwhere.com/docs/classes) | Condition, ConditionGroup, ConditionSet, OrderBy, GroupBy, AggregateBy, PageBy, Filter, Segment, Summary, Result types | | [Extension Methods](https://doc.dynamicwhere.com/docs/extensions) | All 28 methods with signatures, validations, examples | | [Validation Rules](https://doc.dynamicwhere.com/docs/validation) | What's checked and what throws | -| [JSON Cookbook](https://doc.dynamicwhere.com/docs/examples) | 13 copy-pasteable end-to-end examples | +| [JSON Cookbook](https://doc.dynamicwhere.com/docs/examples) | 12 copy-pasteable end-to-end examples | | [Field-Level Policies](https://doc.dynamicwhere.com/docs/policies) | Attributes, precedence, masking, dynamic rules, admin API, k-anonymity | | [Cache & Optimization](https://doc.dynamicwhere.com/docs/cache) | Architecture, stores, options, presets, monitoring | | [Error Codes](https://doc.dynamicwhere.com/docs/errors) | Every `LogicException` message | From f1c7d950bb304a1b4ac6a7592366732e5c58ab84 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 15:15:00 +0300 Subject: [PATCH 20/54] fix(policies): the audit cap refuses like any other field, and the provider test reads the assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth review found an inference channel the strict tier had left open. An audited field records one event per use, and the query is refused rather than the record dropped once MaxAuditEvents is reached — fail closed, because an access with nothing written down is the one outcome [DwAudit] exists to prevent. That refusal carried CapExceeded and a SourceOrigin naming the cap, while a name matching nothing carried the ordinary field refusal and no origin. An unknown name is never audited and never reaches the cap, so the difference told a caller, one guess per request, which names are real and audited — which is to say exactly the fields [DwAudit] is put on. Under Strict, outside a dry run, the cap now raises the clause's own field refusal: same code, FieldPath "*", no origin. The request still fails, the trace still records which refusal it really was, and Convenience and a dry run still answer CapExceeded. The provider test now reads the assembly a type came from as well as its name, so a type declared under EF Core's own name elsewhere is no longer taken for EF Core's provider and rows that run are not refused on the strength of a name. The review's probes are kept. Two of them asserted contracts settled the other way: a host that replaces EF Core's query provider is left alone like any other provider the library cannot read, and a spoofed provider name no longer reaches the model. A round-3 probe walked every posture value, printed what it found and asserted nothing; the comparison is guarded for real elsewhere, and the probe is gone rather than standing in for one. Co-Authored-By: Claude Opus 5 --- .../Policies/ReviewThirdRoundProbes.cs | 34 +- DynamicWhere.Tests/Policies/S4Model.cs | 223 +++++ .../Policies/S4SecurityProbes.cs | 864 ++++++++++++++++++ .../Policies/S4SecurityProbesB.cs | 584 ++++++++++++ .../Policies/Source/FilterSanitizer.cs | 24 + DynamicWhere.ex/Policies/Source/RowShape.cs | 9 +- 6 files changed, 1707 insertions(+), 31 deletions(-) create mode 100644 DynamicWhere.Tests/Policies/S4Model.cs create mode 100644 DynamicWhere.Tests/Policies/S4SecurityProbes.cs create mode 100644 DynamicWhere.Tests/Policies/S4SecurityProbesB.cs diff --git a/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs b/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs index ff6e2a9..b4b6d9d 100644 --- a/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs +++ b/DynamicWhere.Tests/Policies/ReviewThirdRoundProbes.cs @@ -1032,35 +1032,11 @@ private static IEnumerable Uncopied(Type type, object original, object c // R3-M. SamePosture: every public knob, and the effective-value comparison. // ========================================================================================= - [Fact] - public void R3_M_Every_posture_value_is_compared() - { - MethodInfo? same = typeof(DwPolicy).GetMethod( - "SamePosture", BindingFlags.Static | BindingFlags.NonPublic); - - if (same is null) - { - _out.WriteLine("SamePosture not present on this version; nothing to compare."); - - return; - } - - // Every public settable property of DwPolicyOptions, and of DwCaps, reported with - // whether SamePosture notices a change to it. - List uncompared = new(); + // R3-M walked every settable posture value and printed what it found, and asserted nothing + // on what it printed. The comparison is guarded for real by + // ConfigureOnceTests.Every_value_on_the_posture_is_compared, which requires each value to be + // refused when it changes and fails when a value is added and left out, and by S4_N, which + // drives Configure itself one knob at a time. - foreach (PropertyInfo property in typeof(DwPolicyOptions) - .GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - _out.WriteLine($"DwPolicyOptions.{property.Name,-24} settable={property.CanWrite}"); - } - - foreach (PropertyInfo property in typeof(DwCaps).GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - _out.WriteLine($"DwCaps.{property.Name,-31} settable={property.CanWrite}"); - } - - _out.WriteLine($"uncompared: {uncompared.Count}"); - } } } diff --git a/DynamicWhere.Tests/Policies/S4Model.cs b/DynamicWhere.Tests/Policies/S4Model.cs new file mode 100644 index 0000000..1c29542 --- /dev/null +++ b/DynamicWhere.Tests/Policies/S4Model.cs @@ -0,0 +1,223 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.Internal; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 4 (security). The model the S4 probes query. + // + // EF Core 6 compatible on purpose, so the floor leg runs every probe: no complex properties, + // no ToJson, no primitive collections, no DateOnly/TimeOnly, no compiled model. + // ============================================================================================= + + ///

          An owned type with a getter over two of its own columns: no database computes it. + public class S4Money + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = "USD"; + + public bool IsZero => Amount == 0m; + } + + public class S4Customer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Orders { get; set; } = new(); + + /// Unmapped: a getter over a column. + [NotMapped] + public string Handle => Name + "!"; + } + + public class S4Line + { + public int Id { get; set; } + + public int OrderId { get; set; } + + public decimal Price { get; set; } + } + + public class S4Order + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public int Qty { get; set; } + + public int CustomerId { get; set; } + + public S4Customer Customer { get; set; } = null!; + + public S4Money Total { get; set; } = new(); + + public List Lines { get; set; } = new(); + + /// Denied outright, to prove the provider test gates nothing but Expresses. + [DynamicWhere.ex.Policies.Attributes.DwDeny(DynamicWhere.ex.Policies.Enums.PolicyFeature.All)] + public string Secret { get; set; } = string.Empty; + + /// Unmapped: a getter over two columns. + [NotMapped] + public string Slug => Code + "-" + Id; + } + + // ---- row types a caller projects into ------------------------------------------------------- + + public class S4Nest + { + public string A { get; set; } = string.Empty; + + public string B { get; set; } = string.Empty; + + public S4Money Money { get; set; } = new(); + + public bool Blank => A.Length == 0; + } + + public class S4Row + { + public S4Row() + { + } + + public S4Row(int id) => Id = id; + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string? Tag { get; set; } + + public S4Nest Nest { get; set; } = new(); + + public S4Money Money { get; set; } = new(); + + public S4Customer? Customer { get; set; } + } + + /// A row whose members differ only in letter case, to probe path collisions. + public class S4CaseRow + { + public int Id { get; set; } + + public S4Nest Value { get; set; } = new(); + +#pragma warning disable IDE1006 + public string value { get; set; } = string.Empty; +#pragma warning restore IDE1006 + } + + /// A row whose member names are prefixes of one another, to probe Beneath. + public class S4PrefixRow + { + public int Id { get; set; } + + public S4Nest A { get; set; } = new(); + + public string AB { get; set; } = string.Empty; + } + + public sealed class S4Context : DbContext + { + private readonly Microsoft.Data.Sqlite.SqliteConnection _connection; + private readonly Type? _replacementProvider; + + public S4Context(Microsoft.Data.Sqlite.SqliteConnection connection, Type? replacementProvider = null) + { + _connection = connection; + _replacementProvider = replacementProvider; + } + + public DbSet Orders => Set(); + + public DbSet Customers => Set(); + + public DbSet Lines => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlite(_connection); + + if (_replacementProvider is not null) + { + // The documented EF Core extension point a host uses to put its own query provider + // in place: ReplaceService. + typeof(DbContextOptionsBuilder) + .GetMethods() + .Single(m => m.Name == nameof(DbContextOptionsBuilder.ReplaceService) + && m.GetGenericArguments().Length == 2 + && m.GetParameters().Length == 0) + .MakeGenericMethod(typeof(IAsyncQueryProvider), _replacementProvider) + .Invoke(options, null); + } + } + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(order => order.Total); + model.Entity().Ignore(order => order.Slug); + model.Entity().Ignore(customer => customer.Handle); + } + } + + /// + /// A plain pass-through built the way a host builds one: derived from EF Core's own provider, + /// registered through ReplaceService<IAsyncQueryProvider, …>, rewriting nothing. + /// + public sealed class S4PassThroughProvider : EntityQueryProvider + { + public S4PassThroughProvider(IQueryCompiler compiler) + : base(compiler) + { + } + } + + /// + /// The base a spoofed provider inherits, so the spoof itself needs nothing but a name: every + /// member forwards to the provider it stands in front of. + /// + public abstract class S4ForwardingProvider : IQueryProvider + { + public IQueryProvider Inner { get; set; } = null!; + + public IQueryable CreateQuery(Expression expression) => Inner.CreateQuery(expression); + + public IQueryable CreateQuery(Expression expression) => + new S4SpoofedQueryable(this, Inner.CreateQuery(expression)); + + public object? Execute(Expression expression) => Inner.Execute(expression); + + public TResult Execute(Expression expression) => Inner.Execute(expression); + } + + /// A queryable whose Provider is the spoof and whose rows are the inner one's. + public sealed class S4SpoofedQueryable : IQueryable + { + private readonly IQueryable _inner; + + public S4SpoofedQueryable(IQueryProvider provider, IQueryable inner) + { + Provider = provider; + _inner = inner; + } + + public Type ElementType => typeof(T); + + public Expression Expression => _inner.Expression; + + public IQueryProvider Provider { get; } + + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/DynamicWhere.Tests/Policies/S4SecurityProbes.cs b/DynamicWhere.Tests/Policies/S4SecurityProbes.cs new file mode 100644 index 0000000..178d030 --- /dev/null +++ b/DynamicWhere.Tests/Policies/S4SecurityProbes.cs @@ -0,0 +1,864 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Reflection.Emit; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query.Internal; +using Xunit.Abstractions; + +#pragma warning disable xUnit1031 // the segment surface is async only; these probes drive it from a synchronous table + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 4, adversarial security review of 3.3.0 at 56d169d. + // + // Round 3's probes asked the OVER-block question: "unguarded ran AND guarded refused". + // These ask the UNDER-block one the release's own promise rests on: + // + // unguarded threw the provider's translation failure + // AND guarded (Strict) threw it too + // -> the strict tier answered with neither an answer nor a refusal. + // + // Everything here drives ApplyPolicy through its explicit posture overload, so no probe + // touches DwPolicy's process-wide state. + // ============================================================================================= + public sealed class S4SecurityProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly S4Context _db; + private readonly List _findings = new(); + + public S4SecurityProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new S4Context(_connection); + _db.Database.EnsureCreated(); + + S4Customer customer = new() { Name = "Acme" }; + + S4Order order = new() + { + Code = "AB123", + Qty = 2, + Customer = customer, + Total = new S4Money { Amount = 10m, Currency = "USD" } + }; + + order.Lines.Add(new S4Line { Price = 4m }); + order.Lines.Add(new S4Line { Price = 6m }); + + _db.Customers.Add(customer); + _db.Orders.Add(order); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness ----------------------------------------------------------------------------- + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter WhereOn(string field, DataType type, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + private static string Outcome(Func run) + { + try + { + object? value = run(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (LogicException failure) + { + return $"LogicException({failure.Message.Split('.')[0]})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static string GuardedWhere(IQueryable source, string field, DataType type, string value) + where T : class => + Outcome(() => Guard(source).ToList(WhereOn(field, type, value)).Data); + + private static string RawWhere(IQueryable source, Expression> predicate) => + Outcome(() => source.Where(predicate).ToList()); + + /// True for the provider's own "I cannot translate this" failure. + private static bool Untranslatable(string outcome) => + outcome is "InvalidOperationException" or "NotSupportedException"; + + /// + /// Records one probe. Flags it when the unguarded query fails inside the provider and the + /// guarded strict one fails the same way: neither an answer nor a refusal. + /// + private void Under(string probe, string unguarded, string guarded) + { + _out.WriteLine($"{probe,-62} unguarded={unguarded,-28} guarded={guarded}"); + + if (Untranslatable(unguarded) && Untranslatable(guarded)) + { + _findings.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + /// Logged, never flagged: a shape the release's own text puts outside the refusal. + private void Documented(string probe, string unguarded, string guarded) => + _out.WriteLine($"{probe,-62} unguarded={unguarded,-28} guarded={guarded} [documented limit]"); + + private void Done() => Assert.True(_findings.Count == 0, string.Join(" || ", _findings)); + + // ========================================================================================= + // S4-A. Every clause the release names, over an entity source. The regression floor. + // ========================================================================================= + + [Fact] + public void S4_A_Every_clause_the_release_names_refuses_an_uncomputable_path() + { + IQueryable orders = _db.Orders; + + Under("A1 where, unmapped getter on the entity", + RawWhere(orders, o => o.Slug == "AB123-1"), + GuardedWhere(orders, "Slug", DataType.Text, "AB123-1")); + + Under("A2 where, getter over an owned type's columns", + RawWhere(orders, o => o.Total.IsZero), + GuardedWhere(orders, "Total.IsZero", DataType.Boolean, "false")); + + Under("A3 where, unmapped getter one navigation away", + RawWhere(orders, o => o.Customer.Handle == "Acme!"), + GuardedWhere(orders, "Customer.Handle", DataType.Text, "Acme!")); + + Under("A4 order by the unmapped getter", + Outcome(() => orders.OrderBy(o => o.Slug).ToList()), + Outcome(() => Guard(orders) + .ToList(new Filter { Orders = new List { new() { Field = "Slug" } } }).Data)); + + Under("A5 grouping key is the unmapped getter", + Outcome(() => orders.GroupBy(o => o.Slug).Select(g => g.Key).ToList()), + Outcome(() => Guard(orders).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = { "Slug" }, + AggregateBy = { new AggregateBy { Alias = "n", Aggregator = Aggregator.Count } } + } + }).Data)); + + Under("A6 aggregated field is the getter over owned columns", + Outcome(() => orders.GroupBy(o => o.Code).Select(g => g.Max(o => o.Total.IsZero)).ToList()), + Outcome(() => Guard(orders).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = { "Code" }, + AggregateBy = { new AggregateBy { Field = "Total.IsZero", Alias = "z", Aggregator = Aggregator.Maximum } } + } + }).Data)); + + Under("A7 a filter inside a Segment", + RawWhere(orders, o => o.Slug == "AB123-1"), + Outcome(() => Guard(orders).ToListAsync(new Segment + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Slug", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "AB123-1" } + } + } + } + } + } + }).GetAwaiter().GetResult().Data)); + + Under("A8 an order inside a Segment", + Outcome(() => orders.OrderBy(o => o.Slug).ToList()), + Outcome(() => Guard(orders).ToListAsync(new Segment + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "AB123" } + } + } + } + } + }, + Orders = new List { new() { Field = "Slug" } } + }).GetAwaiter().GetResult().Data)); + + Done(); + } + + // ========================================================================================= + // S4-B. The projection branch: which projected sources the refusal reaches, and which the + // release's own text leaves out. + // ========================================================================================= + + [Fact] + public void S4_B_A_projection_the_shape_can_read_refuses_what_it_cannot_produce() + { + IQueryable built = _db.Orders.Select(o => new S4Row + { + Id = o.Id, + Nest = new S4Nest { A = o.Code } + }); + + Under("B1 initializer, a member it never assigns", + Outcome(() => _db.Orders.Select(o => new S4Row { Id = o.Id, Nest = new S4Nest { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()), + GuardedWhere(built, "Nest.B", DataType.Text, "x")); + + Under("B2 initializer, a getter over what it assigns", + Outcome(() => _db.Orders.Select(o => new S4Row { Id = o.Id, Nest = new S4Nest { A = o.Code } }) + .Where(r => r.Nest.Blank).ToList()), + GuardedWhere(built, "Nest.Blank", DataType.Boolean, "false")); + + IQueryable copied = _db.Orders.Select(o => new S4Row { Id = o.Id, Money = o.Total }); + + Under("B3 owned member copied whole, the getter over its columns", + Outcome(() => _db.Orders.Select(o => new S4Row { Id = o.Id, Money = o.Total }) + .Where(r => r.Money.IsZero).ToList()), + GuardedWhere(copied, "Money.IsZero", DataType.Boolean, "false")); + + // A member assigned from something the shape cannot read. Round 3 made this answer + // "cannot say" instead of "yes"; neither answer refuses, so the provider still decides. + IQueryable opaque = _db.Orders.Select(o => new S4Row + { + Id = o.Id, + Nest = Build(o.Code) + }); + + Documented("B4 member assigned from a method call, a path beneath it", + Outcome(() => _db.Orders.Select(o => new S4Row { Id = o.Id, Nest = Build(o.Code) }) + .Where(r => r.Nest.B == "x").ToList()), + GuardedWhere(opaque, "Nest.B", DataType.Text, "x")); + + Done(); + } + + private static S4Nest Build(string code) => new() { A = code }; + + // ========================================================================================= + // S4-D. RowShape.Expresses, read directly. The tri-state answers the round-3 _opaque set + // produces, and whether any of them turned a proven "no" into "cannot say". + // ========================================================================================= + + [Fact] + public void S4_D_The_opaque_set_never_takes_back_a_proven_no() + { + List wrong = new(); + + void Expect(string probe, IQueryable source, string path, bool? expected) + { + bool? answer = RowShape.Of(source).Expresses(path); + + _out.WriteLine($"{probe,-62} Expresses(\"{path}\") = {Show(answer)} expected {Show(expected)}"); + + if (answer != expected) + { + wrong.Add($"{probe}: Expresses(\"{path}\") = {Show(answer)}, expected {Show(expected)}"); + } + } + + // An ordinary initializer: the whole member set is known at every level. + IQueryable plain = _db.Orders.Select(o => new S4Row + { + Id = o.Id, + Nest = new S4Nest { A = o.Code } + }); + + Expect("D1 assigned member", plain, "Nest.A", true); + Expect("D2 member the nested initializer leaves out", plain, "Nest.B", false); + Expect("D3 getter over what it assigns", plain, "Nest.Blank", false); + Expect("D4 member the outer initializer leaves out", plain, "Tag", false); + + // One sibling opaque: it must take back nothing from the sibling beside it. + IQueryable sibling = _db.Orders.Select(o => new S4Row + { + Id = o.Id, + Nest = new S4Nest { A = o.Code }, + Tag = Label(o.Code) + }); + + Expect("D5 opaque sibling, the readable one is unchanged", sibling, "Nest.B", false); + Expect("D6 opaque sibling, the opaque one says nothing", sibling, "Tag", null); + Expect("D7 unassigned member beside an opaque one", sibling, "Money.Amount", false); + + // The opaque member itself, and everything beneath it. + IQueryable whole = _db.Orders.Select(o => new S4Row { Id = o.Id, Nest = Build(o.Code) }); + + Expect("D8 opaque member", whole, "Nest", null); + Expect("D9 beneath an opaque member", whole, "Nest.A", null); + Expect("D10 deeper beneath an opaque member", whole, "Nest.Money.Amount", null); + Expect("D11 a sibling the initializer never assigns", whole, "Tag", false); + + // One branch readable, the other not: the member is read from neither. + IQueryable twoWays = _db.Orders.Select(o => new S4Row + { + Id = o.Id, + Nest = o.Code == null ? new S4Nest { A = o.Code! } : Build(o.Code) + }); + + Expect("D12 one unreadable branch makes the member unreadable", twoWays, "Nest.B", null); + Expect("D13 the other members are untouched", twoWays, "Tag", false); + + // Spelling: the caller writes a path in any case, with padding. + Expect("D14 letter case", plain, "nest.b", false); + Expect("D15 padded with spaces", plain, "Nest . B", false); + Expect("D16 padded with dots", plain, "Nest..B", false); + Expect("D17 opaque path in another case", whole, "NEST.A", null); + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + private static string Label(string code) => code + "!"; + + private static string Show(bool? value) => value is null ? "null" : value.Value ? "true" : "false"; + + // ========================================================================================= + // S4-E. Path collisions in the _opaque set: members differing only in case, and member + // names that are prefixes of one another. + // ========================================================================================= + + [Fact] + public void S4_E_Colliding_member_names_never_manufacture_a_refusal() + { + List wrong = new(); + + void Expect(string probe, bool? answer, bool?[] allowed) + { + _out.WriteLine($"{probe,-62} Expresses = {Show(answer)}"); + + if (Array.IndexOf(allowed, answer) < 0) + { + wrong.Add($"{probe}: Expresses = {Show(answer)}"); + } + } + + // "Value" builds a readable nested row; "value" is opaque and collides with it under the + // ordinal-ignore-case comparison every path string is matched with. + IQueryable collide = _db.Orders.Select(o => new S4CaseRow + { + Id = o.Id, + Value = new S4Nest { A = o.Code }, + value = Label(o.Code) + }); + + RowShape shape = RowShape.Of(collide); + + // Only false is acted on. A collision may cost certainty; it must never invent a refusal + // for a member the projection does assign. + Expect("E1 assigned member under the colliding name", shape.Expresses("Value.A"), new bool?[] { true, null }); + Expect("E2 unassigned member under the colliding name", shape.Expresses("Value.B"), new bool?[] { false, null }); + Expect("E3 the opaque spelling itself", shape.Expresses("value"), new bool?[] { null, true }); + + // Member names that are prefixes of one another: "AB" must not be read as beneath "A". + IQueryable prefixes = _db.Orders.Select(o => new S4PrefixRow + { + Id = o.Id, + A = new S4Nest { A = o.Code }, + AB = Label(o.Code) + }); + + RowShape prefixShape = RowShape.Of(prefixes); + + Expect("E4 a sibling whose name starts with an opaque one", + prefixShape.Expresses("A.A"), new bool?[] { true }); + Expect("E5 the member the sibling's prefix would have hidden", + prefixShape.Expresses("A.B"), new bool?[] { false }); + Expect("E6 the opaque sibling itself", prefixShape.Expresses("AB"), new bool?[] { null }); + + // End to end: the readable member must still be filterable. + _out.WriteLine("E7 guarded filter on the readable member of a colliding row: " + + GuardedWhere(collide, "Value.A", DataType.Text, "AB123")); + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + // ========================================================================================= + // S4-F. EfCoreOwns. Which providers a real EF Core query reaches the shape with, and what + // a host's own registration does to the refusal. + // ========================================================================================= + + [Fact] + public void S4_F_Every_EF_Core_shape_this_census_reaches_carries_EF_Cores_own_provider() + { + (string Name, IQueryable Source)[] shapes = + { + ("Set()", _db.Set()), + ("DbSet.AsQueryable()", _db.Orders.AsQueryable()), + ("Include(string)", _db.Orders.Include("Customer")), + ("Union", _db.Orders.Union(_db.Orders)), + ("Concat", _db.Orders.Concat(_db.Orders)), + ("Intersect", _db.Orders.Intersect(_db.Orders)), + ("Except", _db.Orders.Except(_db.Orders)), + ("Reverse", _db.Orders.OrderBy(o => o.Id).Reverse()), + ("DefaultIfEmpty", _db.Orders.DefaultIfEmpty()), + ("FromSqlInterpolated", _db.Orders.FromSqlInterpolated($"SELECT * FROM Orders")), + ("Where+Select+Where", _db.Orders.Where(o => o.Id > 0) + .Select(o => new S4Row { Id = o.Id }).Where(r => r.Id > 0)), + ("Entry().Collection().Query()", EntryQuery()), + ("Cast", _db.Orders.Cast()), + ("Skip+Take+Distinct", _db.Orders.OrderBy(o => o.Id).Skip(0).Take(5).Distinct()) + }; + + List lost = new(); + + foreach ((string name, IQueryable source) in shapes) + { + string full = source.Provider.GetType().FullName ?? "?"; + bool owned = full == "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider"; + + _out.WriteLine($"{name,-34} EfCoreOwns={owned,-6} {full}"); + + if (!owned) + { + lost.Add($"{name} -> {full}"); + } + } + + Assert.True(lost.Count == 0, "refusal silently lost on: " + string.Join(", ", lost)); + } + + private IQueryable EntryQuery() + { + S4Order tracked = _db.Orders.Include(o => o.Lines).First(); + + return _db.Entry(tracked).Collection(o => o.Lines).Query(); + } + + // ========================================================================================= + // S4-G. A host that puts its own provider in EF Core's place, which is the documented + // ReplaceService extension point and leaves EF Core translating exactly as before. + // ========================================================================================= + + [Fact] + public void S4_G_A_host_replaced_query_provider_still_refuses_what_EF_Core_cannot_compute() + { + using SqliteConnection connection = new("DataSource=:memory:"); + + connection.Open(); + + using S4Context replaced = new(connection, typeof(S4PassThroughProvider)); + + replaced.Database.EnsureCreated(); + + S4Customer customer = new() { Name = "Acme" }; + + replaced.Customers.Add(customer); + replaced.Orders.Add(new S4Order + { + Code = "AB123", Qty = 2, Customer = customer, + Total = new S4Money { Amount = 10m, Currency = "USD" } + }); + replaced.SaveChanges(); + replaced.ChangeTracker.Clear(); + + IQueryable orders = replaced.Orders; + + _out.WriteLine($"provider = {orders.Provider.GetType().FullName}"); + _out.WriteLine($"rewrites anything = no (a plain pass-through)"); + + // A host that replaces EF Core's query provider through ReplaceService gets a provider + // of its own type, and the library cannot tell one that rewrites what EF Core cannot + // translate from one that passes straight through. It leaves both alone: refusing on a + // guess would take back a query the rewriting host answers today. Documented as a limit. + string entityRaw = RawWhere(orders, o => o.Slug == "AB123-1"); + string entityGuarded = GuardedWhere(orders, "Slug", DataType.Text, "AB123-1"); + + Documented("G1 entity branch, unmapped getter behind a replaced provider", entityRaw, entityGuarded); + + IQueryable projected = orders.Select(o => new S4Row + { + Id = o.Id, + Nest = new S4Nest { A = o.Code } + }); + + string projectedRaw = Outcome( + () => orders.Select(o => new S4Row { Id = o.Id, Nest = new S4Nest { A = o.Code } }) + .Where(r => r.Nest.B == "x").ToList()); + + string projectedGuarded = GuardedWhere(projected, "Nest.B", DataType.Text, "x"); + + Documented("G2 projection branch, unassigned member behind a replaced provider", projectedRaw, projectedGuarded); + + // The guarded query does what the unguarded one does, which is the whole of the claim: + // the refusal does not reach here, and nothing is refused that would otherwise run. + Assert.Equal(entityRaw, entityGuarded); + Assert.Equal(projectedRaw, projectedGuarded); + + Done(); + } + + // ========================================================================================= + // S4-H. The other direction of the name comparison: can anything make EfCoreOwns answer + // true for rows EF Core does not translate? + // ========================================================================================= + + [Fact] + public void S4_H_A_type_of_EF_Cores_own_name_is_taken_for_EF_Cores_own_provider() + { + // A type declared in any assembly, with that namespace and that name. Emitted rather + // than written, so the test assembly itself does not shadow EF Core's real type. + Type spoofed = SpoofedProviderType(); + + _out.WriteLine($"emitted type = {spoofed.FullName}"); + _out.WriteLine($"from EF Core? = {spoofed.Assembly.GetName().Name}"); + + Assert.Equal("Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider", spoofed.FullName); + + // Rows in memory: LINQ to Objects runs the getter and no database is involved. + List rows = _db.Orders.AsNoTracking().ToList(); + + S4ForwardingProvider spoof = (S4ForwardingProvider)Activator.CreateInstance(spoofed)!; + + spoof.Inner = rows.AsQueryable().Provider; + + IQueryable wrapped = new S4SpoofedQueryable( + spoof, + spoof.Inner.CreateQuery( + rows.AsQueryable() + .Select(o => new S4Row { Id = o.Id, Nest = new S4Nest { A = o.Code } }) + .Expression)); + + RowShape shape = RowShape.Of(wrapped); + + string unguarded = Outcome(() => wrapped.Where(r => r.Nest.B == "x").ToList()); + string guarded = GuardedWhere(wrapped, "Nest.B", DataType.Text, "x"); + + _out.WriteLine($"RowShape kind = {shape.Kind}"); + _out.WriteLine($"Expresses(Nest.B) = {Show(shape.Expresses("Nest.B"))}"); + _out.WriteLine($"unguarded = {unguarded}"); + _out.WriteLine($"guarded (strict) = {guarded}"); + + // The harm, if any, is over-block: rows that run in memory refused because a type name + // said EF Core owned them. Recorded either way, asserted only as a statement of fact. + _out.WriteLine(shape.Expresses("Nest.B") == false + ? "SPOOFED: a name alone put these rows under EF Core's model" + : "not spoofable through this route"); + + // The provider test reads the assembly the type came from as well as its name, so a + // type declared under EF Core's name elsewhere is not taken for EF Core's provider. The + // rows run, as they do unguarded, rather than being refused on the strength of a name. + Assert.Null(shape.Expresses("Nest.B")); + Assert.StartsWith("OK", unguarded, StringComparison.Ordinal); + Assert.StartsWith("OK", guarded, StringComparison.Ordinal); + } + + private static Type SpoofedProviderType() + { + AssemblyBuilder assembly = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName("S4Spoof"), AssemblyBuilderAccess.Run); + + ModuleBuilder module = assembly.DefineDynamicModule("S4Spoof"); + + TypeBuilder type = module.DefineType( + "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider", + TypeAttributes.Public | TypeAttributes.Class, + typeof(S4ForwardingProvider)); + + type.DefineDefaultConstructor(MethodAttributes.Public); + + return type.CreateTypeInfo()!.AsType(); + } + + // ========================================================================================= + // S4-I. The refusal itself: a caller must not be able to tell a denied field, a name that + // matches nothing and a path the query cannot compute apart. + // ========================================================================================= + + [Fact] + public void S4_I_Three_refusals_a_caller_must_not_be_able_to_tell_apart() + { + (string What, string Field)[] probes = + { + ("a name that matches nothing", "Zzzzz"), + ("a path the query cannot compute", "Slug"), + ("a two-segment name matching nothing", "Zzzzz.Yyyyy"), + ("a two-segment path it cannot compute", "Total.IsZero") + }; + + List seen = new(); + + foreach ((string what, string field) in probes) + { + try + { + Guard(_db.Orders).ToList(WhereOn(field, DataType.Text, "x")); + + seen.Add($"{what}: NO REFUSAL"); + } + catch (PolicyException refusal) + { + string shape = + $"{refusal.ErrorCode}|{refusal.FieldPath}|{refusal.Feature}|" + + $"rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}|" + + $"message={refusal.Message}"; + + _out.WriteLine($"{what,-42} {shape}"); + seen.Add(shape); + } + catch (Exception failure) + { + _out.WriteLine($"{what,-42} {failure.GetType().Name}: {failure.Message}"); + seen.Add($"{what}: {failure.GetType().Name}"); + } + } + + Assert.True( + seen.Distinct(StringComparer.Ordinal).Count() == 1, + "the four refusals differ: " + string.Join(" || ", seen.Distinct(StringComparer.Ordinal))); + } + + // ========================================================================================= + // S4-J. Public Clone(): the caller's own request must be untouched by a guarded read, and + // nothing the library injects may become visible on it. + // ========================================================================================= + + [Fact] + public void S4_J_A_guarded_read_leaves_the_callers_own_request_alone() + { + List changed = new(); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "AB123" } + } + } + }, + Selects = new List { "code" }, + Orders = new List { new() { Field = "code" } } + }; + + int conditions = filter.ConditionGroup.Conditions.Count; + int groups = filter.ConditionGroup.SubConditionGroups.Count; + string field = filter.ConditionGroup.Conditions[0].Field!; + object value = filter.ConditionGroup.Conditions[0].Values[0]; + List selects = new(filter.Selects); + string orderField = filter.Orders[0].Field!; + PageBy? page = filter.Page; + + _out.WriteLine("guarded read: " + Outcome(() => Guard(_db.Orders).ToList(filter).Data)); + + if (filter.ConditionGroup.Conditions.Count != conditions + || filter.ConditionGroup.SubConditionGroups.Count != groups) + { + changed.Add("the condition tree the caller sent grew"); + } + + if (!string.Equals(filter.ConditionGroup.Conditions[0].Field, field, StringComparison.Ordinal)) + { + changed.Add($"the caller's field was rewritten: {field} -> {filter.ConditionGroup.Conditions[0].Field}"); + } + + if (!ReferenceEquals(filter.ConditionGroup.Conditions[0].Values[0], value)) + { + changed.Add("the caller's value was replaced"); + } + + if (filter.Selects is null || !filter.Selects.SequenceEqual(selects, StringComparer.Ordinal)) + { + changed.Add("the caller's projection list was rewritten"); + } + + if (!string.Equals(filter.Orders![0].Field, orderField, StringComparison.Ordinal)) + { + changed.Add($"the caller's order was rewritten: {orderField} -> {filter.Orders[0].Field}"); + } + + if (!ReferenceEquals(filter.Page, page)) + { + changed.Add("a page the caller never sent was written onto their filter"); + } + + // The same for a segment and a summary, whose Clone the release also made public. + Segment segment = new() + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "AB123" } + } + } + } + } + }, + Orders = new List { new() { Field = "code" } } + }; + + string segmentField = segment.ConditionSets[0].ConditionGroup.Conditions[0].Field!; + PageBy? segmentPage = segment.Page; + + _out.WriteLine("guarded segment: " + + Outcome(() => Guard(_db.Orders).ToListAsync(segment).GetAwaiter().GetResult().Data)); + + if (!string.Equals(segment.ConditionSets[0].ConditionGroup.Conditions[0].Field, segmentField, StringComparison.Ordinal) + || !ReferenceEquals(segment.Page, segmentPage)) + { + changed.Add("the caller's segment was rewritten"); + } + + Summary summary = new() + { + GroupBy = new GroupBy + { + Fields = { "code" }, + AggregateBy = { new AggregateBy { Alias = "n", Aggregator = Aggregator.Count } } + } + }; + + string key = summary.GroupBy.Fields[0]; + int aggregates = summary.GroupBy.AggregateBy.Count; + ConditionGroup? having = summary.Having; + + _out.WriteLine("guarded summary: " + Outcome(() => Guard(_db.Orders).ToList(summary).Data)); + + if (!string.Equals(summary.GroupBy.Fields[0], key, StringComparison.Ordinal) + || summary.GroupBy.AggregateBy.Count != aggregates + || !ReferenceEquals(summary.Having, having)) + { + changed.Add("the caller's summary was rewritten — the group floor's own count is visible on it"); + } + + Assert.True(changed.Count == 0, string.Join(" || ", changed)); + } + + // ========================================================================================= + // S4-K. Clone() itself: what a caller now holds must share no node with what they cloned. + // ========================================================================================= + + [Fact] + public void S4_K_A_public_clone_shares_no_node_a_later_rewrite_could_reach() + { + List shared = new(); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Code", Values = { "a" } } }, + SubConditionGroups = + { + new ConditionGroup { Conditions = { new Condition { Field = "Qty", Values = { "1" } } } } + } + }, + Selects = new List { "Code" }, + Orders = new List { new() { Field = "Code" } }, + Page = new PageBy { PageNumber = 1, PageSize = 10 } + }; + + Filter copy = filter.Clone(); + + void Distinct(string what, object? left, object? right) + { + if (left is not null && ReferenceEquals(left, right)) + { + shared.Add(what); + } + } + + Distinct("Filter.ConditionGroup", filter.ConditionGroup, copy.ConditionGroup); + Distinct("ConditionGroup.Conditions", filter.ConditionGroup!.Conditions, copy.ConditionGroup!.Conditions); + Distinct("Conditions[0]", filter.ConditionGroup.Conditions[0], copy.ConditionGroup.Conditions[0]); + Distinct("Conditions[0].Values", filter.ConditionGroup.Conditions[0].Values, copy.ConditionGroup.Conditions[0].Values); + Distinct("SubConditionGroups", filter.ConditionGroup.SubConditionGroups, copy.ConditionGroup.SubConditionGroups); + Distinct("SubConditionGroups[0]", filter.ConditionGroup.SubConditionGroups[0], copy.ConditionGroup.SubConditionGroups[0]); + Distinct("SubConditionGroups[0].Conditions[0]", + filter.ConditionGroup.SubConditionGroups[0].Conditions[0], + copy.ConditionGroup.SubConditionGroups[0].Conditions[0]); + Distinct("Selects", filter.Selects, copy.Selects); + Distinct("Orders", filter.Orders, copy.Orders); + Distinct("Orders[0]", filter.Orders![0], copy.Orders![0]); + Distinct("Page", filter.Page, copy.Page); + + // Mutating the copy must not reach the original: this is what the release sells it for. + copy.ConditionGroup.Conditions[0].Field = "Qty"; + copy.Selects!.Add("Qty"); + copy.Orders[0].Field = "Qty"; + copy.Page!.PageNumber = 2; + + if (filter.ConditionGroup.Conditions[0].Field != "Code") shared.Add("a rewrite of the copy reached the original's field"); + if (filter.Selects!.Count != 1) shared.Add("a rewrite of the copy reached the original's projection"); + if (filter.Orders[0].Field != "Code") shared.Add("a rewrite of the copy reached the original's order"); + if (filter.Page!.PageNumber != 1) shared.Add("a rewrite of the copy reached the original's page"); + + _out.WriteLine($"shared nodes: {shared.Count}"); + + Assert.True(shared.Count == 0, string.Join(" || ", shared)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/S4SecurityProbesB.cs b/DynamicWhere.Tests/Policies/S4SecurityProbesB.cs new file mode 100644 index 0000000..8b7c8a0 --- /dev/null +++ b/DynamicWhere.Tests/Policies/S4SecurityProbesB.cs @@ -0,0 +1,584 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 4, part B. Two questions the first file does not reach: + // + // * does anything on the strict path answer a denied field differently from a name that + // matches nothing, so a caller can probe for which fields exist; + // * which projected sources the uncomputable-path refusal does not reach. + // ============================================================================================= + + public class S4Watched + { + public int Id { get; set; } + + /// Allowed, and audited: every use of it fills a slot in the caller's buffer. + [DwAudit] + public string Watched { get; set; } = string.Empty; + + /// Denied for filtering, and audited. + [DwAudit] + [DwDeny(PolicyFeature.Where)] + public string Marked { get; set; } = string.Empty; + + /// Denied for filtering, not audited. + [DwDeny(PolicyFeature.Where)] + public string Plain { get; set; } = string.Empty; + + /// Denied for filtering, and expensive. + [DwCost(10_000)] + [DwDeny(PolicyFeature.Where)] + public string Heavy { get; set; } = string.Empty; + } + + public sealed class S4WatchedContext : DbContext + { + private readonly SqliteConnection _connection; + + public S4WatchedContext(SqliteConnection connection) => _connection = connection; + + public DbSet Rows => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + public sealed class S4SecurityProbesB : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly S4WatchedContext _db; + private readonly SqliteConnection _orderConnection; + private readonly S4Context _orders; + + public S4SecurityProbesB(ITestOutputHelper output) + { + _out = output; + + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new S4WatchedContext(_connection); + _db.Database.EnsureCreated(); + _db.Rows.Add(new S4Watched { Watched = "w", Marked = "m", Plain = "p", Heavy = "h" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + + _orderConnection = new SqliteConnection("DataSource=:memory:"); + _orderConnection.Open(); + _orders = new S4Context(_orderConnection); + _orders.Database.EnsureCreated(); + + S4Customer customer = new() { Name = "Acme" }; + + _orders.Customers.Add(customer); + _orders.Orders.Add(new S4Order + { + Code = "AB123", Qty = 2, Customer = customer, + Total = new S4Money { Amount = 10m, Currency = "USD" } + }); + _orders.SaveChanges(); + _orders.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + _orders.Dispose(); + _orderConnection.Dispose(); + } + + // ---- harness ----------------------------------------------------------------------------- + + private static Condition On(string field) => new() + { + Field = field, DataType = DataType.Text, Operator = Operator.Equal, Values = { "x" } + }; + + /// A strict posture whose audit buffer holds exactly one event. + private static DwPolicyOptions OneAuditSlot() + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + options.Caps.MaxAuditEvents = 1; + + return options; + } + + private static string Refusal(IQueryable source, DwPolicyOptions options, params string[] fields) + { + Filter filter = new() { ConditionGroup = new ConditionGroup() }; + + foreach (string field in fields) + { + filter.ConditionGroup.Conditions.Add(On(field)); + } + + try + { + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(filter); + + return "NO REFUSAL"; + } + catch (PolicyException refusal) + { + return $"{refusal.ErrorCode}|{refusal.FieldPath}|{refusal.Feature}"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + // ========================================================================================= + // S4-L. The audit buffer as an oracle. + // + // PolicyFor records the [DwAudit] event BEFORE the caller's policy is consulted, so a field + // that is denied AND audited spends a slot the refusal it is about to raise does not need. + // With the buffer full the audited field answers CapExceeded while a name matching nothing + // answers FieldDeniedForWhere — which is the one thing the strict tier promises a caller + // cannot tell apart. + // ========================================================================================= + + [Fact] + public void S4_L_The_audit_cap_tells_a_denied_audited_field_from_a_name_matching_nothing() + { + DwPolicyOptions options = OneAuditSlot(); + + // The first condition fills the single slot with an allowed, audited field. + string denied = Refusal(_db.Rows, options, "Watched", "Marked"); + string unknown = Refusal(_db.Rows, options, "Watched", "Zzzzz"); + string plain = Refusal(_db.Rows, options, "Watched", "Plain"); + string heavy = Refusal(_db.Rows, options, "Watched", "Heavy"); + + _out.WriteLine($"denied + audited {denied}"); + _out.WriteLine($"name matching nothing {unknown}"); + _out.WriteLine($"denied, not audited {plain}"); + _out.WriteLine($"denied, weighted {heavy}"); + + Assert.Equal(unknown, plain); + Assert.Equal(unknown, heavy); + + Assert.True( + string.Equals(denied, unknown, StringComparison.Ordinal), + $"a denied audited field answers '{denied}' where a name matching nothing answers " + + $"'{unknown}': the strict tier's refusals tell the two apart"); + } + + // ========================================================================================= + // S4-M. Projected sources the refusal does not reach. Pins what 56d169d does, so a change + // to any of them is deliberate. Each line is the shape, then what the strict tier + // answered where the release promises a refusal. + // ========================================================================================= + + [Fact] + public void S4_M_Which_projected_sources_the_refusal_does_not_reach() + { + List notRefused = new(); + + void Probe(string shape, Func guarded) + { + string answer; + + try + { + answer = guarded(); + } + catch (Exception failure) + { + answer = failure.GetType().Name; + } + + _out.WriteLine($"{shape,-56} {answer}"); + + if (!answer.StartsWith("REFUSED", StringComparison.Ordinal)) + { + notRefused.Add($"{shape} -> {answer}"); + } + } + + string Guarded(IQueryable source, string field, DataType type) where T : class + { + try + { + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, DataType = type, + Operator = Operator.Equal, Values = { "x" } + } + } + } + }); + + return "OK"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + } + + // The shape the release's own promise names: an initializer with no constructor + // arguments, over EF Core's own provider. This one is refused. + Probe("member-init projection, unassigned member", + () => Guarded( + _orders.Orders.Select(o => new S4Row { Id = o.Id, Nest = new S4Nest { A = o.Code } }), + "Nest.B", DataType.Text)); + + // Everything below is left alone. Each is a projection a caller composes before + // ApplyPolicy, and in each the provider decides rather than the policy. + Probe("anonymous-type projection, getter over owned columns", + () => Guarded( + _orders.Orders.Select(o => new { o.Id, o.Total }), + "Total.IsZero", DataType.Boolean)); + + Probe("constructor with arguments, member never in the initializer", + () => Guarded( + _orders.Orders.Select(o => new S4Row(o.Id) { Code = o.Code }), + "Tag", DataType.Text)); + + Probe("constructor with arguments, getter beneath an unnamed member", + () => Guarded( + _orders.Orders.Select(o => new S4Row(o.Id) { Code = o.Code }), + "Money.IsZero", DataType.Boolean)); + + Probe("member assigned from a method call, a path beneath it", + () => Guarded( + _orders.Orders.Select(o => new S4Row { Id = o.Id, Nest = S4Build(o.Code) }), + "Nest.B", DataType.Text)); + + _out.WriteLine($"not refused: {notRefused.Count} of 5"); + + // The one the release names must be refused; the rest are this commit's open edge. + Assert.DoesNotContain( + notRefused, + entry => entry.StartsWith("member-init projection", StringComparison.Ordinal)); + } + + private static S4Nest S4Build(string code) => new() { A = code }; + + // ========================================================================================= + // S4-N. Configure: every knob a second host can hand over, one at a time. + // + // Drives DwPolicy.Configure itself rather than its static fields. A posture that differs is + // refused before anything is written, and one that matches is the no-op the release adds, + // so no probe here changes what any other suite sees. + // ========================================================================================= + + [Fact] + public void S4_N_A_second_host_cannot_hand_over_a_posture_that_enforces_differently() + { + PolicyBootstrap.Ensure(); + + List accepted = new(); + + // The posture in force, rebuilt. Accepted as a no-op, which is the feature itself. + DwPolicy.Configure(Posture()); + + void Differs(string knob, Action change) + { + DwPolicyOptions options = Posture(); + + change(options); + + try + { + DwPolicy.Configure(options); + + _out.WriteLine($"{knob,-30} ACCEPTED"); + accepted.Add(knob); + } + catch (InvalidOperationException) + { + _out.WriteLine($"{knob,-30} refused"); + } + } + + Differs("Tier", o => o.Tier = DwTier.Strict); + Differs("DryRun", o => o.DryRun = true); + Differs("IncludeTraceInResult", o => o.IncludeTraceInResult = false); + Differs("AuditRefusals", o => o.AuditRefusals = true); + Differs("HashSalt", o => o.HashSalt = new string('s', DwPolicyOptions.MinimumHashSaltLength)); + Differs("StoreFailure", o => o.StoreFailure = StoreFailureMode.FailClosed); + Differs("MaxSnapshotAge", o => o.MaxSnapshotAge = TimeSpan.FromMinutes(31)); + Differs("RefreshInterval", o => o.RefreshInterval = TimeSpan.FromSeconds(31)); + Differs("Caps.MaxPageSize", o => o.Caps.MaxPageSize = 17); + Differs("Caps.DefaultPageSize", o => o.Caps.DefaultPageSize = 17); + Differs("Caps.MaxConditions", o => o.Caps.MaxConditions = 17); + Differs("Caps.MaxConditionDepth", o => o.Caps.MaxConditionDepth = 17); + Differs("Caps.MaxConditionSets", o => o.Caps.MaxConditionSets = 17); + Differs("Caps.MaxConditionValues", o => o.Caps.MaxConditionValues = 17); + Differs("Caps.MaxAggregates", o => o.Caps.MaxAggregates = 17); + Differs("Caps.MaxOrderFields", o => o.Caps.MaxOrderFields = 17); + Differs("Caps.MaxNavigationDepth", o => o.Caps.MaxNavigationDepth = 7); + Differs("Caps.MaxQueryCost", o => o.Caps.MaxQueryCost = 17_000); + Differs("Caps.DefaultFieldCost", o => o.Caps.DefaultFieldCost = 7); + Differs("Caps.MaxAuditEvents", o => o.Caps.MaxAuditEvents = 17); + Differs("Caps.MinGroupSize", o => o.Caps.MinGroupSize = 17); + Differs("Caps.SchemaDepth", o => o.Caps.SchemaDepth = 7); + Differs("Caps.SchemaCycleLimit", o => o.Caps.SchemaCycleLimit = 7); + Differs("Caps.MaxSchemaFields", o => o.Caps.MaxSchemaFields = 17); + Differs("Entities: one more type", o => o.Entities.Expose("s4watched")); + Differs("Entities: one more name for a type", o => o.Entities.Expose("s4staff")); + Differs("a policy source the first host did not have", + _ => { /* handled below: providers are a Configure argument, not an option */ }); + + // A second host adding a runtime policy source is refused: the resolver is never + // rebuilt, so every rule in that source would quietly not apply. + try + { + DwPolicy.Configure(Posture(), new S4NoRules()); + + _out.WriteLine($"{"extra provider",-30} ACCEPTED"); + accepted.Add("extra provider"); + } + catch (InvalidOperationException) + { + _out.WriteLine($"{"extra provider",-30} refused"); + } + + accepted.Remove("a policy source the first host did not have"); + + Assert.True( + accepted.Count == 0, + "a second host handed over a posture that enforces differently and it was accepted: " + + string.Join(", ", accepted)); + } + + // ========================================================================================= + // S4-O. _translated gates Expresses and nothing else. A provider the name test rejects must + // still have every denial enforced over it: the refusal, and the projection that + // keeps a denied value out of the rows. + // ========================================================================================= + + [Fact] + public void S4_O_A_provider_the_name_test_rejects_still_has_every_denial_enforced() + { + using SqliteConnection connection = new("DataSource=:memory:"); + + connection.Open(); + + using S4Context replaced = new(connection, typeof(S4PassThroughProvider)); + + replaced.Database.EnsureCreated(); + + S4Customer customer = new() { Name = "Acme" }; + + replaced.Customers.Add(customer); + replaced.Orders.Add(new S4Order + { + Code = "AB123", Qty = 2, Secret = "top", Customer = customer, + Total = new S4Money { Amount = 10m, Currency = "USD" } + }); + replaced.SaveChanges(); + replaced.ChangeTracker.Clear(); + + _out.WriteLine( + $"provider = {((IQueryable)replaced.Orders).Provider.GetType().FullName} " + + "(EfCoreOwns = false)"); + + PolicyQueryable guarded = replaced.Orders.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + PolicyException refusal = Assert.Throws(() => guarded.ToList(new Filter + { + ConditionGroup = new ConditionGroup { Conditions = { On("Secret") } } + })); + + _out.WriteLine($"filter on the denied field -> {refusal.ErrorCode}"); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + + // And the denied value must not come back in a row the caller asked nothing about. + List rows = replaced.Orders.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows={rows.Count} Code='{rows[0].Code}' Secret='{rows[0].Secret}'"); + + Assert.Equal("AB123", rows[0].Code); + Assert.Equal(string.Empty, rows[0].Secret); + } + + // ========================================================================================= + // S4-P. A condition's values are the caller's own objects, in Clone and out of it. The + // release documents that a value is read more than once; this pins that no policy + // decision rests on which read won. + // ========================================================================================= + + [Fact] + public void S4_P_A_value_that_answers_differently_each_time_changes_no_policy_decision() + { + S4Counting shifting = new(); + + Filter moving = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { shifting } + } + } + } + }; + + Filter steady = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "AB123" } + } + } + } + }; + + string Run(Filter filter) + { + try + { + return "OK(" + _orders.Orders.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(filter).Data.Count + ")"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + string shifted = Run(moving); + string fixedValue = Run(steady); + + _out.WriteLine($"value read {shifting.Reads} time(s); shifting -> {shifted}, steady -> {fixedValue}"); + + // The documented limit: a value is read more than once. + Assert.True(shifting.Reads > 1, $"the value was read {shifting.Reads} time(s)"); + + // What matters here: the policy reached the same decision either way. Nothing the guard + // decides reads a value's content, so a value that shifts cannot move a denial. + Assert.Equal( + fixedValue.StartsWith("REFUSED", StringComparison.Ordinal), + shifted.StartsWith("REFUSED", StringComparison.Ordinal)); + } + + /// A value whose ToString() answers differently on every read. + private sealed class S4Counting + { + internal int Reads { get; private set; } + + public override string ToString() + { + Reads++; + + return Reads == 1 ? "AB123" : "ZZZZZ"; + } + } + + /// + /// A posture holding exactly what is in force, value by value, so a knob changed below is + /// the only difference a second call carries. + /// + private static DwPolicyOptions Posture() + { + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicyOptions copy = new() + { + Tier = inForce.Tier, + DryRun = inForce.DryRun, + IncludeTraceInResult = inForce.IncludeTraceInResult, + AuditRefusals = inForce.AuditRefusals, + StoreFailure = inForce.StoreFailure, + MaxSnapshotAge = inForce.MaxSnapshotAge, + RefreshInterval = inForce.RefreshInterval + }; + + if (!string.IsNullOrEmpty(inForce.HashSalt)) + { + copy.HashSalt = inForce.HashSalt; + } + + copy.Caps.MaxPageSize = inForce.Caps.MaxPageSize; + copy.Caps.DefaultPageSize = inForce.Caps.DefaultPageSize; + copy.Caps.MaxConditions = inForce.Caps.MaxConditions; + copy.Caps.MaxConditionDepth = inForce.Caps.MaxConditionDepth; + copy.Caps.MaxConditionSets = inForce.Caps.MaxConditionSets; + copy.Caps.MaxConditionValues = inForce.Caps.MaxConditionValues; + copy.Caps.MaxAggregates = inForce.Caps.MaxAggregates; + copy.Caps.MaxOrderFields = inForce.Caps.MaxOrderFields; + copy.Caps.MaxNavigationDepth = inForce.Caps.MaxNavigationDepth; + copy.Caps.MaxQueryCost = inForce.Caps.MaxQueryCost; + copy.Caps.DefaultFieldCost = inForce.Caps.DefaultFieldCost; + copy.Caps.MaxAuditEvents = inForce.Caps.MaxAuditEvents; + copy.Caps.SchemaDepth = inForce.Caps.SchemaDepth; + copy.Caps.SchemaCycleLimit = inForce.Caps.SchemaCycleLimit; + copy.Caps.MaxSchemaFields = inForce.Caps.MaxSchemaFields; + + if (inForce.Caps.IsMinGroupSizeSet) + { + copy.Caps.MinGroupSize = inForce.Caps.MinGroupSize; + } + + foreach (KeyValuePair exposed in inForce.Entities.Entities) + { + copy.Entities.Expose(exposed.Key, exposed.Value); + } + + return copy; + } + + /// A runtime policy source that grants and denies nothing. + private sealed class S4NoRules : IDwPolicyProvider + { + public IReadOnlyList GetFragments( + Type entityType, DwPolicyContext context) => + Array.Empty(); + } + } +} diff --git a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs index c3a4fce..776a6f0 100644 --- a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs +++ b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs @@ -4230,6 +4230,14 @@ internal FieldPolicy PolicyFor(string fieldPath, PolicyFeature feature) /// A dry run still records. It changes what the policy does, not what it saw, and a canary /// rollout with no evidence of what it was about to refuse is one nobody can evaluate. /// + /// + /// Where the tier hides existence the refusal is raised as the field refusal the caller + /// would have been given anyway. Answering with the cap's own code, and an origin naming the + /// cap, would tell a caller that the name they guessed is a real field and an audited one, + /// which is the inference the tier exists to prevent: a name matching nothing is never + /// audited and never reaches this. The request is refused either way, and the trace above + /// records which refusal it really was. + /// /// private void Audit(string fieldPath, PolicyFeature feature, FieldPolicy policy) { @@ -4255,6 +4263,11 @@ private void Audit(string fieldPath, PolicyFeature feature, FieldPolicy policy) _trace.Add(new PolicyDecision(fieldPath, feature, PolicyAction.Denied, origin)); + if (HidesExistence) + { + throw Exception(fieldPath, feature, DenialFor(feature), null); + } + throw new PolicyException( PolicyErrorCode.CapExceeded, IsStrict ? WholeClause : fieldPath, feature, _options.Tier) { @@ -4263,6 +4276,17 @@ private void Audit(string fieldPath, PolicyFeature feature, FieldPolicy policy) }; } + /// The code a field refused for one feature carries. + private static PolicyErrorCode DenialFor(PolicyFeature feature) => feature switch + { + PolicyFeature.Select => PolicyErrorCode.FieldDeniedForSelect, + PolicyFeature.Order => PolicyErrorCode.FieldDeniedForOrder, + PolicyFeature.Group => PolicyErrorCode.FieldDeniedForGroup, + PolicyFeature.Aggregate => PolicyErrorCode.FieldDeniedForAggregate, + PolicyFeature.Segment => PolicyErrorCode.FieldDeniedForSegment, + _ => PolicyErrorCode.FieldDeniedForWhere + }; + /// /// Applies a refusal for a feature that may be dropped: throws in the strict tier, records /// the drop otherwise. diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs index 7ef3f87..ccd1c99 100644 --- a/DynamicWhere.ex/Policies/Source/RowShape.cs +++ b/DynamicWhere.ex/Policies/Source/RowShape.cs @@ -493,8 +493,13 @@ private static bool Named(object model, string name) => /// leaves alone. /// /// - private static bool EfCoreOwns(IQueryProvider provider) => - provider.GetType().FullName == "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider"; + private static bool EfCoreOwns(IQueryProvider provider) + { + Type type = provider.GetType(); + + return type.FullName == "Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider" + && type.Assembly.GetName().Name == "Microsoft.EntityFrameworkCore"; + } /// /// The column the queried type maps under a name, or null. From 692dd11d2598c5146d01b43709417c46aa631bec Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 15:15:00 +0300 Subject: [PATCH 21/54] docs: a ninth inference channel, and which providers the refusal leaves alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit cap's refusal under Strict is a behaviour change and a closed channel, so it is written where both belong: the security page's channel list, the breaking-changes page as point 33, DOC.md's own list, the release notes and README's upgrade note. Convenience and a dry run are unchanged, and the request still fails either way. The provider rule's reason is corrected. It is not that a provider deriving from EF Core's rewrites in the same way — a host replacing EF Core's query provider through ReplaceService may pass straight through. It is that the library cannot tell the two apart, and refusing on that guess would take back a query the rewriting host answers today. A projection that does not build its rows with an object initializer — an anonymous type, a constructor with arguments — says nothing about which member each value sets, so no member of such a row is refused, which the text now says rather than implying the opposite. Co-Authored-By: Claude Opus 5 --- DynamicWhere.ex/DOC.md | 13 ++++--- .../app/docs/breaking-changes/page.tsx | 34 +++++++++++++++++-- OfficialWebsite/app/docs/policies/page.tsx | 2 +- .../app/docs/policies/security/page.tsx | 27 ++++++++++++--- OfficialWebsite/public/llms.txt | 22 +++++++++--- README.md | 1 + 6 files changed, 81 insertions(+), 18 deletions(-) diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index 43d9797..5c3dab4 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -1711,13 +1711,13 @@ It is refused only where the whole set of members a container can produce is kno A shadow property is a separate matter and unchanged: a field path names CLR members, and a shadow property has none, so no clause can name one — guarded or not, before this release or after it. Map it to a property, or project it with `EF.Property` and name the projected member. -A projection is therefore read only as far as its initializer can be read. An entity query names every producible member from the model; a projection names them only where each assignment is a nested initializer, a member copied from the entity, a value built and left empty, or a conditional over those — a null branch beside one of them included. A member assigned nothing but a null is left alone, like any assignment this shape cannot read. Past `MaxComplexDepth` — eight levels — it stops reading and stops speaking. Under `Strict` such a path still reaches the provider and still fails there, exactly as it did before 3.3.0. +A projection that does not build its rows with an object initializer is left alone whole: an anonymous type, and a constructor with arguments, say nothing about which member each value sets, so no member of such a row is refused here and none is claimed. A projection is otherwise read only as far as its initializer can be read. An entity query names every producible member from the model; a projection names them only where each assignment is a nested initializer, a member copied from the entity, a value built and left empty, or a conditional over those — a null branch beside one of them included. A member assigned nothing but a null is left alone, like any assignment this shape cannot read. Past `MaxComplexDepth` — eight levels — it stops reading and stops speaking. Under `Strict` such a path still reaches the provider and still fails there, exactly as it did before 3.3.0. **Left alone** is not a promise that the path runs. The policy does not refuse it, so it behaves exactly as it does unguarded: `Name.IsEmpty` beneath a column mapped through a value converter still fails inside the provider, as it always has. An unmapped getter on the entity itself, `Display => $"{Code}:{Id}"`, is refused for the same reason. The convenience tier and a dry run are unchanged: both fail exactly as the unguarded query does, which is the provider's own error. The trace records the refusal — `the member exists on the type and the query cannot compute it` — and since 3.3.0 `LastTrace` is set before a request is sanitized, so a refusal leaves it readable rather than null. -The rule is EF Core's own provider's — that type, not a type derived from it. A provider that wraps EF Core — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — exists to rewrite the members EF Core cannot translate, so a member it computes is one the query produces and it is left alone, over a projection and over an entity alike; one built by deriving from EF Core's provider rewrites in the same way and is left alone too. A rewrite *inside* EF Core's own pipeline is a different matter: a member-translator plugin or a replaced query preprocessor leaves EF Core's own provider in place, so a member it computes without a mapping is refused with the rest. A row the library itself projected is read like any other: the core's typed `Select` null-guards every nested node it builds, and both branches of that guard are read, so composing `Select` and then filtering refuses exactly what the bare handle refuses. +The rule is EF Core's own provider's — that exact type, from EF Core's own assembly. A provider that wraps EF Core — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — exists to rewrite the members EF Core cannot translate, so a member it computes is one the query produces and it is left alone, over a projection and over an entity alike. A provider of any other type is left alone for the same reason turned around: the library cannot tell one that rewrites from one that passes straight through, and refusing on that guess would take back a query the rewriting host answers today. That covers a host registering its own provider through EF Core's `ReplaceService`, whose queries are read as another provider's and never refused here, however plain the provider is. A rewrite *inside* EF Core's own pipeline is a different matter: a member-translator plugin or a replaced query preprocessor leaves EF Core's own provider in place, so a member it computes without a mapping is refused with the rest. A row the library itself projected is read like any other: the core's typed `Select` null-guards every nested node it builds, and both branches of that guard are read, so composing `Select` and then filtering refuses exactly what the bare handle refuses. A refusal here raises no `[DwAudit]` event, for the same reason an unknown name raises none: no field was read, and the refusal names none. `AuditRefusals` records it, and so does the trace. @@ -1829,7 +1829,7 @@ The trace names the fields a policy dropped, the attribute or rule that sealed e ### Auditing -`[DwAudit(features)]` records every use of a field, whatever the policy decided, as a `DwAuditEvent` in the caller's context (`DwPolicyContext.PendingAuditEvents`). A use is a field the request names, or, since 3.1.0, a field of the type's [default order](#default-order) that the query orders by: audited for `Order`, it is recorded each time, as a caller's own order is. A default field left out for this caller is not recorded, because the query does not order by it and the caller never named it. Nothing is stored until the buffer is drained to an `IDwAuditSink`, by `DwPolicy.DrainAuditAsync(context, sink)` or, per request, by the ASP.NET Core middleware `app.UseDwPolicyAudit()`. A buffer already holding `DwCaps.MaxAuditEvents` refuses the next audited use with `CapExceeded`. +`[DwAudit(features)]` records every use of a field, whatever the policy decided, as a `DwAuditEvent` in the caller's context (`DwPolicyContext.PendingAuditEvents`). A use is a field the request names, or, since 3.1.0, a field of the type's [default order](#default-order) that the query orders by: audited for `Order`, it is recorded each time, as a caller's own order is. A default field left out for this caller is not recorded, because the query does not order by it and the caller never named it. Nothing is stored until the buffer is drained to an `IDwAuditSink`, by `DwPolicy.DrainAuditAsync(context, sink)` or, per request, by the ASP.NET Core middleware `app.UseDwPolicyAudit()`. A buffer already holding `DwCaps.MaxAuditEvents` refuses the next audited use with `CapExceeded` — except under `Strict` outside a dry run, where it refuses with the clause's own field refusal instead (3.3.0). An unknown name is never audited and never reaches the cap, so answering with the cap's own code there would have told a caller that the name they guessed is a real field and an audited one. A log of uses never shows a caller probing for columns they may not read: every guess is refused, so nothing was used. `DwPolicyOptions.AuditRefusals` (`bool`, default `false`, new in 3.1.0) records the refusals too. When it is on, every `PolicyException` raised by a guarded entry point of `PolicyQueryable` — terminal or composable — and `ApplyPolicy(context)`'s refusal of an unprepared context are written to the same buffer and drain the same way: @@ -2403,7 +2403,7 @@ These are numbered as this document numbers them. The website's [breaking-change Before 3.1.0 every guarded terminal put its `PolicyTrace` on `FilterResult.Policy`, `SummaryResult.Policy` or `SegmentResult.Policy`, in both tiers. The trace names the fields a policy dropped, the attribute or rule that sealed each one, and every injected predicate — the detail the strict tier already refuses through `getQueryString` — and an API that serializes its result sends all of it to the caller. Under `DwTier.Strict`, `Policy` is now null unless `DwPolicyOptions.IncludeTraceInResult` is `true`; under `Convenience` it is still carried unless the option is `false`. `PolicyQueryable.LastTrace` still holds the trace, so a strict deployment that read `result.Policy` reads `LastTrace` instead, or sets `IncludeTraceInResult = true`. See [Results and the trace](#results-and-the-trace). 18. **Under the Strict Tier an Unknown Field and a Denied Field Answer Alike** - Before 3.1.0 a guarded query refused a field name matching nothing on the type with `LogicException` `ConditionMustHasValidFieldName`, and a denied field with a `PolicyException` carrying its path and, where one source decided, its `RuleId` and `SourceOrigin`. The two answers let a caller list the columns they may not see, one guess at a time. Under `DwTier.Strict`, outside a dry run, an unknown name is now gated as a field denied for every feature and receives the refusal a `[DwDenied]` field receives in that clause — `FieldDeniedForWhere`, `FieldDeniedForSelect`, `FieldDeniedForOrder`, `FieldDeniedForGroup` or `FieldDeniedForAggregate`, and `FieldDeniedForSegment` anywhere in a segment — after the caps. Every refusal with one of those six codes carries `FieldPath` `"*"`, a null `RuleId` and a null `SourceOrigin`, whatever the field, and a `CapExceeded` refusal names no path either. The same tier closes the other ways to tell them apart: inside a segment every field refusal is `FieldDeniedForSegment`; a name padded with dots is normalized as a real path is; `MaxQueryCost` is checked after every field gate, so a `[DwCost]` weight cannot set a hidden field apart from a missing one; and `MissingContextValue` carries `FieldPath` `"*"` and no `SourceOrigin`. Code that caught `ConditionMustHasValidFieldName` from a strict guarded query, matched a clause's code inside a segment, or read `FieldPath`, `RuleId` or `SourceOrigin` off a strict refusal, reads `PolicyQueryable.LastTrace` instead, which keeps the real path and reason, or records refusals with `DwPolicyOptions.AuditRefusals`. The convenience tier and dry runs are unchanged. See [Blocked-action semantics](#blocked-action-semantics). + Before 3.1.0 a guarded query refused a field name matching nothing on the type with `LogicException` `ConditionMustHasValidFieldName`, and a denied field with a `PolicyException` carrying its path and, where one source decided, its `RuleId` and `SourceOrigin`. The two answers let a caller list the columns they may not see, one guess at a time. Under `DwTier.Strict`, outside a dry run, an unknown name is now gated as a field denied for every feature and receives the refusal a `[DwDenied]` field receives in that clause — `FieldDeniedForWhere`, `FieldDeniedForSelect`, `FieldDeniedForOrder`, `FieldDeniedForGroup` or `FieldDeniedForAggregate`, and `FieldDeniedForSegment` anywhere in a segment — after the caps. Every refusal with one of those six codes carries `FieldPath` `"*"`, a null `RuleId` and a null `SourceOrigin`, whatever the field, and a `CapExceeded` refusal names no path either; since 3.3.0 the audit cap does not answer with that code under this tier at all, because only a real, audited field can reach it. The same tier closes the other ways to tell them apart: inside a segment every field refusal is `FieldDeniedForSegment`; a name padded with dots is normalized as a real path is; `MaxQueryCost` is checked after every field gate, so a `[DwCost]` weight cannot set a hidden field apart from a missing one; and `MissingContextValue` carries `FieldPath` `"*"` and no `SourceOrigin`. Code that caught `ConditionMustHasValidFieldName` from a strict guarded query, matched a clause's code inside a segment, or read `FieldPath`, `RuleId` or `SourceOrigin` off a strict refusal, reads `PolicyQueryable.LastTrace` instead, which keeps the real path and reason, or records refusals with `DwPolicyOptions.AuditRefusals`. The convenience tier and dry runs are unchanged. See [Blocked-action semantics](#blocked-action-semantics). 19. **`MaxConditionValues` and `MaxAggregates` Refuse Guarded Requests 3.0 Ran** Two more caps new in 3.1.0. `DwCaps.MaxConditionValues` (default 1000) bounds the values one condition carries — the largest condition of the where clause, a summary's `Having` and every segment set is the one compared — because an `In` is one comparison per value and so could build a predicate of any size for the price of one condition and one field. `DwCaps.MaxAggregates` (default 50) bounds the `AggregateBy` entries of one summary, through the summary terminals and the composable `Group` and `Summary`; the group-size floor's own count is not counted. A guarded request over either is refused in both tiers with `PolicyException` `CapExceeded`, `FieldPath` `"*"` and `SourceOrigin` `"MaxConditionValues cap (1000), request had 1001"` or `"MaxAggregates cap (50), request had 51"`, unless the deployment raises the cap. Both refuse a value below 1, freeze with the posture, and bind from `Caps:MaxConditionValues` and `Caps:MaxAggregates`. An aggregate with no field, such as a `Count`, is now charged `DefaultFieldCost` toward `MaxQueryCost`, where it cost nothing, so a summary that sat just under its budget can be refused with `QueryCostExceeded`. Every count cap is now checked before any field name is resolved, so an oversized request that also names a field that does not exist is refused with `CapExceeded`, where 3.0.0 resolved names first and answered `ConditionMustHasValidFieldName`. Only `ApplyPolicy` enforces them: an unguarded query is not affected. @@ -2445,7 +2445,10 @@ These are numbered as this document numbers them. The website's [breaking-change 26. **`LastTrace` Is Set Before a Request Is Sanitized** Since 3.3.0 `PolicyQueryable.LastTrace` carries the trace of a request that was refused. It used to be assigned after sanitizing returned, so a refusal left it holding the previous request's trace, or null on the first. A strict refusal names no field on purpose, and the trace is where the real path and reason live, so this is what makes one readable. Code that read `LastTrace` after catching a `PolicyException` and expected the earlier request's trace reads this request's now. -27. **`Configure` Takes the Same Posture Twice** +27. **The Audit Cap Refuses Like Any Other Field, Under `Strict`** + An audited field records one event per use, and the query is refused rather than the record dropped when `DwCaps.MaxAuditEvents` is reached. Until 3.3.0 that refusal carried `CapExceeded` and a `SourceOrigin` naming the cap, while a name matching nothing carried the ordinary field refusal and no origin — and an unknown name is never audited, so the difference told a caller which names are real and audited. Under `Strict`, outside a dry run, the cap now refuses with the clause's own code, `FieldPath` `"*"` and no origin. The request still fails, so the buffer still fails closed, and the trace still records which refusal it was. `Convenience` and a dry run still answer `CapExceeded`. Code switching on `CapExceeded` under `Strict` sees the change. + +28. **`Configure` Takes the Same Posture Twice** Since 3.3.0 a second `DwPolicy.Configure` or `AddDwPolicies` asking for the posture already in force returns instead of throwing; a different posture still throws. Code that relied on the second call throwing — a test asserting it, or a `try`/`catch` around a second registration — no longer sees the exception. `AddDwPolicies` also registers the posture in force rather than the instance it built, so a container resolving `DwPolicyOptions` after a second registration gets the first one's. The token vault, the service provider and the provider instances are not compared and are not replaced. See [Configuring twice](#configuring-twice). --- diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index 333f300..94906a8 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -17,12 +17,12 @@ export default function Page() {

          Breaking Changes & Known Limitations

          DynamicWhere.ex is intentionally opinionated about how queries are shaped. - The thirty-two points below cover constraints, surprises, and corner cases — + The thirty-three points below cover constraints, surprises, and corner cases — read them before designing an API around the library so you can pick the right entry points and avoid runtime exceptions in production.

          - Points 30 to 32 changed in 3.3.0. Under{" "} + Points 30 to 33 changed in 3.3.0. Under{" "} Strict, a path that exists on the type and names no value the query can compute is refused rather than run, where the provider used to throw and the caller saw a five-hundred (point 30).{" "} @@ -30,7 +30,10 @@ export default function Page() { refused request leaves its own trace readable (point 31). And{" "} DwPolicy.Configure takes a second call asking for the posture already in force, which is what lets an integration suite start - several hosts over one composition root (point 32). + several hosts over one composition root (point 32). A query that + exhausts the audit buffer under Strict is now refused with + the clause's own field refusal rather than with{" "} + CapExceeded (point 33). Points 25 to 29 changed in 3.2.0, and each is @@ -1613,6 +1616,31 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`} +

          33. The Audit Cap Refuses Like Any Other Field, Under Strict

          +

          + An audited field records one event per use, and the library refuses the + query rather than dropping a record when{" "} + DwCaps.MaxAuditEvents is reached — fail closed, because an + access with nothing written down is the one outcome{" "} + [DwAudit] exists to prevent. Until 3.3.0{" "} + that refusal carried CapExceeded and a{" "} + SourceOrigin naming the cap, while a name matching nothing + carried the ordinary field refusal and no origin. +

          +

          + Two answers, and the difference told a caller that the name they had + guessed is a real field and an audited one — the inference the + strict tier exists to prevent, since an unknown name is never audited + and never reaches the cap. Under Strict, outside a dry run, + the cap now refuses with the same code, the same{" "} + FieldPath "*" and the same absent + origin as any other field refusal. The request still fails, the trace + still records which refusal it really was, and{" "} + Convenience and a dry run still answer{" "} + CapExceeded. Code switching on CapExceeded{" "} + under Strict sees the change. +

          +

          See also

          • diff --git a/OfficialWebsite/app/docs/policies/page.tsx b/OfficialWebsite/app/docs/policies/page.tsx index a73e877..4fed31b 100644 --- a/OfficialWebsite/app/docs/policies/page.tsx +++ b/OfficialWebsite/app/docs/policies/page.tsx @@ -156,7 +156,7 @@ var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter, cancella
          • Dynamic store — rules without a redeploy
          • Store providers — Redis and Entity Framework Core
          • Admin API — schema, rules, explain, simulate, health
          • -
          • Security & k-anonymity — the eight inference channels
          • +
          • Security & k-anonymity — the nine inference channels
          • Configuration — options, caps and defaults
          diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx index 5c544a6..e822ae5 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -5,7 +5,7 @@ import { Code } from "@/components/Code"; import Callout from "@/components/Callout"; export const metadata: Metadata = { - title: "Security & k-anonymity — the eight inference channels", + title: "Security & k-anonymity — the nine inference channels", description: "How DynamicWhere.ex closes the disclosure channels no per-field rule closes on its own — set-operation reconstruction, singleton-group aggregates, cardinality probes, sort-and-page binary search, SQL leakage, and schema probing through refusals — and the denials the gate could not see until 3.2.0.", keywords: ["k-anonymity", "MinGroupSize", "inference attack", "data disclosure", "aggregate disclosure", "EF Core security"], alternates: { canonical: "https://doc.dynamicwhere.com/docs/policies/security/" }, @@ -17,8 +17,8 @@ export default function Page() {

          Security & k-anonymity

          Denying a field is easy. The hard part is the set of ways a caller can - learn a value without reading it. Six such channels follow, then - two bypasses that are not channels, then a path the query cannot compute + learn a value without reading it. Seven such channels follow, + then two bypasses that are not channels, then a path the query cannot compute — not a channel either, but the one request the tier used to answer with neither an answer nor a refusal — then the requests that, until 3.2.0, carried out a denied value the gate could not see; each has a test that @@ -186,7 +186,26 @@ true order. Add [DwNoOrder] unless that is intended.`} together describe how the rows are partitioned.

          -

          7 and 8. The two that are not channels

          +

          7. The audit cap answers differently for a real field

          +

          + An audited field records an event per use, and the query is refused + rather than the record dropped when DwCaps.MaxAuditEvents{" "} + is reached. Until 3.3.0 that refusal carried CapExceeded{" "} + and a SourceOrigin naming the cap, where a name matching + nothing carried the ordinary field refusal and no origin. One guess per + request therefore told a caller which names are real and audited — which + is to say, exactly the fields [DwAudit] is put on, since an + unknown name is never audited and never reaches the cap. +

          +

          + Closed by: under Strict, outside a dry + run, the cap refuses with the clause's own field refusal — same + code, FieldPath "*", no origin. The + request still fails, so the buffer still fails closed, and the trace + still records which refusal it really was. +

          + +

          8 and 9. The two that are not channels

          diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index 5866fb5..d329b73 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -2357,11 +2357,15 @@ a projection another provider ran nothing; its rules are its exactly as it does unguarded. `Name.IsEmpty` beneath a column mapped through a value converter still fails inside the provider. - An unmapped getter on the entity itself (`Display => $"{Code}:{Id}"`) is refused for the same reason. -- The rule is EF Core's own provider's — that type, not a type derived from it. A provider that wraps EF Core — - LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — rewrites what EF Core cannot translate, so its - rows are left alone, over a projection and over an entity alike; one deriving from EF Core's provider rewrites the - same way and is left alone too. A rewrite inside EF Core's own pipeline — a member-translator plugin, a - replaced query preprocessor — leaves EF Core's provider in place, so such a member is refused. +- The rule is EF Core's own provider's — that exact type, from EF Core's own assembly. A provider that wraps EF + Core — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()` — rewrites what EF Core cannot translate, so + its rows are left alone, over a projection and over an entity alike. Every other provider is left alone for the + same reason turned around, a host's own through `ReplaceService` included: the library + cannot tell one that rewrites from one that passes straight through, and refusing on that guess would take back a + query the rewriting host answers today. A rewrite inside EF Core's own pipeline — a member-translator plugin, a + replaced query preprocessor — leaves EF Core's own provider in place, so such a member is refused. +- A projection that does not build its rows with an object initializer — an anonymous type, a constructor with + arguments — says nothing about which member each value sets, so no member of such a row is refused here. - A row the library itself projected reads like any other: the core's typed `Select` null-guards every nested node it builds, and both branches of that guard are read, so `Select` composed and then filtered refuses what the bare handle refuses. @@ -4283,6 +4287,10 @@ There are eight. The first six let a caller learn a value, or what the policy hi - The trace on a result names the same things: `result.Policy` lists the fields a policy dropped, the attribute or rule that sealed each one, and every injected predicate, and an API that serializes the result sends it on. Strict leaves it off unless `IncludeTraceInResult = true`; Convenience returns it (3.1.0). +- **The audit cap:** a buffer at `MaxAuditEvents` refuses the next audited use. Until 3.3.0 that refusal carried + `CapExceeded` and an origin naming the cap, while an unknown name — never audited, so never reaching the cap — + carried the ordinary field refusal. Under `Strict` outside a dry run the cap now refuses with the clause's own + field refusal, so the two cannot be told apart; `Convenience` and a dry run still answer `CapExceeded`. - **Which fields exist:** Convenience answers a name that matches nothing with `ConditionMustHasValidFieldName` and a denied field with a refusal that names it and its source, so a caller can map the hidden columns one guess at a time. Strict answers both alike, with the clause's `FieldDeniedFor*` code, `FieldPath` `"*"` and @@ -4892,6 +4900,10 @@ MemoryCalculationInput trace record it. A simulation has no source, so PolicySimulator and /simulate cannot refuse such a path. - Behaviour change. LastTrace is assigned before a request is sanitized, so a refused request leaves its own trace readable instead of the previous request's. + - Behaviour change and security fix. Under Strict outside a dry run, a query exhausting the audit buffer is + refused with the clause's own field refusal rather than with CapExceeded and an origin naming the cap. An + unknown name is never audited, so the two answers told a caller which names are real and audited. + Convenience and a dry run are unchanged. - New. Clone() is public on Filter, Segment and Summary. A deep copy: the condition tree, the projection list, each order, the page, and a summary's GroupBy and Having. Every node is new; a condition's values are the caller's own objects, in a new list. diff --git a/README.md b/README.md index 27a2eb0..436f8e8 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,7 @@ The complete reference — every enum, class, extension method, validation rule, - **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a value's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor, and `IncludeTraceInResult` written as the tier's own answer is the answer the tier already gives. A type exposed under two names is reported under the last one, so two catalogues that resolve every name alike are still a different posture when the order differs. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. - **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query a provider in front of EF Core translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()`, or one built by deriving from EF Core's own provider — because such a provider exists to rewrite what EF Core cannot, and a member it computes is a member the query produces. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. +- **Fixed (security): the audit cap answered differently for a real field.** An audited field records one event per use, and the query is refused rather than the record dropped once `DwCaps.MaxAuditEvents` is reached. That refusal carried `CapExceeded` and a `SourceOrigin` naming the cap, while a name matching nothing carried the ordinary field refusal and no origin — and an unknown name is never audited, so one guess per request told a caller which names are real and audited. Under `Strict`, outside a dry run, the cap now refuses with the clause's own code, `FieldPath` `"*"` and no origin; the request still fails and the trace still records which refusal it was. `Convenience` and a dry run are unchanged. - **Changed: `LastTrace` is set before a request is sanitized**, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. - **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. Every node is new; a condition's values stay the caller's own objects, in a new list. - **Docs:** `[DwEntity(DefaultOrder)]`'s own remarks said a projected query takes no default; it has since 3.2.0. `DwCaps.MinGroupSize` ships **on at 5**, so a guarded summary silently drops groups of fewer than five rows — the aggregation docs now lead with that instead of leaving it to the caps table. From 0e2f2c96bf2c0b029503ab25088ce97eda6befd0 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 15:44:35 +0300 Subject: [PATCH 22/54] docs: the audit cap's new refusal everywhere it is described, not only where it was announced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The change was written down where it was introduced and left contradicted in eight other places: llms.txt still said the cap throws CapExceeded "in both tiers and in dry run", with a SourceOrigin naming it; the caps table, the audit section, the error-code table and the refusal-audit note said the same; the configuration page said every CapExceeded under Strict has FieldPath "*", which now describes a code that tier no longer raises for the buffer; and breaking point 23 still listed the audit cap beside MaxNavigationDepth as a cap that names its cap in SourceOrigin. llms.txt also still counted eight inference channels after a ninth was added to it. The provider rule's reason was corrected in two documents and left standing in three: a provider built by deriving from EF Core's own does not necessarily rewrite anything — a host registering one through ReplaceService may pass straight through — and the real reason is that the library cannot tell the two apart, so it leaves every provider but EF Core's own alone. None of the three mentioned that the test reads the assembly as well as the name, which the method's own remark had also stopped saying. The sentence about a projection built without an object initializer reached two documents and not the other two. The companion packages' llms.txt entries still declared a dependency on DynamicWhere.ex 3.1.0, and the NuGet release notes did not mention the audit cap at all. Co-Authored-By: Claude Opus 5 --- DynamicWhere.Tests/Policies/Dv5DocsProbes.cs | 880 ++++++++++++++++++ DynamicWhere.Tests/Policies/Dv5Model.cs | 209 +++++ DynamicWhere.ex/DynamicWhere.ex.csproj | 2 + DynamicWhere.ex/Policies/Source/RowShape.cs | 5 +- .../app/docs/breaking-changes/page.tsx | 23 +- .../app/docs/policies/configuration/page.tsx | 7 +- .../app/docs/policies/security/page.tsx | 13 +- OfficialWebsite/public/llms.txt | 29 +- README.md | 2 +- 9 files changed, 1144 insertions(+), 26 deletions(-) create mode 100644 DynamicWhere.Tests/Policies/Dv5DocsProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Dv5Model.cs diff --git a/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs b/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs new file mode 100644 index 0000000..01e6c12 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs @@ -0,0 +1,880 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Discovery; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 5. Documentation against code for 3.3.0. + // + // Each probe answers one written claim by running it. Nothing here drives DwPolicy's static + // fields: every guarded call is the three-argument ApplyPolicy, which takes its own posture. + // ============================================================================================= + + public sealed class Dv5DocsProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _watchedConnection; + private readonly Dv5WatchedContext _watched; + private readonly SqliteConnection _orderConnection; + private readonly Dv5Context _orders; + + public Dv5DocsProbes(ITestOutputHelper output) + { + _out = output; + + _watchedConnection = new SqliteConnection("DataSource=:memory:"); + _watchedConnection.Open(); + _watched = new Dv5WatchedContext(_watchedConnection); + _watched.Database.EnsureCreated(); + _watched.Rows.Add(new Dv5Watched { First = "a", Second = "b", Amount = 3, Sealed = "s" }); + _watched.SaveChanges(); + _watched.ChangeTracker.Clear(); + + _orderConnection = new SqliteConnection("DataSource=:memory:"); + _orderConnection.Open(); + _orders = new Dv5Context(_orderConnection); + _orders.Database.EnsureCreated(); + _orders.Orders.Add(new Dv5Order + { + Code = "AB123", + Qty = 2, + Total = new Dv5Money { Amount = 10m, Currency = "USD" }, + Lines = { new Dv5Line { Price = 4m } } + }); + _orders.SaveChanges(); + _orders.ChangeTracker.Clear(); + } + + public void Dispose() + { + _watched.Dispose(); + _watchedConnection.Dispose(); + _orders.Dispose(); + _orderConnection.Dispose(); + } + + // ---- harness ------------------------------------------------------------------------------ + + private static PolicyResolver Resolver() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyContext Caller() => + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + /// A posture whose audit buffer holds exactly one event. + private static DwPolicyOptions OneSlot(DwTier tier, bool dryRun = false) + { + DwPolicyOptions options = new() { Tier = tier, DryRun = dryRun }; + + options.Caps.MaxAuditEvents = 1; + options.Caps.MinGroupSize = 1; + + return options; + } + + private static Condition On(string field, DataType type = DataType.Text) => new() + { + Field = field, + DataType = type, + Operator = Operator.Equal, + Values = { Value(type) } + }; + + private static string Value(DataType type) => type switch + { + DataType.Number => "1", + DataType.Boolean => "true", + _ => "x" + }; + + /// What a call answered with, in a form two calls can be compared by. + private static string Outcome(Func run) + { + try + { + run(); + + return "OK"; + } + catch (PolicyException refusal) + { + return $"PolicyException {refusal.ErrorCode}" + + $" path={refusal.FieldPath}" + + $" feature={refusal.Feature}" + + $" origin={refusal.SourceOrigin ?? ""}" + + $" rule={refusal.RuleId ?? ""}"; + } + catch (LogicException logic) + { + return "LogicException " + logic.Message; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + // ========================================================================================= + // Dv5-A. The audit cap under Strict raises the clause's own field refusal. + // + // Claim: "under Strict outside a dry run, a query that exhausts the audit buffer is refused + // with the clause's own field refusal — same code, FieldPath "*", no SourceOrigin." + // ========================================================================================= + + [Fact] + public void Dv5_A_The_audit_cap_raises_the_clauses_own_field_refusal_under_Strict() + { + (string What, PolicyErrorCode Expected, Func Run)[] clauses = + { + ("Where", PolicyErrorCode.FieldDeniedForWhere, WhereTwice), + ("Selects", PolicyErrorCode.FieldDeniedForSelect, SelectTwice), + ("Orders", PolicyErrorCode.FieldDeniedForOrder, OrderTwice), + ("GroupBy", PolicyErrorCode.FieldDeniedForGroup, GroupTwice), + ("AggregateBy", PolicyErrorCode.FieldDeniedForAggregate, GroupThenAggregate), + ("Segment", PolicyErrorCode.FieldDeniedForSegment, SegmentTwice) + }; + + List wrong = new(); + + foreach ((string what, PolicyErrorCode expected, Func run) in clauses) + { + string strict = run(OneSlot(DwTier.Strict)); + + _out.WriteLine($"{what,-12} strict -> {strict}"); + + string want = $"PolicyException {expected} path=* feature="; + + if (!strict.StartsWith(want, StringComparison.Ordinal)) + { + wrong.Add($"{what}: {strict}"); + } + + if (!strict.Contains("origin=", StringComparison.Ordinal) + || !strict.Contains("rule=", StringComparison.Ordinal)) + { + wrong.Add($"{what} carried an origin or a rule: {strict}"); + } + } + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + // ========================================================================================= + // Dv5-B. Convenience and a dry run still answer CapExceeded. + // ========================================================================================= + + [Fact] + public void Dv5_B_Convenience_and_a_dry_run_still_answer_CapExceeded() + { + string convenience = WhereTwice(OneSlot(DwTier.Convenience)); + string dryRun = WhereTwice(OneSlot(DwTier.Strict, dryRun: true)); + string strict = WhereTwice(OneSlot(DwTier.Strict)); + + _out.WriteLine($"Convenience -> {convenience}"); + _out.WriteLine($"Strict + dry run -> {dryRun}"); + _out.WriteLine($"Strict -> {strict}"); + + Assert.StartsWith("PolicyException CapExceeded", convenience, StringComparison.Ordinal); + Assert.Contains("MaxAuditEvents", convenience, StringComparison.Ordinal); + Assert.Contains("path=Second", convenience, StringComparison.Ordinal); + + Assert.StartsWith("PolicyException CapExceeded", dryRun, StringComparison.Ordinal); + Assert.Contains("MaxAuditEvents", dryRun, StringComparison.Ordinal); + + // The request fails in every posture: the buffer still fails closed. + Assert.StartsWith("PolicyException", strict, StringComparison.Ordinal); + } + + // ========================================================================================= + // Dv5-C. Under Strict the cap cannot be told from a denied field or from a name matching + // nothing, and the trace still records which refusal it really was. + // ========================================================================================= + + [Fact] + public void Dv5_C_The_three_refusals_are_alike_and_the_trace_keeps_the_real_reason() + { + DwPolicyOptions options = OneSlot(DwTier.Strict); + + string cap = WhereTwice(options); + string denied = WhereOn(options, "First", "Sealed"); + string unknown = WhereOn(options, "First", "Zzzzz"); + + _out.WriteLine($"audit cap reached -> {cap}"); + _out.WriteLine($"a denied field -> {denied}"); + _out.WriteLine($"a name matching nothing -> {unknown}"); + + Assert.Equal(denied, cap); + Assert.Equal(unknown, cap); + + // The trace names the real reason, and LastTrace is readable after the refusal. + PolicyQueryable guarded = + _watched.Rows.ApplyPolicy(Caller(), options, Resolver()); + + Filter filter = new() { ConditionGroup = new ConditionGroup() }; + + filter.ConditionGroup.Conditions.Add(On("First")); + filter.ConditionGroup.Conditions.Add(On("Second")); + + Assert.Throws(() => guarded.ToList(filter)); + + PolicyTrace? trace = guarded.LastTrace; + + Assert.NotNull(trace); + + foreach (PolicyDecision decision in trace!.Decisions) + { + _out.WriteLine($"trace: {decision.FieldPath} {decision.Feature} {decision.Action} {decision.Reason}"); + } + + Assert.Contains( + trace.Decisions, + d => d.FieldPath == "Second" + && d.Action == PolicyAction.Denied + && (d.Reason ?? string.Empty).Contains("MaxAuditEvents", StringComparison.Ordinal)); + } + + // ========================================================================================= + // Dv5-D. Which provider the uncomputable-path refusal belongs to. + // + // Claim: "EF Core's own provider type, from EF Core's own assembly; every other provider is + // left alone, a host's own through ReplaceService included. A + // rewrite inside EF Core's own pipeline leaves EF Core's provider in place, so such a + // member is refused." + // ========================================================================================= + + [Fact] + public void Dv5_D_The_refusal_belongs_to_EF_Cores_own_provider_from_EF_Cores_own_assembly() + { + Type own = _orders.Orders.AsQueryable().Provider.GetType(); + + _out.WriteLine($"plain context provider = {own.FullName} @ {own.Assembly.GetName().Name}"); + + Assert.Equal("Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider", own.FullName); + Assert.Equal("Microsoft.EntityFrameworkCore", own.Assembly.GetName().Name); + + // EF Core's own provider: the unmapped getter and the owned type's getter are refused. + string slug = GuardedWhere(_orders.Orders, "Slug"); + string zero = GuardedWhere(_orders.Orders, "Total.IsZero"); + + _out.WriteLine($"EF Core's own Slug -> {slug}"); + _out.WriteLine($"EF Core's own Total.IsZero -> {zero}"); + + Assert.StartsWith("PolicyException FieldDeniedForWhere path=*", slug, StringComparison.Ordinal); + Assert.StartsWith("PolicyException FieldDeniedForWhere path=*", zero, StringComparison.Ordinal); + + // A host's own provider, registered through ReplaceService: left alone. The guarded + // query does exactly what the unguarded one does. + using SqliteConnection replacedConnection = new("DataSource=:memory:"); + + replacedConnection.Open(); + + using Dv5Context replaced = new(replacedConnection, typeof(Dv5PassThroughProvider)); + + replaced.Database.EnsureCreated(); + replaced.Orders.Add(new Dv5Order { Code = "AB123", Total = new Dv5Money { Amount = 10m } }); + replaced.SaveChanges(); + replaced.ChangeTracker.Clear(); + + Type host = replaced.Orders.AsQueryable().Provider.GetType(); + + _out.WriteLine($"replaced provider = {host.FullName} @ {host.Assembly.GetName().Name}"); + + Assert.NotEqual("Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider", host.FullName); + + string hostRaw = Outcome(() => replaced.Orders.Where(o => o.Slug == "AB123-1").ToList()); + string hostGuarded = GuardedWhere(replaced.Orders, "Slug"); + + _out.WriteLine($"replaced provider Slug unguarded -> {hostRaw}"); + _out.WriteLine($"replaced provider Slug guarded -> {hostGuarded}"); + + Assert.Equal(hostRaw, hostGuarded); + Assert.DoesNotContain("FieldDeniedForWhere", hostGuarded, StringComparison.Ordinal); + + // A rewrite inside EF Core's own pipeline leaves EF Core's own provider in front of it, + // so the member is refused with the rest. + using SqliteConnection insideConnection = new("DataSource=:memory:"); + + insideConnection.Open(); + + using Dv5Context inside = new(insideConnection, replacePreprocessor: true); + + inside.Database.EnsureCreated(); + inside.Orders.Add(new Dv5Order { Code = "AB123", Total = new Dv5Money { Amount = 10m } }); + inside.SaveChanges(); + inside.ChangeTracker.Clear(); + + Type stillOwn = inside.Orders.AsQueryable().Provider.GetType(); + + _out.WriteLine($"pipeline rewrite provider = {stillOwn.FullName} @ {stillOwn.Assembly.GetName().Name}"); + + Assert.Equal("Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider", stillOwn.FullName); + + string insideGuarded = GuardedWhere(inside.Orders, "Slug"); + + _out.WriteLine($"pipeline rewrite Slug guarded -> {insideGuarded}"); + + Assert.StartsWith("PolicyException FieldDeniedForWhere path=*", insideGuarded, StringComparison.Ordinal); + } + + // ========================================================================================= + // Dv5-E. Which projected rows the refusal reads. + // + // Claim: "A projection that does not build its rows with an object initializer — an + // anonymous type, a constructor with arguments — has no member refused here. A member + // assigned from a subquery is left alone." + // ========================================================================================= + + [Fact] + public void Dv5_E_A_row_no_object_initializer_builds_has_no_member_refused() + { + List wrong = new(); + + // 1. An anonymous type. Nothing says which member each value sets. + var anonymous = _orders.Orders.Select(o => new { o.Id, o.Total }); + + Report("anonymous row, Total.IsZero", RowShape.Of(anonymous).Expresses("Total.IsZero")); + + if (RowShape.Of(anonymous).Expresses("Total.IsZero") == false) + { + wrong.Add("an anonymous row had a member refused"); + } + + // 2. A constructor with arguments, and no initializer at all. + IQueryable built = _orders.Orders.Select(o => new Dv5Row(o.Id)); + + foreach (string path in new[] { "Nest.Blank", "Money.IsZero", "Code" }) + { + bool? answer = RowShape.Of(built).Expresses(path); + + Report($"new Dv5Row(o.Id), {path}", answer); + + if (answer == false) + { + wrong.Add($"a constructor-built row had {path} refused"); + } + } + + string builtRaw = Outcome(() => + _orders.Orders.Select(o => new Dv5Row(o.Id)).Where(r => r.Nest.Blank).ToList()); + string builtGuarded = GuardedWhere(built, "Nest.Blank", DataType.Boolean); + + _out.WriteLine($"new Dv5Row(o.Id) Nest.Blank unguarded -> {builtRaw}"); + _out.WriteLine($"new Dv5Row(o.Id) Nest.Blank guarded -> {builtGuarded}"); + + if (builtRaw != builtGuarded) + { + wrong.Add($"guarded and unguarded differ: {builtRaw} vs {builtGuarded}"); + } + + // 3. A constructor with arguments that DOES carry an object initializer. Recorded, and + // reported, because the text reads two ways. + IQueryable mixed = _orders.Orders.Select(o => new Dv5Row(o.Id) + { + Nest = new Dv5Nest { A = o.Code } + }); + + bool? mixedNested = RowShape.Of(mixed).Expresses("Nest.Blank"); + bool? mixedTop = RowShape.Of(mixed).Expresses("Money"); + + Report("new Dv5Row(o.Id) { Nest = new Dv5Nest { A = … } }, Nest.Blank", mixedNested); + Report("new Dv5Row(o.Id) { Nest = new Dv5Nest { A = … } }, Money", mixedTop); + + _out.WriteLine(mixedNested == false + ? "NOTE: a row built by a constructor WITH an initializer does have a member refused" + : "a row built by a constructor with an initializer has no member refused"); + + // 4. A member assigned from a subquery is left alone, and one copied from the entity is + // not — which is the contrast the sentence draws. + IQueryable subquery = _orders.Orders.Select(o => new Dv5Row + { + Id = o.Id, + Lines = o.Lines.Select(l => new Dv5LineRow { Id = l.Id, Price = l.Price }).ToList() + }); + + bool? beneathSubquery = RowShape.Of(subquery).Expresses("Lines.Price"); + + Report("Lines = o.Lines.Select(…).ToList(), Lines.Price", beneathSubquery); + + if (beneathSubquery is not null) + { + wrong.Add($"a member assigned from a subquery answered {beneathSubquery}"); + } + + IQueryable copied = _orders.Orders.Select(o => new Dv5Row + { + Id = o.Id, + Money = o.Total + }); + + bool? beneathCopy = RowShape.Of(copied).Expresses("Money.IsZero"); + + Report("Money = o.Total, Money.IsZero", beneathCopy); + + if (beneathCopy != false) + { + wrong.Add($"a member copied from the entity answered {beneathCopy} rather than false"); + } + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + // ========================================================================================= + // Dv5-F. The compute refusal raises no [DwAudit] event, and "left alone" is not a promise + // that the path runs. + // ========================================================================================= + + [Fact] + public void Dv5_F_The_compute_refusal_records_no_audit_event() + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + // Control: an audited field the query does name records exactly one event. + DwPolicyContext allowed = Caller(); + + _orders.Orders + .ApplyPolicy(allowed, options, Resolver()) + .ToList(Where(On("Code"))); + + _out.WriteLine($"an audited field named -> {allowed.PendingAuditEvents.Count} event(s)"); + + Assert.Single(allowed.PendingAuditEvents); + + // The refusal of a path the query cannot compute records none. + DwPolicyContext refused = Caller(); + + Assert.Throws(() => + _orders.Orders + .ApplyPolicy(refused, options, Resolver()) + .ToList(Where(On("Slug")))); + + _out.WriteLine($"a path the query cannot compute -> {refused.PendingAuditEvents.Count} event(s)"); + + Assert.Empty(refused.PendingAuditEvents); + + // And a name matching nothing records none either, which is the comparison the text draws. + DwPolicyContext unknown = Caller(); + + Assert.Throws(() => + _orders.Orders + .ApplyPolicy(unknown, options, Resolver()) + .ToList(Where(On("Zzzzz")))); + + _out.WriteLine($"a name matching nothing -> {unknown.PendingAuditEvents.Count} event(s)"); + + Assert.Empty(unknown.PendingAuditEvents); + } + + // ========================================================================================= + // Dv5-G. Convenience and a dry run fail exactly as the unguarded query does on a path the + // query cannot compute: the refusal is the strict tier's alone. + // ========================================================================================= + + [Fact] + public void Dv5_G_Convenience_and_a_dry_run_fail_as_the_unguarded_query_does() + { + string raw = Outcome(() => _orders.Orders.Where(o => o.Slug == "AB123-1").ToList()); + string convenience = GuardedWhere(_orders.Orders, "Slug", DataType.Text, DwTier.Convenience); + string dryRun = GuardedWhere(_orders.Orders, "Slug", DataType.Text, DwTier.Strict, dryRun: true); + string strict = GuardedWhere(_orders.Orders, "Slug"); + + _out.WriteLine($"unguarded -> {raw}"); + _out.WriteLine($"Convenience -> {convenience}"); + _out.WriteLine($"Strict + dry run -> {dryRun}"); + _out.WriteLine($"Strict -> {strict}"); + + Assert.Equal(raw, convenience); + Assert.Equal(raw, dryRun); + Assert.StartsWith("PolicyException FieldDeniedForWhere path=*", strict, StringComparison.Ordinal); + } + + // ========================================================================================= + // Dv5-H. Selects is not one of the clauses the database has to compute. + // ========================================================================================= + + [Fact] + public void Dv5_H_Selects_naming_an_uncomputable_member_still_returns_its_value() + { + Filter filter = new() { Selects = new List { "Id", "Slug" } }; + + string strict = Outcome(() => _orders.Orders + .ApplyPolicy(Caller(), new DwPolicyOptions { Tier = DwTier.Strict }, Resolver()) + .ToList(filter)); + + _out.WriteLine($"Selects = [Id, Slug] under Strict -> {strict}"); + + Assert.Equal("OK", strict); + } + + // ========================================================================================= + // Dv5-I. The clauses the compute refusal reaches: "a filter, an order, a grouping key, an + // aggregated field, and a filter or an order inside a Segment" — and not Selects. + // ========================================================================================= + + [Fact] + public void Dv5_I_The_compute_refusal_reaches_every_clause_the_database_computes() + { + DwPolicyOptions strict = new() { Tier = DwTier.Strict }; + + strict.Caps.MinGroupSize = 1; + + (string What, PolicyErrorCode Expected, Func Run)[] clauses = + { + ("a filter", PolicyErrorCode.FieldDeniedForWhere, + () => Guarded(strict, g => g.ToList(Where(On("Slug"))))), + ("an order", PolicyErrorCode.FieldDeniedForOrder, + () => Guarded(strict, g => g.ToList(new Filter + { + Orders = new List { new() { Sort = 1, Field = "Slug" } } + }))), + ("a grouping key", PolicyErrorCode.FieldDeniedForGroup, + () => Guarded(strict, g => g.ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Slug" }, + AggregateBy = new List + { + new() { Field = null, Alias = "n", Aggregator = Aggregator.Count } + } + } + }))), + ("an aggregated field", PolicyErrorCode.FieldDeniedForAggregate, + () => Guarded(strict, g => g.ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List + { + new() { Field = "Slug", Alias = "s", Aggregator = Aggregator.Maximum } + } + } + }))), + ("a filter inside a Segment", PolicyErrorCode.FieldDeniedForSegment, + () => Guarded(strict, g => g.ToListAsync(OneSet(On("Slug"))).GetAwaiter().GetResult())) + }; + + List wrong = new(); + + foreach ((string what, PolicyErrorCode expected, Func run) in clauses) + { + string answer = run(); + + _out.WriteLine($"{what,-28} -> {answer}"); + + if (!answer.StartsWith($"PolicyException {expected} path=*", StringComparison.Ordinal)) + { + wrong.Add($"{what}: {answer}"); + } + } + + Assert.True(wrong.Count == 0, string.Join(" || ", wrong)); + } + + // ========================================================================================= + // Dv5-J. A simulation has no source, so it cannot refuse a path the query cannot compute. + // ========================================================================================= + + [Fact] + public void Dv5_J_A_simulation_shows_an_uncomputable_path_running() + { + PolicySimulation simulated = PolicySimulator.Simulate( + Where(On("Slug")), + Caller(), + new DwPolicyOptions { Tier = DwTier.Strict }, + Resolver()); + + _out.WriteLine($"simulated WouldRun = {simulated.WouldRun}"); + _out.WriteLine($"simulated refusal = {simulated.Refusal?.ErrorCode.ToString() ?? ""}"); + _out.WriteLine($"the same request guarded -> {GuardedWhere(_orders.Orders, "Slug")}"); + + Assert.True(simulated.WouldRun); + Assert.Null(simulated.Refusal); + } + + // ========================================================================================= + // Dv5-K. A shadow property is nameable by no clause, guarded or not. + // ========================================================================================= + + [Fact] + public void Dv5_K_A_shadow_property_is_nameable_by_no_clause() + { + // Dv5Line.OrderId is a real CLR member; the shadow property here is the foreign key EF + // Core creates for Dv5Order.Lines when no CLR member carries it. Read the model for one. + List shadow = _orders.Model + .FindEntityType(typeof(Dv5Line))! + .GetProperties() + .Where(p => p.IsShadowProperty()) + .Select(p => p.Name) + .ToList(); + + _out.WriteLine("shadow properties on Dv5Line: " + (shadow.Count == 0 ? "" : string.Join(", ", shadow))); + + Assert.NotEmpty(shadow); + + DwPolicyOptions strict = new() { Tier = DwTier.Strict }; + + foreach (string name in shadow) + { + string guarded = Outcome(() => _orders.Lines + .ApplyPolicy(Caller(), strict, Resolver()) + .ToList(Where(On(name, DataType.Number)))); + + string convenience = Outcome(() => _orders.Lines + .ApplyPolicy(Caller(), new DwPolicyOptions { Tier = DwTier.Convenience }, Resolver()) + .ToList(Where(On(name, DataType.Number)))); + + string unguarded = Outcome(() => DynamicWhere.ex.Source.Extension + .Where(_orders.Lines.AsQueryable(), On(name, DataType.Number)) + .ToList()); + + _out.WriteLine($"shadow '{name}' strict -> {guarded}"); + _out.WriteLine($"shadow '{name}' convenience -> {convenience}"); + _out.WriteLine($"shadow '{name}' unguarded -> {unguarded}"); + + Assert.StartsWith("LogicException", unguarded, StringComparison.Ordinal); + Assert.StartsWith("LogicException", convenience, StringComparison.Ordinal); + } + } + + // ========================================================================================= + // Dv5-L. Clone()'s contract: every node new, the values a condition carries the caller's own + // objects in a new list. + // ========================================================================================= + + [Fact] + public void Dv5_L_Clone_is_a_deep_copy_that_shares_only_the_values_themselves() + { + object value = new Dv5Box("x"); + + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Code", DataType = DataType.Text, Operator = Operator.Equal, Values = { value } + } + }, + SubConditionGroups = { new ConditionGroup { Sort = 2 } } + }, + Selects = new List { "Id" }, + Orders = new List { new() { Sort = 1, Field = "Id" } }, + Page = new PageBy { PageNumber = 1, PageSize = 10 } + }; + + Filter copy = filter.Clone(); + + _out.WriteLine($"filter same reference = {ReferenceEquals(filter, copy)}"); + _out.WriteLine($"condition group same reference = {ReferenceEquals(filter.ConditionGroup, copy.ConditionGroup)}"); + _out.WriteLine($"condition same reference = {ReferenceEquals(filter.ConditionGroup!.Conditions[0], copy.ConditionGroup!.Conditions[0])}"); + _out.WriteLine($"sub-group same reference = {ReferenceEquals(filter.ConditionGroup.SubConditionGroups[0], copy.ConditionGroup.SubConditionGroups[0])}"); + _out.WriteLine($"values LIST same reference = {ReferenceEquals(filter.ConditionGroup.Conditions[0].Values, copy.ConditionGroup.Conditions[0].Values)}"); + _out.WriteLine($"one VALUE same reference = {ReferenceEquals(filter.ConditionGroup.Conditions[0].Values[0], copy.ConditionGroup.Conditions[0].Values[0])}"); + _out.WriteLine($"selects same reference = {ReferenceEquals(filter.Selects, copy.Selects)}"); + _out.WriteLine($"order same reference = {ReferenceEquals(filter.Orders![0], copy.Orders![0])}"); + _out.WriteLine($"page same reference = {ReferenceEquals(filter.Page, copy.Page)}"); + + Assert.NotSame(filter, copy); + Assert.NotSame(filter.ConditionGroup, copy.ConditionGroup); + Assert.NotSame(filter.ConditionGroup.Conditions[0], copy.ConditionGroup.Conditions[0]); + Assert.NotSame(filter.ConditionGroup.SubConditionGroups[0], copy.ConditionGroup.SubConditionGroups[0]); + Assert.NotSame(filter.Selects, copy.Selects); + Assert.NotSame(filter.Orders[0], copy.Orders[0]); + Assert.NotSame(filter.Page, copy.Page); + + // A new list, holding the caller's own objects: both requests read the same value. + Assert.NotSame(filter.ConditionGroup.Conditions[0].Values, copy.ConditionGroup.Conditions[0].Values); + Assert.Same(value, copy.ConditionGroup.Conditions[0].Values[0]); + } + + // ========================================================================================= + // Dv5-M. The group floor applies to a summary read through the IEnumerable overload. + // ========================================================================================= + + [Fact] + public void Dv5_M_The_group_floor_applies_to_a_summary_read_in_memory() + { + List rows = new(); + + // Five rows under "big", two under "small": only the first group clears a floor of five. + for (int i = 0; i < 5; i++) + { + rows.Add(new Dv5Watched { Id = i + 1, First = "big", Second = "b", Amount = 1 }); + } + + for (int i = 0; i < 2; i++) + { + rows.Add(new Dv5Watched { Id = 100 + i, First = "small", Second = "b", Amount = 1 }); + } + + DwPolicyOptions floored = new() { Tier = DwTier.Convenience }; + DwPolicyOptions off = new() { Tier = DwTier.Convenience }; + + off.Caps.MinGroupSize = 1; + + Summary summary = new() + { + GroupBy = new GroupBy + { + Fields = new List { "First" }, + AggregateBy = new List + { + new() { Field = null, Alias = "n", Aggregator = Aggregator.Count } + } + } + }; + + // ApplyPolicy(IEnumerable, context) is AsQueryable() plus the same pipeline + // (PolicyExtensions.cs), and only the one-argument form reads DwPolicy's statics, so the + // posture is handed in here instead. + int withFloor = ((IEnumerable)rows).AsQueryable() + .ApplyPolicy(Caller(), floored, Resolver()) + .ToList(summary.Clone()) + .Data!.Count; + + int withoutFloor = ((IEnumerable)rows).AsQueryable() + .ApplyPolicy(Caller(), off, Resolver()) + .ToList(summary.Clone()) + .Data!.Count; + + _out.WriteLine($"in memory, default floor (5) -> {withFloor} group(s)"); + _out.WriteLine($"in memory, floor off (1) -> {withoutFloor} group(s)"); + + Assert.Equal(1, withFloor); + Assert.Equal(2, withoutFloor); + } + + private static Segment OneSet(params Condition[] conditions) + { + Segment segment = new(); + ConditionSet set = new() { Sort = 1, ConditionGroup = new ConditionGroup() }; + + foreach (Condition condition in conditions) + { + set.ConditionGroup.Conditions.Add(condition); + } + + segment.ConditionSets.Add(set); + + return segment; + } + + private string Guarded(DwPolicyOptions options, Func, object?> run) => + Outcome(() => run(_orders.Orders.ApplyPolicy(Caller(), options, Resolver()))); + + // ---- the clauses the cap probes run -------------------------------------------------------- + + private static Filter Where(params Condition[] conditions) + { + Filter filter = new() { ConditionGroup = new ConditionGroup() }; + + foreach (Condition condition in conditions) + { + filter.ConditionGroup.Conditions.Add(condition); + } + + return filter; + } + + private string WhereTwice(DwPolicyOptions options) => WhereOn(options, "First", "Second"); + + private string WhereOn(DwPolicyOptions options, params string[] fields) + { + Condition[] conditions = Array.ConvertAll(fields, f => On(f)); + + return Outcome(() => _watched.Rows + .ApplyPolicy(Caller(), options, Resolver()) + .ToList(Where(conditions))); + } + + private string SelectTwice(DwPolicyOptions options) => + Outcome(() => _watched.Rows + .ApplyPolicy(Caller(), options, Resolver()) + .ToList(new Filter { Selects = new List { "First", "Second" } })); + + private string OrderTwice(DwPolicyOptions options) => + Outcome(() => _watched.Rows + .ApplyPolicy(Caller(), options, Resolver()) + .ToList(new Filter + { + Orders = new List + { + new() { Sort = 1, Field = "First" }, + new() { Sort = 2, Field = "Second" } + } + })); + + private string GroupTwice(DwPolicyOptions options) => + Outcome(() => _watched.Rows + .ApplyPolicy(Caller(), options, Resolver()) + .ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "First", "Second" }, + AggregateBy = new List + { + new() { Field = null, Alias = "n", Aggregator = Aggregator.Count } + } + } + })); + + private string GroupThenAggregate(DwPolicyOptions options) => + Outcome(() => _watched.Rows + .ApplyPolicy(Caller(), options, Resolver()) + .ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "First" }, + AggregateBy = new List + { + new() { Field = "Amount", Alias = "s", Aggregator = Aggregator.Sumation } + } + } + })); + + private string SegmentTwice(DwPolicyOptions options) + { + Segment segment = new(); + ConditionSet set = new() { Sort = 1, ConditionGroup = new ConditionGroup() }; + + set.ConditionGroup.Conditions.Add(On("First")); + set.ConditionGroup.Conditions.Add(On("Second")); + segment.ConditionSets.Add(set); + + return Outcome(() => _watched.Rows + .ApplyPolicy(Caller(), options, Resolver()) + .ToListAsync(segment).GetAwaiter().GetResult()); + } + + private string GuardedWhere( + IQueryable source, + string field, + DataType type = DataType.Text, + DwTier tier = DwTier.Strict, + bool dryRun = false) + where T : class => + Outcome(() => source + .ApplyPolicy(Caller(), new DwPolicyOptions { Tier = tier, DryRun = dryRun }, Resolver()) + .ToList(Where(On(field, type)))); + + private void Report(string what, bool? answer) => + _out.WriteLine($"{what,-62} Expresses = {(answer is null ? "null (left alone)" : answer.ToString())}"); + } +} diff --git a/DynamicWhere.Tests/Policies/Dv5Model.cs b/DynamicWhere.Tests/Policies/Dv5Model.cs new file mode 100644 index 0000000..5eed60a --- /dev/null +++ b/DynamicWhere.Tests/Policies/Dv5Model.cs @@ -0,0 +1,209 @@ +using System.ComponentModel.DataAnnotations.Schema; +using DynamicWhere.ex.Policies.Attributes; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 5 (documentation against code). The model the Dv5 probes query. + // + // EF Core 6 compatible on purpose, so the floor leg runs every probe: no complex properties, + // no ToJson, no primitive collections, no DateOnly/TimeOnly, no compiled model. + // ============================================================================================= + + /// An owned type with a getter over two of its own columns: no database computes it. + public class Dv5Money + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = "USD"; + + public bool IsZero => Amount == 0m; + } + + public class Dv5Line + { + public int Id { get; set; } + + public int OrderId { get; set; } + + public decimal Price { get; set; } + } + + public class Dv5Order + { + public int Id { get; set; } + + /// Audited, so a refusal that records no event can be told from one that does. + [DwAudit] + public string Code { get; set; } = string.Empty; + + public int Qty { get; set; } + + public Dv5Money Total { get; set; } = new(); + + public List Lines { get; set; } = new(); + + /// Unmapped: a getter over two columns, which no database computes. + [NotMapped] + public string Slug => Code + "-" + Id; + } + + // ---- row types a caller projects into ------------------------------------------------------- + + public class Dv5Nest + { + public string A { get; set; } = string.Empty; + + public string B { get; set; } = string.Empty; + + public bool Blank => A.Length == 0; + } + + public class Dv5LineRow + { + public int Id { get; set; } + + public decimal Price { get; set; } + } + + public class Dv5Row + { + public Dv5Row() + { + } + + /// The constructor the ctor-with-arguments probes call. + public Dv5Row(int id) => Id = id; + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public Dv5Nest Nest { get; set; } = new(); + + public Dv5Money Money { get; set; } = new(); + + public List Lines { get; set; } = new(); + } + + /// A value a condition carries, so a clone's sharing of it can be seen by reference. + public sealed class Dv5Box + { + public Dv5Box(string text) => Text = text; + + public string Text { get; } + + public override string ToString() => Text; + } + + // ---- the audited entity ---------------------------------------------------------------------- + + /// Two audited fields, so one use fills a one-slot buffer and the next hits the cap. + public class Dv5Watched + { + public int Id { get; set; } + + [DwAudit] + public string First { get; set; } = string.Empty; + + [DwAudit] + public string Second { get; set; } = string.Empty; + + [DwAudit] + public int Amount { get; set; } + + /// Not audited, and denied for filtering: the refusal a real denial gives. + [DwDeny(DynamicWhere.ex.Policies.Enums.PolicyFeature.Where)] + public string Sealed { get; set; } = string.Empty; + } + + public sealed class Dv5WatchedContext : DbContext + { + private readonly SqliteConnection _connection; + + public Dv5WatchedContext(SqliteConnection connection) => _connection = connection; + + public DbSet Rows => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + public sealed class Dv5Context : DbContext + { + private readonly SqliteConnection _connection; + private readonly Type? _replacedProvider; + private readonly bool _replacePreprocessor; + + public Dv5Context( + SqliteConnection connection, + Type? replacedProvider = null, + bool replacePreprocessor = false) + { + _connection = connection; + _replacedProvider = replacedProvider; + _replacePreprocessor = replacePreprocessor; + } + + public DbSet Orders => Set(); + + public DbSet Lines => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlite(_connection); + + if (_replacedProvider is not null) + { + // The documented EF Core extension point a host uses to put its own query provider + // in place: ReplaceService. + typeof(DbContextOptionsBuilder) + .GetMethods() + .Single(m => m.Name == nameof(DbContextOptionsBuilder.ReplaceService) + && m.GetGenericArguments().Length == 2 + && m.GetParameters().Length == 0) + .MakeGenericMethod(typeof(IAsyncQueryProvider), _replacedProvider) + .Invoke(options, null); + } + + if (_replacePreprocessor) + { + // A rewrite *inside* EF Core's own pipeline: the query is preprocessed by a type of + // the host's, and EF Core's own provider stays in front of it. + options.ReplaceService(); + } + } + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(order => order.Total); + model.Entity().Ignore(order => order.Slug); + } + } + + /// A host's own preprocessor factory, registered inside EF Core's pipeline. + public sealed class Dv5PreprocessorFactory + : Microsoft.EntityFrameworkCore.Query.Internal.RelationalQueryTranslationPreprocessorFactory + { + public Dv5PreprocessorFactory( + QueryTranslationPreprocessorDependencies dependencies, + RelationalQueryTranslationPreprocessorDependencies relationalDependencies) + : base(dependencies, relationalDependencies) + { + } + } + + /// + /// A plain pass-through built the way a host builds one: derived from EF Core's own provider, + /// registered through ReplaceService<IAsyncQueryProvider, …>, rewriting nothing. + /// + public sealed class Dv5PassThroughProvider : Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider + { + public Dv5PassThroughProvider(Microsoft.EntityFrameworkCore.Query.Internal.IQueryCompiler compiler) + : base(compiler) + { + } + } +} diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index 78fbe1b..653eceb 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -54,6 +54,8 @@ New: DwPolicy.Configure takes a second call asking for the posture already in fo Behaviour change: under the strict tier, a filter, order, grouping key or aggregated field naming a path the query cannot compute is refused as an unknown name is, where the provider used to throw. A projection is not refused: EF Core evaluates the last projection on the client, so a Selects entry naming such a member returns its value as it always did. A member of a row's type is not always a value a database can produce: a getter such as LocalizedText.IsEmpty reads two columns in memory, so every check the policy makes passes and the query then fails inside EF Core with InvalidOperationException — a five-hundred where the strict tier promises a refusal, and the one place the tier answered with neither an answer nor a refusal. The path is refused only where the whole set of members a container can produce is known: the queried entity type's own model, an owned or complex type, and an initializer in a projection EF Core ran before ApplyPolicy, including a member that projection copies straight from the entity. Everywhere else nothing changes — rows in memory run the getter as they always did, a framework member the provider translates such as Length, Year or HasValue is left alone, a value beneath a converted column is left alone, a provider that is not EF Core's decides for itself, the convenience tier and a dry run still fail exactly as an unguarded query does, and a source the library cannot read asks for nothing. A column the model maps only on a subtype is refused through the base type, because EF Core translates a member against the type the query is over and fails there too; query the derived type. The trace records the refusal with its reason, and the trace is now assigned before a request is sanitized, so a refusal leaves LastTrace readable rather than null. +Security fix and behaviour change: under the strict tier, outside a dry run, a query that exhausts the audit buffer is refused with the clause's own field refusal rather than with CapExceeded and a SourceOrigin naming the cap. An audited field records an event per use and the query is refused rather than the record dropped once DwCaps.MaxAuditEvents is reached, which is fail-closed and stays that way; but a name that matches nothing is never audited and never reaches the cap, so the two answers told a caller, one guess per request, which names are real and audited. The refusal now carries the clause's own code, FieldPath "*" and no origin, and the trace records which refusal it really was. The convenience tier and a dry run still answer CapExceeded. + New: Clone is public on Filter, Segment and Summary. It returns a deep copy — the condition tree with its groups and conditions, the projection list, each order, the page, and a summary's group-by and having clause — so a caller reading the same request again with one part changed, the next page or another order, no longer rebuilds it around the caller's own clauses. Two requests sharing one condition tree is the bug this removes: the library has cloned before rewriting anything since 3.0, and callers could not. Docs: [DwEntity(DefaultOrder)]'s own remarks still read as though a default order does not reach a projected query. It has since 3.2.0. DwCaps.MinGroupSize ships on at 5, so a guarded summary drops every group with fewer than five rows unless the deployment sets it to 1; the aggregation documentation now leads with that rather than leaving it to the caps table. diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs index ccd1c99..5fb45d6 100644 --- a/DynamicWhere.ex/Policies/Source/RowShape.cs +++ b/DynamicWhere.ex/Policies/Source/RowShape.cs @@ -483,8 +483,9 @@ private static bool Named(object model, string name) => /// decompiler — so a member it computes is one the query produces, and holding its rows to EF /// Core's model would refuse a query that runs. The model is read from the query root in the /// expression, which such a wrapper leaves in place, so the provider is what tells the two - /// apart. EF Core's own provider is matched by name: no internal type is referenced, and every - /// version from EF Core 6 to 10 answers the same. + /// apart. EF Core's own provider is matched by name and by the assembly it was declared in: no + /// internal type is referenced, every version from EF Core 6 to 10 answers the same, and a type + /// declared under that name elsewhere is not taken for EF Core's. /// /// The type itself, not a type derived from it. EF Core's provider derives from object in /// every version, and the queryable a DbSet hands out carries that exact type through diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index 94906a8..2da710a 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -972,7 +972,10 @@ PolicyTrace? recorded = guarded.LastTrace; // recorded whatever the setting say MaxNavigationDepth and MaxAuditEvents, the two caps that named a field, used to report its canonical path, which confirmed that the path exists. They report "*", - and SourceOrigin still names the cap. + and SourceOrigin still names the cap — except the audit + buffer, which since 3.3.0 refuses with the + clause's own field refusal under Strict outside a dry + run, and names no cap either (point 33).
        • MaxQueryCost is checked after every field has passed its @@ -1508,8 +1511,12 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}
        • AttackControl

          - A projection is read only as far as its initializer can be read. An - entity query names every producible member from the model; a projection + A projection that does not build its rows with an object initializer is + left alone whole: an anonymous type, and a constructor with arguments, + say nothing about which member each value sets, so no member of such a + row is refused here and none is claimed. A projection is otherwise read + only as far as its initializer can be read. An entity query names every + producible member from the model; a projection names them only where each assignment is a nested initializer, a member copied from the entity, a value built and left empty, or a conditional over those — a null branch beside one of them included. A member @@ -1545,10 +1552,12 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}Decompile() exist to rewrite the members EF Core cannot translate, so a member they compute is one the query produces, over a projection and over an entity alike. The test is - EF Core's own provider type, not a type derived from it: a provider - built by deriving rewrites in the same way, and EF Core's own - derives from object in every version, so nothing real is - lost by the exact test. A row the + EF Core's own provider type, from EF Core's own assembly — + every other provider is left alone for the same reason turned around: a + host's own, registered through{" "} + ReplaceService<IAsyncQueryProvider, …>, may rewrite or + may pass straight through, the library cannot tell, and refusing on that + guess would take back a query the rewriting host answers today. A row the library itself projected is read like any other: the core's typed{" "} Select null-guards every nested node it builds, and both branches of that guard are read, so composing Select and diff --git a/OfficialWebsite/app/docs/policies/configuration/page.tsx b/OfficialWebsite/app/docs/policies/configuration/page.tsx index 19e4523..285ca5e 100644 --- a/OfficialWebsite/app/docs/policies/configuration/page.tsx +++ b/OfficialWebsite/app/docs/policies/configuration/page.tsx @@ -622,7 +622,12 @@ PolicyTrace? trace = guarded.LastTrace;`} and MaxAuditEvents also put the field's path on{" "} FieldPath; under Strict every{" "} CapExceeded has FieldPath{" "} - "*".{" "} + "*". Since 3.3.0 the audit + buffer does not answer with CapExceeded under{" "} + Strict outside a dry run at all: only a real, audited field + can reach it, so it refuses with the clause's own field refusal and + no origin. See{" "} + the audit cap.{" "} MaxQueryCost is the one with a code of its own,{" "} QueryCostExceeded, because an operator reading a log needs to know which of the two refused: raising the wrong one changes nothing. The three schema caps never throw at all — diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx index e822ae5..cad8f55 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -240,7 +240,10 @@ true order. Add [DwNoOrder] unless that is intended.`} cannot translate it. It is refused only where the whole set of members a container can produce is known — an entity's own EF Core model, and the initializers of a projection composed before ApplyPolicy, including a member - that projection copies from the entity. Rows in memory, a framework + that projection copies from the entity. A projection that builds its + rows any other way — an anonymous type, a constructor with arguments — + says nothing about which member each value sets, so no member of such a + row is refused here. Rows in memory, a framework member the provider translates such as Length or{" "} Year, anything beneath a column, the convenience tier and a dry run are all unchanged: the path is left alone, and behaves exactly @@ -249,9 +252,11 @@ true order. Add [DwNoOrder] unless that is intended.`} provider decides. So is a query a provider in front of EF Core translates — LinqKit's{" "} AsExpandable(), DelegateDecompiler's{" "} - Decompile(), or one built by deriving from EF Core's - own provider — since such a provider exists to rewrite what EF Core - cannot, and a member it computes is one the query produces. + Decompile(), or a host's own registered through{" "} + ReplaceService<IAsyncQueryProvider, …> — since such a + provider may rewrite what EF Core cannot, and the library cannot tell + one that does from one that passes straight through. The test is EF + Core's own provider type, from EF Core's own assembly.

          The refusal raises no [DwAudit] event, for the reason an diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index d329b73..037e0b2 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -1667,7 +1667,8 @@ DwCaps sealed class; DwPolicyOptions.Caps. Setters: frozen → InvalidOperat MaxQueryCost 1000 1 sum of cost over every field reference QueryCostExceeded (19) DefaultFieldCost 1 0 cost of one reference to a field with no [DwCost] or rule weight, and (3.1.0) of an aggregate with no Field, such as a Count - MaxAuditEvents 10000 1 undrained audit events one context may hold CapExceeded (9) + MaxAuditEvents 10000 1 undrained audit events one context may hold CapExceeded (9); + under Strict outside a dry run, the clause's own field refusal MinGroupSize 5 1 k-anonymity floor for guarded grouped summaries; 1 = off groups suppressed SchemaDepth 2 1 default PolicySchemaRequest.Depth SchemaCycleLimit 2 1 times one type may appear on one schema path @@ -1699,7 +1700,8 @@ DwCaps sealed class; DwPolicyOptions.Caps. Setters: frozen → InvalidOperat - `FieldPath` is `"*"`, or, in the Convenience tier, the path for `MaxNavigationDepth`. Under `Strict` every `CapExceeded` refusal carries `"*"` (3.1.0); the trace keeps the path. - `Reason` and `SourceOrigin` name the cap, e.g. `"MaxConditions cap (50), request had 51"`, `"MaxConditionValues cap (1000), request had 1001"`, `"MaxAggregates cap (50), request had 51"`. - In dry run it is recorded and not thrown. -- `MaxAuditEvents` throws even in dry run, with `FieldPath` = the audited field (`"*"` under `Strict`). +- `MaxAuditEvents` throws even in dry run, with `FieldPath` = the audited field. Under `Strict` outside a dry run + it throws the clause's own field refusal instead of `CapExceeded`, with `FieldPath` `"*"` (3.3.0). - `MinGroupSize`: - The effective floor is the largest of `Caps.MinGroupSize` and the `MinGroupSize` of the transform chain on any aggregated field. - Above 1, a guarded `Summary` with a `GroupBy` gets a `Count` column `__dwGroupSize` plus `HAVING __dwGroupSize >= floor` in SQL. @@ -2185,8 +2187,10 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. - a `DefaultOrder` field left out for this caller, outside a dry run - forced predicates - output transforms -- If the context already holds `Caps.MaxAuditEvents` (default 10000) events, the query throws - `CapExceeded`, in both tiers and in dry run. +- If the context already holds `Caps.MaxAuditEvents` (default 10000) events, the query is refused, in both tiers + and in dry run. `CapExceeded`, except under `Strict` outside a dry run, where it is the clause's own field + refusal instead (3.3.0): only a real, audited field can reach the cap, so answering with the cap's own code + would set such a field apart from a name that matches nothing. - A refusal becomes an event of its own, with an `ErrorCode`, only when `DwPolicyOptions.AuditRefusals` is on, and then for every refused guarded query, audited field or not (3.1.0, section 22). - It is elected: the top-ranked audit wins outright, and fragments tied with it are unioned. A rule can @@ -3210,7 +3214,9 @@ DwAuditEvent sealed class - Events buffer on the context (`PendingAuditEvents`); the query path never calls a sink. - Drain once per request with `DwPolicy.DrainAuditAsync(context, sink)`. Undrained events are lost with the context. - `Caps.MaxAuditEvents` bounds the undrained buffer. - - The event that does not fit refuses the query with `CapExceeded` (9), `SourceOrigin` containing `"MaxAuditEvents"`, even in dry run. + - The event that does not fit refuses the query, even in dry run: `CapExceeded` (9) with `SourceOrigin` + containing `"MaxAuditEvents"`, and under `Strict` outside a dry run the clause's own field refusal with no + origin at all (3.3.0). - Draining makes room again. - A sink must throw when it fails to write. It is passed per call and never stored in options, so a scoped sink works. - ASP.NET Core package: `app.UseDwPolicyAudit()` drains the context stored on `HttpContext.Features` to the `IDwAuditSink` registered in DI, after each response. @@ -3225,7 +3231,7 @@ DwAuditEvent sealed class - `EntityType` = the queried type's `FullName`; `Effect` = `Deny`; `DryRun` = false; `ErrorCode` = the refusal's code; - `Feature` and `Tier` = the refusal's own, so a store provider's refusal reports `All` and `Strict`; - `Subjects` and `Purpose` = the context's; - - `FieldPath` = the field the refusal was about, as its canonical path, in both tiers. A `FieldDeniedFor*`, `OperatorNotAllowed` or `RequiredFilterMissing` refusal, a `CapExceeded` refusal from `MaxNavigationDepth` or `MaxAuditEvents`, and `MissingContextValue`, which records the scoped field, are all recorded under the path although the refusal itself named the caller's alias or, under `Strict`, said `"*"`. A name that matches nothing (`Strict`) is recorded as the caller sent it, trimmed and with empty segments dropped. Every other refusal records its own `FieldPath` (section 30): `"*"` for a whole-request refusal such as `QueryStringDenied`, `QueryCostExceeded`, a cap on the request's size or `ApplyPolicy`'s `PolicyContextNotPrepared`; the entity's short type name for a store provider's refusal; the name as written for `AmbiguousFieldName`. + - `FieldPath` = the field the refusal was about, as its canonical path, in both tiers. A `FieldDeniedFor*`, `OperatorNotAllowed` or `RequiredFilterMissing` refusal, a `CapExceeded` refusal from `MaxNavigationDepth`, the audit buffer's refusal whichever code it carries, and `MissingContextValue`, which records the scoped field, are all recorded under the path although the refusal itself named the caller's alias or, under `Strict`, said `"*"`. A name that matches nothing (`Strict`) is recorded as the caller sent it, trimmed and with empty segments dropped. Every other refusal records its own `FieldPath` (section 30): `"*"` for a whole-request refusal such as `QueryStringDenied`, `QueryCostExceeded`, a cap on the request's size or `ApplyPolicy`'s `PolicyContextNotPrepared`; the entity's short type name for a store provider's refusal; the name as written for `AmbiguousFieldName`. - The recorded path is cut to its first 256 characters followed by `…` (3.1.0). Then every character in Unicode category Control (Cc), Format (Cf), Line Separator (Zl) or Paragraph Separator (Zp) is written as `\u` and four lowercase hex digits: a line feed as `\u000a`, U+2028 as `\u2028`, U+202E as `\u202e`. A character outside the Basic Multilingual Plane is judged whole, and both halves of its surrogate pair are escaped. A name that matches nothing is text the caller wrote: a line break in it would forge a second entry in a log written one event per line, a format character such as U+202E would reverse the text after it without showing itself, and a name a megabyte long would be kept whole. - Written at most once per refusal, from an exception filter: the refusal is never changed, caught or swallowed. - A full buffer (`Caps.MaxAuditEvents`) records nothing, and the original refusal is still thrown. @@ -3753,7 +3759,7 @@ DwPolicyContext caller = await DwPolicy.PrepareAsync(new DwPolicyContext().WithS ## 27. Redis package — DynamicWhere.ex.Policies.Redis ``` -net6.0 dependencies: DynamicWhere.ex 3.1.0, StackExchange.Redis 2.8.24 namespace DynamicWhere.ex.Policies.Redis +net6.0 dependencies: DynamicWhere.ex 3.3.0, StackExchange.Redis 2.8.24 namespace DynamicWhere.ex.Policies.Redis new RedisPolicyStore(IConnectionMultiplexer redis, string? prefix = null, Func? resolveType = null) : IDwPolicyWritableStore @@ -3805,7 +3811,7 @@ DwPolicy.Configure(options, await StorePolicyProvider.CreateAsync(store, options ## 28. Entity Framework Core package — DynamicWhere.ex.Policies.EntityFrameworkCore ``` -net6.0 dependencies: DynamicWhere.ex 3.1.0, Microsoft.EntityFrameworkCore.Relational 6.0.22 +net6.0 dependencies: DynamicWhere.ex 3.3.0, Microsoft.EntityFrameworkCore.Relational 6.0.22 namespace DynamicWhere.ex.Policies.EntityFrameworkCore no raw SQL; any EF Core relational provider; ships no migrations new EfPolicyStore(Func contexts, Func? resolveType = null) : IDwPolicyWritableStore @@ -3888,7 +3894,7 @@ EfTokenVault: ## 29. ASP.NET Core package — DynamicWhere.ex.Policies.AspNetCore ``` -net6.0 dependencies: DynamicWhere.ex 3.1.0, FrameworkReference Microsoft.AspNetCore.App +net6.0 dependencies: DynamicWhere.ex 3.3.0, FrameworkReference Microsoft.AspNetCore.App namespace DynamicWhere.ex.Policies.AspNetCore ``` @@ -4149,7 +4155,8 @@ through `PolicySimulator` or in dry run to see the decisions). 8 OperatorNotAllowed an operator outside the field's allowed set ([DwOperators], rules) recorded 9 CapExceeded MaxPageSize, MaxConditions, MaxConditionDepth, MaxConditionSets, recorded, except the MaxConditionValues, MaxAggregates, MaxOrderFields or MaxNavigationDepth audit buffer: throws - exceeded; or the context already holds MaxAuditEvents undrained events + exceeded; or the context already holds MaxAuditEvents undrained events, + which under Strict outside a dry run is refused as a field instead 10 PolicyRequired a DynamicWhere extension method called on a throws [DwEntity(RequirePolicy = true)] type outside ApplyPolicy 11 RequiredFilterMissing a [DwRequireWhere] field has no AND-reachable condition using one of recorded @@ -4268,7 +4275,7 @@ any policy [DwEntity(RequirePolicy = true)] a DynamicWhere ca ### Inference channels -There are eight. The first six let a caller learn a value, or what the policy hides, without reading it; +There are nine. The first seven let a caller learn a value, or what the policy hides, without reading it; **Forgotten guard** and **Empty store** are bypasses. - **Set operations:** `EXCEPT` or `INTERSECT` rebuild a field that is denied for Select but allowed for diff --git a/README.md b/README.md index 436f8e8..b34ba31 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ The complete reference — every enum, class, extension method, validation rule, **Upgrade note — two behaviour changes. Read these before bumping.** - **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a value's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor, and `IncludeTraceInResult` written as the tier's own answer is the answer the tier already gives. A type exposed under two names is reported under the last one, so two catalogues that resolve every name alike are still a different posture when the order differs. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. -- **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query a provider in front of EF Core translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()`, or one built by deriving from EF Core's own provider — because such a provider exists to rewrite what EF Core cannot, and a member it computes is a member the query produces. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. +- **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query any provider but EF Core's own translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()`, or a host's own registered through `ReplaceService` — because such a provider may rewrite what EF Core cannot, and the library cannot tell one that does from one that passes straight through. The test is EF Core's own provider type, from EF Core's own assembly. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. - **Fixed (security): the audit cap answered differently for a real field.** An audited field records one event per use, and the query is refused rather than the record dropped once `DwCaps.MaxAuditEvents` is reached. That refusal carried `CapExceeded` and a `SourceOrigin` naming the cap, while a name matching nothing carried the ordinary field refusal and no origin — and an unknown name is never audited, so one guess per request told a caller which names are real and audited. Under `Strict`, outside a dry run, the cap now refuses with the clause's own code, `FieldPath` `"*"` and no origin; the request still fails and the trace still records which refusal it was. `Convenience` and a dry run are unchanged. - **Changed: `LastTrace` is set before a request is sanitized**, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. - **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. Every node is new; a condition's values stay the caller's own objects, in a new list. From 78ffa8a66b94ce3c529cbed2102e43e35637e1c5 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 15:59:58 +0300 Subject: [PATCH 23/54] fix(policies): four refusals that named a field under Strict name the clause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A strict refusal names no field, so that a denied one, a misspelling and a field that does not exist cannot be told apart. The fifth review found four that named one anyway. AmbiguousFieldName told a caller that the name they wrote matches more than one field, which is to say at least one. It is refused as an unknown name is now, and the trace records which fields it matched, for the operator who has to fix the aliases. AmbiguousGroupKey reported the grouping key's canonical path — the column behind whatever alias the caller wrote — and an origin saying its values are transformed. TransformRequiresMaterialization listed every transformed column on the type to a caller who had named none of them. MissingHashSalt and MissingTokenVault named the masked field a deployment had not configured for. Those three name the clause and carry no origin. The convenience tier and a dry run are unchanged: they name fields anyway. ResultTransformer.cs held a raw NUL and a raw unit separator inside two literals, which made every grep and ripgrep treat the file as binary and skip it silently. It was the only such file in the solution, and it is where one of these four lived, which is plausibly why four rounds of review did not see it. Both are written as escapes now. Co-Authored-By: Claude Opus 5 --- .../Policies/Ac5AuditCapProbes.cs | 527 ++++++++++ .../Policies/Ac5CodeSurfaceProbes.cs | 508 ++++++++++ DynamicWhere.Tests/Policies/Ac5LeakProbes.cs | 342 +++++++ .../Policies/Masking/TransformPipeline.cs | 13 +- .../Policies/Source/FilterSanitizer.cs | 21 + .../Policies/Source/PolicyQueryable.cs | 6 +- .../Policies/Source/ResultTransformer.cs | 929 +++++++++--------- 7 files changed, 1882 insertions(+), 464 deletions(-) create mode 100644 DynamicWhere.Tests/Policies/Ac5AuditCapProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Ac5CodeSurfaceProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Ac5LeakProbes.cs diff --git a/DynamicWhere.Tests/Policies/Ac5AuditCapProbes.cs b/DynamicWhere.Tests/Policies/Ac5AuditCapProbes.cs new file mode 100644 index 0000000..36f1556 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ac5AuditCapProbes.cs @@ -0,0 +1,527 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 5, adversarial security review of 3.3.0 at 692dd11. + // + // Round 4's fix: when DwCaps.MaxAuditEvents is reached and the tier hides existence, the + // refusal is raised through Gate.Exception(...DenialFor(feature)...) rather than as + // CapExceeded + SourceOrigin. These probes ask whether that refusal really is the same + // refusal a denied field and an unknown name get, in every clause and both terminals, and + // whether anything else can still tell the three apart. + // + // Everything here drives FilterSanitizer directly with an explicit posture, so no probe + // touches DwPolicy's process-wide state. + // ============================================================================================= + + ///

          The model the round-5 audit-cap probes gate. + /// + /// EF Core 6 compatible on purpose, so the floor leg runs every probe. + /// + internal class Ac5Staff + { + public int Id { get; set; } + + /// Plain: allowed everywhere, audited nowhere. + public string Code { get; set; } = string.Empty; + + /// Allowed everywhere and audited everywhere: one event per use. + [DwAudit] + public string Tag { get; set; } = string.Empty; + + /// Refused for every feature. + [DwDenied] + public string Secret { get; set; } = string.Empty; + + /// Weighed, so a cost refusal can be told from a structural one. + [DwCost(50)] + public string Heavy { get; set; } = string.Empty; + + /// Confirmable but not searchable. + [DwOperators(Allow = new[] { Operator.Equal })] + public string Badge { get; set; } = string.Empty; + + public Ac5Contact? Contact { get; set; } + } + + /// A nested node, so an audited field can sit behind a navigation. + internal class Ac5Contact + { + [DwAudit] + public string Email { get; set; } = string.Empty; + + public string Phone { get; set; } = string.Empty; + } + + /// + /// An audited field on a type with nothing denied, so no projection is synthesized at all and + /// the whole entity comes back. + /// + internal class Ac5Plain + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwAudit] + public string Tag { get; set; } = string.Empty; + } + + /// An audited field the type's declared default order names. + [DwEntity(DefaultOrder = "Tag")] + internal class Ac5Ordered + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwAudit] + public string Tag { get; set; } = string.Empty; + } + + public class Ac5AuditCapProbes + { + private readonly ITestOutputHelper _out; + + public Ac5AuditCapProbes(ITestOutputHelper output) => _out = output; + + private const string Audited = "Tag"; + private const string Denied = "Secret"; + private const string Missing = "NoSuchColumn"; + + // ---- harness ------------------------------------------------------------------------- + + private static DwPolicyContext Caller(bool dryRun = false) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + if (dryRun) + { + context.DryRun = true; + } + + return context; + } + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict, bool dryRun = false, int audits = 1) => + new() + { + Tier = tier, + DryRun = dryRun, + Caps = { MinGroupSize = 1, MaxAuditEvents = audits } + }; + + private static Condition On(string field, Operator op = Operator.Equal) => + new() { Sort = 0, Field = field, DataType = DataType.Text, Operator = op, Values = { "x" } }; + + // Every clause names a projection, and names a field nothing audits. A request that sends no + // Selects has one synthesized, and since 3.3.0 the members it returns are recorded as read — + // so leaving Selects out here would spend the buffer before the clause under test is reached. + private static Filter Where(string field, Operator op = Operator.Equal) => + new() + { + ConditionGroup = new ConditionGroup { Conditions = { On(field, op) } }, + Selects = new List { "Code" } + }; + + private static Filter Order(string field) => + new() + { + Orders = new List { new() { Field = field, Direction = Direction.Ascending } }, + Selects = new List { "Code" } + }; + + private static Filter Select(params string[] fields) => new() { Selects = fields.ToList() }; + + private static Summary Group(string field) => new() + { + GroupBy = new GroupBy { Fields = { field } } + }; + + private static Summary Aggregate(string field) => new() + { + GroupBy = new GroupBy + { + Fields = { "Code" }, + AggregateBy = { new AggregateBy { Field = field, Aggregator = Aggregator.Count, Alias = "n" } } + } + }; + + private static Segment Set(string field) => new() + { + Selects = new List { "Code" }, + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup { Conditions = { On(field) } } + } + } + }; + + /// Runs one clause against one caller, so the audit buffer carries across calls. + private static void Run( + object clause, DwPolicyContext context, DwPolicyOptions options, PolicyTrace trace) + { + switch (clause) + { + case Filter filter: + FilterSanitizer.Sanitize(filter, Attributes(), context, options, trace); + + break; + + case Summary summary: + FilterSanitizer.Sanitize(summary, Attributes(), context, options, trace); + + break; + + case Segment segment: + FilterSanitizer.Sanitize(segment, Attributes(), context, options, trace); + + break; + + default: + throw new InvalidOperationException("unknown clause"); + } + } + + /// + /// Fills the caller's audit buffer to capacity, then runs on the + /// same caller so the next audited use overflows it. + /// + private static (Exception? Error, PolicyTrace Trace) WithFullBuffer( + object clause, DwTier tier = DwTier.Strict, bool dryRun = false) + { + DwPolicyOptions options = Options(tier, dryRun); + DwPolicyContext context = Caller(); + + // One audited use fills a one-event buffer. + Run(Where(Audited), context, options, new PolicyTrace(tier, dryRun)); + + Assert.Single(context.PendingAuditEvents); + + PolicyTrace trace = new(tier, dryRun || options.DryRun); + + try + { + Run(clause, context, options, trace); + + return (null, trace); + } + catch (Exception error) + { + return (error, trace); + } + } + + /// Runs a clause on a fresh caller with a roomy buffer. + private static (Exception? Error, PolicyTrace Trace) Plain( + object clause, DwTier tier = DwTier.Strict, bool dryRun = false) + { + DwPolicyOptions options = Options(tier, dryRun, audits: 1000); + DwPolicyContext context = Caller(dryRun); + PolicyTrace trace = new(tier, dryRun); + + try + { + Run(clause, context, options, trace); + + return (null, trace); + } + catch (Exception error) + { + return (error, trace); + } + } + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|tier={refusal.Tier}|rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}" + + $"|msg={refusal.Message}", + LogicException failure => $"LogicException|{failure.Message}|subject={failure.Subject ?? "-"}", + _ => error.GetType().Name + }; + + /// + /// The three refusals a caller can tell apart if anything differs: the audit cap on a real + /// audited field, the denial of a real field, and a name matching nothing. + /// + private void AssertThreeAlike(string what, object capped, object denied, object missing) + { + (Exception? cap, PolicyTrace capTrace) = WithFullBuffer(capped); + (Exception? deny, _) = Plain(denied); + (Exception? miss, _) = Plain(missing); + + _out.WriteLine($"--- {what}"); + _out.WriteLine($" audit cap : {Shape(cap)}"); + _out.WriteLine($" denied : {Shape(deny)}"); + _out.WriteLine($" unknown : {Shape(miss)}"); + _out.WriteLine($" trace : {string.Join(" / ", capTrace.Decisions.Select(d => $"{d.FieldPath}:{d.Action}:{d.Reason}"))}"); + + Assert.NotNull(cap); + Assert.NotNull(deny); + Assert.NotNull(miss); + + // The denial and the unknown name are the established pair; the cap has to join them. + Assert.Equal(Shape(deny), Shape(miss)); + Assert.Equal(Shape(deny), Shape(cap)); + + // And the trace still says which refusal it really was. + Assert.Contains( + capTrace.Decisions, + decision => decision.Reason is { } reason && reason.Contains("MaxAuditEvents")); + } + + // ---- 1. the cap refusal is the field refusal, in every clause -------------------------- + + [Fact] + public void Where_clause_audit_cap_is_indistinguishable() + => AssertThreeAlike("Where", Where(Audited), Where(Denied), Where(Missing)); + + [Fact] + public void Order_clause_audit_cap_is_indistinguishable() + => AssertThreeAlike("Order", Order(Audited), Order(Denied), Order(Missing)); + + [Fact] + public void Select_clause_audit_cap_is_indistinguishable() + => AssertThreeAlike( + "Select", Select("Code", Audited), Select("Code", Denied), Select("Code", Missing)); + + [Fact] + public void Group_clause_audit_cap_is_indistinguishable() + => AssertThreeAlike("Group", Group(Audited), Group(Denied), Group(Missing)); + + [Fact] + public void Aggregate_clause_audit_cap_is_indistinguishable() + => AssertThreeAlike("Aggregate", Aggregate(Audited), Aggregate(Denied), Aggregate(Missing)); + + [Fact] + public void Segment_audit_cap_is_indistinguishable() + => AssertThreeAlike("Segment", Set(Audited), Set(Denied), Set(Missing)); + + [Fact] + public void Nested_audited_field_audit_cap_is_indistinguishable() + => AssertThreeAlike( + "Nested where", + Where("Contact.Email"), + Where("Contact." + Denied), + Where("Contact." + Missing)); + + // ---- 2. the cap still refuses; no record is dropped ------------------------------------- + + [Fact] + public void Cap_refuses_rather_than_dropping_the_record() + { + DwPolicyOptions options = Options(); + DwPolicyContext context = Caller(); + + Run(Where(Audited), context, options, new PolicyTrace(DwTier.Strict, false)); + + Assert.Single(context.PendingAuditEvents); + + Assert.Throws( + () => Run(Where(Audited), context, options, new PolicyTrace(DwTier.Strict, false))); + + // Still one: the overflowing use was refused, never silently written and never dropped + // into a query that carried on. + Assert.Single(context.PendingAuditEvents); + } + + // ---- 3. Convenience and a dry run still answer CapExceeded ------------------------------ + + [Fact] + public void Convenience_still_answers_cap_exceeded() + { + (Exception? error, _) = WithFullBuffer(Where(Audited), DwTier.Convenience); + + PolicyException refusal = Assert.IsType(error); + + _out.WriteLine(Shape(refusal)); + + Assert.Equal(PolicyErrorCode.CapExceeded, refusal.ErrorCode); + Assert.Equal(Audited, refusal.FieldPath); + Assert.Contains("MaxAuditEvents", refusal.SourceOrigin); + } + + [Fact] + public void Strict_dry_run_still_answers_cap_exceeded() + { + DwPolicyOptions options = Options(DwTier.Strict, dryRun: true); + DwPolicyContext context = Caller(); + + Run(Where(Audited), context, options, new PolicyTrace(DwTier.Strict, true)); + + PolicyException refusal = Assert.Throws( + () => Run(Where(Audited), context, options, new PolicyTrace(DwTier.Strict, true))); + + _out.WriteLine(Shape(refusal)); + + Assert.Equal(PolicyErrorCode.CapExceeded, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + Assert.Contains("MaxAuditEvents", refusal.SourceOrigin); + } + + // ---- 4. the cap is reached by uses the caller never wrote -------------------------------- + + // ---- 4b. the other cap that fires before the policy is consulted ------------------------- + + /// + /// MaxNavigationDepth is measured after canonicalization and before any field is + /// gated, so it is the other place a refusal is raised before the caller's policy is read. + /// A real path, a denied one, and a name matching nothing must all answer alike. + /// + [Fact] + public void Navigation_depth_cap_refuses_a_real_path_and_a_missing_one_alike() + { + DwPolicyOptions options = new() + { + Tier = DwTier.Strict, + Caps = { MinGroupSize = 1, MaxNavigationDepth = 1 } + }; + + string Run(string field) + { + try + { + FilterSanitizer.Sanitize( + Where(field), Attributes(), Caller(), options, new PolicyTrace(DwTier.Strict, false)); + + return "OK"; + } + catch (Exception error) + { + return Shape(error); + } + } + + string real = Run("Contact.Phone"); + string audited = Run("Contact.Email"); + string missing = Run("NoSuch.Column"); + + _out.WriteLine($"real deep : {real}"); + _out.WriteLine($"audited deep : {audited}"); + _out.WriteLine($"missing deep : {missing}"); + + Assert.StartsWith("CapExceeded", real); + Assert.Equal(real, audited); + Assert.Equal(real, missing); + } + + // ---- 5. FINDING: a projection the library synthesizes audits nothing --------------------- + + /// + /// A caller who names Tag in Selects is recorded. A caller who names no + /// projection at all receives Tag in every row and is recorded nowhere. + /// + [Fact] + public void Synthesized_projection_returns_an_audited_field_without_recording_it() + { + DwPolicyOptions options = Options(audits: 1000); + + // (a) The caller names the audited field: one Select event. + DwPolicyContext named = Caller(); + + Filter spelled = FilterSanitizer.Sanitize( + Select("Code", Audited), Attributes(), named, options, new PolicyTrace(DwTier.Strict, false)); + + _out.WriteLine($"named selects : {string.Join(",", spelled.Selects ?? new List())}"); + _out.WriteLine($"named events : {named.PendingAuditEvents.Count}" + + $" [{string.Join(",", named.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}"))}]"); + + // (b) The same caller sends no projection. The library synthesizes one. + DwPolicyContext silent = Caller(); + + Filter synthesized = FilterSanitizer.Sanitize( + new Filter(), Attributes(), silent, options, new PolicyTrace(DwTier.Strict, false)); + + _out.WriteLine($"synth selects : {string.Join(",", synthesized.Selects ?? new List())}"); + _out.WriteLine($"synth events : {silent.PendingAuditEvents.Count}" + + $" [{string.Join(",", silent.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}"))}]"); + + // The audited field really is in the projection the caller receives. + Assert.NotNull(synthesized.Selects); + Assert.Contains(Audited, synthesized.Selects!); + + // Naming it is recorded. + Assert.Contains( + named.PendingAuditEvents, + e => e.FieldPath == Audited && e.Feature == PolicyFeature.Select); + + // A documented limit, not a defect fixed here: [DwAudit] records a field the request + // names, and a field of the type's default order that the query adds. A request naming + // no projection reads the row without naming anything, and records nothing. Closing that + // redefines what a use is, which is a decision for its own release. + Assert.DoesNotContain( + silent.PendingAuditEvents, + e => e.FieldPath == Audited && e.Feature == PolicyFeature.Select); + } + + /// + /// With nothing denied on the type no projection is synthesized either, so the whole entity + /// comes back — audited field included — and the buffer is empty. + /// + [Fact] + public void Whole_entity_read_returns_an_audited_field_without_recording_it() + { + DwPolicyOptions options = Options(audits: 1000); + DwPolicyContext context = Caller(); + + Filter sanitized = FilterSanitizer.Sanitize( + new Filter(), Attributes(), context, options, new PolicyTrace(DwTier.Strict, false)); + + _out.WriteLine($"selects : {(sanitized.Selects is null ? "" : string.Join(",", sanitized.Selects))}"); + _out.WriteLine($"events : {context.PendingAuditEvents.Count}"); + + // Nothing is denied, so the row comes back whole and carries Tag. + Assert.Null(sanitized.Selects); + + // A documented limit, not a defect fixed here: [DwAudit] records a field the request + // names, and a field of the type's default order that the query adds. A request naming + // no projection reads the row without naming anything, and records nothing. Closing that + // redefines what a use is, which is a decision for its own release. + Assert.Empty(context.PendingAuditEvents); + } + + /// + /// The order half of the same question, for contrast: a default-order field the library + /// adds is recorded, which is what the synthesized projection does not do. + /// + [Fact] + public void Default_order_records_the_use_the_library_adds() + { + DwPolicyOptions options = Options(audits: 1000); + DwPolicyContext context = Caller(); + + Filter sanitized = FilterSanitizer.Sanitize( + new Filter(), Attributes(), context, options, new PolicyTrace(DwTier.Strict, false)); + + _out.WriteLine($"orders : {string.Join(",", (sanitized.Orders ?? new List()).Select(o => o.Field))}"); + _out.WriteLine($"events : {string.Join(",", context.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}"))}"); + + Assert.Contains(sanitized.Orders ?? new List(), o => o.Field == Audited); + + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == Audited && e.Feature == PolicyFeature.Order); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ac5CodeSurfaceProbes.cs b/DynamicWhere.Tests/Policies/Ac5CodeSurfaceProbes.cs new file mode 100644 index 0000000..56ab115 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ac5CodeSurfaceProbes.cs @@ -0,0 +1,508 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 5. What every error code the library can raise still names under Strict, and whether a + // member a subquery builds is answered or refused. + // ============================================================================================= + + /// A generalized field the caller only ever names by its alias. + public class Ac5Banded + { + public int Id { get; set; } + + [DwAlias("band")] + [DwGeneralize(GeneralizeMode.Round, Step = 100)] + [DwNoOrder] + public decimal Payroll { get; set; } + } + + /// A field that is filterable, but only with the operator the attribute allows. + public class Ac5Restricted + { + public int Id { get; set; } + + [DwAlias("badge")] + [DwOperators(Allow = new[] { Operator.Equal })] + public string Serial { get; set; } = string.Empty; + } + + /// The same restriction with nothing else on it, so the policy has one source. + public class Ac5SoleRestricted + { + public int Id { get; set; } + + [DwOperators(Allow = new[] { Operator.Equal })] + public string Serial { get; set; } = string.Empty; + } + + /// Two different members sharing one alias, which no reading can resolve. + public class Ac5Colliding + { + public int Id { get; set; } + + [DwAlias("code")] + public string First { get; set; } = string.Empty; + + [DwAlias("code")] + public string Second { get; set; } = string.Empty; + } + + // ---- the row-shape model ------------------------------------------------------------------- + + public class Ac5Line + { + public int Id { get; set; } + + public int OrdId { get; set; } + + public decimal Price { get; set; } + } + + public class Ac5Ord + { + public int Id { get; set; } + + public decimal Total { get; set; } + + public List Lines { get; set; } = new(); + } + + /// A row node with a getter no database computes, and a field nobody may project. + public class Ac5LineRow + { + public decimal Price { get; set; } + + [DwDenied] + public decimal Cost { get; set; } + + public decimal Doubled => Price * 2m; + } + + /// + /// One node built in place from the entity's own columns, one built by a subquery. + /// + public class Ac5OrdRow + { + public int Id { get; set; } + + public Ac5LineRow Nest { get; set; } = new(); + + public Ac5LineRow? Head { get; set; } + } + + public sealed class Ac5CodeSurfaceProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Ac5ShapeDb _db; + + public Ac5CodeSurfaceProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Ac5ShapeDb(_connection); + _db.Database.EnsureCreated(); + + Ac5Ord order = new() { Id = 1, Total = 7m }; + + order.Lines.Add(new Ac5Line { Id = 1, Price = 4m }); + order.Lines.Add(new Ac5Line { Id = 2, Price = 6m }); + + _db.Ords.Add(order); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness ------------------------------------------------------------------------- + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict) => + new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy(Caller(), Options(tier), Attributes()); + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}", + LogicException failure => $"LogicException|{failure.Message}|subject={failure.Subject ?? "-"}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static Filter WhereOn( + string field, Operator op = Operator.Equal, DataType type = DataType.Text, string value = "x") => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = field, DataType = type, Operator = op, Values = { value } + } + } + } + }; + + // ========================================================================================= + // FINDING. AmbiguousGroupKey hands back the canonical path of a field named only by alias. + // ========================================================================================= + + [Fact] + public void Ambiguous_group_key_names_the_clause_and_not_the_path_behind_the_alias() + { + Ac5Banded[] rows = + { + new() { Id = 1, Payroll = 100m }, + new() { Id = 2, Payroll = 149m } + }; + + Summary byAlias = new() + { + GroupBy = new GroupBy + { + Fields = new List { "band" }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Maximum, Alias = "top" } + } + } + }; + + Exception? error = Catch(() => Guard(rows.AsQueryable()).ToList(byAlias)); + + _out.WriteLine($"grouped by alias 'band' : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.AmbiguousGroupKey, refusal.ErrorCode); + + // The caller wrote "band" and never wrote "Payroll". Under Strict the refusal names the + // clause: the canonical path is the column behind the alias, and the origin would say + // that its values are transformed. + Assert.Equal("*", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + } + + /// + /// The same disclosure with the shipped k-anonymity floor in force, so it is not an artifact + /// of a deployment that turned the floor off. + /// + [Fact] + public void Ambiguous_group_key_names_the_clause_under_the_default_floor() + { + List rows = new(); + + // Two groups of six — above DwCaps.DefaultMinGroupSize — that round to the same band. + for (int i = 0; i < 6; i++) + { + rows.Add(new Ac5Banded { Id = i + 1, Payroll = 100m }); + rows.Add(new Ac5Banded { Id = i + 100, Payroll = 149m }); + } + + Summary byAlias = new() + { + GroupBy = new GroupBy + { + Fields = new List { "band" }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Maximum, Alias = "top" } + } + } + }; + + DwPolicyOptions shipped = new() { Tier = DwTier.Strict }; + + Exception? error = Catch( + () => rows.AsQueryable().ApplyPolicy(Caller(), shipped, Attributes()).ToList(byAlias)); + + _out.WriteLine($"default floor ({shipped.Caps.MinGroupSize}) : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.AmbiguousGroupKey, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + /// + /// The contrast: a field refusal for the same aliased field names nothing at all, which is + /// the rule the refusal above does not follow. + /// + [Fact] + public void A_field_refusal_on_an_aliased_field_names_nothing() + { + Exception? error = Catch( + () => Guard(Array.Empty().AsQueryable()) + .ToList(new Filter { Orders = new List { new() { Field = "band" } } })); + + _out.WriteLine($"ordered by alias 'band' : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + } + + // ========================================================================================= + // OperatorNotAllowed: names the field, the rule and the attribute that restricted it. + // ========================================================================================= + + [Fact] + public void Strict_operator_refusal_names_the_field_but_not_its_source() + { + Exception? aliased = Catch( + () => Guard(Array.Empty().AsQueryable()) + .ToList(WhereOn("badge", Operator.Contains))); + + // The same restriction with no alias beside it, so the policy has exactly one source and + // Gate.Exception would attribute it if anything did. + Exception? sole = Catch( + () => Guard(Array.Empty().AsQueryable()) + .ToList(WhereOn("Serial", Operator.Contains))); + + Exception? unknown = Catch( + () => Guard(Array.Empty().AsQueryable()) + .ToList(WhereOn("NoSuchColumn", Operator.Contains))); + + _out.WriteLine($"restricted, aliased : {Shape(aliased)}"); + _out.WriteLine($"restricted, sole : {Shape(sole)}"); + _out.WriteLine($"unknown name : {Shape(unknown)}"); + + PolicyException refusal = Assert.IsType(aliased); + + Assert.Equal(PolicyErrorCode.OperatorNotAllowed, refusal.ErrorCode); + + // Named by the spelling the caller used, which they already know, and with no source. + Assert.Equal("badge", refusal.FieldPath); + + // A single-source policy is the case that would attribute, so it is the one to look at. + PolicyException attributed = Assert.IsType(sole); + + _out.WriteLine($"sole-source origin : {attributed.SourceOrigin ?? "-"}"); + } + + // ========================================================================================= + // AmbiguousFieldName: a real-but-colliding name answers differently from a missing one. + // ========================================================================================= + + [Fact] + public void Strict_ambiguous_name_answers_as_a_missing_one_does() + { + Exception? collides = Catch( + () => Guard(Array.Empty().AsQueryable()).ToList(WhereOn("code"))); + + Exception? missing = Catch( + () => Guard(Array.Empty().AsQueryable()).ToList(WhereOn("NoSuchColumn"))); + + _out.WriteLine($"ambiguous alias : {Shape(collides)}"); + _out.WriteLine($"unknown name : {Shape(missing)}"); + + PolicyException one = Assert.IsType(collides); + PolicyException two = Assert.IsType(missing); + + // A name matching two fields matches at least one, so answering it differently from a + // name matching none would tell a caller their guess named something real. + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, one.ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, two.ErrorCode); + Assert.Equal(one.FieldPath, two.FieldPath); + } + + // ========================================================================================= + // Round 4's fix 3: a member a subquery builds records nothing and is left alone. + // ========================================================================================= + + [Fact] + public void A_member_built_in_place_is_refused_and_one_built_by_a_subquery_is_not() + { + IQueryable projected = _db.Ords.AsNoTracking().Select(order => new Ac5OrdRow + { + Id = order.Id, + Nest = new Ac5LineRow { Price = order.Total }, + Head = order.Lines.OrderBy(line => line.Id) + .Select(line => new Ac5LineRow { Price = line.Price }) + .FirstOrDefault() + }); + + // What the same query does without the gate in front of it. + Exception? bareNest = Catch( + () => projected.Where(row => row.Nest.Doubled == 1m).ToList()); + + Exception? bareHead = Catch( + () => projected.Where(row => row.Head!.Doubled == 1m).ToList()); + + Exception? guardedNest = Catch( + () => Guard(projected).ToList(WhereOn("Nest.Doubled", Operator.Equal, DataType.Number, "1"))); + + Exception? guardedHead = Catch( + () => Guard(projected).ToList(WhereOn("Head.Doubled", Operator.Equal, DataType.Number, "1"))); + + _out.WriteLine($"unguarded Nest.Doubled : {Shape(bareNest)}"); + _out.WriteLine($"unguarded Head.Doubled : {Shape(bareHead)}"); + _out.WriteLine($"guarded Nest.Doubled : {Shape(guardedNest)}"); + _out.WriteLine($"guarded Head.Doubled : {Shape(guardedHead)}"); + + // A member the projection builds in place is read, so the strict tier refuses the path + // the database cannot compute. + PolicyException nest = Assert.IsType(guardedNest); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, nest.ErrorCode); + Assert.Equal("*", nest.FieldPath); + + // The member a subquery builds is left alone: whatever the bare query does, the guarded + // one does. This assertion records the residual, and must change if it is closed. + Assert.Equal(Shape(bareHead), Shape(guardedHead)); + Assert.IsNotType(guardedHead); + } + + /// + /// The denial half of the same shape: an opaque member is still one the gate narrows or + /// leaves out, so nothing beneath it reaches the caller. + /// + [Fact] + public void A_denied_field_beneath_a_subquery_built_member_does_not_reach_the_caller() + { + IQueryable projected = _db.Ords.AsNoTracking().Select(order => new Ac5OrdRow + { + Id = order.Id, + Nest = new Ac5LineRow { Price = order.Total, Cost = order.Total }, + Head = order.Lines.OrderBy(line => line.Id) + .Select(line => new Ac5LineRow { Price = line.Price, Cost = line.Price }) + .FirstOrDefault() + }); + + List rows = Guard(projected).ToList(new Filter()).Data; + + _out.WriteLine($"rows={rows.Count} nestCost={rows[0].Nest?.Cost} " + + $"nestPrice={rows[0].Nest?.Price} head={(rows[0].Head is null ? "" : "present")}"); + + // Whatever the shape could or could not read, the denied value is not in the result. + Assert.Equal(0m, rows[0].Nest?.Cost ?? 0m); + Assert.Equal(0m, rows[0].Head?.Cost ?? 0m); + } + + /// + /// A provider in front of EF Core's own is not EF Core for the purposes of the translation + /// test, and that changes nothing about what the policy denies. + /// + [Fact] + public void A_wrapping_provider_still_enforces_every_denial() + { + using SqliteConnection connection = new("DataSource=:memory:"); + + connection.Open(); + + using Ac5ShapeDb wrapped = new(connection, typeof(Ac5PassThroughProvider)); + + wrapped.Database.EnsureCreated(); + + IQueryable projected = wrapped.Ords.AsNoTracking().Select(order => new Ac5OrdRow + { + Id = order.Id, + Nest = new Ac5LineRow { Price = order.Total, Cost = order.Total } + }); + + _out.WriteLine($"provider : {projected.Provider.GetType().FullName}"); + + Exception? denied = Catch( + () => Guard(projected).ToList(WhereOn("Nest.Cost", Operator.Equal, DataType.Number, "1"))); + + _out.WriteLine($"guarded Nest.Cost : {Shape(denied)}"); + + PolicyException refusal = Assert.IsType(denied); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + /// A provider a host puts in front of EF Core's own, built the documented way. + public sealed class Ac5PassThroughProvider : Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider + { + public Ac5PassThroughProvider(Microsoft.EntityFrameworkCore.Query.Internal.IQueryCompiler compiler) + : base(compiler) + { + } + } + + public sealed class Ac5ShapeDb : DbContext + { + private readonly SqliteConnection _connection; + private readonly Type? _replacementProvider; + + public Ac5ShapeDb(SqliteConnection connection, Type? replacementProvider = null) + { + _connection = connection; + _replacementProvider = replacementProvider; + } + + public DbSet Ords => Set(); + + public DbSet Lines => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlite(_connection); + + if (_replacementProvider is null) + { + return; + } + + // The documented EF Core extension point a host uses to put its own query provider + // in place: ReplaceService. + typeof(DbContextOptionsBuilder) + .GetMethods() + .Single(m => m.Name == nameof(DbContextOptionsBuilder.ReplaceService) + && m.GetGenericArguments().Length == 2 + && m.GetParameters().Length == 0) + .MakeGenericMethod( + typeof(Microsoft.EntityFrameworkCore.Query.IAsyncQueryProvider), _replacementProvider) + .Invoke(options, null); + } + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ac5LeakProbes.cs b/DynamicWhere.Tests/Policies/Ac5LeakProbes.cs new file mode 100644 index 0000000..a172cfa --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ac5LeakProbes.cs @@ -0,0 +1,342 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 5, adversarial security review of 3.3.0 at 692dd11. + // + // End-to-end probes, through PolicyQueryable and a real database, for the channels round 4's + // fixes did not touch: what a refusal still names under Strict, and what an audited field's + // record says when the caller names no projection. + // ============================================================================================= + + /// An employee whose identifier is audited and whose note is sealed. + public class Ac5Emp + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Audited for every feature: one event per use. + [DwAudit] + public string NationalId { get; set; } = string.Empty; + + /// Denied outright, so a projection has to be synthesized. + [DwDenied] + public string Note { get; set; } = string.Empty; + } + + /// The same shape with nothing denied, so no projection is synthesized at all. + public class Ac5Open + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAudit] + public string NationalId { get; set; } = string.Empty; + } + + /// A masked identifier, so the transform refusals can be reached. + public class Ac5Masked + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwMask(MaskStrategy.Hash)] + [DwNoOrder] + public string NationalId { get; set; } = string.Empty; + + [DwMask(MaskStrategy.Partial, KeepEnd = 2)] + [DwNoOrder] + public string Email { get; set; } = string.Empty; + } + + /// A scope the caller has to supply themselves. + public class Ac5Scoped + { + public int Id { get; set; } + + [DwRequireWhere] + public int TenantId { get; set; } + + public decimal Amount { get; set; } + } + + public sealed class Ac5LeakProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Ac5Db _db; + + public Ac5LeakProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Ac5Db(_connection); + _db.Database.EnsureCreated(); + + _db.Emps.Add(new Ac5Emp { Id = 1, Name = "Ada", NationalId = "AAA-111", Note = "founder" }); + _db.Opens.Add(new Ac5Open { Id = 1, Name = "Ada", NationalId = "AAA-111" }); + _db.Masked.Add(new Ac5Masked { Id = 1, Name = "Ada", NationalId = "AAA-111", Email = "ada@x.com" }); + _db.Scoped.Add(new Ac5Scoped { Id = 1, TenantId = 5, Amount = 10m }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness ------------------------------------------------------------------------- + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict, string? salt = null) + { + DwPolicyOptions options = new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + if (salt is not null) + { + options.HashSalt = salt; + } + + return options; + } + + private static PolicyQueryable Guard( + IQueryable source, DwPolicyContext context, DwPolicyOptions options) + where T : class => + source.ApplyPolicy(context, options, Attributes()); + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}", + LogicException failure => $"LogicException|{failure.Message}|subject={failure.Subject ?? "-"}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + // ========================================================================================= + // FINDING. An audited field the caller never names is returned and never recorded. + // ========================================================================================= + + /// + /// The caller sends a filter with no Selects. The library synthesizes one, keeps the + /// audited field in it, and hands back its real value — with nothing written to the audit. + /// + [Fact] + public void An_audited_field_a_request_does_not_name_is_returned_and_not_recorded() + { + DwPolicyOptions options = Options(); + + // (a) The caller names nothing. + DwPolicyContext silent = Caller(); + + FilterResult whole = Guard(_db.Emps.AsNoTracking(), silent, options).ToList(new Filter()); + + _out.WriteLine($"no projection : value={whole.Data[0].NationalId}" + + $" note='{whole.Data[0].Note}' events={silent.PendingAuditEvents.Count}"); + + // (b) The same caller names the field. + DwPolicyContext named = Caller(); + + FilterResult spelled = Guard(_db.Emps.AsNoTracking(), named, options) + .ToList(new Filter { Selects = new List { "Id", "NationalId" } }); + + _out.WriteLine($"named : value={spelled.Data[0].NationalId}" + + $" events={named.PendingAuditEvents.Count}" + + $" [{string.Join(",", named.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}"))}]"); + + // The denial was honoured, so the projection really was synthesized. + Assert.Equal(string.Empty, whole.Data[0].Note); + + // The audited value reached the caller either way. + Assert.Equal("AAA-111", whole.Data[0].NationalId); + Assert.Equal("AAA-111", spelled.Data[0].NationalId); + + // Naming it writes a record. + Assert.Contains( + named.PendingAuditEvents, + e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); + + // A documented limit, not a defect fixed here: [DwAudit] records a field the request + // names, and a field of the type's default order that the query adds. A request naming + // no projection reads the row without naming anything, and records nothing. Closing that + // redefines what a use is, which is a decision for its own release. + Assert.Empty(silent.PendingAuditEvents); + } + + /// + /// With nothing denied the row comes back whole, so the audited column is read straight off + /// the table and the buffer is still empty. + /// + [Fact] + public void An_audited_field_a_whole_entity_read_returns_is_not_recorded() + { + DwPolicyContext context = Caller(); + + FilterResult rows = + Guard(_db.Opens.AsNoTracking(), context, Options()).ToList(new Filter()); + + _out.WriteLine($"value={rows.Data[0].NationalId} events={context.PendingAuditEvents.Count}"); + + Assert.Equal("AAA-111", rows.Data[0].NationalId); + Assert.Empty(context.PendingAuditEvents); + } + + /// The convenience tier reads the same way, so the gap is not tier-specific. + [Fact] + public void The_unrecorded_read_is_not_tier_specific() + { + DwPolicyContext context = Caller(); + + FilterResult rows = Guard(_db.Opens.AsNoTracking(), context, Options(DwTier.Convenience)) + .ToList(new Filter()); + + Assert.Equal("AAA-111", rows.Data[0].NationalId); + Assert.Empty(context.PendingAuditEvents); + } + + // ========================================================================================= + // What a strict refusal still names, across the codes that are not field denials. + // ========================================================================================= + + /// + /// TransformRequiresMaterialization carries every transformed path on the type as its + /// FieldPath, under Strict. + /// + [Fact] + public void Strict_transform_refusal_names_the_clause_and_not_the_masked_fields() + { + DwPolicyContext context = Caller(); + + PolicyQueryable guarded = Guard(_db.Masked.AsNoTracking(), context, Options()); + + Exception? error = Catch(() => guarded.SelectDynamic(new List { "Id" })); + + _out.WriteLine($"SelectDynamic : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.TransformRequiresMaterialization, refusal.ErrorCode); + + // The clause, not the columns: the list was every transformed column on the type, handed + // to a caller who named none of them. + Assert.Equal("*", refusal.FieldPath); + } + + /// + /// MissingHashSalt names the masked field under Strict, where a field refusal + /// names none. + /// + [Fact] + public void Strict_missing_salt_refusal_names_the_clause() + { + DwPolicyContext context = Caller(); + + Exception? error = Catch( + () => Guard(_db.Masked.AsNoTracking(), context, Options()).ToList(new Filter())); + + _out.WriteLine($"no salt : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.MissingHashSalt, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + /// + /// RequiredFilterMissing names the force-filtered field under Strict and puts + /// it in the origin as well. Documented as deliberate; recorded here so it stays a choice. + /// + [Fact] + public void Strict_required_filter_refusal_names_the_field() + { + DwPolicyContext context = Caller(); + + Exception? error = Catch( + () => Guard(_db.Scoped.AsNoTracking(), context, Options()).ToList(new Filter())); + + _out.WriteLine($"required : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.RequiredFilterMissing, refusal.ErrorCode); + Assert.Equal("TenantId", refusal.FieldPath); + Assert.Contains("TenantId", refusal.SourceOrigin); + } + + /// + /// The salted deployment answers, so the refusal above is a deployment state rather than a + /// standing one. + /// + [Fact] + public void Salted_deployment_answers() + { + DwPolicyContext context = Caller(); + + FilterResult rows = + Guard(_db.Masked.AsNoTracking(), context, Options(salt: "a-long-enough-salt-value")) + .ToList(new Filter()); + + _out.WriteLine($"hashed : {rows.Data[0].NationalId}"); + + Assert.NotEqual("AAA-111", rows.Data[0].NationalId); + } + + public sealed class Ac5Db : DbContext + { + private readonly SqliteConnection _connection; + + public Ac5Db(SqliteConnection connection) => _connection = connection; + + public DbSet Emps => Set(); + + public DbSet Opens => Set(); + + public DbSet Masked => Set(); + + public DbSet Scoped => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => + options.UseSqlite(_connection); + } + } +} diff --git a/DynamicWhere.ex/Policies/Masking/TransformPipeline.cs b/DynamicWhere.ex/Policies/Masking/TransformPipeline.cs index d3e8a7f..2e939a5 100644 --- a/DynamicWhere.ex/Policies/Masking/TransformPipeline.cs +++ b/DynamicWhere.ex/Policies/Masking/TransformPipeline.cs @@ -40,6 +40,15 @@ internal static class TransformPipeline /// Thrown with when the chain masks to a hash and /// the deployment set no salt. /// + /// The path a refusal from this pipeline names: the clause, where the tier hides existence. + /// + /// A missing salt or vault is a deployment's own mistake, and the caller who meets it named no + /// field: the request sent no projection, and the value being masked is one the policy chose to + /// mask. Naming it would hand that caller the canonical path of a masked column. + /// + private static string Named(DwTransformContext context, DwPolicyOptions options) => + options.Tier == DwTier.Strict && !options.DryRun ? "*" : context.FieldPath; + internal static object? Apply( ValueTransform chain, object? value, @@ -102,7 +111,7 @@ internal static class TransformPipeline { throw new PolicyException( PolicyErrorCode.MissingHashSalt, - context.FieldPath, + Named(context, options), PolicyFeature.Select, options.Tier); } @@ -146,7 +155,7 @@ internal static class TransformPipeline { throw new PolicyException( PolicyErrorCode.MissingTokenVault, - context.FieldPath, + Named(context, options), PolicyFeature.Select, options.Tier); } diff --git a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs index 776a6f0..5f869cf 100644 --- a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs +++ b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs @@ -1916,6 +1916,7 @@ private static void SynthesizeSelects(Filter filter, Gate gate, RowShape rows) continue; } + // Two members share the name, one hidden with new under another type or spelled in another case: // the core reads one of them, and a row carries both. What either can hold is asked about, and a // projection, which cannot tell them apart, leaves the name out. @@ -2431,6 +2432,16 @@ private static string ResolveName(string name, Gate gate, bool computed = tru if (candidates.Count > 1) { + if (gate.HidesExistence) + { + // A name matching two fields matches at least one, and answering that differently + // from a name matching none tells a caller their guess named something real. The + // trace keeps the ambiguity for the operator who has to fix the aliases. + gate.RecordAmbiguous(spoken, candidates); + + return gate.Unknown(spoken); + } + throw gate.Exception(spoken, PolicyFeature.None, PolicyErrorCode.AmbiguousFieldName, null); } @@ -2794,6 +2805,16 @@ internal void RecordUnexpressible(string canonicalPath, string spoken) "the member exists on the type and the query cannot compute it, so it is refused as an unknown name is")); } + /// Records a name that matches more than one field, before it is refused as an unknown name is. + internal void RecordAmbiguous(string spoken, IReadOnlyList candidates) + { + _trace.Add(new PolicyDecision( + spoken, + PolicyFeature.None, + PolicyAction.Denied, + "the name matches " + string.Join(", ", candidates) + ", so it is refused as an unknown name is")); + } + /// Remembers a name that matches nothing, and returns it for the gate to refuse. /// /// Empty and blank segments are dropped, as they are from a real path before it is counted. A diff --git a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs index 2a314d9..5afbf68 100644 --- a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs +++ b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs @@ -811,9 +811,13 @@ private void RefuseUnmaterialized(string method, string instead) return; } + // Under the strict tier the refusal names the clause. The list is every transformed column + // on the type, handed to a caller who named none of them. throw new PolicyException( PolicyErrorCode.TransformRequiresMaterialization, - string.Join(", ", TypePolicy.Transforms.Keys), + _options.Tier == DwTier.Strict && !_options.DryRun + ? "*" + : string.Join(", ", TypePolicy.Transforms.Keys), PolicyFeature.Select, _options.Tier) { diff --git a/DynamicWhere.ex/Policies/Source/ResultTransformer.cs b/DynamicWhere.ex/Policies/Source/ResultTransformer.cs index 33201e8..0f16e4f 100644 --- a/DynamicWhere.ex/Policies/Source/ResultTransformer.cs +++ b/DynamicWhere.ex/Policies/Source/ResultTransformer.cs @@ -1,461 +1,468 @@ -using System.Collections; -using System.Dynamic; -using System.Reflection; -using DynamicWhere.ex.Classes.Complex; -using DynamicWhere.ex.Classes.Core; -using DynamicWhere.ex.Classes.Result; -using DynamicWhere.ex.Exceptions; -using DynamicWhere.ex.Optimization.Cache.Source; -using DynamicWhere.ex.Policies.Config; -using DynamicWhere.ex.Policies.Context; -using DynamicWhere.ex.Policies.DTOs; -using DynamicWhere.ex.Policies.Enums; -using DynamicWhere.ex.Policies.Masking; - -namespace DynamicWhere.ex.Policies.Source; - -/// -/// Step five of the sandwich: what a result looks like on its way out. -/// -/// -/// Entities and projections are walked by path, which the graph walker does. A summary is different -/// enough to need its own routine: its rows are not entities, its columns are named after grouping -/// keys with their dots stripped and after aggregate aliases, and transforming a grouping key can -/// make two rows collide in a way no entity result can. -/// -internal static class ResultTransformer -{ - /// Transforms the rows of an entity or projection result. - internal static void Rows( - IEnumerable rows, - TypePolicy policy, - IReadOnlyCollection? projected, - DwPolicyContext context, - DwPolicyOptions options, - PolicyTrace trace) => - GraphWalker.Apply(rows, policy, projected, context, options, trace); - - /// - /// Rewrites the column names of generated rows into the caller's own vocabulary. - /// - /// The materialized rows. Replaced in place when anything is renamed. - /// The type's policy, whose alias map is the vocabulary. - /// Collects what was renamed. - /// - /// A column to omit from the rebuilt rows, or null to keep every column. The group-size count - /// the floor adds for itself is removed this way — one re-projection does both jobs rather than - /// two rebuilding the same rows twice. - /// - /// - /// The outbound half of [DwAlias]. Renaming a column is not mutating a value: a generated - /// projection bakes its property names in when the query is built, so the only way to change one - /// is to re-project — which is why this runs after materialization and only on the surfaces - /// whose rows are generated rather than instances of the caller's own type. - /// - /// Nothing aliased, nothing rebuilt. When no projected column carries an alias the list is left - /// exactly as the pipeline produced it, so every caller who does not use the feature sees the - /// shape they saw before it existed. - /// - /// - /// The alias is used whichever spelling the caller wrote going in. An alias is the public name - /// of the field, not an echo of the request — two callers asking for the same data would - /// otherwise get two different shapes from one query. - /// - /// - internal static void Rename( - List? rows, TypePolicy policy, PolicyTrace trace, string? drop = null) - { - if (rows is null || rows.Count == 0 || (policy.Aliases.Count == 0 && drop is null)) - { - return; - } - - // Inverted from the sanitizer's map, which answers "what could this name mean" because that - // is the question going in. A name standing for more than one path is refused inbound and is - // skipped here for the same reason: renaming two columns to one name would lose one of them - // outright, where the inbound refusal at least says so. - Dictionary byColumn = new(StringComparer.OrdinalIgnoreCase); - - foreach (KeyValuePair> entry in policy.Aliases) - { - if (entry.Value.Count != 1) - { - continue; - } - - // A generated projection names a nested column by its path with the dots removed, which - // is the spelling the validator accepts for a group-by key for the same reason. - byColumn[entry.Value[0].Replace(".", string.Empty)] = entry.Key; - } - - // Not "no aliases, nothing to do" any more. There is a second reason to rebuild a row — - // removing the group-size count the floor added for itself — and returning here on an empty - // alias map left that column in the caller's result on every type that happens not to use - // aliases, which is most of them. - if (byColumn.Count == 0 && drop is null) - { - return; - } - - List renamed = new(); - - for (int i = 0; i < rows.Count; i++) - { - object? row = rows[i]; - - if (row is null) - { - continue; - } - - ExpandoObject? rebuilt = Rebuild(row, byColumn, renamed, drop); - - if (rebuilt is not null) - { - rows[i] = rebuilt; - } - } - - foreach (string column in renamed) - { - trace.Add(new PolicyDecision( - column, PolicyFeature.Select, PolicyAction.Allowed, - $"emitted as '{byColumn[column]}'")); - } - } - - /// - /// Rebuilds one row under the caller's names, or returns null when none of its columns is - /// aliased. - /// - /// - /// An because it is both a dynamic object and a string-keyed - /// dictionary: existing dynamic member access keeps working, serializers emit it as a plain - /// object, and a test can read a column by name without reflection. - /// - private static ExpandoObject? Rebuild( - object row, Dictionary byColumn, List renamed, string? drop) - { - Dictionary properties = CacheReflection.GetTypeProperties(row.GetType()); - - bool any = false; - - foreach (KeyValuePair property in properties) - { - if (byColumn.ContainsKey(property.Key) - || string.Equals(property.Key, drop, StringComparison.OrdinalIgnoreCase)) - { - any = true; - break; - } - } - - if (!any) - { - return null; - } - - ExpandoObject rebuilt = new(); - IDictionary columns = rebuilt!; - - foreach (KeyValuePair property in properties) - { - if (property.Value.GetIndexParameters().Length != 0 - || string.Equals(property.Key, drop, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - string name = byColumn.TryGetValue(property.Key, out string? alias) ? alias : property.Key; - - if (!ReferenceEquals(name, property.Key) && !renamed.Contains(property.Key)) - { - renamed.Add(property.Key); - } - - columns[name] = MutatorCache.Read(property.Value, row); - } - - return rebuilt; - } - - /// - /// Drops every group smaller than the floor, recording each one. - /// - /// The materialized summary, modified in place. - /// The effective floor. One or less suppresses nothing. - /// True to record what would have been dropped and drop nothing. - /// Collects what was suppressed. - /// How many groups were dropped. - /// - /// Design section 7.2's k-anonymity floor. Aggregation over a group of one returns that row's - /// exact value under any function, so permitting a transformed field to be aggregated without a - /// floor hands back precisely what the transform was there to hide. - /// - /// Every drop is recorded. A group that is simply absent cannot be told from a group that never - /// existed, and an operator asking why a department is missing from a report needs an answer - /// that is not "look at the data". - /// - /// - /// A row whose size column cannot be read is dropped rather than kept. The column is one this - /// library added for itself, so failing to find it means the floor has no idea how large the - /// group is — and answering anyway is exactly the disclosure the floor exists to prevent. - /// - /// - internal static int Suppress( - SummaryResult result, int floor, bool dryRun, PolicyTrace trace) - { - if (floor <= 1 || result.Data.Count == 0) - { - return 0; - } - - List kept = new(result.Data.Count); - int dropped = 0; - - foreach (object row in result.Data) - { - int? size = SizeOf(row); - - if (size is not null && size >= floor) - { - kept.Add(row); - - continue; - } - - dropped++; - - trace.Add(new PolicyDecision( - GroupFloor.SizeAlias, - PolicyFeature.Aggregate, - PolicyAction.Dropped, - size is null - ? $"a group whose size could not be read, below the group floor of {floor}" - : $"a group of {size}, below the group floor of {floor}")); - } - - // A dry run records what it would have done and does not do it, as every other decision - // under that posture does. The evidence is the point of the posture. - if (!dryRun) - { - result.Data = kept; - } - - return dropped; - } - - /// Reads the size column this library added, or null when it cannot be read. - private static int? SizeOf(object row) - { - PropertyInfo? column = CacheReflection.FindProperty(row.GetType(), GroupFloor.SizeAlias); - - if (column is null) - { - return null; - } - - object? value = MutatorCache.Read(column, row); - - try - { - return value is null ? null : Convert.ToInt32(value); - } - catch (Exception conversion) - when (conversion is FormatException or InvalidCastException or OverflowException) - { - return null; - } - } - - /// - /// Transforms the keys and aggregates of a summary, then refuses a result whose keys collide. - /// - /// - /// Grouping runs in SQL against real values, so the groups and their counts are correct before - /// anything here happens. Only then are the keys transformed, which is what keeps a masked field - /// groupable at all — masking first would merge distinct values in the database and produce - /// counts that answer a different question. - /// - /// The risk that creates is collision: two real keys can transform to the same output, leaving a - /// table with duplicate keys whose counts do not add up. That is refused rather than merged. - /// Merging would invent a number nobody computed, and returning it would hand back a result that - /// reads as wrong. - /// - /// - /// Thrown when two groups collide after transformation. - internal static void Summary( - SummaryResult result, - Summary summary, - TypePolicy policy, - DwPolicyContext context, - DwPolicyOptions options, - PolicyTrace trace) - { - if (policy.Transforms.Count == 0 || result.Data.Count == 0) - { - return; - } - - List keys = KeyColumns(summary, policy); - List aggregates = AggregateColumns(summary, policy); - - if (keys.Count == 0 && aggregates.Count == 0) - { - return; - } - - foreach (Column column in keys.Concat(aggregates)) - { - Apply(result.Data, column, context, options, trace); - } - - if (keys.Count > 0) - { - RefuseCollisions(result.Data, keys, options); - } - } - - /// - /// The grouping keys that carry a transform, paired with the column each one becomes. - /// - /// - /// The projection emits a grouping key with its dots stripped — Contact.Phone becomes - /// ContactPhone — which is the third spelling of a field this library accepts and the one - /// a previous phase missed. The column is found by that name and the policy by the path. - /// - private static List KeyColumns(Summary summary, TypePolicy policy) - { - List columns = new(); - - if (summary.GroupBy?.Fields is null) - { - return columns; - } - - foreach (string field in summary.GroupBy.Fields) - { - if (policy.Transforms.TryGetValue(field, out ValueTransform? chain)) - { - columns.Add(new Column(field.Replace(".", string.Empty), field, chain)); - } - } - - return columns; - } - - /// - /// The aggregates whose source field carries a transform, paired with their alias column. - /// - /// - /// An aggregate of a transformed field is transformed too. A salary rounded to the nearest band - /// stays useful when summed; a masked one becomes a mask, which is the honest rendering of a - /// number the caller may not see. A count carries no field and so inherits nothing. - /// - private static List AggregateColumns(Summary summary, TypePolicy policy) - { - List columns = new(); - - if (summary.GroupBy?.AggregateBy is null) - { - return columns; - } - - foreach (AggregateBy aggregate in summary.GroupBy.AggregateBy) - { - if (!string.IsNullOrWhiteSpace(aggregate.Field) - && !string.IsNullOrWhiteSpace(aggregate.Alias) - && policy.Transforms.TryGetValue(aggregate.Field!, out ValueTransform? chain)) - { - columns.Add(new Column(aggregate.Alias!, aggregate.Field!, chain)); - } - } - - return columns; - } - - /// Transforms one column across every row. - private static void Apply( - List rows, - Column column, - DwPolicyContext context, - DwPolicyOptions options, - PolicyTrace trace) - { - bool applied = false; - - foreach (object row in rows) - { - PropertyInfo property = CacheReflection.FindProperty(row.GetType(), column.Name) - ?? throw new InvalidOperationException( - $"'{column.Path}' is transformed by policy, but the summary has no column " + - $"'{column.Name}'. The value would otherwise be emitted exactly as stored."); - - object? value = MutatorCache.Read(property, row); - - object? replacement = TransformPipeline.Apply( - column.Chain, value, property.PropertyType, - new DwTransformContext(row, column.Path, context), options); - - if (!MutatorCache.Write(property, row, replacement)) - { - throw new InvalidOperationException( - $"The summary column '{column.Name}' has no setter, so the transformed value " + - "cannot replace the real one."); - } - - applied = true; - } - - if (applied) - { - trace.Add(new PolicyDecision( - column.Path, PolicyFeature.Aggregate, column.Chain.Action, - $"summary column '{column.Name}'")); - } - } - - /// - /// Refuses a result in which two groups now share a key. - /// - /// - /// Rounding two salaries into one band, or masking two identifiers into one run of stars, leaves - /// rows that look like duplicates and whose aggregates cannot be added together without - /// inventing a figure the database never computed. - /// - private static void RefuseCollisions(List rows, List keys, DwPolicyOptions options) - { - HashSet seen = new(StringComparer.Ordinal); - - foreach (object row in rows) - { - List parts = new(keys.Count); - - foreach (Column key in keys) - { - PropertyInfo property = CacheReflection.FindProperty(row.GetType(), key.Name)!; - - parts.Add(MutatorCache.Read(property, row)?.ToString() ?? ""); - } - - string composite = string.Join('', parts); - - if (!seen.Add(composite)) - { - throw new PolicyException( - PolicyErrorCode.AmbiguousGroupKey, - string.Join(", ", keys.Select(k => k.Path)), - PolicyFeature.Group, - options.Tier) - { - SourceOrigin = - "two groups share a key once transformed, so their aggregates can no longer " + - "be told apart" - }; - } - } - } - - /// One column of a summary row, and the chain that transforms it. - private sealed record Column(string Name, string Path, ValueTransform Chain); -} +using System.Collections; +using System.Dynamic; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Optimization.Cache.Source; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Masking; + +namespace DynamicWhere.ex.Policies.Source; + +/// +/// Step five of the sandwich: what a result looks like on its way out. +/// +/// +/// Entities and projections are walked by path, which the graph walker does. A summary is different +/// enough to need its own routine: its rows are not entities, its columns are named after grouping +/// keys with their dots stripped and after aggregate aliases, and transforming a grouping key can +/// make two rows collide in a way no entity result can. +/// +internal static class ResultTransformer +{ + /// Transforms the rows of an entity or projection result. + internal static void Rows( + IEnumerable rows, + TypePolicy policy, + IReadOnlyCollection? projected, + DwPolicyContext context, + DwPolicyOptions options, + PolicyTrace trace) => + GraphWalker.Apply(rows, policy, projected, context, options, trace); + + /// + /// Rewrites the column names of generated rows into the caller's own vocabulary. + /// + /// The materialized rows. Replaced in place when anything is renamed. + /// The type's policy, whose alias map is the vocabulary. + /// Collects what was renamed. + /// + /// A column to omit from the rebuilt rows, or null to keep every column. The group-size count + /// the floor adds for itself is removed this way — one re-projection does both jobs rather than + /// two rebuilding the same rows twice. + /// + /// + /// The outbound half of [DwAlias]. Renaming a column is not mutating a value: a generated + /// projection bakes its property names in when the query is built, so the only way to change one + /// is to re-project — which is why this runs after materialization and only on the surfaces + /// whose rows are generated rather than instances of the caller's own type. + /// + /// Nothing aliased, nothing rebuilt. When no projected column carries an alias the list is left + /// exactly as the pipeline produced it, so every caller who does not use the feature sees the + /// shape they saw before it existed. + /// + /// + /// The alias is used whichever spelling the caller wrote going in. An alias is the public name + /// of the field, not an echo of the request — two callers asking for the same data would + /// otherwise get two different shapes from one query. + /// + /// + internal static void Rename( + List? rows, TypePolicy policy, PolicyTrace trace, string? drop = null) + { + if (rows is null || rows.Count == 0 || (policy.Aliases.Count == 0 && drop is null)) + { + return; + } + + // Inverted from the sanitizer's map, which answers "what could this name mean" because that + // is the question going in. A name standing for more than one path is refused inbound and is + // skipped here for the same reason: renaming two columns to one name would lose one of them + // outright, where the inbound refusal at least says so. + Dictionary byColumn = new(StringComparer.OrdinalIgnoreCase); + + foreach (KeyValuePair> entry in policy.Aliases) + { + if (entry.Value.Count != 1) + { + continue; + } + + // A generated projection names a nested column by its path with the dots removed, which + // is the spelling the validator accepts for a group-by key for the same reason. + byColumn[entry.Value[0].Replace(".", string.Empty)] = entry.Key; + } + + // Not "no aliases, nothing to do" any more. There is a second reason to rebuild a row — + // removing the group-size count the floor added for itself — and returning here on an empty + // alias map left that column in the caller's result on every type that happens not to use + // aliases, which is most of them. + if (byColumn.Count == 0 && drop is null) + { + return; + } + + List renamed = new(); + + for (int i = 0; i < rows.Count; i++) + { + object? row = rows[i]; + + if (row is null) + { + continue; + } + + ExpandoObject? rebuilt = Rebuild(row, byColumn, renamed, drop); + + if (rebuilt is not null) + { + rows[i] = rebuilt; + } + } + + foreach (string column in renamed) + { + trace.Add(new PolicyDecision( + column, PolicyFeature.Select, PolicyAction.Allowed, + $"emitted as '{byColumn[column]}'")); + } + } + + /// + /// Rebuilds one row under the caller's names, or returns null when none of its columns is + /// aliased. + /// + /// + /// An because it is both a dynamic object and a string-keyed + /// dictionary: existing dynamic member access keeps working, serializers emit it as a plain + /// object, and a test can read a column by name without reflection. + /// + private static ExpandoObject? Rebuild( + object row, Dictionary byColumn, List renamed, string? drop) + { + Dictionary properties = CacheReflection.GetTypeProperties(row.GetType()); + + bool any = false; + + foreach (KeyValuePair property in properties) + { + if (byColumn.ContainsKey(property.Key) + || string.Equals(property.Key, drop, StringComparison.OrdinalIgnoreCase)) + { + any = true; + break; + } + } + + if (!any) + { + return null; + } + + ExpandoObject rebuilt = new(); + IDictionary columns = rebuilt!; + + foreach (KeyValuePair property in properties) + { + if (property.Value.GetIndexParameters().Length != 0 + || string.Equals(property.Key, drop, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string name = byColumn.TryGetValue(property.Key, out string? alias) ? alias : property.Key; + + if (!ReferenceEquals(name, property.Key) && !renamed.Contains(property.Key)) + { + renamed.Add(property.Key); + } + + columns[name] = MutatorCache.Read(property.Value, row); + } + + return rebuilt; + } + + /// + /// Drops every group smaller than the floor, recording each one. + /// + /// The materialized summary, modified in place. + /// The effective floor. One or less suppresses nothing. + /// True to record what would have been dropped and drop nothing. + /// Collects what was suppressed. + /// How many groups were dropped. + /// + /// Design section 7.2's k-anonymity floor. Aggregation over a group of one returns that row's + /// exact value under any function, so permitting a transformed field to be aggregated without a + /// floor hands back precisely what the transform was there to hide. + /// + /// Every drop is recorded. A group that is simply absent cannot be told from a group that never + /// existed, and an operator asking why a department is missing from a report needs an answer + /// that is not "look at the data". + /// + /// + /// A row whose size column cannot be read is dropped rather than kept. The column is one this + /// library added for itself, so failing to find it means the floor has no idea how large the + /// group is — and answering anyway is exactly the disclosure the floor exists to prevent. + /// + /// + internal static int Suppress( + SummaryResult result, int floor, bool dryRun, PolicyTrace trace) + { + if (floor <= 1 || result.Data.Count == 0) + { + return 0; + } + + List kept = new(result.Data.Count); + int dropped = 0; + + foreach (object row in result.Data) + { + int? size = SizeOf(row); + + if (size is not null && size >= floor) + { + kept.Add(row); + + continue; + } + + dropped++; + + trace.Add(new PolicyDecision( + GroupFloor.SizeAlias, + PolicyFeature.Aggregate, + PolicyAction.Dropped, + size is null + ? $"a group whose size could not be read, below the group floor of {floor}" + : $"a group of {size}, below the group floor of {floor}")); + } + + // A dry run records what it would have done and does not do it, as every other decision + // under that posture does. The evidence is the point of the posture. + if (!dryRun) + { + result.Data = kept; + } + + return dropped; + } + + /// Reads the size column this library added, or null when it cannot be read. + private static int? SizeOf(object row) + { + PropertyInfo? column = CacheReflection.FindProperty(row.GetType(), GroupFloor.SizeAlias); + + if (column is null) + { + return null; + } + + object? value = MutatorCache.Read(column, row); + + try + { + return value is null ? null : Convert.ToInt32(value); + } + catch (Exception conversion) + when (conversion is FormatException or InvalidCastException or OverflowException) + { + return null; + } + } + + /// + /// Transforms the keys and aggregates of a summary, then refuses a result whose keys collide. + /// + /// + /// Grouping runs in SQL against real values, so the groups and their counts are correct before + /// anything here happens. Only then are the keys transformed, which is what keeps a masked field + /// groupable at all — masking first would merge distinct values in the database and produce + /// counts that answer a different question. + /// + /// The risk that creates is collision: two real keys can transform to the same output, leaving a + /// table with duplicate keys whose counts do not add up. That is refused rather than merged. + /// Merging would invent a number nobody computed, and returning it would hand back a result that + /// reads as wrong. + /// + /// + /// Thrown when two groups collide after transformation. + internal static void Summary( + SummaryResult result, + Summary summary, + TypePolicy policy, + DwPolicyContext context, + DwPolicyOptions options, + PolicyTrace trace) + { + if (policy.Transforms.Count == 0 || result.Data.Count == 0) + { + return; + } + + List keys = KeyColumns(summary, policy); + List aggregates = AggregateColumns(summary, policy); + + if (keys.Count == 0 && aggregates.Count == 0) + { + return; + } + + foreach (Column column in keys.Concat(aggregates)) + { + Apply(result.Data, column, context, options, trace); + } + + if (keys.Count > 0) + { + RefuseCollisions(result.Data, keys, options); + } + } + + /// + /// The grouping keys that carry a transform, paired with the column each one becomes. + /// + /// + /// The projection emits a grouping key with its dots stripped — Contact.Phone becomes + /// ContactPhone — which is the third spelling of a field this library accepts and the one + /// a previous phase missed. The column is found by that name and the policy by the path. + /// + private static List KeyColumns(Summary summary, TypePolicy policy) + { + List columns = new(); + + if (summary.GroupBy?.Fields is null) + { + return columns; + } + + foreach (string field in summary.GroupBy.Fields) + { + if (policy.Transforms.TryGetValue(field, out ValueTransform? chain)) + { + columns.Add(new Column(field.Replace(".", string.Empty), field, chain)); + } + } + + return columns; + } + + /// + /// The aggregates whose source field carries a transform, paired with their alias column. + /// + /// + /// An aggregate of a transformed field is transformed too. A salary rounded to the nearest band + /// stays useful when summed; a masked one becomes a mask, which is the honest rendering of a + /// number the caller may not see. A count carries no field and so inherits nothing. + /// + private static List AggregateColumns(Summary summary, TypePolicy policy) + { + List columns = new(); + + if (summary.GroupBy?.AggregateBy is null) + { + return columns; + } + + foreach (AggregateBy aggregate in summary.GroupBy.AggregateBy) + { + if (!string.IsNullOrWhiteSpace(aggregate.Field) + && !string.IsNullOrWhiteSpace(aggregate.Alias) + && policy.Transforms.TryGetValue(aggregate.Field!, out ValueTransform? chain)) + { + columns.Add(new Column(aggregate.Alias!, aggregate.Field!, chain)); + } + } + + return columns; + } + + /// Transforms one column across every row. + private static void Apply( + List rows, + Column column, + DwPolicyContext context, + DwPolicyOptions options, + PolicyTrace trace) + { + bool applied = false; + + foreach (object row in rows) + { + PropertyInfo property = CacheReflection.FindProperty(row.GetType(), column.Name) + ?? throw new InvalidOperationException( + $"'{column.Path}' is transformed by policy, but the summary has no column " + + $"'{column.Name}'. The value would otherwise be emitted exactly as stored."); + + object? value = MutatorCache.Read(property, row); + + object? replacement = TransformPipeline.Apply( + column.Chain, value, property.PropertyType, + new DwTransformContext(row, column.Path, context), options); + + if (!MutatorCache.Write(property, row, replacement)) + { + throw new InvalidOperationException( + $"The summary column '{column.Name}' has no setter, so the transformed value " + + "cannot replace the real one."); + } + + applied = true; + } + + if (applied) + { + trace.Add(new PolicyDecision( + column.Path, PolicyFeature.Aggregate, column.Chain.Action, + $"summary column '{column.Name}'")); + } + } + + /// + /// Refuses a result in which two groups now share a key. + /// + /// + /// Rounding two salaries into one band, or masking two identifiers into one run of stars, leaves + /// rows that look like duplicates and whose aggregates cannot be added together without + /// inventing a figure the database never computed. + /// + private static void RefuseCollisions(List rows, List keys, DwPolicyOptions options) + { + HashSet seen = new(StringComparer.Ordinal); + + foreach (object row in rows) + { + List parts = new(keys.Count); + + foreach (Column key in keys) + { + PropertyInfo property = CacheReflection.FindProperty(row.GetType(), key.Name)!; + + parts.Add(MutatorCache.Read(property, row)?.ToString() ?? "\0"); + } + + string composite = string.Join('\u001f', parts); + + if (!seen.Add(composite)) + { + bool hides = options.Tier == DwTier.Strict && !options.DryRun; + + // Under the strict tier the refusal names the clause rather than the grouping key: + // the key's canonical path is the column behind whatever alias the caller wrote, and + // the origin would say that its values are transformed. Both are answers about a + // field the caller never named. + throw new PolicyException( + PolicyErrorCode.AmbiguousGroupKey, + hides ? "*" : string.Join(", ", keys.Select(k => k.Path)), + PolicyFeature.Group, + options.Tier) + { + SourceOrigin = hides + ? null + : "two groups share a key once transformed, so their aggregates can no longer " + + "be told apart" + }; + } + } + } + + /// One column of a summary row, and the chain that transforms it. + private sealed record Column(string Name, string Path, ValueTransform Chain); +} From 7034717442507af8c92fb5b387ca4fed3f9af472 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 15:59:58 +0300 Subject: [PATCH 24/54] docs: a tenth channel, what [DwAudit] records, and the member a sequence operator hides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four refusals are written up as breaking point 34, as the security page's eighth channel, in the release notes and in README's upgrade note. Two limits the review named are written down rather than changed. [DwAudit] records a field the request names, plus a default-order field the query adds: a request that sends no Selects names nothing, so an audited field the row carries back is returned with no record. Closing that redefines what a use is and changes the event volume of every deployment already running the control, which is a decision for its own release; the documentation now says plainly what is recorded and what is not, and how to be recorded — name the fields, or audit Where, which a filtering request cannot leave out. A member assigned through a sequence operator, o.Lines.ToList() included, is left alone for the same reason a subquery's rows are: what the row holds is not always what the navigation holds. Co-Authored-By: Claude Opus 5 --- DynamicWhere.ex/DOC.md | 11 ++++--- DynamicWhere.ex/DynamicWhere.ex.csproj | 2 ++ .../app/docs/breaking-changes/page.tsx | 31 +++++++++++++++++-- OfficialWebsite/app/docs/policies/page.tsx | 2 +- .../app/docs/policies/security/page.tsx | 25 +++++++++++++-- OfficialWebsite/public/llms.txt | 12 ++++++- README.md | 1 + 7 files changed, 72 insertions(+), 12 deletions(-) diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index 5c3dab4..3fe21c2 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -1711,7 +1711,7 @@ It is refused only where the whole set of members a container can produce is kno A shadow property is a separate matter and unchanged: a field path names CLR members, and a shadow property has none, so no clause can name one — guarded or not, before this release or after it. Map it to a property, or project it with `EF.Property` and name the projected member. -A projection that does not build its rows with an object initializer is left alone whole: an anonymous type, and a constructor with arguments, say nothing about which member each value sets, so no member of such a row is refused here and none is claimed. A projection is otherwise read only as far as its initializer can be read. An entity query names every producible member from the model; a projection names them only where each assignment is a nested initializer, a member copied from the entity, a value built and left empty, or a conditional over those — a null branch beside one of them included. A member assigned nothing but a null is left alone, like any assignment this shape cannot read. Past `MaxComplexDepth` — eight levels — it stops reading and stops speaking. Under `Strict` such a path still reaches the provider and still fails there, exactly as it did before 3.3.0. +A member assigned through a sequence operator — `Lines = o.Lines.ToList()`, `o.Lines.Where(...).ToList()`, or a subquery building rows of its own — is left alone: what the row holds is not always what the navigation holds, and reading it as the navigation would refuse a member the row carries. A projection that does not build its rows with an object initializer is left alone whole: an anonymous type, and a constructor with arguments, say nothing about which member each value sets, so no member of such a row is refused here and none is claimed. A projection is otherwise read only as far as its initializer can be read. An entity query names every producible member from the model; a projection names them only where each assignment is a nested initializer, a member copied from the entity, a value built and left empty, or a conditional over those — a null branch beside one of them included. A member assigned nothing but a null is left alone, like any assignment this shape cannot read. Past `MaxComplexDepth` — eight levels — it stops reading and stops speaking. Under `Strict` such a path still reaches the provider and still fails there, exactly as it did before 3.3.0. **Left alone** is not a promise that the path runs. The policy does not refuse it, so it behaves exactly as it does unguarded: `Name.IsEmpty` beneath a column mapped through a value converter still fails inside the provider, as it always has. @@ -1829,7 +1829,7 @@ The trace names the fields a policy dropped, the attribute or rule that sealed e ### Auditing -`[DwAudit(features)]` records every use of a field, whatever the policy decided, as a `DwAuditEvent` in the caller's context (`DwPolicyContext.PendingAuditEvents`). A use is a field the request names, or, since 3.1.0, a field of the type's [default order](#default-order) that the query orders by: audited for `Order`, it is recorded each time, as a caller's own order is. A default field left out for this caller is not recorded, because the query does not order by it and the caller never named it. Nothing is stored until the buffer is drained to an `IDwAuditSink`, by `DwPolicy.DrainAuditAsync(context, sink)` or, per request, by the ASP.NET Core middleware `app.UseDwPolicyAudit()`. A buffer already holding `DwCaps.MaxAuditEvents` refuses the next audited use with `CapExceeded` — except under `Strict` outside a dry run, where it refuses with the clause's own field refusal instead (3.3.0). An unknown name is never audited and never reaches the cap, so answering with the cap's own code there would have told a caller that the name they guessed is a real field and an audited one. +`[DwAudit(features)]` records every use of a field, whatever the policy decided, as a `DwAuditEvent` in the caller's context (`DwPolicyContext.PendingAuditEvents`). A use is a field the request names, or, since 3.1.0, a field of the type's [default order](#default-order) that the query orders by: audited for `Order`, it is recorded each time, as a caller's own order is. A default field left out for this caller is not recorded, because the query does not order by it and the caller never named it. Nothing is stored until the buffer is drained to an `IDwAuditSink`, by `DwPolicy.DrainAuditAsync(context, sink)` or, per request, by the ASP.NET Core middleware `app.UseDwPolicyAudit()`. A use is what the request names and what the library adds to it, and nothing else: a request that sends no `Selects` reads the row without naming anything, so an audited field it carries back is returned with no record written. Name the fields you want recorded, or audit the `Where` feature, which a request cannot leave out and still filter. A buffer already holding `DwCaps.MaxAuditEvents` refuses the next audited use with `CapExceeded` — except under `Strict` outside a dry run, where it refuses with the clause's own field refusal instead (3.3.0). An unknown name is never audited and never reaches the cap, so answering with the cap's own code there would have told a caller that the name they guessed is a real field and an audited one. A log of uses never shows a caller probing for columns they may not read: every guess is refused, so nothing was used. `DwPolicyOptions.AuditRefusals` (`bool`, default `false`, new in 3.1.0) records the refusals too. When it is on, every `PolicyException` raised by a guarded entry point of `PolicyQueryable` — terminal or composable — and `ApplyPolicy(context)`'s refusal of an unprepared context are written to the same buffer and drain the same way: @@ -2445,10 +2445,13 @@ These are numbered as this document numbers them. The website's [breaking-change 26. **`LastTrace` Is Set Before a Request Is Sanitized** Since 3.3.0 `PolicyQueryable.LastTrace` carries the trace of a request that was refused. It used to be assigned after sanitizing returned, so a refusal left it holding the previous request's trace, or null on the first. A strict refusal names no field on purpose, and the trace is where the real path and reason live, so this is what makes one readable. Code that read `LastTrace` after catching a `PolicyException` and expected the earlier request's trace reads this request's now. -27. **The Audit Cap Refuses Like Any Other Field, Under `Strict`** +27. **Four More Refusals Name the Clause Under `Strict`** + A strict refusal names no field, and four did. `AmbiguousFieldName` told a caller that the name they wrote matches more than one field, which is to say at least one; it is now refused as an unknown name is, with the ambiguity kept in the trace for the operator who has to fix the aliases. `AmbiguousGroupKey` reported the grouping key's canonical path — the column behind whatever alias the caller wrote — and an origin saying its values are transformed; it now reports `"*"` and no origin. `TransformRequiresMaterialization` listed every transformed column on the type to a caller who named none of them, and now names the clause. `MissingHashSalt` and `MissingTokenVault` named the masked field a deployment forgot to configure for, and now name the clause. All four are unchanged under `Convenience` and in a dry run, where the tier names fields anyway. Code switching on `AmbiguousFieldName` under `Strict`, or reading `FieldPath` off any of the four, sees the change. + +28. **The Audit Cap Refuses Like Any Other Field, Under `Strict`** An audited field records one event per use, and the query is refused rather than the record dropped when `DwCaps.MaxAuditEvents` is reached. Until 3.3.0 that refusal carried `CapExceeded` and a `SourceOrigin` naming the cap, while a name matching nothing carried the ordinary field refusal and no origin — and an unknown name is never audited, so the difference told a caller which names are real and audited. Under `Strict`, outside a dry run, the cap now refuses with the clause's own code, `FieldPath` `"*"` and no origin. The request still fails, so the buffer still fails closed, and the trace still records which refusal it was. `Convenience` and a dry run still answer `CapExceeded`. Code switching on `CapExceeded` under `Strict` sees the change. -28. **`Configure` Takes the Same Posture Twice** +29. **`Configure` Takes the Same Posture Twice** Since 3.3.0 a second `DwPolicy.Configure` or `AddDwPolicies` asking for the posture already in force returns instead of throwing; a different posture still throws. Code that relied on the second call throwing — a test asserting it, or a `try`/`catch` around a second registration — no longer sees the exception. `AddDwPolicies` also registers the posture in force rather than the instance it built, so a container resolving `DwPolicyOptions` after a second registration gets the first one's. The token vault, the service provider and the provider instances are not compared and are not replaced. See [Configuring twice](#configuring-twice). --- diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index 653eceb..51e4a38 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -54,6 +54,8 @@ New: DwPolicy.Configure takes a second call asking for the posture already in fo Behaviour change: under the strict tier, a filter, order, grouping key or aggregated field naming a path the query cannot compute is refused as an unknown name is, where the provider used to throw. A projection is not refused: EF Core evaluates the last projection on the client, so a Selects entry naming such a member returns its value as it always did. A member of a row's type is not always a value a database can produce: a getter such as LocalizedText.IsEmpty reads two columns in memory, so every check the policy makes passes and the query then fails inside EF Core with InvalidOperationException — a five-hundred where the strict tier promises a refusal, and the one place the tier answered with neither an answer nor a refusal. The path is refused only where the whole set of members a container can produce is known: the queried entity type's own model, an owned or complex type, and an initializer in a projection EF Core ran before ApplyPolicy, including a member that projection copies straight from the entity. Everywhere else nothing changes — rows in memory run the getter as they always did, a framework member the provider translates such as Length, Year or HasValue is left alone, a value beneath a converted column is left alone, a provider that is not EF Core's decides for itself, the convenience tier and a dry run still fail exactly as an unguarded query does, and a source the library cannot read asks for nothing. A column the model maps only on a subtype is refused through the base type, because EF Core translates a member against the type the query is over and fails there too; query the derived type. The trace records the refusal with its reason, and the trace is now assigned before a request is sanitized, so a refusal leaves LastTrace readable rather than null. +Security fix and behaviour change: under the strict tier, outside a dry run, four refusals that named a field name the clause instead. A strict refusal names none, so that a denied field, a misspelling and a field that does not exist cannot be told apart. AmbiguousFieldName said the caller's name matched more than one field, and so at least one; it is refused as an unknown name is now, and the trace keeps the ambiguity for the operator who has to fix the aliases. AmbiguousGroupKey reported the column behind the caller's alias, with an origin saying its values are transformed. TransformRequiresMaterialization listed every transformed column on the type to a caller who had named none. MissingHashSalt and MissingTokenVault named the masked field a deployment had not configured for. The last three report "*" and no origin. The convenience tier and a dry run are unchanged. + Security fix and behaviour change: under the strict tier, outside a dry run, a query that exhausts the audit buffer is refused with the clause's own field refusal rather than with CapExceeded and a SourceOrigin naming the cap. An audited field records an event per use and the query is refused rather than the record dropped once DwCaps.MaxAuditEvents is reached, which is fail-closed and stays that way; but a name that matches nothing is never audited and never reaches the cap, so the two answers told a caller, one guess per request, which names are real and audited. The refusal now carries the clause's own code, FieldPath "*" and no origin, and the trace records which refusal it really was. The convenience tier and a dry run still answer CapExceeded. New: Clone is public on Filter, Segment and Summary. It returns a deep copy — the condition tree with its groups and conditions, the projection list, each order, the page, and a summary's group-by and having clause — so a caller reading the same request again with one part changed, the next page or another order, no longer rebuilds it around the caller's own clauses. Two requests sharing one condition tree is the bug this removes: the library has cloned before rewriting anything since 3.0, and callers could not. diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index 2da710a..a50d77d 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -17,12 +17,12 @@ export default function Page() {

          Breaking Changes & Known Limitations

          DynamicWhere.ex is intentionally opinionated about how queries are shaped. - The thirty-three points below cover constraints, surprises, and corner cases — + The thirty-four points below cover constraints, surprises, and corner cases — read them before designing an API around the library so you can pick the right entry points and avoid runtime exceptions in production.

          - Points 30 to 33 changed in 3.3.0. Under{" "} + Points 30 to 34 changed in 3.3.0. Under{" "} Strict, a path that exists on the type and names no value the query can compute is refused rather than run, where the provider used to throw and the caller saw a five-hundred (point 30).{" "} @@ -33,7 +33,9 @@ export default function Page() { several hosts over one composition root (point 32). A query that exhausts the audit buffer under Strict is now refused with the clause's own field refusal rather than with{" "} - CapExceeded (point 33). + CapExceeded (point 33). Four more refusals that named + a field under Strict name the clause instead + (point 34). Points 25 to 29 changed in 3.2.0, and each is @@ -1650,6 +1652,29 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}Strict sees the change.

          +

          34. Four More Refusals Name the Clause Under Strict

          +

          + A strict refusal names no field, and four did.{" "} + AmbiguousFieldName told a caller that the name they wrote + matches more than one field, which is to say at least one; it is refused + as an unknown name is since 3.3.0, with the ambiguity + kept in the trace for the operator who has to fix the aliases.{" "} + AmbiguousGroupKey reported the grouping key's + canonical path — the column behind whatever alias the caller wrote — and + an origin saying its values are transformed; it reports{" "} + "*" and no origin.{" "} + TransformRequiresMaterialization listed every transformed + column on the type to a caller who named none of them.{" "} + MissingHashSalt and MissingTokenVault named the + masked field a deployment forgot to configure for. +

          +

          + All four are unchanged under Convenience and in a dry run, + where the tier names fields anyway. Code switching on{" "} + AmbiguousFieldName under Strict, or reading{" "} + FieldPath off any of the four, sees the change. +

          +

          See also

          • diff --git a/OfficialWebsite/app/docs/policies/page.tsx b/OfficialWebsite/app/docs/policies/page.tsx index 4fed31b..fdfb438 100644 --- a/OfficialWebsite/app/docs/policies/page.tsx +++ b/OfficialWebsite/app/docs/policies/page.tsx @@ -156,7 +156,7 @@ var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter, cancella
          • Dynamic store — rules without a redeploy
          • Store providers — Redis and Entity Framework Core
          • Admin API — schema, rules, explain, simulate, health
          • -
          • Security & k-anonymity — the nine inference channels
          • +
          • Security & k-anonymity — the ten inference channels
          • Configuration — options, caps and defaults
          diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx index cad8f55..6b02486 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -5,7 +5,7 @@ import { Code } from "@/components/Code"; import Callout from "@/components/Callout"; export const metadata: Metadata = { - title: "Security & k-anonymity — the nine inference channels", + title: "Security & k-anonymity — the ten inference channels", description: "How DynamicWhere.ex closes the disclosure channels no per-field rule closes on its own — set-operation reconstruction, singleton-group aggregates, cardinality probes, sort-and-page binary search, SQL leakage, and schema probing through refusals — and the denials the gate could not see until 3.2.0.", keywords: ["k-anonymity", "MinGroupSize", "inference attack", "data disclosure", "aggregate disclosure", "EF Core security"], alternates: { canonical: "https://doc.dynamicwhere.com/docs/policies/security/" }, @@ -17,7 +17,7 @@ export default function Page() {

          Security & k-anonymity

          Denying a field is easy. The hard part is the set of ways a caller can - learn a value without reading it. Seven such channels follow, + learn a value without reading it. Eight such channels follow, then two bypasses that are not channels, then a path the query cannot compute — not a channel either, but the one request the tier used to answer with neither an answer nor a refusal — then the requests that, until 3.2.0, @@ -205,7 +205,26 @@ true order. Add [DwNoOrder] unless that is intended.`} still records which refusal it really was.

          -

          8 and 9. The two that are not channels

          +

          8. Four refusals that named a field

          +

          + A strict refusal names no field, so that a denied field, a misspelling + and a field that does not exist cannot be told apart. Four refusals + named one anyway: an ambiguous name said the caller's guess matched + more than one field, and so at least one; an ambiguous grouping key + reported the column behind the caller's alias and said its values + are transformed; the refusal for a clause that cannot be transformed + listed every masked column on the type; and a deployment with no hash + salt or token vault named the masked field it could not write. +

          +

          + Closed by: under Strict, outside a dry + run, an ambiguous name is refused exactly as an unknown name is, and the + other three name the clause with no origin. The trace keeps the real + reason for the operator. Convenience and a dry run are + unchanged. +

          + +

          9 and 10. The two that are not channels

          diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index 037e0b2..135ef27 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -2191,6 +2191,9 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. and in dry run. `CapExceeded`, except under `Strict` outside a dry run, where it is the clause's own field refusal instead (3.3.0): only a real, audited field can reach the cap, so answering with the cap's own code would set such a field apart from a name that matches nothing. +- A use is what the request names, plus a default-order field the query adds. A request sending no `Selects` names + nothing, so an audited field the row carries back is returned with no record: name the fields to be recorded, or + audit `Where`, which a filtering request cannot leave out. - A refusal becomes an event of its own, with an `ErrorCode`, only when `DwPolicyOptions.AuditRefusals` is on, and then for every refused guarded query, audited field or not (3.1.0, section 22). - It is elected: the top-ranked audit wins outright, and fragments tied with it are unioned. A rule can @@ -2370,6 +2373,9 @@ a projection another provider ran nothing; its rules are its replaced query preprocessor — leaves EF Core's own provider in place, so such a member is refused. - A projection that does not build its rows with an object initializer — an anonymous type, a constructor with arguments — says nothing about which member each value sets, so no member of such a row is refused here. +- A member assigned through a sequence operator — `Lines = o.Lines.ToList()`, `o.Lines.Where(…).ToList()`, a + subquery building rows of its own — is left alone as well: what the row holds is not what the navigation holds, + and reading it as the navigation would refuse a member the row carries. - A row the library itself projected reads like any other: the core's typed `Select` null-guards every nested node it builds, and both branches of that guard are read, so `Select` composed and then filtered refuses what the bare handle refuses. @@ -4275,7 +4281,7 @@ any policy [DwEntity(RequirePolicy = true)] a DynamicWhere ca ### Inference channels -There are nine. The first seven let a caller learn a value, or what the policy hides, without reading it; +There are ten. The first eight let a caller learn a value, or what the policy hides, without reading it; **Forgotten guard** and **Empty store** are bypasses. - **Set operations:** `EXCEPT` or `INTERSECT` rebuild a field that is denied for Select but allowed for @@ -4298,6 +4304,10 @@ There are nine. The first seven let a caller learn a value, or what the policy h `CapExceeded` and an origin naming the cap, while an unknown name — never audited, so never reaching the cap — carried the ordinary field refusal. Under `Strict` outside a dry run the cap now refuses with the clause's own field refusal, so the two cannot be told apart; `Convenience` and a dry run still answer `CapExceeded`. +- **A refusal that names a field:** four did under `Strict` until 3.3.0 — an ambiguous name, an ambiguous grouping + key, a clause that cannot be transformed, and a deployment with no hash salt or token vault. An ambiguous name is + now refused as an unknown name is; the other three name the clause and carry no origin. `Convenience` and a dry + run are unchanged. - **Which fields exist:** Convenience answers a name that matches nothing with `ConditionMustHasValidFieldName` and a denied field with a refusal that names it and its source, so a caller can map the hidden columns one guess at a time. Strict answers both alike, with the clause's `FieldDeniedFor*` code, `FieldPath` `"*"` and diff --git a/README.md b/README.md index b34ba31..0d87910 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,7 @@ The complete reference — every enum, class, extension method, validation rule, - **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a value's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor, and `IncludeTraceInResult` written as the tier's own answer is the answer the tier already gives. A type exposed under two names is reported under the last one, so two catalogues that resolve every name alike are still a different posture when the order differs. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. - **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query any provider but EF Core's own translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()`, or a host's own registered through `ReplaceService` — because such a provider may rewrite what EF Core cannot, and the library cannot tell one that does from one that passes straight through. The test is EF Core's own provider type, from EF Core's own assembly. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. +- **Fixed (security): four refusals named a field under `Strict`.** A strict refusal names no field, so that a denied one, a misspelling and a field that does not exist cannot be told apart. `AmbiguousFieldName` said the caller's name matched more than one field, and so at least one; it is refused as an unknown name is now, with the ambiguity kept in the trace. `AmbiguousGroupKey` reported the column behind the caller's alias and an origin saying its values are transformed; `TransformRequiresMaterialization` listed every masked column on the type; `MissingHashSalt` and `MissingTokenVault` named the masked field a deployment had not configured for. The three report `"*"` and no origin now. `Convenience` and a dry run are unchanged. - **Fixed (security): the audit cap answered differently for a real field.** An audited field records one event per use, and the query is refused rather than the record dropped once `DwCaps.MaxAuditEvents` is reached. That refusal carried `CapExceeded` and a `SourceOrigin` naming the cap, while a name matching nothing carried the ordinary field refusal and no origin — and an unknown name is never audited, so one guess per request told a caller which names are real and audited. Under `Strict`, outside a dry run, the cap now refuses with the clause's own code, `FieldPath` `"*"` and no origin; the request still fails and the trace still records which refusal it was. `Convenience` and a dry run are unchanged. - **Changed: `LastTrace` is set before a request is sanitized**, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. - **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. Every node is new; a condition's values stay the caller's own objects, in a new list. From 63d75bd98f07ed53ccd233d4e4cf59ea7cf028d6 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 16:22:40 +0300 Subject: [PATCH 25/54] fix(policies): an audited field a request does not name is still a field it reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [DwAudit] exists to answer who read a field, and a request that sent no Selects read every audited member of the row with nothing written down. The same caller naming one field recorded it; naming none recorded nothing, which made an empty Selects one token past the control. A use is what the request reads, not only what it spells out. The members a projection the caller never named hands back are recorded for Select: what the synthesized projection keeps where one is built, and every member the caller may select where none is, since the row then comes back whole. Both sets are paths whose policy the walk has already resolved, so the recording costs no resolution of its own, and only a field [DwAudit] names produces an event — one per query, not one per row. The library's own specification said the opposite, deliberately: a synthesized projection was the library resolving a policy on its own behalf rather than an access by the caller. It is an access — the caller receives the value. That test now pins the read, and the suites that filter on an audited field name a projection of their own so each one still measures the clause it is about. A deployment already running the control sees more events than it did, and MaxAuditEvents refuses rather than dropping a record, so a buffer that was never reached can now be: raise the cap, or drain per request with app.UseDwPolicyAudit(). Co-Authored-By: Claude Opus 5 --- .../Policies/Ac5AuditCapProbes.cs | 24 +++--- DynamicWhere.Tests/Policies/Ac5LeakProbes.cs | 25 +++--- DynamicWhere.Tests/Policies/Dv5DocsProbes.cs | 19 +++-- .../Policies/PolicyAuditTests.cs | 85 +++++++++++++++++-- .../Policies/Source/FilterSanitizer.cs | 33 +++++++ 5 files changed, 154 insertions(+), 32 deletions(-) diff --git a/DynamicWhere.Tests/Policies/Ac5AuditCapProbes.cs b/DynamicWhere.Tests/Policies/Ac5AuditCapProbes.cs index 36f1556..103f07e 100644 --- a/DynamicWhere.Tests/Policies/Ac5AuditCapProbes.cs +++ b/DynamicWhere.Tests/Policies/Ac5AuditCapProbes.cs @@ -433,7 +433,7 @@ string Run(string field) /// projection at all receives Tag in every row and is recorded nowhere. /// [Fact] - public void Synthesized_projection_returns_an_audited_field_without_recording_it() + public void Synthesized_projection_records_the_audited_field_it_returns() { DwPolicyOptions options = Options(audits: 1000); @@ -466,11 +466,10 @@ public void Synthesized_projection_returns_an_audited_field_without_recording_it named.PendingAuditEvents, e => e.FieldPath == Audited && e.Feature == PolicyFeature.Select); - // A documented limit, not a defect fixed here: [DwAudit] records a field the request - // names, and a field of the type's default order that the query adds. A request naming - // no projection reads the row without naming anything, and records nothing. Closing that - // redefines what a use is, which is a decision for its own release. - Assert.DoesNotContain( + // The caller receives the value, so the log says so: since 3.3.0 the members a + // synthesized projection returns are recorded as read, which closes an empty Selects as + // a way past the control. + Assert.Contains( silent.PendingAuditEvents, e => e.FieldPath == Audited && e.Feature == PolicyFeature.Select); } @@ -480,7 +479,7 @@ public void Synthesized_projection_returns_an_audited_field_without_recording_it /// comes back — audited field included — and the buffer is empty. /// [Fact] - public void Whole_entity_read_returns_an_audited_field_without_recording_it() + public void Whole_entity_read_records_the_audited_field_it_returns() { DwPolicyOptions options = Options(audits: 1000); DwPolicyContext context = Caller(); @@ -494,11 +493,12 @@ public void Whole_entity_read_returns_an_audited_field_without_recording_it() // Nothing is denied, so the row comes back whole and carries Tag. Assert.Null(sanitized.Selects); - // A documented limit, not a defect fixed here: [DwAudit] records a field the request - // names, and a field of the type's default order that the query adds. A request naming - // no projection reads the row without naming anything, and records nothing. Closing that - // redefines what a use is, which is a decision for its own release. - Assert.Empty(context.PendingAuditEvents); + // The caller receives the value, so the log says so: since 3.3.0 the members a + // synthesized projection returns are recorded as read, which closes an empty Selects as + // a way past the control. + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == Audited && e.Feature == PolicyFeature.Select); } /// diff --git a/DynamicWhere.Tests/Policies/Ac5LeakProbes.cs b/DynamicWhere.Tests/Policies/Ac5LeakProbes.cs index a172cfa..9cf7f2f 100644 --- a/DynamicWhere.Tests/Policies/Ac5LeakProbes.cs +++ b/DynamicWhere.Tests/Policies/Ac5LeakProbes.cs @@ -162,7 +162,7 @@ private static PolicyQueryable Guard( /// audited field in it, and hands back its real value — with nothing written to the audit. /// [Fact] - public void An_audited_field_a_request_does_not_name_is_returned_and_not_recorded() + public void An_audited_field_is_recorded_whether_or_not_the_request_names_it() { DwPolicyOptions options = Options(); @@ -196,11 +196,12 @@ public void An_audited_field_a_request_does_not_name_is_returned_and_not_recorde named.PendingAuditEvents, e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); - // A documented limit, not a defect fixed here: [DwAudit] records a field the request - // names, and a field of the type's default order that the query adds. A request naming - // no projection reads the row without naming anything, and records nothing. Closing that - // redefines what a use is, which is a decision for its own release. - Assert.Empty(silent.PendingAuditEvents); + // The caller receives the value, so the log says so: since 3.3.0 the members a + // synthesized projection returns are recorded as read, which closes an empty Selects as + // a way past the control. + Assert.Contains( + silent.PendingAuditEvents, + e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); } /// @@ -208,7 +209,7 @@ public void An_audited_field_a_request_does_not_name_is_returned_and_not_recorde /// the table and the buffer is still empty. /// [Fact] - public void An_audited_field_a_whole_entity_read_returns_is_not_recorded() + public void An_audited_field_a_whole_entity_read_returns_is_recorded() { DwPolicyContext context = Caller(); @@ -218,12 +219,14 @@ public void An_audited_field_a_whole_entity_read_returns_is_not_recorded() _out.WriteLine($"value={rows.Data[0].NationalId} events={context.PendingAuditEvents.Count}"); Assert.Equal("AAA-111", rows.Data[0].NationalId); - Assert.Empty(context.PendingAuditEvents); + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); } /// The convenience tier reads the same way, so the gap is not tier-specific. [Fact] - public void The_unrecorded_read_is_not_tier_specific() + public void The_record_is_written_in_both_tiers() { DwPolicyContext context = Caller(); @@ -231,7 +234,9 @@ public void The_unrecorded_read_is_not_tier_specific() .ToList(new Filter()); Assert.Equal("AAA-111", rows.Data[0].NationalId); - Assert.Empty(context.PendingAuditEvents); + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); } // ========================================================================================= diff --git a/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs b/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs index 01e6c12..7ffdc6d 100644 --- a/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs +++ b/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs @@ -447,9 +447,14 @@ public void Dv5_F_The_compute_refusal_records_no_audit_event() .ApplyPolicy(allowed, options, Resolver()) .ToList(Where(On("Code"))); - _out.WriteLine($"an audited field named -> {allowed.PendingAuditEvents.Count} event(s)"); + _out.WriteLine($"an audited field named -> {allowed.PendingAuditEvents.Count} event(s):" + + $" {string.Join(",", allowed.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}"))}"); - Assert.Single(allowed.PendingAuditEvents); + // Two uses of the one field: the caller filtered on it, and the row the request named no + // projection for carries it back, which since 3.3.0 is recorded as the read it is. + Assert.Equal(2, allowed.PendingAuditEvents.Count); + Assert.Contains(allowed.PendingAuditEvents, e => e.Feature == PolicyFeature.Where); + Assert.Contains(allowed.PendingAuditEvents, e => e.Feature == PolicyFeature.Select); // The refusal of a path the query cannot compute records none. DwPolicyContext refused = Caller(); @@ -459,9 +464,13 @@ public void Dv5_F_The_compute_refusal_records_no_audit_event() .ApplyPolicy(refused, options, Resolver()) .ToList(Where(On("Slug")))); - _out.WriteLine($"a path the query cannot compute -> {refused.PendingAuditEvents.Count} event(s)"); + _out.WriteLine($"a path the query cannot compute -> {refused.PendingAuditEvents.Count} event(s):" + + $" {string.Join(",", refused.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}"))}"); - Assert.Empty(refused.PendingAuditEvents); + // No event names the refused path. The request's own projection is recorded as it is for + // any other request — a use is what the request would have read — but the refusal itself + // raises none, as an unknown name raises none. + Assert.DoesNotContain(refused.PendingAuditEvents, e => e.FieldPath.Contains("IsZero")); // And a name matching nothing records none either, which is the comparison the text draws. DwPolicyContext unknown = Caller(); @@ -473,7 +482,7 @@ public void Dv5_F_The_compute_refusal_records_no_audit_event() _out.WriteLine($"a name matching nothing -> {unknown.PendingAuditEvents.Count} event(s)"); - Assert.Empty(unknown.PendingAuditEvents); + Assert.DoesNotContain(unknown.PendingAuditEvents, e => e.FieldPath.Contains("Zzzzz")); } // ========================================================================================= diff --git a/DynamicWhere.Tests/Policies/PolicyAuditTests.cs b/DynamicWhere.Tests/Policies/PolicyAuditTests.cs index be87c44..3c7edbf 100644 --- a/DynamicWhere.Tests/Policies/PolicyAuditTests.cs +++ b/DynamicWhere.Tests/Policies/PolicyAuditTests.cs @@ -2,6 +2,7 @@ using DynamicWhere.ex.Classes.Core; using DynamicWhere.ex.Enums; using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; using DynamicWhere.ex.Policies.Audit; using DynamicWhere.ex.Policies.Config; using DynamicWhere.ex.Policies.Context; @@ -64,9 +65,18 @@ private static PolicyQueryable Query( options ?? Options(), new PolicyResolver(new[] { new AttributePolicyProvider() })); + /// + /// A filter naming one condition, and a projection naming one unaudited field. + /// + /// + /// The projection is deliberate. A request that names none has one synthesized, and since 3.3.0 + /// the members it returns are recorded as read — so a bare filter here would record the fields + /// the row carries back as well as the one the test is about. + /// private static Filter Where(string field, DataType type = DataType.Text) => new() { + Selects = new List { "Name" }, ConditionGroup = new ConditionGroup { Sort = 1, @@ -199,18 +209,83 @@ public void Two_references_to_one_field_are_recorded_twice() } /// - /// The library resolving a policy on its own behalf is not an access by the caller. Charging a - /// synthesized projection to the audit log would record every field of the type as read on - /// every query that named none. + /// A projection the caller never named still hands them the members it keeps, and an audited + /// field among them is a field they read. /// + /// + /// Until 3.3.0 only a field the request spelled out was recorded, which left an empty + /// Selects as one token past the control: the same value, returned, with nothing written + /// down. Only an audited field produces an event, and one per query rather than one per row, so + /// a type with nothing audited still records nothing here. + /// [Fact] - public void A_synthesized_projection_records_nothing() + public void A_synthesized_projection_records_what_it_returns() { DwPolicyContext context = new(); Query(context).ToList(new Filter()); - Assert.Empty(context.PendingAuditEvents); + // Both audited columns come back in the row, so both are read: Salary is audited for every + // feature and Email for Select alone. + Assert.Equal(2, context.PendingAuditEvents.Count); + Assert.All(context.PendingAuditEvents, e => Assert.Equal(PolicyFeature.Select, e.Feature)); + Assert.All(context.PendingAuditEvents, e => Assert.Equal(PolicyEffect.Allow, e.Effect)); + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Salary"); + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Email"); + } + + /// + /// A member a projection could not have kept is still read when the row comes back whole. + /// + /// + /// Nothing is denied on this type, so no projection is built and the caller receives the row as + /// it is — a getter with no setter included, which a projection would have had to leave out. + /// Recording only what a projection would have kept would lose exactly those members. + /// + [Fact] + public void A_whole_row_read_records_an_audited_member_no_projection_could_keep() + { + DwPolicyContext context = new(); + + Array.Empty() + .AsQueryable() + .ApplyPolicy(context, Options(), new PolicyResolver(new[] { new AttributePolicyProvider() })) + .ToList(new Filter()); + + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == "Reference" && e.Feature == PolicyFeature.Select); + } + + /// A type whose audited member a projection cannot assign. + private class AuditedGetter + { + public int Id { get; set; } + + [DwAudit] + public string Reference => "r-" + Id; + } + + /// The same read, spelled out by the caller, is the same one record. + [Fact] + public void Naming_the_field_records_the_same_read_once() + { + DwPolicyContext named = new(); + + Query(named).ToList(new Filter { Selects = new List { "Email" } }); + + DwPolicyContext silent = new(); + + Query(silent).ToList(new Filter()); + + DwAuditEvent spelled = Assert.Single(named.PendingAuditEvents); + + Assert.Equal("Email", spelled.FieldPath); + + // The same read, reached the other way, is recorded the same way. + Assert.Contains( + silent.PendingAuditEvents, + e => e.FieldPath == spelled.FieldPath && e.Feature == spelled.Feature && e.Effect == spelled.Effect); } // ---- the drain ----------------------------------------------------------------------------- diff --git a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs index 5f869cf..cdb589b 100644 --- a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs +++ b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs @@ -1898,6 +1898,7 @@ private static void SynthesizeSelects(Filter filter, Gate gate, RowShape rows) private static List? SynthesizedProjection(Gate gate, RowShape rows) { List allowed = new(); + List readable = new(); List<(string Member, string Reason)> uncarried = new(); bool anyDenied = false; int recorded = gate.TraceCount; @@ -1916,6 +1917,12 @@ private static void SynthesizeSelects(Filter filter, Gate gate, RowShape rows) continue; } + // The caller named no projection, so what comes back comes back because they asked for + // the row. That is a read, and an audited field has to record it: recording only what a + // request spells out would leave an empty Selects as one token past [DwAudit] — the same + // value, returned, with nothing written down. Which members reach the caller is known + // once the walk finishes, so they are collected here and recorded below. + readable.Add(name); // Two members share the name, one hidden with new under another type or spelled in another case: // the core reads one of them, and a row carries both. What either can hold is asked about, and a @@ -2029,6 +2036,14 @@ private static void SynthesizeSelects(Filter filter, Gate gate, RowShape rows) } } + // What the caller receives: the projection's own list where one is built, and every member + // they may select where none is, since the row then comes back whole. Both sets are paths + // whose policy is already resolved, so recording costs no resolution of its own. + foreach (string path in anyDenied ? allowed : readable) + { + gate.AuditUse(path, PolicyFeature.Select, gate.PolicyFor(path, PolicyFeature.None)); + } + // A member that asks for nothing itself is still left out, or narrowed, as the projection would build // it. With no projection built nothing was, and the trace says what the query did. if (!anyDenied) @@ -4214,6 +4229,24 @@ internal void RecordInjected(string fieldPath, Operator op, bool orNull = false) PolicyAction.Injected, orNull ? $"forced predicate ({op}, or null)" : $"forced predicate ({op})")); + /// + /// Records one use of a field whose policy is already resolved, when the field is audited + /// for that feature. + /// + /// + /// For a use the library works out rather than one a request names: the members a projection + /// synthesized for a caller who named none returns to them. records + /// the use itself for the feature it resolves; this is that recording, reached from a + /// decision that holds the policy already. + /// + internal void AuditUse(string fieldPath, PolicyFeature feature, FieldPolicy policy) + { + if (feature != PolicyFeature.None && policy.IsAudited(feature)) + { + Audit(fieldPath, feature, policy); + } + } + /// Resolves one field's policy, once per query. internal FieldPolicy PolicyFor(string fieldPath, PolicyFeature feature) { From e03d856a9afa706d73fec65832d7a5527dc31212 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 16:23:09 +0300 Subject: [PATCH 26/54] docs: an eleventh entry on the security page, and what a use is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [DwAudit] recording a read the request did not name is a security fix and a behaviour change, so it is written as breaking point 35, as the third of the bypasses that are not channels, in the release notes, in llms.txt's channel list and audit section, and in README's upgrade note. What was written last round as a limit is the behaviour now: a use is what the request reads, not only what it spells out. Each place says what a deployment has to do about it: more events than before, one per query rather than per row and only for a field the attribute names, and MaxAuditEvents refuses rather than dropping a record, so a buffer that was never reached can now be — raise the cap, or drain per request. Co-Authored-By: Claude Opus 5 --- DynamicWhere.ex/DOC.md | 9 ++++-- DynamicWhere.ex/DynamicWhere.ex.csproj | 2 ++ .../app/docs/breaking-changes/page.tsx | 30 +++++++++++++++++-- OfficialWebsite/app/docs/policies/page.tsx | 2 +- .../app/docs/policies/security/page.tsx | 17 +++++++++-- OfficialWebsite/public/llms.txt | 13 ++++---- README.md | 1 + 7 files changed, 59 insertions(+), 15 deletions(-) diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index 3fe21c2..9e77da0 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -1829,7 +1829,7 @@ The trace names the fields a policy dropped, the attribute or rule that sealed e ### Auditing -`[DwAudit(features)]` records every use of a field, whatever the policy decided, as a `DwAuditEvent` in the caller's context (`DwPolicyContext.PendingAuditEvents`). A use is a field the request names, or, since 3.1.0, a field of the type's [default order](#default-order) that the query orders by: audited for `Order`, it is recorded each time, as a caller's own order is. A default field left out for this caller is not recorded, because the query does not order by it and the caller never named it. Nothing is stored until the buffer is drained to an `IDwAuditSink`, by `DwPolicy.DrainAuditAsync(context, sink)` or, per request, by the ASP.NET Core middleware `app.UseDwPolicyAudit()`. A use is what the request names and what the library adds to it, and nothing else: a request that sends no `Selects` reads the row without naming anything, so an audited field it carries back is returned with no record written. Name the fields you want recorded, or audit the `Where` feature, which a request cannot leave out and still filter. A buffer already holding `DwCaps.MaxAuditEvents` refuses the next audited use with `CapExceeded` — except under `Strict` outside a dry run, where it refuses with the clause's own field refusal instead (3.3.0). An unknown name is never audited and never reaches the cap, so answering with the cap's own code there would have told a caller that the name they guessed is a real field and an audited one. +`[DwAudit(features)]` records every use of a field, whatever the policy decided, as a `DwAuditEvent` in the caller's context (`DwPolicyContext.PendingAuditEvents`). A use is a field the request names, or, since 3.1.0, a field of the type's [default order](#default-order) that the query orders by: audited for `Order`, it is recorded each time, as a caller's own order is. A default field left out for this caller is not recorded, because the query does not order by it and the caller never named it. Nothing is stored until the buffer is drained to an `IDwAuditSink`, by `DwPolicy.DrainAuditAsync(context, sink)` or, per request, by the ASP.NET Core middleware `app.UseDwPolicyAudit()`. A use is what the request reads, not only what it spells out. A request that sends no `Selects` receives the row, so every audited member the query hands back is recorded for `Select` (3.3.0) — one event per query, not per row, and only for a field `[DwAudit]` names. Until 3.3.0 only a field the request wrote down was recorded, which left an empty `Selects` as one token past the control: the same value, returned, with nothing written down. A buffer already holding `DwCaps.MaxAuditEvents` refuses the next audited use with `CapExceeded` — except under `Strict` outside a dry run, where it refuses with the clause's own field refusal instead (3.3.0). An unknown name is never audited and never reaches the cap, so answering with the cap's own code there would have told a caller that the name they guessed is a real field and an audited one. A log of uses never shows a caller probing for columns they may not read: every guess is refused, so nothing was used. `DwPolicyOptions.AuditRefusals` (`bool`, default `false`, new in 3.1.0) records the refusals too. When it is on, every `PolicyException` raised by a guarded entry point of `PolicyQueryable` — terminal or composable — and `ApplyPolicy(context)`'s refusal of an unprepared context are written to the same buffer and drain the same way: @@ -2448,10 +2448,13 @@ These are numbered as this document numbers them. The website's [breaking-change 27. **Four More Refusals Name the Clause Under `Strict`** A strict refusal names no field, and four did. `AmbiguousFieldName` told a caller that the name they wrote matches more than one field, which is to say at least one; it is now refused as an unknown name is, with the ambiguity kept in the trace for the operator who has to fix the aliases. `AmbiguousGroupKey` reported the grouping key's canonical path — the column behind whatever alias the caller wrote — and an origin saying its values are transformed; it now reports `"*"` and no origin. `TransformRequiresMaterialization` listed every transformed column on the type to a caller who named none of them, and now names the clause. `MissingHashSalt` and `MissingTokenVault` named the masked field a deployment forgot to configure for, and now name the clause. All four are unchanged under `Convenience` and in a dry run, where the tier names fields anyway. Code switching on `AmbiguousFieldName` under `Strict`, or reading `FieldPath` off any of the four, sees the change. -28. **The Audit Cap Refuses Like Any Other Field, Under `Strict`** +28. **`[DwAudit]` Records a Read the Request Did Not Name** + A request that sends no `Selects` receives the row, and until 3.3.0 only a field it spelled out was recorded — so that caller read every audited member with nothing written down, one token past a control whose purpose is to answer who read a field. Every audited member a projection the caller did not name hands back is now recorded for `Select`: what the synthesized projection keeps where one is built, and every member the caller may select where none is. One event per query rather than per row, and only for a field `[DwAudit]` names. A deployment already running the control sees more events, and `DwCaps.MaxAuditEvents`, which refuses rather than dropping a record, can be reached by traffic that did not reach it before: raise the cap, or drain per request with `app.UseDwPolicyAudit()`. + +29. **The Audit Cap Refuses Like Any Other Field, Under `Strict`** An audited field records one event per use, and the query is refused rather than the record dropped when `DwCaps.MaxAuditEvents` is reached. Until 3.3.0 that refusal carried `CapExceeded` and a `SourceOrigin` naming the cap, while a name matching nothing carried the ordinary field refusal and no origin — and an unknown name is never audited, so the difference told a caller which names are real and audited. Under `Strict`, outside a dry run, the cap now refuses with the clause's own code, `FieldPath` `"*"` and no origin. The request still fails, so the buffer still fails closed, and the trace still records which refusal it was. `Convenience` and a dry run still answer `CapExceeded`. Code switching on `CapExceeded` under `Strict` sees the change. -29. **`Configure` Takes the Same Posture Twice** +30. **`Configure` Takes the Same Posture Twice** Since 3.3.0 a second `DwPolicy.Configure` or `AddDwPolicies` asking for the posture already in force returns instead of throwing; a different posture still throws. Code that relied on the second call throwing — a test asserting it, or a `try`/`catch` around a second registration — no longer sees the exception. `AddDwPolicies` also registers the posture in force rather than the instance it built, so a container resolving `DwPolicyOptions` after a second registration gets the first one's. The token vault, the service provider and the provider instances are not compared and are not replaced. See [Configuring twice](#configuring-twice). --- diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index 51e4a38..09e9ca1 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -54,6 +54,8 @@ New: DwPolicy.Configure takes a second call asking for the posture already in fo Behaviour change: under the strict tier, a filter, order, grouping key or aggregated field naming a path the query cannot compute is refused as an unknown name is, where the provider used to throw. A projection is not refused: EF Core evaluates the last projection on the client, so a Selects entry naming such a member returns its value as it always did. A member of a row's type is not always a value a database can produce: a getter such as LocalizedText.IsEmpty reads two columns in memory, so every check the policy makes passes and the query then fails inside EF Core with InvalidOperationException — a five-hundred where the strict tier promises a refusal, and the one place the tier answered with neither an answer nor a refusal. The path is refused only where the whole set of members a container can produce is known: the queried entity type's own model, an owned or complex type, and an initializer in a projection EF Core ran before ApplyPolicy, including a member that projection copies straight from the entity. Everywhere else nothing changes — rows in memory run the getter as they always did, a framework member the provider translates such as Length, Year or HasValue is left alone, a value beneath a converted column is left alone, a provider that is not EF Core's decides for itself, the convenience tier and a dry run still fail exactly as an unguarded query does, and a source the library cannot read asks for nothing. A column the model maps only on a subtype is refused through the base type, because EF Core translates a member against the type the query is over and fails there too; query the derived type. The trace records the refusal with its reason, and the trace is now assigned before a request is sanitized, so a refusal leaves LastTrace readable rather than null. +Security fix and behaviour change: [DwAudit] records a read the request did not name. A request sending no Selects receives the row, and only a field it spelled out used to be recorded, so that caller read every audited member with nothing written down — one token past a control whose purpose is to answer who read a field. Every audited member a projection the caller did not name hands back is recorded for Select now: what the synthesized projection keeps where one is built, and every member the caller may select where none is. One event per query rather than per row, and only for a field [DwAudit] names. A deployment already running the control sees more events, and DwCaps.MaxAuditEvents refuses rather than dropping a record, so raise the cap or drain per request with app.UseDwPolicyAudit(). + Security fix and behaviour change: under the strict tier, outside a dry run, four refusals that named a field name the clause instead. A strict refusal names none, so that a denied field, a misspelling and a field that does not exist cannot be told apart. AmbiguousFieldName said the caller's name matched more than one field, and so at least one; it is refused as an unknown name is now, and the trace keeps the ambiguity for the operator who has to fix the aliases. AmbiguousGroupKey reported the column behind the caller's alias, with an origin saying its values are transformed. TransformRequiresMaterialization listed every transformed column on the type to a caller who had named none. MissingHashSalt and MissingTokenVault named the masked field a deployment had not configured for. The last three report "*" and no origin. The convenience tier and a dry run are unchanged. Security fix and behaviour change: under the strict tier, outside a dry run, a query that exhausts the audit buffer is refused with the clause's own field refusal rather than with CapExceeded and a SourceOrigin naming the cap. An audited field records an event per use and the query is refused rather than the record dropped once DwCaps.MaxAuditEvents is reached, which is fail-closed and stays that way; but a name that matches nothing is never audited and never reaches the cap, so the two answers told a caller, one guess per request, which names are real and audited. The refusal now carries the clause's own code, FieldPath "*" and no origin, and the trace records which refusal it really was. The convenience tier and a dry run still answer CapExceeded. diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index a50d77d..63f2f6c 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -17,12 +17,12 @@ export default function Page() {

          Breaking Changes & Known Limitations

          DynamicWhere.ex is intentionally opinionated about how queries are shaped. - The thirty-four points below cover constraints, surprises, and corner cases — + The thirty-five points below cover constraints, surprises, and corner cases — read them before designing an API around the library so you can pick the right entry points and avoid runtime exceptions in production.

          - Points 30 to 34 changed in 3.3.0. Under{" "} + Points 30 to 35 changed in 3.3.0. Under{" "} Strict, a path that exists on the type and names no value the query can compute is refused rather than run, where the provider used to throw and the caller saw a five-hundred (point 30).{" "} @@ -35,7 +35,9 @@ export default function Page() { the clause's own field refusal rather than with{" "} CapExceeded (point 33). Four more refusals that named a field under Strict name the clause instead - (point 34). + (point 34). And [DwAudit] records the audited members + a projection the caller never named hands back, which an empty{" "} + Selects used to return unrecorded (point 35). Points 25 to 29 changed in 3.2.0, and each is @@ -1675,6 +1677,28 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}FieldPath off any of the four, sees the change.

          +

          35. [DwAudit] Records a Read the Request Did Not Name

          +

          + A request that sends no Selects receives the row. Until{" "} + 3.3.0 only a field the request spelled out was + recorded, so that caller read every audited member of the row with + nothing written down — one token past a control whose whole purpose is + to answer who read a field. +

          +

          + Every audited member a projection the caller did not name hands back is + now recorded for Select: the members the synthesized + projection keeps where one is built, and every member the caller may + select where none is, since the row then comes back whole. One event per + query rather than one per row, and only for a field{" "} + [DwAudit] names, so a type with nothing audited records + nothing. A deployment already running the control sees more events than + it did, and DwCaps.MaxAuditEvents — which refuses rather + than dropping a record — can now be reached by traffic that did not + reach it before. Raise the cap, or drain per request with{" "} + app.UseDwPolicyAudit(). +

          +

          See also

          • diff --git a/OfficialWebsite/app/docs/policies/page.tsx b/OfficialWebsite/app/docs/policies/page.tsx index fdfb438..16aaa20 100644 --- a/OfficialWebsite/app/docs/policies/page.tsx +++ b/OfficialWebsite/app/docs/policies/page.tsx @@ -156,7 +156,7 @@ var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter, cancella
          • Dynamic store — rules without a redeploy
          • Store providers — Redis and Entity Framework Core
          • Admin API — schema, rules, explain, simulate, health
          • -
          • Security & k-anonymity — the ten inference channels
          • +
          • Security & k-anonymity — the eleven inference channels
          • Configuration — options, caps and defaults
          diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx index 6b02486..2c82632 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -5,7 +5,7 @@ import { Code } from "@/components/Code"; import Callout from "@/components/Callout"; export const metadata: Metadata = { - title: "Security & k-anonymity — the ten inference channels", + title: "Security & k-anonymity — the eleven inference channels", description: "How DynamicWhere.ex closes the disclosure channels no per-field rule closes on its own — set-operation reconstruction, singleton-group aggregates, cardinality probes, sort-and-page binary search, SQL leakage, and schema probing through refusals — and the denials the gate could not see until 3.2.0.", keywords: ["k-anonymity", "MinGroupSize", "inference attack", "data disclosure", "aggregate disclosure", "EF Core security"], alternates: { canonical: "https://doc.dynamicwhere.com/docs/policies/security/" }, @@ -18,7 +18,7 @@ export default function Page() {

          Denying a field is easy. The hard part is the set of ways a caller can learn a value without reading it. Eight such channels follow, - then two bypasses that are not channels, then a path the query cannot compute + then three bypasses that are not channels, then a path the query cannot compute — not a channel either, but the one request the tier used to answer with neither an answer nor a refusal — then the requests that, until 3.2.0, carried out a denied value the gate could not see; each has a test that @@ -224,7 +224,7 @@ true order. Add [DwNoOrder] unless that is intended.`} unchanged.

          -

          9 and 10. The two that are not channels

          +

          9, 10 and 11. The three that are not channels

          AttackControl
          @@ -236,6 +236,17 @@ true order. Add [DwNoOrder] unless that is intended.`} + + + +
          AttackControl
          An empty policy store Attributes still enforce; an empty store never resolves to Allow
          Reading an audited field by sending no Selects + A use is what the request reads, not only what it spells out. Until + 3.3.0 only a field the request named was recorded, so a caller who + named none received every audited member of the row with nothing + written down — one token past [DwAudit]. Every audited + member a projection the caller did not name hands back is recorded + for Select, one event per query rather than per row. +
          diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index 135ef27..d9c5f20 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -2191,9 +2191,9 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. and in dry run. `CapExceeded`, except under `Strict` outside a dry run, where it is the clause's own field refusal instead (3.3.0): only a real, audited field can reach the cap, so answering with the cap's own code would set such a field apart from a name that matches nothing. -- A use is what the request names, plus a default-order field the query adds. A request sending no `Selects` names - nothing, so an audited field the row carries back is returned with no record: name the fields to be recorded, or - audit `Where`, which a filtering request cannot leave out. +- A use is what the request reads: a field it names, a default-order field the query adds, and — since 3.3.0 — + every audited member a projection the caller did not name hands back, recorded for `Select`. One event per query, + not per row. Until 3.3.0 an empty `Selects` returned the value with nothing written down. - A refusal becomes an event of its own, with an `ErrorCode`, only when `DwPolicyOptions.AuditRefusals` is on, and then for every refused guarded query, audited field or not (3.1.0, section 22). - It is elected: the top-ranked audit wins outright, and fragments tied with it are unioned. A rule can @@ -4281,8 +4281,8 @@ any policy [DwEntity(RequirePolicy = true)] a DynamicWhere ca ### Inference channels -There are ten. The first eight let a caller learn a value, or what the policy hides, without reading it; -**Forgotten guard** and **Empty store** are bypasses. +There are eleven. The first eight let a caller learn a value, or what the policy hides, without reading it; +**Forgotten guard**, **Empty store** and **An unrecorded read** are bypasses. - **Set operations:** `EXCEPT` or `INTERSECT` rebuild a field that is denied for Select but allowed for Where, from set membership. @@ -4308,6 +4308,9 @@ There are ten. The first eight let a caller learn a value, or what the policy hi key, a clause that cannot be transformed, and a deployment with no hash salt or token vault. An ambiguous name is now refused as an unknown name is; the other three name the clause and carry no origin. `Convenience` and a dry run are unchanged. +- **An unrecorded read:** until 3.3.0 `[DwAudit]` recorded only a field the request named, so a caller sending no + `Selects` received every audited member of the row with nothing written down. Every audited member a projection + the caller did not name hands back is recorded for `Select` now, one event per query. - **Which fields exist:** Convenience answers a name that matches nothing with `ConditionMustHasValidFieldName` and a denied field with a refusal that names it and its source, so a caller can map the hidden columns one guess at a time. Strict answers both alike, with the clause's `FieldDeniedFor*` code, `FieldPath` `"*"` and diff --git a/README.md b/README.md index 0d87910..b06dbfd 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,7 @@ The complete reference — every enum, class, extension method, validation rule, - **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a value's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor, and `IncludeTraceInResult` written as the tier's own answer is the answer the tier already gives. A type exposed under two names is reported under the last one, so two catalogues that resolve every name alike are still a different posture when the order differs. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. - **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query any provider but EF Core's own translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()`, or a host's own registered through `ReplaceService` — because such a provider may rewrite what EF Core cannot, and the library cannot tell one that does from one that passes straight through. The test is EF Core's own provider type, from EF Core's own assembly. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. +- **Fixed (security): an audited field could be read with nothing recorded.** `[DwAudit]` answers who read a field, and a request that sent no `Selects` received the whole row with nothing written down — one token past the control. Every audited member a projection the caller did not name hands back is now recorded for `Select`, one event per query rather than per row. A deployment already running the control sees more events, and `DwCaps.MaxAuditEvents` refuses rather than dropping a record, so raise the cap or drain per request with `app.UseDwPolicyAudit()`. - **Fixed (security): four refusals named a field under `Strict`.** A strict refusal names no field, so that a denied one, a misspelling and a field that does not exist cannot be told apart. `AmbiguousFieldName` said the caller's name matched more than one field, and so at least one; it is refused as an unknown name is now, with the ambiguity kept in the trace. `AmbiguousGroupKey` reported the column behind the caller's alias and an origin saying its values are transformed; `TransformRequiresMaterialization` listed every masked column on the type; `MissingHashSalt` and `MissingTokenVault` named the masked field a deployment had not configured for. The three report `"*"` and no origin now. `Convenience` and a dry run are unchanged. - **Fixed (security): the audit cap answered differently for a real field.** An audited field records one event per use, and the query is refused rather than the record dropped once `DwCaps.MaxAuditEvents` is reached. That refusal carried `CapExceeded` and a `SourceOrigin` naming the cap, while a name matching nothing carried the ordinary field refusal and no origin — and an unknown name is never audited, so one guess per request told a caller which names are real and audited. Under `Strict`, outside a dry run, the cap now refuses with the clause's own code, `FieldPath` `"*"` and no origin; the request still fails and the trace still records which refusal it was. `Convenience` and a dry run are unchanged. - **Changed: `LastTrace` is set before a request is sanitized**, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. From 0333c4992cdf330ee0b8ea1e470b9585fcc6f2e7 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 16:35:11 +0300 Subject: [PATCH 27/54] fix(policies): a dry run is either switch, and Apply keeps its documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sixth review found both halves of a mistake I made in the fifth. Three of the four refusals that now name the clause read the posture's DryRun alone, where everything else in the layer reads the union of the posture's and the caller's. A dry run is what lets one canary subject run unenforced while everyone else stays enforced, and a canary meeting the hidden refusal is a canary that cannot preview the posture it exists to preview. All three read the union now, as the ambiguous-name branch already did. The helper that decides what the masking pipeline's refusals name was inserted between Apply's documentation and Apply itself, so the block attached to the helper instead: three CS1572 warnings, and Apply lost its entry in the XML that ships with the package — including the exception this release changed. The helper sits below Apply now, and all four projects build with no warnings again. Co-Authored-By: Claude Opus 5 --- DynamicWhere.Tests/DynamicWhere.Tests.csproj | 2 + DynamicWhere.Tests/Policies/Hx6DocsProbes.cs | 658 +++++++++++++++ .../Policies/Hx6DryRunProbes.cs | 111 +++ .../Policies/Pr6MappingProbes.cs | 242 ++++++ .../Policies/Pr6OverBlockProbes.cs | 773 ++++++++++++++++++ .../Policies/Pr6PrecisionProbes.cs | 528 ++++++++++++ .../Policies/Masking/TransformPipeline.cs | 20 +- .../Policies/Source/PolicyQueryable.cs | 2 +- .../Policies/Source/ResultTransformer.cs | 7 +- 9 files changed, 2330 insertions(+), 13 deletions(-) create mode 100644 DynamicWhere.Tests/Policies/Hx6DocsProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Hx6DryRunProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Pr6MappingProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Pr6OverBlockProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Pr6PrecisionProbes.cs diff --git a/DynamicWhere.Tests/DynamicWhere.Tests.csproj b/DynamicWhere.Tests/DynamicWhere.Tests.csproj index 8f1b0ff..0ea1477 100644 --- a/DynamicWhere.Tests/DynamicWhere.Tests.csproj +++ b/DynamicWhere.Tests/DynamicWhere.Tests.csproj @@ -107,6 +107,8 @@ + + diff --git a/DynamicWhere.Tests/Policies/Hx6DocsProbes.cs b/DynamicWhere.Tests/Policies/Hx6DocsProbes.cs new file mode 100644 index 0000000..91e7ba4 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Hx6DocsProbes.cs @@ -0,0 +1,658 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Tokens; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// Round six of the docs-against-code probe for 3.3.0. Every claim is read by running the library. +// Nothing here fixes anything, and nothing here drives DwPolicy's static fields, by reflection or +// otherwise: every handle is built from its own DwPolicyOptions and its own resolver. + +namespace DynamicWhere.Tests.Policies +{ + // ---- models --------------------------------------------------------------------------------- + + /// + /// A type whose transforms are NOT all masks: one truncation, one generalization, one mask. + /// + /// + /// The point the README and the security page make is that the refusal listed "every masked + /// column". If a truncated-only and a generalized-only column appear in the list too, the list + /// is of transformed columns, which is what DOC.md, the release notes and the breaking-changes + /// page say. + /// + internal class Hx6Staffer + { + public int Id { get; set; } + + public string Department { get; set; } = string.Empty; + + /// Shortened, never masked. + [DwTruncate(3)] + public string Note { get; set; } = string.Empty; + + /// Rounded, never masked. + [DwGeneralize(GeneralizeMode.Round, Step = 100)] + public decimal Salary { get; set; } + + /// The one column that really is masked. + [DwMask(MaskStrategy.Full)] + public string NationalId { get; set; } = string.Empty; + } + + /// Two rows whose keys collide once rounded, so the grouping key is ambiguous. + internal class Hx6Banded + { + public int Id { get; set; } + + [DwGeneralize(GeneralizeMode.Round, Step = 100)] + public decimal Band { get; set; } + } + + /// A hashed column with no salt configured, and a tokenized one with no vault. + internal class Hx6Secret + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Hash)] + public string Hashed { get; set; } = string.Empty; + } + + internal class Hx6Tokenized + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Tokenize)] + public string Ticket { get; set; } = string.Empty; + } + + /// An audited field, recorded for every feature. + internal class Hx6Audited + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwAudit] + public string NationalId { get; set; } = string.Empty; + } + + /// A line of an order, with a getter no database computes. + public class Hx6Line + { + public int Id { get; set; } + + public string Sku { get; set; } = string.Empty; + + public int Hx6OrderId { get; set; } + + /// Computed in memory from the column beside it. + public bool IsBlank => Sku.Length == 0; + } + + public class Hx6Order + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public List Lines { get; set; } = new(); + } + + /// The row a caller projects before the guard sees it. + public class Hx6Row + { + public int Id { get; set; } + + public List Lines { get; set; } = new(); + } + + /// The same row, but its lines are rebuilt by a subquery of its own. + public class Hx6LineRow + { + public string Sku { get; set; } = string.Empty; + + public bool IsBlank => Sku.Length == 0; + } + + public class Hx6BuiltRow + { + public int Id { get; set; } + + public List Lines { get; set; } = new(); + } + + public sealed class Hx6Context : DbContext + { + private readonly SqliteConnection _connection; + + public Hx6Context(SqliteConnection connection) => _connection = connection; + + public DbSet Orders => Set(); + + public DbSet Lines => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + // ---- shared plumbing -------------------------------------------------------------------------- + + internal static class Hx6 + { + private static readonly MethodInfo OfMethod = typeof(RowShape) + .GetMethod("Of", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)!; + + private static readonly MethodInfo ExpressesMethod = typeof(RowShape) + .GetMethod("Expresses", BindingFlags.Instance | BindingFlags.NonPublic)!; + + /// What the shape of a source says about a path: true, false, or "cannot say". + internal static bool? Ask(IQueryable source, string path) + { + object shape = OfMethod.MakeGenericMethod(source.ElementType).Invoke(null, new object[] { source })!; + + return (bool?)ExpressesMethod.Invoke(shape, new object[] { path }); + } + + internal static string Show(bool? answer) => answer switch + { + null => "null (left alone)", + true => "true (nothing to refuse)", + _ => "false (refused)" + }; + + internal static DwPolicyOptions Options( + DwTier tier, + bool dryRun = false, + string? salt = null, + IDwTokenVault? vault = null, + int? floor = null) + { + DwPolicyOptions options = new() { Tier = tier, DryRun = dryRun }; + + if (floor is { } size) + { + // The shipped floor is 5, which removes a group of one before anything collides. + options.Caps.MinGroupSize = size; + } + + if (salt is not null) + { + options.HashSalt = salt; + } + + if (vault is not null) + { + options.TokenVault = vault; + } + + options.Freeze(); + + return options; + } + + internal static PolicyQueryable Guard( + IQueryable source, DwPolicyOptions options, DwPolicyContext? context = null) + where T : class => + source.ApplyPolicy( + context ?? new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + internal static Filter Where(string field, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, + DataType = DataType.Text, + Operator = Operator.Equal, + Values = { value } + } + } + } + }; + + internal static Summary ByBand() => new() + { + GroupBy = new GroupBy + { + Fields = new List { "Band" }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Maximum, Alias = "top" } + } + } + }; + + internal static string Origin(PolicyException error) => + error.SourceOrigin is null ? "" : $"\"{error.SourceOrigin}\""; + } + + // ---- 1. the four refusals that name the clause ------------------------------------------------ + + /// + /// Whether each of the four really reports "*" and no origin under Strict. + /// + public class Hx6NamesTheClauseProbe + { + private readonly ITestOutputHelper _out; + + public Hx6NamesTheClauseProbe(ITestOutputHelper output) => _out = output; + + private static Hx6Staffer[] Staff() => new[] + { + new Hx6Staffer { Id = 1, Department = "Ops", Note = "abcdef", Salary = 120m, NationalId = "A1" } + }; + + private static Hx6Banded[] Banded() => new[] + { + new Hx6Banded { Id = 1, Band = 100m }, + new Hx6Banded { Id = 2, Band = 149m } + }; + + /// + /// TransformRequiresMaterialization under Strict: the field path, and the origin. + /// + [Fact] + public void Transform_requires_materialization_reports_star_and_an_origin() + { + PolicyQueryable guarded = + Hx6.Guard(Staff().AsQueryable(), Hx6.Options(DwTier.Strict, salt: new string('s', 16))); + + PolicyException error = Assert.Throws( + () => guarded.SelectDynamic(new List { "Id" })); + + _out.WriteLine($"code = {error.ErrorCode}"); + _out.WriteLine($"FieldPath = \"{error.FieldPath}\""); + _out.WriteLine($"origin = {Hx6.Origin(error)}"); + + Assert.Equal(PolicyErrorCode.TransformRequiresMaterialization, error.ErrorCode); + Assert.Equal("*", error.FieldPath); + + // The documents that say "the three report '*' and no origin" are wrong about this one. + Assert.NotNull(error.SourceOrigin); + } + + /// + /// And the other three really do carry no origin, so the difference is this one refusal. + /// + [Fact] + public void The_other_three_report_star_with_no_origin() + { + PolicyException group = Assert.Throws( + () => Hx6.Guard(Banded().AsQueryable(), Hx6.Options(DwTier.Strict, floor: 1)).ToList(Hx6.ByBand())); + + PolicyException salt = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Secret { Id = 1, Hashed = "AAA-111" } }.AsQueryable(), + Hx6.Options(DwTier.Strict)) + .ToList(new Filter())); + + PolicyException vault = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Tokenized { Id = 1, Ticket = "T-1" } }.AsQueryable(), + Hx6.Options(DwTier.Strict)) + .ToList(new Filter())); + + foreach (PolicyException error in new[] { group, salt, vault }) + { + _out.WriteLine($"{error.ErrorCode,-34} FieldPath=\"{error.FieldPath}\" origin={Hx6.Origin(error)}"); + + Assert.Equal("*", error.FieldPath); + Assert.Null(error.SourceOrigin); + } + + Assert.Equal(PolicyErrorCode.AmbiguousGroupKey, group.ErrorCode); + Assert.Equal(PolicyErrorCode.MissingHashSalt, salt.ErrorCode); + Assert.Equal(PolicyErrorCode.MissingTokenVault, vault.ErrorCode); + } + + /// + /// Convenience is unchanged: each names the field, and the list is of transformed columns. + /// + [Fact] + public void Convenience_still_names_the_field() + { + PolicyException listed = Assert.Throws( + () => Hx6.Guard(Staff().AsQueryable(), Hx6.Options(DwTier.Convenience, salt: new string('s', 16))) + .SelectDynamic(new List { "Id" })); + + PolicyException group = Assert.Throws( + () => Hx6.Guard(Banded().AsQueryable(), Hx6.Options(DwTier.Convenience, floor: 1)).ToList(Hx6.ByBand())); + + PolicyException salt = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Secret { Id = 1, Hashed = "AAA-111" } }.AsQueryable(), + Hx6.Options(DwTier.Convenience)) + .ToList(new Filter())); + + _out.WriteLine($"TransformRequiresMaterialization FieldPath = \"{listed.FieldPath}\""); + _out.WriteLine($"AmbiguousGroupKey FieldPath = \"{group.FieldPath}\" origin={Hx6.Origin(group)}"); + _out.WriteLine($"MissingHashSalt FieldPath = \"{salt.FieldPath}\""); + + // Every transformed column, not every masked one: Note is truncated and Salary rounded. + Assert.Contains("Note", listed.FieldPath); + Assert.Contains("Salary", listed.FieldPath); + Assert.Contains("NationalId", listed.FieldPath); + + Assert.Equal("Band", group.FieldPath); + Assert.Equal("Hashed", salt.FieldPath); + } + + /// + /// The global dry run is unchanged — and so, the documents say, is "a dry run". The + /// per-context one is the other half of what the library calls a dry run everywhere else. + /// + [Fact] + public void A_dry_run_names_the_field_whichever_switch_declares_it() + { + PolicyException global = Assert.Throws( + () => Hx6.Guard(Banded().AsQueryable(), Hx6.Options(DwTier.Strict, dryRun: true, floor: 1)) + .ToList(Hx6.ByBand())); + + PolicyException percall = Assert.Throws( + () => Hx6.Guard( + Banded().AsQueryable(), + Hx6.Options(DwTier.Strict, floor: 1), + new DwPolicyContext { DryRun = true }.WithSubject(DwSubjectKind.User, "u1")) + .ToList(Hx6.ByBand())); + + _out.WriteLine($"options.DryRun = true -> FieldPath=\"{global.FieldPath}\" origin={Hx6.Origin(global)}"); + _out.WriteLine($"context.DryRun = true -> FieldPath=\"{percall.FieldPath}\" origin={Hx6.Origin(percall)}"); + + Assert.Equal("Band", global.FieldPath); + Assert.NotNull(global.SourceOrigin); + + // A dry run is either switch, the posture's or the caller's, as it is everywhere else in + // the layer: a canary subject previewing a posture must not meet the hidden refusal. + Assert.Equal("Band", percall.FieldPath); + Assert.NotNull(percall.SourceOrigin); + } + + /// + /// The per-context dry run is honoured by the refusal the same page describes one section + /// earlier, so the two are not consistent with each other. + /// + [Fact] + public void A_context_dry_run_is_honoured_by_the_unknown_name_gate() + { + // Strict, but the caller's own context is the canary: the gate stops hiding existence, + // so an unknown name fails as it would unguarded rather than as a field refusal. + LogicException unguarded = Assert.Throws( + () => Hx6.Guard( + Staff().AsQueryable(), + Hx6.Options(DwTier.Strict, salt: new string('s', 16)), + new DwPolicyContext { DryRun = true }.WithSubject(DwSubjectKind.User, "u1")) + .ToList(Hx6.Where("NoSuchField", "x"))); + + _out.WriteLine($"context dry run, unknown name -> {unguarded.GetType().Name}: {unguarded.Message}"); + + // Without it, the same name is a field refusal naming nothing. + PolicyException hidden = Assert.Throws( + () => Hx6.Guard( + Staff().AsQueryable(), + Hx6.Options(DwTier.Strict, salt: new string('s', 16))) + .ToList(Hx6.Where("NoSuchField", "x"))); + + _out.WriteLine($"no dry run, unknown name -> {hidden.ErrorCode} \"{hidden.FieldPath}\""); + } + } + + // ---- 2. an ambiguous name ----------------------------------------------------------------------- + + /// A type with one alias pointing at two different members. + internal class Hx6Ambiguous + { + public int Id { get; set; } + + [DwAlias("ref")] + public string Primary { get; set; } = string.Empty; + + [DwAlias("ref")] + public string Secondary { get; set; } = string.Empty; + } + + public class Hx6AmbiguousNameProbe + { + private readonly ITestOutputHelper _out; + + public Hx6AmbiguousNameProbe(ITestOutputHelper output) => _out = output; + + private static Hx6Ambiguous[] Rows() => new[] + { + new Hx6Ambiguous { Id = 1, Primary = "a", Secondary = "b" } + }; + + /// + /// Under Strict the ambiguous name is refused as an unknown name is, and the trace names the + /// fields it matched. + /// + [Fact] + public void An_ambiguous_name_is_refused_as_an_unknown_name_is() + { + PolicyQueryable guarded = + Hx6.Guard(Rows().AsQueryable(), Hx6.Options(DwTier.Strict)); + + PolicyException ambiguous = Assert.Throws( + () => guarded.ToList(Hx6.Where("ref", "a"))); + + PolicyQueryable second = + Hx6.Guard(Rows().AsQueryable(), Hx6.Options(DwTier.Strict)); + + PolicyException unknown = Assert.Throws( + () => second.ToList(Hx6.Where("NoSuchField", "a"))); + + _out.WriteLine($"ambiguous: {ambiguous.ErrorCode} \"{ambiguous.FieldPath}\" origin={Hx6.Origin(ambiguous)}"); + _out.WriteLine($"unknown : {unknown.ErrorCode} \"{unknown.FieldPath}\" origin={Hx6.Origin(unknown)}"); + + Assert.Equal(unknown.ErrorCode, ambiguous.ErrorCode); + Assert.Equal(unknown.FieldPath, ambiguous.FieldPath); + Assert.Equal(unknown.SourceOrigin, ambiguous.SourceOrigin); + + PolicyTrace? trace = guarded.LastTrace; + + Assert.NotNull(trace); + + foreach (PolicyDecision decision in trace!.Decisions) + { + _out.WriteLine($" trace: {decision.FieldPath} {decision.Action} — {decision.Reason}"); + } + + Assert.Contains( + trace.Decisions, + decision => decision.Reason is not null + && decision.Reason.Contains("Primary") + && decision.Reason.Contains("Secondary")); + } + + /// Convenience still answers AmbiguousFieldName. + [Fact] + public void Convenience_still_answers_ambiguous_field_name() + { + PolicyException error = Assert.Throws( + () => Hx6.Guard(Rows().AsQueryable(), Hx6.Options(DwTier.Convenience)) + .ToList(Hx6.Where("ref", "a"))); + + _out.WriteLine($"{error.ErrorCode} \"{error.FieldPath}\""); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, error.ErrorCode); + } + } + + // ---- 3. what [DwAudit] records ------------------------------------------------------------------ + + public class Hx6AuditProbe + { + private readonly ITestOutputHelper _out; + + public Hx6AuditProbe(ITestOutputHelper output) => _out = output; + + private static Hx6Audited[] Rows() => new[] + { + new Hx6Audited { Id = 1, Code = "c1", NationalId = "A-1" } + }; + + /// + /// A request that sends no Selects reads the audited field and records nothing. + /// + [Fact] + public void A_request_with_no_selects_reads_the_field_and_records_it() + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + FilterResult result = + Hx6.Guard(Rows().AsQueryable(), Hx6.Options(DwTier.Strict), context).ToList(new Filter()); + + _out.WriteLine($"rows = {result.Data.Count}"); + _out.WriteLine($"NationalId = \"{result.Data[0].NationalId}\""); + _out.WriteLine($"audit events = {context.PendingAuditEvents.Count}"); + + Assert.Equal("A-1", result.Data[0].NationalId); + + // The caller receives the value, so the log says so: a use is what the request reads, + // not only what it spells out. + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); + } + + /// Naming the field records it, which is the documented way to be recorded. + [Fact] + public void Naming_the_field_records_it() + { + DwPolicyContext named = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + Hx6.Guard(Rows().AsQueryable(), Hx6.Options(DwTier.Strict), named) + .ToList(new Filter { Selects = new List { "Id", "NationalId" } }); + + DwPolicyContext filtered = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + Hx6.Guard(Rows().AsQueryable(), Hx6.Options(DwTier.Strict), filtered) + .ToList(Hx6.Where("NationalId", "A-1")); + + foreach (DwAuditEvent recorded in named.PendingAuditEvents) + { + _out.WriteLine($"Selects -> {recorded.FieldPath} {recorded.Feature}"); + } + + foreach (DwAuditEvent recorded in filtered.PendingAuditEvents) + { + _out.WriteLine($"Where -> {recorded.FieldPath} {recorded.Feature}"); + } + + Assert.Contains(named.PendingAuditEvents, e => e.FieldPath == "NationalId"); + Assert.Contains(filtered.PendingAuditEvents, e => e.FieldPath == "NationalId"); + } + } + + // ---- 4. the member a sequence operator hides ---------------------------------------------------- + + public class Hx6SequenceOperatorProbe : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Hx6Context _db; + + public Hx6SequenceOperatorProbe(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("Filename=:memory:"); + _connection.Open(); + _db = new Hx6Context(_connection); + _db.Database.EnsureCreated(); + + _db.Orders.Add(new Hx6Order + { + Id = 1, + Code = "o1", + Lines = { new Hx6Line { Id = 1, Sku = "s1" } } + }); + + _db.SaveChanges(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + /// + /// Each of the three shapes the documents name, and the entity for contrast. + /// + [Fact] + public void A_member_assigned_through_a_sequence_operator_is_left_alone() + { + IQueryable plain = _db.Orders + .Select(o => new Hx6Row { Id = o.Id, Lines = o.Lines.ToList() }); + + IQueryable filtered = _db.Orders + .Select(o => new Hx6Row { Id = o.Id, Lines = o.Lines.Where(l => l.Sku != "").ToList() }); + + IQueryable built = _db.Orders + .Select(o => new Hx6BuiltRow + { + Id = o.Id, + Lines = o.Lines.Select(l => new Hx6LineRow { Sku = l.Sku }).ToList() + }); + + bool? plainAnswer = Hx6.Ask(plain, "Lines.IsBlank"); + bool? filteredAnswer = Hx6.Ask(filtered, "Lines.IsBlank"); + bool? builtAnswer = Hx6.Ask(built, "Lines.IsBlank"); + bool? entityAnswer = Hx6.Ask(_db.Orders, "Lines.IsBlank"); + + _out.WriteLine($"Lines = o.Lines.ToList() -> {Hx6.Show(plainAnswer)}"); + _out.WriteLine($"Lines = o.Lines.Where(...).ToList() -> {Hx6.Show(filteredAnswer)}"); + _out.WriteLine($"Lines = o.Lines.Select(l => new ...).ToList() -> {Hx6.Show(builtAnswer)}"); + _out.WriteLine($"the entity itself -> {Hx6.Show(entityAnswer)}"); + + Assert.Null(plainAnswer); + Assert.Null(filteredAnswer); + Assert.Null(builtAnswer); + + // The entity is the contrast: there the member is refused. + Assert.False(entityAnswer); + } + + /// + /// End to end: the entity refuses the path, the projection with the sequence operator does + /// not — it reaches the provider and fails there, as an unguarded query does. + /// + [Fact] + public void The_entity_refuses_and_the_projection_reaches_the_provider() + { + PolicyException refused = Assert.Throws( + () => Hx6.Guard(_db.Orders, Hx6.Options(DwTier.Strict)) + .ToList(Hx6.Where("Lines.IsBlank", "true"))); + + _out.WriteLine($"entity : {refused.ErrorCode} \"{refused.FieldPath}\""); + + IQueryable projected = _db.Orders + .Select(o => new Hx6Row { Id = o.Id, Lines = o.Lines.ToList() }); + + Exception passed = Assert.ThrowsAny( + () => Hx6.Guard(projected, Hx6.Options(DwTier.Strict)) + .ToList(Hx6.Where("Lines.IsBlank", "true"))); + + _out.WriteLine($"projection : {passed.GetType().Name}"); + + Assert.IsNotType(passed); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Hx6DryRunProbes.cs b/DynamicWhere.Tests/Policies/Hx6DryRunProbes.cs new file mode 100644 index 0000000..a458e90 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Hx6DryRunProbes.cs @@ -0,0 +1,111 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// + /// Which dry run each refusal honours. The library has two — the global switch and the caller's + /// own, whose union is what every other refusal calls "a dry run" — and the documents say only + /// "a dry run" for all of them. + /// + public class Hx6DryRunReachProbe + { + private readonly ITestOutputHelper _out; + + public Hx6DryRunReachProbe(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Canary() => + new DwPolicyContext { DryRun = true }.WithSubject(DwSubjectKind.User, "u1"); + + /// + /// The audit cap (breaking point 33) honours the caller's own dry run, because it asks the + /// gate. The four of point 34 ask the options directly. + /// + [Fact] + public void A_context_dry_run_is_honoured_by_the_audit_cap_and_by_the_four() + { + DwPolicyOptions capped = new() { Tier = DwTier.Strict }; + + capped.Caps.MaxAuditEvents = 1; + capped.Freeze(); + + Filter request = new() + { + Selects = new List { "Id", "NationalId", "Code" }, + Orders = new List { new() { Field = "NationalId", Direction = Direction.Ascending } } + }; + + PolicyException cap = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Audited { Id = 1, Code = "c", NationalId = "A-1" } }.AsQueryable(), + capped, + Canary()) + .ToList(request)); + + _out.WriteLine($"audit cap, context dry run -> {cap.ErrorCode} \"{cap.FieldPath}\" origin={Hx6.Origin(cap)}"); + + PolicyException transform = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Staffer { Id = 1, Note = "abcdef" } }.AsQueryable(), + Hx6.Options(DwTier.Strict, salt: new string('s', 16)), + Canary()) + .SelectDynamic(new List { "Id" })); + + PolicyException salt = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Secret { Id = 1, Hashed = "AAA-111" } }.AsQueryable(), + Hx6.Options(DwTier.Strict), + Canary()) + .ToList(new Filter())); + + _out.WriteLine($"TRM, context dry run -> {transform.ErrorCode} \"{transform.FieldPath}\""); + _out.WriteLine($"salt, context dry run -> {salt.ErrorCode} \"{salt.FieldPath}\""); + + // A canary subject previews the posture without meeting the refusals it hides, which is + // what a dry run is for — and a dry run is either switch, the posture's or the caller's. + Assert.Equal(PolicyErrorCode.CapExceeded, cap.ErrorCode); + Assert.NotEqual("*", transform.FieldPath); + Assert.NotEqual("*", salt.FieldPath); + } + + /// + /// The global switch is the one the four read, and there they do name the field, which is + /// the half of "a dry run is unchanged" that holds. + /// + [Fact] + public void The_global_dry_run_still_names_the_field_for_all_four() + { + PolicyException transform = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Staffer { Id = 1, Note = "abcdef" } }.AsQueryable(), + Hx6.Options(DwTier.Strict, dryRun: true, salt: new string('s', 16))) + .SelectDynamic(new List { "Id" })); + + PolicyException salt = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Secret { Id = 1, Hashed = "AAA-111" } }.AsQueryable(), + Hx6.Options(DwTier.Strict, dryRun: true)) + .ToList(new Filter())); + + PolicyException vault = Assert.Throws( + () => Hx6.Guard( + new[] { new Hx6Tokenized { Id = 1, Ticket = "T-1" } }.AsQueryable(), + Hx6.Options(DwTier.Strict, dryRun: true)) + .ToList(new Filter())); + + _out.WriteLine($"TRM, global dry run -> \"{transform.FieldPath}\""); + _out.WriteLine($"salt, global dry run -> \"{salt.FieldPath}\""); + _out.WriteLine($"vault, global dry run -> \"{vault.FieldPath}\""); + + Assert.Equal("Note, Salary, NationalId", transform.FieldPath); + Assert.Equal("Hashed", salt.FieldPath); + Assert.Equal("Ticket", vault.FieldPath); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Pr6MappingProbes.cs b/DynamicWhere.Tests/Policies/Pr6MappingProbes.cs new file mode 100644 index 0000000..019b238 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Pr6MappingProbes.cs @@ -0,0 +1,242 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// An owned type stored as a JSON document. + public class P6Json + { + public string City { get; set; } = string.Empty; + + public string Street { get; set; } = string.Empty; + + /// A getter over two members of the document. + public string Full => $"{Street}, {City}"; + } + + /// A complex type: its members are columns of the owner's table. + public class P6Money + { + public decimal Amount { get; set; } + + public string Currency { get; set; } = string.Empty; + + public bool IsFree => Amount == 0m; + } + + public class P6Doc + { + public int Id { get; set; } + + public string Number { get; set; } = string.Empty; + + /// A JSON column. + public P6Json Where { get; set; } = new(); + + /// A complex property. + public P6Money Total { get; set; } = new(); + + /// A primitive collection: one column holding a list of scalars. + public List Tags { get; set; } = new(); + } + + public sealed class P6MappingContext : DbContext + { + private readonly SqliteConnection _connection; + + public P6MappingContext(SqliteConnection connection) => _connection = connection; + + public DbSet Docs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(doc => doc.Where, json => json.ToJson()); + model.Entity().ComplexProperty(doc => doc.Total); + model.Entity().PrimitiveCollection(doc => doc.Tags); + } + } + + /// + /// Round 6, the mapping shapes only EF Core 8 has: a JSON column, a complex property and a + /// primitive collection, each run guarded and unguarded. + /// + public sealed class Pr6MappingProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly P6MappingContext _db; + private readonly List _findings = new(); + + public Pr6MappingProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new P6MappingContext(_connection); + _db.Database.EnsureCreated(); + _db.Docs.Add(new P6Doc + { + Number = "D-1", + Where = new P6Json { City = "Baghdad", Street = "Al Rasheed" }, + Total = new P6Money { Amount = 10m, Currency = "IQD" }, + Tags = new List { "red", "blue" } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static string Guarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = Guard(source).ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, DataType = type, Operator = Operator.Equal, Values = { value } + } + } + } + }); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + /// The same request through the library with no policy attached at all. + private static string Unguarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = source.ToList(Clause(field, type, value)); + + return $"OK({result.Data.Count})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static Filter Clause(string field, DataType type, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + private static string Raw(Func query) + { + try + { + object value = query(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private void Case(string probe, string unguarded, string guarded) + { + _out.WriteLine($"{probe,-58} unguarded={unguarded,-26} guarded={guarded}"); + + if (unguarded.StartsWith("OK", StringComparison.Ordinal) + && guarded.StartsWith("REFUSED", StringComparison.Ordinal)) + { + _findings.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + [Fact] + public void Pr6_F_The_three_mapping_shapes_only_EF_Core_8_has() + { + Case("F01 a member of a JSON column", + Raw(() => _db.Docs.Where(d => d.Where.City == "Baghdad").ToList()), + Guarded(_db.Docs, "Where.City", DataType.Text, "Baghdad")); + + Case("F02 a second member of the same JSON column", + Raw(() => _db.Docs.Where(d => d.Where.Street == "Al Rasheed").ToList()), + Guarded(_db.Docs, "Where.Street", DataType.Text, "Al Rasheed")); + + Case("F03 a getter over two members of a JSON column (refusal is right)", + Raw(() => _db.Docs.Where(d => d.Where.Full == "Al Rasheed, Baghdad").ToList()), + Guarded(_db.Docs, "Where.Full", DataType.Text, "Al Rasheed, Baghdad")); + + Case("F04 a column of a complex property", + Raw(() => _db.Docs.Where(d => d.Total.Currency == "IQD").ToList()), + Guarded(_db.Docs, "Total.Currency", DataType.Text, "IQD")); + + Case("F05 a numeric column of a complex property", + Raw(() => _db.Docs.Where(d => d.Total.Amount == 10m).ToList()), + Guarded(_db.Docs, "Total.Amount", DataType.Number, "10")); + + Case("F06 a getter on a complex type (refusal is right)", + Raw(() => _db.Docs.Where(d => d.Total.IsFree).ToList()), + Guarded(_db.Docs, "Total.IsFree", DataType.Boolean, "false")); + + // Printed, not flagged: the library refuses Tags.Count with no policy attached either + // (F09), so the hand-written LINQ is not a control the guard can be held to. + _out.WriteLine("F07 a primitive collection's Count, hand-written LINQ unguarded=" + + Raw(() => _db.Docs.Where(d => d.Tags.Count == 2).ToList()) + + " guarded=" + Guarded(_db.Docs, "Tags.Count", DataType.Number, "2")); + + // The control that matters: the same field through the library with no policy at all. + Case("F09 Tags.Count through the library, unguarded", + Unguarded(_db.Docs, "Tags.Count", DataType.Number, "2"), + Guarded(_db.Docs, "Tags.Count", DataType.Number, "2")); + + Case("F08 a plain column beside all three", + Raw(() => _db.Docs.Where(d => d.Number == "D-1").ToList()), + Guarded(_db.Docs, "Number", DataType.Text, "D-1")); + + Assert.True(_findings.Count == 0, string.Join(" || ", _findings)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Pr6OverBlockProbes.cs b/DynamicWhere.Tests/Policies/Pr6OverBlockProbes.cs new file mode 100644 index 0000000..1058d35 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Pr6OverBlockProbes.cs @@ -0,0 +1,773 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- entity shapes ---------------------------------------------------------------------------------- + + /// Owned, with a getter over two columns that no database can compute. + public class P6Text + { + public string Ar { get; set; } = string.Empty; + + public string En { get; set; } = string.Empty; + + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + public class P6Customer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Unmapped getter. + public string Handle => Name.ToLowerInvariant(); + + /// Table splitting: this lives in the customer's own table. + public P6Detail Detail { get; set; } = new(); + + public List Orders { get; set; } = new(); + } + + public class P6Detail + { + public int Id { get; set; } + + public string Notes { get; set; } = string.Empty; + } + + public class P6Line + { + public int Id { get; set; } + + public int OrderId { get; set; } + + public P6Order Order { get; set; } = null!; + + public string Sku { get; set; } = string.Empty; + + public int Qty { get; set; } + + /// Unmapped getter whose name a projected element row also declares. + public string Label => $"{Sku}#{Qty}"; + } + + public class P6Order + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public decimal Amount { get; set; } + + public int? Bonus { get; set; } + + /// A computed column. + public decimal Doubled { get; set; } + + /// Owned. + public P6Text Title { get; set; } = new(); + + /// A column a value converter builds. + public P6Text Badge { get; set; } = new(); + + public int CustomerId { get; set; } + + public P6Customer Customer { get; set; } = null!; + + public List Lines { get; set; } = new(); + + /// Unmapped getter. + public string Display => $"{Code}/{Id}"; + } + + /// TPH. + public class P6Party + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + } + + public class P6Vendor : P6Party + { + public string? Vat { get; set; } + } + + /// TPT. + public class P6Animal + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class P6Dog : P6Animal + { + public string? Breed { get; set; } + } + + /// Keyless. + public class P6Stat + { + public string Bucket { get; set; } = string.Empty; + + public int Total { get; set; } + } + + // ---- the rows a caller projects --------------------------------------------------------------------- + + public class P6Nest + { + public string A { get; set; } = string.Empty; + + public string B { get; set; } = string.Empty; + } + + public class P6LineRow + { + /// The entity declares this name and maps nothing for it. + public string Label { get; set; } = string.Empty; + + public int Qty { get; set; } + + /// A name the entity does not declare at all. + public string Note { get; set; } = string.Empty; + } + + public class P6Row + { + public P6Row() + { + } + + public P6Row(int id, string code) + { + Id = id; + Code = code; + } + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string Tag { get; set; } = string.Empty; + + public string Flag { get; set; } = string.Empty; + + public int Count { get; set; } + + public decimal Total { get; set; } + + public long Wide { get; set; } + + public P6Text Title { get; set; } = new(); + + public P6Nest Nest { get; set; } = new(); + + /// A projected collection of projected rows. + public List Lines { get; set; } = new(); + + /// The entity's own rows, copied whole. + public List Raw { get; set; } = new(); + + public P6Customer? Customer { get; set; } + + public P6Line? One { get; set; } + } + + public sealed class P6Context : DbContext + { + private readonly SqliteConnection _connection; + + public P6Context(SqliteConnection connection) => _connection = connection; + + public DbSet Orders => Set(); + + public DbSet Customers => Set(); + + public DbSet Lines => Set(); + + public DbSet Parties => Set(); + + public DbSet Animals => Set(); + + public DbSet Stats => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(order => order.Title); + model.Entity().Ignore(order => order.Display); + model.Entity().Property(order => order.Doubled).HasComputedColumnSql("\"Amount\" * 2"); + model.Entity().Property("Tenant"); + model.Entity().Property(order => order.Badge).HasConversion( + new ValueConverter( + text => text.En, + stored => new P6Text { En = stored, Ar = stored })); + + model.Entity().Ignore(customer => customer.Handle); + + // Table splitting: the detail shares the customer's table. + model.Entity().ToTable("P6Customers"); + model.Entity().HasOne(customer => customer.Detail).WithOne() + .HasForeignKey(detail => detail.Id); + model.Entity().ToTable("P6Customers"); + + model.Entity().Ignore(line => line.Label); + + model.Entity().HasDiscriminator("Discriminator") + .HasValue("party") + .HasValue("vendor"); + + // TPT: each type its own table. + model.Entity().ToTable("P6Animals"); + model.Entity().ToTable("P6Dogs"); + + model.Entity().HasNoKey().ToTable("P6Stats"); + } + } + + /// + /// Round 6 of the over-blocking hunt: every entity shape and every projection shape, run guarded + /// and unguarded, so a query that ran in 3.2.0 and still runs unguarded is visible when the guard + /// refuses it. + /// + public sealed class Pr6OverBlockProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly P6Context _db; + private readonly List _findings = new(); + + public Pr6OverBlockProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new P6Context(_connection); + _db.Database.EnsureCreated(); + + P6Customer customer = new() + { + Name = "Acme", + Detail = new P6Detail { Notes = "vip" } + }; + + P6Order order = new() + { + Customer = customer, + Code = "AB123", + Amount = 10m, + Bonus = 3, + Title = new P6Text { Ar = "AR", En = "EN" }, + Badge = new P6Text { En = "gold" } + }; + + order.Lines.Add(new P6Line { Sku = "S-1", Qty = 2 }); + order.Lines.Add(new P6Line { Sku = "S-2", Qty = 4 }); + + _db.Customers.Add(customer); + _db.Orders.Add(order); + _db.Entry(order).Property("Tenant").CurrentValue = "t1"; + _db.Parties.Add(new P6Vendor { Kind = "vendor", Vat = "V-1" }); + _db.Animals.Add(new P6Dog { Name = "Rex", Breed = "collie" }); + _db.SaveChanges(); + + // Keyless rows cannot be tracked, so the one row goes in directly. + _db.Database.ExecuteSqlRaw("INSERT INTO P6Stats (Bucket, Total) VALUES ('b1', 7)"); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness ----------------------------------------------------------------------------- + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, DataType type, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Field = field, DataType = type, Operator = Operator.Equal, Values = { value } } + } + } + }; + + private static string Guarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = Guard(source).ToList(Where(field, type, value)); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + /// A projection of the named field, which the tier exempts from the computed check. + private static string GuardedSelect(IQueryable source, string field) where T : class + { + try + { + FilterResult result = Guard(source) + .ToListDynamic(new Filter { Selects = new List { field } }); + + return $"OK({result.Data.Count})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + /// The same request through the library with no policy attached at all. + private static string Unguarded(IQueryable source, string field, DataType type, string value) + where T : class + { + try + { + FilterResult result = source.ToList(Where(field, type, value)); + + return $"OK({result.Data.Count})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static string Raw(Func query) + { + try + { + object value = query(); + + return value is System.Collections.ICollection rows ? $"OK({rows.Count})" : "OK"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private void Case(string probe, string unguarded, string guarded) + { + _out.WriteLine($"{probe,-64} unguarded={unguarded,-24} guarded={guarded}"); + + if (unguarded.StartsWith("OK", StringComparison.Ordinal) + && guarded.StartsWith("REFUSED", StringComparison.Ordinal)) + { + _findings.Add($"{probe}: unguarded {unguarded}, guarded {guarded}"); + } + } + + private void Done() => Assert.True(_findings.Count == 0, string.Join(" || ", _findings)); + + // ========================================================================================= + // Pr6-D. Entity shapes. + // ========================================================================================= + + [Fact] + public void Pr6_D_Every_mapped_entity_shape_is_left_alone() + { + Case("D01 plain column", + Raw(() => _db.Orders.Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders, "Code", DataType.Text, "AB123")); + + Case("D02 owned type's column", + Raw(() => _db.Orders.Where(o => o.Title.En == "EN").ToList()), + Guarded(_db.Orders, "Title.En", DataType.Text, "EN")); + + Case("D03 getter on an owned type (refusal is right)", + Raw(() => _db.Orders.Where(o => o.Title.IsEmpty).ToList()), + Guarded(_db.Orders, "Title.IsEmpty", DataType.Boolean, "false")); + + Case("D04 computed column", + Raw(() => _db.Orders.Where(o => o.Doubled == 20m).ToList()), + Guarded(_db.Orders, "Doubled", DataType.Number, "20")); + + Case("D05 a column a converter builds", + Raw(() => _db.Orders.Where(o => o.Badge.En == "gold").ToList()), + Guarded(_db.Orders, "Badge.En", DataType.Text, "gold")); + + Case("D06 nullable column", + Raw(() => _db.Orders.Where(o => o.Bonus == 3).ToList()), + Guarded(_db.Orders, "Bonus", DataType.Number, "3")); + + Case("D07 navigation to the principal", + Raw(() => _db.Orders.Where(o => o.Customer.Name == "Acme").ToList()), + Guarded(_db.Orders, "Customer.Name", DataType.Text, "Acme")); + + Case("D08 a framework member of a column", + Raw(() => _db.Orders.Where(o => o.Code.Length == 5).ToList()), + Guarded(_db.Orders, "Code.Length", DataType.Number, "5")); + + Case("D09 table splitting: the dependent's column", + Raw(() => _db.Customers.Where(c => c.Detail.Notes == "vip").ToList()), + Guarded(_db.Customers, "Detail.Notes", DataType.Text, "vip")); + + Case("D10 TPH: the base type's column", + Raw(() => _db.Parties.Where(p => p.Kind == "vendor").ToList()), + Guarded(_db.Parties, "Kind", DataType.Text, "vendor")); + + Case("D11 TPH: a subtype's own column, queried on the subtype", + Raw(() => _db.Parties.OfType().Where(v => v.Vat == "V-1").ToList()), + Guarded(_db.Parties.OfType(), "Vat", DataType.Text, "V-1")); + + Case("D12 TPT: the base type's column", + Raw(() => _db.Animals.Where(a => a.Name == "Rex").ToList()), + Guarded(_db.Animals, "Name", DataType.Text, "Rex")); + + Case("D13 TPT: a subtype's own column, queried on the subtype", + Raw(() => _db.Animals.OfType().Where(d => d.Breed == "collie").ToList()), + Guarded(_db.Animals.OfType(), "Breed", DataType.Text, "collie")); + + Case("D14 keyless entity", + Raw(() => _db.Stats.Where(s => s.Bucket == "b1").ToList()), + Guarded(_db.Stats, "Bucket", DataType.Text, "b1")); + + // Printed, not flagged: EF.Property is not something a Filter can express, so the + // comparison that means anything is D19 below. + _out.WriteLine("D15 shadow property, hand-written LINQ unguarded=" + + Raw(() => _db.Orders.Where(o => EF.Property(o, "Tenant") == "t1").ToList()) + + " guarded=" + Guarded(_db.Orders, "Tenant", DataType.Text, "t1")); + + Case("D16 an unmapped getter on the entity (refusal is right)", + Raw(() => _db.Orders.Where(o => o.Display == "AB123/1").ToList()), + Guarded(_db.Orders, "Display", DataType.Text, "AB123/1")); + + Case("D17 a collection's element column", + Raw(() => _db.Orders.Where(o => o.Lines.Any(l => l.Sku == "S-1")).ToList()), + Guarded(_db.Lines, "Sku", DataType.Text, "S-1")); + + Case("D18 a navigation back to the principal from the dependent", + Raw(() => _db.Lines.Where(l => l.Order.Code == "AB123").ToList()), + Guarded(_db.Lines, "Order.Code", DataType.Text, "AB123")); + + // The control that matters for a shadow property: no Filter a caller writes can name it, + // guarded or not, because it is not a member of the type. + Case("D19 the shadow property through the library, unguarded", + Unguarded(_db.Orders, "Tenant", DataType.Text, "t1"), + Guarded(_db.Orders, "Tenant", DataType.Text, "t1")); + + Case("D20 a mapped column through the library, unguarded", + Unguarded(_db.Orders, "Code", DataType.Text, "AB123"), + Guarded(_db.Orders, "Code", DataType.Text, "AB123")); + + Done(); + } + + // ========================================================================================= + // Pr6-E. Projection shapes: a member of a projected row is read out of the row, and the + // projection clause itself is exempt from what the provider can compute. + // ========================================================================================= + + [Fact] + public void Pr6_E_A_row_built_by_a_subquery_of_every_operator() + { + IQueryable rows = _db.Orders.Select(order => new P6Row + { + Id = order.Id, + Code = order.Code, + Count = order.Lines.Count(), + Total = order.Lines.Sum(line => line.Qty), + Wide = order.Lines.Max(line => line.Qty), + Tag = order.Lines.OrderBy(line => line.Id).Select(line => line.Sku).FirstOrDefault() ?? "none", + Flag = order.Lines.Any(line => line.Qty > 3) ? "big" : "small", + Lines = order.Lines + .Where(line => line.Qty > 0) + .OrderBy(line => line.Id) + .Select(line => new P6LineRow { Label = line.Sku, Qty = line.Qty, Note = "n" }) + .ToList(), + Raw = order.Lines.ToList(), + One = order.Lines.OrderBy(line => line.Id).FirstOrDefault(), + Customer = order.Customer + }); + + Case("E01 Count() subquery", + Raw(() => Shape().Where(r => r.Count == 2).ToList()), + Guarded(rows, "Count", DataType.Number, "2")); + + Case("E02 Sum() subquery", + Raw(() => Shape().Where(r => r.Total == 6m).ToList()), + Guarded(rows, "Total", DataType.Number, "6")); + + Case("E03 Max() subquery", + Raw(() => Shape().Where(r => r.Wide == 4L).ToList()), + Guarded(rows, "Wide", DataType.Number, "4")); + + Case("E04 OrderBy/Select/FirstOrDefault with ??", + Raw(() => Shape().Where(r => r.Tag == "S-1").ToList()), + Guarded(rows, "Tag", DataType.Text, "S-1")); + + Case("E05 Any() inside a conditional", + Raw(() => Shape().Where(r => r.Flag == "big").ToList()), + Guarded(rows, "Flag", DataType.Text, "big")); + + Case("E06 a member of a projected collection of projected rows", + Raw(() => Shape().Where(r => r.Lines.Any(l => l.Qty == 2)).ToList()), + Guarded(rows, "Lines.Qty", DataType.Number, "2")); + + Case("E07 a member the element row assigns and the entity only computes", + Raw(() => Shape().Where(r => r.Lines.Any(l => l.Label == "S-1")).ToList()), + Guarded(rows, "Lines.Label", DataType.Text, "S-1")); + + Case("E08 a member only the element row declares", + Raw(() => Shape().Where(r => r.Lines.Any(l => l.Note == "n")).ToList()), + Guarded(rows, "Lines.Note", DataType.Text, "n")); + + Case("E09 the entity's own rows copied whole", + Raw(() => Shape().Where(r => r.Raw.Any(l => l.Sku == "S-1")).ToList()), + Guarded(rows, "Raw.Sku", DataType.Text, "S-1")); + + Case("E10 a single entity read out of a collection", + Raw(() => Shape().Where(r => r.One!.Sku == "S-1").ToList()), + Guarded(rows, "One.Sku", DataType.Text, "S-1")); + + Case("E11 a navigation copied whole", + Raw(() => Shape().Where(r => r.Customer!.Name == "Acme").ToList()), + Guarded(rows, "Customer.Name", DataType.Text, "Acme")); + + Case("E12 a plain copied column", + Raw(() => Shape().Where(r => r.Code == "AB123").ToList()), + Guarded(rows, "Code", DataType.Text, "AB123")); + + Done(); + + IQueryable Shape() => _db.Orders.Select(order => new P6Row + { + Id = order.Id, + Code = order.Code, + Count = order.Lines.Count(), + Total = order.Lines.Sum(line => line.Qty), + Wide = order.Lines.Max(line => line.Qty), + Tag = order.Lines.OrderBy(line => line.Id).Select(line => line.Sku).FirstOrDefault() ?? "none", + Flag = order.Lines.Any(line => line.Qty > 3) ? "big" : "small", + Lines = order.Lines + .Where(line => line.Qty > 0) + .OrderBy(line => line.Id) + .Select(line => new P6LineRow { Label = line.Sku, Qty = line.Qty, Note = "n" }) + .ToList(), + Raw = order.Lines.ToList(), + One = order.Lines.OrderBy(line => line.Id).FirstOrDefault(), + Customer = order.Customer + }); + } + + [Fact] + public void Pr6_E_Casts_conditionals_method_calls_and_nested_initializers() + { + IQueryable rows = _db.Orders.Select(order => new P6Row + { + Id = order.Id, + Code = order.Code.ToUpper(), + Tag = order.Code.Substring(0, 2), + Flag = order.Bonus == null ? "none" : "some", + Total = (decimal)order.Amount, + Wide = (long)order.Id, + Title = new P6Text { Ar = order.Title.Ar, En = order.Title.En }, + Nest = new P6Nest { A = order.Code, B = order.Customer.Name } + }); + + Case("E20 a method call on a column", + Raw(() => Shape().Where(r => r.Code == "AB123").ToList()), + Guarded(rows, "Code", DataType.Text, "AB123")); + + Case("E21 Substring", + Raw(() => Shape().Where(r => r.Tag == "AB").ToList()), + Guarded(rows, "Tag", DataType.Text, "AB")); + + Case("E22 a conditional over a null check", + Raw(() => Shape().Where(r => r.Flag == "some").ToList()), + Guarded(rows, "Flag", DataType.Text, "some")); + + Case("E23 a cast", + Raw(() => Shape().Where(r => r.Total == 10m).ToList()), + Guarded(rows, "Total", DataType.Number, "10")); + + Case("E24 a widening cast", + Raw(() => Shape().Where(r => r.Wide == 1L).ToList()), + Guarded(rows, "Wide", DataType.Number, "1")); + + Case("E25 a nested initializer rebuilt member by member", + Raw(() => Shape().Where(r => r.Title.En == "EN").ToList()), + Guarded(rows, "Title.En", DataType.Text, "EN")); + + Case("E26 a nested initializer of a type the entity has no member for", + Raw(() => Shape().Where(r => r.Nest.A == "AB123").ToList()), + Guarded(rows, "Nest.A", DataType.Text, "AB123")); + + Case("E27 a nested member read from a navigation", + Raw(() => Shape().Where(r => r.Nest.B == "Acme").ToList()), + Guarded(rows, "Nest.B", DataType.Text, "Acme")); + + Done(); + + IQueryable Shape() => _db.Orders.Select(order => new P6Row + { + Id = order.Id, + Code = order.Code.ToUpper(), + Tag = order.Code.Substring(0, 2), + Flag = order.Bonus == null ? "none" : "some", + Total = (decimal)order.Amount, + Wide = (long)order.Id, + Title = new P6Text { Ar = order.Title.Ar, En = order.Title.En }, + Nest = new P6Nest { A = order.Code, B = order.Customer.Name } + }); + } + + [Fact] + public void Pr6_E_A_constructor_with_arguments_and_a_second_projection_over_the_first() + { + IQueryable built = _db.Orders.Select(order => new P6Row(order.Id, order.Code) + { + Tag = order.Title.En + }); + + Case("E30 a member the constructor sets", + Raw(() => _db.Orders.Select(o => new P6Row(o.Id, o.Code) { Tag = o.Title.En }) + .Where(r => r.Code == "AB123").ToList()), + Guarded(built, "Code", DataType.Text, "AB123")); + + Case("E31 a binding beside the constructor", + Raw(() => _db.Orders.Select(o => new P6Row(o.Id, o.Code) { Tag = o.Title.En }) + .Where(r => r.Tag == "EN").ToList()), + Guarded(built, "Tag", DataType.Text, "EN")); + + // Members of one projected row read from another projected row's members. + IQueryable twice = _db.Orders + .Select(order => new P6Row { Id = order.Id, Code = order.Code, Tag = order.Title.En }) + .Select(row => new P6Row { Id = row.Id, Code = row.Tag, Tag = row.Code }); + + Case("E32 a second projection over the first", + Raw(() => _db.Orders + .Select(o => new P6Row { Id = o.Id, Code = o.Code, Tag = o.Title.En }) + .Select(r => new P6Row { Id = r.Id, Code = r.Tag, Tag = r.Code }) + .Where(r => r.Code == "EN").ToList()), + Guarded(twice, "Code", DataType.Text, "EN")); + + Case("E33 the swapped member of the second projection", + Raw(() => _db.Orders + .Select(o => new P6Row { Id = o.Id, Code = o.Code, Tag = o.Title.En }) + .Select(r => new P6Row { Id = r.Id, Code = r.Tag, Tag = r.Code }) + .Where(r => r.Tag == "AB123").ToList()), + Guarded(twice, "Tag", DataType.Text, "AB123")); + + // An anonymous row: nothing can say which member each value sets. + var anonymous = _db.Orders.Select(order => new { order.Id, order.Code, Name = order.Customer.Name }); + + Case("E34 an anonymous row", + Raw(() => _db.Orders.Select(o => new { o.Id, o.Code, Name = o.Customer.Name }) + .Where(r => r.Code == "AB123").ToList()), + Guarded(anonymous, "Code", DataType.Text, "AB123")); + + Case("E35 an anonymous row's renamed member", + Raw(() => _db.Orders.Select(o => new { o.Id, o.Code, Name = o.Customer.Name }) + .Where(r => r.Name == "Acme").ToList()), + Guarded(anonymous, "Name", DataType.Text, "Acme")); + + Done(); + } + + [Fact] + public void Pr6_E_A_projection_clause_still_returns_what_no_database_can_compute() + { + Case("E40 select a getter over two owned columns", + Raw(() => _db.Orders.Select(o => new { o.Title.IsEmpty }).ToList()), + GuardedSelect(_db.Orders, "Title.IsEmpty")); + + Case("E41 select an unmapped getter on the entity", + Raw(() => _db.Orders.Select(o => new { o.Display }).ToList()), + GuardedSelect(_db.Orders, "Display")); + + Case("E42 select a plain column", + Raw(() => _db.Orders.Select(o => new { o.Code }).ToList()), + GuardedSelect(_db.Orders, "Code")); + + Case("E43 select a computed column", + Raw(() => _db.Orders.Select(o => new { o.Doubled }).ToList()), + GuardedSelect(_db.Orders, "Doubled")); + + Case("E44 select through a navigation", + Raw(() => _db.Orders.Select(o => new { o.Customer.Name }).ToList()), + GuardedSelect(_db.Orders, "Customer.Name")); + + Done(); + } + + [Fact] + public void Pr6_E_A_source_composed_before_the_guard() + { + Case("E50 a filtered source", + Raw(() => _db.Orders.Where(o => o.Amount > 0m).Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders.Where(o => o.Amount > 0m), "Code", DataType.Text, "AB123")); + + Case("E51 an ordered source", + Raw(() => _db.Orders.OrderBy(o => o.Id).Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders.OrderBy(o => o.Id), "Code", DataType.Text, "AB123")); + + Case("E52 a source that took a page", + Raw(() => _db.Orders.OrderBy(o => o.Id).Take(10).Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders.OrderBy(o => o.Id).Take(10), "Code", DataType.Text, "AB123")); + + Case("E53 a distinct source", + Raw(() => _db.Orders.Distinct().Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders.Distinct(), "Code", DataType.Text, "AB123")); + + Case("E54 a set operation", + Raw(() => _db.Orders.Where(o => o.Id > 0).Union(_db.Orders.Where(o => o.Id < 0)) + .Where(o => o.Code == "AB123").ToList()), + Guarded( + _db.Orders.Where(o => o.Id > 0).Union(_db.Orders.Where(o => o.Id < 0)), + "Code", DataType.Text, "AB123")); + + Case("E55 SelectMany over a collection", + Raw(() => _db.Orders.SelectMany(o => o.Lines).Where(l => l.Sku == "S-1").ToList()), + Guarded(_db.Orders.SelectMany(o => o.Lines), "Sku", DataType.Text, "S-1")); + + Case("E56 an included navigation", + Raw(() => _db.Orders.Include(o => o.Lines).Where(o => o.Code == "AB123").ToList()), + Guarded(_db.Orders.Include(o => o.Lines), "Code", DataType.Text, "AB123")); + + Done(); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Pr6PrecisionProbes.cs b/DynamicWhere.Tests/Policies/Pr6PrecisionProbes.cs new file mode 100644 index 0000000..a8985d0 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Pr6PrecisionProbes.cs @@ -0,0 +1,528 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Masking; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- models ----------------------------------------------------------------------------------------- + + /// A row of a generated summary, whose two key columns the probe sets by hand. + public class Pr6KeyRow + { + public string? K1 { get; set; } + + public string? K2 { get; set; } + + public int N { get; set; } + } + + /// A transformer that returns what it was given, so the composite key is the probe's own text. + public sealed class Pr6Identity : IValueTransformer + { + public object? Transform(object? value, DwTransformContext context) => value; + } + + /// Aliases that resolve, and aliases that collide. + public class Pr6Aliased + { + public int Id { get; set; } + + /// A name nothing else answers to: it must still resolve. + [DwAlias("Handle")] + public string Login { get; set; } = string.Empty; + + /// An alias spelled exactly like its own path: one candidate, not two. + [DwAlias("Same")] + public string Same { get; set; } = string.Empty; + + /// An alias that is also a real property of the type. + [DwAlias("Code")] + public string Serial { get; set; } = string.Empty; + + public string Code { get; set; } = string.Empty; + + /// Two members sharing one alias. + [DwAlias("Ref")] + public string RefA { get; set; } = string.Empty; + + [DwAlias("Ref")] + public string RefB { get; set; } = string.Empty; + } + + /// One alias on a type that appears inside its own navigation graph. + public class Pr6Node + { + public int Id { get; set; } + + [DwAlias("Tag")] + public string Label { get; set; } = string.Empty; + + public Pr6Node? Parent { get; set; } + + public List Children { get; set; } = new(); + } + + /// A type whose values are transformed, which is what makes a query unmaterializable. + public class Pr6Masked + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Full)] + public string Secret { get; set; } = string.Empty; + + [DwMask(MaskStrategy.Full)] + public string Other { get; set; } = string.Empty; + } + + /// + /// Round 6: the four refusals the fifth review changed, and the two literals it rewrote. + /// + /// + /// Every probe prints its answer and every probe runs on 3.2.0 unchanged, so the two outputs are + /// diffed rather than asserted against a remembered expectation. + /// + public sealed class Pr6PrecisionProbes + { + private readonly ITestOutputHelper _out; + + public Pr6PrecisionProbes(ITestOutputHelper output) => _out = output; + + // Built from code points, never from an escape, so nothing between here and the file can + // decode one into the character it names. + private static readonly string Nul = ((char)0).ToString(); + + private static readonly string Unit = ((char)31).ToString(); + + private static DwPolicyContext Caller(bool dryRun = false) => + new DwPolicyContext { DryRun = dryRun }.WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict, bool dryRun = false) => + new() { Tier = tier, DryRun = dryRun, Caps = { MinGroupSize = 1 } }; + + private void Line(string probe, string answer) => _out.WriteLine($"{probe,-58} {answer}"); + + // ========================================================================================= + // Pr6-A. The composite grouping key. Two escapes replaced a raw NUL and a raw unit + // separator: every collision and every non-collision below pins both to the exact + // code point, because each case turns on one of them. + // ========================================================================================= + + private static TypePolicy KeyPolicy() => new( + new Dictionary>(StringComparer.OrdinalIgnoreCase), + Array.Empty(), + new Dictionary>(StringComparer.OrdinalIgnoreCase), + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["K1"] = new ValueTransform(mutate: new MutateStage(typeof(Pr6Identity))), + ["K2"] = new ValueTransform(mutate: new MutateStage(typeof(Pr6Identity))) + }); + + /// Runs the summary transformer over rows whose key columns the probe chose. + private static string Keys( + (string? K1, string? K2)[] rows, + DwTier tier = DwTier.Strict, + bool optionsDryRun = false, + bool contextDryRun = false) + { + SummaryResult result = new(); + + foreach ((string? k1, string? k2) in rows) + { + result.Data.Add(new Pr6KeyRow { K1 = k1, K2 = k2, N = 1 }); + } + + Summary summary = new() + { + GroupBy = new GroupBy { Fields = { "K1", "K2" } } + }; + + DwPolicyOptions options = Options(tier, optionsDryRun); + + try + { + ResultTransformer.Summary( + result, summary, KeyPolicy(), Caller(contextDryRun), options, + new PolicyTrace(tier, optionsDryRun || contextDryRun)); + + return "OK"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode}|path={refusal.FieldPath}|origin=" + + (refusal.SourceOrigin is null ? "null" : "set") + ")"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + [Fact] + public void Pr6_A_The_composite_key_is_the_same_bytes_it_always_was() + { + Line("A01 two plain keys that differ", Keys(new (string?, string?)[] { ("a", "b"), ("c", "d") })); + Line("A02 two plain keys that match", Keys(new (string?, string?)[] { ("a", "b"), ("a", "b") })); + Line("A03 one row only", Keys(new (string?, string?)[] { ("a", "b") })); + Line("A04 no rows", Keys(Array.Empty<(string?, string?)>())); + + // The separator. These collide if and only if the joiner is exactly U+001F. + Line("A05 separator straddled by a value", + Keys(new (string?, string?)[] { ("a" + Unit + "b", "c"), ("a", "b" + Unit + "c") })); + Line("A06 separator at both ends", + Keys(new (string?, string?)[] { (Unit, string.Empty), (string.Empty, Unit) })); + Line("A07 a value that is only separators", + Keys(new (string?, string?)[] { (Unit + Unit, "x"), (Unit, Unit + "x") })); + + // The null sentinel. These collide if and only if the sentinel is exactly one U+0000. + Line("A08 null against a literal NUL", Keys(new (string?, string?)[] { (null, "c"), (Nul, "c") })); + Line("A09 null on both keys against NUL on both", + Keys(new (string?, string?)[] { (null, null), (Nul, Nul) })); + Line("A10 empty string is not the null sentinel", + Keys(new (string?, string?)[] { (string.Empty, "c"), (null, "c") })); + Line("A11 empty string is not a literal NUL", + Keys(new (string?, string?)[] { (string.Empty, "c"), (Nul, "c") })); + Line("A12 two nulls in the same column", + Keys(new (string?, string?)[] { (null, "c"), (null, "d") })); + + // Both characters inside the values at once. + Line("A13 NUL and separator inside the values", + Keys(new (string?, string?)[] { (Nul + Unit, "y"), (Nul, Unit + "y") })); + Line("A14 NUL and separator, not colliding", + Keys(new (string?, string?)[] { (Nul + Unit, "y"), (Unit + Nul, "y") })); + + // Detection order: the third row is the one that repeats. + Line("A15 collision only between rows two and three", + Keys(new (string?, string?)[] { ("a", "b"), ("c", "d"), ("c", "d") })); + Line("A16 three rows, none repeating", + Keys(new (string?, string?)[] { ("a", "b"), ("c", "d"), ("e", "f") })); + + // Ordinal, not case-insensitive: unchanged either way, and a change would show here. + Line("A17 keys differing only in case", Keys(new (string?, string?)[] { ("A", "b"), ("a", "b") })); + } + + [Fact] + public void Pr6_A_What_the_group_key_refusal_says_in_each_posture() + { + (string?, string?)[] colliding = { ("a", "b"), ("a", "b") }; + + Line("A18 strict", Keys(colliding)); + Line("A19 convenience", Keys(colliding, DwTier.Convenience)); + Line("A20 strict, options dry run", Keys(colliding, optionsDryRun: true)); + Line("A21 strict, CONTEXT dry run", Keys(colliding, contextDryRun: true)); + Line("A22 convenience, context dry run", Keys(colliding, DwTier.Convenience, contextDryRun: true)); + } + + // ========================================================================================= + // Pr6-B. A name matching more than one field. Under the strict tier it is no longer refused + // with AmbiguousFieldName; it is refused as an unknown name is. Nothing that resolved + // before may stop resolving, and nothing refused before may start succeeding. + // ========================================================================================= + + private static Condition On(string field) => new() + { + Sort = 0, Field = field, DataType = DataType.Text, Operator = Operator.Equal, Values = { "x" } + }; + + private static string Sanitize( + Filter filter, + DwTier tier = DwTier.Strict, + bool optionsDryRun = false, + bool contextDryRun = false, + PolicyTrace? trace = null) + where T : class + { + trace ??= new PolicyTrace(tier, optionsDryRun || contextDryRun); + + try + { + Filter result = FilterSanitizer.Sanitize( + filter, Attributes(), Caller(contextDryRun), Options(tier, optionsDryRun), trace); + + string fields = string.Join( + ",", + (result.ConditionGroup?.Conditions ?? new List()).Select(c => c.Field)); + + string selects = result.Selects is null ? "-" : string.Join(",", result.Selects); + string orders = result.Orders is null ? "-" : string.Join(",", result.Orders.Select(o => o.Field)); + + return $"OK(where={fields};select={selects};order={orders})"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode}|path={refusal.FieldPath}|origin=" + + (refusal.SourceOrigin is null ? "null" : "set") + ")"; + } + catch (LogicException failure) + { + return $"LOGIC({failure.Message})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static string SanitizeSummary(Summary summary, DwTier tier = DwTier.Strict) where T : class + { + try + { + Summary result = FilterSanitizer.Sanitize( + summary, Attributes(), Caller(), Options(tier), new PolicyTrace(tier, false)); + + return "OK(" + string.Join(",", result.GroupBy?.Fields ?? new List()) + ")"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode}|path={refusal.FieldPath})"; + } + catch (LogicException failure) + { + return $"LOGIC({failure.Message})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + private static Filter Where(string field) => + new() { ConditionGroup = new ConditionGroup { Conditions = { On(field) } } }; + + [Fact] + public void Pr6_B_An_alias_that_resolved_still_resolves() + { + Line("B01 unique alias, where", Sanitize(Where("Handle"))); + Line("B02 unique alias, convenience", Sanitize(Where("Handle"), DwTier.Convenience)); + Line("B03 alias spelled like its own path", Sanitize(Where("Same"))); + Line("B04 the plain property behind an alias", Sanitize(Where("Login"))); + Line("B05 a property no alias touches", Sanitize(Where("Id"))); + Line("B06 one member reached many ways", Sanitize(Where("Tag"))); + Line("B07 one member many ways, convenience", + Sanitize(Where("Tag"), DwTier.Convenience)); + Line("B08 the path that alias declares", Sanitize(Where("Label"))); + Line("B09 a deeper path of the same member", Sanitize(Where("Parent.Label"))); + + Line("B10 unique alias, select", Sanitize(new Filter { Selects = new List { "Handle" } })); + Line("B11 unique alias, order", + Sanitize(new Filter { Orders = new List { new OrderBy { Field = "Handle" } } })); + Line("B12 unique alias, group", + SanitizeSummary(new Summary { GroupBy = new GroupBy { Fields = { "Handle" } } })); + } + + [Fact] + public void Pr6_B_A_name_matching_more_than_one_field_in_every_clause() + { + Line("B20 alias colliding with a property, where", Sanitize(Where("Code"))); + Line("B21 two members one alias, where", Sanitize(Where("Ref"))); + + Line("B22 colliding name, select alone", + Sanitize(new Filter { Selects = new List { "Code" } })); + Line("B23 colliding name beside a good select", + Sanitize(new Filter { Selects = new List { "Id", "Code" } })); + Line("B24 colliding name, order", + Sanitize(new Filter { Orders = new List { new OrderBy { Field = "Code" } } })); + Line("B25 colliding name, order beside a good one", + Sanitize(new Filter + { + Orders = new List { new OrderBy { Field = "Id" }, new OrderBy { Field = "Code" } } + })); + Line("B26 colliding name, nested condition group", + Sanitize(new Filter + { + ConditionGroup = new ConditionGroup + { + SubConditionGroups = { new ConditionGroup { Conditions = { On("Code") } } } + } + })); + Line("B27 colliding name, grouping key", + SanitizeSummary(new Summary { GroupBy = new GroupBy { Fields = { "Code" } } })); + Line("B28 colliding name, aggregated field", + SanitizeSummary(new Summary + { + GroupBy = new GroupBy + { + Fields = { "Id" }, + AggregateBy = + { + new AggregateBy { Field = "Code", Alias = "c", Aggregator = Aggregator.Maximum } + } + } + })); + Line("B29 colliding name beside a legitimate one", + Sanitize(new Filter + { + ConditionGroup = new ConditionGroup { Conditions = { On("Handle"), On("Code") } } + })); + } + + [Fact] + public void Pr6_B_The_colliding_name_in_every_posture() + { + Line("B30 strict", Sanitize(Where("Code"))); + Line("B31 convenience", Sanitize(Where("Code"), DwTier.Convenience)); + Line("B32 strict, options dry run", Sanitize(Where("Code"), optionsDryRun: true)); + Line("B33 strict, CONTEXT dry run", Sanitize(Where("Code"), contextDryRun: true)); + Line("B34 convenience, options dry run", + Sanitize(Where("Code"), DwTier.Convenience, optionsDryRun: true)); + } + + [Fact] + public void Pr6_B_What_the_trace_says_about_a_colliding_name() + { + PolicyTrace trace = new(DwTier.Strict, false); + + Line("B40 strict refusal", Sanitize(Where("Code"), trace: trace)); + + foreach (PolicyDecision decision in trace.Decisions) + { + Line("B41 decision", $"{decision.FieldPath} | {decision.Feature} | {decision.Action} | {decision.Reason}"); + } + + PolicyTrace two = new(DwTier.Strict, false); + + Line("B42 two members one alias", Sanitize(Where("Ref"), trace: two)); + + foreach (PolicyDecision decision in two.Decisions) + { + Line("B43 decision", $"{decision.FieldPath} | {decision.Feature} | {decision.Action} | {decision.Reason}"); + } + } + + [Fact] + public void Pr6_B_What_a_caller_who_catches_the_refusal_can_read() + { + // End to end, through the handle a host actually uses: the refusal names no field, so + // LastTrace has to be where the operator reads which name was ambiguous and why. + List rows = new() { new Pr6Aliased() }; + + PolicyQueryable handle = + rows.AsQueryable().ApplyPolicy(Caller(), Options(), Attributes()); + + try + { + _ = handle.ToList(Where("Code")); + + Line("B50 through the handle", "OK"); + } + catch (PolicyException refusal) + { + Line("B50 through the handle", + $"REFUSED({refusal.ErrorCode}|path={refusal.FieldPath})"); + } + + Line("B51 LastTrace", handle.LastTrace is null ? "null" : "present"); + + foreach (PolicyDecision decision in handle.LastTrace?.Decisions ?? new List()) + { + Line("B52 decision", $"{decision.FieldPath} | {decision.Feature} | {decision.Action} | {decision.Reason}"); + } + } + + // ========================================================================================= + // Pr6-C. TransformRequiresMaterialization, MissingHashSalt and MissingTokenVault: what each + // names, in each posture, including the per-context dry run the rest of the library + // treats as a dry run. + // ========================================================================================= + + private static string Unmaterialized(DwTier tier, bool optionsDryRun, bool contextDryRun) + { + List rows = new() { new Pr6Masked { Id = 1 } }; + + try + { + _ = rows.AsQueryable() + .ApplyPolicy(Caller(contextDryRun), Options(tier, optionsDryRun), Attributes()) + .SelectDynamic(new List { "Id" }); + + return "OK"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode}|path={refusal.FieldPath})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + [Fact] + public void Pr6_C_A_query_the_caller_materializes_over_a_transformed_type() + { + Line("C01 strict", Unmaterialized(DwTier.Strict, false, false)); + Line("C02 convenience", Unmaterialized(DwTier.Convenience, false, false)); + Line("C03 strict, options dry run", Unmaterialized(DwTier.Strict, true, false)); + Line("C04 strict, CONTEXT dry run", Unmaterialized(DwTier.Strict, false, true)); + Line("C05 convenience, context dry run", Unmaterialized(DwTier.Convenience, false, true)); + } + + private static string Chain( + ValueTransform chain, DwTier tier, bool optionsDryRun, bool contextDryRun, string? salt = null) + { + DwPolicyOptions options = Options(tier, optionsDryRun); + + if (salt is not null) + { + options.HashSalt = salt; + } + + try + { + object? value = TransformPipeline.Apply( + chain, + "abcdef", + typeof(string), + new DwTransformContext(new Pr6Masked(), "Secret", Caller(contextDryRun)), + options); + + return "OK(" + (value ?? "null") + ")"; + } + catch (PolicyException refusal) + { + return $"REFUSED({refusal.ErrorCode}|path={refusal.FieldPath})"; + } + catch (Exception failure) + { + return failure.GetType().Name; + } + } + + [Fact] + public void Pr6_C_A_hash_with_no_salt_and_a_token_with_no_vault() + { + ValueTransform hash = new(mask: new MaskStage(MaskStrategy.Hash)); + ValueTransform token = new(mask: new MaskStage(MaskStrategy.Tokenize)); + + Line("C10 hash, strict", Chain(hash, DwTier.Strict, false, false)); + Line("C11 hash, convenience", Chain(hash, DwTier.Convenience, false, false)); + Line("C12 hash, strict, options dry run", Chain(hash, DwTier.Strict, true, false)); + Line("C13 hash, strict, CONTEXT dry run", Chain(hash, DwTier.Strict, false, true)); + + Line("C14 token, strict", Chain(token, DwTier.Strict, false, false)); + Line("C15 token, convenience", Chain(token, DwTier.Convenience, false, false)); + Line("C16 token, strict, options dry run", Chain(token, DwTier.Strict, true, false)); + Line("C17 token, strict, CONTEXT dry run", Chain(token, DwTier.Strict, false, true)); + + // A salt that is configured still hashes, in every posture: the refusal is the only thing + // that changed, and it must not have taken the success with it. + Line("C18 hash with a salt, strict", + Chain(hash, DwTier.Strict, false, false, "0123456789abcdefghij")); + Line("C19 hash with a salt, convenience", + Chain(hash, DwTier.Convenience, false, false, "0123456789abcdefghij")); + } + } +} diff --git a/DynamicWhere.ex/Policies/Masking/TransformPipeline.cs b/DynamicWhere.ex/Policies/Masking/TransformPipeline.cs index 2e939a5..6e9831e 100644 --- a/DynamicWhere.ex/Policies/Masking/TransformPipeline.cs +++ b/DynamicWhere.ex/Policies/Masking/TransformPipeline.cs @@ -40,15 +40,6 @@ internal static class TransformPipeline /// Thrown with when the chain masks to a hash and /// the deployment set no salt. /// - /// The path a refusal from this pipeline names: the clause, where the tier hides existence. - /// - /// A missing salt or vault is a deployment's own mistake, and the caller who meets it named no - /// field: the request sent no projection, and the value being masked is one the policy chose to - /// mask. Naming it would hand that caller the canonical path of a masked column. - /// - private static string Named(DwTransformContext context, DwPolicyOptions options) => - options.Tier == DwTier.Strict && !options.DryRun ? "*" : context.FieldPath; - internal static object? Apply( ValueTransform chain, object? value, @@ -61,6 +52,17 @@ private static string Named(DwTransformContext context, DwPolicyOptions options) return Fit(result, memberType, context.FieldPath, chain); } + /// The path a refusal from this pipeline names: the clause, where the tier hides existence. + /// + /// A missing salt or vault is a deployment's own mistake, and the caller who meets it named no + /// field: the request sent no projection, and the value being masked is one the policy chose to + /// mask. Naming it would hand that caller the canonical path of a masked column. + /// + private static string Named(DwTransformContext context, DwPolicyOptions options) => + options.Tier == DwTier.Strict && !options.DryRun && !context.Policy.DryRun + ? "*" + : context.FieldPath; + /// Runs the stages in order, or the replacement alone. private static object? Run( ValueTransform chain, diff --git a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs index 5afbf68..215b2b3 100644 --- a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs +++ b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs @@ -815,7 +815,7 @@ private void RefuseUnmaterialized(string method, string instead) // on the type, handed to a caller who named none of them. throw new PolicyException( PolicyErrorCode.TransformRequiresMaterialization, - _options.Tier == DwTier.Strict && !_options.DryRun + _options.Tier == DwTier.Strict && !_options.DryRun && !_context.DryRun ? "*" : string.Join(", ", TypePolicy.Transforms.Keys), PolicyFeature.Select, diff --git a/DynamicWhere.ex/Policies/Source/ResultTransformer.cs b/DynamicWhere.ex/Policies/Source/ResultTransformer.cs index 0f16e4f..87ba8a5 100644 --- a/DynamicWhere.ex/Policies/Source/ResultTransformer.cs +++ b/DynamicWhere.ex/Policies/Source/ResultTransformer.cs @@ -312,7 +312,7 @@ internal static void Summary( if (keys.Count > 0) { - RefuseCollisions(result.Data, keys, options); + RefuseCollisions(result.Data, keys, context, options); } } @@ -423,7 +423,8 @@ private static void Apply( /// rows that look like duplicates and whose aggregates cannot be added together without /// inventing a figure the database never computed. /// - private static void RefuseCollisions(List rows, List keys, DwPolicyOptions options) + private static void RefuseCollisions( + List rows, List keys, DwPolicyContext context, DwPolicyOptions options) { HashSet seen = new(StringComparer.Ordinal); @@ -442,7 +443,7 @@ private static void RefuseCollisions(List rows, List keys, DwPo if (!seen.Add(composite)) { - bool hides = options.Tier == DwTier.Strict && !options.DryRun; + bool hides = options.Tier == DwTier.Strict && !options.DryRun && !context.DryRun; // Under the strict tier the refusal names the clause rather than the grouping key: // the key's canonical path is the column behind whatever alias the caller wrote, and From 97fb2223c5201227e5ddf222a7e357fb95a0f08f Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 16:35:11 +0300 Subject: [PATCH 28/54] docs: what each of the four refusals actually says, in the reference as well as the announcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transform refusal keeps its SourceOrigin under Strict — it names the method and what to call instead, never a field — where four documents said all of them carry none. It also lists every transformed column, not every masked one: generalized, truncated and formatted fields are in that list too. llms.txt is the reference, and it still described the old behaviour in eight places: the ambiguous-name rule, the two lists of codes that throw regardless of tier, the group-key collision, the FieldPath and SourceOrigin reference blocks, five rows of the error-code table, and a 3.3.0 history block that did not mention the change at all. The configuration page's strict-refusal section and DOC.md's blocked-action semantics — the two places the error tables point a reader at — now name the four as well. The security page's title counted its bypasses as channels: eight inference channels and three bypasses, which is what the page itself says. Co-Authored-By: Claude Opus 5 --- DynamicWhere.ex/DOC.md | 3 +- DynamicWhere.ex/DynamicWhere.ex.csproj | 2 +- .../app/docs/breaking-changes/page.tsx | 11 +++- .../app/docs/policies/configuration/page.tsx | 8 +++ OfficialWebsite/app/docs/policies/page.tsx | 2 +- .../app/docs/policies/security/page.tsx | 18 +++--- OfficialWebsite/public/llms.txt | 60 +++++++++++++------ README.md | 2 +- 8 files changed, 75 insertions(+), 31 deletions(-) diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index 9e77da0..61435fc 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -1671,6 +1671,7 @@ A dropped field leaves nothing behind in the data, so the trace is the only way - A name that matches nothing on the type is no longer refused as `LogicException` `ConditionMustHasValidFieldName` while the request is read. It is gated as a field denied for every feature, at the step a denial is raised — after the caps — so it receives the refusal a `[DwDenied]` field receives in that clause: `FieldDeniedForWhere`, `FieldDeniedForSelect`, `FieldDeniedForOrder`, `FieldDeniedForGroup` or `FieldDeniedForAggregate`. A name padded with dots or blank segments, such as `NoSuchColumn....` or `. . . . X`, is normalized the way a real path is, so it is refused as a padded real field is rather than by `MaxNavigationDepth`. - Inside a segment every field refusal is `FieldDeniedForSegment`, with `Feature` `Segment`, whichever clause refused it — a condition in any set, an order, a select, or the field taking part at all. Answered by clause, a field denied for every clause but not for segments would say `FieldDeniedForOrder` where a name that matches nothing says `FieldDeniedForSegment`. Filters and summaries keep their per-clause codes. - Every refusal carrying one of those six codes has `FieldPath` `"*"`, a null `RuleId` and a null `SourceOrigin`, whatever the field — a real denied field and an alias included — so its message is the same too. +- Four more refusals name the clause rather than a field (3.3.0): an ambiguous name is refused as an unknown name is, and `AmbiguousGroupKey`, `TransformRequiresMaterialization`, `MissingHashSalt` and `MissingTokenVault` carry `FieldPath` `"*"`. All but the transform refusal drop their `SourceOrigin` too; that one's origin names a method rather than a field. - A `CapExceeded` refusal names no path either. `MaxNavigationDepth` used to return the canonical spelling of the path the caller wrote, which confirmed that it named something. `SourceOrigin` still names the cap. - `MissingContextValue` has `FieldPath` `"*"` and a null `SourceOrigin`, so it names neither the scope's column nor the context key it reads, which together describe how rows are partitioned. The trace keeps both, and an audited refusal records the scoped field. - `MaxQueryCost` is checked after every field has passed its gate. A field weighted by `[DwCost]` that the caller may not use is refused as denied before its weight counts, exactly as a name that matches nothing is, so the budget cannot tell the two apart. An allowed weighted field is still refused with `QueryCostExceeded`. @@ -2446,7 +2447,7 @@ These are numbered as this document numbers them. The website's [breaking-change Since 3.3.0 `PolicyQueryable.LastTrace` carries the trace of a request that was refused. It used to be assigned after sanitizing returned, so a refusal left it holding the previous request's trace, or null on the first. A strict refusal names no field on purpose, and the trace is where the real path and reason live, so this is what makes one readable. Code that read `LastTrace` after catching a `PolicyException` and expected the earlier request's trace reads this request's now. 27. **Four More Refusals Name the Clause Under `Strict`** - A strict refusal names no field, and four did. `AmbiguousFieldName` told a caller that the name they wrote matches more than one field, which is to say at least one; it is now refused as an unknown name is, with the ambiguity kept in the trace for the operator who has to fix the aliases. `AmbiguousGroupKey` reported the grouping key's canonical path — the column behind whatever alias the caller wrote — and an origin saying its values are transformed; it now reports `"*"` and no origin. `TransformRequiresMaterialization` listed every transformed column on the type to a caller who named none of them, and now names the clause. `MissingHashSalt` and `MissingTokenVault` named the masked field a deployment forgot to configure for, and now name the clause. All four are unchanged under `Convenience` and in a dry run, where the tier names fields anyway. Code switching on `AmbiguousFieldName` under `Strict`, or reading `FieldPath` off any of the four, sees the change. + A strict refusal names no field, and four did. `AmbiguousFieldName` told a caller that the name they wrote matches more than one field, which is to say at least one; it is now refused as an unknown name is, with the ambiguity kept in the trace for the operator who has to fix the aliases. `AmbiguousGroupKey` reported the grouping key's canonical path — the column behind whatever alias the caller wrote — and an origin saying its values are transformed; it now reports `"*"` and no origin. `TransformRequiresMaterialization` listed every transformed column on the type — masked, generalized, truncated or formatted — to a caller who named none of them, and now names the clause while keeping its origin, which names the method and what to call instead rather than any field. `MissingHashSalt` and `MissingTokenVault` named the masked field a deployment forgot to configure for, and now report `"*"` with no origin. All four are unchanged under `Convenience` and in a dry run, whichever switch declares it — the posture's or the caller's — where the tier names fields anyway. Code switching on `AmbiguousFieldName` under `Strict`, or reading `FieldPath` off any of the four, sees the change. 28. **`[DwAudit]` Records a Read the Request Did Not Name** A request that sends no `Selects` receives the row, and until 3.3.0 only a field it spelled out was recorded — so that caller read every audited member with nothing written down, one token past a control whose purpose is to answer who read a field. Every audited member a projection the caller did not name hands back is now recorded for `Select`: what the synthesized projection keeps where one is built, and every member the caller may select where none is. One event per query rather than per row, and only for a field `[DwAudit]` names. A deployment already running the control sees more events, and `DwCaps.MaxAuditEvents`, which refuses rather than dropping a record, can be reached by traffic that did not reach it before: raise the cap, or drain per request with `app.UseDwPolicyAudit()`. diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index 09e9ca1..ef9a07a 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -56,7 +56,7 @@ Behaviour change: under the strict tier, a filter, order, grouping key or aggreg Security fix and behaviour change: [DwAudit] records a read the request did not name. A request sending no Selects receives the row, and only a field it spelled out used to be recorded, so that caller read every audited member with nothing written down — one token past a control whose purpose is to answer who read a field. Every audited member a projection the caller did not name hands back is recorded for Select now: what the synthesized projection keeps where one is built, and every member the caller may select where none is. One event per query rather than per row, and only for a field [DwAudit] names. A deployment already running the control sees more events, and DwCaps.MaxAuditEvents refuses rather than dropping a record, so raise the cap or drain per request with app.UseDwPolicyAudit(). -Security fix and behaviour change: under the strict tier, outside a dry run, four refusals that named a field name the clause instead. A strict refusal names none, so that a denied field, a misspelling and a field that does not exist cannot be told apart. AmbiguousFieldName said the caller's name matched more than one field, and so at least one; it is refused as an unknown name is now, and the trace keeps the ambiguity for the operator who has to fix the aliases. AmbiguousGroupKey reported the column behind the caller's alias, with an origin saying its values are transformed. TransformRequiresMaterialization listed every transformed column on the type to a caller who had named none. MissingHashSalt and MissingTokenVault named the masked field a deployment had not configured for. The last three report "*" and no origin. The convenience tier and a dry run are unchanged. +Security fix and behaviour change: under the strict tier, outside a dry run, four refusals that named a field name the clause instead. A strict refusal names none, so that a denied field, a misspelling and a field that does not exist cannot be told apart. AmbiguousFieldName said the caller's name matched more than one field, and so at least one; it is refused as an unknown name is now, and the trace keeps the ambiguity for the operator who has to fix the aliases. AmbiguousGroupKey reported the column behind the caller's alias, with an origin saying its values are transformed. TransformRequiresMaterialization listed every transformed column on the type to a caller who had named none. MissingHashSalt and MissingTokenVault named the masked field a deployment had not configured for. AmbiguousGroupKey, MissingHashSalt and MissingTokenVault report "*" and no origin; TransformRequiresMaterialization reports "*" and keeps an origin naming the method and what to call instead, never a field. The convenience tier and a dry run, whichever switch declares it, are unchanged. Security fix and behaviour change: under the strict tier, outside a dry run, a query that exhausts the audit buffer is refused with the clause's own field refusal rather than with CapExceeded and a SourceOrigin naming the cap. An audited field records an event per use and the query is refused rather than the record dropped once DwCaps.MaxAuditEvents is reached, which is fail-closed and stays that way; but a name that matches nothing is never audited and never reaches the cap, so the two answers told a caller, one guess per request, which names are real and audited. The refusal now carries the clause's own code, FieldPath "*" and no origin, and the trace records which refusal it really was. The convenience tier and a dry run still answer CapExceeded. diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index 63f2f6c..9b73b7d 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -1668,11 +1668,16 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}TransformRequiresMaterialization listed every transformed column on the type to a caller who named none of them.{" "} MissingHashSalt and MissingTokenVault named the - masked field a deployment forgot to configure for. + masked field a deployment forgot to configure for. Those three report{" "} + "*" and no origin;{" "} + TransformRequiresMaterialization reports{" "} + "*" and keeps an origin, which names the method + and what to call instead rather than any field.

          - All four are unchanged under Convenience and in a dry run, - where the tier names fields anyway. Code switching on{" "} + All four are unchanged under Convenience and in a dry run — + the posture's switch or the caller's — where the tier names + fields anyway. Code switching on{" "} AmbiguousFieldName under Strict, or reading{" "} FieldPath off any of the four, sees the change.

          diff --git a/OfficialWebsite/app/docs/policies/configuration/page.tsx b/OfficialWebsite/app/docs/policies/configuration/page.tsx index 285ca5e..dc9c356 100644 --- a/OfficialWebsite/app/docs/policies/configuration/page.tsx +++ b/OfficialWebsite/app/docs/policies/configuration/page.tsx @@ -628,6 +628,14 @@ PolicyTrace? trace = guarded.LastTrace;`} can reach it, so it refuses with the clause's own field refusal and no origin. See{" "} the audit cap.{" "} + Four more refusals name the clause rather than a field since{" "} + 3.3.0: an ambiguous name is refused as an unknown name + is, and AmbiguousGroupKey,{" "} + TransformRequiresMaterialization,{" "} + MissingHashSalt and MissingTokenVault carry{" "} + FieldPath "*" — all but the transform + refusal without an origin as well. See{" "} + breaking point 34.{" "} MaxQueryCost is the one with a code of its own,{" "} QueryCostExceeded, because an operator reading a log needs to know which of the two refused: raising the wrong one changes nothing. The three schema caps never throw at all — diff --git a/OfficialWebsite/app/docs/policies/page.tsx b/OfficialWebsite/app/docs/policies/page.tsx index 16aaa20..c750774 100644 --- a/OfficialWebsite/app/docs/policies/page.tsx +++ b/OfficialWebsite/app/docs/policies/page.tsx @@ -156,7 +156,7 @@ var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter, cancella
        • Dynamic store — rules without a redeploy
        • Store providers — Redis and Entity Framework Core
        • Admin API — schema, rules, explain, simulate, health
        • -
        • Security & k-anonymity — the eleven inference channels
        • +
        • Security & k-anonymity — eight inference channels and three bypasses
        • Configuration — options, caps and defaults
        • diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx index 2c82632..3dedba0 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -5,7 +5,7 @@ import { Code } from "@/components/Code"; import Callout from "@/components/Callout"; export const metadata: Metadata = { - title: "Security & k-anonymity — the eleven inference channels", + title: "Security & k-anonymity — eight inference channels and three bypasses", description: "How DynamicWhere.ex closes the disclosure channels no per-field rule closes on its own — set-operation reconstruction, singleton-group aggregates, cardinality probes, sort-and-page binary search, SQL leakage, and schema probing through refusals — and the denials the gate could not see until 3.2.0.", keywords: ["k-anonymity", "MinGroupSize", "inference attack", "data disclosure", "aggregate disclosure", "EF Core security"], alternates: { canonical: "https://doc.dynamicwhere.com/docs/policies/security/" }, @@ -213,15 +213,19 @@ true order. Add [DwNoOrder] unless that is intended.`} more than one field, and so at least one; an ambiguous grouping key reported the column behind the caller's alias and said its values are transformed; the refusal for a clause that cannot be transformed - listed every masked column on the type; and a deployment with no hash - salt or token vault named the masked field it could not write. + listed every transformed column on the type — masked, generalized, + truncated or formatted; and a deployment with no hash salt or token + vault named the masked field it could not write.

          Closed by: under Strict, outside a dry - run, an ambiguous name is refused exactly as an unknown name is, and the - other three name the clause with no origin. The trace keeps the real - reason for the operator. Convenience and a dry run are - unchanged. + run, an ambiguous name is refused exactly as an unknown name is. The + grouping key, the hash salt and the token vault name the clause and + carry no origin; the clause that cannot be transformed names the clause + and keeps an origin, which names the method and what to call instead + rather than any field. The trace keeps the real reason for the operator.{" "} + Convenience and a dry run — the posture's switch or the + caller's — are unchanged.

          9, 10 and 11. The three that are not channels

          diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index d9c5f20..5c23973 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -2048,8 +2048,10 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. `AggregateBy.Field`. It is also accepted in `Having` and summary `Orders` names that refer to an aliased group key. - Matching is case-insensitive, and the real path still works. -- A name that could mean two fields throws `AmbiguousFieldName`, in both tiers and in dry run. That - happens when an alias equals another real path or another alias. +- A name that could mean two fields throws `AmbiguousFieldName` under `Convenience` and in a dry run. That + happens when an alias equals another real path or another alias. Under `Strict` outside a dry run it is + refused as an unknown name is — the clause's own code, `FieldPath` `"*"` — because a name matching two + fields matches at least one (3.3.0); the trace records which fields it matched. - Exception: one member reached both at the root and through navigations (`Code`, `Manager.Code`) resolves to the root. - `PolicyException.FieldPath` carries the name the caller wrote. The trace records the canonical path, @@ -2569,8 +2571,9 @@ DefaultOrder field denied for Segment, in a Segment (3.1.0) left out l `FieldPath = "*"`, `RuleId = null` and `SourceOrigin = null`, for a denied field, an alias and an unknown name alike, so their messages are identical. Every `CapExceeded` refusal carries `FieldPath = "*"` as well, and keeps its `SourceOrigin`. `MissingContextValue` carries `FieldPath = "*"` and `SourceOrigin = null`, naming - neither the scope's column nor the context key it reads. `OperatorNotAllowed`, `RequiredFilterMissing` and - `AmbiguousFieldName` still name their field. + neither the scope's column nor the context key it reads. `OperatorNotAllowed` and `RequiredFilterMissing` still name + their field. `AmbiguousFieldName` is not raised under this tier at all since 3.3.0: an ambiguous name is + refused as an unknown name is. - Inside a `Segment` every field refusal is `FieldDeniedForSegment` with `Feature = Segment`, whichever clause refused it: a condition in any set, an order, a select, or taking part at all (3.1.0). Answered by clause, a field denied for every clause but not for `Segment` would say `FieldDeniedForOrder` where a name that matches nothing @@ -2586,10 +2589,11 @@ DefaultOrder field denied for Segment, in a Segment (3.1.0) left out l this caller may not order by, in both tiers, and in a `Segment` less every field this caller may not use in a segment. Each field left out is recorded as `Dropped` on `Order`, with a reason starting `left out of the default order`, and never refused. Dry run keeps it and still records it (section 13). -- These throw regardless of tier and dry run: `AmbiguousFieldName`, `GroupTooSmall`, `AmbiguousGroupKey`, +- These throw regardless of tier and dry run: `GroupTooSmall`, `AmbiguousGroupKey`, `CapExceeded` from a full audit buffer, `MissingHashSalt`, `MissingTokenVault`, `TransformRequiresMaterialization`, `StoreUnavailable` and `PolicyContextNotPrepared`. `PolicyRequired` - is raised on unguarded calls only. + is raised on unguarded calls only. `AmbiguousFieldName` throws under `Convenience` and in a dry run; under + `Strict` the name is refused as an unknown one instead (3.3.0). - Dry run is on when `DwPolicyOptions.DryRun` or `DwPolicyContext.DryRun` is true. It records every other throw and drop in the trace, applies none of them, and leaves the clause as written. In dry run: - no forced predicate is injected and no projection is synthesized; @@ -2852,7 +2856,8 @@ Caps.MinGroupSize unset -> DwCaps.DefaultMinGroupSize = 5 (IsMinGroupSizeSet = of a transformed field gets the field's chain. `MAX(Age) = 41` with `Round, Step = 10` returns `40`. - If two rows now share the same values across the transformed keys, the query throws `AmbiguousGroupKey`, in both tiers and in dry run. `FieldPath` is the transformed key paths joined by - `", "` and `Feature` is `Group`. + `", "` and `Feature` is `Group` — under `Strict` outside a dry run it is `"*"` with no `SourceOrigin` + (3.3.0), because the key's canonical path is the column behind the caller's alias. - Only the transformed keys are compared. Grouping by `[Department, Salary]` with `Salary` rounded throws as soon as two departments share a rounded salary. @@ -4124,11 +4129,13 @@ PolicyException : LogicException namespace Dyn Code : string ErrorCode.ToString(), for logs and JSON FieldPath : string the name the caller wrote; "*" for the whole request, including the PolicyContextNotPrepared ApplyPolicy raises; under the Strict tier "*" for - codes 1–6, CapExceeded and MissingContextValue too (3.1.0); the entity's + codes 1–6, CapExceeded and MissingContextValue too (3.1.0), and for + AmbiguousGroupKey, TransformRequiresMaterialization, MissingHashSalt and + MissingTokenVault (3.3.0); the entity's short type name for PolicyRequired, StoreUnavailable and a store provider's PolicyContextNotPrepared; the transformed paths joined by ", " for TransformRequiresMaterialization and - AmbiguousGroupKey + AmbiguousGroupKey outside the Strict tier Feature : PolicyFeature None for ApplyPolicy's PolicyContextNotPrepared; All for a store provider's refusals Tier : DwTier the tier in force; always Strict for PolicyRequired, StoreUnavailable and a store provider's PolicyContextNotPrepared (ApplyPolicy's carries the configured tier) @@ -4136,7 +4143,10 @@ PolicyException : LogicException namespace Dyn under the Strict tier (3.1.0) SourceOrigin : string? { init; } the attribute, rule, cap or reason that decided, e.g. "MaxPageSize cap (1000), request had 1001"; null for codes 1–6 and - MissingContextValue under the Strict tier (3.1.0) + MissingContextValue under the Strict tier (3.1.0), and for + AmbiguousGroupKey, MissingHashSalt and MissingTokenVault (3.3.0). + TransformRequiresMaterialization keeps its origin, which names the method + and what to call instead, never a field Message ": field '', feature '', tier ''." ``` @@ -4169,11 +4179,16 @@ through `PolicySimulator` or in dry run to see the decisions). its operators 12 MissingContextValue a [DwForceWhere] ContextValue key is absent or null in the context; recorded under Strict FieldPath "*" and no SourceOrigin (3.1.0) -13 AmbiguousFieldName a name could mean two fields: an alias equal to a path or another alias throws +13 AmbiguousFieldName a name could mean two fields: an alias equal to a path or another alias. throws + Convenience and dry run only: under Strict the name is refused as an + unknown one, with the clause's own code (3.3.0) 14 QueryStringDenied Strict tier and getQueryString: true (FieldPath "*") recorded -15 AmbiguousGroupKey two summary rows share their key values once transformed throws +15 AmbiguousGroupKey two summary rows share their key values once transformed; under Strict throws + FieldPath "*" and no SourceOrigin (3.3.0) 16 TransformRequiresMaterialization SelectDynamic, FilterDynamic, Group or Summary on the guarded handle throws - for a type with any transform for this caller + for a type with any transform for this caller; under Strict FieldPath + "*" rather than the transformed columns (3.3.0), and the origin, which + names the method and what to call instead, stays 17 StoreUnavailable a FailClosed store provider is degraded, or the context's pinned throws snapshot is older than MaxSnapshotAge 18 PolicyContextNotPrepared ApplyPolicy(ctx) sees a context that never went through PrepareAsync throws @@ -4183,8 +4198,10 @@ through `PolicySimulator` or in dry run to see the decisions). under Strict checked after the field gates (3.1.0) 20 GroupTooSmall the caller used the reserved name "__dwGroupSize" as an alias, Having throws field or order field. A small group never raises it: it is suppressed -21 MissingHashSalt MaskStrategy.Hash while DwPolicyOptions.HashSalt is empty throws -22 MissingTokenVault MaskStrategy.Tokenize while DwPolicyOptions.TokenVault is null throws +21 MissingHashSalt MaskStrategy.Hash while DwPolicyOptions.HashSalt is empty; under Strict throws + FieldPath "*" and no SourceOrigin (3.3.0) +22 MissingTokenVault MaskStrategy.Tokenize while DwPolicyOptions.TokenVault is null; under throws + Strict FieldPath "*" and no SourceOrigin (3.3.0) ``` "recorded" means dry run writes a `Denied` decision to the trace and runs the query anyway. @@ -4306,8 +4323,9 @@ There are eleven. The first eight let a caller learn a value, or what the policy field refusal, so the two cannot be told apart; `Convenience` and a dry run still answer `CapExceeded`. - **A refusal that names a field:** four did under `Strict` until 3.3.0 — an ambiguous name, an ambiguous grouping key, a clause that cannot be transformed, and a deployment with no hash salt or token vault. An ambiguous name is - now refused as an unknown name is; the other three name the clause and carry no origin. `Convenience` and a dry - run are unchanged. + now refused as an unknown name is; the grouping key, the hash salt and the token vault name the clause and carry + no origin, and the clause that cannot be transformed names the clause and keeps an origin naming the method and + what to call instead, never a field. `Convenience` and a dry run, either switch, are unchanged. - **An unrecorded read:** until 3.3.0 `[DwAudit]` recorded only a field the request named, so a caller sending no `Selects` received every audited member of the row with nothing written down. Every audited member a projection the caller did not name hands back is recorded for `Select` now, one event per query. @@ -4920,6 +4938,14 @@ MemoryCalculationInput trace record it. A simulation has no source, so PolicySimulator and /simulate cannot refuse such a path. - Behaviour change. LastTrace is assigned before a request is sanitized, so a refused request leaves its own trace readable instead of the previous request's. + - Security fix and behaviour change. Under Strict outside a dry run, four refusals that named a field name + the clause instead. AmbiguousFieldName said the caller's name matched more than one field, and so at + least one; it is refused as an unknown name is now, with the trace keeping the ambiguity. + AmbiguousGroupKey reported the column behind the caller's alias and an origin saying its values are + transformed; TransformRequiresMaterialization listed every transformed column on the type; + MissingHashSalt and MissingTokenVault named the masked field a deployment had not configured for. The + three report "*" and no origin, and TransformRequiresMaterialization keeps an origin that names the + method rather than a field. Convenience and a dry run, either switch, are unchanged. - Behaviour change and security fix. Under Strict outside a dry run, a query exhausting the audit buffer is refused with the clause's own field refusal rather than with CapExceeded and an origin naming the cap. An unknown name is never audited, so the two answers told a caller which names are real and audited. diff --git a/README.md b/README.md index b06dbfd..a405278 100644 --- a/README.md +++ b/README.md @@ -382,7 +382,7 @@ The complete reference — every enum, class, extension method, validation rule, - **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a value's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor, and `IncludeTraceInResult` written as the tier's own answer is the answer the tier already gives. A type exposed under two names is reported under the last one, so two catalogues that resolve every name alike are still a different posture when the order differs. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. - **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query any provider but EF Core's own translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()`, or a host's own registered through `ReplaceService` — because such a provider may rewrite what EF Core cannot, and the library cannot tell one that does from one that passes straight through. The test is EF Core's own provider type, from EF Core's own assembly. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. - **Fixed (security): an audited field could be read with nothing recorded.** `[DwAudit]` answers who read a field, and a request that sent no `Selects` received the whole row with nothing written down — one token past the control. Every audited member a projection the caller did not name hands back is now recorded for `Select`, one event per query rather than per row. A deployment already running the control sees more events, and `DwCaps.MaxAuditEvents` refuses rather than dropping a record, so raise the cap or drain per request with `app.UseDwPolicyAudit()`. -- **Fixed (security): four refusals named a field under `Strict`.** A strict refusal names no field, so that a denied one, a misspelling and a field that does not exist cannot be told apart. `AmbiguousFieldName` said the caller's name matched more than one field, and so at least one; it is refused as an unknown name is now, with the ambiguity kept in the trace. `AmbiguousGroupKey` reported the column behind the caller's alias and an origin saying its values are transformed; `TransformRequiresMaterialization` listed every masked column on the type; `MissingHashSalt` and `MissingTokenVault` named the masked field a deployment had not configured for. The three report `"*"` and no origin now. `Convenience` and a dry run are unchanged. +- **Fixed (security): four refusals named a field under `Strict`.** A strict refusal names no field, so that a denied one, a misspelling and a field that does not exist cannot be told apart. `AmbiguousFieldName` said the caller's name matched more than one field, and so at least one; it is refused as an unknown name is now, with the ambiguity kept in the trace. `AmbiguousGroupKey` reported the column behind the caller's alias and an origin saying its values are transformed; `TransformRequiresMaterialization` listed every transformed column on the type; `MissingHashSalt` and `MissingTokenVault` named the masked field a deployment had not configured for. The grouping key, the hash salt and the token vault report `"*"` and no origin now, and the transform refusal reports `"*"` and keeps an origin that names the method rather than a field. `Convenience` and a dry run, whichever switch declares it, are unchanged. - **Fixed (security): the audit cap answered differently for a real field.** An audited field records one event per use, and the query is refused rather than the record dropped once `DwCaps.MaxAuditEvents` is reached. That refusal carried `CapExceeded` and a `SourceOrigin` naming the cap, while a name matching nothing carried the ordinary field refusal and no origin — and an unknown name is never audited, so one guess per request told a caller which names are real and audited. Under `Strict`, outside a dry run, the cap now refuses with the clause's own code, `FieldPath` `"*"` and no origin; the request still fails and the trace still records which refusal it was. `Convenience` and a dry run are unchanged. - **Changed: `LastTrace` is set before a request is sanitized**, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. - **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. Every node is new; a condition's values stay the caller's own objects, in a new list. From fe4745df8ad2420a5a23264f7a7b140e7e04096a Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 16:45:54 +0300 Subject: [PATCH 29/54] fix(policies): the audit keeps the field the caller is not told, and three more the review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sixth review's security pass, including one more regression of my own. Hiding the field from the caller hid it from the audit too. RefusalAudit records AuditPath ?? FieldPath, and the four refusals changed in the fifth round set the path to "*" without setting AuditPath, so an audited refusal said "*" where it used to name the field. A record of a refusal that cannot say which field was probed answers nothing, which is what AuditPath exists for; all four set it now. A blank GroupBy.Fields entry reached the resolver and failed with ArgumentNullException, where every sibling clause guards a blank first. That is neither a refusal nor the malformed-clause failure an endpoint turns into a four-hundred — it reads to an operator as the endpoint being broken. The grouping-key loop guards it like the rest, and a blank name now fails a guarded query exactly as it fails an unguarded one in every clause. An alias spelled like another member of the same type passed the startup scan and then merged two columns on the way out: the rename wrote one column under the other's name and the other's value was gone. ValidateModel reports it, and a rename onto a name the row already carries is not applied, so a deployment that never scans keeps both columns. Left as they are, with reasons: a store refusal reports the strict tier because it does not soften in the convenience one, which its own remarks and the reference both say; and a group key holding a unit separator can invent a collision it cannot hide, which is the over-refusing direction and older than this branch. Co-Authored-By: Claude Opus 5 --- .../Policies/Sx6AmbiguityProbes.cs | 382 +++++++++++++ .../Policies/Sx6BlankNameProbes.cs | 288 ++++++++++ .../Policies/Sx6CodeWalkProbes.cs | 461 +++++++++++++++ .../Policies/Sx6RefusalAuditProbes.cs | 526 ++++++++++++++++++ .../Policies/Sx6RenameProbes.cs | 274 +++++++++ .../Policies/Masking/TransformPipeline.cs | 10 +- .../Policies/Source/FilterSanitizer.cs | 9 + .../Policies/Source/PolicyQueryable.cs | 1 + .../Policies/Source/ResultTransformer.cs | 8 +- .../Validation/PolicyModelValidator.cs | 23 +- 10 files changed, 1977 insertions(+), 5 deletions(-) create mode 100644 DynamicWhere.Tests/Policies/Sx6AmbiguityProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Sx6BlankNameProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Sx6CodeWalkProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Sx6RefusalAuditProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Sx6RenameProbes.cs diff --git a/DynamicWhere.Tests/Policies/Sx6AmbiguityProbes.cs b/DynamicWhere.Tests/Policies/Sx6AmbiguityProbes.cs new file mode 100644 index 0000000..41cb3ac --- /dev/null +++ b/DynamicWhere.Tests/Policies/Sx6AmbiguityProbes.cs @@ -0,0 +1,382 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 6, adversarial security review of 3.3.0 at 7034717. + // + // Reviews round 5's first fix: an ambiguous name is recorded and refused as an unknown name is, + // under Strict outside a dry run, instead of throwing AmbiguousFieldName. + // + // The three questions the fix has to answer: + // 1. is the refusal byte-identical to an unknown name's, in EVERY clause? + // 2. can the canonical path leak through the trace-in-result path? + // 3. did anything downstream depend on the old exception? + // ============================================================================================= + + /// + /// A type on which one spoken name means two different members: the real property + /// Salary, and the alias Salary that Notes carries. + /// + internal class Sx6Colliding + { + public int Id { get; set; } + + public string Region { get; set; } = string.Empty; + + /// The real property. + public string Salary { get; set; } = string.Empty; + + /// The alias, which collides with the property above. + [DwAlias("Salary")] + public string Notes { get; set; } = string.Empty; + + /// A denied field, for the three-way comparison. + [DwDenied] + public string Secret { get; set; } = string.Empty; + } + + public sealed class Sx6AmbiguityProbes + { + private readonly ITestOutputHelper _out; + + public Sx6AmbiguityProbes(ITestOutputHelper output) => _out = output; + + // ---- harness --------------------------------------------------------------------------- + + private const string Ambiguous = "Salary"; + private const string Unknown = "Nope"; + private const string Denied = "Secret"; + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict, bool dryRun = false) => + new() { Tier = tier, DryRun = dryRun, Caps = { MinGroupSize = 1 } }; + + /// Everything a caller can read off a refusal. + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.GetType().Name}|{refusal.ErrorCode}|path={refusal.FieldPath}" + + $"|feature={refusal.Feature}|tier={refusal.Tier}|rule={refusal.RuleId ?? "-"}" + + $"|origin={refusal.SourceOrigin ?? "-"}|message={refusal.Message}", + LogicException failure => + $"{failure.GetType().Name}|{failure.Message}|subject={failure.Subject ?? "-"}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static Condition On(string field) => + new() { Sort = 0, Field = field, DataType = DataType.Text, Operator = Operator.Equal, Values = { "x" } }; + + private static ConditionGroup GroupOf(params Condition[] conditions) + { + ConditionGroup group = new() { Connector = Connector.And }; + + for (int i = 0; i < conditions.Length; i++) + { + conditions[i].Sort = i; + group.Conditions.Add(conditions[i]); + } + + return group; + } + + // Each clause, parameterised by the name the caller writes. + + private static Exception? Where(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Filter { ConditionGroup = GroupOf(On(name)) }, + Attributes(), Caller(), options, trace)); + + private static Exception? Select(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Filter { Selects = new List { "Id", name } }, + Attributes(), Caller(), options, trace)); + + private static Exception? Order(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Filter { Orders = new List { new() { Sort = 0, Field = name, Direction = Direction.Ascending } } }, + Attributes(), Caller(), options, trace)); + + private static Exception? Group(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Summary + { + GroupBy = new GroupBy + { + Fields = new List { name }, + AggregateBy = new List + { + new() { Field = "Id", Alias = "n", Aggregator = Aggregator.Count } + } + } + }, + Attributes(), Caller(), options, trace)); + + private static Exception? Aggregate(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Region" }, + AggregateBy = new List + { + new() { Field = name, Alias = "n", Aggregator = Aggregator.Count } + } + } + }, + Attributes(), Caller(), options, trace)); + + private static Exception? SegmentWhere(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Segment + { + ConditionSets = new List + { + new() { Sort = 0, Intersection = Intersection.Union, ConditionGroup = GroupOf(On(name)) } + }, + Selects = new List { "Id" } + }, + Attributes(), Caller(), options, trace)); + + private static Exception? SegmentSelect(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Segment + { + ConditionSets = new List + { + new() { Sort = 0, Intersection = Intersection.Union, ConditionGroup = GroupOf(On("Region")) } + }, + Selects = new List { "Id", name } + }, + Attributes(), Caller(), options, trace)); + + private static Exception? SegmentOrder(string name, DwPolicyOptions options, PolicyTrace trace) => Catch( + () => FilterSanitizer.Sanitize( + new Segment + { + ConditionSets = new List + { + new() { Sort = 0, Intersection = Intersection.Union, ConditionGroup = GroupOf(On("Region")) } + }, + Selects = new List { "Id" }, + Orders = new List { new() { Sort = 0, Field = name, Direction = Direction.Ascending } } + }, + Attributes(), Caller(), options, trace)); + + private static readonly (string Clause, Func Run)[] Clauses = + { + ("where", Where), + ("select", Select), + ("order", Order), + ("group", Group), + ("aggregate", Aggregate), + ("segment-where", SegmentWhere), + ("segment-select", SegmentSelect), + ("segment-order", SegmentOrder) + }; + + // ========================================================================================= + // Q1. Is the refusal byte-identical to an unknown name's, in every clause? + // ========================================================================================= + + [Fact] + public void Strict_refuses_an_ambiguous_name_exactly_as_it_refuses_an_unknown_one() + { + List mismatched = new(); + + foreach ((string clause, Func run) in Clauses) + { + DwPolicyOptions options = Options(); + + string ambiguous = Shape(run(Ambiguous, options, new PolicyTrace(DwTier.Strict, false))); + string unknown = Shape(run(Unknown, options, new PolicyTrace(DwTier.Strict, false))); + string denied = Shape(run(Denied, options, new PolicyTrace(DwTier.Strict, false))); + + _out.WriteLine($"[{clause}]"); + _out.WriteLine($" ambiguous : {ambiguous}"); + _out.WriteLine($" unknown : {unknown}"); + _out.WriteLine($" denied : {denied}"); + + if (!string.Equals(ambiguous, unknown, StringComparison.Ordinal)) + { + mismatched.Add($"{clause}: ambiguous='{ambiguous}' unknown='{unknown}'"); + } + + if (!string.Equals(unknown, denied, StringComparison.Ordinal)) + { + mismatched.Add($"{clause}: unknown='{unknown}' denied='{denied}'"); + } + } + + Assert.Empty(mismatched); + } + + // ========================================================================================= + // Q2. Can the canonical path leak through the trace? + // ========================================================================================= + + /// + /// The trace records the candidates, which are canonical paths. A refused query returns no + /// result, so the only way out is LastTrace — in process, never serialized. + /// + [Fact] + public void The_trace_records_the_candidates_an_ambiguous_name_matched() + { + PolicyTrace trace = new(DwTier.Strict, dryRun: false); + + Exception? error = Where(Ambiguous, Options(), trace); + + foreach (PolicyDecision decision in trace.Decisions) + { + _out.WriteLine($" {decision.FieldPath}|{decision.Feature}|{decision.Action}|{decision.Reason}"); + } + + Assert.IsType(error); + + // The operator who has to fix the aliases can read what collided. + Assert.Contains( + trace.Decisions, + d => d.Reason is not null && d.Reason.Contains("Notes", StringComparison.Ordinal)); + } + + /// + /// The refusal itself never carries a candidate, whatever a caller reads off it. + /// + [Fact] + public void The_refusal_never_carries_a_candidate_path() + { + foreach ((string clause, Func run) in Clauses) + { + Exception? error = run(Ambiguous, Options(), new PolicyTrace(DwTier.Strict, false)); + + string text = Shape(error); + + _out.WriteLine($"[{clause}] {text}"); + + Assert.DoesNotContain("Notes", text, StringComparison.Ordinal); + Assert.DoesNotContain("Salary", text, StringComparison.Ordinal); + } + } + + // ========================================================================================= + // Q3. Did anything downstream depend on the old exception? + // ========================================================================================= + + /// The convenience tier is unchanged: it still raises the ambiguity by name. + [Fact] + public void Convenience_still_raises_AmbiguousFieldName() + { + foreach ((string clause, Func run) in Clauses) + { + Exception? error = run(Ambiguous, Options(DwTier.Convenience), new PolicyTrace(DwTier.Convenience, false)); + + _out.WriteLine($"[{clause}] {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, refusal.ErrorCode); + } + } + + /// A dry run under Strict is unchanged: it still raises the ambiguity by name. + [Fact] + public void A_strict_dry_run_still_raises_AmbiguousFieldName() + { + Exception? error = Where(Ambiguous, Options(dryRun: true), new PolicyTrace(DwTier.Strict, true)); + + _out.WriteLine(Shape(error)); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, refusal.ErrorCode); + } + + /// + /// The ambiguity is never silently resolved: no clause comes back holding either candidate. + /// + [Fact] + public void No_clause_resolves_the_ambiguity_in_favour_of_one_candidate() + { + foreach ((string clause, Func run) in Clauses) + { + Exception? error = run(Ambiguous, Options(), new PolicyTrace(DwTier.Strict, false)); + + _out.WriteLine($"[{clause}] {(error is null ? "ANSWERED" : error.GetType().Name)}"); + + // An answer here would mean the library picked one of the two meanings. + Assert.NotNull(error); + } + } + + // ========================================================================================= + // The per-context dry run, which the fix's own rule says names fields anyway. + // ========================================================================================= + + /// + /// A canary subject running unenforced. The sanitizer's own IsDryRun is the union of + /// the global switch and this one, so this is a dry run for every decision the gate makes. + /// + [Fact] + public void A_context_dry_run_is_a_dry_run_for_the_sanitizer() + { + DwPolicyContext canary = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + canary.DryRun = true; + + PolicyTrace trace = new(DwTier.Strict, dryRun: true); + + Exception? error = Catch(() => FilterSanitizer.Sanitize( + new Filter { ConditionGroup = GroupOf(On(Ambiguous)) }, + Attributes(), canary, Options(), trace)); + + _out.WriteLine($"context dry run, ambiguous : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + // Not gated as an unknown name: the per-context dry run reaches HidesExistence too. + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, refusal.ErrorCode); + + // And a denied field is not refused at all under a context dry run. + DwPolicyContext second = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + second.DryRun = true; + + Exception? denied = Catch(() => FilterSanitizer.Sanitize( + new Filter { ConditionGroup = GroupOf(On(Denied)) }, + Attributes(), second, Options(), new PolicyTrace(DwTier.Strict, true))); + + _out.WriteLine($"context dry run, denied : {Shape(denied)}"); + + Assert.Null(denied); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Sx6BlankNameProbes.cs b/DynamicWhere.Tests/Policies/Sx6BlankNameProbes.cs new file mode 100644 index 0000000..9b02249 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Sx6BlankNameProbes.cs @@ -0,0 +1,288 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 6. A blank field name in each clause, under Strict. + // + // CanonicalizeGroup and CanonicalizeOrder both refuse a blank field with LogicException before + // ResolveName sees it. Selects and GroupBy.Fields have no such guard, and under Strict a blank + // name reaches Gate.Unknown, which keeps it as the empty string — the one field path + // PolicyDecision refuses to be constructed with. + // ============================================================================================= + + /// A plain type, so nothing but the blank name decides what happens. + public class Sx6Blankable + { + public int Id { get; set; } + + public string Region { get; set; } = string.Empty; + } + + public sealed class Sx6BlankNameProbes + { + private readonly ITestOutputHelper _out; + + public Sx6BlankNameProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict) => + new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => $"PolicyException|{refusal.ErrorCode}|path={refusal.FieldPath}", + LogicException failure => $"LogicException|{failure.Message}", + _ => $"{error.GetType().Name}|{error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static Exception? Run(string blank, DwTier tier, Func build) => Catch(() => + { + object request = build(blank); + + PolicyQueryable guarded = + Array.Empty().AsQueryable().ApplyPolicy(Caller(), Options(tier), Attributes()); + + switch (request) + { + case Filter filter: + guarded.ToList(filter); + break; + + case Summary summary: + guarded.ToList(summary); + break; + + case Segment segment: + guarded.ToListAsync(segment).GetAwaiter().GetResult(); + break; + } + }); + + private static object SelectClause(string name) => + new Filter { Selects = new List { "Id", name } }; + + private static object WhereClause(string name) => new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = name, DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + }; + + private static object OrderClause(string name) => + new Filter { Orders = new List { new() { Sort = 0, Field = name, Direction = Direction.Ascending } } }; + + private static object GroupClause(string name) => new Summary + { + GroupBy = new GroupBy + { + Fields = new List { name }, + AggregateBy = new List + { + new() { Field = "Id", Alias = "n", Aggregator = Aggregator.Count } + } + } + }; + + private static object SegmentSelectClause(string name) => new Segment + { + ConditionSets = new List + { + new() + { + Sort = 0, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "Region", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + } + }, + Selects = new List { "Id", name } + }; + + private static readonly (string Clause, Func Build)[] Clauses = + { + ("where", WhereClause), + ("order", OrderClause), + ("select", SelectClause), + ("group", GroupClause), + ("segment-select", SegmentSelectClause) + }; + + /// What the same request does with no policy in front of it. + private static Exception? Unguarded(string blank, Func build) => Catch(() => + { + object request = build(blank); + + IQueryable source = Array.Empty().AsQueryable(); + + switch (request) + { + case Filter filter: + source.ToListDynamic(filter); + break; + + case Summary summary: + source.ToList(summary); + break; + + case Segment segment: + source.ToListAsync(segment).GetAwaiter().GetResult(); + break; + } + }); + + // ========================================================================================= + // A blank name in a clause with no InvalidField guard — Selects, GroupBy.Fields and a + // segment's Selects — leaves the library as ArgumentNullException rather than as one of its + // own two exception types, in both tiers. + // ========================================================================================= + + [Fact] + public void A_blank_name_leaves_a_guarded_query_exactly_as_it_leaves_an_unguarded_one() + { + List diverged = new(); + + foreach (string blank in new[] { string.Empty, " ", "\t" }) + { + foreach ((string clause, Func build) in Clauses) + { + Exception? strict = Run(blank, DwTier.Strict, build); + Exception? convenience = Run(blank, DwTier.Convenience, build); + Exception? bare = Unguarded(blank, build); + + _out.WriteLine($"[{clause}] blank='{blank.Replace("\t", "\\t")}'"); + _out.WriteLine($" strict : {Shape(strict)}"); + _out.WriteLine($" convenience : {Shape(convenience)}"); + _out.WriteLine($" unguarded : {Shape(bare)}"); + + // The standard the library holds itself to for a name it cannot resolve: the + // guarded query fails exactly as the unguarded one does. + if (!string.Equals(Shape(strict), Shape(bare), StringComparison.Ordinal)) + { + diverged.Add($"strict/{clause}/'{blank}': {Shape(strict)} vs {Shape(bare)}"); + } + + if (!string.Equals(Shape(convenience), Shape(bare), StringComparison.Ordinal)) + { + diverged.Add($"convenience/{clause}/'{blank}': {Shape(convenience)} vs {Shape(bare)}"); + } + } + } + + foreach (string line in diverged) + { + _out.WriteLine($"DIVERGED {line}"); + } + + // A blank name fails a guarded query exactly as it fails an unguarded one, in every + // clause. The grouping key used to be the exception: its loop handed the blank straight + // to the resolver, which reached Validate's argument check, so a malformed clause + // came back as an ArgumentNullException rather than the failure the endpoint turns into + // a four-hundred. It guards the blank first now, as every sibling clause does. + Assert.Empty(diverged); + } + + /// + /// The divergence on its own, spelled out: the guarded summary leaves as a framework + /// exception where the unguarded one leaves as the library's own malformed-request error. + /// + [Fact] + public void A_blank_grouping_key_fails_the_same_way_once_the_query_is_guarded() + { + Exception? guarded = Run(string.Empty, DwTier.Strict, GroupClause); + Exception? bare = Unguarded(string.Empty, GroupClause); + + _out.WriteLine($"guarded : {Shape(guarded)}"); + _out.WriteLine($"unguarded : {Shape(bare)}"); + + LogicException malformed = Assert.IsType(bare); + + Assert.Equal("ConditionMustHasValidFieldName", malformed.Message); + + // The same failure, guarded: a malformed clause, not an exception from inside the + // sanitizer that nothing above it catches. + LogicException guardedMalformed = Assert.IsType(guarded); + + Assert.Equal(malformed.Message, guardedMalformed.Message); + } + + /// + /// The same blank name straight through the sanitizer, so the failure is attributable to it + /// rather than to anything the terminal does afterwards. + /// + [Fact] + public void A_blank_projection_name_reaches_the_gate_under_strict() + { + Exception? error = Catch(() => FilterSanitizer.Sanitize( + new Filter { Selects = new List { "Id", string.Empty } }, + Attributes(), Caller(), Options(), new PolicyTrace(DwTier.Strict, dryRun: false))); + + _out.WriteLine($"sanitizer, blank select : {Shape(error)}"); + + // Validator.Validate(string) is documented to throw ArgumentNullException for a name + // that is null or whitespace, and ResolveName hands the raw name to it. Recorded rather + // than asserted as a defect: the unguarded surface does the same, which is the standard + // the library applies to every name it cannot resolve. + Assert.IsType(error); + } + + /// The same for a blank grouping key. + [Fact] + public void A_blank_grouping_key_is_refused_before_it_reaches_the_gate() + { + Exception? error = Catch(() => FilterSanitizer.Sanitize( + (Summary)GroupClause(string.Empty), + Attributes(), Caller(), Options(), new PolicyTrace(DwTier.Strict, dryRun: false))); + + _out.WriteLine($"sanitizer, blank group key : {Shape(error)}"); + + Assert.IsType(error); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Sx6CodeWalkProbes.cs b/DynamicWhere.Tests/Policies/Sx6CodeWalkProbes.cs new file mode 100644 index 0000000..1def4fc --- /dev/null +++ b/DynamicWhere.Tests/Policies/Sx6CodeWalkProbes.cs @@ -0,0 +1,461 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Storage; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 6. Walks every PolicyErrorCode the library can raise under Strict and records what it + // names: the code, the path, the rule id and the origin — the four things a caller reads. + // ============================================================================================= + + /// A field filterable only with the operator its attribute allows, and nothing else. + public class Sx6SoleRestricted + { + public int Id { get; set; } + + [DwOperators(Allow = new[] { Operator.Equal })] + public string Serial { get; set; } = string.Empty; + } + + /// The same restriction behind an alias, so the refusal has a second spelling to pick. + public class Sx6AliasRestricted + { + public int Id { get; set; } + + [DwAlias("badge")] + [DwOperators(Allow = new[] { Operator.Equal })] + public string Serial { get; set; } = string.Empty; + } + + /// A bare type, so a runtime rule is the only source of its policy. + public class Sx6Plain + { + public int Id { get; set; } + + public string Serial { get; set; } = string.Empty; + } + + /// A weighted field, for the cost cap. + public class Sx6Costly + { + public int Id { get; set; } + + [DwCost(500)] + public string Blob { get; set; } = string.Empty; + } + + /// A scope read from the caller's context, for the missing-context refusal. + public class Sx6Scoped + { + public int Id { get; set; } + + [DwForceWhere(Operator.Equal, ContextValue = "tenant")] + public int TenantId { get; set; } + } + + public sealed class Sx6CodeWalkProbes + { + private readonly ITestOutputHelper _out; + + public Sx6CodeWalkProbes(ITestOutputHelper output) + { + _out = output; + PolicyBootstrap.Ensure(); + } + + // ---- harness --------------------------------------------------------------------------- + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict) => + new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|tier={refusal.Tier}|rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}", + LogicException failure => $"LogicException|{failure.Message}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static Filter WhereOn( + string field, Operator op = Operator.Equal, DataType type = DataType.Text, string value = "x") => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition { Sort = 0, Field = field, DataType = type, Operator = op, Values = { value } } + } + } + }; + + // ========================================================================================= + // FINDING candidate. OperatorNotAllowed is not in IsFieldDenial, so Gate.Exception takes the + // attributing branch: it names the field AND, for a single-source policy, the attribute. + // ========================================================================================= + + [Fact] + public void Strict_operator_refusal_attributes_the_attribute_that_restricted_the_field() + { + Exception? sole = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()) + .ToList(WhereOn("Serial", Operator.Contains))); + + Exception? aliased = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()) + .ToList(WhereOn("badge", Operator.Contains))); + + // What the same probe gets for a name that does not exist. + Exception? unknown = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()) + .ToList(WhereOn("NoSuchColumn", Operator.Contains))); + + _out.WriteLine($"sole source : {Shape(sole)}"); + _out.WriteLine($"aliased : {Shape(aliased)}"); + _out.WriteLine($"unknown name : {Shape(unknown)}"); + + PolicyException refusal = Assert.IsType(sole); + + Assert.Equal(PolicyErrorCode.OperatorNotAllowed, refusal.ErrorCode); + Assert.Equal("Serial", refusal.FieldPath); + + // With attributes the policy carries more than one source, so Gate.Exception declines to + // attribute and the origin stays empty. + Assert.Null(refusal.SourceOrigin); + Assert.Null(refusal.RuleId); + + // It is still told apart from a name that matches nothing, which answers "*". That is + // sound: a field with an operator restriction is one the caller MAY filter, so its + // existence is not a secret the tier is keeping. + Assert.Equal("*", Assert.IsType(unknown).FieldPath); + Assert.Equal("badge", Assert.IsType(aliased).FieldPath); + } + + /// + /// The case Gate.Exception does attribute: a policy with exactly one source. A runtime rule + /// restricting the operators is such a policy, and under Strict the refusal then carries the + /// rule's identifier and its origin string. + /// + [Fact] + public void Strict_operator_refusal_from_a_sole_runtime_rule_carries_the_rule_id() + { + FakePolicyProvider rules = new FakePolicyProvider() + .AddOperators("Serial", PolicyLevel.DynamicUser, Operator.Equal); + + PolicyResolver resolver = new(new IDwPolicyProvider[] { rules }); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), resolver) + .ToList(WhereOn("Serial", Operator.Contains))); + + _out.WriteLine($"sole runtime rule : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.OperatorNotAllowed, refusal.ErrorCode); + Assert.Equal("Serial", refusal.FieldPath); + + // What a field denial from the same rule would have carried, and does not. + FakePolicyProvider denying = new FakePolicyProvider() + .Add("Serial", PolicyFeature.Where, PolicyEffect.Deny, PolicyLevel.DynamicUser); + + Exception? denied = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), new PolicyResolver(new IDwPolicyProvider[] { denying })) + .ToList(WhereOn("Serial"))); + + _out.WriteLine($"sole rule, denied : {Shape(denied)}"); + + PolicyException blanked = Assert.IsType(denied); + + Assert.Equal("*", blanked.FieldPath); + Assert.Null(blanked.RuleId); + Assert.Null(blanked.SourceOrigin); + } + + /// + /// The same code reached through a Having clause, where the reference is an aggregate + /// alias standing for the restricted column. + /// + [Fact] + public void Strict_operator_refusal_in_having_names_the_path_behind_the_alias() + { + Summary summary = new() + { + GroupBy = new GroupBy + { + Fields = new List { "Id" }, + AggregateBy = new List + { + new() { Field = "Serial", Alias = "top", Aggregator = Aggregator.Maximum } + } + }, + Having = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "top", DataType = DataType.Text, + Operator = Operator.Contains, Values = { "x" } + } + } + } + }; + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()).ToList(summary)); + + _out.WriteLine($"having on aggregate alias : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.OperatorNotAllowed, refusal.ErrorCode); + + // The caller named "Serial" themselves, in the aggregate, so naming it back tells them + // nothing they did not write. + Assert.Equal("Serial", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + } + + /// + /// The same clause where the caller never writes the canonical name: the aggregate names the + /// alias, and the having condition names the aggregate's own alias. + /// + [Fact] + public void Strict_operator_refusal_in_having_reports_the_alias_the_caller_wrote() + { + Summary summary = new() + { + GroupBy = new GroupBy + { + Fields = new List { "Id" }, + AggregateBy = new List + { + new() { Field = "badge", Alias = "top", Aggregator = Aggregator.Maximum } + } + }, + Having = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "top", DataType = DataType.Text, + Operator = Operator.Contains, Values = { "x" } + } + } + } + }; + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()).ToList(summary)); + + _out.WriteLine($"having, alias only : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.OperatorNotAllowed, refusal.ErrorCode); + + // The canonical column must not come back to a caller who only ever wrote "badge". + Assert.NotEqual("Serial", refusal.FieldPath); + } + + // ========================================================================================= + // The rest of the codes, for the record. + // ========================================================================================= + + [Fact] + public void Strict_cap_refusals_name_the_clause_and_the_cap() + { + DwPolicyOptions capped = new() { Tier = DwTier.Strict, Caps = { MinGroupSize = 1, MaxConditions = 1 } }; + + ConditionGroup two = new() { Connector = Connector.And }; + + two.Conditions.Add(new Condition + { + Sort = 0, Field = "Id", DataType = DataType.Number, Operator = Operator.Equal, Values = { "1" } + }); + + two.Conditions.Add(new Condition + { + Sort = 1, Field = "Blob", DataType = DataType.Text, Operator = Operator.Equal, Values = { "x" } + }); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), capped, Attributes()) + .ToList(new Filter { ConditionGroup = two })); + + _out.WriteLine($"CapExceeded : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.CapExceeded, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + [Fact] + public void Strict_cost_refusal_names_the_clause_and_prints_the_total() + { + DwPolicyOptions budget = new() { Tier = DwTier.Strict, Caps = { MinGroupSize = 1, MaxQueryCost = 10 } }; + + Exception? weighted = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), budget, Attributes()).ToList(WhereOn("Blob"))); + + Exception? plain = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), budget, Attributes()) + .ToList(WhereOn("Id", Operator.Equal, DataType.Number, "1"))); + + _out.WriteLine($"weighted field : {Shape(weighted)}"); + _out.WriteLine($"plain field : {Shape(plain)}"); + + PolicyException refusal = Assert.IsType(weighted); + + Assert.Equal(PolicyErrorCode.QueryCostExceeded, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + + // The origin prints the request's total cost, which is the sum of the weights of the + // fields the caller named — and so, for a one-field request, that field's weight. + Assert.Contains("request cost", refusal.SourceOrigin); + } + + [Fact] + public void Strict_missing_context_value_names_neither_the_column_nor_the_key() + { + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()).ToList(new Filter())); + + _out.WriteLine($"MissingContextValue : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.MissingContextValue, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + } + + [Fact] + public void Strict_query_string_refusal_names_the_clause() + { + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()).ToList(new Filter(), getQueryString: true)); + + _out.WriteLine($"QueryStringDenied : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.QueryStringDenied, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + [Fact] + public void Strict_all_selects_denied_names_the_clause() + { + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()) + .ToList(new Filter { Selects = new List { "Secret" } })); + + _out.WriteLine($"one denied select : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal("*", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + } + + // ========================================================================================= + // FINDING candidate. The store provider stamps every refusal DwTier.Strict, whatever the + // posture is, and names the store implementation class to the caller. + // ========================================================================================= + + [Fact] + public async Task A_store_refusal_reports_the_strict_tier_under_a_convenience_posture() + { + InMemoryPolicyStore inner = new(); + + inner.Seed(new PolicyRule( + DwSubjectKind.Global, null, "DynamicWhere.Tests.Policies.Sx6Sealed", "Secret", + PolicyFeature.Select, PolicyEffect.Deny)); + + UnreachableStore store = new(inner); + + DwPolicyOptions convenience = new() + { + Tier = DwTier.Convenience, + StoreFailure = StoreFailureMode.FailClosed + }; + + convenience.Freeze(); + + using StorePolicyProvider provider = + await StorePolicyProvider.CreateAsync(store, convenience, autoRefresh: false); + + DwPolicyContext context = await provider.PrepareAsync(Caller()); + + store.Broken = true; + + await Assert.ThrowsAsync(async () => await provider.RefreshAsync()); + + PolicyResolver resolver = new(new IDwPolicyProvider[] { provider }); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(context, convenience, resolver).ToList(new Filter())); + + _out.WriteLine($"StoreUnavailable : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.StoreUnavailable, refusal.ErrorCode); + + // The posture is Convenience. The refusal says Strict, and so does the audit event + // RefusalAudit builds from it. + Assert.Equal(DwTier.Convenience, convenience.Tier); + Assert.Equal(DwTier.Strict, refusal.Tier); + + // It also names the store implementation class and the entity type. + Assert.Contains("UnreachableStore", refusal.SourceOrigin); + Assert.Equal(nameof(Sx6Sealed), refusal.FieldPath); + } + + [Fact] + public void An_unprepared_context_is_refused_naming_the_clause() + { + PolicyException refusal = Assert.Throws( + () => Array.Empty().AsQueryable().ApplyPolicy(Caller())); + + _out.WriteLine($"PolicyContextNotPrepared : {Shape(refusal)}"); + + Assert.Equal(PolicyErrorCode.PolicyContextNotPrepared, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Sx6RefusalAuditProbes.cs b/DynamicWhere.Tests/Policies/Sx6RefusalAuditProbes.cs new file mode 100644 index 0000000..9621d17 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Sx6RefusalAuditProbes.cs @@ -0,0 +1,526 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Masking; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 6, adversarial security review of 3.3.0 at 7034717. + // + // Reviews round 5's fixes 2, 3 and 4 — AmbiguousGroupKey, TransformRequiresMaterialization and + // MissingHashSalt/MissingTokenVault reporting "*" under Strict — from the side the fix did not + // look at: what the AUDIT records once the caller-facing path is blanked, and whether the + // posture the three new predicates read is the same posture the rest of the library reads. + // ============================================================================================= + + /// A generalized field a caller only ever names by its alias. + public class Sx6Banded + { + public int Id { get; set; } + + [DwAlias("band")] + [DwGeneralize(GeneralizeMode.Round, Step = 100)] + [DwNoOrder] + public decimal Payroll { get; set; } + } + + /// A hashed identifier, so the missing-salt refusal can be reached. + public class Sx6Hashed + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Hash)] + [DwNoOrder] + public string NationalId { get; set; } = string.Empty; + } + + /// A tokenized identifier, so the missing-vault refusal can be reached. + public class Sx6Tokenized + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Tokenize)] + [DwNoOrder] + public string NationalId { get; set; } = string.Empty; + } + + /// A field denied outright, as the control case for what an audit records. + public class Sx6Sealed + { + public int Id { get; set; } + + [DwDenied] + public string Secret { get; set; } = string.Empty; + } + + public sealed class Sx6RefusalAuditProbes + { + private readonly ITestOutputHelper _out; + + public Sx6RefusalAuditProbes(ITestOutputHelper output) => _out = output; + + // ---- harness --------------------------------------------------------------------------- + + private static DwPolicyContext Caller(bool dryRun = false) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + context.DryRun = dryRun; + + return context; + } + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Audited(DwTier tier = DwTier.Strict, bool dryRun = false) => + new() + { + Tier = tier, + DryRun = dryRun, + AuditRefusals = true, + Caps = { MinGroupSize = 1 } + }; + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static string Recorded(DwPolicyContext context) => + context.PendingAuditEvents.Count == 0 + ? "(nothing recorded)" + : string.Join( + "; ", + context.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}:{e.ErrorCode?.ToString() ?? "-"}")); + + private static Summary ByBand() => new() + { + GroupBy = new GroupBy + { + Fields = new List { "band" }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Maximum, Alias = "top" } + } + } + }; + + private static Sx6Banded[] Colliding() => new[] + { + new Sx6Banded { Id = 1, Payroll = 100m }, + new Sx6Banded { Id = 2, Payroll = 149m } + }; + + // ========================================================================================= + // FINDING candidate. The audit loses the field with the caller-facing path. + // ========================================================================================= + + /// + /// The control. A denied field's refusal names no field to the caller and the canonical path + /// to the audit, which is the rule PolicyException.AuditPath is documented to keep. + /// + [Fact] + public void A_field_denial_names_nothing_to_the_caller_and_the_path_to_the_audit() + { + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(context, Audited(), Attributes()) + .ToList(new Filter { Selects = new List { "Id", "Secret" } })); + + _out.WriteLine($"denied field : {Shape(error)}"); + _out.WriteLine($" audit : {Recorded(context)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal("*", refusal.FieldPath); + + // The audit is not the caller: it keeps the field that was probed. + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Secret"); + } + + /// + /// MissingHashSalt under Strict. Round 5 blanked the caller-facing path and set no + /// AuditPath, so the audit is blanked with it. + /// + [Fact] + public void Missing_hash_salt_records_the_field_in_the_audit_under_strict() + { + Sx6Hashed[] rows = { new() { Id = 1, NationalId = "AAA-111" } }; + + DwPolicyContext strict = Caller(); + + Exception? underStrict = Catch(() => rows.AsQueryable() + .ApplyPolicy(strict, Audited(), Attributes()).ToList(new Filter())); + + DwPolicyContext convenience = Caller(); + + Exception? underConvenience = Catch(() => rows.AsQueryable() + .ApplyPolicy(convenience, Audited(DwTier.Convenience), Attributes()).ToList(new Filter())); + + _out.WriteLine($"strict : {Shape(underStrict)}"); + _out.WriteLine($" audit : {Recorded(strict)}"); + _out.WriteLine($"convenience : {Shape(underConvenience)}"); + _out.WriteLine($" audit : {Recorded(convenience)}"); + + Assert.Equal( + PolicyErrorCode.MissingHashSalt, + Assert.IsType(underStrict).ErrorCode); + + // The convenience tier records the field a deployment failed to configure for. + Assert.Contains(convenience.PendingAuditEvents, e => e.FieldPath == "NationalId"); + + // Under Strict the same record says "*". Documented contract of AuditPath: "a record of + // a refusal that cannot say which field was probed answers nothing". + // The caller is told nothing; the audit keeps the field, which is what an audited refusal + // is for. + Assert.NotEmpty(strict.PendingAuditEvents); + Assert.All(strict.PendingAuditEvents, e => Assert.NotEqual("*", e.FieldPath)); + Assert.NotEmpty(strict.PendingAuditEvents); + } + + /// MissingTokenVault behaves the same way. + [Fact] + public void Missing_token_vault_records_the_field_in_the_audit_under_strict() + { + Sx6Tokenized[] rows = { new() { Id = 1, NationalId = "AAA-111" } }; + + DwPolicyContext strict = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(strict, Audited(), Attributes()).ToList(new Filter())); + + _out.WriteLine($"strict : {Shape(error)}"); + _out.WriteLine($" audit : {Recorded(strict)}"); + + Assert.Equal( + PolicyErrorCode.MissingTokenVault, + Assert.IsType(error).ErrorCode); + + Assert.NotEmpty(strict.PendingAuditEvents); + // The caller is told nothing; the audit keeps the field, which is what an audited refusal + // is for. + Assert.NotEmpty(strict.PendingAuditEvents); + Assert.All(strict.PendingAuditEvents, e => Assert.NotEqual("*", e.FieldPath)); + } + + /// AmbiguousGroupKey behaves the same way. + [Fact] + public void Ambiguous_group_key_records_the_field_in_the_audit_under_strict() + { + DwPolicyContext strict = Caller(); + + Exception? underStrict = Catch(() => Colliding().AsQueryable() + .ApplyPolicy(strict, Audited(), Attributes()).ToList(ByBand())); + + DwPolicyContext convenience = Caller(); + + Exception? underConvenience = Catch(() => Colliding().AsQueryable() + .ApplyPolicy(convenience, Audited(DwTier.Convenience), Attributes()).ToList(ByBand())); + + _out.WriteLine($"strict : {Shape(underStrict)}"); + _out.WriteLine($" audit : {Recorded(strict)}"); + _out.WriteLine($"convenience : {Shape(underConvenience)}"); + _out.WriteLine($" audit : {Recorded(convenience)}"); + + Assert.Equal( + PolicyErrorCode.AmbiguousGroupKey, + Assert.IsType(underStrict).ErrorCode); + + Assert.Contains(convenience.PendingAuditEvents, e => e.FieldPath == "Payroll"); + + Assert.NotEmpty(strict.PendingAuditEvents); + // The caller is told nothing; the audit keeps the field, which is what an audited refusal + // is for. + Assert.NotEmpty(strict.PendingAuditEvents); + Assert.All(strict.PendingAuditEvents, e => Assert.NotEqual("*", e.FieldPath)); + } + + /// TransformRequiresMaterialization behaves the same way. + [Fact] + public void Transform_materialization_records_the_field_in_the_audit_under_strict() + { + DwPolicyContext strict = Caller(); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(strict, Audited(), Attributes()) + .SelectDynamic(new List { "Id" })); + + _out.WriteLine($"strict : {Shape(error)}"); + _out.WriteLine($" audit : {Recorded(strict)}"); + + Assert.Equal( + PolicyErrorCode.TransformRequiresMaterialization, + Assert.IsType(error).ErrorCode); + + // The caller is told nothing; the audit keeps the field, which is what an audited refusal + // is for. + Assert.NotEmpty(strict.PendingAuditEvents); + Assert.All(strict.PendingAuditEvents, e => Assert.NotEqual("*", e.FieldPath)); + } + + /// + /// An ambiguous name now reaches the audit as the caller spelled it, where before round 5 it + /// was refused with the same spelling. Recorded for contrast: the field it stood for is in + /// the trace, never in the audit. + /// + [Fact] + public void An_ambiguous_name_is_audited_as_the_caller_wrote_it() + { + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(context, Audited(), Attributes()) + .ToList(new Filter { Selects = new List { "Id", "Salary" } })); + + _out.WriteLine($"ambiguous : {Shape(error)}"); + _out.WriteLine($" audit : {Recorded(context)}"); + + Assert.IsType(error); + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Salary"); + } + + // ========================================================================================= + // FINDING candidate. The three new predicates read options.DryRun alone, where every other + // decision in the library reads options.DryRun || context.DryRun. + // ========================================================================================= + + /// + /// The rule the whole posture is built on: a dry run is the union of the global switch and + /// the per-context one, so one canary subject can run unenforced. + /// + [Fact] + public void A_context_dry_run_stops_the_gate_refusing() + { + DwPolicyContext canary = Caller(dryRun: true); + + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(canary, Audited(), Attributes()) + .ToList(new Filter { Selects = new List { "Id", "Secret" } })); + + _out.WriteLine($"canary, denied field : {Shape(error)}"); + + Assert.Null(error); + } + + /// + /// The same canary meets AmbiguousGroupKey, MissingHashSalt and + /// TransformRequiresMaterialization. All three still refuse, and all three hide the + /// field although the tier's own rule for a dry run is to name it. + /// + [Fact] + public void A_context_dry_run_is_a_dry_run_for_the_three_new_predicates() + { + List observed = new(); + + // (a) AmbiguousGroupKey. + DwPolicyContext one = Caller(dryRun: true); + + Exception? group = Catch(() => Colliding().AsQueryable() + .ApplyPolicy(one, Audited(), Attributes()).ToList(ByBand())); + + observed.Add($"context dry run, group key : {Shape(group)}"); + + // (b) MissingHashSalt. + DwPolicyContext two = Caller(dryRun: true); + + Exception? salt = Catch(() => new[] { new Sx6Hashed { Id = 1, NationalId = "A" } }.AsQueryable() + .ApplyPolicy(two, Audited(), Attributes()).ToList(new Filter())); + + observed.Add($"context dry run, salt : {Shape(salt)}"); + + // (c) TransformRequiresMaterialization. + DwPolicyContext three = Caller(dryRun: true); + + Exception? materialize = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(three, Audited(), Attributes()) + .SelectDynamic(new List { "Id" })); + + observed.Add($"context dry run, transform : {Shape(materialize)}"); + + // The contrast: the same three under a GLOBAL dry run, which the fix does honour. + Exception? groupGlobal = Catch(() => Colliding().AsQueryable() + .ApplyPolicy(Caller(), Audited(dryRun: true), Attributes()).ToList(ByBand())); + + Exception? saltGlobal = Catch(() => new[] { new Sx6Hashed { Id = 1, NationalId = "A" } }.AsQueryable() + .ApplyPolicy(Caller(), Audited(dryRun: true), Attributes()).ToList(new Filter())); + + Exception? materializeGlobal = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Audited(dryRun: true), Attributes()) + .SelectDynamic(new List { "Id" })); + + observed.Add($"global dry run, group key : {Shape(groupGlobal)}"); + observed.Add($"global dry run, salt : {Shape(saltGlobal)}"); + observed.Add($"global dry run, transform : {Shape(materializeGlobal)}"); + + foreach (string line in observed) + { + _out.WriteLine(line); + } + + // A global dry run names the field; the per-context one does not. + Assert.Equal("Payroll", Assert.IsType(groupGlobal).FieldPath); + Assert.Equal("NationalId", Assert.IsType(saltGlobal).FieldPath); + Assert.Equal("Payroll", Assert.IsType(materializeGlobal).FieldPath); + + // A dry run is either switch, the posture's or the caller's, as it is everywhere else. + Assert.Equal("Payroll", Assert.IsType(group).FieldPath); + Assert.Equal("NationalId", Assert.IsType(salt).FieldPath); + Assert.Equal("Payroll", Assert.IsType(materialize).FieldPath); + } + + // ========================================================================================= + // The composite group key, after the raw NUL and unit separator became escapes. + // ========================================================================================= + + /// A transformer that hands back exactly what it was given. + public sealed class Sx6Echo : IValueTransformer + { + public object? Transform(object? value, DwTransformContext context) => value; + } + + /// Two grouping keys, both transformed, so both join the composite. + public class Sx6Pair + { + public int Id { get; set; } + + [DwMutate(typeof(Sx6Echo))] + [DwNoOrder] + public string A { get; set; } = string.Empty; + + [DwMutate(typeof(Sx6Echo))] + [DwNoOrder] + public string B { get; set; } = string.Empty; + } + + private static Summary ByPair() => new() + { + GroupBy = new GroupBy + { + Fields = new List { "A", "B" }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Maximum, Alias = "top" } + } + } + }; + + /// Two genuinely distinct key pairs still group and answer. + [Fact] + public void Distinct_composite_keys_are_not_a_collision() + { + Sx6Pair[] rows = + { + new() { Id = 1, A = "x", B = "y" }, + new() { Id = 2, A = "p", B = "q" } + }; + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(Caller(), Audited(), Attributes()).ToList(ByPair())); + + _out.WriteLine($"distinct : {Shape(error)}"); + + Assert.Null(error); + } + + /// + /// The separator is a value a caller can put in a column. Two distinct key pairs whose parts + /// straddle it build one composite, and the summary is refused for a collision that is not + /// one. Unchanged by round 5 — the escape is the same character — and recorded so the + /// property is on the record. + /// + [Fact] + public void A_value_holding_the_separator_invents_a_collision() + { + // Built from its code point rather than written as a literal: a raw unit separator + // in a source file is what made ResultTransformer.cs read as binary to grep. + string Separator = ((char)0x1F).ToString(); + + Sx6Pair[] rows = + { + new() { Id = 1, A = "x" + Separator + "y", B = "z" }, + new() { Id = 2, A = "x", B = "y" + Separator + "z" } + }; + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(Caller(), Audited(), Attributes()).ToList(ByPair())); + + _out.WriteLine($"straddling separator : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.AmbiguousGroupKey, refusal.ErrorCode); + } + + /// + /// The null stand-in is a value a caller can put in a column too: a group whose key is null + /// and a group whose key is a lone NUL build the same composite. + /// + [Fact] + public void A_value_holding_the_null_stand_in_invents_a_collision() + { + string Nul = ((char)0x00).ToString(); + + Sx6Pair[] rows = + { + new() { Id = 1, A = null!, B = "z" }, + new() { Id = 2, A = Nul, B = "z" } + }; + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(Caller(), Audited(), Attributes()).ToList(ByPair())); + + _out.WriteLine($"null vs NUL : {Shape(error)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.AmbiguousGroupKey, refusal.ErrorCode); + } + + /// + /// The direction that would matter: two rows that really do share a key are still caught. + /// A missed collision would return a summary with duplicate keys whose counts do not add up. + /// + [Fact] + public void A_real_collision_is_still_caught() + { + Exception? error = Catch(() => Colliding().AsQueryable() + .ApplyPolicy(Caller(), Audited(), Attributes()).ToList(ByBand())); + + _out.WriteLine($"real collision : {Shape(error)}"); + + Assert.Equal( + PolicyErrorCode.AmbiguousGroupKey, + Assert.IsType(error).ErrorCode); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Sx6RenameProbes.cs b/DynamicWhere.Tests/Policies/Sx6RenameProbes.cs new file mode 100644 index 0000000..1690338 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Sx6RenameProbes.cs @@ -0,0 +1,274 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Validation; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 6. The outbound half of [DwAlias], and the set of names a query marks unknown. + // ============================================================================================= + + /// + /// A type where one member's alias is another member's own column name. Inbound the spelling is + /// ambiguous and refused; outbound both columns want to be called Salary. + /// + public class Sx6Shadowed + { + public int Id { get; set; } + + public string Salary { get; set; } = string.Empty; + + [DwAlias("Salary")] + public string Notes { get; set; } = string.Empty; + } + + /// Two members sharing one alias: the collision the startup scan does report. + public class Sx6TwoAliases + { + public int Id { get; set; } + + [DwAlias("code")] + public string First { get; set; } = string.Empty; + + [DwAlias("code")] + public string Second { get; set; } = string.Empty; + } + + /// A plain type with a navigation, for the unknown-name normalization probe. + public class Sx6Leaf + { + public int Id { get; set; } + + public string Value { get; set; } = string.Empty; + } + + public class Sx6Root + { + public int Id { get; set; } + + public string Region { get; set; } = string.Empty; + + public Sx6Leaf? Leaf { get; set; } + } + + public sealed class Sx6RenameProbes + { + private readonly ITestOutputHelper _out; + + public Sx6RenameProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Strict) => + new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static List ColumnsOf(object row) => + row is IDictionary expando + ? expando.Keys.ToList() + : row.GetType().GetProperties().Select(p => p.Name).ToList(); + + private static object? Read(object row, string column) => + row is IDictionary expando && expando.TryGetValue(column, out object? value) + ? value + : row.GetType().GetProperty(column)?.GetValue(row); + + // ========================================================================================= + // FINDING candidate. Rename maps a column onto a name another column in the same row already + // has, and one of the two values is lost — the outcome its own comment says it avoids. + // ========================================================================================= + + [Fact] + public void Rename_keeps_both_columns_when_an_alias_shadows_another_columns_name() + { + Sx6Shadowed[] rows = { new() { Id = 1, Salary = "REAL-SALARY", Notes = "THE-NOTES" } }; + + // The caller names no projection, so the whole row comes back and both columns are on + // it. Naming them would be refused inbound: "Salary" is the ambiguous spelling. + FilterResult result = rows.AsQueryable() + .ApplyPolicy(Caller(), Options(DwTier.Convenience), Attributes()) + .ToListDynamic(new Filter()); + + object row = result.Data[0]; + + List columns = ColumnsOf(row); + + _out.WriteLine($"columns : {string.Join(", ", columns)}"); + + foreach (string column in columns) + { + _out.WriteLine($" {column} = {Read(row, column)}"); + } + + // The row held Id, Salary and Notes, and "Notes" is aliased to "Salary", which the row + // already carries. The rename is not applied, so neither value is lost: renaming would + // emit one column under the other's name and drop that other's value outright. + Assert.Contains("Notes", columns); + Assert.Contains("Salary", columns); + Assert.Equal(3, columns.Count); + + Assert.Equal("REAL-SALARY", Read(row, "Salary")); + Assert.Equal("THE-NOTES", Read(row, "Notes")); + } + + /// + /// The startup scan does not report the model. CheckAlias compares an alias only + /// against other aliases, never against the type's own property names, so the collision it + /// exists to catch at deployment is found at query time instead. + /// + [Fact] + public void The_startup_scan_reports_an_alias_that_shadows_a_property() + { + PolicyModelReport shadowing = PolicyModelValidator.Inspect(new[] { typeof(Sx6Shadowed) }); + + // The case the scan does catch, for contrast: two members sharing one alias. + PolicyModelReport twoAliases = PolicyModelValidator.Inspect(new[] { typeof(Sx6TwoAliases) }); + + _out.WriteLine($"alias shadows a property : errors={shadowing.Errors.Count}" + + $" warnings={shadowing.Warnings.Count}"); + + foreach (string message in shadowing.Errors.Concat(shadowing.Warnings)) + { + _out.WriteLine($" {message}"); + } + + _out.WriteLine($"two members, one alias : errors={twoAliases.Errors.Count}"); + + foreach (string message in twoAliases.Errors) + { + _out.WriteLine($" {message}"); + } + + // The alias-against-alias collision is reported. + Assert.False(twoAliases.IsValid); + + // And so is the alias-against-property collision: a generated row cannot carry one name + // twice, so one of the two values would be the one the caller receives. + Assert.False(shadowing.IsValid); + } + + /// + /// The inbound half still refuses the spelling, so the two halves disagree: one name is too + /// ambiguous to accept and not too ambiguous to emit. + /// + [Fact] + public void The_same_spelling_is_refused_inbound_and_emitted_outbound() + { + Exception? inbound = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(DwTier.Convenience), Attributes()) + .ToListDynamic(new Filter { Selects = new List { "Salary" } })); + + _out.WriteLine($"inbound 'Salary' : {inbound?.GetType().Name ?? "OK"}"); + + PolicyException refusal = Assert.IsType(inbound); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, refusal.ErrorCode); + } + + // ========================================================================================= + // The set of names a query marks unknown: a name that normalizes onto a real path. + // ========================================================================================= + + /// + /// Gate.Unknown collapses empty segments so a padded unknown name cannot be told from + /// a real one by the navigation cap. The collapsed form is what is remembered, so a name + /// that collapses onto a real path marks that path unknown for the rest of the request. + /// + [Fact] + public void A_name_that_normalizes_onto_a_real_path_marks_that_path_unknown() + { + // Both clauses in one request: an unknown spelling first, then the real field. + Filter filter = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "Region..", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + }, + Selects = new List { "Id", "Region" } + }; + + Exception? together = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()).ToList(filter)); + + // The same projection on its own, to show the field is one the caller may use. + Exception? alone = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()) + .ToList(new Filter { Selects = new List { "Id", "Region" } })); + + _out.WriteLine($"padded name + real select : {together?.GetType().Name ?? "OK"}"); + _out.WriteLine($"real select alone : {alone?.GetType().Name ?? "OK"}"); + + // The projection alone is answered, so Region is allowed. + Assert.Null(alone); + + // RULED OUT. "Region.." canonicalizes to "Region" in the validator, so it never reaches + // Gate.Unknown and cannot mark the real path unknown for the rest of the request. + Assert.Null(together); + } + + /// + /// The same padded spelling on its own, so the shape of its refusal is on the record. + /// + [Fact] + public void A_padded_name_on_its_own_is_refused_as_an_unknown_name() + { + Exception? error = Catch(() => Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()) + .ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "Region..", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + })); + + _out.WriteLine(error is PolicyException refusal + ? $"padded alone : {refusal.ErrorCode}|path={refusal.FieldPath}" + : $"padded alone : {error?.GetType().Name ?? "OK"}"); + + // A real field padded with dots is a real field. Gate.Unknown's collapse only ever sees + // names the validator already rejected. + Assert.Null(error); + } + } +} diff --git a/DynamicWhere.ex/Policies/Masking/TransformPipeline.cs b/DynamicWhere.ex/Policies/Masking/TransformPipeline.cs index 6e9831e..f4d2043 100644 --- a/DynamicWhere.ex/Policies/Masking/TransformPipeline.cs +++ b/DynamicWhere.ex/Policies/Masking/TransformPipeline.cs @@ -115,7 +115,10 @@ private static string Named(DwTransformContext context, DwPolicyOptions options) PolicyErrorCode.MissingHashSalt, Named(context, options), PolicyFeature.Select, - options.Tier); + options.Tier) + { + AuditPath = context.FieldPath + }; } // Tokenization is the one strategy that is not a pure function of its input, so it is @@ -159,7 +162,10 @@ private static string Named(DwTransformContext context, DwPolicyOptions options) PolicyErrorCode.MissingTokenVault, Named(context, options), PolicyFeature.Select, - options.Tier); + options.Tier) + { + AuditPath = context.FieldPath + }; } // Null has nothing to stand in for, and minting a token for it would invent a value where diff --git a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs index cdb589b..84abb9e 100644 --- a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs +++ b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs @@ -315,6 +315,15 @@ private static void CanonicalizeGrouping(GroupBy? groupBy, Gate gate) where T { for (int i = 0; i < groupBy.Fields.Count; i++) { + // A blank key fails validation, as it does unguarded. Handing it to the resolver + // instead reaches Validate's argument check, and an ArgumentNullException is not + // the failure the endpoint turns into a four-hundred — every other clause guards it + // here for the same reason. + if (string.IsNullOrWhiteSpace(groupBy.Fields[i])) + { + throw new LogicException(ErrorCode.InvalidField); + } + groupBy.Fields[i] = ResolveName(groupBy.Fields[i], gate); } } diff --git a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs index 215b2b3..0391097 100644 --- a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs +++ b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs @@ -821,6 +821,7 @@ private void RefuseUnmaterialized(string method, string instead) PolicyFeature.Select, _options.Tier) { + AuditPath = string.Join(", ", TypePolicy.Transforms.Keys), SourceOrigin = $"{method} returns a query for the caller to run, and a transformed value only " + $"exists once the library has materialized it. Use {instead}, or " + diff --git a/DynamicWhere.ex/Policies/Source/ResultTransformer.cs b/DynamicWhere.ex/Policies/Source/ResultTransformer.cs index 87ba8a5..ed98da2 100644 --- a/DynamicWhere.ex/Policies/Source/ResultTransformer.cs +++ b/DynamicWhere.ex/Policies/Source/ResultTransformer.cs @@ -166,7 +166,12 @@ internal static void Rename( continue; } - string name = byColumn.TryGetValue(property.Key, out string? alias) ? alias : property.Key; + // Not onto a name the row already carries: writing there would emit one column under + // another's name and drop that other's value outright. The startup scan reports such an + // alias as an error; a deployment that runs without scanning keeps both columns. + string name = byColumn.TryGetValue(property.Key, out string? alias) && !properties.ContainsKey(alias) + ? alias + : property.Key; if (!ReferenceEquals(name, property.Key) && !renamed.Contains(property.Key)) { @@ -455,6 +460,7 @@ private static void RefuseCollisions( PolicyFeature.Group, options.Tier) { + AuditPath = string.Join(", ", keys.Select(k => k.Path)), SourceOrigin = hides ? null : "two groups share a key once transformed, so their aggregates can no longer " + diff --git a/DynamicWhere.ex/Policies/Validation/PolicyModelValidator.cs b/DynamicWhere.ex/Policies/Validation/PolicyModelValidator.cs index 5b9b16f..77e8c64 100644 --- a/DynamicWhere.ex/Policies/Validation/PolicyModelValidator.cs +++ b/DynamicWhere.ex/Policies/Validation/PolicyModelValidator.cs @@ -76,7 +76,7 @@ private static void Inspect( { string member = $"{type.Name}.{property.Name}"; - CheckAlias(property, member, aliases, errors); + CheckAlias(property, member, type, aliases, errors); CheckFacts(property, member, errors); CheckForced(property, member, errors); @@ -251,13 +251,32 @@ private static void CheckForced(PropertyInfo property, string member, ListRefuses two members of one type answering to the same public name. private static void CheckAlias( - PropertyInfo property, string member, Dictionary aliases, List errors) + PropertyInfo property, + string member, + Type type, + Dictionary aliases, + List errors) { if (property.GetCustomAttribute(inherit: true) is not { } alias) { return; } + // An alias spelled like another member of the same type is the same collision as two members + // sharing one alias, and it is worse on the way out: a generated row would carry one name + // twice, so one of the two values is the one the caller receives. + if (!string.Equals(alias.Name, property.Name, StringComparison.OrdinalIgnoreCase) + && type.GetProperty(alias.Name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase) + is { } shadowed) + { + errors.Add( + $"{member}: the alias '{alias.Name}' is the name of {type.Name}.{shadowed.Name}. A name " + + "that could mean two fields is refused at query time, and a generated row cannot " + + "carry the name twice, so rename one of them."); + + return; + } + if (aliases.TryGetValue(alias.Name, out string? existing)) { errors.Add( From 893cadcc5022cf21ab3343de3818b7e7be8d4646 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Sun, 20 Sep 2026 16:45:54 +0300 Subject: [PATCH 30/54] docs: the audit keeps the field, a blank name fails alike, and an alias may not shadow a member Three lines the sixth review's fixes earned: the four refusals that name the clause still record the real field for AuditRefusals, as every refusal with a caller-facing "*" does; a blank name fails a guarded query exactly as it fails an unguarded one, in every clause; and an alias spelled like another member of the same type is reported by ValidateModel, with a generated row keeping both columns under their own names. Co-Authored-By: Claude Opus 5 --- DynamicWhere.ex/DOC.md | 5 +++-- OfficialWebsite/app/docs/breaking-changes/page.tsx | 4 +++- OfficialWebsite/public/llms.txt | 5 ++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index 61435fc..304a9ce 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -1565,7 +1565,7 @@ var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter, cancella | `[DwDenied]` | member | Refuse all six | | `[DwNoWhere]` `[DwNoSelect]` `[DwNoOrder]` `[DwNoGroup]` `[DwNoAggregate]` | member | Refuse one feature each | | `[DwOperators(Allow =, Deny =)]` | member | Restrict which operators may target the member | -| `[DwAlias("name")]` | member | A public name, accepted anywhere a field path is, renamed back on output | +| `[DwAlias("name")]` | member | A public name, accepted anywhere a field path is, renamed back on output. A name spelled like another member of the same type is reported by `ValidateModel` (3.3.0): a generated row cannot carry one name twice, so the rename is not applied there and both columns keep their own names | | `[DwForceWhere(op, Value =, ContextValue =, AllowNull =)]` | member | A predicate ANDed into every guarded query. `AllowNull = true` lets rows whose member is null through as well — see [A forced predicate that lets null through](#a-forced-predicate-that-lets-null-through) | | `[DwRequireWhere(Operators =)]` | member | The caller must filter on this member | | `[DwMask(strategy)]` | member | `Full` `Partial` `Email` `Phone` `Regex` `Fixed` `Hash` `Null` `Tokenize` | @@ -1671,6 +1671,7 @@ A dropped field leaves nothing behind in the data, so the trace is the only way - A name that matches nothing on the type is no longer refused as `LogicException` `ConditionMustHasValidFieldName` while the request is read. It is gated as a field denied for every feature, at the step a denial is raised — after the caps — so it receives the refusal a `[DwDenied]` field receives in that clause: `FieldDeniedForWhere`, `FieldDeniedForSelect`, `FieldDeniedForOrder`, `FieldDeniedForGroup` or `FieldDeniedForAggregate`. A name padded with dots or blank segments, such as `NoSuchColumn....` or `. . . . X`, is normalized the way a real path is, so it is refused as a padded real field is rather than by `MaxNavigationDepth`. - Inside a segment every field refusal is `FieldDeniedForSegment`, with `Feature` `Segment`, whichever clause refused it — a condition in any set, an order, a select, or the field taking part at all. Answered by clause, a field denied for every clause but not for segments would say `FieldDeniedForOrder` where a name that matches nothing says `FieldDeniedForSegment`. Filters and summaries keep their per-clause codes. - Every refusal carrying one of those six codes has `FieldPath` `"*"`, a null `RuleId` and a null `SourceOrigin`, whatever the field — a real denied field and an alias included — so its message is the same too. +- A blank name fails a guarded query as it fails an unguarded one, in every clause. A blank grouping key used to reach the resolver and fail with `ArgumentNullException`, which is neither a refusal nor the malformed-clause failure an endpoint turns into a four-hundred (3.3.0). - Four more refusals name the clause rather than a field (3.3.0): an ambiguous name is refused as an unknown name is, and `AmbiguousGroupKey`, `TransformRequiresMaterialization`, `MissingHashSalt` and `MissingTokenVault` carry `FieldPath` `"*"`. All but the transform refusal drop their `SourceOrigin` too; that one's origin names a method rather than a field. - A `CapExceeded` refusal names no path either. `MaxNavigationDepth` used to return the canonical spelling of the path the caller wrote, which confirmed that it named something. `SourceOrigin` still names the cap. - `MissingContextValue` has `FieldPath` `"*"` and a null `SourceOrigin`, so it names neither the scope's column nor the context key it reads, which together describe how rows are partitioned. The trace keeps both, and an audited refusal records the scoped field. @@ -2447,7 +2448,7 @@ These are numbered as this document numbers them. The website's [breaking-change Since 3.3.0 `PolicyQueryable.LastTrace` carries the trace of a request that was refused. It used to be assigned after sanitizing returned, so a refusal left it holding the previous request's trace, or null on the first. A strict refusal names no field on purpose, and the trace is where the real path and reason live, so this is what makes one readable. Code that read `LastTrace` after catching a `PolicyException` and expected the earlier request's trace reads this request's now. 27. **Four More Refusals Name the Clause Under `Strict`** - A strict refusal names no field, and four did. `AmbiguousFieldName` told a caller that the name they wrote matches more than one field, which is to say at least one; it is now refused as an unknown name is, with the ambiguity kept in the trace for the operator who has to fix the aliases. `AmbiguousGroupKey` reported the grouping key's canonical path — the column behind whatever alias the caller wrote — and an origin saying its values are transformed; it now reports `"*"` and no origin. `TransformRequiresMaterialization` listed every transformed column on the type — masked, generalized, truncated or formatted — to a caller who named none of them, and now names the clause while keeping its origin, which names the method and what to call instead rather than any field. `MissingHashSalt` and `MissingTokenVault` named the masked field a deployment forgot to configure for, and now report `"*"` with no origin. All four are unchanged under `Convenience` and in a dry run, whichever switch declares it — the posture's or the caller's — where the tier names fields anyway. Code switching on `AmbiguousFieldName` under `Strict`, or reading `FieldPath` off any of the four, sees the change. + A strict refusal names no field, and four did. `AmbiguousFieldName` told a caller that the name they wrote matches more than one field, which is to say at least one; it is now refused as an unknown name is, with the ambiguity kept in the trace for the operator who has to fix the aliases. `AmbiguousGroupKey` reported the grouping key's canonical path — the column behind whatever alias the caller wrote — and an origin saying its values are transformed; it now reports `"*"` and no origin. `TransformRequiresMaterialization` listed every transformed column on the type — masked, generalized, truncated or formatted — to a caller who named none of them, and now names the clause while keeping its origin, which names the method and what to call instead rather than any field. `MissingHashSalt` and `MissingTokenVault` named the masked field a deployment forgot to configure for, and now report `"*"` with no origin. All four are unchanged under `Convenience` and in a dry run, whichever switch declares it — the posture's or the caller's — where the tier names fields anyway. The refusal audit still records the real field: `AuditRefusals` writes the path the refusal was about, as it does for every refusal whose caller-facing path is `"*"`. Code switching on `AmbiguousFieldName` under `Strict`, or reading `FieldPath` off any of the four, sees the change. 28. **`[DwAudit]` Records a Read the Request Did Not Name** A request that sends no `Selects` receives the row, and until 3.3.0 only a field it spelled out was recorded — so that caller read every audited member with nothing written down, one token past a control whose purpose is to answer who read a field. Every audited member a projection the caller did not name hands back is now recorded for `Select`: what the synthesized projection keeps where one is built, and every member the caller may select where none is. One event per query rather than per row, and only for a field `[DwAudit]` names. A deployment already running the control sees more events, and `DwCaps.MaxAuditEvents`, which refuses rather than dropping a record, can be reached by traffic that did not reach it before: raise the cap, or drain per request with `app.UseDwPolicyAudit()`. diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index 9b73b7d..0a40e8e 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -1677,7 +1677,9 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`} All four are unchanged under Convenience and in a dry run — the posture's switch or the caller's — where the tier names - fields anyway. Code switching on{" "} + fields anyway, and the refusal audit still records the real field, as it + does for every refusal whose caller-facing path is{" "} + "*". Code switching on{" "} AmbiguousFieldName under Strict, or reading{" "} FieldPath off any of the four, sees the change.

          diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index 5c23973..cc5f129 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -4325,7 +4325,8 @@ There are eleven. The first eight let a caller learn a value, or what the policy key, a clause that cannot be transformed, and a deployment with no hash salt or token vault. An ambiguous name is now refused as an unknown name is; the grouping key, the hash salt and the token vault name the clause and carry no origin, and the clause that cannot be transformed names the clause and keeps an origin naming the method and - what to call instead, never a field. `Convenience` and a dry run, either switch, are unchanged. + what to call instead, never a field. `Convenience` and a dry run, either switch, are unchanged, and + `AuditRefusals` still records the real field for all four. - **An unrecorded read:** until 3.3.0 `[DwAudit]` recorded only a field the request named, so a caller sending no `Selects` received every audited member of the row with nothing written down. Every audited member a projection the caller did not name hands back is recorded for `Select` now, one event per query. @@ -5204,6 +5205,8 @@ MemoryCalculationInput - A shadow property cannot be named by any clause, guarded or not: a field path names CLR members, and a shadow property has none, so the name matches nothing and is refused as an unknown name. Map it to a property, or reach it through a projection that assigns it with `EF.Property`. +- An alias spelled like another member of the same type is reported by `ValidateModel` (3.3.0). A generated row + cannot carry one name twice, so the rename is not applied there and both columns keep their own names. - A member no database can compute — a getter over columns, or an unmapped getter on an entity — cannot be filtered, ordered, grouped or aggregated under `Strict` on a source the library can read (3.3.0, section 17). Map it, or name the columns beneath it. A projection may still name it, because EF Core evaluates the last projection From fc4ac719409d28a01e1e03efdae3a61320e2f810 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Mon, 21 Sep 2026 02:14:30 +0300 Subject: [PATCH 31/54] docs: the ASP.NET Core package says which of the core changes reach its surface The package itself is unchanged, and two of the core release changes are visible through it: the audit middleware drains more events, and a blank grouping key reaches the endpoints as a malformed clause, so it is answered with a four-hundred rather than escaping as an exception the endpoint does not catch. Co-Authored-By: Claude Opus 5 --- .../DynamicWhere.ex.Policies.AspNetCore.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj b/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj index a9297f4..e6c9c10 100644 --- a/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj +++ b/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj @@ -32,7 +32,7 @@ Free Forever — Copyright © Sajjad H. Al-Khafaji MIT 3.3.0 - v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. No API or behaviour change in this package. + v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. No API or behaviour change in this package, and two the core's changes are visible through it. The audit middleware drains more events: [DwAudit] now records the audited members a projection the caller never named hands back, one event per query, and MaxAuditEvents refuses rather than dropping a record. And a blank grouping key reaches the endpoints as a malformed clause rather than as an exception from inside the sanitizer, so it is answered with a four-hundred like every other malformed clause. v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API change in this package. The core release changes what /simulate reports for a request that sends no Selects. A simulation has no source, so it reads the type as one it cannot see into: every denial beneath a member counts, and the projection it shows keeps only members holding a value, where a guarded query over a projected row, an entity or rows in memory keeps what that source carries. From bd85d50fd8053b3c63219f800e92f0617fb5c8be Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Mon, 21 Sep 2026 02:46:47 +0300 Subject: [PATCH 32/54] fix(policies): what the caller receives is what the audit records, and four more the review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seventh review, and four of the six are mine from the sixth. The audit recorded what a projection would have kept, and a dry run applies no projection — so a dry run recorded a list the caller never received while handing them every member, the audited denied ones included. It records what the row carries there, with the effect the policy decided. It also recorded every member the caller MAY select rather than every member the query hands back: a navigation nothing loads was logged as read, though the caller receives null for it, and the false event counted against a cap that refuses rather than dropping a record. And it stopped at the top level, so an audited column of an owned type was handed back unrecorded — the same hole one level down. A member kept whole now records the audited paths inside it, and a member the source does not carry records nothing. The rule that stops a rename landing on a name the row already carries read a case-insensitive map, so an alias spelling its own member differently — total for Total — looked like a shadow of itself and the public name stopped being emitted, which 3.2.0 emitted. A column does not collide with itself. The scan asked reflection for one property by name with IgnoreCase, and a type with two members answering to one name — a base member hidden with new, two spelled in different cases — took an AmbiguousMatchException out of a method documented to return a report, losing every other type's errors with it. It walks the type's own properties now. Last, one older than this branch: MaxNavigationDepth counts the canonical path, so an alias the caller wrote as one token was refused with the cap's own code and an origin stating that path's depth, where a name matching nothing got the clause's refusal. Under Strict such a name is refused as an unknown name is; a caller who wrote the path themselves still meets the cap, which is what the existing test pins. Co-Authored-By: Claude Opus 5 --- DynamicWhere.Tests/Policies/Ar7AuditProbes.cs | 328 ++++++++ DynamicWhere.Tests/Policies/Ar7BlankProbes.cs | 256 ++++++ .../Policies/Ar7EfAuditProbes.cs | 202 +++++ .../Policies/Ar7OracleProbes.cs | 291 +++++++ .../Policies/Ar7RefusalProbes.cs | 339 ++++++++ DynamicWhere.Tests/Policies/Ar7ScanProbes.cs | 173 ++++ .../Policies/Ar7SurfaceProbes.cs | 347 ++++++++ .../Policies/Dx7AuditReadProbes.cs | 297 +++++++ .../Policies/Dx7BlankAndAliasProbes.cs | 376 +++++++++ .../Policies/Dx7MiddlewareSurfaceProbes.cs | 128 +++ .../Policies/Dx7RefusalShapeProbes.cs | 462 +++++++++++ .../Policies/Ov7OverBlockProbes.cs | 742 ++++++++++++++++++ .../Policies/Ov7RefusalProbes.cs | 327 ++++++++ DynamicWhere.Tests/Policies/Ov7ScaleProbes.cs | 264 +++++++ .../Policies/Source/FilterSanitizer.cs | 82 +- .../Policies/Source/ResultTransformer.cs | 4 +- .../Validation/PolicyModelValidator.cs | 9 +- 17 files changed, 4605 insertions(+), 22 deletions(-) create mode 100644 DynamicWhere.Tests/Policies/Ar7AuditProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Ar7BlankProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Ar7EfAuditProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Ar7OracleProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Ar7RefusalProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Ar7ScanProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Ar7SurfaceProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Dx7AuditReadProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Dx7BlankAndAliasProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Dx7MiddlewareSurfaceProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Dx7RefusalShapeProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Ov7OverBlockProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Ov7RefusalProbes.cs create mode 100644 DynamicWhere.Tests/Policies/Ov7ScaleProbes.cs diff --git a/DynamicWhere.Tests/Policies/Ar7AuditProbes.cs b/DynamicWhere.Tests/Policies/Ar7AuditProbes.cs new file mode 100644 index 0000000..c743337 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7AuditProbes.cs @@ -0,0 +1,328 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Validation; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 7, adversarial security review of 3.3.0 at 893cadc. + // + // Reviews round 6's fixes: the audit use recorded for a synthesized projection, AuditPath on the + // four "*" refusals, the blank GroupBy guard, and the alias/rename agreement. + // ============================================================================================= + + /// An audited field the caller may not project, beside one they may. + public class Ar7Audited + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Audited and refused for projection. + [DwAudit] + [DwNoSelect] + public string NationalId { get; set; } = string.Empty; + } + + /// An audited member a projection cannot assign, beside a denied one. + public class Ar7Uncarried + { + public int Id { get; set; } + + /// Audited, allowed, and read-only — a projection cannot assign it. + [DwAudit] + public string Computed => "computed-" + Id; + + /// Denied, which is what makes a projection be synthesized at all. + [DwDenied] + public string Secret { get; set; } = string.Empty; + } + + /// Every member audited and allowed, so nothing is denied and no projection is built. + public class Ar7AllAudited + { + public int Id { get; set; } + + [DwAudit] + public string Name { get; set; } = string.Empty; + } + + /// An audited field named in a where clause and carried by the synthesized projection. + public class Ar7Both + { + public int Id { get; set; } + + [DwAudit] + public string Badge { get; set; } = string.Empty; + + [DwDenied] + public string Secret { get; set; } = string.Empty; + } + + public sealed class Ar7AuditProbes + { + private readonly ITestOutputHelper _out; + + public Ar7AuditProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller(bool dryRun = false) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + context.DryRun = dryRun; + + return context; + } + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Posture(DwTier tier = DwTier.Strict, bool dryRun = false) => + new() + { + Tier = tier, + DryRun = dryRun, + AuditRefusals = true, + Caps = { MinGroupSize = 1 } + }; + + private static string Recorded(DwPolicyContext context) => + context.PendingAuditEvents.Count == 0 + ? "(nothing recorded)" + : string.Join( + "; ", + context.PendingAuditEvents.Select( + e => $"{e.FieldPath}:{e.Feature}:{e.Effect}:dry={e.DryRun}:{e.ErrorCode?.ToString() ?? "-"}")); + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + // ========================================================================================= + // 1. The audit use a synthesized projection records. + // ========================================================================================= + + /// + /// The control: enforced, the denied audited field is not returned and no use is recorded + /// for it. + /// + [Fact] + public void Enforced_records_no_use_for_a_field_the_projection_leaves_out() + { + Ar7Audited[] rows = { new() { Id = 1, Name = "a", NationalId = "AAA-111" } }; + + DwPolicyContext context = Caller(); + + List got = rows.AsQueryable() + .ApplyPolicy(context, Posture(DwTier.Convenience), Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows : NationalId='{got[0].NationalId}' Name='{got[0].Name}'"); + _out.WriteLine($"audit : {Recorded(context)}"); + + Assert.Equal(string.Empty, got[0].NationalId); + Assert.DoesNotContain(context.PendingAuditEvents, e => e.FieldPath == "NationalId"); + } + + /// + /// CANDIDATE. In a dry run the row comes back whole, so the audited denied field's value + /// reaches the caller — and the audit loop records only what the projection would have kept, + /// which does not include it. + /// + [Fact] + public void Dry_run_returns_the_audited_denied_value_and_records_no_use() + { + Ar7Audited[] rows = { new() { Id = 1, Name = "a", NationalId = "AAA-111" } }; + + DwPolicyContext context = Caller(dryRun: true); + + List got = rows.AsQueryable() + .ApplyPolicy(context, Posture(DwTier.Strict), Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows : NationalId='{got[0].NationalId}'"); + _out.WriteLine($"audit : {Recorded(context)}"); + + // What the caller receives. + Assert.Equal("AAA-111", got[0].NationalId); + + // A dry run applies no projection, so what the caller receives is every member — the + // audited denied one included — and every one of them is recorded, with the effect the + // policy decided. + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == "NationalId" && e.Feature == PolicyFeature.Select); + + // The asymmetry, in the same posture: naming the field records the use, so an empty + // Selects is still one token past [DwAudit] — which is the hole round 6 set out to close. + DwPolicyContext named = Caller(dryRun: true); + + rows.AsQueryable() + .ApplyPolicy(named, Posture(DwTier.Strict), Attributes()) + .ToList(new Filter { Selects = new List { "Id", "NationalId" } }); + + _out.WriteLine($"named : {Recorded(named)}"); + + Assert.Contains(named.PendingAuditEvents, e => e.FieldPath == "NationalId"); + } + + /// + /// CANDIDATE. An audited member the projection cannot assign is still returned by the + /// narrowed projection's sibling path... or is it? Records what actually happens. + /// + [Fact] + public void An_audited_member_a_projection_cannot_assign() + { + Ar7Uncarried[] rows = { new() { Id = 1, Secret = "s" } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => + { + List got = rows.AsQueryable() + .ApplyPolicy(context, Posture(DwTier.Convenience), Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows : Computed='{got[0].Computed}' Secret='{got[0].Secret}'"); + }); + + _out.WriteLine($"error : {error?.GetType().Name} {error?.Message}"); + _out.WriteLine($"audit : {Recorded(context)}"); + } + + /// + /// Nothing denied: no projection is built, the row comes back whole, and every audited + /// member is recorded as used. + /// + [Fact] + public void Nothing_denied_records_a_use_for_every_member_returned() + { + Ar7AllAudited[] rows = { new() { Id = 1, Name = "a" } }; + + DwPolicyContext context = Caller(); + + List got = rows.AsQueryable() + .ApplyPolicy(context, Posture(), Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows : Name='{got[0].Name}'"); + _out.WriteLine($"audit : {Recorded(context)}"); + + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Name" && e.Feature == PolicyFeature.Select); + } + + /// + /// A field the request names in a where clause and the synthesized projection also carries + /// records one Where use and one Select use — not two of either. + /// + [Fact] + public void A_field_named_and_projected_records_each_use_once() + { + Ar7Both[] rows = { new() { Id = 1, Badge = "b", Secret = "s" } }; + + DwPolicyContext context = Caller(); + + rows.AsQueryable() + .ApplyPolicy(context, Posture(DwTier.Convenience), Attributes()) + .ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Sort = 0, + Connector = DynamicWhere.ex.Enums.Connector.And, + Conditions = new List + { + new() + { + Sort = 0, Field = "Badge", + DataType = DynamicWhere.ex.Enums.DataType.Text, + Operator = DynamicWhere.ex.Enums.Operator.Equal, + Values = new List { "b" } + } + } + } + }); + + _out.WriteLine($"audit : {Recorded(context)}"); + + int where = context.PendingAuditEvents.Count( + e => e.FieldPath == "Badge" && e.Feature == PolicyFeature.Where); + int select = context.PendingAuditEvents.Count( + e => e.FieldPath == "Badge" && e.Feature == PolicyFeature.Select); + + _out.WriteLine($"where={where} select={select}"); + + Assert.Equal(1, where); + Assert.Equal(1, select); + } + + /// + /// The recording runs before the cost check, the scope injection and the required-filter + /// check, so a refused query records uses of fields the caller neither named nor received. + /// + [Fact] + public void A_refused_query_records_uses_of_the_projection_it_never_returned() + { + Ar7Both[] rows = { new() { Id = 1, Badge = "b", Secret = "s" } }; + + DwPolicyContext context = Caller(); + + DwPolicyOptions options = Posture(); + + // Refused after the projection is synthesized and its uses recorded. + options.Caps.MaxQueryCost = 1; + options.Caps.DefaultFieldCost = 1000; + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, options, Attributes()) + .ToList(new Filter + { + ConditionGroup = new ConditionGroup + { + Sort = 0, + Connector = DynamicWhere.ex.Enums.Connector.And, + Conditions = new List + { + new() + { + Sort = 0, Field = "Id", + DataType = DynamicWhere.ex.Enums.DataType.Number, + Operator = DynamicWhere.ex.Enums.Operator.GreaterThan, + Values = new List { "0" } + } + } + } + })); + + _out.WriteLine($"refused : {(error as PolicyException)?.ErrorCode.ToString() ?? error?.GetType().Name ?? "OK"}"); + _out.WriteLine($"audit : {Recorded(context)}"); + + Assert.NotNull(error); + + // The caller received nothing, and the audit says they read Badge. + Assert.Contains( + context.PendingAuditEvents, + e => e.FieldPath == "Badge" && e.Feature == PolicyFeature.Select && e.Effect == PolicyEffect.Allow); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7BlankProbes.cs b/DynamicWhere.Tests/Policies/Ar7BlankProbes.cs new file mode 100644 index 0000000..827c796 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7BlankProbes.cs @@ -0,0 +1,256 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A plain type with an alias, so both branches of ResolveName are reachable. + public class Ar7Blank + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAlias("label")] + public string Title { get; set; } = string.Empty; + } + + /// The same shape with no alias at all, which takes the other branch of ResolveName. + public class Ar7Plain + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public sealed class Ar7BlankProbes + { + private readonly ITestOutputHelper _out; + + public Ar7BlankProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller() + => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Posture(DwTier tier) => + new() { Tier = tier, AuditRefusals = true, Caps = { MinGroupSize = 1 } }; + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => $"PolicyException {refusal.ErrorCode} path='{refusal.FieldPath}'", + LogicException logic => $"LogicException {logic.Message}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static Ar7Blank[] Rows() => new[] { new Ar7Blank { Id = 1, Name = "a", Title = "t" } }; + + private static Ar7Plain[] Plain() => new[] { new Ar7Plain { Id = 1, Name = "a" } }; + + // ========================================================================================= + // The blank guard: which clauses have one, and what the ones without do. + // ========================================================================================= + + /// The fixed clause: a blank grouping key now fails as validation, not as an argument. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_blank_group_by_key_fails_as_validation(DwTier tier) + { + Exception? guarded = Catch(() => Rows().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), Attributes()) + .ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { " " }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Count, Alias = "n" } + } + } + })); + + _out.WriteLine($"{tier} group-by blank : {Shape(guarded)}"); + + Assert.IsType(guarded); + Assert.IsNotType(guarded); + } + + /// + /// CANDIDATE. A blank projection entry has no such guard on either the aliased or the + /// unaliased branch of ResolveName. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_blank_select_entry(DwTier tier) + { + Exception? aliased = Catch(() => Rows().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), Attributes()) + .ToList(new Filter { Selects = new List { "Id", " " } })); + + Exception? unaliased = Catch(() => Plain().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), Attributes()) + .ToList(new Filter { Selects = new List { "Id", " " } })); + + Exception? unguarded = Catch(() => Plain().AsQueryable() + .ToList(new Filter { Selects = new List { "Id", " " } })); + + _out.WriteLine($"{tier} guarded, type with an alias : {Shape(aliased)}"); + _out.WriteLine($"{tier} guarded, type with none : {Shape(unaliased)}"); + _out.WriteLine($" unguarded : {Shape(unguarded)}"); + } + + /// CANDIDATE. The same for a segment's projection. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public async Task A_blank_segment_select_entry(DwTier tier) + { + Exception? guarded = null; + + try + { + await Plain().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), Attributes()) + .ToListAsync(new Segment + { + Selects = new List { "Id", " " }, + ConditionSets = new List + { + new() + { + Sort = 0, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Sort = 0, + Connector = Connector.And, + Conditions = new List + { + new() + { + Sort = 0, Field = "Id", DataType = DataType.Number, + Operator = Operator.GreaterThan, + Values = new List { "0" } + } + } + } + } + } + }); + } + catch (Exception error) + { + guarded = error; + } + + Exception? unguarded = null; + + try + { + await Plain().AsQueryable().ToListAsync(new Segment + { + Selects = new List { "Id", " " }, + ConditionSets = new List + { + new() + { + Sort = 0, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Sort = 0, + Connector = Connector.And, + Conditions = new List + { + new() + { + Sort = 0, Field = "Id", DataType = DataType.Number, + Operator = Operator.GreaterThan, + Values = new List { "0" } + } + } + } + } + } + }); + } + catch (Exception error) + { + unguarded = error; + } + + _out.WriteLine($"{tier} guarded segment : {Shape(guarded)}"); + _out.WriteLine($" unguarded : {Shape(unguarded)}"); + } + + /// A blank order field, for comparison: guarded there since before round 6. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_blank_order_field_fails_as_validation(DwTier tier) + { + Exception? guarded = Catch(() => Plain().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), Attributes()) + .ToList(new Filter + { + Orders = new List { new() { Sort = 0, Field = " ", Direction = Direction.Ascending } } + })); + + _out.WriteLine($"{tier} order blank : {Shape(guarded)}"); + + Assert.IsType(guarded); + } + + /// The guard must not swallow a real name that merely has padding around it. + [Fact] + public void A_padded_real_group_key_is_still_resolved() + { + Exception? error = Catch(() => Rows().AsQueryable() + .ApplyPolicy(Caller(), Posture(DwTier.Convenience), Attributes()) + .ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { " label " }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Count, Alias = "n" } + } + } + })); + + _out.WriteLine($"padded alias : {Shape(error)}"); + + Assert.Null(error); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7EfAuditProbes.cs b/DynamicWhere.Tests/Policies/Ar7EfAuditProbes.cs new file mode 100644 index 0000000..0e3f7de --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7EfAuditProbes.cs @@ -0,0 +1,202 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A department an employee points at, which nothing includes. + public class Ar7Dept + { + public int Id { get; set; } + + public string Title { get; set; } = string.Empty; + } + + /// An employee whose audited navigation is never loaded. + public class Ar7Employee + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public int DeptId { get; set; } + + /// Audited, allowed, and not loaded by a query that does not include it. + [DwAudit] + public Ar7Dept? Dept { get; set; } + } + + /// A staff record whose audited column a source projection may leave unassigned. + public class Ar7Partial + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Audited and allowed: the question is whether a use is recorded when it is not read. + [DwAudit] + public string NationalId { get; set; } = string.Empty; + } + + public sealed class Ar7EfContext : DbContext + { + public Ar7EfContext(DbContextOptions options) : base(options) + { + } + + public DbSet Depts => Set(); + + public DbSet Employees => Set(); + + public DbSet Partials => Set(); + } + + public sealed class Ar7EfAuditProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + + public Ar7EfAuditProbes(ITestOutputHelper output) + { + _out = output; + + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _options = new DbContextOptionsBuilder().UseSqlite(_connection).Options; + + using Ar7EfContext seed = new(_options); + + seed.Database.EnsureCreated(); + seed.Depts.Add(new Ar7Dept { Id = 1, Title = "Engineering" }); + seed.Employees.Add(new Ar7Employee { Id = 1, Name = "Ada", DeptId = 1 }); + seed.Partials.Add(new Ar7Partial { Id = 1, Name = "Ada", NationalId = "AAA-111" }); + seed.SaveChanges(); + } + + public void Dispose() => _connection.Dispose(); + + private static DwPolicyContext Caller() + => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Posture(DwTier tier = DwTier.Strict) => + new() { Tier = tier, AuditRefusals = true, Caps = { MinGroupSize = 1 } }; + + private static string Recorded(DwPolicyContext context) => + context.PendingAuditEvents.Count == 0 + ? "(nothing recorded)" + : string.Join( + "; ", + context.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}:{e.Effect}")); + + /// + /// CANDIDATE. Nothing is denied on this type, so no projection is synthesized and every + /// member goes into readable — the audited navigation included, although the query + /// never loads it and the caller receives null. + /// + [Fact] + public void An_unloaded_audited_navigation_is_recorded_as_used() + { + using Ar7EfContext db = new(_options); + + DwPolicyContext context = Caller(); + + List got = db.Employees + .ApplyPolicy(context, Posture(), Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows : Dept is {(got[0].Dept is null ? "null (never loaded)" : "loaded")}"); + _out.WriteLine($"audit : {Recorded(context)}"); + + Assert.Null(got[0].Dept); + + // Nothing is denied on this type, so no projection is synthesized and the row comes + // back whole — but a navigation nothing loads is not part of it. The audit records what + // the query hands back, not every member the caller may select. + Assert.DoesNotContain(context.PendingAuditEvents, e => e.FieldPath == "Dept"); + } + + /// + /// CANDIDATE. The source already projected, leaving the audited column unassigned, and the + /// guarded read still records a use of it. + /// + [Fact] + public void An_unassigned_audited_column_of_a_projected_source_is_recorded_as_used() + { + using Ar7EfContext db = new(_options); + + DwPolicyContext context = Caller(); + + IQueryable projected = + db.Partials.Select(p => new Ar7Partial { Id = p.Id, Name = p.Name }); + + List got = projected + .ApplyPolicy(context, Posture(), Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"rows : NationalId='{got[0].NationalId}' (source never read the column)"); + _out.WriteLine($"audit : {Recorded(context)}"); + + Assert.Equal(string.Empty, got[0].NationalId); + + // The same, for a column the source projection never assigned: the row carries no value + // for it, so reading it is not something the log has to answer for. + Assert.DoesNotContain(context.PendingAuditEvents, e => e.FieldPath == "NationalId"); + } + + /// + /// What the over-recording costs: the buffer is spent on members the query never read, and + /// MaxAuditEvents fails closed, so a read the caller is entitled to is refused. + /// + [Fact] + public void A_use_recorded_for_a_member_never_read_can_refuse_the_query() + { + using Ar7EfContext db = new(_options); + + DwPolicyContext context = Caller(); + + DwPolicyOptions options = Posture(); + + // Room for nothing beyond the members the query does read. Employees reads Id, Name and + // DeptId; only the unloaded Dept is audited. + options.Caps.MaxAuditEvents = 1; + + List first = db.Employees + .ApplyPolicy(context, options, Attributes()) + .ToList(new Filter()).Data; + + _out.WriteLine($"first read : {first.Count} row(s), audit={Recorded(context)}"); + + Exception? second = null; + + try + { + db.Employees.ApplyPolicy(context, options, Attributes()).ToList(new Filter()); + } + catch (Exception error) + { + second = error; + } + + _out.WriteLine($"second read : {second?.GetType().Name ?? "OK"} " + + $"{(second as DynamicWhere.ex.Exceptions.PolicyException)?.ErrorCode.ToString() ?? string.Empty}"); + + // The second read is answered: nothing was spent on a navigation neither read loaded, + // so the buffer a fail-closed cap watches is not filled by reads that did not happen. + Assert.Null(second); + Assert.DoesNotContain(context.PendingAuditEvents, e => e.FieldPath == "Dept"); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7OracleProbes.cs b/DynamicWhere.Tests/Policies/Ar7OracleProbes.cs new file mode 100644 index 0000000..2340597 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7OracleProbes.cs @@ -0,0 +1,291 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Tokens; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A nested branch whose member carries the alias, so the alias names a deep path. + public class Ar7Branch + { + public int Id { get; set; } + + [DwAlias("org")] + public string Name { get; set; } = string.Empty; + } + + /// A division holding the branch, so an alias can name a path two navigations deep. + public class Ar7Division + { + public int Id { get; set; } + + public Ar7Branch? Branch { get; set; } + } + + /// + /// A root whose alias resolves to a path deeper than a lowered navigation cap allows, while a + /// name that matches nothing stays one segment long. + /// + public class Ar7Aliased + { + public int Id { get; set; } + + public Ar7Division? Division { get; set; } + + public string Tag { get; set; } = string.Empty; + } + + public sealed class Ar7OracleProbes + { + private readonly ITestOutputHelper _out; + + public Ar7OracleProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller() + => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|origin={refusal.SourceOrigin ?? "-"}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static Exception? Ask(string name, int depth) + { + DwPolicyOptions options = new() { Tier = DwTier.Strict, AuditRefusals = true }; + + options.Caps.MinGroupSize = 1; + options.Caps.MaxNavigationDepth = depth; + + Ar7Aliased[] rows = { new() { Id = 1, Tag = "t" } }; + + return Catch(() => rows.AsQueryable() + .ApplyPolicy(Caller(), options, Attributes()) + .ToList(new Filter + { + Orders = new List + { + new() { Sort = 0, Field = name, Direction = Direction.Ascending } + } + })); + } + + /// + /// CANDIDATE. Under Strict a name that matches nothing and a name that does must be refused + /// alike. The navigation cap is measured on the canonical path, which an alias hides, so a + /// one-token alias standing for a deep path is refused with a different code from a + /// one-token name standing for nothing. + /// + [Fact] + public void An_alias_and_an_unknown_name_are_refused_alike_under_the_navigation_cap() + { + // 'org' stands for Division.Branch.Name, three segments; 'zzz' stands for nothing and + // stays one. Both are one token as the caller writes them. + Exception? alias = Ask("org", depth: 2); + Exception? unknown = Ask("zzz", depth: 2); + Exception? real = Ask("Id", depth: 2); + + _out.WriteLine($"alias 'org' (Division.Branch.Name) : {Shape(alias)}"); + _out.WriteLine($"unknown 'zzz' : {Shape(unknown)}"); + _out.WriteLine($"real 'Id' : {Shape(real)}"); + + Exception? deepAlias = Ask("Division.Branch.Name", depth: 2); + Exception? deepUnknown = Ask("Zzz.Yyy.Xxx", depth: 2); + + _out.WriteLine($"real deep path : {Shape(deepAlias)}"); + _out.WriteLine($"unknown of the same shape : {Shape(deepUnknown)}"); + + // A name that matches nothing and a real one of the SAME WRITTEN SHAPE read alike: + // Unknown() collapses the padding so both count three segments. That half holds. + Assert.Equal( + (deepAlias as PolicyException)?.ErrorCode, + (deepUnknown as PolicyException)?.ErrorCode); + + // A one-token alias is answered the same way. The cap is measured on the canonical path + // the alias stands for, and a name matching nothing stays one segment, so answering with + // the cap's own code would tell the caller their token named something several + // navigations deep. A caller who wrote the path themselves still meets the cap. + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, ((PolicyException)alias!).ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, ((PolicyException)unknown!).ErrorCode); + } + + /// + /// The cap's origin is returned under Strict. It must not describe anything the caller did + /// not write. + /// + [Fact] + public void The_cap_origin_says_nothing_about_a_name_the_caller_did_not_write() + { + Exception? alias = Ask("org", depth: 2); + Exception? unknown = Ask("zzz", depth: 2); + + _out.WriteLine($"alias : {Shape(alias)}"); + _out.WriteLine($"unknown : {Shape(unknown)}"); + + // It says nothing: the origin used to state the navigation depth of a canonical path + // the caller wrote as one token, while the name matching nothing was given none. + Assert.Null(((PolicyException)alias!).SourceOrigin); + Assert.Null(((PolicyException)unknown!).SourceOrigin); + } + + // ========================================================================================= + // A posture a second host hands over. + // ========================================================================================= + + /// + /// Every value of the posture that decides what a query may do has to be compared, or a + /// second host's configuration is accepted as the same and enforces differently. + /// + [Fact] + public void Every_posture_value_is_compared_by_the_same_posture_check() + { + MethodInfo same = typeof(DwPolicy) + .GetMethod("SamePosture", BindingFlags.NonPublic | BindingFlags.Static)!; + + // Documented as deliberately uncompared: objects a host builds for itself. + HashSet exempt = new(StringComparer.Ordinal) + { + "TokenVault", "Services", "IsFrozen", "IncludeTraceInResult" + }; + + List uncompared = new(); + + foreach (PropertyInfo property in typeof(DwPolicyOptions) + .GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (exempt.Contains(property.Name)) + { + continue; + } + + uncompared.Add(property.Name); + } + + _out.WriteLine("posture properties compared or exempt must cover: " + + string.Join(", ", uncompared)); + + Assert.NotNull(same); + } + + /// + /// The concrete shape: a second host handing over a posture that differs in any cap is + /// refused rather than accepted, so the caps a query is measured against cannot be swapped. + /// + [Fact] + public void A_cap_that_differs_is_refused_by_the_same_posture_check() + { + MethodInfo same = typeof(DwPolicy) + .GetMethod("SamePosture", BindingFlags.NonPublic | BindingFlags.Static)!; + + // Two identical postures must read as the same, or every comparison below is vacuously + // "different" and the probe proves nothing. + Assert.True((bool)same.Invoke( + null, + new object?[] { new DwPolicyOptions(), new DwPolicyOptions(), Array.Empty() })!); + + List accepted = new(); + + foreach (PropertyInfo cap in typeof(DwCaps) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.PropertyType == typeof(int) && p.CanWrite)) + { + DwPolicyOptions inForce = new(); + DwPolicyOptions asked = new(); + + int baseline = (int)cap.GetValue(inForce.Caps)!; + + cap.SetValue(asked.Caps, baseline + 1); + + bool agreed = (bool)same.Invoke( + null, new object?[] { inForce, asked, Array.Empty() })!; + + _out.WriteLine($"{cap.Name,-22} {baseline} vs {baseline + 1} -> same? {agreed}"); + + if (agreed) + { + accepted.Add(cap.Name); + } + } + + Assert.Empty(accepted); + } + + /// The same for every non-cap value of the posture. + [Fact] + public void A_posture_value_that_differs_is_refused_by_the_same_posture_check() + { + MethodInfo same = typeof(DwPolicy) + .GetMethod("SamePosture", BindingFlags.NonPublic | BindingFlags.Static)!; + + List<(string Name, Action Change)> changes = new() + { + ("Tier", o => o.Tier = DwTier.Strict), + ("DryRun", o => o.DryRun = true), + ("IncludeTraceInResult", o => o.IncludeTraceInResult = true), + ("IncludeTraceInResult(false)", o => o.IncludeTraceInResult = false), + ("AuditRefusals", o => o.AuditRefusals = true), + ("HashSalt", o => o.HashSalt = new string('s', 32)), + ("StoreFailure", o => o.StoreFailure = StoreFailureMode.FailClosed), + ("MaxSnapshotAge", o => o.MaxSnapshotAge = TimeSpan.FromHours(3)), + ("RefreshInterval", o => o.RefreshInterval = TimeSpan.FromHours(3)), + ("TokenVault", o => o.TokenVault = new InMemoryTokenVault()), + ("Entities", o => o.Entities.Expose("aliased")) + }; + + List accepted = new(); + + foreach ((string name, Action change) in changes) + { + DwPolicyOptions inForce = new(); + DwPolicyOptions asked = new(); + + change(asked); + + bool agreed = (bool)same.Invoke( + null, new object?[] { inForce, asked, Array.Empty() })!; + + _out.WriteLine($"{name,-22} differs -> same? {agreed}"); + + if (agreed) + { + accepted.Add(name); + } + } + + _out.WriteLine("accepted although different: " + (accepted.Count == 0 ? "-" : string.Join(", ", accepted))); + + // TokenVault is documented as deliberately uncompared; IncludeTraceInResult is compared + // by the value that applies, and true is the Convenience tier's own answer. + Assert.Equal(new[] { "IncludeTraceInResult", "TokenVault" }, accepted.OrderBy(a => a, StringComparer.Ordinal).ToArray()); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7RefusalProbes.cs b/DynamicWhere.Tests/Policies/Ar7RefusalProbes.cs new file mode 100644 index 0000000..44878a2 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7RefusalProbes.cs @@ -0,0 +1,339 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A nested node, so a path can be made deep enough to trip the navigation cap. + public class Ar7Node + { + public int Id { get; set; } + + public string Label { get; set; } = string.Empty; + + public Ar7Node? Child { get; set; } + } + + /// A root whose child chain gives a long path. + public class Ar7Deep + { + public int Id { get; set; } + + public Ar7Node? Node { get; set; } + } + + /// A tenant-scoped type whose scope reads an ambient value. + public class Ar7Scoped + { + public int Id { get; set; } + + [DwForceWhere(Operator.Equal, ContextValue = "TenantId")] + public int TenantId { get; set; } + + public decimal Amount { get; set; } + } + + /// A type demanding the caller supply the scope, under a public name. + public class Ar7Demanding + { + public int Id { get; set; } + + [DwAlias("org")] + [DwRequireWhere] + public int TenantId { get; set; } + + public decimal Amount { get; set; } + } + + /// Several audited members, so the audit cap can be tripped by any one of them. + public class Ar7ManyAudited + { + public int Id { get; set; } + + [DwAudit] + public string A { get; set; } = string.Empty; + + [DwAudit] + public string B { get; set; } = string.Empty; + + [DwAudit] + public string C { get; set; } = string.Empty; + } + + public sealed class Ar7RefusalProbes + { + private readonly ITestOutputHelper _out; + + public Ar7RefusalProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller(bool dryRun = false, int tenant = -1) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + context.DryRun = dryRun; + + if (tenant >= 0) + { + context.WithValue("TenantId", tenant); + } + + return context; + } + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Posture( + DwTier tier = DwTier.Strict, bool dryRun = false, Action? caps = null) + { + DwPolicyOptions options = new() { Tier = tier, DryRun = dryRun, AuditRefusals = true }; + + options.Caps.MinGroupSize = 1; + + caps?.Invoke(options.Caps); + + return options; + } + + private static string Recorded(DwPolicyContext context) => + context.PendingAuditEvents.Count == 0 + ? "(nothing recorded)" + : string.Join( + "; ", + context.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}:{e.ErrorCode?.ToString() ?? "-"}")); + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => + $"{refusal.ErrorCode}|path={refusal.FieldPath}|feature={refusal.Feature}" + + $"|rule={refusal.RuleId ?? "-"}|origin={refusal.SourceOrigin ?? "-"}", + _ => $"{error.GetType().Name}: {error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + // ========================================================================================= + // AuditPath on the four refusals that report "*" under Strict. + // ========================================================================================= + + /// A navigation-depth cap: "*" to the caller, the real path to the audit. + [Fact] + public void The_navigation_depth_cap_keeps_the_path_for_the_audit() + { + Ar7Deep[] rows = { new() { Id = 1 } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, Posture(caps: c => c.MaxNavigationDepth = 2), Attributes()) + .ToList(new Filter + { + Orders = new List + { + new() { Sort = 0, Field = "Node.Child.Label", Direction = Direction.Ascending } + } + })); + + _out.WriteLine($"refusal : {Shape(error)}"); + _out.WriteLine($"audit : {Recorded(context)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.CapExceeded, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Node.Child.Label"); + } + + /// A structural cap concerns no field, and records "*" honestly. + [Fact] + public void A_structural_cap_records_the_clause() + { + Ar7Deep[] rows = { new() { Id = 1 } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, Posture(caps: c => c.MaxOrderFields = 1), Attributes()) + .ToList(new Filter + { + Orders = new List + { + new() { Sort = 0, Field = "Id", Direction = Direction.Ascending }, + new() { Sort = 1, Field = "Node", Direction = Direction.Ascending } + } + })); + + _out.WriteLine($"refusal : {Shape(error)}"); + _out.WriteLine($"audit : {Recorded(context)}"); + + Assert.Equal("*", Assert.IsType(error).FieldPath); + Assert.All(context.PendingAuditEvents, e => Assert.Equal("*", e.FieldPath)); + } + + /// A forced predicate the context cannot supply: "*" out, the column to the audit. + [Fact] + public void A_missing_context_value_keeps_the_scope_column_for_the_audit() + { + Ar7Scoped[] rows = { new() { Id = 1, TenantId = 5, Amount = 1m } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, Posture(), Attributes()) + .ToList(new Filter())); + + _out.WriteLine($"refusal : {Shape(error)}"); + _out.WriteLine($"audit : {Recorded(context)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.MissingContextValue, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "TenantId"); + } + + /// A missing required filter names the alias out and the canonical path in. + [Fact] + public void A_missing_required_filter_names_the_alias_out_and_the_path_in() + { + Ar7Demanding[] rows = { new() { Id = 1, TenantId = 5, Amount = 1m } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, Posture(), Attributes()) + .ToList(new Filter())); + + _out.WriteLine($"refusal : {Shape(error)}"); + _out.WriteLine($"audit : {Recorded(context)}"); + + PolicyException refusal = Assert.IsType(error); + + Assert.Equal(PolicyErrorCode.RequiredFilterMissing, refusal.ErrorCode); + Assert.Equal("org", refusal.FieldPath); + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "TenantId"); + } + + /// + /// The audit cap: the refusal must be the ordinary field denial under Strict, identical + /// whichever member tripped it, and the audit must still say which one that was. + /// + [Fact] + public void The_audit_cap_refuses_identically_whichever_member_trips_it() + { + Ar7ManyAudited[] rows = { new() { Id = 1, A = "a", B = "b", C = "c" } }; + + List shapes = new(); + + for (int room = 1; room < 4; room++) + { + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, Posture(caps: c => c.MaxAuditEvents = room), Attributes()) + .ToList(new Filter())); + + shapes.Add(Shape(error)); + + _out.WriteLine($"room={room} : {Shape(error)}"); + _out.WriteLine($" audit : {Recorded(context)}"); + } + + // Every refusal that fires reads alike: no field, no origin, no rule. So the cap cannot + // be turned into an oracle by watching which member tripped it. + Assert.Single(shapes.Where(shape => shape != "OK").Distinct()); + } + + // ========================================================================================= + // The dry run each of the four reads. + // ========================================================================================= + + /// A per-context dry run alone suppresses every one of the four refusals. + [Fact] + public void A_per_context_dry_run_suppresses_all_four() + { + Ar7Deep[] deep = { new() { Id = 1, Node = new Ar7Node { Id = 2, Label = "n", Child = new Ar7Node { Id = 3, Label = "c" } } } }; + Ar7Scoped[] scoped = { new() { Id = 1, TenantId = 5, Amount = 1m } }; + Ar7Demanding[] demanding = { new() { Id = 1, TenantId = 5, Amount = 1m } }; + Ar7ManyAudited[] audited = { new() { Id = 1, A = "a", B = "b", C = "c" } }; + + Exception? cap = Catch(() => deep.AsQueryable() + .ApplyPolicy(Caller(dryRun: true), Posture(caps: c => c.MaxNavigationDepth = 2), Attributes()) + .ToList(new Filter + { + Orders = new List + { + new() { Sort = 0, Field = "Node.Child.Label", Direction = Direction.Ascending } + } + })); + + Exception? context = Catch(() => scoped.AsQueryable() + .ApplyPolicy(Caller(dryRun: true), Posture(), Attributes()) + .ToList(new Filter())); + + Exception? required = Catch(() => demanding.AsQueryable() + .ApplyPolicy(Caller(dryRun: true), Posture(), Attributes()) + .ToList(new Filter())); + + // The audit cap is the one that fails closed even in a dry run, by design. + Exception? auditCap = Catch(() => audited.AsQueryable() + .ApplyPolicy(Caller(dryRun: true), Posture(caps: c => c.MaxAuditEvents = 1), Attributes()) + .ToList(new Filter())); + + _out.WriteLine("(the audit cap is documented to fail closed even in a dry run)"); + + _out.WriteLine($"navigation cap : {Shape(cap)}"); + _out.WriteLine($"context value : {Shape(context)}"); + _out.WriteLine($"required filter: {Shape(required)}"); + _out.WriteLine($"audit cap : {Shape(auditCap)}"); + + Assert.Null(cap); + Assert.Null(context); + Assert.Null(required); + } + + /// The global switch alone does the same. + [Fact] + public void A_global_dry_run_suppresses_all_four() + { + Ar7Scoped[] scoped = { new() { Id = 1, TenantId = 5, Amount = 1m } }; + Ar7Demanding[] demanding = { new() { Id = 1, TenantId = 5, Amount = 1m } }; + + Exception? context = Catch(() => scoped.AsQueryable() + .ApplyPolicy(Caller(), Posture(dryRun: true), Attributes()) + .ToList(new Filter())); + + Exception? required = Catch(() => demanding.AsQueryable() + .ApplyPolicy(Caller(), Posture(dryRun: true), Attributes()) + .ToList(new Filter())); + + _out.WriteLine($"context value : {Shape(context)}"); + _out.WriteLine($"required filter: {Shape(required)}"); + + Assert.Null(context); + Assert.Null(required); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7ScanProbes.cs b/DynamicWhere.Tests/Policies/Ar7ScanProbes.cs new file mode 100644 index 0000000..1b3324e --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7ScanProbes.cs @@ -0,0 +1,173 @@ +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Validation; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- the exact shapes that make the startup scan's alias check ambiguous ----------------- + + /// Two members of one name in one class, which C# allows and the core folds together. + public class Ar7TwoCases + { + public int Id { get; set; } + + public string Info { get; set; } = string.Empty; + + public string INFO { get; set; } = string.Empty; + + [DwAlias("info")] + public string Label { get; set; } = string.Empty; + } + + /// A base and a derived member of the same name, hidden with new. + public class Ar7NewBase + { + public int Id { get; set; } + + public string Tag { get; set; } = string.Empty; + } + + public class Ar7NewDerived : Ar7NewBase + { + public new string Tag { get; set; } = string.Empty; + + [DwAlias("tag")] + public string Label { get; set; } = string.Empty; + } + + /// A base and a derived member differing only in case. + public class Ar7CaseBase + { + public int Id { get; set; } + + public string Tag { get; set; } = string.Empty; + } + + public class Ar7CaseDerived : Ar7CaseBase + { + public string tag { get; set; } = string.Empty; + + [DwAlias("TAG")] + public string Label { get; set; } = string.Empty; + } + + /// A sound model carrying a real error, to show what the abort costs. + public class Ar7AlsoBroken + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAlias("Name")] + public string Nickname { get; set; } = string.Empty; + } + + public sealed class Ar7ScanProbes + { + private readonly ITestOutputHelper _out; + + public Ar7ScanProbes(ITestOutputHelper output) => _out = output; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private string Scan(params Type[] types) + { + PolicyModelReport? report = null; + + Exception? error = Catch(() => report = PolicyModelValidator.Inspect(types)); + + if (error is not null) + { + return $"RAISED {error.GetType().Name}: {error.Message}"; + } + + return report!.Errors.Count == 0 && report.Warnings.Count == 0 + ? "clean" + : string.Join(" | ", report.Errors.Concat(report.Warnings)); + } + + /// CANDIDATE. Two members of one name in one class abort the whole scan. + [Fact] + public void Two_members_of_one_name_in_one_class() + { + string outcome = Scan(typeof(Ar7TwoCases)); + + _out.WriteLine("Ar7TwoCases : " + outcome); + + // The scan reports; it never throws. Asking the type for one property by name with + // IgnoreCase raised AmbiguousMatchException here, because two members whose names differ + // only in case are two matches. + Assert.DoesNotContain("RAISED", outcome, StringComparison.Ordinal); + } + + /// A plain new shadow of the same name: reflection prefers the derived one. + [Fact] + public void A_new_shadow_of_the_same_name_is_fine() + { + string outcome = Scan(typeof(Ar7NewDerived)); + + _out.WriteLine("Ar7NewDerived : " + outcome); + + Assert.DoesNotContain("RAISED", outcome, StringComparison.Ordinal); + } + + /// CANDIDATE. A base and derived member differing only in case. + [Fact] + public void A_base_and_derived_member_differing_in_case() + { + string outcome = Scan(typeof(Ar7CaseDerived)); + + _out.WriteLine("Ar7CaseDerived: " + outcome); + + // The same across a base and a derived declaration. + Assert.DoesNotContain("RAISED", outcome, StringComparison.Ordinal); + } + + /// + /// The cost of the abort: one unscannable type takes every other type's errors with it, so a + /// startup gate that was going to refuse the deployment instead dies on a reflection call. + /// + [Fact] + public void One_unscannable_type_hides_every_other_types_errors() + { + string alone = Scan(typeof(Ar7AlsoBroken)); + string together = Scan(typeof(Ar7TwoCases), typeof(Ar7AlsoBroken)); + + _out.WriteLine("alone : " + alone); + _out.WriteLine("together : " + together); + + Assert.Contains("alias 'Name'", alone, StringComparison.Ordinal); + + // Scanned beside it, the real error is still reported: no type can take another type's + // errors down with it, because the scan no longer raises anything. + Assert.Contains("alias 'Name'", together, StringComparison.Ordinal); + } + + /// The same through the throwing façade a deployment actually calls. + [Fact] + public void ValidateModel_reports_rather_than_raising_a_reflection_error() + { + Exception? error = Catch( + () => DwPolicy.ValidateModel(new DwPolicyOptions(), new[] { typeof(Ar7TwoCases) })); + + _out.WriteLine($"ValidateModel : {error?.GetType().Name ?? "-"} {error?.Message.Split('\n')[0]}"); + + // The documented startup gate refuses the deployment with the model's errors, rather + // than raising a reflection error out of a method that returns a report. + Assert.IsType(error); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ar7SurfaceProbes.cs b/DynamicWhere.Tests/Policies/Ar7SurfaceProbes.cs new file mode 100644 index 0000000..a0211d2 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ar7SurfaceProbes.cs @@ -0,0 +1,347 @@ +using System.Reflection; +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Validation; +using DynamicWhere.ex.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- alias / rename models ------------------------------------------------------------- + + /// A base declaring a member the derived type re-declares in another case. + public class Ar7ShadowBase + { + public int Id { get; set; } + + [DwDenied] + public string Secret { get; set; } = string.Empty; + } + + /// + /// The derived type declares secret, differing from the base's Secret only in + /// case, and puts an alias spelled the same on a third member. + /// + public class Ar7Shadow : Ar7ShadowBase + { + public string secret { get; set; } = string.Empty; + + [DwAlias("secret")] + public string Label { get; set; } = string.Empty; + } + + /// An alias spelled exactly like another member of the same type. + public class Ar7AliasShadowsMember + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAlias("Name")] + public string Nickname { get; set; } = string.Empty; + } + + /// An alias spelled like another member but in a different case. + public class Ar7AliasShadowsCase + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAlias("name")] + public string Nickname { get; set; } = string.Empty; + } + + /// An alias on a denied field, to see what a rename can carry out. + public class Ar7AliasOnDenied + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAlias("code")] + [DwNoSelect] + public string Secret { get; set; } = string.Empty; + } + + public sealed class Ar7SurfaceProbes + { + private readonly ITestOutputHelper _out; + + public Ar7SurfaceProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller() + => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Posture(DwTier tier = DwTier.Strict) => + new() { Tier = tier, AuditRefusals = true, Caps = { MinGroupSize = 1 } }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + // ========================================================================================= + // AuditPath must not reach a caller. + // ========================================================================================= + + /// + /// AuditPath is internal; nothing public on the exception exposes it and a serializer + /// cannot see it. + /// + [Fact] + public void AuditPath_is_not_on_the_public_surface_and_does_not_serialize() + { + PropertyInfo[] published = typeof(PolicyException) + .GetProperties(BindingFlags.Public | BindingFlags.Instance); + + _out.WriteLine("public properties: " + string.Join(", ", published.Select(p => p.Name))); + + Assert.DoesNotContain(published, p => p.Name == "AuditPath"); + + PolicyException refusal = + new(PolicyErrorCode.FieldDeniedForSelect, "*", PolicyFeature.Select, DwTier.Strict) + { + AuditPath = "NationalId", + SourceOrigin = null + }; + + string json = JsonSerializer.Serialize(refusal); + string text = refusal.ToString(); + string message = refusal.Message; + + _out.WriteLine("json : " + json); + _out.WriteLine("message : " + message); + + Assert.DoesNotContain("NationalId", json, StringComparison.Ordinal); + Assert.DoesNotContain("NationalId", message, StringComparison.Ordinal); + Assert.DoesNotContain("NationalId", text, StringComparison.Ordinal); + } + + /// Every strict refusal reachable from a guarded read, and what it names. + [Fact] + public void Strict_refusals_name_no_field_the_caller_did_not_write() + { + Ar7AliasOnDenied[] rows = { new() { Id = 1, Name = "a", Secret = "s" } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => rows.AsQueryable() + .ApplyPolicy(context, Posture(), Attributes()) + .ToList(new Filter { Selects = new List { "Id", "code" } })); + + PolicyException refusal = Assert.IsType(error); + + _out.WriteLine($"code={refusal.ErrorCode} path='{refusal.FieldPath}' rule={refusal.RuleId ?? "-"} origin={refusal.SourceOrigin ?? "-"}"); + _out.WriteLine("audit : " + string.Join("; ", context.PendingAuditEvents.Select(e => e.FieldPath))); + + Assert.Equal("*", refusal.FieldPath); + Assert.Null(refusal.SourceOrigin); + + // The audit keeps the canonical path, not the alias the caller wrote. + Assert.Contains(context.PendingAuditEvents, e => e.FieldPath == "Secret"); + } + + // ========================================================================================= + // The inbound and outbound alias rules. + // ========================================================================================= + + /// The startup scan reports an alias spelled exactly like another member. + [Fact] + public void The_scan_reports_an_alias_spelled_like_a_member() + { + PolicyModelReport report = PolicyModelValidator.Inspect(new[] { typeof(Ar7AliasShadowsMember) }); + + foreach (string error in report.Errors) + { + _out.WriteLine("error : " + error); + } + + Assert.Contains(report.Errors, e => e.Contains("'Name'", StringComparison.Ordinal)); + } + + /// And one differing only in case, which is how the core compares names. + [Fact] + public void The_scan_reports_an_alias_spelled_like_a_member_in_another_case() + { + PolicyModelReport report = PolicyModelValidator.Inspect(new[] { typeof(Ar7AliasShadowsCase) }); + + foreach (string error in report.Errors) + { + _out.WriteLine("error : " + error); + } + + Assert.Contains(report.Errors, e => e.Contains("'name'", StringComparison.Ordinal)); + } + + /// + /// CANDIDATE. The scan asks the type for the shadowed member with IgnoreCase, and a type + /// that declares two members whose names differ only in case has two matches. + /// + [Fact] + public void The_scan_survives_a_type_with_two_members_of_one_name() + { + Exception? error = Catch(() => + { + PolicyModelReport report = PolicyModelValidator.Inspect(new[] { typeof(Ar7Shadow) }); + + foreach (string message in report.Errors) + { + _out.WriteLine("error : " + message); + } + + foreach (string message in report.Warnings) + { + _out.WriteLine("warning : " + message); + } + }); + + _out.WriteLine($"raised : {error?.GetType().Name ?? "-"} {error?.Message}"); + + // It does: the scan walks the type's own properties for a name that matches the alias, + // rather than asking reflection for one property by a name two members answer to, which + // raised AmbiguousMatchException out of a method that returns a report. Ar7ScanProbes + // reduces that to its two smallest shapes. + Assert.Null(error); + } + + /// + /// A query on the same type: the alias 'secret' could mean the aliased member or either + /// real member, and the answer must not hand the denied value out under the alias. + /// + [Fact] + public void A_shadowing_alias_cannot_carry_a_denied_value_out() + { + Ar7Shadow[] rows = { new() { Id = 1, Secret = "DENIED", secret = "lower", Label = "L" } }; + + DwPolicyContext context = Caller(); + + Exception? error = Catch(() => + { + var got = rows.AsQueryable() + .ApplyPolicy(context, Posture(DwTier.Convenience), Attributes()) + .ToListDynamic(new Filter()).Data; + + foreach (object row in got) + { + _out.WriteLine("row : " + JsonSerializer.Serialize(row)); + } + }); + + _out.WriteLine($"raised : {error?.GetType().Name ?? "-"} {error?.Message}"); + + // The denied value does not leave. What does leave is a column literally named 'secret' + // holding Label's value, while both real members of that name are left out — the shape + // the startup scan is meant to report and cannot, because it aborts on this very type. + Assert.Null(error); + } + + /// + /// The outbound rename must not put a column under a name the row already carries, and must + /// not carry a denied value out under the alias. + /// + [Fact] + public void A_rename_does_not_land_on_a_name_the_row_already_carries() + { + Ar7AliasShadowsCase[] rows = { new() { Id = 1, Name = "real", Nickname = "nick" } }; + + // The inbound half: naming the colliding name at all is refused. + Exception? named = Catch(() => rows.AsQueryable() + .ApplyPolicy(Caller(), Posture(DwTier.Convenience), Attributes()) + .ToListDynamic(new Filter { Selects = new List { "Id", "Name" } })); + + _out.WriteLine("naming the collision : " + + ((named as PolicyException)?.ErrorCode.ToString() ?? named?.GetType().Name ?? "OK")); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, Assert.IsType(named).ErrorCode); + + // The outbound half: a row carrying 'Name' must not have 'Nickname' renamed onto it. + var got = rows.AsQueryable() + .ApplyPolicy(Caller(), Posture(DwTier.Convenience), Attributes()) + .ToListDynamic(new Filter { Selects = new List { "Id", "Nickname" } }) + .Data; + + foreach (object row in got) + { + _out.WriteLine("row : " + JsonSerializer.Serialize(row)); + } + + IDictionary columns = (IDictionary)got[0]; + + _out.WriteLine("columns : " + string.Join(", ", columns.Keys)); + + Assert.Contains(columns, c => Equals(c.Value, "nick")); + } + + /// The alias of a deny-select field must not appear in the result at all. + [Fact] + public void A_deny_select_field_does_not_reach_the_caller_under_its_alias() + { + Ar7AliasOnDenied[] rows = { new() { Id = 1, Name = "a", Secret = "SECRET" } }; + + DwPolicyContext context = Caller(); + + var got = rows.AsQueryable() + .ApplyPolicy(context, Posture(DwTier.Convenience), Attributes()) + .ToListDynamic(new Filter()).Data; + + foreach (object row in got) + { + _out.WriteLine("row : " + JsonSerializer.Serialize(row)); + } + + Assert.DoesNotContain("SECRET", JsonSerializer.Serialize(got), StringComparison.Ordinal); + } + + /// The unguarded shape of a blank grouping key, for parity with the guarded one. + [Fact] + public void A_blank_group_key_fails_alike_guarded_and_not() + { + Ar7AliasShadowsCase[] rows = { new() { Id = 1, Name = "a", Nickname = "n" } }; + + Summary Blank() => new() + { + GroupBy = new GroupBy + { + Fields = new List { " " }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Count, Alias = "n" } + } + } + }; + + Exception? unguarded = Catch(() => rows.AsQueryable().ToList(Blank())); + + Exception? guarded = Catch(() => rows.AsQueryable() + .ApplyPolicy(Caller(), Posture(DwTier.Convenience), Attributes()) + .ToList(Blank())); + + _out.WriteLine($"unguarded : {unguarded?.GetType().Name} {unguarded?.Message.Split('\n')[0]}"); + _out.WriteLine($"guarded : {guarded?.GetType().Name} {guarded?.Message.Split('\n')[0]}"); + + Assert.Equal(unguarded?.GetType(), guarded?.GetType()); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Dx7AuditReadProbes.cs b/DynamicWhere.Tests/Policies/Dx7AuditReadProbes.cs new file mode 100644 index 0000000..889c0da --- /dev/null +++ b/DynamicWhere.Tests/Policies/Dx7AuditReadProbes.cs @@ -0,0 +1,297 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 7, documentation review of 3.3.0 at 893cadc. + // + // Claim 1: "[DwAudit] records a read the request did not name: the members a projection the + // caller never named hands back are recorded for Select, one event per query rather than per + // row, only for a field the attribute names." + // + // Checked against every document that states it, and against llms.txt section 22, which is the + // one place that still states the opposite. + // ============================================================================================= + + /// Nothing denied, one audited member, so the row comes back whole. + public class Dx7Whole + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Audited for everything. + [DwAudit] + public string Email { get; set; } = string.Empty; + } + + /// One member audited for Where alone, so a Select read must not record it. + public class Dx7WhereAudited + { + public int Id { get; set; } + + [DwAudit(PolicyFeature.Where)] + public string Email { get; set; } = string.Empty; + } + + /// + /// A denied member forces a projection, and an audited member the projection cannot keep. + /// + /// + /// Computed has no setter, so a synthesized projection cannot assign it and leaves it + /// out — while a dry run, which synthesizes nothing, hands the whole row back with it in. + /// + public class Dx7Uncarried + { + public int Id { get; set; } + + [DwDenied] + public string Secret { get; set; } = string.Empty; + + [DwAudit] + public string Kept { get; set; } = string.Empty; + + /// Readable, audited, and not assignable by a projection. + [DwAudit] + public string Computed => "derived"; + } + + public sealed class Dx7AuditReadProbes + { + private readonly ITestOutputHelper _out; + + public Dx7AuditReadProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller(bool dryRun = false) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + context.DryRun = dryRun; + + return context; + } + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier = DwTier.Convenience, bool dryRun = false) => + new() { Tier = tier, DryRun = dryRun, Caps = { MinGroupSize = 1 } }; + + private static PolicyQueryable Guarded(T[] rows, DwPolicyContext context, DwPolicyOptions options) + where T : class => + rows.AsQueryable().ApplyPolicy(context, options, Attributes()); + + private static string Recorded(DwPolicyContext context) => + context.PendingAuditEvents.Count == 0 + ? "(nothing)" + : string.Join("; ", context.PendingAuditEvents.Select(Describe)); + + private static string Describe(DwAuditEvent e) => + $"{e.FieldPath}:{e.Feature}:{e.Effect}:dry={e.DryRun}"; + + // ========================================================================================= + // The claim itself, and the one document that still denies it. + // ========================================================================================= + + /// + /// A request that names no projection records every audited member it hands back, for + /// Select. + /// + /// + /// llms.txt section 22 lists "a projection the library synthesized" under "Nothing is + /// recorded for". This is that case, and something is recorded. + /// + [Fact] + public void A_request_naming_no_projection_records_the_audited_member_for_select() + { + DwPolicyContext context = Caller(); + + Guarded(new[] { new Dx7Whole { Id = 1, Name = "a", Email = "a@b" } }, context, Options()) + .ToList(new Filter()); + + _out.WriteLine($"no Selects, nothing denied : {Recorded(context)}"); + + DwAuditEvent recorded = Assert.Single(context.PendingAuditEvents); + + Assert.Equal("Email", recorded.FieldPath); + Assert.Equal(PolicyFeature.Select, recorded.Feature); + Assert.Equal(PolicyEffect.Allow, recorded.Effect); + Assert.Null(recorded.ErrorCode); + } + + /// One event per query, whatever the row count. + [Fact] + public void One_event_per_query_not_one_per_row() + { + Dx7Whole[] rows = + { + new() { Id = 1, Email = "a@b" }, + new() { Id = 2, Email = "c@d" }, + new() { Id = 3, Email = "e@f" }, + new() { Id = 4, Email = "g@h" }, + new() { Id = 5, Email = "i@j" } + }; + + DwPolicyContext context = Caller(); + + List returned = Guarded(rows, context, Options()).ToList(new Filter()).Data; + + _out.WriteLine($"{returned.Count} rows returned, events: {Recorded(context)}"); + + Assert.Equal(5, returned.Count); + Assert.Single(context.PendingAuditEvents); + } + + /// Only a member the attribute names, and only for a feature it names. + [Fact] + public void Only_a_field_the_attribute_names_for_a_feature_it_names() + { + DwPolicyContext whole = Caller(); + + Guarded(new[] { new Dx7Whole { Id = 1, Name = "a", Email = "a@b" } }, whole, Options()) + .ToList(new Filter()); + + _out.WriteLine($"audited-for-All : {Recorded(whole)}"); + + // Id and Name are handed back too, and neither carries the attribute. + Assert.Equal(new[] { "Email" }, whole.PendingAuditEvents.Select(e => e.FieldPath).ToArray()); + + DwPolicyContext narrowed = Caller(); + + Guarded(new[] { new Dx7WhereAudited { Id = 1, Email = "a@b" } }, narrowed, Options()) + .ToList(new Filter()); + + _out.WriteLine($"audited-for-Where: {Recorded(narrowed)}"); + + // The member is read, but the attribute names Where, so the read is not in its set. + Assert.Empty(narrowed.PendingAuditEvents); + } + + // ========================================================================================= + // What the documents promise for the two halves of the rule. + // ========================================================================================= + + /// + /// Where a projection IS built, the members it keeps are recorded and the ones it leaves + /// out are not. + /// + [Fact] + public void Where_a_projection_is_built_it_records_what_the_projection_keeps() + { + DwPolicyContext context = Caller(); + + Guarded( + new[] { new Dx7Uncarried { Id = 1, Secret = "s", Kept = "k" } }, + context, + Options()) + .ToList(new Filter()); + + _out.WriteLine($"projection built, enforced : {Recorded(context)}"); + + // Kept is assigned by the projection; Computed has no setter, so the projection cannot + // assign it and the caller never receives it. + Assert.Equal(new[] { "Kept" }, context.PendingAuditEvents.Select(e => e.FieldPath).ToArray()); + } + + /// + /// FINDING candidate. A dry run synthesizes nothing, so the whole row is handed back — but + /// the audit records only what the projection it did not build would have kept. + /// + /// + /// Every document states the rule in two halves: "what the synthesized projection keeps + /// where one is built, and every member the caller may select where none is, since the row + /// then comes back whole". A dry run is the second half — nothing is built and the row comes + /// back whole — and the code takes the first. + /// + [Fact] + public void A_dry_run_records_every_member_it_hands_back() + { + foreach ((string leg, DwPolicyContext context, DwPolicyOptions options) in new[] + { + ("posture switch", Caller(), Options(dryRun: true)), + ("caller switch", Caller(dryRun: true), Options()) + }) + { + List rows = Guarded( + new[] { new Dx7Uncarried { Id = 1, Secret = "s", Kept = "k" } }, + context, + options) + .ToList(new Filter()) + .Data; + + string[] paths = context.PendingAuditEvents.Select(e => e.FieldPath).ToArray(); + + _out.WriteLine($"[{leg}] rows[0].Secret = '{rows[0].Secret}', " + + $"rows[0].Computed = '{rows[0].Computed}'"); + _out.WriteLine($"[{leg}] recorded: {Recorded(context)}"); + + // The row came back whole: the denied member is still on it, so nothing was + // projected and every member reached the caller. + Assert.Equal("s", rows[0].Secret); + + // A dry run builds no projection, whatever is denied, so the whole row is what the + // caller read — and both audited members it handed back are recorded. + Assert.Contains("Kept", paths); + Assert.Contains("Computed", paths); + } + } + + /// + /// The same type with nothing denied, for contrast: no projection is built, and then every + /// member the caller may select IS recorded, Computed included. + /// + /// + /// This is what pins the previous probe as a divergence rather than a rule about read-only + /// members: the member is recordable, and a dry run is the one state that drops it. + /// + [Fact] + public void With_nothing_denied_a_read_only_audited_member_is_recorded() + { + DwPolicyContext context = Caller(); + + Guarded(new[] { new Dx7Readable { Id = 1, Kept = "k" } }, context, Options()) + .ToList(new Filter()); + + _out.WriteLine($"nothing denied, read-only member : {Recorded(context)}"); + + Assert.Contains("Computed", context.PendingAuditEvents.Select(e => e.FieldPath)); + } + + /// A dry run of a query that denies nothing records both, as it always did. + [Fact] + public void A_dry_run_with_nothing_denied_records_both_members() + { + DwPolicyContext context = Caller(dryRun: true); + + Guarded(new[] { new Dx7Readable { Id = 1, Kept = "k" } }, context, Options()) + .ToList(new Filter()); + + _out.WriteLine($"dry run, nothing denied : {Recorded(context)}"); + + Assert.Contains("Computed", context.PendingAuditEvents.Select(e => e.FieldPath)); + Assert.All(context.PendingAuditEvents, e => Assert.True(e.DryRun)); + } + } + + /// The uncarried type with the denial removed. + public class Dx7Readable + { + public int Id { get; set; } + + [DwAudit] + public string Kept { get; set; } = string.Empty; + + [DwAudit] + public string Computed => "derived"; + } +} diff --git a/DynamicWhere.Tests/Policies/Dx7BlankAndAliasProbes.cs b/DynamicWhere.Tests/Policies/Dx7BlankAndAliasProbes.cs new file mode 100644 index 0000000..8fec7c4 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Dx7BlankAndAliasProbes.cs @@ -0,0 +1,376 @@ +using System.Dynamic; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Validation; +using DynamicWhere.ex.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 7, documentation review of 3.3.0 at 893cadc. + // + // Claim 3: "A blank name fails a guarded query exactly as it fails an unguarded one, in every + // clause." (DOC.md, breaking-changes page) + // Claim 4: "An alias spelled like another member of the same type is a ValidateModel error, and + // a generated row keeps both columns under their own names." + // + // Round 6 covered where / order / select / group / segment-select. The clauses below are the + // ones it did not: an aggregated field, a Having condition, a summary's own Orders, and a + // segment's Orders — each named in llms.txt's "Checks by shape" table as a place a blank name + // is checked. + // ============================================================================================= + + /// A plain type, so only the blank name decides what happens. + public class Dx7Plain + { + public int Id { get; set; } + + public string Region { get; set; } = string.Empty; + + public decimal Amount { get; set; } + } + + /// An alias spelled exactly like another member of the same type. + public class Dx7Shadowing + { + public int Id { get; set; } + + /// Answers to the name Code, which already carries. + [DwAlias("Code")] + public string Reference { get; set; } = string.Empty; + + public string Code { get; set; } = string.Empty; + } + + /// The same collision, spelled in another case. + public class Dx7ShadowingByCase + { + public int Id { get; set; } + + [DwAlias("code")] + public string Reference { get; set; } = string.Empty; + + public string Code { get; set; } = string.Empty; + } + + /// An alias that shadows nothing, as the control. + public class Dx7Aliased + { + public int Id { get; set; } + + [DwAlias("reference")] + public string Number { get; set; } = string.Empty; + } + + public sealed class Dx7BlankAndAliasProbes + { + private readonly ITestOutputHelper _out; + + public Dx7BlankAndAliasProbes(ITestOutputHelper output) => _out = output; + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options(DwTier tier) => + new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + private static string Shape(Exception? error) => error switch + { + null => "OK", + PolicyException refusal => $"PolicyException|{refusal.ErrorCode}|path={refusal.FieldPath}", + LogicException failure => $"LogicException|{failure.Message}", + _ => $"{error.GetType().Name}|{error.Message.Split('\n')[0]}" + }; + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + // ---- the clauses round 6 did not cover --------------------------------------------------- + + private static object AggregateField(string name) => new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Region" }, + AggregateBy = new List + { + new() { Field = name, Alias = "total", Aggregator = Aggregator.Sumation } + } + } + }; + + private static object SummaryOrder(string name) => new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Region" }, + AggregateBy = new List + { + new() { Field = "Id", Alias = "n", Aggregator = Aggregator.Count } + } + }, + Orders = new List { new() { Sort = 0, Field = name, Direction = Direction.Ascending } } + }; + + private static object Having(string name) => new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Region" }, + AggregateBy = new List + { + new() { Field = "Id", Alias = "n", Aggregator = Aggregator.Count } + } + }, + Having = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = name, DataType = DataType.Number, + Operator = Operator.GreaterThan, Values = { "0" } + } + } + } + }; + + private static object SegmentOrder(string name) => new Segment + { + ConditionSets = new List + { + new() + { + Sort = 0, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "Region", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + } + }, + Orders = new List { new() { Sort = 0, Field = name, Direction = Direction.Ascending } }, + Selects = new List { "Id" } + }; + + private static object SegmentCondition(string name) => new Segment + { + ConditionSets = new List + { + new() + { + Sort = 0, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = name, DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + } + }, + Selects = new List { "Id" } + }; + + private static readonly (string Clause, Func Build)[] Clauses = + { + ("aggregate-field", AggregateField), + ("summary-order", SummaryOrder), + ("having", Having), + ("segment-order", SegmentOrder), + ("segment-condition", SegmentCondition) + }; + + private static void Execute(IQueryable source, object request) + { + switch (request) + { + case Filter filter: + source.ToListDynamic(filter); + break; + + case Summary summary: + source.ToList(summary); + break; + + case Segment segment: + source.ToListAsync(segment).GetAwaiter().GetResult(); + break; + } + } + + private static void Execute(PolicyQueryable guarded, object request) + { + switch (request) + { + case Filter filter: + guarded.ToListDynamic(filter); + break; + + case Summary summary: + guarded.ToList(summary); + break; + + case Segment segment: + guarded.ToListAsync(segment).GetAwaiter().GetResult(); + break; + } + } + + // ========================================================================================= + // Claim 3, widened. + // ========================================================================================= + + [Fact] + public void A_blank_name_fails_alike_in_the_clauses_round_six_did_not_cover() + { + List diverged = new(); + + foreach (string blank in new[] { string.Empty, " ", "\t" }) + { + foreach ((string clause, Func build) in Clauses) + { + Exception? bare = Catch(() => + Execute(Array.Empty().AsQueryable(), build(blank))); + + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + Exception? guarded = Catch(() => Execute( + Array.Empty().AsQueryable() + .ApplyPolicy(Caller(), Options(tier), Attributes()), + build(blank))); + + _out.WriteLine( + $"[{clause}/{tier}] blank='{blank.Replace("\t", "\\t")}' " + + $"guarded={Shape(guarded)} unguarded={Shape(bare)}"); + + if (!string.Equals(Shape(guarded), Shape(bare), StringComparison.Ordinal)) + { + diverged.Add($"{tier}/{clause}/'{blank}': {Shape(guarded)} vs {Shape(bare)}"); + } + } + } + } + + foreach (string line in diverged) + { + _out.WriteLine($"DIVERGED {line}"); + } + + Assert.Empty(diverged); + } + + // ========================================================================================= + // Claim 4, both halves. + // ========================================================================================= + + [Fact] + public void An_alias_spelled_like_another_member_is_a_validate_model_error() + { + foreach (Type type in new[] { typeof(Dx7Shadowing), typeof(Dx7ShadowingByCase) }) + { + PolicyModelReport report = PolicyModelValidator.Inspect(new[] { type }); + + _out.WriteLine($"[{type.Name}] valid={report.IsValid}"); + + foreach (string error in report.Errors) + { + _out.WriteLine($" error: {error}"); + } + + Assert.False(report.IsValid); + Assert.Contains(report.Errors, e => e.Contains("Reference") && e.Contains("Code")); + } + + // The control: an alias that shadows nothing is still fine. + PolicyModelReport clean = PolicyModelValidator.Inspect(new[] { typeof(Dx7Aliased) }); + + _out.WriteLine($"[Dx7Aliased] valid={clean.IsValid}"); + + Assert.True(clean.IsValid); + } + + /// + /// A generated row keeps both columns under their own names, for a deployment that never + /// ran the scan. + /// + [Fact] + public void A_generated_row_keeps_both_columns_under_their_own_names() + { + foreach (Type type in new[] { typeof(Dx7Shadowing), typeof(Dx7ShadowingByCase) }) + { + IDictionary row = type == typeof(Dx7Shadowing) + ? Dynamic(new[] { new Dx7Shadowing { Id = 1, Reference = "R-1", Code = "C-1" } }) + : Dynamic(new[] { new Dx7ShadowingByCase { Id = 1, Reference = "R-1", Code = "C-1" } }); + + _out.WriteLine( + $"[{type.Name}] columns: {string.Join(", ", row.Select(c => $"{c.Key}={c.Value}"))}"); + + // Both values survive, each under the name of the member that holds it. + Assert.Equal("R-1", row["Reference"]); + Assert.Equal("C-1", row["Code"]); + } + } + + /// The control: an alias that shadows nothing still renames the column. + [Fact] + public void An_alias_that_shadows_nothing_still_renames_the_column() + { + IDictionary row = + Dynamic(new[] { new Dx7Aliased { Id = 1, Number = "N-1" } }); + + _out.WriteLine($"[Dx7Aliased] columns: {string.Join(", ", row.Select(c => $"{c.Key}={c.Value}"))}"); + + Assert.Equal("N-1", row["reference"]); + Assert.False(row.ContainsKey("Number")); + } + + private static IDictionary Dynamic(T[] rows) where T : class + { + DwPolicyOptions options = new() { Tier = DwTier.Convenience, Caps = { MinGroupSize = 1 } }; + + dynamic first = rows.AsQueryable() + .ApplyPolicy(Caller(), options, Attributes()) + .ToListDynamic(new Filter()) + .Data[0]; + + return first is ExpandoObject expando + ? (IDictionary)expando + : ((object)first).GetType() + .GetProperties() + .ToDictionary(p => p.Name, p => (object?)p.GetValue((object)first)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Dx7MiddlewareSurfaceProbes.cs b/DynamicWhere.Tests/Policies/Dx7MiddlewareSurfaceProbes.cs new file mode 100644 index 0000000..7c8a7a7 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Dx7MiddlewareSurfaceProbes.cs @@ -0,0 +1,128 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.AspNetCore; +using DynamicWhere.ex.Policies.Audit; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 7, documentation review of 3.3.0 at 893cadc. + // + // The AspNetCore package's own 3.3.0 release note says "No API or behaviour change in this + // package", while the core's 3.3.0 note ends the [DwAudit] paragraph with "raise the cap or + // drain per request with app.UseDwPolicyAudit()". This measures what that package's surface + // actually does with a request that names no projection. + // ============================================================================================= + + public sealed class Dx7MiddlewareSurfaceProbes + { + private readonly ITestOutputHelper _out; + + public Dx7MiddlewareSurfaceProbes(ITestOutputHelper output) => _out = output; + + private sealed class Recorder : IDwAuditSink + { + internal List Written { get; } = new(); + + public ValueTask WriteAsync(DwAuditEvent auditEvent, CancellationToken ct = default) + { + Written.Add(auditEvent); + + return default; + } + } + + private sealed class Recording : ILogger + { + internal List<(LogLevel Level, string Message)> Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + Entries.Add((logLevel, formatter(state, exception))); + } + + /// + /// Runs a guarded query that names no projection, then drains the context the way the + /// middleware does for a request. + /// + private static async Task<(List Written, List<(LogLevel Level, string Message)> Logged)> + RequestWithNoSelects(bool registerSink) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + new[] { new Dx7Whole { Id = 1, Name = "a", Email = "a@b" } } + .AsQueryable() + .ApplyPolicy( + context, + new DwPolicyOptions { Tier = DwTier.Convenience, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(new Filter()); + + Recorder sink = new(); + ServiceCollection services = new(); + + if (registerSink) + { + services.AddSingleton(sink); + } + + Recording logger = new(); + DefaultHttpContext http = new() { RequestServices = services.BuildServiceProvider() }; + + http.Features.Set(context); + + await new DwPolicyAuditMiddleware(_ => Task.CompletedTask, logger).InvokeAsync(http); + + return (sink.Written, logger.Entries); + } + + /// + /// A request that names no projection now reaches the sink this package drains to. + /// + [Fact] + public async Task A_request_naming_no_projection_reaches_the_sink_this_package_drains_to() + { + (List written, _) = await RequestWithNoSelects(registerSink: true); + + _out.WriteLine($"written: {string.Join("; ", written.Select(e => $"{e.FieldPath}:{e.Feature}"))}"); + + DwAuditEvent recorded = Assert.Single(written); + + Assert.Equal("Email", recorded.FieldPath); + Assert.Equal(PolicyFeature.Select, recorded.Feature); + } + + /// + /// And the same request, on a deployment with no sink, now trips the middleware's warning + /// where it recorded nothing to warn about before. + /// + [Fact] + public async Task The_same_request_with_no_sink_now_trips_the_middleware_warning() + { + (_, List<(LogLevel Level, string Message)> logged) = await RequestWithNoSelects(registerSink: false); + + foreach ((LogLevel level, string message) in logged) + { + _out.WriteLine($"{level}: {message}"); + } + + Assert.Contains(logged, entry => entry.Level == LogLevel.Warning); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Dx7RefusalShapeProbes.cs b/DynamicWhere.Tests/Policies/Dx7RefusalShapeProbes.cs new file mode 100644 index 0000000..905a26e --- /dev/null +++ b/DynamicWhere.Tests/Policies/Dx7RefusalShapeProbes.cs @@ -0,0 +1,462 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Masking; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================================= + // Round 7, documentation review of 3.3.0 at 893cadc. + // + // Claim 2: "The four refusals that report FieldPath "*" under Strict: AmbiguousGroupKey, + // MissingHashSalt, MissingTokenVault carry no SourceOrigin; TransformRequiresMaterialization + // keeps an origin naming the method, and its list is every transformed column, not every masked + // one. An ambiguous name is refused as an unknown name is. All four honour a dry run declared by + // either switch, and all four still record the real field through AuditRefusals." + // ============================================================================================= + + /// A generalized column a caller names only by its alias. + public class Dx7Banded + { + public int Id { get; set; } + + [DwAlias("band")] + [DwGeneralize(GeneralizeMode.Round, Step = 100)] + [DwNoOrder] + public decimal Payroll { get; set; } + } + + /// Hashed, so the missing-salt refusal is reachable. + public class Dx7Hashed + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Hash)] + [DwNoOrder] + public string NationalId { get; set; } = string.Empty; + } + + /// Tokenized, so the missing-vault refusal is reachable. + public class Dx7Tokenized + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Tokenize)] + [DwNoOrder] + public string NationalId { get; set; } = string.Empty; + } + + /// + /// One column of each transform kind, so the materialization refusal's list can be read for + /// what it actually contains. + /// + public class Dx7EveryTransform + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Full)] + [DwNoOrder] + public string Masked { get; set; } = string.Empty; + + [DwGeneralize(GeneralizeMode.Round, Step = 10)] + [DwNoOrder] + public decimal Generalized { get; set; } + + [DwTruncate(3)] + [DwNoOrder] + public string Truncated { get; set; } = string.Empty; + + [DwFormat("0.00")] + [DwNoOrder] + public decimal Formatted { get; set; } + + /// Nothing at all, so the list is not simply "every member". + public string Plain { get; set; } = string.Empty; + } + + /// Two members answering to one public name. + public class Dx7Ambiguous + { + public int Id { get; set; } + + [DwAlias("label")] + public string First { get; set; } = string.Empty; + + [DwAlias("label")] + public string Second { get; set; } = string.Empty; + } + + public sealed class Dx7RefusalShapeProbes + { + private readonly ITestOutputHelper _out; + + public Dx7RefusalShapeProbes(ITestOutputHelper output) => _out = output; + + // ---- harness --------------------------------------------------------------------------- + + private static DwPolicyContext Caller(bool dryRun = false) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + context.DryRun = dryRun; + + return context; + } + + private static PolicyResolver Attributes() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Options( + DwTier tier = DwTier.Strict, bool dryRun = false, bool auditRefusals = false) => + new() + { + Tier = tier, + DryRun = dryRun, + AuditRefusals = auditRefusals, + Caps = { MinGroupSize = 1 } + }; + + private static PolicyException Refusal(Action run) + { + try + { + run(); + } + catch (PolicyException refusal) + { + return refusal; + } + + throw new Xunit.Sdk.XunitException("the request was not refused"); + } + + private static Exception? Catch(Action run) + { + try + { + run(); + + return null; + } + catch (Exception error) + { + return error; + } + } + + private static string Shape(PolicyException r) => + $"{r.ErrorCode}|path={r.FieldPath}|feature={r.Feature}|origin={r.SourceOrigin ?? "-"}"; + + private static string Recorded(DwPolicyContext context) => + context.PendingAuditEvents.Count == 0 + ? "(nothing)" + : string.Join( + "; ", + context.PendingAuditEvents.Select(e => $"{e.FieldPath}:{e.Feature}:{e.ErrorCode?.ToString() ?? "-"}")); + + // ---- the four requests ------------------------------------------------------------------- + + private static Summary ByBand() => new() + { + GroupBy = new GroupBy + { + Fields = new List { "band" }, + AggregateBy = new List + { + new() { Field = "Id", Aggregator = Aggregator.Maximum, Alias = "top" } + } + } + }; + + private static void GroupKeyCollision(DwPolicyContext context, DwPolicyOptions options) => + new[] + { + new Dx7Banded { Id = 1, Payroll = 100m }, + new Dx7Banded { Id = 2, Payroll = 149m } + } + .AsQueryable() + .ApplyPolicy(context, options, Attributes()) + .ToList(ByBand()); + + private static void HashWithNoSalt(DwPolicyContext context, DwPolicyOptions options) => + new[] { new Dx7Hashed { Id = 1, NationalId = "x" } } + .AsQueryable() + .ApplyPolicy(context, options, Attributes()) + .ToList(new Filter { Selects = new List { "Id", "NationalId" } }); + + private static void TokenizeWithNoVault(DwPolicyContext context, DwPolicyOptions options) => + new[] { new Dx7Tokenized { Id = 1, NationalId = "x" } } + .AsQueryable() + .ApplyPolicy(context, options, Attributes()) + .ToList(new Filter { Selects = new List { "Id", "NationalId" } }); + + private static void Unmaterialized(DwPolicyContext context, DwPolicyOptions options) => + new[] { new Dx7EveryTransform { Id = 1 } } + .AsQueryable() + .ApplyPolicy(context, options, Attributes()) + .SelectDynamic(new List { "Id" }); + + private static readonly (string Name, PolicyErrorCode Code, Action Run)[] Four = + { + ("AmbiguousGroupKey", PolicyErrorCode.AmbiguousGroupKey, GroupKeyCollision), + ("MissingHashSalt", PolicyErrorCode.MissingHashSalt, HashWithNoSalt), + ("MissingTokenVault", PolicyErrorCode.MissingTokenVault, TokenizeWithNoVault), + ("TransformRequiresMaterialization", PolicyErrorCode.TransformRequiresMaterialization, Unmaterialized) + }; + + // ========================================================================================= + // Under Strict: "*", and an origin only on the transform refusal. + // ========================================================================================= + + [Fact] + public void All_four_report_star_under_strict_and_only_one_keeps_an_origin() + { + foreach ((string name, PolicyErrorCode code, Action run) in Four) + { + DwPolicyContext context = Caller(); + PolicyException refusal = Refusal(() => run(context, Options())); + + _out.WriteLine($"[strict] {name}: {Shape(refusal)}"); + + Assert.Equal(code, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + + if (code == PolicyErrorCode.TransformRequiresMaterialization) + { + // Kept, and it names the method and what to call instead, never a field. + Assert.NotNull(refusal.SourceOrigin); + Assert.Contains(nameof(PolicyQueryable.SelectDynamic), refusal.SourceOrigin!); + Assert.Contains("ToListDynamic", refusal.SourceOrigin!); + Assert.DoesNotContain("Masked", refusal.SourceOrigin!); + } + else + { + Assert.Null(refusal.SourceOrigin); + } + } + } + + /// + /// The transform refusal's list is every transformed column, not every masked one. + /// + /// + /// Read on the convenience tier, where the list is the caller-facing path, and again from + /// the strict refusal's audit path, where it is what reaches a sink. + /// + [Fact] + public void The_transform_refusal_lists_every_transformed_column_not_every_masked_one() + { + DwPolicyContext lenient = Caller(); + PolicyException open = Refusal(() => Unmaterialized(lenient, Options(DwTier.Convenience))); + + _out.WriteLine($"[convenience] path = {open.FieldPath}"); + + foreach (string column in new[] { "Masked", "Generalized", "Truncated", "Formatted" }) + { + Assert.Contains(column, open.FieldPath); + } + + // A column with no transform at all is not in the list. + Assert.DoesNotContain("Plain", open.FieldPath); + + DwPolicyContext strict = Caller(); + + Assert.Throws(() => Unmaterialized(strict, Options(auditRefusals: true))); + + _out.WriteLine($"[strict, audited] {Recorded(strict)}"); + + string audited = Assert.Single(strict.PendingAuditEvents).FieldPath; + + foreach (string column in new[] { "Masked", "Generalized", "Truncated", "Formatted" }) + { + Assert.Contains(column, audited); + } + } + + // ========================================================================================= + // A dry run, declared by either switch, names the field again. + // ========================================================================================= + + [Fact] + public void All_four_honour_a_dry_run_declared_by_either_switch() + { + foreach ((string name, PolicyErrorCode code, Action run) in Four) + { + foreach ((string leg, DwPolicyContext context, DwPolicyOptions options) in new[] + { + ("posture switch", Caller(), Options(dryRun: true)), + ("caller switch", Caller(dryRun: true), Options()) + }) + { + PolicyException refusal = Refusal(() => run(context, options)); + + _out.WriteLine($"[dry:{leg}] {name}: {Shape(refusal)}"); + + Assert.Equal(code, refusal.ErrorCode); + + // Named again, exactly as the convenience tier names it. + Assert.NotEqual("*", refusal.FieldPath); + Assert.NotEmpty(refusal.FieldPath); + } + } + } + + // ========================================================================================= + // AuditRefusals keeps the real field for all four. + // ========================================================================================= + + [Fact] + public void All_four_record_the_real_field_through_audit_refusals() + { + (string Name, string Expected)[] expected = + { + ("AmbiguousGroupKey", "Payroll"), + ("MissingHashSalt", "NationalId"), + ("MissingTokenVault", "NationalId"), + ("TransformRequiresMaterialization", "Masked") + }; + + for (int i = 0; i < Four.Length; i++) + { + DwPolicyContext context = Caller(); + + Assert.Throws(() => Four[i].Run(context, Options(auditRefusals: true))); + + _out.WriteLine($"[audited] {Four[i].Name}: {Recorded(context)}"); + + string path = Assert.Single(context.PendingAuditEvents).FieldPath; + + Assert.NotEqual("*", path); + Assert.Contains(expected[i].Expected, path); + } + } + + // ========================================================================================= + // An ambiguous name is refused as an unknown name is. + // ========================================================================================= + + [Fact] + public void An_ambiguous_name_is_refused_as_an_unknown_name_is_under_strict() + { + static Filter Named(string field) => new() + { + Selects = new List { "Id" }, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = field, DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + }; + + static PolicyException Ask(DwPolicyContext context, DwPolicyOptions options, string field) => + Refusal(() => Array.Empty().AsQueryable() + .ApplyPolicy(context, options, Attributes()) + .ToList(Named(field))); + + PolicyException ambiguous = Ask(Caller(), Options(), "label"); + PolicyException unknown = Ask(Caller(), Options(), "NoSuchColumn"); + + _out.WriteLine($"[strict] ambiguous : {Shape(ambiguous)}"); + _out.WriteLine($"[strict] unknown : {Shape(unknown)}"); + + // The same refusal, indistinguishable at the caller. + Assert.Equal(unknown.ErrorCode, ambiguous.ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, ambiguous.ErrorCode); + Assert.Equal("*", ambiguous.FieldPath); + Assert.Null(ambiguous.SourceOrigin); + Assert.Null(ambiguous.RuleId); + + // Convenience still says what it is. + PolicyException open = Ask(Caller(), Options(DwTier.Convenience), "label"); + + _out.WriteLine($"[convenience] ambiguous : {Shape(open)}"); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, open.ErrorCode); + + // And a dry run, either switch, says what it is too. + foreach ((string leg, DwPolicyContext context, DwPolicyOptions options) in new[] + { + ("posture switch", Caller(), Options(dryRun: true)), + ("caller switch", Caller(dryRun: true), Options()) + }) + { + PolicyException dry = Ask(context, options, "label"); + + _out.WriteLine($"[dry:{leg}] ambiguous : {Shape(dry)}"); + + Assert.Equal(PolicyErrorCode.AmbiguousFieldName, dry.ErrorCode); + } + } + + /// What an ambiguous name records, once it is refused as an unknown name is. + [Fact] + public void An_ambiguous_name_records_the_name_the_caller_wrote() + { + DwPolicyContext context = Caller(); + + Assert.Throws(() => Array.Empty().AsQueryable() + .ApplyPolicy(context, Options(auditRefusals: true), Attributes()) + .ToList(new Filter + { + Selects = new List { "Id" }, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Sort = 0, Field = "label", DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + })); + + _out.WriteLine($"[audited] ambiguous : {Recorded(context)}"); + + // Recorded as the caller sent it, as a name matching nothing is — not as either of the + // two canonical paths it could have meant. + Assert.Equal("label", Assert.Single(context.PendingAuditEvents).FieldPath); + } + + /// The trace keeps the ambiguity for the operator, as every document promises. + [Fact] + public void The_trace_keeps_the_ambiguity_under_strict() + { + PolicyQueryable guarded = Array.Empty() + .AsQueryable() + .ApplyPolicy(Caller(), Options(), Attributes()); + + Exception? error = Catch(() => guarded.ToList(new Filter + { + Selects = new List { "Id" }, + Orders = new List { new() { Sort = 0, Field = "label", Direction = Direction.Ascending } } + })); + + Assert.IsType(error); + + string reasons = string.Join( + " | ", + guarded.LastTrace!.Decisions.Select(d => $"{d.FieldPath}:{d.Action}:{d.Reason}")); + + _out.WriteLine($"trace: {reasons}"); + + Assert.Contains("First", reasons); + Assert.Contains("Second", reasons); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ov7OverBlockProbes.cs b/DynamicWhere.Tests/Policies/Ov7OverBlockProbes.cs new file mode 100644 index 0000000..7856c45 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ov7OverBlockProbes.cs @@ -0,0 +1,742 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Validation; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- entity shapes ------------------------------------------------------------------------ + + /// Several audited members, one denied member, one owned member with an audited leaf. + public class Ov7Aud + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] + public string Email { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] + public string Phone { get; set; } = string.Empty; + + [DwAudit] + public string Ssn { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] + public DateTime Dob { get; set; } + + public Ov7Own Own { get; set; } = new(); + } + + public class Ov7Own + { + public string Street { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] + public string Zip { get; set; } = string.Empty; + } + + /// Same shape, plus a member denied for select so a projection is synthesized. + public class Ov7AudDenied + { + public int Id { get; set; } + + [DwAudit(PolicyFeature.Select)] + public string Email { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] + public string Phone { get; set; } = string.Empty; + + [DwNoSelect] + public decimal Salary { get; set; } + } + + /// Nothing audited at all. + public class Ov7Plain + { + public int Id { get; set; } + + public string City { get; set; } = string.Empty; + + public int Capacity { get; set; } + } + + /// An alias that differs from its own member only in case. + public class Ov7Case + { + public int Id { get; set; } + + [DwAlias("total")] + public decimal Total { get; set; } + + public string Note { get; set; } = string.Empty; + } + + /// A chain: A answers to B, and B answers to C. No two names collide in the output. + public class Ov7Chain + { + public int Id { get; set; } + + [DwAlias("B")] + public string A { get; set; } = string.Empty; + + [DwAlias("C")] + public string B { get; set; } = string.Empty; + } + + /// An ordinary alias, as a control. + public class Ov7Ref + { + public int Id { get; set; } + + [DwAlias("reference")] + public string Number { get; set; } = string.Empty; + + public decimal Amount { get; set; } + } + + /// An alias naming a member the group-size column would have taken. + public class Ov7Floor + { + public int Id { get; set; } + + public string Bucket { get; set; } = string.Empty; + + [DwAlias("Bucket2")] + public decimal Amount { get; set; } + } + + // ---- validator-only shapes (never queried) -------------------------------------------------- + + public class Ov7ShadowBase + { + public object Value { get; set; } = string.Empty; + } + + /// A member hidden with new, and an alias naming it: reflection sees the name twice. + public class Ov7ShadowDerived : Ov7ShadowBase + { + public new string Value { get; set; } = string.Empty; + + [DwAlias("Value")] + public string Other { get; set; } = string.Empty; + } + + /// Two members differing only in case, and an alias naming one of them. + public class Ov7DualCase + { + public string Name { get; set; } = string.Empty; + + public string name { get; set; } = string.Empty; + + [DwAlias("Name")] + public string Other { get; set; } = string.Empty; + } + + /// An alias naming another member of the same type: the collision the round-6 rule adds. + public class Ov7Shadow + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwAlias("Code")] + public string Reference { get; set; } = string.Empty; + } + + public sealed class Ov7Context : DbContext + { + private readonly SqliteConnection _connection; + + public Ov7Context(SqliteConnection connection) => _connection = connection; + + public DbSet Auds => Set(); + + public DbSet Denied => Set(); + + public DbSet Plains => Set(); + + public DbSet Cases => Set(); + + public DbSet Chains => Set(); + + public DbSet Refs => Set(); + + public DbSet Floors => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) => model.Entity().OwnsOne(a => a.Own); + } + + /// + /// Round 7 of the over-blocking hunt: what rounds 5 and 6 changed, measured against 3.2.0. + /// + public sealed class Ov7OverBlockProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Ov7Context _db; + + public Ov7OverBlockProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Ov7Context(_connection); + _db.Database.EnsureCreated(); + + for (int i = 1; i <= 5; i++) + { + _db.Auds.Add(new Ov7Aud + { + Name = $"n{i}", + Email = $"e{i}@x", + Phone = $"p{i}", + Ssn = $"s{i}", + Dob = new DateTime(1990, 1, 1).AddDays(i), + Own = new Ov7Own { Street = $"st{i}", Zip = $"z{i}" } + }); + + _db.Denied.Add(new Ov7AudDenied { Email = $"e{i}@x", Phone = $"p{i}", Salary = i * 100 }); + _db.Plains.Add(new Ov7Plain { City = $"c{i}", Capacity = i }); + _db.Cases.Add(new Ov7Case { Total = i * 10, Note = $"note{i}" }); + _db.Chains.Add(new Ov7Chain { A = $"a{i}", B = $"b{i}" }); + _db.Refs.Add(new Ov7Ref { Number = $"NUM{i}", Amount = i }); + _db.Floors.Add(new Ov7Floor { Bucket = i <= 3 ? "x" : "y", Amount = i }); + } + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + // ---- harness --------------------------------------------------------------------------- + + private static PolicyResolver Resolver() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyContext Ctx() => + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyQueryable Guard( + IQueryable source, DwPolicyContext context, DwTier tier = DwTier.Strict, int auditCap = 10_000) + where T : class + { + DwPolicyOptions options = new() { Tier = tier }; + options.Caps.MinGroupSize = 1; + options.Caps.MaxAuditEvents = auditCap; + + return source.ApplyPolicy(context, options, Resolver()); + } + + private static string Outcome(Func run) + { + try + { + return $"OK({run()})"; + } + catch (PolicyException refusal) + { + return $"POLICY({refusal.ErrorCode}/{refusal.FieldPath})"; + } + catch (LogicException logic) + { + return $"LOGIC({logic.Message})"; + } + catch (Exception other) + { + return other.GetType().Name; + } + } + + private void Say(string probe, string value) => _out.WriteLine($"OV7 {probe} = {value}"); + + // ---- 1. what [DwAudit] now records ------------------------------------------------------- + + [Fact] + public void Ov7_Audit_Counts() + { + // Five rows in the table: the count must not move with them. + DwPolicyContext whole = Ctx(); + Say("a1.no-selects.5rows", Outcome(() => Guard(_db.Auds, whole).ToList(new Filter()).Data.Count)); + Say("a1.events", whole.PendingAuditEvents.Count.ToString()); + Say("a1.paths", string.Join("|", whole.PendingAuditEvents.Select(e => e.FieldPath + ":" + e.Feature))); + + // One row only, same request. + DwPolicyContext one = Ctx(); + Filter justOne = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Id", DataType = DataType.Number, + Operator = Operator.Equal, Values = { "1" } + } + } + } + }; + Say("a2.one-row", Outcome(() => Guard(_db.Auds, one).ToList(justOne).Data.Count)); + Say("a2.events", one.PendingAuditEvents.Count.ToString()); + + // Nothing audited on the type. + DwPolicyContext plain = Ctx(); + Say("a3.nothing-audited", Outcome(() => Guard(_db.Plains, plain).ToList(new Filter()).Data.Count)); + Say("a3.events", plain.PendingAuditEvents.Count.ToString()); + + // A projection is synthesized because something is denied. + DwPolicyContext denied = Ctx(); + Say("a4.synthesized", Outcome(() => Guard(_db.Denied, denied).ToList(new Filter()).Data.Count)); + Say("a4.events", denied.PendingAuditEvents.Count.ToString()); + Say("a4.paths", string.Join("|", denied.PendingAuditEvents.Select(e => e.FieldPath))); + + // The caller names a projection themselves: only what they named. + DwPolicyContext named = Ctx(); + Filter spelled = new() { Selects = new List { "Email" } }; + Say("a5.named-selects", Outcome(() => Guard(_db.Auds, named).ToList(spelled).Data.Count)); + Say("a5.events", named.PendingAuditEvents.Count.ToString()); + + // Composed clauses must not multiply the recording. + DwPolicyContext composed = Ctx(); + Say("a6.composed", Outcome(() => + Guard(_db.Auds, composed) + .Where(new Condition + { + Field = "Id", DataType = DataType.Number, + Operator = Operator.GreaterThan, Values = { "0" } + }) + .Order(new OrderBy { Field = "Id", Direction = Direction.Ascending, Sort = 1 }) + .ToList(new Filter()).Data.Count)); + Say("a6.events", composed.PendingAuditEvents.Count.ToString()); + + // A segment. + DwPolicyContext segment = Ctx(); + Segment seg = new() + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Id", DataType = DataType.Number, + Operator = Operator.GreaterThan, Values = { "0" } + } + } + } + }, + new ConditionSet + { + Sort = 2, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Id", DataType = DataType.Number, + Operator = Operator.LessThan, Values = { "99" } + } + } + } + } + } + }; + Say("a7.segment-2sets", Outcome(() => + Guard(_db.Auds, segment).ToListAsync(seg).GetAwaiter().GetResult().Data.Count)); + Say("a7.events", segment.PendingAuditEvents.Count.ToString()); + + // A summary. + DwPolicyContext summary = Ctx(); + Summary sum = new() + { + GroupBy = new GroupBy + { + Fields = new List { "Name" }, + AggregateBy = new List + { + new() { Aggregator = Aggregator.Count, Alias = "n" } + } + } + }; + Say("a8.summary", Outcome(() => Guard(_db.Auds, summary).ToList(sum).Data.Count)); + Say("a8.events", summary.PendingAuditEvents.Count.ToString()); + + // Two terminals on one context: the buffer accumulates per query. + DwPolicyContext twice = Ctx(); + Guard(_db.Auds, twice).ToList(new Filter()); + Guard(_db.Auds, twice).ToList(new Filter()); + Say("a9.two-queries.events", twice.PendingAuditEvents.Count.ToString()); + + // The cap, at exactly what one query now costs and at one less. + DwPolicyContext capped = Ctx(); + Say("a10.cap-4", Outcome(() => Guard(_db.Auds, capped, auditCap: 4).ToList(new Filter()).Data.Count)); + DwPolicyContext capped5 = Ctx(); + Say("a10.cap-5", Outcome(() => Guard(_db.Auds, capped5, auditCap: 5).ToList(new Filter()).Data.Count)); + + // Convenience tier at the same cap: the refusal's own shape. + DwPolicyContext conv = Ctx(); + Say("a11.cap-4-convenience", Outcome(() => + Guard(_db.Auds, conv, DwTier.Convenience, auditCap: 4).ToList(new Filter()).Data.Count)); + + // ToListDynamic, the other filter terminal. + DwPolicyContext dyn = Ctx(); + Say("a12.dynamic", Outcome(() => Guard(_db.Auds, dyn).ToListDynamic(new Filter()).Data.Count)); + Say("a12.events", dyn.PendingAuditEvents.Count.ToString()); + } + + // ---- 2. a blank name, in every clause ---------------------------------------------------- + + [Fact] + public void Ov7_Blank_Names() + { + string[] names = { "", " ", "\t", ".", "A.", ".A", "A..B", "Name.", "..", "Name..Name" }; + + foreach (string name in names) + { + string label = name.Replace("\t", "\\t"); + + Say($"b.group[{label}].guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(SummaryOn(name)).Data.Count)); + Say($"b.group[{label}].unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(SummaryOn(name)).Data.Count)); + + Say($"b.aggfield[{label}].guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(SummaryAgg(name)).Data.Count)); + Say($"b.aggfield[{label}].unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(SummaryAgg(name)).Data.Count)); + + Say($"b.where[{label}].guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(WhereOn(name)).Data.Count)); + Say($"b.where[{label}].unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(WhereOn(name)).Data.Count)); + + Say($"b.order[{label}].guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(OrderOn(name)).Data.Count)); + Say($"b.order[{label}].unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(OrderOn(name)).Data.Count)); + + Say($"b.select[{label}].guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToListDynamic(SelectOn(name)).Data.Count)); + Say($"b.select[{label}].unguarded", Outcome(() => + _db.Auds.AsQueryable().ToListDynamic(SelectOn(name)).Data.Count)); + + Say($"b.segsel[{label}].guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToListAsync(SegmentSelect(name)).GetAwaiter().GetResult().Data.Count)); + Say($"b.segsel[{label}].unguarded", Outcome(() => + _db.Auds.AsQueryable().ToListAsync(SegmentSelect(name)).GetAwaiter().GetResult().Data.Count)); + } + + // A legitimate key still resolves, guarded and unguarded. + Say("b.legit.group.guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(SummaryOn("Name")).Data.Count)); + Say("b.legit.group.unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(SummaryOn("Name")).Data.Count)); + Say("b.legit.group.owned.guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(SummaryOn("Own.Street")).Data.Count)); + Say("b.legit.group.owned.unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(SummaryOn("Own.Street")).Data.Count)); + Say("b.legit.group.case.guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(SummaryOn("nAmE")).Data.Count)); + Say("b.legit.group.spaces.guarded", Outcome(() => + Guard(_db.Auds, Ctx()).ToList(SummaryOn(" Name ")).Data.Count)); + Say("b.legit.group.spaces.unguarded", Outcome(() => + _db.Auds.AsQueryable().ToList(SummaryOn(" Name ")).Data.Count)); + + // The same, on an aliased type, which takes the other branch of ResolveName. + Say("b.alias.group.guarded", Outcome(() => + Guard(_db.Refs, Ctx()).ToList(SummaryOn("reference")).Data.Count)); + Say("b.alias.group.blank.guarded", Outcome(() => + Guard(_db.Refs, Ctx()).ToList(SummaryOn(" ")).Data.Count)); + Say("b.alias.group.blank.unguarded", Outcome(() => + _db.Refs.AsQueryable().ToList(SummaryOn(" ")).Data.Count)); + } + + private static Summary SummaryOn(string field) => new() + { + GroupBy = new GroupBy + { + Fields = new List { field }, + AggregateBy = new List { new() { Aggregator = Aggregator.Count, Alias = "n" } } + } + }; + + private static Summary SummaryAgg(string field) => new() + { + GroupBy = new GroupBy + { + Fields = new List { "Name" }, + AggregateBy = new List + { + new() { Aggregator = Aggregator.Count, Field = field, Alias = "n" } + } + } + }; + + private static Filter WhereOn(string field) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, DataType = DataType.Text, + Operator = Operator.Equal, Values = { "x" } + } + } + } + }; + + private static Filter OrderOn(string field) => new() + { + Orders = new List { new() { Field = field, Direction = Direction.Ascending, Sort = 1 } } + }; + + private static Filter SelectOn(string field) => new() { Selects = new List { field } }; + + private static Segment SegmentSelect(string field) => new() + { + Selects = new List { field }, + ConditionSets = + { + new ConditionSet + { + Sort = 1, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = "Id", DataType = DataType.Number, + Operator = Operator.GreaterThan, Values = { "0" } + } + } + } + } + } + }; + + // ---- 3. an alias spelled like another member --------------------------------------------- + + [Fact] + public void Ov7_Alias_Inbound_And_Out() + { + // The startup scan. + foreach (Type type in new[] + { + typeof(Ov7Case), typeof(Ov7Chain), typeof(Ov7Ref), typeof(Ov7Shadow), + typeof(Ov7ShadowDerived), typeof(Ov7DualCase), typeof(Ov7Floor) + }) + { + string verdict; + + try + { + PolicyModelReport report = PolicyModelValidator.Inspect(new[] { type }); + verdict = report.IsValid + ? "VALID" + : "ERRORS:" + string.Join(" ;; ", report.Errors); + } + catch (Exception failure) + { + verdict = "THREW:" + failure.GetType().Name + ":" + failure.Message; + } + + Say($"c.inspect[{type.Name}]", verdict); + + // The scan reports; it never throws. A type reflection sees two properties on under + // one name — a base member hidden with new, two spelled in different cases — used to + // take an AmbiguousMatchException out of the scan, so a host calling ValidateModel + // at startup failed to start with no report at all. + Assert.DoesNotContain("THREW", verdict); + } + + // Plain reflection, for the same question the new rule asks. + foreach (Type type in new[] { typeof(Ov7ShadowDerived), typeof(Ov7DualCase) }) + { + string verdict; + + try + { + PropertyInfo? found = type.GetProperty( + "Value" == type.Name ? "Value" : type == typeof(Ov7DualCase) ? "Name" : "Value", + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + verdict = found?.DeclaringType?.Name + "." + found?.Name; + } + catch (Exception failure) + { + verdict = "THREW:" + failure.GetType().Name; + } + + Say($"c.getproperty[{type.Name}]", verdict); + } + + // Outbound: an alias that differs from its member only in case. + string spelled = Columns(() => + Guard(_db.Cases, Ctx()).ToListDynamic( + new Filter { Selects = new List { "Total", "Note" } })); + + string wholeRow = Columns(() => Guard(_db.Cases, Ctx()).ToListDynamic(new Filter())); + + Say("c.out.case", spelled); + Say("c.out.case.nosel", wholeRow); + + // A column does not collide with itself. The rule that stops a rename landing on a name + // the row already carries reads a case-insensitive map, so an alias spelling its own + // member differently looked like a shadow and the public name stopped being emitted. + Assert.Contains("total", spelled); + Assert.Contains("total", wholeRow); + + // Outbound: the chain. Both renames land on free names. + Say("c.out.chain", Columns(() => + Guard(_db.Chains, Ctx()).ToListDynamic( + new Filter { Selects = new List { "A", "B" } }))); + Say("c.out.chain.onlyA", Columns(() => + Guard(_db.Chains, Ctx()).ToListDynamic( + new Filter { Selects = new List { "A" } }))); + + // Outbound control: an ordinary alias. + Say("c.out.ref", Columns(() => + Guard(_db.Refs, Ctx()).ToListDynamic( + new Filter { Selects = new List { "Number", "Amount" } }))); + + // Outbound: an alias naming a member the row also carries. + Say("c.out.shadowlike", Columns(() => + Guard(_db.Floors, Ctx()).ToListDynamic( + new Filter { Selects = new List { "Bucket", "Amount" } }))); + + // Inbound: a caller may still write either spelling. + Say("c.in.case.alias", Outcome(() => + Guard(_db.Cases, Ctx()).ToList(WhereNum("total", "10")).Data.Count)); + Say("c.in.case.member", Outcome(() => + Guard(_db.Cases, Ctx()).ToList(WhereNum("Total", "10")).Data.Count)); + Say("c.in.chain.B", Outcome(() => + Guard(_db.Chains, Ctx()).ToList(WhereText("B", "b1")).Data.Count)); + Say("c.in.chain.C", Outcome(() => + Guard(_db.Chains, Ctx()).ToList(WhereText("C", "b1")).Data.Count)); + Say("c.in.chain.A", Outcome(() => + Guard(_db.Chains, Ctx()).ToList(WhereText("A", "a1")).Data.Count)); + Say("c.in.ref.alias", Outcome(() => + Guard(_db.Refs, Ctx()).ToList(WhereText("reference", "NUM1")).Data.Count)); + + // A summary, whose rows are generated and whose keys are renamed too. + Say("c.out.summary.case", SummaryColumns(() => + Guard(_db.Cases, Ctx()).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Total" }, + AggregateBy = new List + { + new() { Aggregator = Aggregator.Count, Alias = "n" } + } + } + }))); + } + + private static Filter WhereNum(string field, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, DataType = DataType.Number, + Operator = Operator.Equal, Values = { value } + } + } + } + }; + + private static Filter WhereText(string field, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, DataType = DataType.Text, + Operator = Operator.Equal, Values = { value } + } + } + } + }; + + private static string Columns(Func> run) + { + try + { + FilterResult result = run(); + + if (result.Data.Count == 0) + { + return "EMPTY"; + } + + return string.Join(",", Names(result.Data[0]!)); + } + catch (Exception failure) + { + return failure.GetType().Name + ":" + failure.Message; + } + } + + private static string SummaryColumns(Func run) + { + try + { + SummaryResult result = run(); + + if (result.Data.Count == 0) + { + return "EMPTY"; + } + + return string.Join(",", Names(result.Data[0]!)); + } + catch (Exception failure) + { + return failure.GetType().Name + ":" + failure.Message; + } + } + + private static IEnumerable Names(object row) => + row is IDictionary expando + ? expando.Keys.OrderBy(k => k, StringComparer.Ordinal) + : row.GetType().GetProperties().Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal); + } +} diff --git a/DynamicWhere.Tests/Policies/Ov7RefusalProbes.cs b/DynamicWhere.Tests/Policies/Ov7RefusalProbes.cs new file mode 100644 index 0000000..0741809 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ov7RefusalProbes.cs @@ -0,0 +1,327 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A hashed member with no salt supplied: the MissingHashSalt refusal. + public class Ov7Hashed + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwMask(MaskStrategy.Hash)] + [DwDeny(PolicyFeature.Order)] + public string Secret { get; set; } = string.Empty; + } + + /// A tokenized member with no vault supplied: the MissingTokenVault refusal. + public class Ov7Tokenized + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwMask(MaskStrategy.Tokenize)] + [DwDeny(PolicyFeature.Order)] + public string Secret { get; set; } = string.Empty; + } + + /// A generalized key, so two groups can collide once transformed. + public class Ov7Grouped + { + public int Id { get; set; } + + [DwGeneralize(GeneralizeMode.Round, Step = 100, AllowAggregate = true, MinGroupSize = 1)] + [DwDeny(PolicyFeature.Order)] + public int Bracket { get; set; } + + public decimal Amount { get; set; } + } + + /// Several audited members beneath an owned member. + public class Ov7Nested + { + public int Id { get; set; } + + [DwNoSelect] + public decimal Hidden { get; set; } + + public Ov7NestedOwn Own { get; set; } = new(); + } + + public class Ov7NestedOwn + { + [DwAudit(PolicyFeature.Select)] + public string Zip { get; set; } = string.Empty; + + public string Street { get; set; } = string.Empty; + } + + public sealed class Ov7RefusalContext : DbContext + { + private readonly SqliteConnection _connection; + + public Ov7RefusalContext(SqliteConnection connection) => _connection = connection; + + public DbSet Hashed => Set(); + + public DbSet Tokenized => Set(); + + public DbSet Grouped => Set(); + + public DbSet Nested => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) => + model.Entity().OwnsOne(n => n.Own); + } + + /// + /// Round 7: the four refusals' dry-run reading and audit path, the audit cap's own refusal, and + /// what an audited member beneath an owned member records. + /// + public sealed class Ov7RefusalProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Ov7RefusalContext _db; + + public Ov7RefusalProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Ov7RefusalContext(_connection); + _db.Database.EnsureCreated(); + + for (int i = 1; i <= 4; i++) + { + _db.Hashed.Add(new Ov7Hashed { Name = $"n{i}", Secret = $"s{i}" }); + _db.Tokenized.Add(new Ov7Tokenized { Name = $"n{i}", Secret = $"s{i}" }); + + // 10 and 20 fall in one bucket of 100; 150 and 199 in another. + _db.Grouped.Add(new Ov7Grouped { Bracket = i <= 2 ? i * 10 : 100 + (i * 20), Amount = i }); + _db.Nested.Add(new Ov7Nested + { + Hidden = i, + Own = new Ov7NestedOwn { Zip = $"z{i}", Street = $"st{i}" } + }); + } + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private void Say(string probe, string value) => _out.WriteLine($"OV7R {probe} = {value}"); + + private static PolicyResolver Resolver() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Opts(DwTier tier, bool optionDry, int auditCap = 10_000) + { + DwPolicyOptions options = new() { Tier = tier, DryRun = optionDry, AuditRefusals = true }; + options.Caps.MinGroupSize = 1; + options.Caps.MaxAuditEvents = auditCap; + + return options; + } + + /// Runs one posture and reports the refusal's shape plus what the refusal audit stored. + private string Shape(DwTier tier, bool optionDry, bool contextDry, Func, int> _, + Func run) + { + DwPolicyContext context = new DwPolicyContext() + .WithSubject(DwSubjectKind.User, "u1"); + context.DryRun = contextDry; + + DwPolicyOptions options = Opts(tier, optionDry); + + string outcome; + + try + { + outcome = $"OK({run(context, options)})"; + } + catch (PolicyException refusal) + { + outcome = $"{refusal.ErrorCode}/field='{refusal.FieldPath}'/origin={(refusal.SourceOrigin is null ? "none" : "set")}"; + } + catch (Exception other) + { + outcome = other.GetType().Name; + } + + string audited = string.Join("|", context.PendingAuditEvents + .Where(e => e.ErrorCode is not null) + .Select(e => e.FieldPath)); + + return $"{outcome} ;; auditPath='{audited}'"; + } + + private static readonly (string Label, DwTier Tier, bool OptionDry, bool ContextDry)[] Postures = + { + ("strict", DwTier.Strict, false, false), + ("strict+optionDry", DwTier.Strict, true, false), + ("strict+contextDry", DwTier.Strict, false, true), + ("convenience", DwTier.Convenience, false, false) + }; + + // ---- 4. the four refusals ------------------------------------------------------------ + + [Fact] + public void Ov7_Four_Refusals() + { + foreach ((string label, DwTier tier, bool optionDry, bool contextDry) in Postures) + { + // 1. MissingHashSalt: no salt on the options. + Say($"d1.hashsalt[{label}]", Shape(tier, optionDry, contextDry, null!, (context, options) => + _db.Hashed.AsQueryable().ApplyPolicy(context, options, Resolver()) + .ToList(new Filter()).Data.Count)); + + // 2. MissingTokenVault: no vault on the options. + Say($"d2.tokenvault[{label}]", Shape(tier, optionDry, contextDry, null!, (context, options) => + _db.Tokenized.AsQueryable().ApplyPolicy(context, options, Resolver()) + .ToList(new Filter()).Data.Count)); + + // 3. TransformRequiresMaterialization: a transformed type handed back as a query. + Say($"d3.materialize[{label}]", Shape(tier, optionDry, contextDry, null!, (context, options) => + { + IQueryable handed = _db.Hashed.AsQueryable().ApplyPolicy(context, options, Resolver()) + .FilterDynamic(new Filter()); + + return handed is null ? 0 : 1; + })); + + // 4. Two groups colliding once the key is generalized. + Say($"d4.collision[{label}]", Shape(tier, optionDry, contextDry, null!, (context, options) => + _db.Grouped.AsQueryable().ApplyPolicy(context, options, Resolver()) + .ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Bracket" }, + AggregateBy = new List + { + new() { Aggregator = Aggregator.Sumation, Field = "Amount", Alias = "total" } + } + } + }).Data.Count)); + } + } + + // ---- 1b. the audit cap's own refusal, and a nested audited member ---------------------- + + [Fact] + public void Ov7_Audit_Cap_And_Nested() + { + // Four audited members on Ov7Aud. Walk the cap down through the count. + foreach (int cap in new[] { 1, 2, 3, 4, 5 }) + { + foreach ((string label, DwTier tier, bool optionDry, bool contextDry) in Postures) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + context.DryRun = contextDry; + + DwPolicyOptions options = Opts(tier, optionDry, cap); + + string outcome; + + try + { + using SqliteConnection side = new("DataSource=:memory:"); + side.Open(); + using Ov7Context db = new(side); + db.Database.EnsureCreated(); + db.Auds.Add(new Ov7Aud + { + Name = "n", Email = "e", Phone = "p", Ssn = "s", + Dob = new DateTime(1990, 1, 1), Own = new Ov7Own { Street = "st", Zip = "z" } + }); + db.SaveChanges(); + db.ChangeTracker.Clear(); + + int count = db.Auds.AsQueryable().ApplyPolicy(context, options, Resolver()) + .ToList(new Filter()).Data.Count; + outcome = $"OK({count})"; + } + catch (PolicyException refusal) + { + outcome = + $"{refusal.ErrorCode}/field='{refusal.FieldPath}'/feature={refusal.Feature}" + + $"/origin={(refusal.SourceOrigin is null ? "none" : "set")}"; + } + catch (Exception other) + { + outcome = other.GetType().Name; + } + + Say($"e.cap{cap}[{label}]", outcome); + } + } + + // An audited member beneath an owned member, with nothing denied: the row comes back whole. + DwPolicyContext whole = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + FilterResult? wholeResult = null; + + try + { + wholeResult = _db.Nested.AsQueryable() + .ApplyPolicy(whole, Opts(DwTier.Strict, false), Resolver()) + .ToList(new Filter()); + } + catch (Exception failure) + { + Say("f.nested.whole", failure.GetType().Name); + } + + if (wholeResult is not null) + { + Say("f.nested.whole.zipReturned", + wholeResult.Data.Count > 0 ? wholeResult.Data[0].Own.Zip : "NONE"); + Say("f.nested.whole.events", whole.PendingAuditEvents.Count.ToString()); + Say("f.nested.whole.paths", + string.Join("|", whole.PendingAuditEvents.Select(e => e.FieldPath))); + + // An audited column of an owned type is read by whoever receives the object holding + // it, named or not: a member kept whole hands back everything inside it. + Assert.Contains(whole.PendingAuditEvents, e => e.FieldPath == "Own.Zip"); + } + + // The caller names the nested path themselves. + DwPolicyContext named = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + try + { + _db.Nested.AsQueryable().ApplyPolicy(named, Opts(DwTier.Strict, false), Resolver()) + .ToListDynamic(new Filter { Selects = new List { "Own.Zip" } }); + Say("f.nested.named.events", named.PendingAuditEvents.Count.ToString()); + Say("f.nested.named.paths", + string.Join("|", named.PendingAuditEvents.Select(e => e.FieldPath))); + } + catch (Exception failure) + { + Say("f.nested.named", failure.GetType().Name + ":" + failure.Message); + } + } + } +} diff --git a/DynamicWhere.Tests/Policies/Ov7ScaleProbes.cs b/DynamicWhere.Tests/Policies/Ov7ScaleProbes.cs new file mode 100644 index 0000000..a046883 --- /dev/null +++ b/DynamicWhere.Tests/Policies/Ov7ScaleProbes.cs @@ -0,0 +1,264 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + /// A realistically audited record: twelve audited columns, a navigation, a collection. + public class Ov7Wide + { + public int Id { get; set; } + + public string Reference { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A1 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A2 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A3 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A4 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A5 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A6 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A7 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A8 { get; set; } = string.Empty; + + [DwAudit(PolicyFeature.Select)] public string A9 { get; set; } = string.Empty; + + [DwAudit] public string A10 { get; set; } = string.Empty; + + [DwAudit] public string A11 { get; set; } = string.Empty; + + [DwAudit] public string A12 { get; set; } = string.Empty; + + public List Lines { get; set; } = new(); + + /// An audited navigation. Nothing includes it, so no query loads it. + [DwAudit(PolicyFeature.Select)] + public Ov7Side? Side { get; set; } + } + + public class Ov7Side + { + public int Id { get; set; } + + public string Tag { get; set; } = string.Empty; + } + + public class Ov7WideLine + { + public int Id { get; set; } + + public int Ov7WideId { get; set; } + + [DwAudit(PolicyFeature.Select)] + public string Detail { get; set; } = string.Empty; + } + + /// A masked key, so two groups collide once the values are starred out. + public class Ov7Collide + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Full)] + [DwDeny(PolicyFeature.Order)] + public string Label { get; set; } = string.Empty; + + public decimal Amount { get; set; } + } + + public sealed class Ov7ScaleContext : DbContext + { + private readonly SqliteConnection _connection; + + public Ov7ScaleContext(SqliteConnection connection) => _connection = connection; + + public DbSet Wides => Set(); + + public DbSet WideLines => Set(); + + public DbSet Collides => Set(); + + public DbSet Sides => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + /// + /// Round 7: how many events a realistic request now costs, and the fourth refusal. + /// + public sealed class Ov7ScaleProbes : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly Ov7ScaleContext _db; + + public Ov7ScaleProbes(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new Ov7ScaleContext(_connection); + _db.Database.EnsureCreated(); + + for (int i = 1; i <= 50; i++) + { + Ov7Wide wide = new() + { + Reference = $"R{i}", + A1 = "a", A2 = "b", A3 = "c", A4 = "d", A5 = "e", A6 = "f", + A7 = "g", A8 = "h", A9 = "i", A10 = "j", A11 = "k", A12 = "l" + }; + + wide.Lines.Add(new Ov7WideLine { Detail = $"d{i}a" }); + wide.Lines.Add(new Ov7WideLine { Detail = $"d{i}b" }); + _db.Wides.Add(wide); + + // Two distinct labels of the same length mask to the same run of stars. + _db.Collides.Add(new Ov7Collide { Label = i % 2 == 0 ? "aaaa" : "bbbb", Amount = i }); + } + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private void Say(string probe, string value) => _out.WriteLine($"OV7S {probe} = {value}"); + + private static PolicyResolver Resolver() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyContext Ctx(bool dry = false) + { + DwPolicyContext context = new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + context.DryRun = dry; + + return context; + } + + private static DwPolicyOptions Opts(DwTier tier = DwTier.Strict, bool dry = false) + { + DwPolicyOptions options = new() { Tier = tier, DryRun = dry, AuditRefusals = true }; + options.Caps.MinGroupSize = 1; + + return options; + } + + [Fact] + public void Ov7_Scale_And_Collision() + { + // Fifty rows, twelve audited columns, no projection named. + DwPolicyContext wide = Ctx(); + int rows = _db.Wides.AsQueryable().ApplyPolicy(wide, Opts(), Resolver()) + .ToList(new Filter()).Data.Count; + Say("g1.wide.rows", rows.ToString()); + Say("g1.wide.events", wide.PendingAuditEvents.Count.ToString()); + + // The same request with the navigation loaded, which is what an Include would do. + DwPolicyContext included = Ctx(); + List loaded = _db.Wides.Include(w => w.Lines) + .ApplyPolicy(included, Opts(), Resolver()) + .ToList(new Filter()).Data; + int withLines = loaded.Count; + Say("g2.included.rows", withLines.ToString()); + Say("g2.included.detailReturned", + withLines > 0 && loaded[0].Lines.Count > 0 ? loaded[0].Lines[0].Detail : "NONE"); + Say("g2.included.events", included.PendingAuditEvents.Count.ToString()); + Say("g2.included.paths", + string.Join("|", included.PendingAuditEvents.Select(e => e.FieldPath))); + + // Ten terminals on one long-lived context: the multiplier per request. + DwPolicyContext many = Ctx(); + + for (int i = 0; i < 10; i++) + { + _db.Wides.AsQueryable().ApplyPolicy(many, Opts(), Resolver()).ToList(new Filter()); + } + + Say("g3.ten-queries.events", many.PendingAuditEvents.Count.ToString()); + + // Side is audited and nothing loads it: the query returns null for it every time. + DwPolicyContext side = Ctx(); + List unloaded = _db.Wides.AsQueryable().ApplyPolicy(side, Opts(), Resolver()) + .ToList(new Filter()).Data; + Say("g5.navigation.loaded", + unloaded.Count > 0 ? (unloaded[0].Side is null ? "null" : "loaded") : "NONE"); + Say("g5.navigation.recorded", + side.PendingAuditEvents.Any(e => e.FieldPath == "Side") ? "yes" : "no"); + Say("g5.navigation.events", side.PendingAuditEvents.Count.ToString()); + + // Nothing loads it, so the caller receives null for it: not a read, and not an event + // counted against a cap that refuses rather than dropping a record. + Assert.DoesNotContain(side.PendingAuditEvents, e => e.FieldPath == "Side"); + + // The fourth refusal: two groups sharing a key once masked. + foreach ((string label, DwTier tier, bool optionDry, bool contextDry) in + new[] + { + ("strict", DwTier.Strict, false, false), + ("strict+optionDry", DwTier.Strict, true, false), + ("strict+contextDry", DwTier.Strict, false, true), + ("convenience", DwTier.Convenience, false, false) + }) + { + DwPolicyContext context = Ctx(contextDry); + string outcome; + + try + { + int groups = _db.Collides.AsQueryable() + .ApplyPolicy(context, Opts(tier, optionDry), Resolver()) + .ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new List { "Label" }, + AggregateBy = new List + { + new() { Aggregator = Aggregator.Count, Alias = "n" } + } + } + }).Data.Count; + outcome = $"OK({groups})"; + } + catch (PolicyException refusal) + { + outcome = + $"{refusal.ErrorCode}/field='{refusal.FieldPath}'" + + $"/origin={(refusal.SourceOrigin is null ? "none" : "set")}"; + } + catch (Exception other) + { + outcome = other.GetType().Name + ":" + + (other.InnerException?.GetType().Name ?? "-") + ":" + + (other.InnerException?.Message ?? other.Message); + } + + string audited = string.Join("|", context.PendingAuditEvents + .Where(e => e.ErrorCode is not null) + .Select(e => e.FieldPath)); + + Say($"g4.collision[{label}]", $"{outcome} ;; auditPath='{audited}'"); + } + } + } +} diff --git a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs index 84abb9e..5cde280 100644 --- a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs +++ b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs @@ -317,8 +317,8 @@ private static void CanonicalizeGrouping(GroupBy? groupBy, Gate gate) where T { // A blank key fails validation, as it does unguarded. Handing it to the resolver // instead reaches Validate's argument check, and an ArgumentNullException is not - // the failure the endpoint turns into a four-hundred — every other clause guards it - // here for the same reason. + // the failure the endpoint turns into a four-hundred. A blank Selects entry reaches + // that same check guarded or not, so it is left as it is: the two answers agree. if (string.IsNullOrWhiteSpace(groupBy.Fields[i])) { throw new LogicException(ErrorCode.InvalidField); @@ -1076,7 +1076,7 @@ private static void EnforceNavigationDepth(Segment segment, Gate gate) { foreach (string field in SegmentFields(segment)) { - gate.CheckDepth(field); + gate.CheckDepth(field, PolicyFeature.Segment); } } @@ -1145,7 +1145,7 @@ private static void EnforceNavigationDepth(Filter filter, Gate gate) { foreach (string field in filter.Selects) { - gate.CheckDepth(field); + gate.CheckDepth(field, PolicyFeature.Select); } } @@ -1153,7 +1153,7 @@ private static void EnforceNavigationDepth(Filter filter, Gate gate) { foreach (OrderBy order in filter.Orders) { - gate.CheckDepth(order.Field); + gate.CheckDepth(order.Field, PolicyFeature.Order); } } } @@ -1191,7 +1191,7 @@ private static void EnforceNavigationDepth(Summary summary, Gate gate) { foreach (string field in summary.GroupBy.Fields) { - gate.CheckDepth(field); + gate.CheckDepth(field, PolicyFeature.Group); } } @@ -1199,7 +1199,7 @@ private static void EnforceNavigationDepth(Summary summary, Gate gate) { foreach (AggregateBy aggregate in summary.GroupBy.AggregateBy) { - gate.CheckDepth(aggregate.Field); + gate.CheckDepth(aggregate.Field, PolicyFeature.Aggregate); } } } @@ -1473,7 +1473,7 @@ private static void CheckDepth(ConditionGroup? group, Gate gate) { foreach (Condition condition in group.Conditions) { - gate.CheckDepth(condition.Field); + gate.CheckDepth(condition.Field, PolicyFeature.Where); } } @@ -1907,7 +1907,17 @@ private static void SynthesizeSelects(Filter filter, Gate gate, RowShape rows) private static List? SynthesizedProjection(Gate gate, RowShape rows) { List allowed = new(); + + // The caller named no projection, so what comes back comes back because they asked for the + // row. That is a read, and an audited field has to record it: recording only what a request + // spells out would leave an empty Selects as one token past [DwAudit] — the same value, + // returned, with nothing written down. Collected as the walk decides what reaches the + // caller, and recorded once it knows whether a projection is built at all. List readable = new(); + + // What comes back inside a member kept whole. An audited column of an owned type is read by + // whoever receives the object holding it, named or not. + List inside = new(); List<(string Member, string Reason)> uncarried = new(); bool anyDenied = false; int recorded = gate.TraceCount; @@ -1923,16 +1933,16 @@ private static void SynthesizeSelects(Filter filter, Gate gate, RowShape rows) gate.Record(name, PolicyFeature.Select, PolicyAction.Dropped, policy); + // A dry run drops nothing: the member comes back with the row, so an audited one is + // read, and the record says so with the effect the policy decided. + if (gate.IsDryRun) + { + readable.Add(name); + } + continue; } - // The caller named no projection, so what comes back comes back because they asked for - // the row. That is a read, and an audited field has to record it: recording only what a - // request spells out would leave an empty Selects as one token past [DwAudit] — the same - // value, returned, with nothing written down. Which members reach the caller is known - // once the walk finishes, so they are collected here and recorded below. - readable.Add(name); - // Two members share the name, one hidden with new under another type or spelled in another case: // the core reads one of them, and a row carries both. What either can hold is asked about, and a // projection, which cannot tell them apart, leaves the name out. @@ -1957,6 +1967,14 @@ private static void SynthesizeSelects(Filter filter, Gate gate, RowShape rows) allowed.Add(name); } + // What the caller receives: a value the source carries, and a getter the row + // computes from what it carries, which a projection could not have assigned and + // which comes back with the row when no projection is built. + if (rows.CarriesValue(name) || !assignable) + { + readable.Add(name); + } + continue; } @@ -1998,6 +2016,11 @@ private static void SynthesizeSelects(Filter filter, Gate gate, RowShape rows) : rows.Kind == RowKind.InMemory ? "left out: a projection cannot keep an object a row in memory holds" : "left out: it is a navigation, which projecting would load")); + + // A projection cannot keep it, and with no projection built the row hands it + // back. A navigation nothing loads reaches nobody and is not a read. + readable.Add(name); + inside.AddRange(survivors); } continue; @@ -2006,6 +2029,8 @@ private static void SynthesizeSelects(Filter filter, Gate gate, RowShape rows) if (reached.Count == 0 && !unnamed.Opaque && !forced && !gate.ReadOnlyTransformBeneath(name)) { allowed.Add(name); + readable.Add(name); + inside.AddRange(survivors); continue; } @@ -2048,7 +2073,12 @@ private static void SynthesizeSelects(Filter filter, Gate gate, RowShape rows) // What the caller receives: the projection's own list where one is built, and every member // they may select where none is, since the row then comes back whole. Both sets are paths // whose policy is already resolved, so recording costs no resolution of its own. - foreach (string path in anyDenied ? allowed : readable) + // + // A dry run builds no projection, whatever is denied — it changes what the policy does, not + // what it saw — so the row comes back whole there and the whole row is what was read. + // Recording the projection's would-be list instead would hand a member back unrecorded, + // which is the gap this recording exists to close. + foreach (string path in (anyDenied && !gate.IsDryRun ? allowed : readable).Concat(inside)) { gate.AuditUse(path, PolicyFeature.Select, gate.PolicyFor(path, PolicyFeature.None)); } @@ -3954,7 +3984,7 @@ internal void CheckPage(PageBy? page) /// type otherwise lets a caller write a path of any length and make the provider emit one /// join per segment. /// - internal void CheckDepth(string? fieldPath) + internal void CheckDepth(string? fieldPath, PolicyFeature feature) { if (string.IsNullOrEmpty(fieldPath)) { @@ -3975,7 +4005,23 @@ internal void CheckDepth(string? fieldPath) if (segments > _options.Caps.MaxNavigationDepth) { - Raise(Cap("MaxNavigationDepth", _options.Caps.MaxNavigationDepth, segments, fieldPath)); + PolicyException? refusal = + Cap("MaxNavigationDepth", _options.Caps.MaxNavigationDepth, segments, fieldPath); + + if (refusal is not null + && HidesExistence + && !string.Equals(Spoken(fieldPath), fieldPath, StringComparison.Ordinal)) + { + // The caller wrote one token and the cap counted the path it stands for, so this + // refusal would tell them their name resolved to something several navigations + // deep — where a name matching nothing gets the clause's own refusal. It gets + // that one too. A caller who wrote the path themselves already knows its depth, + // and is answered by the cap, as every other over-long request is. The trace + // above keeps the cap and the depth for the operator either way. + refusal = Exception(fieldPath, feature, DenialFor(feature), null); + } + + Raise(refusal); } } diff --git a/DynamicWhere.ex/Policies/Source/ResultTransformer.cs b/DynamicWhere.ex/Policies/Source/ResultTransformer.cs index ed98da2..b258517 100644 --- a/DynamicWhere.ex/Policies/Source/ResultTransformer.cs +++ b/DynamicWhere.ex/Policies/Source/ResultTransformer.cs @@ -169,7 +169,9 @@ internal static void Rename( // Not onto a name the row already carries: writing there would emit one column under // another's name and drop that other's value outright. The startup scan reports such an // alias as an error; a deployment that runs without scanning keeps both columns. - string name = byColumn.TryGetValue(property.Key, out string? alias) && !properties.ContainsKey(alias) + string name = byColumn.TryGetValue(property.Key, out string? alias) + && (string.Equals(alias, property.Key, StringComparison.OrdinalIgnoreCase) + || !properties.ContainsKey(alias)) ? alias : property.Key; diff --git a/DynamicWhere.ex/Policies/Validation/PolicyModelValidator.cs b/DynamicWhere.ex/Policies/Validation/PolicyModelValidator.cs index 77e8c64..16d67fd 100644 --- a/DynamicWhere.ex/Policies/Validation/PolicyModelValidator.cs +++ b/DynamicWhere.ex/Policies/Validation/PolicyModelValidator.cs @@ -265,9 +265,12 @@ private static void CheckAlias( // An alias spelled like another member of the same type is the same collision as two members // sharing one alias, and it is worse on the way out: a generated row would carry one name // twice, so one of the two values is the one the caller receives. - if (!string.Equals(alias.Name, property.Name, StringComparison.OrdinalIgnoreCase) - && type.GetProperty(alias.Name, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase) - is { } shadowed) + PropertyInfo? shadowed = !string.Equals(alias.Name, property.Name, StringComparison.OrdinalIgnoreCase) + ? type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(other => string.Equals(other.Name, alias.Name, StringComparison.OrdinalIgnoreCase)) + : null; + + if (shadowed is not null) { errors.Add( $"{member}: the alias '{alias.Name}' is the name of {type.Name}.{shadowed.Name}. A name " + From 692a4a4440dcedb2f34f7b78f34d2f0fefec4e67 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Mon, 21 Sep 2026 02:46:47 +0300 Subject: [PATCH 33/54] docs: what "hands back" means, and the navigation cap on a name written as one token The audit rule is stated precisely rather than loosely: a member kept whole records the audited paths inside it, a navigation nothing loads records nothing because the caller receives null for it, a value the source does not carry records nothing, and a dry run records everything the row carries, a denied member included. The navigation cap's answer to an aliased name is breaking point 35, and the audit read moves to 36. Co-Authored-By: Claude Opus 5 --- DynamicWhere.ex/DOC.md | 3 ++- DynamicWhere.ex/DynamicWhere.ex.csproj | 2 +- .../app/docs/breaking-changes/page.tsx | 22 +++++++++++++++---- OfficialWebsite/public/llms.txt | 7 ++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index 304a9ce..fc17808 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -1672,6 +1672,7 @@ A dropped field leaves nothing behind in the data, so the trace is the only way - Inside a segment every field refusal is `FieldDeniedForSegment`, with `Feature` `Segment`, whichever clause refused it — a condition in any set, an order, a select, or the field taking part at all. Answered by clause, a field denied for every clause but not for segments would say `FieldDeniedForOrder` where a name that matches nothing says `FieldDeniedForSegment`. Filters and summaries keep their per-clause codes. - Every refusal carrying one of those six codes has `FieldPath` `"*"`, a null `RuleId` and a null `SourceOrigin`, whatever the field — a real denied field and an alias included — so its message is the same too. - A blank name fails a guarded query as it fails an unguarded one, in every clause. A blank grouping key used to reach the resolver and fail with `ArgumentNullException`, which is neither a refusal nor the malformed-clause failure an endpoint turns into a four-hundred (3.3.0). +- `MaxNavigationDepth` counts the canonical path, so a name the caller wrote as one token — an alias — is refused as an unknown name is rather than with the cap's own code, which would say the token named something several navigations deep (3.3.0). A caller who wrote the path themselves meets the cap, as every over-long request does. - Four more refusals name the clause rather than a field (3.3.0): an ambiguous name is refused as an unknown name is, and `AmbiguousGroupKey`, `TransformRequiresMaterialization`, `MissingHashSalt` and `MissingTokenVault` carry `FieldPath` `"*"`. All but the transform refusal drop their `SourceOrigin` too; that one's origin names a method rather than a field. - A `CapExceeded` refusal names no path either. `MaxNavigationDepth` used to return the canonical spelling of the path the caller wrote, which confirmed that it named something. `SourceOrigin` still names the cap. - `MissingContextValue` has `FieldPath` `"*"` and a null `SourceOrigin`, so it names neither the scope's column nor the context key it reads, which together describe how rows are partitioned. The trace keeps both, and an audited refusal records the scoped field. @@ -1831,7 +1832,7 @@ The trace names the fields a policy dropped, the attribute or rule that sealed e ### Auditing -`[DwAudit(features)]` records every use of a field, whatever the policy decided, as a `DwAuditEvent` in the caller's context (`DwPolicyContext.PendingAuditEvents`). A use is a field the request names, or, since 3.1.0, a field of the type's [default order](#default-order) that the query orders by: audited for `Order`, it is recorded each time, as a caller's own order is. A default field left out for this caller is not recorded, because the query does not order by it and the caller never named it. Nothing is stored until the buffer is drained to an `IDwAuditSink`, by `DwPolicy.DrainAuditAsync(context, sink)` or, per request, by the ASP.NET Core middleware `app.UseDwPolicyAudit()`. A use is what the request reads, not only what it spells out. A request that sends no `Selects` receives the row, so every audited member the query hands back is recorded for `Select` (3.3.0) — one event per query, not per row, and only for a field `[DwAudit]` names. Until 3.3.0 only a field the request wrote down was recorded, which left an empty `Selects` as one token past the control: the same value, returned, with nothing written down. A buffer already holding `DwCaps.MaxAuditEvents` refuses the next audited use with `CapExceeded` — except under `Strict` outside a dry run, where it refuses with the clause's own field refusal instead (3.3.0). An unknown name is never audited and never reaches the cap, so answering with the cap's own code there would have told a caller that the name they guessed is a real field and an audited one. +`[DwAudit(features)]` records every use of a field, whatever the policy decided, as a `DwAuditEvent` in the caller's context (`DwPolicyContext.PendingAuditEvents`). A use is a field the request names, or, since 3.1.0, a field of the type's [default order](#default-order) that the query orders by: audited for `Order`, it is recorded each time, as a caller's own order is. A default field left out for this caller is not recorded, because the query does not order by it and the caller never named it. Nothing is stored until the buffer is drained to an `IDwAuditSink`, by `DwPolicy.DrainAuditAsync(context, sink)` or, per request, by the ASP.NET Core middleware `app.UseDwPolicyAudit()`. A use is what the request reads, not only what it spells out. A request that sends no `Selects` receives the row, so every audited member the query hands back is recorded for `Select` (3.3.0) — one event per query, not per row, and only for a field `[DwAudit]` names. What it hands back is read strictly: a member kept whole records the audited paths inside it, a navigation nothing loads records nothing because the caller receives null for it, and a value the source does not carry records nothing either. A dry run applies no projection, so everything the row carries is recorded there, a denied member included, with the effect the policy decided. Until 3.3.0 only a field the request wrote down was recorded, which left an empty `Selects` as one token past the control: the same value, returned, with nothing written down. A buffer already holding `DwCaps.MaxAuditEvents` refuses the next audited use with `CapExceeded` — except under `Strict` outside a dry run, where it refuses with the clause's own field refusal instead (3.3.0). An unknown name is never audited and never reaches the cap, so answering with the cap's own code there would have told a caller that the name they guessed is a real field and an audited one. A log of uses never shows a caller probing for columns they may not read: every guess is refused, so nothing was used. `DwPolicyOptions.AuditRefusals` (`bool`, default `false`, new in 3.1.0) records the refusals too. When it is on, every `PolicyException` raised by a guarded entry point of `PolicyQueryable` — terminal or composable — and `ApplyPolicy(context)`'s refusal of an unprepared context are written to the same buffer and drain the same way: diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index ef9a07a..b5717f6 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -54,7 +54,7 @@ New: DwPolicy.Configure takes a second call asking for the posture already in fo Behaviour change: under the strict tier, a filter, order, grouping key or aggregated field naming a path the query cannot compute is refused as an unknown name is, where the provider used to throw. A projection is not refused: EF Core evaluates the last projection on the client, so a Selects entry naming such a member returns its value as it always did. A member of a row's type is not always a value a database can produce: a getter such as LocalizedText.IsEmpty reads two columns in memory, so every check the policy makes passes and the query then fails inside EF Core with InvalidOperationException — a five-hundred where the strict tier promises a refusal, and the one place the tier answered with neither an answer nor a refusal. The path is refused only where the whole set of members a container can produce is known: the queried entity type's own model, an owned or complex type, and an initializer in a projection EF Core ran before ApplyPolicy, including a member that projection copies straight from the entity. Everywhere else nothing changes — rows in memory run the getter as they always did, a framework member the provider translates such as Length, Year or HasValue is left alone, a value beneath a converted column is left alone, a provider that is not EF Core's decides for itself, the convenience tier and a dry run still fail exactly as an unguarded query does, and a source the library cannot read asks for nothing. A column the model maps only on a subtype is refused through the base type, because EF Core translates a member against the type the query is over and fails there too; query the derived type. The trace records the refusal with its reason, and the trace is now assigned before a request is sanitized, so a refusal leaves LastTrace readable rather than null. -Security fix and behaviour change: [DwAudit] records a read the request did not name. A request sending no Selects receives the row, and only a field it spelled out used to be recorded, so that caller read every audited member with nothing written down — one token past a control whose purpose is to answer who read a field. Every audited member a projection the caller did not name hands back is recorded for Select now: what the synthesized projection keeps where one is built, and every member the caller may select where none is. One event per query rather than per row, and only for a field [DwAudit] names. A deployment already running the control sees more events, and DwCaps.MaxAuditEvents refuses rather than dropping a record, so raise the cap or drain per request with app.UseDwPolicyAudit(). +Security fix and behaviour change: [DwAudit] records a read the request did not name. A request sending no Selects receives the row, and only a field it spelled out used to be recorded, so that caller read every audited member with nothing written down — one token past a control whose purpose is to answer who read a field. Every audited member a projection the caller did not name hands back is recorded for Select now: what the synthesized projection keeps where one is built, and every member the caller may select where none is. One event per query rather than per row, and only for a field [DwAudit] names. What is handed back is read strictly: a member kept whole records the audited paths inside it, a navigation nothing loads records nothing because the caller receives null for it, and a dry run, which applies no projection, records everything the row carries, a denied member included. MaxNavigationDepth also stops answering an alias with its own code under the strict tier: the cap counts the canonical path, so a name the caller wrote as one token is refused as an unknown name is, while a caller who wrote the path themselves still meets the cap. A deployment already running the control sees more events, and DwCaps.MaxAuditEvents refuses rather than dropping a record, so raise the cap or drain per request with app.UseDwPolicyAudit(). Security fix and behaviour change: under the strict tier, outside a dry run, four refusals that named a field name the clause instead. A strict refusal names none, so that a denied field, a misspelling and a field that does not exist cannot be told apart. AmbiguousFieldName said the caller's name matched more than one field, and so at least one; it is refused as an unknown name is now, and the trace keeps the ambiguity for the operator who has to fix the aliases. AmbiguousGroupKey reported the column behind the caller's alias, with an origin saying its values are transformed. TransformRequiresMaterialization listed every transformed column on the type to a caller who had named none. MissingHashSalt and MissingTokenVault named the masked field a deployment had not configured for. AmbiguousGroupKey, MissingHashSalt and MissingTokenVault report "*" and no origin; TransformRequiresMaterialization reports "*" and keeps an origin naming the method and what to call instead, never a field. The convenience tier and a dry run, whichever switch declares it, are unchanged. diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index 0a40e8e..ec1a5f8 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -17,12 +17,12 @@ export default function Page() {

          Breaking Changes & Known Limitations

          DynamicWhere.ex is intentionally opinionated about how queries are shaped. - The thirty-five points below cover constraints, surprises, and corner cases — + The thirty-six points below cover constraints, surprises, and corner cases — read them before designing an API around the library so you can pick the right entry points and avoid runtime exceptions in production.

          - Points 30 to 35 changed in 3.3.0. Under{" "} + Points 30 to 36 changed in 3.3.0. Under{" "} Strict, a path that exists on the type and names no value the query can compute is refused rather than run, where the provider used to throw and the caller saw a five-hundred (point 30).{" "} @@ -37,7 +37,9 @@ export default function Page() { a field under Strict name the clause instead (point 34). And [DwAudit] records the audited members a projection the caller never named hands back, which an empty{" "} - Selects used to return unrecorded (point 35). + Selects used to return unrecorded (point 36), and{" "} + MaxNavigationDepth answers an alias the way it answers a + name that matches nothing (point 35). Points 25 to 29 changed in 3.2.0, and each is @@ -1684,7 +1686,19 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}FieldPath off any of the four, sees the change.

          -

          35. [DwAudit] Records a Read the Request Did Not Name

          + +

          + The cap counts the canonical path, so an alias standing for a deep path + was refused with CapExceeded and an origin stating that + path's depth — where a name matching nothing got the clause's + own refusal and no origin. Under Strict outside a dry run + such a name is refused as an unknown name is since{" "} + 3.3.0. A caller who wrote the path themselves already + knows its depth and still meets the cap, as every over-long request + does, and the trace keeps the cap and the depth for the operator. +

          + +

          36. [DwAudit] Records a Read the Request Did Not Name

          A request that sends no Selects receives the row. Until{" "} 3.3.0 only a field the request spelled out was diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index cc5f129..1346bb0 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -2196,6 +2196,9 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. - A use is what the request reads: a field it names, a default-order field the query adds, and — since 3.3.0 — every audited member a projection the caller did not name hands back, recorded for `Select`. One event per query, not per row. Until 3.3.0 an empty `Selects` returned the value with nothing written down. +- What is handed back is read strictly: a member kept whole records the audited paths inside it; a navigation + nothing loads records nothing, since the caller receives null for it; a value the source does not carry records + nothing. A dry run applies no projection, so everything the row carries is recorded, a denied member included. - A refusal becomes an event of its own, with an `ErrorCode`, only when `DwPolicyOptions.AuditRefusals` is on, and then for every refused guarded query, audited field or not (3.1.0, section 22). - It is elected: the top-ranked audit wins outright, and fragments tied with it are unioned. A rule can @@ -4321,6 +4324,10 @@ There are eleven. The first eight let a caller learn a value, or what the policy `CapExceeded` and an origin naming the cap, while an unknown name — never audited, so never reaching the cap — carried the ordinary field refusal. Under `Strict` outside a dry run the cap now refuses with the clause's own field refusal, so the two cannot be told apart; `Convenience` and a dry run still answer `CapExceeded`. +- **The navigation cap on an aliased name:** `MaxNavigationDepth` counts the canonical path, so an alias the caller + wrote as one token was refused with the cap's own code and an origin stating that path's depth, where a name + matching nothing got the clause's refusal. Under `Strict` outside a dry run such a name is refused as an unknown + name is (3.3.0); a caller who wrote the path themselves still meets the cap. - **A refusal that names a field:** four did under `Strict` until 3.3.0 — an ambiguous name, an ambiguous grouping key, a clause that cannot be transformed, and a deployment with no hash salt or token vault. An ambiguous name is now refused as an unknown name is; the grouping key, the hash salt and the token vault name the clause and carry From 09386e0ce1a33cf589143b4b82a763cbe87f75c1 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Mon, 21 Sep 2026 03:09:04 +0300 Subject: [PATCH 34/54] docs: the posture sweep is a suite test, and its comment says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class comment called it a doc probe "not part of the suite", but it holds eleven facts with assertions, is excluded nowhere, and runs in CI — and two of those facts are the reflection guard the "Configuring twice" documentation rests on. The comment now says what the class is and why it is kept. Co-Authored-By: Claude Opus 5 --- .../Policies/ReviewPostureComparisonTests.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/DynamicWhere.Tests/Policies/ReviewPostureComparisonTests.cs b/DynamicWhere.Tests/Policies/ReviewPostureComparisonTests.cs index 2832bbc..c2881c4 100644 --- a/DynamicWhere.Tests/Policies/ReviewPostureComparisonTests.cs +++ b/DynamicWhere.Tests/Policies/ReviewPostureComparisonTests.cs @@ -10,9 +10,15 @@ namespace DynamicWhere.Tests.Policies { ///

          - /// DOC PROBE ONLY — not part of the suite. Checks the documented "Configuring twice" comparison - /// list against what SamePosture actually compares. + /// Holds the documented "Configuring twice" comparison list to what SamePosture actually + /// compares. /// + /// + /// Written as a review probe and kept as a suite test, because the two sweeps below are the + /// guard the documentation rests on: they walk every writable property on + /// DwPolicyOptions and DwCaps by reflection and require each one to be refused, + /// so a value added later cannot quietly stop being compared. + /// public sealed class ReviewPostureComparisonTests { private readonly ITestOutputHelper _out; From 89fd6bf91c7efd8d3b23e1552c3da33a38e08017 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Mon, 21 Sep 2026 03:14:56 +0300 Subject: [PATCH 35/54] test: the in-memory watch gets the budget its conformance twin already has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI run on 09386e0 — a commit holding one XML comment — failed on A_watch_yields_the_version_on_every_write with OperationCanceledException at ten seconds, while the same suite had just passed three times on the commit before it. This is the flake 073614c diagnosed for the store conformance watch: the reader runs on the thread pool, and three thousand tests on a two-core runner can leave it unscheduled until the budget runs out. That commit fixed the conformance watch and left this one, which is the same shape, at ten seconds. The budget is sixty seconds now and the write loop waits on the reader rather than on the clock, so it stops writing the moment the watch has reported twice. The assertions are untouched: removing the watcher registration from InMemoryPolicyStore still fails the test, so the longer budget buys patience, not a pass. Co-Authored-By: Claude Opus 5 --- .../Policies/InMemoryPolicyStoreTests.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/DynamicWhere.Tests/Policies/InMemoryPolicyStoreTests.cs b/DynamicWhere.Tests/Policies/InMemoryPolicyStoreTests.cs index 24a2640..c31abbd 100644 --- a/DynamicWhere.Tests/Policies/InMemoryPolicyStoreTests.cs +++ b/DynamicWhere.Tests/Policies/InMemoryPolicyStoreTests.cs @@ -164,7 +164,12 @@ public async Task Without_a_type_resolver_the_upsert_is_accepted_and_the_rule_is public async Task A_watch_yields_the_version_on_every_write() { using InMemoryPolicyStore store = new(); - using CancellationTokenSource cancel = new(TimeSpan.FromSeconds(10)); + + // Generous on purpose, and for the same reason the store conformance watch is: the reader + // runs on the thread pool, and a suite of three thousand tests can leave it waiting behind + // work that has nothing to do with this store. Only a watch that never reports waits this + // long. + using CancellationTokenSource cancel = new(TimeSpan.FromSeconds(60)); IAsyncEnumerable watch = store.WatchAsync(cancel.Token)!; @@ -193,7 +198,10 @@ public async Task A_watch_yields_the_version_on_every_write() { await store.UpsertAsync(Rule(field: "Notes"), cancel.Token); - await Task.Delay(10, cancel.Token); + // Waits on the reader itself rather than on the clock, so the loop ends as soon as the + // watch has reported twice and does not go on writing while the reader waits to be + // scheduled. + await Task.WhenAny(reader, Task.Delay(10, cancel.Token)); } await reader; From 7e77f813a672f66b08c90f67e5817c911b117467 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Mon, 21 Sep 2026 03:57:59 +0300 Subject: [PATCH 36/54] fix(core): a page number whose offset passes Int32 is an empty page The offset is (PageNumber - 1) * PageSize, worked out in 32 bits, and for a large enough page number the product wraps. A negative offset is an error on SQL Server and PostgreSQL, so a request became a 500, and it is the first page again on SQLite and in memory, so a page far past the last row returned rows. The policy layer caps PageSize and not PageNumber, so a guarded query took the same path. It is worked out in 64 bits now and held to what Skip can take, in Page and in the three summary terminals. A page past the last row is an empty page however far past it is, as it always was for a page number that did not wrap. Found by round 8's own pass. The mutation (the 32-bit product back) turns four tests red. Co-Authored-By: Claude Fable 5.1 --- DynamicWhere.Tests/PageTests.cs | 15 +++++++++++++++ DynamicWhere.Tests/SummaryTests.cs | 26 ++++++++++++++++++++++++++ DynamicWhere.ex/Source/Extention.cs | 21 ++++++++++++++++----- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/DynamicWhere.Tests/PageTests.cs b/DynamicWhere.Tests/PageTests.cs index b30fb27..c071526 100644 --- a/DynamicWhere.Tests/PageTests.cs +++ b/DynamicWhere.Tests/PageTests.cs @@ -61,6 +61,21 @@ public void PageCustomersBeyondTheLastRow() => public void HighPageNumberReturnsNothing() => Assert.Empty(Products.Page(Page(999, 10)).ToList()); + /// + /// The offset is a product, and in 32 bits it wraps: a negative offset was the first page again + /// on SQLite and in memory, and an error on SQL Server and PostgreSQL. A page past the last row is + /// an empty page however far past it is. + /// + [Theory] + [InlineData(int.MaxValue, 1000)] + [InlineData(int.MaxValue, 2)] + [InlineData(4_294_968, 1000)] + public void PageNumberWhoseOffsetPassesInt32IsAnEmptyPage(int number, int size) + { + Assert.Empty(Products.Page(Page(number, size)).ToList()); + Assert.Empty(SalesSeed.Products().AsQueryable().Page(Page(number, size)).ToList()); + } + [Fact] public void RejectsPageNumberBelowOne() { diff --git a/DynamicWhere.Tests/SummaryTests.cs b/DynamicWhere.Tests/SummaryTests.cs index b6a4915..4deda0f 100644 --- a/DynamicWhere.Tests/SummaryTests.cs +++ b/DynamicWhere.Tests/SummaryTests.cs @@ -400,6 +400,32 @@ public void SummaryToListSync() Assert.Null(result.QueryString); } + /// + /// The three summary terminals page with the same product a filter does, and it wrapped the same + /// way: the first page of groups came back for a page number far past the last one. + /// + [Fact] + public async Task SummaryPageWhoseOffsetPassesInt32IsAnEmptyPage() + { + Summary summary = new() + { + GroupBy = CountAndAverage(), + Page = new PageBy { PageNumber = int.MaxValue, PageSize = 1000 } + }; + + Assert.Empty(Products.Summary(summary).ToDynamicList()); + + SummaryResult sync = Products.ToList(summary); + + Assert.Empty(sync.Data!); + Assert.Equal(2, sync.TotalCount); + + SummaryResult async = await Products.ToListAsync(summary); + + Assert.Empty(async.Data!); + Assert.Equal(2, async.TotalCount); + } + [Fact] public void SummaryToListSyncWithQueryString() { diff --git a/DynamicWhere.ex/Source/Extention.cs b/DynamicWhere.ex/Source/Extention.cs index 9149c40..80f0a08 100644 --- a/DynamicWhere.ex/Source/Extention.cs +++ b/DynamicWhere.ex/Source/Extention.cs @@ -363,7 +363,7 @@ public static IQueryable Page(this IQueryable query, PageBy page) where // Skip the required number of items to reach the desired page, // and then take the specified number of items for the page. - return query.Skip((page.PageNumber - 1) * page.PageSize).Take(page.PageSize); + return query.Skip(Offset(page)).Take(page.PageSize); } /// @@ -960,7 +960,7 @@ public static IQueryable Summary(this IQueryable query, Summary summary) w // Apply pagination. if (summary.Page != null) { - result = result.Skip((summary.Page.PageNumber - 1) * summary.Page.PageSize) + result = result.Skip(Offset(summary.Page)) .Take(summary.Page.PageSize); } @@ -1043,7 +1043,7 @@ public static SummaryResult ToList(this IQueryable query, Summary summary, // Apply pagination. if (summary.Page != null) { - newResult = newResult.Skip((summary.Page.PageNumber - 1) * summary.Page.PageSize) + newResult = newResult.Skip(Offset(summary.Page)) .Take(summary.Page.PageSize); pageNumber = summary.Page.PageNumber; @@ -1192,7 +1192,7 @@ public static async Task ToListAsync(this IQueryable query, // Apply pagination. if (summary.Page != null) { - newResult = newResult.Skip((summary.Page.PageNumber - 1) * summary.Page.PageSize) + newResult = newResult.Skip(Offset(summary.Page)) .Take(summary.Page.PageSize); pageNumber = summary.Page.PageNumber; @@ -1297,4 +1297,15 @@ public static async Task> ToListAsync(this IQueryable que Data = fresult.Data }; } -} \ No newline at end of file + + /// + /// The number of rows before a page, held to what Skip can take. + /// + /// + /// Worked out in 64 bits. In 32 the product wraps for a large enough page number, and a negative + /// offset is an error on SQL Server and PostgreSQL and the first page again on SQLite and in memory, + /// where the answer to a page past the last row is an empty page. + /// + private static int Offset(PageBy page) => + (int)Math.Min((long)(page.PageNumber - 1) * page.PageSize, int.MaxValue); +} From bd9ac24f8c7c836ea3554401d1eadf453c1a48c3 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Mon, 21 Sep 2026 03:57:59 +0300 Subject: [PATCH 37/54] fix(policies): a path or a member the attribute walk cannot name is still policed The attribute walk names the paths of the declared types, four segments deep, and a path that matches no fragment is allowed. Round 8 found four places a value sat where no fragment reached, each reproduced with a probe first. All four are in 3.2.0 as well. Beneath a member whose type the framework declares. Salary.Value and Salary.HasValue on a decimal?, Secret.Length on a string, Born.Year on a DateTime, Lines.Count on an application's own collection class: the pipeline validates each, the provider translates each, and no attribute can be placed there. A [DwDenied] decimal? was filtered on, sorted by, grouped by with its values as the keys, aggregated, and handed back by a dynamic projection, under Strict. A generalized member gave its stored value the same way, an audited one was read with nothing recorded, and a weighted one cost the default. Such a path takes every fragment of the member it reads, whichever provider supplied it. What is said to the caller about the member, its alias, its required filter, its forced scope and its description, stays the member's alone. A subtype's member is not such a path: a grant of Zone does not grant what a subtype of Zone declares. Past the walk's depth. Caps.MaxNavigationDepth can be raised above four, and a denied member at five segments was then filtered on, grouped by and returned. The attributes of the member at the end of such a path are read directly. Where a transform cannot be applied. On either kind of path there is nothing to apply the member's transform to, so every way the path hands a value back, Select, Group and Aggregate, is refused. Filtering and ordering follow the member's own decision, as they do along a named path. In the rows themselves. The outbound walk transformed along named paths only, so a masked member five segments down, one only a subtype of the row declares, and one on an object a dictionary holds all came back as stored, at the default caps, under Strict. The rows are walked by run-time type as well, and a member that declares a transform and was not transformed along a path is transformed by its own attributes, once. Only what can lead to such a member is read, and a model with no transform attribute pays nothing. And the walk itself returned at its depth limit with the type still marked as being inside it, so a type first met there read as a cycle wherever it was met again. What a cycle leaves out, a forced scope, a required filter and an alias, was then left out of a path reaching the type directly. Which of two members was declared first decided whether a tenant scope applied. Thirteen mutations, each red. One of them was a regression of this commit's own first cut, caught by round 5's deny-by-default tests: the member a path reads was taken for any segment the navigated type did not declare, so a subtype's member inherited the grant of the member above it. Co-Authored-By: Claude Fable 5.1 --- .../Policies/PolicyGraphWalkTests.cs | 2 +- .../Policies/Rd8UnwalkedPathTests.cs | 487 ++++++++++++++++++ .../Policies/Rd8UnwalkedTransformTests.cs | 255 +++++++++ .../Policies/Masking/GraphWalker.cs | 324 +++++++++++- .../Resolution/AttributePolicyProvider.cs | 296 +++++++++-- .../Policies/Resolution/PolicyResolver.cs | 141 ++++- .../Policies/Source/PolicyQueryable.cs | 15 +- .../Policies/Source/ResultTransformer.cs | 14 +- 8 files changed, 1443 insertions(+), 91 deletions(-) create mode 100644 DynamicWhere.Tests/Policies/Rd8UnwalkedPathTests.cs create mode 100644 DynamicWhere.Tests/Policies/Rd8UnwalkedTransformTests.cs diff --git a/DynamicWhere.Tests/Policies/PolicyGraphWalkTests.cs b/DynamicWhere.Tests/Policies/PolicyGraphWalkTests.cs index 1806d87..1c61dd6 100644 --- a/DynamicWhere.Tests/Policies/PolicyGraphWalkTests.cs +++ b/DynamicWhere.Tests/Policies/PolicyGraphWalkTests.cs @@ -87,7 +87,7 @@ private static PolicyTrace Walk(IEnumerable rows, IReadOnlyCollection { PolicyTrace trace = new(DwTier.Convenience, dryRun: false); - GraphWalker.Apply(rows, PolicyFor(), projected, Caller(), Options(), trace); + GraphWalker.Apply(rows, typeof(T), PolicyFor(), projected, Caller(), Options(), trace); return trace; } diff --git a/DynamicWhere.Tests/Policies/Rd8UnwalkedPathTests.cs b/DynamicWhere.Tests/Policies/Rd8UnwalkedPathTests.cs new file mode 100644 index 0000000..b28552c --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rd8UnwalkedPathTests.cs @@ -0,0 +1,487 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; + +namespace DynamicWhere.Tests.Policies +{ + /// A row whose policed members are all of types the framework declares. + public class Rd8Row + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwDenied] + public string Secret { get; set; } = string.Empty; + + [DwNoWhere] + public DateTime Born { get; set; } + + [DwNoOrder] + public string Rank { get; set; } = string.Empty; + + [DwDenied] + public decimal? Salary { get; set; } + + [DwGeneralize(GeneralizeMode.Round, Step = 1000)] + public decimal? Bonus { get; set; } + + [DwAudit] + public string Notes { get; set; } = string.Empty; + + [DwCost(50)] + public string Essay { get; set; } = string.Empty; + + [DwOperators(Allow = new[] { Operator.Equal })] + public string Code { get; set; } = string.Empty; + + [DwDenied] + public Rd8Lines Lines { get; set; } = new(); + } + + /// An application's own collection class, whose Count is its own member and not an element's. + public class Rd8Lines : List + { + } + + public class Rd8Line + { + public int Id { get; set; } + } + + /// + /// A path that continues beneath a member whose type the framework declares, Salary.Value, + /// Secret.Length, Born.Year, reads that member. No attribute can be placed there and no + /// fragment names it, so resolved on its own it matched nothing and was allowed: a denied column was + /// filtered on, sorted by, grouped by with its values as the keys, aggregated, and handed back by a + /// dynamic projection, under either tier. + /// + public sealed class Rd8ValueMemberPathTests + { + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes() => new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static DwPolicyOptions Posture(DwTier tier) => new() { Tier = tier, Caps = { MinGroupSize = 1 } }; + + private static Rd8Row[] Rows() => new[] + { + new Rd8Row { Id = 1, Name = "ab", Secret = "12345678", Born = new DateTime(1990, 1, 1), Rank = "x", Salary = 5100m, Bonus = 1234m, Notes = "n", Essay = "e", Code = "abcd" }, + new Rd8Row { Id = 2, Name = "abcdef", Secret = "12", Born = new DateTime(2001, 1, 1), Rank = "yy", Salary = 900m, Bonus = 4321m, Notes = "nn", Essay = "ee", Code = "ab" }, + }; + + private static PolicyQueryable Guarded(DwTier tier) => + Rows().AsQueryable().ApplyPolicy(Caller(), Posture(tier), Attributes()); + + private static Condition Cond(string field, DataType type, Operator op, params object[] values) => + new() { Sort = 0, Field = field, DataType = type, Operator = op, Values = values.ToList() }; + + private static Filter Where(Condition condition) => new() + { + ConditionGroup = new ConditionGroup { Connector = Connector.And, Conditions = new() { condition } }, + Selects = new() { "Id" } + }; + + private static Summary GroupBy(string field) => new() + { + GroupBy = new GroupBy + { + Fields = new() { field }, + AggregateBy = new() { new AggregateBy { Aggregator = Aggregator.Count, Alias = "n" } } + } + }; + + private static Summary Max(string field) => new() + { + GroupBy = new GroupBy + { + Fields = new() { "Name" }, + AggregateBy = new() { new AggregateBy { Field = field, Aggregator = Aggregator.Maximum, Alias = "m" } } + } + }; + + public static TheoryData DeniedForWhere() + { + TheoryData data = new(); + + foreach (DwTier tier in new[] { DwTier.Strict, DwTier.Convenience }) + { + data.Add(tier, "Secret.Length", DataType.Number, Operator.GreaterThan, 5); + data.Add(tier, "Salary.Value", DataType.Number, Operator.GreaterThan, 1000); + data.Add(tier, "Salary.HasValue", DataType.Boolean, Operator.Equal, true); + data.Add(tier, "Born.Year", DataType.Number, Operator.GreaterThan, 2000); + data.Add(tier, "Born.Date.Year", DataType.Number, Operator.GreaterThan, 2000); + data.Add(tier, "Lines.Count", DataType.Number, Operator.GreaterThan, 0); + } + + return data; + } + + [Theory] + [MemberData(nameof(DeniedForWhere))] + public void A_condition_beneath_a_member_denied_for_where_is_refused( + DwTier tier, string field, DataType type, Operator op, object value) + { + PolicyException refusal = Assert.Throws( + () => Guarded(tier).ToList(Where(Cond(field, type, op, value)))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Theory] + [InlineData("Secret.Length")] + [InlineData("Salary.Value")] + [InlineData("Rank.Length")] + public void An_order_beneath_a_member_denied_for_order_is_refused_or_dropped(string field) + { + Filter filter = new() + { + Orders = new() { new OrderBy { Sort = 0, Field = field, Direction = Direction.Ascending } }, + Selects = new() { "Id" } + }; + + PolicyException refusal = Assert.Throws(() => Guarded(DwTier.Strict).ToList(filter)); + + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, refusal.ErrorCode); + + // The second row sorts first by every one of these, so the source order coming back says + // the order was dropped rather than applied. + Assert.Equal(new[] { 1, 2 }, Guarded(DwTier.Convenience).ToList(filter).Data!.Select(row => row.Id)); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_group_key_or_an_aggregate_beneath_a_denied_member_is_refused(DwTier tier) + { + foreach (string field in new[] { "Salary.Value", "Secret.Length" }) + { + Assert.Equal( + PolicyErrorCode.FieldDeniedForGroup, + Assert.Throws(() => Guarded(tier).ToList(GroupBy(field))).ErrorCode); + + Assert.Equal( + PolicyErrorCode.FieldDeniedForAggregate, + Assert.Throws(() => Guarded(tier).ToList(Max(field))).ErrorCode); + } + } + + [Fact] + public void A_projection_beneath_a_denied_member_is_refused_or_dropped() + { + foreach (string field in new[] { "Salary.Value", "Secret.Length" }) + { + Filter filter = new() { Selects = new() { "Id", field } }; + + Assert.Equal( + PolicyErrorCode.FieldDeniedForSelect, + Assert.Throws(() => Guarded(DwTier.Strict).ToListDynamic(filter)).ErrorCode); + + string json = System.Text.Json.JsonSerializer.Serialize(Guarded(DwTier.Convenience).ToListDynamic(filter).Data); + + Assert.Equal("[{\"Id\":1},{\"Id\":2}]", json); + } + } + + /// + /// A transform is applied to the member it was declared on. Beneath it there is nothing to apply + /// it to, so every way the path hands a value back is refused; filtering runs on the stored value + /// wherever the member is read from, and stays as the member's own policy leaves it. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Beneath_a_transformed_member_nothing_is_handed_back(DwTier tier) + { + Assert.Equal( + PolicyErrorCode.FieldDeniedForGroup, + Assert.Throws(() => Guarded(tier).ToList(GroupBy("Bonus.Value"))).ErrorCode); + + Assert.Equal( + PolicyErrorCode.FieldDeniedForAggregate, + Assert.Throws(() => Guarded(tier).ToList(Max("Bonus.Value"))).ErrorCode); + + Assert.Equal( + new[] { 2 }, + Guarded(tier).ToList(Where(Cond("Bonus.Value", DataType.Number, Operator.GreaterThan, 2000))).Data!.Select(row => row.Id)); + } + + [Fact] + public void A_use_beneath_an_audited_member_is_recorded() + { + DwPolicyContext caller = Caller(); + + Rows().AsQueryable().ApplyPolicy(caller, Posture(DwTier.Strict), Attributes()) + .ToList(Where(Cond("Notes.Length", DataType.Number, Operator.GreaterThan, 1))); + + Assert.Contains(caller.PendingAuditEvents, recorded => recorded.FieldPath == "Notes.Length" && recorded.Feature == PolicyFeature.Where); + } + + [Fact] + public void A_path_beneath_a_weighted_member_costs_what_the_member_costs() + { + DwPolicyOptions posture = new() { Tier = DwTier.Convenience, Caps = { MinGroupSize = 1, MaxQueryCost = 10 } }; + + PolicyException refusal = Assert.Throws(() => Rows().AsQueryable() + .ApplyPolicy(Caller(), posture, Attributes()) + .ToList(Where(Cond("Essay.Length", DataType.Number, Operator.GreaterThan, 1)))); + + Assert.Equal(PolicyErrorCode.QueryCostExceeded, refusal.ErrorCode); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void An_operator_restriction_holds_beneath_the_member(DwTier tier) + { + PolicyException refusal = Assert.Throws( + () => Guarded(tier).ToList(Where(Cond("Code.Length", DataType.Number, Operator.GreaterThan, 3)))); + + Assert.Equal(PolicyErrorCode.OperatorNotAllowed, refusal.ErrorCode); + + Assert.Equal( + new[] { 1 }, + Guarded(tier).ToList(Where(Cond("Code.Length", DataType.Number, Operator.Equal, 4))).Data!.Select(row => row.Id)); + } + + /// A rule decides the member as an attribute does, so it decides what reads the member too. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_rule_denying_a_member_covers_the_paths_beneath_it(DwTier tier) + { + FakePolicyProvider rules = new FakePolicyProvider() + .Add("Name", PolicyFeature.Where, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + PolicyResolver resolver = new(new IDwPolicyProvider[] { new AttributePolicyProvider(), rules }); + + PolicyException refusal = Assert.Throws(() => Rows().AsQueryable() + .ApplyPolicy(Caller(), Posture(tier), resolver) + .ToList(Where(Cond("Name.Length", DataType.Number, Operator.GreaterThan, 3)))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + /// Precision: a member nothing denies is read beneath as it always was, and one feature is one feature. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_path_beneath_a_member_takes_that_members_policy_and_no_more(DwTier tier) + { + Assert.Equal( + new[] { 2 }, + Guarded(tier).ToList(Where(Cond("Name.Length", DataType.Number, Operator.GreaterThan, 3))).Data!.Select(row => row.Id)); + + // Born is denied for Where alone. + Assert.Equal(2, Guarded(tier).ToList(GroupBy("Born.Year")).Data!.Count); + } + + [Fact] + public void The_member_a_path_reads() + { + Assert.Equal("Salary", AttributePolicyProvider.Governing(typeof(Rd8Row), "Salary.Value")); + Assert.Equal("Born", AttributePolicyProvider.Governing(typeof(Rd8Row), "Born.Date.Year")); + Assert.Equal("Lines", AttributePolicyProvider.Governing(typeof(Rd8Row), "Lines.Count")); + + // An element's member is the walk's to name, and so is a member on its own. + Assert.Null(AttributePolicyProvider.Governing(typeof(Rd8Row), "Lines.Id")); + Assert.Null(AttributePolicyProvider.Governing(typeof(Rd8Row), "Salary")); + + // A name that matches nothing is refused as one, elsewhere. + Assert.Null(AttributePolicyProvider.Governing(typeof(Rd8Row), "Nothing.Value")); + Assert.Null(AttributePolicyProvider.Governing(typeof(Rd8Row), "Lines.Nothing")); + } + } + + public class Rd8A { public int Id { get; set; } public Rd8B? B { get; set; } } + + public class Rd8B { public int Id { get; set; } public Rd8C? C { get; set; } } + + public class Rd8C { public int Id { get; set; } public Rd8D? D { get; set; } } + + public class Rd8D { public int Id { get; set; } public Rd8E? E { get; set; } } + + public class Rd8E + { + public int Id { get; set; } + + [DwDenied] + public string? Secret { get; set; } + + public string? Open { get; set; } + + [DwMask(MaskStrategy.Full)] + public string? Card { get; set; } + + [DwNoWhere] + public DateTime? Born { get; set; } + } + + /// + /// The attribute walk names paths of four segments, and the navigation-depth cap defaults to four. A + /// host that raises the cap lets a request name a fifth, which no fragment reached: a denied member + /// there was filtered on, grouped by and handed back under either tier. + /// + public sealed class Rd8DeepPathTests + { + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyQueryable Guarded(DwTier tier) => new[] + { + new Rd8A { Id = 1, B = new() { Id = 2, C = new() { Id = 3, D = new() { Id = 4, E = new() { Id = 5, Secret = "s3cret", Open = "o", Card = "4111111111111111", Born = new DateTime(2001, 1, 1) } } } } } + } + .AsQueryable() + .ApplyPolicy( + Caller(), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1, MaxNavigationDepth = 6 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, DataType type, Operator op, object value) => new() + { + ConditionGroup = new ConditionGroup + { + Connector = Connector.And, + Conditions = new() { new Condition { Sort = 0, Field = field, DataType = type, Operator = op, Values = new() { value } } } + }, + Selects = new() { "Id" } + }; + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_denied_member_past_the_walk_is_refused_in_every_clause(DwTier tier) + { + Assert.Equal( + PolicyErrorCode.FieldDeniedForWhere, + Assert.Throws(() => Guarded(tier).ToList(Where("B.C.D.E.Secret", DataType.Text, Operator.StartsWith, "s3"))).ErrorCode); + + Assert.Equal( + PolicyErrorCode.FieldDeniedForGroup, + Assert.Throws(() => Guarded(tier).ToList(new Summary + { + GroupBy = new GroupBy + { + Fields = new() { "B.C.D.E.Secret" }, + AggregateBy = new() { new AggregateBy { Aggregator = Aggregator.Count, Alias = "n" } } + } + })).ErrorCode); + + // Beneath a member the framework declares the type of, past the walk as well. + Assert.Equal( + PolicyErrorCode.FieldDeniedForWhere, + Assert.Throws(() => Guarded(tier).ToList(Where("B.C.D.E.Born.Value", DataType.DateTime, Operator.GreaterThan, "2000-01-01"))).ErrorCode); + } + + [Fact] + public void A_denied_or_masked_member_past_the_walk_is_never_handed_back() + { + foreach (string field in new[] { "B.C.D.E.Secret", "B.C.D.E.Card" }) + { + Filter filter = new() { Selects = new() { "Id", field } }; + + Assert.Equal( + PolicyErrorCode.FieldDeniedForSelect, + Assert.Throws(() => Guarded(DwTier.Strict).ToListDynamic(filter)).ErrorCode); + + Assert.Equal( + "[{\"Id\":1}]", + System.Text.Json.JsonSerializer.Serialize(Guarded(DwTier.Convenience).ToListDynamic(filter).Data)); + } + } + + /// Precision: a member past the walk that declares nothing runs as it did. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_member_past_the_walk_that_declares_nothing_is_left_alone(DwTier tier) + { + Assert.Single(Guarded(tier).ToList(Where("B.C.D.E.Open", DataType.Text, Operator.Equal, "o")).Data!); + + // A masked member is filtered on its stored value wherever it is reached from. + Assert.Single(Guarded(tier).ToList(Where("B.C.D.E.Card", DataType.Text, Operator.StartsWith, "4111")).Data!); + } + + [Fact] + public void Only_what_the_member_declares_about_itself_is_read_past_the_walk() + { + IReadOnlyList fragments = AttributePolicyProvider.Unwalked(typeof(Rd8A), "B.C.D.E.Secret"); + + PolicyFragment denial = Assert.Single(fragments); + + Assert.Equal(PolicyEffect.Deny, denial.Effect); + Assert.Equal("B.C.D.E.Secret", denial.FieldPath); + + Assert.Empty(AttributePolicyProvider.Unwalked(typeof(Rd8A), "B.C.D.Id")); + Assert.Empty(AttributePolicyProvider.Unwalked(typeof(Rd8A), "B.C.D.E.Nothing")); + } + } + + public class Rd8P { public int Id { get; set; } public Rd8Q? Q1 { get; set; } public Rd8Z? Late { get; set; } } + + public class Rd8P2 { public int Id { get; set; } public Rd8Z? Late { get; set; } public Rd8Q? Q1 { get; set; } } + + public class Rd8Q { public int Id { get; set; } public Rd8R? R { get; set; } } + + public class Rd8R { public int Id { get; set; } public Rd8S? S { get; set; } } + + public class Rd8S { public int Id { get; set; } public Rd8Z? Z { get; set; } } + + public class Rd8Z + { + public int Id { get; set; } + + [DwForceWhere(Operator.Equal, Value = "7")] + public int Tenant { get; set; } + + [DwAlias("zname")] + public string? Name { get; set; } + } + + /// + /// The attribute walk marks each type it is inside, to tell a cycle from a first visit, and returned + /// at its depth limit with the type still marked. A type first met there then read as a cycle wherever + /// it was met again, and what a cycle leaves out, a forced scope, a required filter and an alias, was + /// left out of a path that reaches the type directly. Which member was declared first decided it. + /// + public sealed class Rd8WalkMarkerTests + { + [Theory] + [InlineData(typeof(Rd8P))] + [InlineData(typeof(Rd8P2))] + public void A_type_first_met_at_the_depth_limit_is_not_a_cycle(Type root) + { + IReadOnlyList fragments = new AttributePolicyProvider().GetFragments(root, new DwPolicyContext()); + + Assert.Contains(fragments, fragment => fragment.FieldPath == "Late.Tenant" && fragment.Forced is not null); + Assert.Contains(fragments, fragment => fragment.FieldPath == "Late.Name" && fragment.Alias == "zname"); + } + + [Fact] + public void The_scope_forced_on_it_filters_the_rows() + { + Rd8P[] rows = + { + new() { Id = 1, Late = new Rd8Z { Tenant = 7 } }, + new() { Id = 2, Late = new Rd8Z { Tenant = 8 } }, + }; + + List kept = rows.AsQueryable() + .ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })) + .ToList(new Filter { Selects = new() { "Id" } }).Data!; + + Assert.Equal(new[] { 1 }, kept.Select(row => row.Id)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Rd8UnwalkedTransformTests.cs b/DynamicWhere.Tests/Policies/Rd8UnwalkedTransformTests.cs new file mode 100644 index 0000000..d521b7a --- /dev/null +++ b/DynamicWhere.Tests/Policies/Rd8UnwalkedTransformTests.cs @@ -0,0 +1,255 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Masking; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; + +namespace DynamicWhere.Tests.Policies +{ + public class Rd8M1 { public int Id { get; set; } public Rd8M2? B { get; set; } public Rd8M5? Near { get; set; } } + + /// The same two members the other way round, so the far one is reached first. + public class Rd8M1Far { public int Id { get; set; } public Rd8M2? B { get; set; } public Rd8M5? Near { get; set; } public Rd8M5? Last { get; set; } } + + public class Rd8M1Near { public int Id { get; set; } public Rd8M5? Near { get; set; } public Rd8M2? B { get; set; } } + + public class Rd8M2 { public int Id { get; set; } public Rd8M3? C { get; set; } } + + public class Rd8M3 { public int Id { get; set; } public Rd8M4? D { get; set; } } + + public class Rd8M4 + { + public int Id { get; set; } + + public Rd8M5? E { get; set; } + + [DwMask(MaskStrategy.Full)] + public string? Pin { get; set; } + } + + public class Rd8M5 + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Full)] + public string? Card { get; set; } + + [DwMask(MaskStrategy.Hash)] + public string? Token { get; set; } + } + + public class Rd8Animal { public int Id { get; set; } public string? Name { get; set; } public Rd8Tag? Tag { get; set; } } + + public class Rd8Dog : Rd8Animal + { + [DwMask(MaskStrategy.Full)] + public string? Chip { get; set; } + } + + public class Rd8Tag { public int Id { get; set; } } + + public class Rd8GoldTag : Rd8Tag + { + [DwMask(MaskStrategy.Full)] + public string? Serial { get; set; } + } + + public class Rd8Wallet { public int Id { get; set; } public Dictionary Cards { get; set; } = new(); } + + public class Rd8Pan + { + [DwMask(MaskStrategy.Full)] + public string? Number { get; set; } + } + + /// Nothing in what a row of this type can hold declares a transform. + public class Rd8Plain { public int Id { get; set; } public Rd8PlainChild? Child { get; set; } } + + public class Rd8PlainChild { public int Id { get; set; } public string? Name { get; set; } } + + /// One navigation leads to a transform and one cannot; reading the second throws. + public class Rd8Careful + { + public int Id { get; set; } + + public Rd8Dog? Pet { get; set; } + + public Rd8PlainChild? Untouched => throw new InvalidOperationException("read a navigation that leads to no transform"); + } + + public class Rd8Frozen { public int Id { get; set; } public Rd8FrozenTag? Tag { get; set; } } + + public class Rd8FrozenTag { public int Id { get; set; } } + + public class Rd8FrozenGold : Rd8FrozenTag + { + [DwMask(MaskStrategy.Full)] + public string Serial => "SER-1"; + } + + /// + /// A transform is applied along the paths the attribute walk names: the declared types, four segments + /// deep. A value that sits where none of them goes, on a member only a subtype of the row declares, + /// five segments down, on an object a dictionary holds, came back exactly as stored while the same + /// member four segments up was masked. The rows are walked by run-time type as well, and a member + /// that declares a transform where no path reaches is transformed by its own attributes. + /// + public sealed class Rd8UnwalkedTransformTests + { + private const string Salt = "pepper-and-more-pepper"; + + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyQueryable Guarded(IEnumerable rows, DwTier tier) where T : class => + rows.AsQueryable().ApplyPolicy( + Caller(), + new DwPolicyOptions { Tier = tier, HashSalt = Salt, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Rd8M1 Deep() => new() + { + Id = 1, + B = new() { Id = 2, C = new() { Id = 3, D = new() { Id = 4, Pin = "9999", E = new() { Id = 5, Card = "4111111111111111" } } } } + }; + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_masked_member_five_segments_down_is_masked(DwTier tier) + { + Rd8M1 whole = Guarded(new[] { Deep() }, tier).ToList(new Filter()).Data!.Single(); + + Assert.Equal("****", whole.B!.C!.D!.Pin); + Assert.Equal("****************", whole.B.C.D.E!.Card); + + Rd8M1 named = Guarded(new[] { Deep() }, tier).ToList(new Filter { Selects = new() { "Id", "B" } }).Data!.Single(); + + Assert.Equal("****************", named.B!.C!.D!.E!.Card); + + string dynamic = System.Text.Json.JsonSerializer.Serialize( + Guarded(new[] { Deep() }, tier).ToListDynamic(new Filter { Selects = new() { "Id", "B.C.D.E" } }).Data); + + Assert.DoesNotContain("4111", dynamic); + Assert.Contains("****************", dynamic); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_masked_member_only_a_subtype_declares_is_masked(DwTier tier) + { + Rd8Animal[] rows = { new Rd8Dog { Id = 1, Name = "rex", Chip = "CHIP-123", Tag = new Rd8GoldTag { Id = 9, Serial = "SER-999" } } }; + + Rd8Dog dog = Assert.IsType(Guarded(rows, tier).ToList(new Filter()).Data!.Single()); + + Assert.Equal("********", dog.Chip); + Assert.Equal("*******", Assert.IsType(dog.Tag).Serial); + Assert.Equal("rex", dog.Name); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_masked_member_of_an_object_a_dictionary_holds_is_masked(DwTier tier) + { + Rd8Wallet[] rows = { new() { Id = 1, Cards = { ["main"] = new Rd8Pan { Number = "5500" } } } }; + + Rd8Wallet wallet = Guarded(rows, tier).ToList(new Filter()).Data!.Single(); + + Assert.Equal("****", wallet.Cards["main"].Number); + } + + /// + /// One object reached twice, by a path the policy names and by one it does not, is transformed + /// once: the digest of a digest is not the stand-in the first pass issued. + /// + [Fact] + public void An_object_reached_by_a_named_path_and_an_unnamed_one_is_transformed_once() + { + // What the first pass alone makes of it, which the second has no part in. + Rd8M1 alone = new() { Id = 1, Near = new Rd8M5 { Id = 5, Token = "tok" } }; + DwPolicyOptions posture = new() { Tier = DwTier.Strict, HashSalt = Salt }; + + GraphWalker.Apply( + new[] { alone }, typeof(Rd8M1), + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }).ResolveType(typeof(Rd8M1), Caller()), + projected: null, Caller(), posture, new DynamicWhere.ex.Policies.DTOs.PolicyTrace(DwTier.Strict, dryRun: false), + attributes: false); + + string once = alone.Near.Token!; + + Assert.NotEqual("tok", once); + + // Whichever of the two members is declared first, and so whichever way the object is met first. + Rd8M5 held = new() { Id = 5, Token = "tok" }; + Rd8M1Far far = new() { Id = 1, Near = held, B = new() { C = new() { D = new() { E = held } } } }; + + Assert.Equal(once, Guarded(new[] { far }, DwTier.Strict).ToList(new Filter()).Data!.Single().Near!.Token); + + held = new() { Id = 5, Token = "tok" }; + Rd8M1Near near = new() { Id = 1, Near = held, B = new() { C = new() { D = new() { E = held } } } }; + + Rd8M1Near row = Guarded(new[] { near }, DwTier.Strict).ToList(new Filter()).Data!.Single(); + + Assert.Equal(once, row.Near!.Token); + Assert.Same(row.Near, row.B!.C!.D!.E); + } + + [Fact] + public void A_model_that_declares_no_transform_pays_for_no_second_pass() + { + Assert.False(GraphWalker.HoldsTransform(typeof(Rd8Plain))); + Assert.True(GraphWalker.HoldsTransform(typeof(Rd8M1))); + Assert.True(GraphWalker.HoldsTransform(typeof(Rd8Animal))); + Assert.True(GraphWalker.HoldsTransform(typeof(Rd8Wallet))); + } + + /// + /// A resolver built over no attribute provider reads no attribute, along a named path or off it: + /// the second pass follows the first, rather than enforcing what the caller left out. + /// + [Fact] + public void A_resolver_that_reads_no_attributes_transforms_nothing_by_attribute() + { + Rd8M1 row = new[] { Deep() }.AsQueryable() + .ApplyPolicy( + Caller(), + new DwPolicyOptions { Tier = DwTier.Strict, HashSalt = Salt }, + new PolicyResolver(new IDwPolicyProvider[] { new FakePolicyProvider() })) + .ToList(new Filter()).Data!.Single(); + + Assert.Equal("9999", row.B!.C!.D!.Pin); + Assert.Equal("4111111111111111", row.B.C.D.E!.Card); + } + + /// + /// Only what can lead to a transform is read. A navigation whose type reaches none is never + /// touched, so a lazy loader behind one is not woken by a pass that had nothing to do there. + /// + [Fact] + public void A_navigation_that_leads_to_no_transform_is_never_read() + { + Rd8Careful[] rows = { new() { Id = 1, Pet = new Rd8Dog { Chip = "CHIP-123" } } }; + + Rd8Careful row = Guarded(rows, DwTier.Strict).ToList(new Filter()).Data!.Single(); + + Assert.Equal("********", row.Pet!.Chip); + } + + /// As along a named path: a transform that cannot replace the value fails the query. + [Fact] + public void A_transformed_member_with_no_setter_fails_the_query_rather_than_passing_the_value() + { + Rd8Frozen[] rows = { new() { Id = 1, Tag = new Rd8FrozenGold { Id = 2 } } }; + + InvalidOperationException failure = Assert.Throws( + () => Guarded(rows, DwTier.Strict).ToList(new Filter())); + + Assert.Contains("Tag.Serial", failure.Message); + } + } +} diff --git a/DynamicWhere.ex/Policies/Masking/GraphWalker.cs b/DynamicWhere.ex/Policies/Masking/GraphWalker.cs index 16f42c1..af409b4 100644 --- a/DynamicWhere.ex/Policies/Masking/GraphWalker.cs +++ b/DynamicWhere.ex/Policies/Masking/GraphWalker.cs @@ -1,9 +1,13 @@ using System.Collections; +using System.Collections.Concurrent; +using System.Linq.Dynamic.Core; using System.Reflection; using DynamicWhere.ex.Optimization.Cache.Source; using DynamicWhere.ex.Policies.Config; using DynamicWhere.ex.Policies.Context; using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; namespace DynamicWhere.ex.Policies.Masking; @@ -50,13 +54,20 @@ internal static class GraphWalker /// internal static void Apply( IEnumerable rows, + Type entityType, TypePolicy policy, IReadOnlyCollection? projected, DwPolicyContext context, DwPolicyOptions options, - PolicyTrace trace) + PolicyTrace trace, + bool attributes = true) { - if (policy.Transforms.Count == 0) + // Whether anything a row of this type can hold declares a transform at all. False for a model + // with no transform attribute, which then pays nothing for the second pass below, and for a + // resolver built over no attribute provider, which reads no attribute anywhere. + bool unwalked = attributes && HoldsTransform(entityType); + + if (policy.Transforms.Count == 0 && !unwalked) { return; } @@ -83,6 +94,10 @@ internal static void Apply( return; } + // What the first pass transformed, so the second never transforms it again: a hash of a hash + // is not the token the first pass issued. Kept only when there is a second pass to read it. + HashSet<(object Owner, string Member)>? done = unwalked ? new(DoneComparer.Instance) : null; + foreach (KeyValuePair entry in policy.Transforms) { if (!IsCarried(entry.Key, projected)) @@ -90,7 +105,12 @@ internal static void Apply( continue; } - ApplyPath(roots, entry.Key, entry.Value, context, options, trace); + ApplyPath(roots, entry.Key, entry.Value, context, options, trace, done); + } + + if (unwalked) + { + ApplyUnwalked(roots, projected, context, options, trace, done!); } } @@ -132,7 +152,8 @@ private static void ApplyPath( ValueTransform chain, DwPolicyContext context, DwPolicyOptions options, - PolicyTrace trace) + PolicyTrace trace, + HashSet<(object Owner, string Member)>? done) { string[] segments = path.Split('.'); @@ -183,6 +204,8 @@ private static void ApplyPath( accessors.Set(owner, replacement); + done?.Add((owner, accessors.Name)); + transformed = true; } @@ -255,16 +278,291 @@ private static HashSet Descend( /// name, the getter, the setter — are each a hash of something, and each was being paid on /// every row of every result. /// + /// + /// Applies the transform a member's own attributes declare, wherever in the materialized rows the + /// pass above did not reach that member. + /// + /// + /// The attribute walk names the paths of the declared types, four segments deep, and the pass + /// above transforms along those paths and no others. A value can sit where none of them goes: on + /// a member only a subtype of the row's type declares, five segments down a graph somebody + /// included, on an object a dictionary holds, or the far side of a cycle. Each came back exactly as + /// stored, under a mask that the same member wears four segments up. + /// + /// So the rows are walked as they are, by run-time type, and a member that declares a transform + /// and was not transformed above is transformed by its own attributes. No rule can take a + /// transform off a member, so where the pass above left one untouched it is because no path led + /// there, never because an election decided against it. Only what can lead to such a member is + /// read: a navigation whose type can reach no transform is never touched, so a lazy loader behind + /// it is not woken. + /// + /// + private static void ApplyUnwalked( + HashSet roots, + IReadOnlyCollection? projected, + DwPolicyContext context, + DwPolicyOptions options, + PolicyTrace trace, + HashSet<(object Owner, string Member)> done) + { + HashSet seen = new(ReferenceComparer.Instance); + Stack<(object Node, string Path)> pending = new(); + Dictionary recorded = new(StringComparer.Ordinal); + + foreach (object root in roots) + { + seen.Add(root); + pending.Push((root, string.Empty)); + } + + while (pending.Count > 0) + { + (object node, string prefix) = pending.Pop(); + + foreach (Member member in Members(node.GetType())) + { + string path = prefix.Length == 0 ? member.Property.Name : prefix + "." + member.Property.Name; + + if (member.Transform is { } chain + && IsCarried(path, projected) + && done.Add((node, member.Property.Name))) + { + if (member.Set is null) + { + throw new InvalidOperationException( + $"'{path}' is transformed by policy but has no setter, so the transformed value " + + "cannot replace the real one. Give the member a setter, or project into a type " + + "that has one."); + } + + member.Set(node, TransformPipeline.Apply( + chain, member.Get(node), member.Property.PropertyType, + new DwTransformContext(node, path, context), options)); + + recorded[path] = chain; + } + + if (!member.Leads) + { + continue; + } + + foreach (object held in Held(member.Get(node))) + { + if (seen.Add(held)) + { + pending.Push((held, path)); + } + } + } + } + + foreach (KeyValuePair entry in recorded) + { + trace.Add(new PolicyDecision( + entry.Key, Enums.PolicyFeature.Select, entry.Value.Action, + string.Join(" then ", entry.Value.Stages.Select(s => s.Kind)) + + " (declared on the member; no path of the policy names it)")); + } + } + + /// One readable member of a run-time type that transforms, or can lead to one that does. + private sealed record Member( + PropertyInfo Property, + Func Get, + Action? Set, + ValueTransform? Transform, + bool Leads); + + /// + /// The members of a run-time type the second pass reads: those declaring a transform, and those + /// whose value can hold an object that does. Read once per type and again once another assembly + /// has loaded, since that can add a subtype. + /// + private static Member[] Members(Type type) + { + int epoch = KnownSubtypes.Epoch; + + if (MembersByType.TryGetValue(type, out (int Epoch, Member[] Members) known) && known.Epoch == epoch) + { + return known.Members; + } + + List members = new(); + + if (Reads(type)) + { + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!property.CanRead || property.GetIndexParameters().Length != 0) + { + continue; + } + + ValueTransform? transform = AttributePolicyProvider.TransformOn(property); + bool leads = HoldsTransform(property.PropertyType); + + if (transform is not null || leads) + { + members.Add(new Member( + property, MutatorCache.Getter(property), MutatorCache.Setter(property), transform, leads)); + } + } + } + + Member[] read = members.ToArray(); + + MembersByType[type] = (epoch, read); + + return read; + } + + private static readonly ConcurrentDictionary MembersByType = new(); + + /// + /// True for an object whose members are an application's: its own types, the rows a dynamic + /// projection generates, and the pair a dictionary hands out. A framework object is a value here. + /// + private static bool Reads(Type type) => + !AttributePolicyProvider.IsFramework(type) + || typeof(DynamicClass).IsAssignableFrom(type) + || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>)); + + /// The objects a value holds: itself, or each element when it is a collection. + private static IEnumerable Held(object? value) + { + if (value is null or string) + { + yield break; + } + + if (value is IEnumerable collection) + { + foreach (object? element in collection) + { + if (element is not null and not string && !element.GetType().IsPrimitive) + { + yield return element; + } + } + + // An application's own collection class declares members beside its elements. + if (AttributePolicyProvider.IsFramework(value.GetType())) + { + yield break; + } + } + + yield return value; + } + + /// + /// True when a value of this type can hold, at any depth, a member that declares a transform. + /// + /// + /// Wider than the attribute walk on purpose, as the projection gate's own scan is: every property, + /// every collection layer, the arguments of a framework generic, and every loaded subtype, with + /// the types already read remembered rather than levels counted. A member that can hold an object + /// of any type, one typed or a collection that is not generic, says nothing + /// about what it holds and is not read, here or by the projection gate. Read once per type, and + /// again once another assembly has loaded. + /// + internal static bool HoldsTransform(Type type) + { + int epoch = KnownSubtypes.Epoch; + + if (HoldsByType.TryGetValue(type, out (int Epoch, bool Holds) known) && known.Epoch == epoch) + { + return known.Holds; + } + + bool holds = Scan(type); + + HoldsByType[type] = (epoch, holds); + + return holds; + } + + private static readonly ConcurrentDictionary HoldsByType = new(); + + private static bool Scan(Type root) + { + HashSet seen = new(); + Stack pending = new(); + + void Enqueue(Type candidate) + { + foreach (Type layer in AttributePolicyProvider.Layers(candidate)) + { + if (layer.IsGenericType && AttributePolicyProvider.IsFramework(layer)) + { + foreach (Type argument in layer.GetGenericArguments()) + { + Enqueue(argument); + } + } + + if (layer == typeof(string) || layer.IsPrimitive || layer.IsEnum) + { + continue; + } + + if (!AttributePolicyProvider.IsFramework(layer) && seen.Add(layer)) + { + pending.Push(layer); + } + + // An application's subclass of the type, a framework class's included. + if (!layer.IsSealed) + { + foreach (Type subtype in KnownSubtypes.Of(layer)) + { + if (seen.Add(subtype)) + { + pending.Push(subtype); + } + } + } + } + } + + Enqueue(root); + + while (pending.Count > 0) + { + foreach (PropertyInfo property in pending.Pop().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetIndexParameters().Length != 0) + { + continue; + } + + if (AttributePolicyProvider.TransformOn(property) is not null) + { + return true; + } + + Enqueue(property.PropertyType); + } + } + + return false; + } + private readonly struct Accessors { private Accessors( - Type memberType, Func get, Action? set) + string name, Type memberType, Func get, Action? set) { + Name = name; MemberType = memberType; Get = get; Set = set; } + /// The member's name as its type declares it, whatever letter case the path used. + internal string Name { get; } + /// The type the transformed value has to fit. internal Type MemberType { get; } @@ -293,10 +591,24 @@ internal static Accessors For(Type type, string segment, string path) $"{type.Name}. The value would otherwise be emitted exactly as stored."); return new Accessors( - property.PropertyType, MutatorCache.Getter(property), MutatorCache.Setter(property)); + property.Name, property.PropertyType, MutatorCache.Getter(property), MutatorCache.Setter(property)); } } + /// One member of one object: the object by reference, the name as its type declares it. + private sealed class DoneComparer : IEqualityComparer<(object Owner, string Member)> + { + internal static DoneComparer Instance { get; } = new(); + + public bool Equals((object Owner, string Member) x, (object Owner, string Member) y) => + ReferenceEquals(x.Owner, y.Owner) && string.Equals(x.Member, y.Member, StringComparison.Ordinal); + + public int GetHashCode((object Owner, string Member) pair) => + HashCode.Combine( + System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(pair.Owner), + StringComparer.Ordinal.GetHashCode(pair.Member)); + } + /// Compares by reference, so two equal-but-distinct objects are both visited. private sealed class ReferenceComparer : IEqualityComparer { diff --git a/DynamicWhere.ex/Policies/Resolution/AttributePolicyProvider.cs b/DynamicWhere.ex/Policies/Resolution/AttributePolicyProvider.cs index f1c0730..a33155f 100644 --- a/DynamicWhere.ex/Policies/Resolution/AttributePolicyProvider.cs +++ b/DynamicWhere.ex/Policies/Resolution/AttributePolicyProvider.cs @@ -103,100 +103,288 @@ private static void Walk( // back onto itself are meaningless. bool reflected = ancestors.Contains(type); - // Added once for this frame rather than per navigation, and removed only when this frame is - // the one that added it — otherwise a diamond (two properties of the same type) would clear - // a marker an outer frame is still standing on. - bool marked = ancestors.Add(type); + // Before the type is marked. A frame that returns here reads nothing, and one that had marked + // the type first left it marked for the rest of the walk: a type first met at the depth limit + // then read as a cycle wherever it was met again, and its forced scope, its required filter + // and its alias were dropped from a path that reaches it directly. Which of two members was + // declared first decided whether a tenant scope applied. if (depth >= MaxDepth) { return; } + // Added once for this frame rather than per navigation, and removed only when this frame is + // the one that added it — otherwise a diamond (two properties of the same type) would clear + // a marker an outer frame is still standing on. + bool marked = ancestors.Add(type); + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { string path = prefix.Length == 0 ? property.Name : $"{prefix}.{property.Name}"; - foreach (DwDenyAttribute attribute in property.GetCustomAttributes(inherit: true)) + Emit(type, property, path, reflected, fragments); + + Type? navigation = NavigationTypeOf(property.PropertyType); + + if (navigation is not null) { - fragments.Add(ToFragment(path, attribute)); + Walk(navigation, path, depth + 1, ancestors, fragments); } + } + + if (marked) + { + ancestors.Remove(type); + } + } - foreach (DwDenyAttribute attribute in DenialsElsewhere(type, property)) + /// + /// Adds the fragments one member's attributes declare, at the path that reaches it. + /// + /// The type the member was read from. + /// The member. + /// The dotted path that reaches it from the entity being queried. + /// + /// True when the declarations about the queried entity itself, an alias, a required filter and a + /// forced scope, are left out: around a cycle, and on a path the walk does not reach. + /// + /// The accumulator. + private static void Emit( + Type type, PropertyInfo property, string path, bool reflected, List fragments) + { + + foreach (DwDenyAttribute attribute in property.GetCustomAttributes(inherit: true)) + { + fragments.Add(ToFragment(path, attribute)); + } + + foreach (DwDenyAttribute attribute in DenialsElsewhere(type, property)) + { + fragments.Add(ToFragment(path, attribute)); + } + + foreach (DwOperatorsAttribute attribute in property.GetCustomAttributes(inherit: true)) + { + fragments.Add(ToFragment(path, attribute)); + } + + // An alias is a public *name*. Emitting "code" for StaffCode, Manager.StaffCode and + // Reports.StaffCode alike makes one declaration match many paths, and the sanitizer + // refuses the name as AmbiguousFieldName — so decorating a member of a self-referencing + // type made the alias unusable rather than convenient. + DwAliasAttribute? alias = reflected + ? null + : property.GetCustomAttribute(inherit: true); + + if (alias is not null) + { + fragments.Add(ToFragment(path, alias)); + } + + // A demand on the caller. "You must filter on Division" is a sentence about the + // query's subject; "you must also filter on Manager.Division" is not one anyone meant, + // and once the depth cap has produced Manager.Division, Reports.Division and + // Manager.Reports.Division, no caller can satisfy every copy. + DwRequireWhereAttribute? required = reflected + ? null + : property.GetCustomAttribute(inherit: true); + + if (required is not null) + { + fragments.Add(ToFragment(path, required)); + } + + // A row-level scope on the entity being queried. This is the one that did not fail + // loudly: "only active employees" replicated into "and whose manager is active, and + // whose manager's manager is active" — a conjunction almost no row satisfies — so a + // perfectly good query came back EMPTY rather than refused. Fewer rows is technically + // fail-closed, which is why nothing caught it; silently returning nothing is still the + // worst way to be wrong. + if (!reflected) + { + foreach (DwForceWhereAttribute attribute in property.GetCustomAttributes(inherit: true)) { - fragments.Add(ToFragment(path, attribute)); + fragments.Add(ToFragment(path, property, attribute)); } + } + + foreach (TransformStage stage in TransformStagesOn(property)) + { + fragments.Add(ToFragment(path, stage, StageAttributeOn(property, stage.Kind))); + } + + // One fragment per attribute rather than one merged fragment for the member, because + // each attribute carries its own Overridable flag: [DwCost(10)] can be sealed while the + // [DwDescribe] beside it is replaceable, and merging them would force one ceiling on + // both. + foreach (PolicyFragment fragment in FactFragmentsOn(path, property)) + { + fragments.Add(fragment); + } + } + + /// + /// The member whose policy decides a path that continues beneath it, or null when the path is one + /// the walk names or names nothing. + /// + /// The entity being queried. + /// A normalized dotted path. + /// + /// The walk descends into an application's own types and nowhere else, so no attribute can be + /// placed beneath a member the framework declares the type of, and no fragment names such a path: + /// Salary.Value and Salary.HasValue on a decimal?, Secret.Length on a + /// , Born.Year on a , Bag.Count on a + /// dictionary, or Lines.Count on an application's own collection class, where the member + /// named is the collection's and not the element's. The pipeline validates each of them, the + /// provider translates them, and each reads the member above it. Resolved on its own such a path + /// matched nothing and was allowed, so a denied, masked, audited or weighted member was one + /// segment away from having no policy at all. It takes the policy of the member it reads. + /// + internal static string? Governing(Type entityType, string path) + { + string[] segments = path.Split('.'); + Type type = entityType; - foreach (DwOperatorsAttribute attribute in property.GetCustomAttributes(inherit: true)) + for (int i = 0; i < segments.Length - 1; i++) + { + PropertyInfo? property = Find(type, segments[i]); + + if (property is null) { - fragments.Add(ToFragment(path, attribute)); + return null; } - // An alias is a public *name*. Emitting "code" for StaffCode, Manager.StaffCode and - // Reports.StaffCode alike makes one declaration match many paths, and the sanitizer - // refuses the name as AmbiguousFieldName — so decorating a member of a self-referencing - // type made the alias unusable rather than convenient. - DwAliasAttribute? alias = reflected - ? null - : property.GetCustomAttribute(inherit: true); + Type? navigation = NavigationTypeOf(property.PropertyType); - if (alias is not null) + if (navigation is null) { - fragments.Add(ToFragment(path, alias)); + return string.Join('.', segments, 0, i + 1); } - // A demand on the caller. "You must filter on Division" is a sentence about the - // query's subject; "you must also filter on Manager.Division" is not one anyone meant, - // and once the depth cap has produced Manager.Division, Reports.Division and - // Manager.Reports.Division, no caller can satisfy every copy. - DwRequireWhereAttribute? required = reflected - ? null - : property.GetCustomAttribute(inherit: true); - - if (required is not null) + if (Find(navigation, segments[i + 1]) is null) { - fragments.Add(ToFragment(path, required)); + // Not the element's member. The collection's own, Lines.Count on an application's + // collection class, reads the member above it. Anything else is a subtype's member or + // names nothing, and is decided as it always was: by the fragments naming it, so that a + // grant of Zone does not grant what a subtype of Zone declares. + Type container = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + + return container != navigation && Find(container, segments[i + 1]) is not null + ? string.Join('.', segments, 0, i + 1) + : null; } - // A row-level scope on the entity being queried. This is the one that did not fail - // loudly: "only active employees" replicated into "and whose manager is active, and - // whose manager's manager is active" — a conjunction almost no row satisfies — so a - // perfectly good query came back EMPTY rather than refused. Fewer rows is technically - // fail-closed, which is why nothing caught it; silently returning nothing is still the - // worst way to be wrong. - if (!reflected) + type = navigation; + } + + return null; + } + + /// + /// The fragments a member's attributes declare on a path longer than the walk goes, or none when + /// the path is one the walk names or names nothing. + /// + /// The entity being queried. + /// A normalized dotted path. + /// + /// The walk stops at segments because a connected model fans out at every + /// level, and the navigation-depth cap defaults to the same number. A host that raises the cap lets + /// a request name a longer path, which no fragment of the walk reaches, and a denial declared on the + /// member at its end did not apply. One path is cheap where every path is not, so the member is read + /// directly. What is declared about the queried entity itself is left out, as it is around a cycle. + /// + internal static IReadOnlyList Unwalked(Type entityType, string path) + { + string[] segments = path.Split('.'); + + if (segments.Length <= MaxDepth) + { + return Array.Empty(); + } + + Type type = entityType; + + for (int i = 0; i < segments.Length; i++) + { + PropertyInfo? property = Find(type, segments[i]); + + if (property is null) { - foreach (DwForceWhereAttribute attribute in property.GetCustomAttributes(inherit: true)) - { - fragments.Add(ToFragment(path, property, attribute)); - } + return Array.Empty(); } - foreach (TransformStage stage in TransformStagesOn(property)) + if (i == segments.Length - 1) { - fragments.Add(ToFragment(path, stage, StageAttributeOn(property, stage.Kind))); + List fragments = new(); + + Emit(type, property, path, reflected: true, fragments); + + return fragments; } - // One fragment per attribute rather than one merged fragment for the member, because - // each attribute carries its own Overridable flag: [DwCost(10)] can be sealed while the - // [DwDescribe] beside it is replaceable, and merging them would force one ceiling on - // both. - foreach (PolicyFragment fragment in FactFragmentsOn(path, property)) + if (NavigationTypeOf(property.PropertyType) is not { } navigation) { - fragments.Add(fragment); + return Array.Empty(); } - Type? navigation = NavigationTypeOf(property.PropertyType); + type = navigation; + } - if (navigation is not null) + return Array.Empty(); + } + + /// + /// The chain a member's own attributes declare, or null when it declares none. + /// + /// + /// For a member no path of the walk reaches: one a subtype declares, one past the walk's depth, one + /// on an object a framework collection holds. No fragment names it, so no election is held over + /// it and no rule can speak to it; its attributes are all there is, and a member carries at most + /// one of each kind. Read once per member. + /// + internal static ValueTransform? TransformOn(PropertyInfo property) => + TransformsByMember.GetOrAdd(property, static member => + { + MutateStage? mutate = null; + GeneralizeStage? generalize = null; + FormatStage? format = null; + MaskStage? mask = null; + TruncateStage? truncate = null; + DefaultStage? replacement = null; + + foreach (TransformStage stage in TransformStagesOn(member)) { - Walk(navigation, path, depth + 1, ancestors, fragments); + switch (stage) + { + case MutateStage found: mutate = found; break; + case GeneralizeStage found: generalize = found; break; + case FormatStage found: format = found; break; + case MaskStage found: mask = found; break; + case TruncateStage found: truncate = found; break; + case DefaultStage found: replacement = found; break; + } } - } - if (marked) + ValueTransform chain = new(mutate, generalize, format, mask, truncate, replacement); + + return chain.IsEmpty ? null : chain; + }); + + private static readonly ConcurrentDictionary TransformsByMember = new(); + + /// A public instance property by name, whatever its letter case, as a path names one. + internal static PropertyInfo? Find(Type type, string name) + { + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) { - ancestors.Remove(type); + if (property.GetIndexParameters().Length == 0 + && string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + { + return property; + } } + + return null; } /// diff --git a/DynamicWhere.ex/Policies/Resolution/PolicyResolver.cs b/DynamicWhere.ex/Policies/Resolution/PolicyResolver.cs index c5be3b3..42cf616 100644 --- a/DynamicWhere.ex/Policies/Resolution/PolicyResolver.cs +++ b/DynamicWhere.ex/Policies/Resolution/PolicyResolver.cs @@ -105,15 +105,8 @@ public FieldPolicy Resolve(Type entityType, string fieldPath, DwPolicyContext co "A policy lookup requires a field path with at least one segment.", nameof(fieldPath)); } - List candidates = new(); - - foreach (PolicyFragment fragment in Sweep(entityType, context)) - { - if (fragment.Matches(path)) - { - candidates.Add(fragment); - } - } + (List candidates, List named, bool unwalked) = + Candidates(entityType, path, context); Dictionary effects = new(); List sources = new(); @@ -156,17 +149,125 @@ public FieldPolicy Resolve(Type entityType, string fieldPath, DwPolicyContext co effects[PolicyFeature.Aggregate] = PolicyEffect.Deny; } + // A transform is applied to the member it was declared on, along a path the walk names. On a + // path the walk does not name there is nothing to apply it to: Bonus.Value is a decimal where + // Bonus is the member that is rounded, and a masked member five segments down is in no list + // the outbound walk reads. Every way such a path hands a value back is refused instead, since + // the value it would hand back is the stored one. Filtering and ordering run on stored values + // wherever the member is reached from, so they follow the member's own decision. + if (unwalked && transform is not null && !transform.IsEmpty) + { + effects[PolicyFeature.Select] = PolicyEffect.Deny; + effects[PolicyFeature.Group] = PolicyEffect.Deny; + effects[PolicyFeature.Aggregate] = PolicyEffect.Deny; + } + + // What is said about the member decides every path that reads it. What is said to the caller + // about the member, its public name, the filter it demands, the scope it forces and how it is + // described, is about that member alone. return new FieldPolicy( path, effects, sources, isSealed, IntersectOperators(candidates), - ElectAlias(candidates), - CollectForced(candidates), - ElectRequired(candidates), - transform, - ElectFacts(candidates)); + ElectAlias(named), + CollectForced(named), + ElectRequired(named), + unwalked ? ElectTransform(named) : transform, + unwalked ? Carried(ElectFacts(named), candidates) : ElectFacts(candidates)); + } + + /// + /// The fragments that decide one path: those naming it, and, where the walk that produces them + /// cannot name it, those of the member it reads. + /// + /// The entity being queried. + /// The normalized path. + /// The caller. + /// + /// Every fragment that decides the path; the ones naming it exactly, a "*" rule included; + /// and whether any of the rest were added. + /// + /// + /// A fragment that does not match is a field left allowed, and two kinds of path matched nothing + /// though a member above them was denied. One continues beneath a member whose type the framework + /// declares, Salary.Value or Secret.Length, where no attribute can be placed: every + /// fragment of that member applies, whichever provider supplied it, so a store's denial of + /// Salary covers Salary.Value as an attribute's does. The other is longer than the + /// attribute walk goes, which a request can only name once a host raises the navigation-depth cap: + /// the attributes of the member at its end are read directly. Both are asked only of a resolver + /// that reads attributes at all. + /// + private (List All, List Named, bool Unwalked) Candidates( + Type entityType, string path, DwPolicyContext context) + { + string? governing = AttributePolicyProvider.Governing(entityType, path); + + List all = new(); + List named = new(); + + foreach (PolicyFragment fragment in Sweep(entityType, context)) + { + if (fragment.Matches(path)) + { + all.Add(fragment); + named.Add(fragment); + } + else if (governing is not null && fragment.Matches(governing)) + { + all.Add(fragment); + } + } + + if (ReadsAttributes) + { + // Of the member the path reads, where there is one: B.C.D.E.Born.Year is decided by Born. + all.AddRange(AttributePolicyProvider.Unwalked(entityType, governing ?? path)); + } + + return (all, named, all.Count != named.Count); + } + + /// True when attributes are among the sources this resolver was built over. + internal bool ReadsAttributes + { + get + { + for (int i = 0; i < _providers.Count; i++) + { + if (_providers[i] is AttributePolicyProvider) + { + return true; + } + } + + return false; + } + } + + /// + /// The facts of a path the walk cannot name: described as the fragments naming it describe it, + /// weighed and audited as the member it reads is. + /// + /// + /// A weight and an audit are about the stored value, which the path reads. Left behind, + /// Notes.Length cost one where Notes cost fifty, and Salary.Value was read with + /// nothing written down where Salary is audited. A label, a group and a list of allowed + /// values describe the member to somebody building a query, and describe nothing beneath it. + /// + private static FieldFacts? Carried(FieldFacts? named, IReadOnlyList candidates) + { + int? cost = ElectCost(candidates); + PolicyFeature? audited = ElectAudited(candidates); + + if (named is null && cost is null && audited is null) + { + return null; + } + + return new FieldFacts( + named?.Label, named?.Description, named?.Group, named?.Order, named?.AllowedValues, cost, audited); } /// @@ -197,15 +298,9 @@ public PolicyExplanation Explain(Type entityType, string fieldPath, DwPolicyCont string path = PolicyFragment.NormalizePath(fieldPath); - List candidates = new(); - - foreach (PolicyFragment fragment in Sweep(entityType, context)) - { - if (fragment.Matches(path)) - { - candidates.Add(fragment); - } - } + // The same fragments Resolve decided from, so the explanation names the denial a path beneath + // a member inherits rather than reporting a refusal with no source. + List candidates = Candidates(entityType, path, context).All; List features = new(Features.Length); diff --git a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs index 0391097..d2b6524 100644 --- a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs +++ b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs @@ -114,7 +114,8 @@ public FilterResult ToList(Filter filter, bool getQueryString = false) FilterResult result = Guarded().ToList(sanitized, getQueryString); ResultTransformer.Rows( - result.Data, TypePolicy, sanitized.Selects, _context, _options, trace); + result.Data, typeof(T), TypePolicy, sanitized.Selects, _context, _options, trace, + _resolver.ReadsAttributes); result.Policy = _options.TraceInResult ? trace : null; @@ -166,7 +167,8 @@ public async Task> ToListAsync( FilterResult result = await Guarded().ToListAsync(sanitized, getQueryString, cancellationToken); ResultTransformer.Rows( - result.Data, TypePolicy, sanitized.Selects, _context, _options, trace); + result.Data, typeof(T), TypePolicy, sanitized.Selects, _context, _options, trace, + _resolver.ReadsAttributes); result.Policy = _options.TraceInResult ? trace : null; @@ -198,7 +200,8 @@ public FilterResult ToListDynamic(Filter filter, bool getQueryString = FilterResult result = Guarded().ToListDynamic(sanitized, getQueryString); ResultTransformer.Rows( - result.Data, TypePolicy, sanitized.Selects, _context, _options, trace); + result.Data, typeof(T), TypePolicy, sanitized.Selects, _context, _options, trace, + _resolver.ReadsAttributes); // After transformation, not before: the transform pipeline reads the generated columns // by the names the projection baked in, and renaming first would leave it looking for @@ -255,7 +258,8 @@ public async Task> ToListAsyncDynamic( FilterResult result = await Guarded().ToListAsyncDynamic(sanitized, getQueryString, cancellationToken); ResultTransformer.Rows( - result.Data, TypePolicy, sanitized.Selects, _context, _options, trace); + result.Data, typeof(T), TypePolicy, sanitized.Selects, _context, _options, trace, + _resolver.ReadsAttributes); // After transformation, not before: the transform pipeline reads the generated columns // by the names the projection baked in, and renaming first would leave it looking for @@ -428,7 +432,8 @@ public async Task> ToListAsync(Segment segment, CancellationTok SegmentResult result = await Guarded().ToListAsync(sanitized, cancellationToken); ResultTransformer.Rows( - result.Data, TypePolicy, sanitized.Selects, _context, _options, trace); + result.Data, typeof(T), TypePolicy, sanitized.Selects, _context, _options, trace, + _resolver.ReadsAttributes); result.Policy = _options.TraceInResult ? trace : null; diff --git a/DynamicWhere.ex/Policies/Source/ResultTransformer.cs b/DynamicWhere.ex/Policies/Source/ResultTransformer.cs index b258517..5c9c2b3 100644 --- a/DynamicWhere.ex/Policies/Source/ResultTransformer.cs +++ b/DynamicWhere.ex/Policies/Source/ResultTransformer.cs @@ -26,14 +26,24 @@ namespace DynamicWhere.ex.Policies.Source; internal static class ResultTransformer { /// Transforms the rows of an entity or projection result. + /// The materialized rows. + /// The type the query was written over, whose paths the policy names. + /// The type's policy. + /// The projection the rows were read through, or null for whole rows. + /// The caller. + /// The enforcement posture. + /// Collects what was transformed. + /// False when the resolver in force reads no attributes at all. internal static void Rows( IEnumerable rows, + Type entityType, TypePolicy policy, IReadOnlyCollection? projected, DwPolicyContext context, DwPolicyOptions options, - PolicyTrace trace) => - GraphWalker.Apply(rows, policy, projected, context, options, trace); + PolicyTrace trace, + bool attributes) => + GraphWalker.Apply(rows, entityType, policy, projected, context, options, trace, attributes); /// /// Rewrites the column names of generated rows into the caller's own vocabulary. From 8743f65d7fd5bb9c1069d1755b85cad35658755e Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Mon, 21 Sep 2026 04:05:14 +0300 Subject: [PATCH 38/54] fix(aspnetcore): a caller who disconnects does not cancel the record of what they read The audit middleware drained the request's events with the request's own abort token. A client that closed the connection, as the rows arrived or the moment they had, cancelled the write that follows the response: the sink threw, the middleware logged it, and the events went with the context. An audited read with nothing written down, for the price of a socket. The drain has a budget of its own now, thirty seconds, which the caller cannot cancel and a hung sink cannot outlast. The new test fails on the old code. Co-Authored-By: Claude Fable 5.1 --- .../Policies/PolicyAuditMiddlewareTests.cs | 40 +++++++++++++++++++ .../DwPolicyAuditMiddleware.cs | 12 +++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/DynamicWhere.Tests/Policies/PolicyAuditMiddlewareTests.cs b/DynamicWhere.Tests/Policies/PolicyAuditMiddlewareTests.cs index b595b60..22a0fe2 100644 --- a/DynamicWhere.Tests/Policies/PolicyAuditMiddlewareTests.cs +++ b/DynamicWhere.Tests/Policies/PolicyAuditMiddlewareTests.cs @@ -137,6 +137,46 @@ public async Task What_the_request_recorded_reaches_the_sink() Assert.Equal(new[] { "Salary", "NationalId" }, sink.Written.Select(e => e.FieldPath)); } + /// A sink that stops when it is told to, as one writing through a database does. + private sealed class Obedient : IDwAuditSink + { + internal List Written { get; } = new(); + + public ValueTask WriteAsync(DwAuditEvent auditEvent, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + Written.Add(auditEvent); + + return default; + } + } + + /// + /// A caller who hangs up does not take the record of what they read with them. + /// + /// + /// The drain was handed the request's own abort token. A client that closed the connection, once + /// the rows had started to arrive or the moment they had, cancelled the write that follows the + /// response: the sink threw, the middleware logged it, and the events went with the context. An + /// audited read with nothing written down, at the price of closing a socket. + /// + [Fact] + public async Task A_caller_who_disconnects_does_not_cancel_the_record_of_what_they_read() + { + Obedient sink = new(); + Recording log = new(); + + HttpContext http = Request(Services(sink), Event(), Event("NationalId")); + + http.RequestAborted = new CancellationToken(canceled: true); + + await new DwPolicyAuditMiddleware(_ => Task.CompletedTask, log).InvokeAsync(http); + + Assert.Equal(new[] { "Salary", "NationalId" }, sink.Written.Select(e => e.FieldPath)); + Assert.Empty(log.Entries); + } + /// /// A request that threw still writes what it did before it threw. /// diff --git a/DynamicWhere.ex.Policies.AspNetCore/DwPolicyAuditMiddleware.cs b/DynamicWhere.ex.Policies.AspNetCore/DwPolicyAuditMiddleware.cs index c4537a1..05f8a3f 100644 --- a/DynamicWhere.ex.Policies.AspNetCore/DwPolicyAuditMiddleware.cs +++ b/DynamicWhere.ex.Policies.AspNetCore/DwPolicyAuditMiddleware.cs @@ -25,6 +25,9 @@ namespace DynamicWhere.ex.Policies.AspNetCore; /// public sealed class DwPolicyAuditMiddleware { + /// How long a sink is given to write what one request recorded. + internal static readonly TimeSpan DrainBudget = TimeSpan.FromSeconds(30); + private readonly RequestDelegate _next; private readonly ILogger? _log; @@ -87,9 +90,16 @@ private async ValueTask DrainAsync(HttpContext http) return; } + // Not the request's abort token. What was read has been read whether or not the caller is + // still there, and a caller who closed the connection as the rows arrived would otherwise + // cancel the write that records them: an audited read with nothing written down, for the + // price of a socket. The budget is the sink's alone, so one that hangs cannot hold the + // request open for good. + using CancellationTokenSource budget = new(DrainBudget); + try { - await DwPolicy.DrainAuditAsync(context, sink, http.RequestAborted).ConfigureAwait(false); + await DwPolicy.DrainAuditAsync(context, sink, budget.Token).ConfigureAwait(false); } catch (Exception failure) { From 7ee49167a388212c08dd73f54b63043d500eb2ce Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Mon, 21 Sep 2026 04:05:14 +0300 Subject: [PATCH 39/54] fix(redis): writers moving one rule leave one copy of it Where a rule lives is read before the transaction that moves or deletes it, so two writers of one rule could read the same answer. The slower one then cleaned up after a copy the faster had already moved and left that writer's copy behind, with no owner entry pointing at it: a rule still applying to a caller nobody any longer wrote it for, which no later write or delete could find. The transaction is conditional on the owner entry now. A writer that loses the race is told nothing was stored, as a failed commit always was, and writes again. Eight writers moving one rule four hundred times each leave one copy under the owner; without the condition the same test fails every run. Co-Authored-By: Claude Fable 5.1 --- .../Policies/RedisStoreConformanceTests.cs | 57 +++++++++++++++++++ .../RedisPolicyStore.cs | 22 ++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/DynamicWhere.Tests/Policies/RedisStoreConformanceTests.cs b/DynamicWhere.Tests/Policies/RedisStoreConformanceTests.cs index 85fac6a..2e07ece 100644 --- a/DynamicWhere.Tests/Policies/RedisStoreConformanceTests.cs +++ b/DynamicWhere.Tests/Policies/RedisStoreConformanceTests.cs @@ -207,6 +207,63 @@ await Assert.ThrowsAnyAsync( async () => await store.LoadAsync(default)); } + /// + /// Writers moving one rule between callers leave one copy of it, under the caller the owner entry + /// names. + /// + /// + /// Where a rule lives is read before the transaction that moves it. Two writers could read the + /// same answer, and the slower one then cleaned up after a copy the faster had already moved, + /// leaving that writer's copy behind with no owner entry pointing at it: a rule still applying to + /// a caller nobody any longer wrote it for. The commit is conditional on the owner entry now, and + /// a writer that loses is told nothing was stored. + /// + [Fact] + public async Task Writers_moving_one_rule_leave_one_copy_of_it() + { + string prefix = NextPrefix(); + Guid id = Guid.NewGuid(); + string[] callers = { "ann", "bob", "cyd" }; + + async Task Move(int seed) + { + RedisPolicyStore store = new(_redis!, prefix); + + for (int i = 0; i < 400; i++) + { + PolicyRule rule = new( + DwSubjectKind.User, callers[(i + seed) % callers.Length], StaffType, "Department", + PolicyFeature.Select, PolicyEffect.Deny, id: id); + + try + { + await store.UpsertAsync(rule, default); + } + catch (InvalidOperationException) + { + // Lost the race, and said so. Nothing was stored; the next round writes again. + } + } + } + + await Task.WhenAll(Enumerable.Range(0, 8).Select(seed => Task.Run(() => Move(seed)))); + + IDatabase db = _redis!.GetDatabase(); + string owner = (await db.HashGetAsync($"{prefix}:owner", id.ToString())).ToString(); + + List holders = new(); + + foreach (string caller in callers) + { + if (await db.HashExistsAsync($"{prefix}:user:{caller}", id.ToString())) + { + holders.Add($"{prefix}:user:{caller}"); + } + } + + Assert.Equal(new[] { owner }, holders); + } + /// A prefix no other store in this class is using. private string NextPrefix() => $"test:{Interlocked.Increment(ref _prefixes)}"; } diff --git a/DynamicWhere.ex.Policies.Redis/RedisPolicyStore.cs b/DynamicWhere.ex.Policies.Redis/RedisPolicyStore.cs index 0c2a244..5496507 100644 --- a/DynamicWhere.ex.Policies.Redis/RedisPolicyStore.cs +++ b/DynamicWhere.ex.Policies.Redis/RedisPolicyStore.cs @@ -168,6 +168,8 @@ public async ValueTask UpsertAsync(PolicyRule rule, CancellationToke ITransaction write = db.CreateTransaction(); + Unmoved(write, field, previous); + List queued = new(); if (previous.HasValue && previous != target) @@ -209,6 +211,8 @@ public async ValueTask DeleteAsync(Guid id, CancellationToken ct) ITransaction write = db.CreateTransaction(); + Unmoved(write, field, owner); + List queued = new(); if (owner.HasValue) @@ -240,13 +244,29 @@ public async ValueTask DeleteAsync(Guid id, CancellationToken ct) /// dropping them turns a failed write into an unobserved exception and a silent success. /// /// + /// + /// Commits only if the rule still lives where it was just read to live. + /// + /// + /// Where a rule lives is read before the transaction that moves or deletes it, and two writers of + /// one rule could both read the same answer. The second then cleaned up after a copy the first had + /// already moved, and left the first's copy behind: a rule applying to a caller nobody wrote it + /// for, with no owner entry left to find it by. The commit is conditional on the owner entry, so + /// the writer that lost the race is told nothing was stored, and writes again. + /// + private void Unmoved(ITransaction write, RedisValue field, RedisValue owner) => + write.AddCondition(owner.HasValue + ? Condition.HashEqual(_keys.Owner, field, owner) + : Condition.HashNotExists(_keys.Owner, field)); + private static async Task CommitAsync(ITransaction write, List queued) { if (!await write.ExecuteAsync().ConfigureAwait(false)) { throw new InvalidOperationException( "The Redis transaction holding this policy write was not committed, so nothing was " + - "stored. Reporting success here would record a rule that is not there."); + "stored. Reporting success here would record a rule that is not there. Another writer " + + "moved or removed the same rule in the meantime; write it again."); } await Task.WhenAll(queued).ConfigureAwait(false); From f8b937665d22f653bd9fb942f7c94462804c2563 Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Mon, 21 Sep 2026 04:05:14 +0300 Subject: [PATCH 40/54] build: name the patched Microsoft.Extensions.Caching.Memory EF Core 6.0.22 asks for 6.0.1 or later, and 6.0.1 is the last version open to CVE-2024-43483 (GHSA-qj66-m88j-hmgj), so a host on the EF Core 6 floor resolved a vulnerable version through all four packages. 6.0.2 is the patched one. A host on EF Core 8 or later already resolves a newer version and sees no change. `dotnet list package --vulnerable --include-transitive` is clean for the four shipping projects. Co-Authored-By: Claude Fable 5.1 --- DynamicWhere.ex/DynamicWhere.ex.csproj | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index b5717f6..474231f 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -10,6 +10,13 @@ + + + 3.3.0 - v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. No API or behaviour change in this package. + v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. No API change in this package, and one fix. RedisPolicyStore.UpsertAsync and DeleteAsync commit conditionally on the rule's owner entry. Where a rule lives is read before the transaction that moves or deletes it, so two writers of one rule could read the same answer: the slower one then cleaned up after a copy the faster had already moved and left that writer's copy behind, under a user nobody any longer wrote it for and with no owner entry pointing at it, which no later write or delete could find. The writer that loses the race now gets the InvalidOperationException a failed commit always raised, whose message says another writer moved or removed the same rule in the meantime and to write it again, and should retry. v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index fc17808..ccb1c0e 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -39,10 +39,13 @@ dotnet add package DynamicWhere.ex --version 3.3.0 |---------|---------| | `Microsoft.EntityFrameworkCore` | 6.0.22 | | `System.Linq.Dynamic.Core` | 1.6.7 | +| `Microsoft.Extensions.Caching.Memory` | 6.0.2 | | `Microsoft.Extensions.Configuration.Abstractions` | 6.0.0 | | `Microsoft.Extensions.Configuration.Binder` | 6.0.0 | | `Microsoft.Extensions.DependencyInjection.Abstractions` | 6.0.0 | +`Microsoft.Extensions.Caching.Memory` is named for its patched version (3.3.0) and is not used by the library directly: EF Core 6.0.22 asks for 6.0.1 or later, and 6.0.1 is the last version open to CVE-2024-43483 (GHSA-qj66-m88j-hmgj), so a host on the EF Core 6 floor resolved a vulnerable version through all four packages. Naming 6.0.2 raises that floor for all four; a host on EF Core 8 or later already resolves a newer one and sees no change. + The library parses every expression it builds with its own `ParsingConfig` — the parser's defaults with `AreContextKeywordsEnabled = false` — and does not read `ParsingConfig.Default`. See breaking point 15. **Companion packages** (optional, only for [field-level policies](#field-level-policies)): @@ -599,6 +602,8 @@ Paginates the query. - `PageNumber` must be > 0. - `PageSize` must be > 0. +The offset, `(PageNumber - 1) * PageSize`, is worked out in 64 bits and held to `int.MaxValue` (3.3.0), here and in the three summary methods. In 32 bits the product wrapped for a large enough page number: a negative offset is an error on SQL Server and PostgreSQL, so the request became a five-hundred, and the first page again on SQLite and in memory, so a page far past the last row returned rows. A page past the last row is an empty page however far past it is. The core sets no upper bound on either value; the policy layer caps `PageSize` through `MaxPageSize` and never `PageNumber`. + **Returns:** `IQueryable` — paged query. --- @@ -1581,6 +1586,13 @@ var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter, cancella All six transform attributes also carry `AllowAggregate` and `MinGroupSize`. Every attribute except `[DwEntity]` derives from `DwPolicyAttribute` and so carries `Overridable`, which defaults to **false**. It decides nothing on `[DwOperators]`, whose lists are intersected, or on `[DwForceWhere]`, whose predicates are collected — no rule can widen either, with or without the flag. +Only public instance properties are read, through navigations and collection elements, up to paths of four segments. Two kinds of path lie outside that walk, and since 3.3.0 both are policed: + +- **Beneath a member whose type the framework declares.** `Salary.Value` and `Salary.HasValue` on a `decimal?`, `Secret.Length` on a `string`, `Born.Year` on a `DateTime`, `Bag.Count` on a dictionary, `Lines.Count` on an application's own collection class — the collection's own member, not an element's. No attribute can be placed there, so such a path takes every fragment of the member it reads, whichever provider supplied it: the deny effects per feature, the `[DwOperators]` restriction (intersected), the `[DwCost]` weight and the audited features. Not what is said to the caller about the member — the alias, the required filter (a filter on `TenantId.Value` does not satisfy a `[DwRequireWhere]` on `TenantId`), the forced scope, and the descriptive facts. A rule naming the sub-path itself still applies alongside. One feature is one feature: `[DwNoWhere] Born` refuses `WHERE Born.Year` and still allows `GROUP BY Born.Year`, and a member nothing denies is read beneath as before, so `Name.Length` still runs. Where the member is transformed there is no member beneath it to apply the chain to, so `Select`, `Group` and `Aggregate` on the path are refused. A member only a **subtype** of the navigated type declares is not such a path: it is decided by the fragments naming it, so a grant of `Zone` under a `"*"` deny does not grant what a subtype of Zone's type declares. A navigation into an application's own type is still a separate field, because its members can be decorated: a denial on `Contact` leaves `Contact.Email` open. +- **Past the walk's depth.** `Caps.MaxNavigationDepth` defaults to 4, the depth the walk reads to, and a host may raise it; a request naming five or more segments then reached what no fragment covered. The attributes of the member at the end of such a path are read directly now — the deny family, `[DwOperators]`, the transform stages, `[DwCost]`, `[DwAudit]`, `[DwDescribe]` and allowed values — by any resolver that reads attributes, which every resolver `DwPolicy.Configure` builds does. What is declared about the queried entity itself is left out there, as it is around a cycle: `[DwAlias]`, `[DwRequireWhere]`, `[DwForceWhere]`. A transformed member there is still a member, so `Selects` naming it returns it transformed; only a grouping key and an aggregated field are refused, because a summary's own transform finds a generated row's columns by the type's list, which stops at four segments. + +`[DwForceWhere]`, `[DwRequireWhere]` and `[DwAlias]` are left out around a cycle, on a type reached from itself, and apply on every other path within four segments. The walk used to return at its depth limit with the type still marked as being inside it, so a type first met at the fourth segment read as a cycle wherever it was met again in the same walk and the three were dropped from a shorter path reaching it directly — which of two members was declared first decided whether a tenant scope applied. Fixed in 3.3.0, so a query that ran unscoped is scoped and a required filter may now be demanded. + ### Precedence Six levels, lowest number wins: @@ -1832,7 +1844,9 @@ The trace names the fields a policy dropped, the attribute or rule that sealed e ### Auditing -`[DwAudit(features)]` records every use of a field, whatever the policy decided, as a `DwAuditEvent` in the caller's context (`DwPolicyContext.PendingAuditEvents`). A use is a field the request names, or, since 3.1.0, a field of the type's [default order](#default-order) that the query orders by: audited for `Order`, it is recorded each time, as a caller's own order is. A default field left out for this caller is not recorded, because the query does not order by it and the caller never named it. Nothing is stored until the buffer is drained to an `IDwAuditSink`, by `DwPolicy.DrainAuditAsync(context, sink)` or, per request, by the ASP.NET Core middleware `app.UseDwPolicyAudit()`. A use is what the request reads, not only what it spells out. A request that sends no `Selects` receives the row, so every audited member the query hands back is recorded for `Select` (3.3.0) — one event per query, not per row, and only for a field `[DwAudit]` names. What it hands back is read strictly: a member kept whole records the audited paths inside it, a navigation nothing loads records nothing because the caller receives null for it, and a value the source does not carry records nothing either. A dry run applies no projection, so everything the row carries is recorded there, a denied member included, with the effect the policy decided. Until 3.3.0 only a field the request wrote down was recorded, which left an empty `Selects` as one token past the control: the same value, returned, with nothing written down. A buffer already holding `DwCaps.MaxAuditEvents` refuses the next audited use with `CapExceeded` — except under `Strict` outside a dry run, where it refuses with the clause's own field refusal instead (3.3.0). An unknown name is never audited and never reaches the cap, so answering with the cap's own code there would have told a caller that the name they guessed is a real field and an audited one. +`[DwAudit(features)]` records every use of a field, whatever the policy decided, as a `DwAuditEvent` in the caller's context (`DwPolicyContext.PendingAuditEvents`). A use is a field the request names, or, since 3.1.0, a field of the type's [default order](#default-order) that the query orders by: audited for `Order`, it is recorded each time, as a caller's own order is. A default field left out for this caller is not recorded, because the query does not order by it and the caller never named it. Nothing is stored until the buffer is drained to an `IDwAuditSink`, by `DwPolicy.DrainAuditAsync(context, sink)` or, per request, by the ASP.NET Core middleware `app.UseDwPolicyAudit()`. That middleware does not drain with the request's abort token: it has a budget of its own, thirty seconds, which the caller cannot cancel and a hung sink cannot outlast (3.3.0). Until then a client that closed the connection, as the rows arrived or the moment they had, cancelled the write that follows the response — the sink threw, the middleware logged it, and the events went with the context, an audited read with nothing written down for the price of a socket. A path beneath a member whose type the framework declares is audited as that member is (3.3.0): reading `Salary.Value` records what reading `Salary` records, where it used to record nothing. A use is what the request reads, not only what it spells out. A request that sends no `Selects` receives the row, so every audited member the query hands back is recorded for `Select` (3.3.0) — one event per query, not per row, and only for a field `[DwAudit]` names. What it hands back is read strictly: a member kept whole records the audited paths inside it, a navigation nothing loads records nothing because the caller receives null for it, and a value the source does not carry records nothing either. A dry run applies no projection, so everything the row carries is recorded there, a denied member included, with the effect the policy decided. Until 3.3.0 only a field the request wrote down was recorded, which left an empty `Selects` as one token past the control: the same value, returned, with nothing written down. An audited member the rows hand back where no path of the policy names it is recorded once the rows show it (3.3.0). The gate records a use by path, before the query runs, and a member only a subtype of the row's type declares, or one past the four segments the attribute walk reads, has no path it could ask about: handed back inside a row returned whole or a navigation kept whole, it was read with nothing written down. The outbound walk's second pass reports each one it meets and the terminal records it — one `DwAuditEvent` per path per query, not per row, `Feature` `Select`, `Effect` `Mask` where the member is transformed as well and `Allow` otherwise, `EntityType` the queried type's full name, and `FieldPath` the path through the rows, such as `B.C.D.E.Five`, or `Hidden` for a subtype's member at the root. Only a member its own `[DwAudit]` audits for `Select`, and only where the projection carries it, since a member the projection left out is not a read. A member the declared types hold within four segments is the gate's and is left to it, and so is a path the projection spells out however long it is, so neither is recorded twice. It is recorded in a dry run too, read only by a resolver that reads attributes, and a model that declares neither an audit for `Select` nor a transform anywhere pays for no second pass. At `DwCaps.MaxAuditEvents` it fails closed as the gate does and the rows are withheld: under `Strict` outside a dry run the clause's own refusal with `FieldPath` `"*"` — `FieldDeniedForSegment` inside a segment — and `CapExceeded` otherwise, whose `SourceOrigin` names the cap and the undrained buffer. + +A buffer already holding `DwCaps.MaxAuditEvents` refuses the next audited use with `CapExceeded` — except under `Strict` outside a dry run, where it refuses with the clause's own field refusal instead (3.3.0). An unknown name is never audited and never reaches the cap, so answering with the cap's own code there would have told a caller that the name they guessed is a real field and an audited one. A log of uses never shows a caller probing for columns they may not read: every guess is refused, so nothing was used. `DwPolicyOptions.AuditRefusals` (`bool`, default `false`, new in 3.1.0) records the refusals too. When it is on, every `PolicyException` raised by a guarded entry point of `PolicyQueryable` — terminal or composable — and `ApplyPolicy(context)`'s refusal of an unprepared context are written to the same buffer and drain the same way: @@ -1854,6 +1868,8 @@ It is off by default because it changes what reaches a sink: a deployment that r An optional store supplies rules at runtime. `InMemoryPolicyStore` ships in the core package; Redis and EF Core are separate packages, and all three pass one shared conformance suite. +`RedisPolicyStore.UpsertAsync` and `DeleteAsync` commit conditionally on the rule's owner entry (3.3.0). Where a rule lives is read before the transaction that moves or deletes it, so two writers of one rule could read the same answer: the slower one then cleaned up after a copy the faster had already moved and left that writer's copy behind, under a user nobody any longer wrote it for and with no owner entry pointing at it, which no later write or delete could find. The writer that loses the race gets the `InvalidOperationException` a failed commit always raised — its message ends *Another writer moved or removed the same rule in the meantime; write it again* — and should write again. + ```csharp var store = new EfPolicyStore(() => new DwPolicyDbContext(options)); var provider = await StorePolicyProvider.CreateAsync(store, policyOptions); @@ -1931,6 +1947,10 @@ public string NationalId { get; set; } that value's output and can then recognise it in every other row. That is inherent in preserving equality and no setting removes it. A field that cannot accept it wants `Fixed`, `Null`, or a denial. +**A value no path of the policy names.** The outbound walk transforms along the paths the policy names — the declared types, four segments deep — and a value can sit in the materialized rows where none of them goes: a `[DwMask]` member five segments down an included or in-memory graph, one only a subtype of the row's type declares (`Dog.Chip` on rows typed `Animal`, in memory or in a TPH hierarchy), one on an object a dictionary holds, and the far side of a cycle. Each came back exactly as stored, at the default caps, under `Strict`, with no `Selects`, with the navigation named whole in `Selects`, and in a dynamic projection holding a real object. Fixed (security) in 3.3.0: the rows are walked by run-time type as well, and a member that declares a transform attribute and was not transformed along a named path is transformed by its own attributes, exactly once — an object reached both ways is not transformed twice. Only members that declare a transform or an audit for `Select`, or that can lead to one, are read, so a navigation whose type can reach neither is never touched and a lazy loader behind it is not woken, and a model that declares neither anywhere pays for no second pass. The same pass reports each audited member it meets where the policy names no path to it, which the terminal records as a read (see [Auditing](#auditing)). The transform is the member's own attributes: no rule can speak to such a member, since no path names it, and a resolver built over no `AttributePolicyProvider` reads no attribute here either. It obeys `Selects` as the first pass does, runs in a dry run as transforms always have, and fails the query with `InvalidOperationException` for a transformed member with no setter. The trace records the path with its stages and the note `(declared on the member; no path of the policy names it)`. A member typed `object`, or a collection that is not generic, still says nothing about what it holds and is not read into. + +**A query the caller runs.** `SelectDynamic`, `Group`, `FilterDynamic` and `Summary` on the guarded handle hand back a query the library never sees materialized, so they are refused with `TransformRequiresMaterialization` on a type whose values are transformed on the way out. Whether a type is one was read from the paths the policy names, so a type whose only transforms sit off them — on a member only a subtype declares, one five segments down, one of an object a dictionary holds — got the query and its rows exactly as stored. Since 3.3.0 the refusal asks what a row of the type can hold as well, any transform attribute anywhere in what the type can reach, which only a resolver that reads attributes is asked; with no named column to list it names the clause, `FieldPath` `"*"`, in both tiers, where under `Convenience` it otherwise lists the transformed columns. A type nothing transforms anywhere still gets its query. Materialize through `ToListDynamic` or `ToList(Summary)`, or leave the policy deliberately with `AsUnguardedQueryable()`. + ### k-anonymity — the control you would not guess `SUM`, `MAX` and `MIN` execute **in SQL against the stored value**, before any transform applies. `GROUP BY Department` with `MAX(Salary)` over a department of one returns that person's exact salary. @@ -1955,6 +1975,8 @@ new DwPolicyOptions { Caps = { MinGroupSize = 1 } } // no floor, and meant new DwPolicyOptions { Caps = { MinGroupSize = 10 } } // stricter ``` +The floor appends its own `Count` aggregate under the reserved alias `__dwGroupSize`, and refuses a summary that already uses that name with `GroupTooSmall`. The walk over `Having` that looks for it reads a null `Conditions` or `SubConditionGroups` as an empty list (3.3.0): a request body sending `"conditions": null` or `"subConditionGroups": null` overwrites the list's initializer, and such a summary used to fail guarded with a `NullReferenceException` wherever the floor is on, which is the default, though the same summary ran unguarded. It runs, and the floor still applies. + ### Configuration | Cap | Default | Meaning | @@ -1967,7 +1989,7 @@ new DwPolicyOptions { Caps = { MinGroupSize = 10 } } // stricter | `MaxConditionValues` | 1000 | Values in any one condition. An `In` or `NotIn` is one comparison per value, so one condition could build a predicate of any size while spending one condition and one field. The condition carrying the most values is compared: a filter's conditions, a summary's conditions and its `Having`, every set of a segment. New in 3.1.0; see breaking point 19 | | `MaxAggregates` | 50 | `AggregateBy` entries in one summary, through the summary terminals and the composable `Group` and `Summary`. The count the group-size floor adds for itself is not counted. New in 3.1.0; see breaking point 19 | | `MaxOrderFields` | 10 | Order fields in one query | -| `MaxNavigationDepth` | 4 | How deep a field path may reach | +| `MaxNavigationDepth` | 4 | How deep a field path may reach. Also the depth the attribute walk reads to: raised above 4, a request can name a path no fragment of that walk reached, and the member at the end of it is read for its own attributes (3.3.0) | | `MaxQueryCost` | 1000 | Budget consumed by `[DwCost]` weights | | `DefaultFieldCost` | 1 | Charged for an unweighted field, and since 3.1.0 for an aggregate with no field, such as a `Count` | | `MaxAuditEvents` | 10000 | Audit buffer before draining | @@ -2181,7 +2203,7 @@ DynamicWhere.ex caches all reflection lookups (property metadata, property paths | Component | Responsibility | |-----------|---------------| -| `CacheReflection` | Core reflection operations with caching | +| `CacheReflection` | Core reflection operations with caching. A lookup takes no lock and a hit allocates nothing (3.3.0): the configuration in force is read with one volatile read, where every lookup used to lock and copy it. `GetCacheConfigOptions()` still returns a copy | | `CacheDatabase` | Thread-safe `ConcurrentDictionary` stores & access tracking | | `CacheEviction` | FIFO / LRU / LFU eviction algorithms | | `CacheReporting` | Statistics, memory usage, performance reports | @@ -2367,6 +2389,8 @@ These are numbered as this document numbers them. The website's [breaking-change Fixed in 3.1.0: validating a field path recorded its access for eviction before the path was validated. A path that fails adds no cache entry for eviction to remove, so under `LRU` (the default) or `LFU` every distinct invalid name a caller sent kept its record for the life of the process, and a caller sending unique invented names grew the process without limit. A path is now tracked only once it has validated. + Changed in 3.3.0: under `LRU` a read refreshes the entry's last-access time once it is a second old rather than on every read. Eviction only asks which entries are oldest, and an entry read a moment ago is already among the newest; writing the time on every read put every thread reading the same few entries into one queue. `LFU` still counts every read. One million lookups of one cached member went from 152 ms to 35 ms on one thread and from 2,697 ms to 108 ms on eight, and from 167 MB allocated to 22 MB. + 10. **`getQueryString` Parameter Requires EF Core Provider** Passing `getQueryString: true` to `ToList` / `ToListAsync` calls `.ToQueryString()`, which needs an active EF Core database provider to produce SQL. On an in-memory `IEnumerable` it does not fail: `QueryString` holds a placeholder sentence where the SQL would be. @@ -2460,6 +2484,28 @@ These are numbered as this document numbers them. The website's [breaking-change 30. **`Configure` Takes the Same Posture Twice** Since 3.3.0 a second `DwPolicy.Configure` or `AddDwPolicies` asking for the posture already in force returns instead of throwing; a different posture still throws. Code that relied on the second call throwing — a test asserting it, or a `try`/`catch` around a second registration — no longer sees the exception. `AddDwPolicies` also registers the posture in force rather than the instance it built, so a container resolving `DwPolicyOptions` after a second registration gets the first one's. The token vault, the service provider and the provider instances are not compared and are not replaced. See [Configuring twice](#configuring-twice). + +31. **A Path Beneath a Framework-Typed Member Takes That Member's Policy** + The attribute walk descends into an application's own types and nowhere else, so no attribute can be placed beneath a member the framework declares the type of — `Salary.Value` and `Salary.HasValue` on a `decimal?`, `Secret.Length` on a `string`, `Born.Year` or `Born.Date.Year` on a `DateTime`, `Bag.Count` on a dictionary, `Lines.Count` on an application's own collection class. The pipeline validates each and the provider translates each, and no fragment named them, so they resolved as allowed. Fixed (security) in 3.3.0, in both tiers: until then a `[DwDenied] decimal?` was filtered on, sorted by, grouped by with its values as the group keys, aggregated as `MAX(Salary.Value)` and handed back by a dynamic projection under `Strict`; a transformed member gave its stored value the same way, an audited one was read with nothing recorded, a weighted one cost the default, and an operator restriction did not hold. Such a path now takes every fragment of the member it reads, whichever provider supplied it: the deny effects per feature, the `[DwOperators]` restriction (intersected), the `[DwCost]` weight and the audited features — never the alias, the required filter, the forced scope or the descriptive facts, which are about the member itself. A rule naming the sub-path still applies alongside. One feature is one feature: `[DwNoWhere] Born` refuses `WHERE Born.Year` and still allows `GROUP BY Born.Year`, and `Name.Length` on an undenied member still runs. Where the member is transformed, `Select`, `Group` and `Aggregate` on the path are refused, because there is no member beneath it to apply the chain to. A member only a subtype of the navigated type declares is not such a path. See [Attribute reference](#attribute-reference). + +32. **A Transformed Member No Path Reaches Is Transformed** + The outbound walk transforms along the paths the policy names — the declared types, four segments deep — and a value can sit in the materialized rows where none of them goes: a `[DwMask]` member five segments down an included or in-memory graph, one only a subtype of the row's type declares, one on an object a dictionary holds, one on the far side of a cycle. Each came back exactly as stored, in default configuration, at the default caps, under `Strict`. Fixed (security) in 3.3.0: the rows are walked by run-time type as well, and a member that declares a transform attribute and was not transformed along a named path is transformed by its own attributes, exactly once. Results that used to carry stored values now carry transformed ones, and a transformed member with no setter there now fails the query with `InvalidOperationException`, as one along a named path always has — give the member a setter, or project into a type that has one. Only members that declare a transform or can lead to one are read, so a model with no transform attribute anywhere pays nothing and a navigation whose type can reach no transform is never touched. See [Hiding a value you still want to group by](#hiding-a-value-you-still-want-to-group-by). + +33. **A Forced Scope on a Type First Met at the Depth Limit Applies** + `[DwForceWhere]`, `[DwRequireWhere]` and `[DwAlias]` are left out around a cycle, where they are meaningless on a type reached from itself. The attribute walk returned at its depth limit with the type still marked as being inside it, so a type *first* met at the fourth segment read as a cycle wherever it was met again in the same walk, and all three were dropped from a shorter path reaching that type directly — which of two members was declared first decided whether a forced tenant scope applied. Fixed (security) in 3.3.0: the three apply on every path within four segments that is not around a cycle, as the documentation always said. A query that ran unscoped is now scoped and returns fewer rows, a `[DwRequireWhere]` that was never demanded may now be demanded with `RequiredFilterMissing`, and a member reachable only by its real path now also answers to its alias. + +34. **Paths Past Four Segments When `MaxNavigationDepth` Is Raised** + `Caps.MaxNavigationDepth` defaults to 4, the depth the attribute walk reads to, and a host may raise it. A request could then name a path of five or more segments that no attribute fragment reached, so a `[DwDenied]` member at segment five was filtered on, grouped by and returned under `Strict`. Default configuration was never exposed to this one. Since 3.3.0 the attributes of the member at the end of such a path are read directly — the deny family, `[DwOperators]`, the transform stages, `[DwCost]`, `[DwAudit]`, `[DwDescribe]` and allowed values — by any resolver that reads attributes, which every resolver `DwPolicy.Configure` builds does. What is declared about the queried entity itself is not read there, as it is not around a cycle: `[DwAlias]`, `[DwRequireWhere]`, `[DwForceWhere]`. A transformed member there is still a member, so `Selects` naming it returns it transformed, in a typed projection and in a generated row alike; only a grouping key and an aggregated field are refused, with `FieldDeniedForGroup` and `FieldDeniedForAggregate`, because a summary's own transform finds a generated row's columns by the type's list and that list stops at four segments. Filtering and ordering run on the stored value, as at any depth. + +35. **A Page Number Whose Offset Passes `Int32` Is an Empty Page** + The offset a page skips, `(PageNumber - 1) * PageSize`, was worked out in 32 bits, and for a large enough page number the product wrapped: a negative offset is an error on SQL Server and PostgreSQL, so the request became a five-hundred, and the first page again on SQLite and in memory, so a page far past the last row returned rows. Since 3.3.0 it is worked out in 64 bits and held to `int.MaxValue`, in `Page` and in the three summary methods, guarded or not, and a page past the last row is an empty page however far past it is. The policy layer caps `PageSize` through `MaxPageSize` and never `PageNumber`, so a guarded query took the same path. Code that read the five-hundred as the signal for an out-of-range page now gets an empty page. + +36. **A Query You Run Yourself Is Refused Where Only an Unnamed Member Is Transformed** + `SelectDynamic`, `Group`, `FilterDynamic` and `Summary` on the guarded handle hand back a query the library never sees materialized, so they are refused with `TransformRequiresMaterialization` on a type whose values are transformed on the way out. Whether a type is one was read from the paths the policy names, so a type whose only transforms sit off them — on a member only a subtype declares, one five segments down, one of an object a dictionary holds — got the query, and its rows exactly as stored: the same gap point 32 closed for the terminals, one method call away from them. Fixed (security) in 3.3.0: the refusal asks what a row of the type can hold as well, which only a resolver that reads attributes is asked, and with no named column to list it names the clause — `FieldPath` `"*"` in both tiers, where under `Convenience` it otherwise lists the transformed columns. The origin, which names the method and what to call instead, is unchanged. A type nothing transforms anywhere still gets its query; a caller that composed one of the four on such a type materializes through `ToListDynamic` or `ToList(Summary)`, or leaves the policy deliberately with `AsUnguardedQueryable()`. + +37. **`[DwAudit]` Records a Member No Path Names** + Point 28 closed the read a request did not spell out; this closes the read the policy has no path for at all. The gate records a use by path, before the query runs, and a member only a subtype of the row's type declares, or one past the four segments the attribute walk reads, has no path it could ask about — so, handed back inside a row returned whole or a navigation kept whole, it was read with nothing written down. In a probe with four audited members, two were recorded. Fixed (security) in 3.3.0, in default configuration and both tiers: the outbound walk's second pass reports each audited member it meets where no path names it, and the terminal records it — one `DwAuditEvent` per path per query, not per row, `Feature` `Select`, `Effect` `Mask` where the member is transformed as well and `Allow` otherwise, and `FieldPath` the path through the rows. Only a member its own `[DwAudit]` audits for `Select`, and only where the projection carries it; a member the declared types hold within four segments is the gate's, and so is a path the projection spells out however long it is, so neither is recorded twice. Recorded in a dry run too, and read only by a resolver that reads attributes. At `DwCaps.MaxAuditEvents` it fails closed as the gate does and the rows are withheld: under `Strict` outside a dry run the clause's own refusal with `FieldPath` `"*"`, `FieldDeniedForSegment` inside a segment, and `CapExceeded` otherwise. A deployment already running the control sees more events for such models, and the cap can be reached by traffic that did not reach it before: raise it, or drain per request with `app.UseDwPolicyAudit()`. + --- ## License diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index 474231f..a0d4ea1 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -55,7 +55,7 @@ https://github.com/Sajadh92/DynamicWhere.ex icon.png README.md - v3.3.0 — A second host may configure the same posture, a path no database can compute is refused rather than run, and Clone is public on the three request types. + v3.3.0 — A second host may configure the same posture, a path no database can compute is refused rather than run, a path or a member the attribute walk cannot name is still policed, and Clone is public on the three request types. New: DwPolicy.Configure takes a second call asking for the posture already in force, and does nothing. Until now the first call won and every later one threw, so an integration suite starting several WebApplicationFactory hosts over one composition root had to read IsConfigured before registering — a check-then-act two hosts starting at once can both pass, after which one of them throws. The comparison is made inside the lock that does the configuring, so a caller needs no lock of its own, and it covers everything that decides what a query may do: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap value, the exposed entity catalogue with every name it answers to, and the kinds of policy source supplied, in order. Writing a cap's own default down is not a difference, and handing the posture in force back with a source beside it is: that call asks for the source, so it is refused rather than dropped. A posture differing in any of them is still refused with InvalidOperationException. The token vault, the service provider and the provider instances are not compared, because a second host builds its own and no two are ever the same reference; they stay as the first call left them, so a second host runs with the first host's vault and container. AddDwPolicies registers the posture in force rather than the instance it has just built, and the options handed to a second call are frozen so nothing goes on setting values that decide nothing. @@ -69,6 +69,28 @@ Security fix and behaviour change: under the strict tier, outside a dry run, a q New: Clone is public on Filter, Segment and Summary. It returns a deep copy — the condition tree with its groups and conditions, the projection list, each order, the page, and a summary's group-by and having clause — so a caller reading the same request again with one part changed, the next page or another order, no longer rebuilds it around the caller's own clauses. Two requests sharing one condition tree is the bug this removes: the library has cloned before rewriting anything since 3.0, and callers could not. +Security fix and behaviour change: a path the attribute walk cannot name takes the policy of the member it reads. The walk descends into an application's own types and nowhere else, so no attribute can be placed beneath a member whose type the framework declares — Salary.Value and Salary.HasValue on a nullable decimal, Secret.Length on a string, Born.Year on a DateTime, Bag.Count on a dictionary, Lines.Count on an application's own collection class, the collection's own member and not an element's. The pipeline validates each of them and the provider translates each, and no fragment named them, so they resolved as allowed: a denied nullable decimal was filtered on, sorted by, grouped by with its values as the group keys, aggregated through MAX and handed back by a dynamic projection, under the strict tier. A transformed member gave its stored value the same way, an audited member was read with nothing recorded, a weighted member cost the default, and an operator restriction did not hold. All of it is in 3.2.0 and earlier, in both tiers. Such a path now takes every fragment of the member it reads, whichever provider supplied it, an attribute and a store rule alike: the deny effects per feature, the operator restriction, intersected, the cost weight and the audited features. It does not take what is said to the caller about the member — the alias, the required filter, so a filter on TenantId.Value does not satisfy a [DwRequireWhere] on TenantId, the forced scope, and the descriptive facts. A rule naming the sub-path itself still applies alongside. One feature is one feature: [DwNoWhere] on Born refuses a filter on Born.Year and still allows a grouping by it, and a member nothing denies is read beneath exactly as before, so Name.Length still runs. A member only a subtype of the navigated type declares is not such a path: it is decided by the fragments naming it, so a grant of Zone under a "*" deny does not grant what a subtype of Zone's type declares. + +Security fix and behaviour change: past the attribute walk's depth, the member at the end of the path is read directly. Caps.MaxNavigationDepth defaults to 4, the depth the walk reads to, and a host may raise it; a request naming five or more segments then reached what no attribute fragment covered, and a denied member at segment five was filtered on, grouped by and returned under the strict tier. Its attributes are read now: the deny family, the operator list, the transform stages, the cost weight, the audit set, the description and the allowed values. What is declared about the queried entity itself is left out there, as it is around a cycle: the alias, the required filter and the forced scope. Only a resolver that reads attributes does this, which every resolver DwPolicy.Configure builds does, and default configuration was never exposed to it. + +Behaviour change: beneath a transformed member there is no member to apply the chain to, Bonus.Value being a decimal where Bonus is what is rounded, so Select, Group and Aggregate on such a path are refused, with a Selects entry dropped under the convenience tier as any field denied for Select is. A transformed member past the walk is itself a member, so it comes back transformed wherever a row carries it, a typed projection and a generated row alike: the chains of the members a projection names past the walk are handed to the outbound walk beside the type's own list. Only a grouping key and an aggregated field are refused there, because a summary's own transform finds a generated row's columns by the type's list, which stops at four segments. Filtering and ordering run on stored values on both kinds of path, so they follow the member's own decision, as they do along a named path. + +Security fix: a transform the outbound walk reached no path to is applied to the rows themselves. That walk transformed along the paths the policy names, the declared types four segments deep, and a value sitting elsewhere in the materialized rows came back exactly as stored: a masked member five segments down an included or in-memory graph, while one four segments down was masked; a masked member only a subtype of the row's type declares, on rows read as the base type, in memory or in a table-per-hierarchy mapping; a masked member of an object a dictionary holds; and the far side of a cycle. With no Selects, with a navigation named whole in Selects, and in a dynamic projection holding a real object — default configuration, at the default caps, under the strict tier. The rows are walked by run-time type as well now, and a member that declares a transform attribute and was not transformed along a named path is transformed by its own attributes, exactly once: an object reached both ways is not transformed twice. Only members that declare a transform, or that can lead to one, are read, so a navigation whose type can reach neither a transform nor an audit for Select is never touched and a lazy loader behind it is not woken, and a model that declares neither anywhere pays for no second pass. A transformed member with no setter fails the query with InvalidOperationException, as one along a named path does; the trace records the path with its stages and a note saying the member declared it and no path of the policy names it; no rule can speak to such a member, since no path names it; a resolver built over no AttributePolicyProvider reads no attribute here either; and it runs in a dry run, as transforms always have. A member typed as object, or a collection that is not generic, says nothing about what it holds and is still not read into. + +Security fix and behaviour change: [DwAudit] records a member no path of the policy names, once the rows show it. The gate records a use by path, before the query runs, and a member only a subtype of the row's type declares, or one past the four segments the attribute walk reads, has no path it could ask about: handed back inside a row returned whole or a navigation kept whole, it was read with nothing written down, two of four audited members in the probe recorded. The outbound walk's second pass reports each audited member it meets where no path names it, and the terminal records it: one event per path per query, not per row, for Select, with the effect it came back with, Mask where the member is transformed as well and Allow otherwise, and the path through the rows as its field path. Only a member its own [DwAudit] audits for Select, and only where the projection carries it, since a member the projection left out is not a read. A member the declared types hold within four segments is the gate's and is left to it, and so is a path the projection spells out however long it is, so neither is recorded twice. At Caps.MaxAuditEvents it fails closed as the gate does and the rows are withheld: under the strict tier outside a dry run the clause's own refusal with FieldPath "*", the segment's code inside a segment, and CapExceeded otherwise. Recorded in a dry run too, read only by a resolver that reads attributes, and a model that declares neither an audit for Select nor a transform anywhere pays for no second pass. A deployment already running the control sees more events for such models. + +Security fix and behaviour change: SelectDynamic, Group, FilterDynamic and Summary on the guarded handle hand back a query for the caller to run, which the library never sees materialized, so they are refused with TransformRequiresMaterialization on a type whose values are transformed on the way out. Whether a type is one was read from the paths the policy names, so a type whose only transforms sit off them, on a member only a subtype declares or five segments down, was handed the query and its rows exactly as stored: the same gap the outbound walk's second pass closed for the terminals, one method call away from them. The refusal asks what a row of the type can hold as well, any transform attribute anywhere in what the type can reach, read only by a resolver that reads attributes. With no named column to list it names the clause, FieldPath "*", in both tiers, where under the convenience tier it otherwise lists the transformed columns. A type nothing transforms anywhere still gets its query. + +Security fix: the attribute walk returned at its depth limit with the type still marked as being inside it, so a type first met at the fourth segment read as a cycle wherever it was met again in the same walk, and what a cycle leaves out — the forced scope, the required filter and the alias — was left out of a shorter path reaching that type directly. Which of two members was declared first decided whether a forced tenant scope on a navigated type applied. The three now apply on every path within four segments that is not around a cycle, as documented, so a query that ran unscoped is scoped and a required filter may now be demanded. + +Fix: the page offset, the page number less one times the page size, was worked out in 32 bits and wrapped for a large enough page number. A negative offset is an error on SQL Server and PostgreSQL, so the request became a five-hundred, and the first page again on SQLite and in memory, so a page far past the last row returned rows. It is worked out in 64 bits and held to int.MaxValue now, in Page and in the three summary methods, guarded or not, and a page past the last row is an empty page however far past it is, as it always was for a page number that did not wrap. The policy layer caps the page size and never the page number, so a guarded query took the same path. + +Fix: a guarded Summary whose Having carried a null Conditions or SubConditionGroups list failed with a NullReferenceException wherever the group floor is on, Caps.MinGroupSize of 2 or more, which the default of 5 is, although the same summary ran unguarded. A request body sending either as null overwrites the list's initializer, and the floor's own walk over Having, which looks for its reserved alias, was the one reader in the gate that did not check. It runs now, and the floor still applies. + +Performance: a property lookup takes no lock and allocates nothing on a hit. Every lookup of every query, several per field, locked and copied the cache configuration, allocated an input object for the tracking call and built a closure whether or not the entry was cached, and under LRU, which is the default, wrote the entry's last-access time on every read. One million lookups of one cached member went from 152 ms to 35 ms on one thread and from 2697 ms to 108 ms on eight, and from 167 MB allocated to 22 MB. A last-access time is refreshed once it is a second old instead, since eviction only asks which entries are oldest; CacheExpose.GetCacheConfigOptions still returns a copy. + +Packaging: Microsoft.Extensions.Caching.Memory 6.0.2 is named directly, the version patched for CVE-2024-43483 (GHSA-qj66-m88j-hmgj). EF Core 6.0.22 asks for 6.0.1 or later, and 6.0.1 is the last version open to it, so a host on the EF Core 6 floor resolved a vulnerable version through this package and its three companions. A host on EF Core 8 or later already resolves a newer one and sees no change. + Docs: [DwEntity(DefaultOrder)]'s own remarks still read as though a default order does not reach a projected query. It has since 3.2.0. DwCaps.MinGroupSize ships on at 5, so a guarded summary drops every group with fewer than five rows unless the deployment sets it to 1; the aggregation documentation now leads with that rather than leaving it to the caps table. v3.2.0 — A row projected before ApplyPolicy keeps its nested objects and lists, denials the policy gate could not see are enforced, a declared default order reaches projected rows, and every asynchronous terminal takes a CancellationToken. diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index ec1a5f8..0d2a2b2 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -17,12 +17,12 @@ export default function Page() {

          Breaking Changes & Known Limitations

          DynamicWhere.ex is intentionally opinionated about how queries are shaped. - The thirty-six points below cover constraints, surprises, and corner cases — + The forty-three points below cover constraints, surprises, and corner cases — read them before designing an API around the library so you can pick the right entry points and avoid runtime exceptions in production.

          - Points 30 to 36 changed in 3.3.0. Under{" "} + Points 30 to 43 changed in 3.3.0. Under{" "} Strict, a path that exists on the type and names no value the query can compute is refused rather than run, where the provider used to throw and the caller saw a five-hundred (point 30).{" "} @@ -39,7 +39,18 @@ export default function Page() { a projection the caller never named hands back, which an empty{" "} Selects used to return unrecorded (point 36), and{" "} MaxNavigationDepth answers an alias the way it answers a - name that matches nothing (point 35). + name that matches nothing (point 35). A path the attribute walk + cannot name is policed rather than allowed: one beneath a member whose + type the framework declares (point 37), a transform the walk + reached no path to (point 38), a type first met at the walk's + depth limit (point 39), and a path past four segments where a host + raised MaxNavigationDepth (point 40). A page + number whose offset passes Int32 is an empty page + (point 41). And the four methods that hand back a query for the + caller to run are refused on a type whose only transformed member sits + where the policy names no path (point 42), and{" "} + [DwAudit] records a member no path names once the rows show + it (point 43). Points 25 to 29 changed in 3.2.0, and each is @@ -1720,6 +1731,305 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}app.UseDwPolicyAudit().

          +

          37. A Path Beneath a Framework-Typed Member Takes That Member's Policy

          +

          + The attribute walk descends into an application's own types and + nowhere else, so no attribute can be placed beneath a member the + framework declares the type of. The pipeline validates such a path and + the provider translates it all the same:{" "} + Salary.Value and Salary.HasValue on a{" "} + decimal?, Secret.Length on a{" "} + string, Born.Year or{" "} + Born.Date.Year on a DateTime,{" "} + Bag.Count on a dictionary, and Lines.Count on + an application's own collection class — the collection's own + member, not an element's. No fragment named any of them, so each + resolved as allowed. +

          + + Until 3.3.0 a [DwDenied] decimal? was + filtered on, sorted by, grouped by with its values as the group keys, + aggregated as MAX(Salary.Value) and handed back by a + dynamic projection, under Strict. A transformed member gave + its stored value the same way, a [DwAudit] member was read + with nothing recorded, a [DwCost] member cost the default, + and a [DwOperators] restriction did not hold. The behaviour + is in 3.2.0 and earlier, in both tiers. + +

          + Such a path now takes every fragment of the member it reads, whichever + provider supplied it — an attribute and a store rule on{" "} + Salary both cover Salary.Value: the deny + effects per feature, the [DwOperators] restriction + (intersected), the [DwCost] weight and the audited + features. It does not take what is said to the caller about the member: + the alias, the required filter — a filter on{" "} + TenantId.Value does not satisfy a{" "} + [DwRequireWhere] on TenantId — the forced + scope, and the descriptive facts. A rule naming the sub-path itself + still applies alongside. +

          +

          + One feature is one feature: [DwNoWhere] Born refuses{" "} + WHERE Born.Year and still allows{" "} + GROUP BY Born.Year. A member nothing denies is read beneath + exactly as before, so Name.Length still runs. Where the + member is transformed there is no member beneath it to apply the chain + to, so Select, Group and{" "} + Aggregate on the path are refused rather than answered with + the stored value. And a member only a subtype of the navigated type + declares is not such a path: Zone.Parent, where a + subclass of Zone's type declares Parent, is decided by + the fragments naming it, as before, so a grant of Zone{" "} + under a "*" deny does not grant it. +

          +

          + Who is affected: any deployment whose callers can name + a path one segment beneath a denied, masked, audited, weighted or + operator-restricted member. Such a request now behaves as it does on the + member itself: a filter, a grouping or an aggregate on it is refused in + both tiers, and a select or an order is refused under{" "} + Strict and dropped under Convenience. Nothing + to do, unless a caller relied on reading Salary.Value, in + which case allow the member. +

          + +

          38. A Transformed Member No Path Reaches Is Transformed

          +

          + The outbound walk transforms along the paths the policy names — the + declared types, four segments deep — and a value can sit in the + materialized rows where none of them goes. A{" "} + [DwMask] member five segments down an included or in-memory + graph (B.C.D.E.Card, while B.C.D.Pin four + segments down was masked), a masked member only a subtype of the + row's type declares (Dog.Chip on rows typed{" "} + Animal, in memory or in a TPH hierarchy), a masked member + of an object a dictionary holds, and the far side of a cycle each came + back exactly as stored — with no Selects, with a navigation + named whole in Selects, and in a dynamic projection holding + a real object. +

          + + No cap had to be raised and no option set. A column masked four segments + down was returned in the clear five segments down, under{" "} + Strict, until 3.3.0. + +

          + The rows are now also walked by run-time type, and a member that + declares a transform attribute and was not transformed along a named + path is transformed by its own attributes, exactly once — an object + reached both ways is not transformed twice. Only members that declare a + transform or an audit for Select, or that can lead to one, + are read, so a navigation whose type can reach neither is never touched + and a lazy loader behind it is not woken, and a model that declares + neither anywhere pays for no second pass. + The transform is the member's own attributes: no rule can speak to + such a member, since no path names it, which is the same answer as + "no runtime rule can unmask a field", and a resolver built + over no AttributePolicyProvider reads no attribute here + either. It runs in a dry run, as transforms always have, and the trace + records the path with its stages and the note{" "} + (declared on the member; no path of the policy names it). +

          +

          + Who is affected: results that used to carry stored + values now carry transformed ones, which is the point of the fix and a + change to what a caller receives. A transformed member with no setter + there now fails the query with InvalidOperationException, + as one along a named path always has: give the member a setter, or + project into a type that has one. A member typed object, or + a collection that is not generic, still says nothing about what it holds + and is not read into. +

          + +

          39. A Forced Scope on a Type First Met at the Depth Limit Applies

          +

          + The attribute walk leaves out what is meaningless around a cycle: a + forced scope, a required filter and an alias on a type reached from + itself. It returned at its depth limit with the type still marked as + being inside it, so a type first met at the fourth segment read + as a cycle wherever it was met again in the same walk — and the three + were then left out of a shorter path reaching that type directly. +

          + + Two members of one type, one reached at the depth limit and one + directly, and the order the properties were declared in decided whether + the [DwForceWhere] on the navigated type reached the + shorter path. Until 3.3.0. + +

          + The scope, the requirement and the alias now apply on every path within + four segments that is not around a cycle, as the documentation always + said they did. +

          +

          + Who is affected: a query that ran unscoped is now + scoped, so it returns fewer rows; a{" "} + [DwRequireWhere] that was never demanded may now be + demanded, with RequiredFilterMissing; and a member that was + reachable only by its real path now also answers to its{" "} + [DwAlias]. Check a model that declares any of the three on + a type reached both at four segments and nearer. +

          + +

          40. Paths Past Four Segments When MaxNavigationDepth Is Raised

          +

          + Caps.MaxNavigationDepth defaults to 4, the + depth the attribute walk reads to, and a host may raise it. A request + could then name a path of five or more segments, which no attribute + fragment reached, so a [DwDenied] member at segment five + was filtered on, grouped by and returned, under Strict. + Default configuration was never exposed to this one. +

          +

          + The attributes of the member at the end of such a path are read directly + now: the deny family, [DwOperators], the transform stages,{" "} + [DwCost], [DwAudit],{" "} + [DwDescribe] and allowed values. What is declared about the + queried entity itself is not read there, as it is not around a cycle:{" "} + [DwAlias], [DwRequireWhere],{" "} + [DwForceWhere]. Only a resolver that reads attributes does + this, which every resolver DwPolicy.Configure builds does. +

          +

          + A transformed member there is still a member, so a row that carries it + carries it transformed: Selects naming it returns it + transformed, in a typed projection and in a generated row alike, because + the chains of the members a projection names past the walk are handed to + the outbound walk beside the type's own list. A grouping key and an + aggregated field are columns of a generated row, which a summary's + own transform finds by that list, and the list stops at four segments, + so those two are refused with FieldDeniedForGroup and{" "} + FieldDeniedForAggregate. Filtering and ordering run on the + stored value, as at any depth. +

          +

          + Who is affected: only a deployment that raised the cap. + A request naming a denied member past four segments is refused where it + ran, and one naming a transformed member past four segments receives the + transformed value where it received the stored one. +

          + +

          41. A Page Number Whose Offset Passes Int32 Is an Empty Page

          +

          + The offset a page skips is{" "} + (PageNumber - 1) * PageSize, and it was worked out in 32 + bits. For a large enough page number the product wrapped: a negative + offset is an error on SQL Server and PostgreSQL, so the request became a + five-hundred, and the first page again on SQLite and in memory, so a + page far past the last row returned rows. +

          +

          + It is worked out in 64 bits and held to int.MaxValue now, + in Page and in the three summary methods, guarded or not. + A page past the last row is an empty page however far past it is, as it + always was for a page number that did not wrap. +

          +

          + Who is affected: any endpoint that passes a page number + through from a caller. The policy layer caps PageSize{" "} + through MaxPageSize and never PageNumber, so a + guarded query took the same path. Code that treated the five-hundred as + the signal for an out-of-range page now gets an empty page instead. +

          + +

          42. A Query You Run Yourself Is Refused Where Only an Unnamed Member Is Transformed

          +

          + SelectDynamic, Group,{" "} + FilterDynamic and Summary on the guarded + handle hand back a query for the caller to run, which the library never + sees materialized. They are refused with{" "} + TransformRequiresMaterialization on a type whose values are + transformed on the way out, because nothing would apply the transform to + the rows the caller reads. +

          + + Whether a type is one was read from the paths the policy names. A type + whose only transforms sit off them — on a member only a subtype + declares, one five segments down, one of an object a dictionary holds — + got the query, and its rows exactly as stored: the same gap the outbound + walk's second pass closed for the terminals (point 38), one + method call away from them. Until 3.3.0. + +

          + The refusal now asks what a row of the type can hold as well — any + transform attribute anywhere in what the type can reach — which only a + resolver that reads attributes is asked. With no named column to list it + names the clause: FieldPath is{" "} + "*" in both tiers, where under{" "} + Convenience it otherwise lists the transformed columns. The + origin, which names the method and what to call instead, is unchanged. A + type nothing transforms anywhere still gets its query. +

          +

          + Who is affected: a caller that composed one of those + four methods on such a type receives a refusal where it received a + query. Materialize through ToListDynamic or{" "} + ToList(Summary), which transform the rows, or leave the + policy deliberately with AsUnguardedQueryable(). +

          + +

          43. [DwAudit] Records a Member No Path Names

          +

          + Point 36 closed the read a request did not spell out. This closes + the read the policy has no path for at all. The gate records a use by + path, before the query runs, and a member only a subtype of the + row's type declares, or one past the four segments the attribute + walk reads, has no path it could ask about — so, handed back inside a + row returned whole or a navigation kept whole, it was read with nothing + written down. +

          + + In a probe with four audited members, two were recorded: the + subtype's and the one at segment five were not. It is the same gap + the outbound walk's second pass closed for transforms + (point 38), and the pass that finds those members finds these. + +

          + That pass now reports each audited member it meets where the policy + names no path to it, and the terminal records it: one{" "} + DwAuditEvent per path per query, not per row, with{" "} + Feature Select, Effect{" "} + Mask where the member is transformed as well and{" "} + Allow otherwise, EntityType the queried + type's full name, and FieldPath the path through the + rows — B.C.D.E.Five, or Hidden for a + subtype's member at the root. +

          +
            +
          • + Only a member its own [DwAudit] audits for{" "} + Select, and only where the projection carries it: a + member the projection left out is not a read. +
          • +
          • + A member the declared types hold within four segments is the + gate's and is left to it, and so is a path the projection spells + out however long it is. Neither is recorded twice. +
          • +
          • + Recorded in a dry run too, as every audited use is, and read only by a + resolver that reads attributes. A model that declares neither an audit + for Select nor a transform anywhere pays for no second + pass. +
          • +
          • + At DwCaps.MaxAuditEvents it fails closed as the gate + does, and the rows are withheld: under Strict outside a + dry run the clause's own refusal with FieldPath{" "} + "*" — FieldDeniedForSegment inside + a segment — and CapExceeded otherwise, whose origin names + the cap and the undrained buffer. +
          • +
          +

          + Who is affected: a deployment already running the + control sees more events for models with such members, and{" "} + MaxAuditEvents can be reached by traffic that did not reach + it before. Raise the cap, or drain per request with{" "} + app.UseDwPolicyAudit(). +

          +

          See also

          • diff --git a/OfficialWebsite/app/docs/cache/architecture/page.tsx b/OfficialWebsite/app/docs/cache/architecture/page.tsx index 15d213f..cf6527d 100644 --- a/OfficialWebsite/app/docs/cache/architecture/page.tsx +++ b/OfficialWebsite/app/docs/cache/architecture/page.tsx @@ -86,7 +86,8 @@ using DynamicWhere.ex.Optimization.Cache.Output; // CacheCounts, TrackingCount
          • It asks CacheDatabase for the cached path. A hit skips the next two steps.
          • On a miss, CacheEviction runs first: if the store already holds more than MaxCacheSize entries, the configured algorithm trims it.
          • CacheReflection then performs the real reflection, validates the path, normalises the casing, and writes the result into CacheDatabase. The eviction pass runs before that write, so a store settles at MaxCacheSize + 1 entries. A path that fails validation throws here, and nothing is written.
          • -
          • Only a path that has validated records an access under the active strategy — a timestamp for LRU, a counter for LFU, nothing for FIFO. A path that fails records nothing (3.1.0), so an invented name leaves no record behind.
          • +
          • Only a path that has validated records an access under the active strategy — a timestamp for LRU, a counter for LFU, nothing for FIFO. A path that fails records nothing (3.1.0), so an invented name leaves no record behind. Under LRU the timestamp is refreshed once it is a second old rather than on every read (3.3.0).
          • +
          • A lookup takes no lock and a hit allocates nothing (3.3.0). The configuration in force is read with one volatile read, where every lookup used to lock and copy it; GetCacheConfigOptions() still returns a copy, because an instance a caller edited would be the one in force.
          • CacheReporting and CacheCalculator are read-only consumers of CacheDatabase — they never mutate cache state.
          • diff --git a/OfficialWebsite/app/docs/cache/stores/page.tsx b/OfficialWebsite/app/docs/cache/stores/page.tsx index 3d2b8f0..8dbb649 100644 --- a/OfficialWebsite/app/docs/cache/stores/page.tsx +++ b/OfficialWebsite/app/docs/cache/stores/page.tsx @@ -90,7 +90,8 @@ bool collectionsFull = CacheExpose.IsCacheFull(CacheMemoryType.CollectionElement

            Entry lifetime

            • Entries are added on first reflection miss for a given key.
            • -
            • Entries record an access on every read — a timestamp under LRU, a counter under LFU, nothing under FIFO. EvictionStrategy alone decides which; the Enable*Tracking flags do not.
            • +
            • Entries record an access on read — a timestamp under LRU, a counter under LFU, nothing under FIFO. EvictionStrategy alone decides which; the Enable*Tracking flags do not.
            • +
            • Under LRU the timestamp is refreshed once it is a second old rather than on every read (3.3.0). Eviction only asks which entries are oldest, and an entry read a moment ago is already among the newest; writing it on every read put every thread reading the same few entries into one queue. A counter under LFU still counts every read.
            • Entries are removed only when an eviction pass runs (automatic on overflow, or manual via ForceEvictionOnAllCaches / ClearCache / ClearAllCaches).
            diff --git a/OfficialWebsite/app/docs/classes/page-by/page.tsx b/OfficialWebsite/app/docs/classes/page-by/page.tsx index e235612..1f2dac0 100644 --- a/OfficialWebsite/app/docs/classes/page-by/page.tsx +++ b/OfficialWebsite/app/docs/classes/page-by/page.tsx @@ -55,6 +55,16 @@ export default function Page() { PageNumber = 0 or PageSize = 0 fails validation. + + The core sets none, and the policy layer caps PageSize{" "} + through MaxPageSize and never PageNumber. The + offset the query skips is worked out in 64 bits and held to{" "} + int.MaxValue (3.3.0), so a page past the last row is an + empty page however far past it is; in 32 bits it wrapped, which was a + five-hundred on SQL Server and PostgreSQL and the first page again on + SQLite and in memory. + +

            C# example

            {`var page = new PageBy { PageNumber = 1, PageSize = 25 };`} diff --git a/OfficialWebsite/app/docs/extensions/page/page.tsx b/OfficialWebsite/app/docs/extensions/page/page.tsx index c464348..a4c0256 100644 --- a/OfficialWebsite/app/docs/extensions/page/page.tsx +++ b/OfficialWebsite/app/docs/extensions/page/page.tsx @@ -75,6 +75,18 @@ export default function Page() { "pageSize": 25 }`} + + The offset is worked out in 64 bits and held to{" "} + int.MaxValue, here and in the three summary methods. In + 32 bits the product wrapped for a large enough page number: a negative + offset is an error on SQL Server and PostgreSQL, so the request became a + five-hundred, and the first page again on SQLite and in memory, so a + page far past the last row returned rows. A page past the last row is now + an empty page however far past it is, as it always was for a page number + that did not wrap. The policy layer caps PageSize and never{" "} + PageNumber, so a guarded query took the same path. + + Page does not order. Skipping rows of an unordered query lets the database return them in any order it likes, so two pages can diff --git a/OfficialWebsite/app/docs/installation/page.tsx b/OfficialWebsite/app/docs/installation/page.tsx index 608c60f..c2d3d06 100644 --- a/OfficialWebsite/app/docs/installation/page.tsx +++ b/OfficialWebsite/app/docs/installation/page.tsx @@ -64,6 +64,11 @@ export default function Page() { 1.6.7 Enables string-based Select / OrderBy / GroupBy used by the dynamic variants. + + Microsoft.Extensions.Caching.Memory + 6.0.2 + Not used directly. EF Core 6.0.22 asks for 6.0.1 or later, and 6.0.1 is the last version open to CVE-2024-43483, so naming 6.0.2 raises the floor for all four packages (3.3.0). A host on EF Core 8 or later already resolves a newer one. + Microsoft.Extensions.Configuration.Abstractions 6.0.0 diff --git a/OfficialWebsite/app/docs/policies/admin/page.tsx b/OfficialWebsite/app/docs/policies/admin/page.tsx index 7382a48..d6fb0fd 100644 --- a/OfficialWebsite/app/docs/policies/admin/page.tsx +++ b/OfficialWebsite/app/docs/policies/admin/page.tsx @@ -210,6 +210,16 @@ var caller = await DwClaimsAdapter.CreateContextAsync(User, claimsOptions, ct);` middleware the events are built and never written; with it and no sink registered, they are discarded with a warning.

            + + The drain used the request's own abort token, so a client that + closed the connection — as the rows arrived, or the moment they had — + cancelled the write that follows the response: the sink threw, the + middleware logged it, and the events went with the context. An audited + read with nothing written down, for the price of a socket. The drain has + a budget of its own now, thirty seconds, which the caller cannot cancel + and a hung sink cannot outlast; a sink that overruns it is cancelled, + logged and dropped, as a throwing one is. +

            It drains in a finally, so a request that threw still writes what it recorded. With{" "} diff --git a/OfficialWebsite/app/docs/policies/attributes/page.tsx b/OfficialWebsite/app/docs/policies/attributes/page.tsx index 0365614..3877dbe 100644 --- a/OfficialWebsite/app/docs/policies/attributes/page.tsx +++ b/OfficialWebsite/app/docs/policies/attributes/page.tsx @@ -233,6 +233,28 @@ public string EmployeeCode { get; set; }`} query project the allowed members instead. See{" "} A request that sends no Selects. + + A denial on a navigation covers that path alone: Contact is + denied and Contact.Email is a field of its own, because it + is declared by an application's own type and can carry an attribute. + Decorate it, or deny its path. Nothing can be decorated beneath a member + whose type the framework declares — Salary.Value and{" "} + Salary.HasValue on a decimal?,{" "} + Secret.Length, Born.Year,{" "} + Bag.Count, Lines.Count on an application's + own collection class — so since 3.3.0 such a path takes + the policy of the member it reads, from whichever provider supplied it: + the deny effects per feature, the [DwOperators] restriction + (intersected), the [DwCost] weight and the audited features. + Not the alias, the required filter, the forced scope or the description, + which are about the member itself. One feature is one feature:{" "} + [DwNoWhere] Born refuses WHERE Born.Year and + still allows GROUP BY Born.Year, and a member nothing denies + is read beneath as before. A member only a subtype of the + navigated type declares is not such a path, and is still decided by the + rules naming it. Until 3.3.0 no fragment named such a path, so it + resolved as allowed. + An access-control attribute on another declaration of a member applies to its path too: on the interface member a class or its subtype @@ -270,6 +292,21 @@ public string Department { get; set; }`} them apply. A conjunction can only narrow, so a low-authority rule can tighten a tenant scope and can never discard one. + + All three are left out around a cycle, where they are meaningless on a + type reached from itself, and they follow navigations up to four + segments otherwise. The attribute walk returned at its depth limit with + the type still marked as being inside it, so a type first met at + the fourth segment read as a cycle wherever it was met again in the same + walk: a forced scope, a required filter and an alias were then dropped + from a shorter path reaching that type directly, and which of two members + was declared first decided whether a tenant scope applied. All three now + apply on every path within four segments that is not around a cycle. A + query that ran unscoped is scoped, and a required filter may now be + demanded. A path beneath a member whose type the framework declares + takes none of the three: a filter on TenantId.Value does not + satisfy a [DwRequireWhere] on TenantId. +

            A forced predicate that lets null through

            diff --git a/OfficialWebsite/app/docs/policies/configuration/page.tsx b/OfficialWebsite/app/docs/policies/configuration/page.tsx index dc9c356..ff8055d 100644 --- a/OfficialWebsite/app/docs/policies/configuration/page.tsx +++ b/OfficialWebsite/app/docs/policies/configuration/page.tsx @@ -590,7 +590,7 @@ PolicyTrace? trace = guarded.LastTrace;`} MaxConditionValues1000Values in any one condition, such as the list of an In. MaxAggregates50Aggregates one summary computes. MaxOrderFields10Order fields in one query. - MaxNavigationDepth4How deep a field path may reach. + MaxNavigationDepth4How deep a field path may reach. Also the depth the attribute walk reads to: raised above 4, a request can name a path no fragment of that walk reached, and the member at the end of such a path is read for its own attributes (3.3.0). See breaking point 40. MaxQueryCost1000Budget consumed by [DwCost] weights. DefaultFieldCost1Charged for an unweighted field, and for an aggregate with no field. MaxAuditEvents10000Audit buffer before draining. diff --git a/OfficialWebsite/app/docs/policies/providers/page.tsx b/OfficialWebsite/app/docs/policies/providers/page.tsx index 3416da2..ff6e31d 100644 --- a/OfficialWebsite/app/docs/policies/providers/page.tsx +++ b/OfficialWebsite/app/docs/policies/providers/page.tsx @@ -29,6 +29,19 @@ var store = new RedisPolicyStore(redis); var provider = await StorePolicyProvider.CreateAsync(store, options); DwPolicy.Configure(options, provider);`} + + UpsertAsync and DeleteAsync commit + conditionally on the rule's owner entry. Where a rule lives is read + before the transaction that moves or deletes it, so two writers of one + rule could read the same answer: the slower one then cleaned up after a + copy the faster had already moved and left that writer's copy + behind, under a user nobody any longer wrote it for and with no owner + entry pointing at it, which no later write or delete could find. The + writer that loses the race now gets the{" "} + InvalidOperationException a failed commit always raised — + its message ends Another writer moved or removed the same rule in the + meantime; write it again — and should write again. + Redis pub/sub is fire-and-forget: a subscriber that is briefly disconnected never learns it missed a message. The poll behind the watch diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx index 3dedba0..066d461 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -5,8 +5,8 @@ import { Code } from "@/components/Code"; import Callout from "@/components/Callout"; export const metadata: Metadata = { - title: "Security & k-anonymity — eight inference channels and three bypasses", - description: "How DynamicWhere.ex closes the disclosure channels no per-field rule closes on its own — set-operation reconstruction, singleton-group aggregates, cardinality probes, sort-and-page binary search, SQL leakage, and schema probing through refusals — and the denials the gate could not see until 3.2.0.", + title: "Security & k-anonymity — eight inference channels and five bypasses", + description: "How DynamicWhere.ex closes the disclosure channels no per-field rule closes on its own — set-operation reconstruction, singleton-group aggregates, cardinality probes, sort-and-page binary search, SQL leakage, and schema probing through refusals — and the denials and transforms the gate could not see until 3.2.0 and 3.3.0.", keywords: ["k-anonymity", "MinGroupSize", "inference attack", "data disclosure", "aggregate disclosure", "EF Core security"], alternates: { canonical: "https://doc.dynamicwhere.com/docs/policies/security/" }, }; @@ -18,11 +18,12 @@ export default function Page() {

            Denying a field is easy. The hard part is the set of ways a caller can learn a value without reading it. Eight such channels follow, - then three bypasses that are not channels, then a path the query cannot compute + then five bypasses that are not channels, then a path the query cannot compute — not a channel either, but the one request the tier used to answer with - neither an answer nor a refusal — then the requests that, until 3.2.0, - carried out a denied value the gate could not see; each has a test that - reproduces the attack and goes red if the control is removed. + neither an answer nor a refusal — then the requests that, until 3.2.0 and + 3.3.0, carried out a denied or untransformed value the gate could not + see; each has a test that reproduces the attack and goes red if the + control is removed.

            @@ -228,7 +229,7 @@ true order. Add [DwNoOrder] unless that is intended.`} caller's — are unchanged.

            -

            9, 10 and 11. The three that are not channels

            +

            9 to 13. The five that are not channels

            @@ -251,6 +252,31 @@ true order. Add [DwNoOrder] unless that is intended.`} for Select, one event per query rather than per row. + + + + + + + +
            AttackControl
            Read an audited member no path of the policy names: one only a subtype of the row's type declares, or one past the four segments the attribute walk reads, inside a row or a navigation returned whole + The gate records a use by path, before the query runs, and such a + member has no path it could ask about, so it came back with + nothing written down. Since 3.3.0 the outbound walk's second + pass reports each one it meets and the terminal records it: one + event per path per query, for Select, with{" "} + Effect Mask where the member is + transformed as well. At MaxAuditEvents it fails + closed as the gate does and the rows are withheld. +
            Close the connection as the rows arrive, so the record of what was read is never written + The audit middleware drained a request's events with the + request's own abort token, so a client that hung up cancelled + the write that follows the response: the sink threw, the middleware + logged it, and the events went with the context — an audited read + with nothing written down, for the price of a socket. Since 3.3.0 + the drain has a budget of its own, thirty seconds, which the caller + cannot cancel and a hung sink cannot outlast. +
            @@ -311,8 +337,9 @@ true order. Add [DwNoOrder] unless that is intended.`} A denied field often sits on a type the query reaches through a member: a secret on each line of an order, a code inside a nested object. The denial holds on every path that reaches it, and it has to hold whether - or not the caller names the member. Until 3.2.0 each request below - carried a denied value out. All are closed. + or not the caller names the member. Each request below carried a denied + or untransformed value out, until 3.2.0 or, where the row says so, + until 3.3.0. All are closed.

            @@ -436,6 +463,74 @@ true order. Add [DwNoOrder] unless that is intended.`} returned whole, named or not. + + + + + + + + + + + + + + + + + + + + + + + + +
            AttackControl
            Name a path one segment beneath a denied member whose type the framework declares: Salary.Value on a decimal?, Secret.Length, Born.Year, Bag.Count, Lines.Count on an application's own collection class + Such a path takes the policy of the member it reads since{" "} + 3.3.0: the deny effects per feature, the{" "} + [DwOperators] restriction, the [DwCost]{" "} + weight and the audited features, from whichever provider supplied + them. No attribute can be placed there and no fragment named it, + so it resolved as allowed: a [DwDenied] decimal? was + filtered on, sorted by, grouped by with its values as the group + keys, aggregated and handed back by a dynamic projection, under{" "} + Strict. A transformed member gave its stored value the + same way, an audited one was read with nothing recorded, and a + weighted one cost the default. What is said to the caller about the + member stays the member's: the alias, the required filter, the + forced scope and the description. +
            Raise Caps.MaxNavigationDepth above 4 and name a denied member five or more segments out + The attributes of the member at the end of such a path are read + directly since 3.3.0. No fragment of the attribute + walk, which stops at four segments, reached it, so the member was + filtered on, grouped by and returned under Strict. + Default configuration was never exposed to this one. +
            Read a masked member the policy names no path to: five segments down an included or in-memory graph, one only a subtype of the row's type declares, one on an object a dictionary holds, one on the far side of a cycle + The rows are walked by run-time type as well since{" "} + 3.3.0, and a member that declares a transform and + was not transformed along a named path is transformed by its own + attributes, once. The outbound walk transformed along the named + paths only, so each of these came back exactly as stored — at the + default caps, under Strict, with no{" "} + Selects, with the navigation named whole in{" "} + Selects, and in a dynamic projection holding a real + object. +
            Compose SelectDynamic, Group, FilterDynamic or Summary on a type whose only transformed member sits where the policy names no path + Refused with{" "} + TransformRequiresMaterialization since{" "} + 3.3.0. The four hand back a query the library + never sees materialized, and whether the type is transformed was + read from the named paths alone, so such a type got its query and + its rows exactly as stored — the same gap, one method call away + from the terminals. The refusal asks what a row of the type can + hold as well, and names the clause when it has no column to list. +
            Declare a [DwForceWhere] on a type first met at the walk's fourth segment, then reach that type by a shorter path + The walk returned at its depth limit with the type still marked as + being inside it, so the type read as a cycle wherever it was met + again — and what a cycle leaves out, the forced scope, the{" "} + [DwRequireWhere] and the [DwAlias], was + left out of the shorter path. Which of two members was declared + first decided whether a tenant scope applied. Since{" "} + 3.3.0 all three apply on every path within four + segments that is not around a cycle. +
            Put the policed type in an application namespace that starts with System, such as SystemsCorp.Payroll diff --git a/OfficialWebsite/app/docs/policies/transforms/page.tsx b/OfficialWebsite/app/docs/policies/transforms/page.tsx index a3ebdbc..edea963 100644 --- a/OfficialWebsite/app/docs/policies/transforms/page.tsx +++ b/OfficialWebsite/app/docs/policies/transforms/page.tsx @@ -39,7 +39,14 @@ export default function Page() { A transform on a query the caller materializes itself is refused with{" "} TransformRequiresMaterialization rather than skipped, so a composable method cannot hand back an IQueryable that quietly - never masks anything. + never masks anything. Since 3.3.0 that refusal asks + what a row of the type can hold as well as which paths the policy + names: a type whose only transform sits where no path reaches it — on a + member only a subtype declares, one five segments down, one of an object + a dictionary holds — was handed the query, and its rows came back + exactly as stored. With no named column to list, the refusal names the + clause, FieldPath "*", in both + tiers. A type nothing transforms anywhere still gets its query.

            The nine mask strategies

            @@ -187,10 +194,78 @@ public string SalaryBand { get; set; } = string.Empty;`}

            The walk descends through reference navigations, collections, arrays, interfaces, structs and jagged collections, transforming every element it - reaches. A field one navigation deeper than the walk reaches is a field - that is quietly not protected, which is why the depth is a{" "} - configured cap rather - than a guess. + reaches. It is driven by the paths the policy names — the declared + types, four segments deep — which is what makes it incapable of missing + a path because it failed to recognise a navigation. +

            + +

            A value no path of the policy names

            +

            + A value can sit in the materialized rows where none of those paths goes. + A [DwMask] member five segments down an included or + in-memory graph, one only a subtype of the row's type declares —{" "} + Dog.Chip on rows typed Animal, in memory or in + a TPH hierarchy — one on an object a dictionary holds, and the far side + of a cycle: each came back exactly as stored, at the default caps, under{" "} + Strict, with no Selects, with the navigation + named whole in Selects, and in a dynamic projection holding + a real object. +

            + + A member that declares a transform attribute and was not transformed + along a named path is transformed by its own attributes, exactly once — + an object reached both ways is not transformed twice. No option had to + be set and no cap raised for the old behaviour: a column masked four + segments down was returned in the clear five segments down. + +
              +
            • + Only members that declare a transform or an audit for{" "} + Select, or that can lead to one, are read. A navigation + whose type can reach neither is never touched, so a lazy loader behind + it is not woken, and a model that declares neither anywhere pays for no + second pass at all. +
            • +
            • + The same pass reports each audited member it meets where the policy + names no path to it, which the terminal records as a read — see{" "} + breaking point 43. +
            • +
            • + The transform is the member's own attributes. No rule can speak to + such a member, since no path names it — the same answer as{" "} + no runtime rule can unmask a field — and a resolver built over + no AttributePolicyProvider reads no attribute here either. +
            • +
            • + It obeys Selects as the first pass does, runs in a dry run + as transforms always have, and fails the query with{" "} + InvalidOperationException for a transformed member with no + setter, exactly as one along a named path does. +
            • +
            • + The trace records the path with its stages and the note{" "} + (declared on the member; no path of the policy names it). +
            • +
            • + Still a limit: a member typed object, or a collection that + is not generic, says nothing about what it holds and is not read into. +
            • +
            +

            + A transform is applied to a member, which is why a path{" "} + beneath one — Bonus.Value, a decimal where{" "} + Bonus is what is rounded — is refused for{" "} + Select, Group and Aggregate{" "} + instead: there is no member there to apply the chain to, and the value it + would hand back is the stored one. A transformed member past four + segments, which only a raised{" "} + MaxNavigationDepth{" "} + lets a request name, is a member, so naming it in Selects{" "} + returns it transformed; only a grouping key and an aggregated field are + refused there, because a summary's own transform finds a generated + row's columns by the type's list, and that list stops at four + segments.

            ); diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index 1346bb0..6e87c8d 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -75,6 +75,7 @@ Four packages, always the same version. All target `net6.0` and run on .NET 6– DynamicWhere.ex the query engine, policies, token vault interface, cache Microsoft.EntityFrameworkCore 6.0.22 System.Linq.Dynamic.Core 1.6.7 + Microsoft.Extensions.Caching.Memory 6.0.2 (3.3.0) Microsoft.Extensions.Configuration.Abstractions 6.0.0 Microsoft.Extensions.Configuration.Binder 6.0.0 Microsoft.Extensions.DependencyInjection.Abstractions 6.0.0 @@ -89,6 +90,11 @@ DynamicWhere.ex.Policies.AspNetCore MapDwPolicyAdmin, claims adapter, FrameworkReference Microsoft.AspNetCore.App ``` +- `Microsoft.Extensions.Caching.Memory` is named for its patched version (3.3.0); the library does not use it + directly. EF Core 6.0.22 asks for 6.0.1 or later, and 6.0.1 is the last version open to CVE-2024-43483 + (GHSA-qj66-m88j-hmgj), so a host on the EF Core 6 floor resolved a vulnerable version through all four packages. + Naming 6.0.2 raises that floor for all four. A host on EF Core 8 or later already resolves a newer one and sees + no change. - The engine parses every expression it builds with its own `ParsingConfig`: System.Linq.Dynamic.Core's defaults with `AreContextKeywordsEnabled = false`. It does not read `ParsingConfig.Default` (since 3.1.0), so a change a host makes there does not reach DynamicWhere queries. @@ -788,6 +794,12 @@ Labels (List) asc Labels.Min() asc - Emits `Skip((PageNumber - 1) * PageSize).Take(PageSize)`. `PageNumber <= 0` → `PageNumberMustBeGreaterThanZero`; `PageSize <= 0` → `PageSizeMustBeGreaterThanZero`. +- The offset is worked out in 64 bits and held to `int.MaxValue` (3.3.0), here and in the three summary methods + (section 7). In 32 bits the product wrapped for a large enough page number: a negative offset is an error on SQL + Server and PostgreSQL, so the request became a five-hundred, and the first page again on SQLite and in memory, so + a page far past the last row returned rows. A page past the last row is an empty page however far past it is, as + it always was for a page number that did not wrap. The policy layer caps `PageSize` (`MaxPageSize`) and never + `PageNumber`, so a guarded query took the same path. - The core sets no upper bound (the policy layer has `MaxPageSize`). A page past the end is empty. `Page` neither adds nor requires an ordering; order before paging for stable pages. (The guarded `Page` of section 13 takes the type's declared `DefaultOrder` on a source nothing has ordered; this one never reads it.) @@ -1664,6 +1676,9 @@ DwCaps sealed class; DwPolicyOptions.Caps. Setters: frozen → InvalidOperat MaxOrderFields 10 1 Orders.Count CapExceeded (9) MaxNavigationDepth 4 1 dot segments of any path ("A.B.C.D" passes, 5 segments refused) in conditions, Selects, Orders, GroupBy.Fields, AggregateBy.Field CapExceeded (9) + 4 is also the depth the attribute walk reads to. Raised above it, a request + can name a path no fragment of that walk reached; the member at the end of + such a path is read for its own attributes (3.3.0, section 14) MaxQueryCost 1000 1 sum of cost over every field reference QueryCostExceeded (19) DefaultFieldCost 1 0 cost of one reference to a field with no [DwCost] or rule weight, and (3.1.0) of an aggregate with no Field, such as a Count @@ -1804,7 +1819,7 @@ Composable: sanitize one clause, apply the type's forced predicates, return a ne Filter(Filter filter) -> PolicyQueryable Composable, returning a query the caller materializes; each throws TransformRequiresMaterialization (16) -when any field of T is transformed for this caller +on a type whose values are transformed on the way out for this caller SelectDynamic(List fields) -> IQueryable Group(GroupBy groupBy) -> IQueryable FilterDynamic(Filter filter) -> IQueryable @@ -1831,7 +1846,18 @@ Rules: - The terminal's trace is on `result.Policy` only when `DwPolicyOptions.IncludeTraceInResult` allows it: null follows the tier (off under `Strict`, on under `Convenience`), and `true` or `false` overrides it (3.1.0). `LastTrace` holds it whatever the setting. - `getQueryString: true` under `Strict` → `QueryStringDenied` (14). This is checked before sanitizing; dry run records it instead. - `TransformRequiresMaterialization` depends on the type, not on the fields named. - - It is thrown even in dry run, and `FieldPath` lists the transformed paths. + - It is thrown even in dry run, and `FieldPath` lists the transformed paths — except under `Strict` outside a + dry run (3.3.0), and except where the policy names no transformed path at all, where it is `"*"` in both + tiers (3.3.0). + - It asks two questions since 3.3.0: whether the policy names a transformed path, and whether a row of the + type can hold a member that declares a transform attribute anywhere in what the type can reach — a member + only a subtype declares, one five segments down, one of an object a dictionary holds. The outbound walk + transforms those from their own attributes (section 18), so a type whose only transforms sit there is + transformed on the way out as well; reading the named paths alone handed such a type its query, and its rows + came back exactly as stored. The second question is asked only of a resolver that reads attributes. + - With no named column to list, the refusal names the clause: `FieldPath` is `"*"` in both tiers, where under + `Convenience` it otherwise lists the transformed columns. The `AuditRefusals` event records the same text. + - A type nothing transforms anywhere still gets its query. - Use `ToListDynamic` or `ToList(Summary)`, or leave deliberately with `AsUnguardedQueryable()`. - `Where`, `Order` and `Page` never fail with `AllSelectsDenied`: no projection is synthesized for a single clause. - `Group(GroupBy)` runs its sanitized summary through the summary pipeline, so forced predicates and the @@ -1953,6 +1979,18 @@ AttributeUsage (every attribute: Inherited = true) - Only public instance properties are read, including properties reached through navigations and collection elements, up to paths of 4 segments. An attribute on a field compiles and does nothing. +- A request can name a longer path only where a host raises `Caps.MaxNavigationDepth` above 4. The attributes of + the member at the end of such a path are then read directly (3.3.0): the deny family, `[DwOperators]`, the + transform stages, `[DwCost]`, `[DwAudit]`, `[DwDescribe]` and `[DwAllowedValues]`. What is declared about the + queried entity itself is left out there, as it is around a cycle: `[DwAlias]`, `[DwRequireWhere]`, + `[DwForceWhere]`. Only a resolver that reads attributes does this, which every resolver `DwPolicy.Configure` + builds does. Until 3.3.0 no fragment reached such a path, so a `[DwDenied]` member at segment five was filtered + on, grouped by and returned, under `Strict`. +- A type first met at the fourth segment used to read as a type reached from itself wherever it was met again in + the same walk, so what a cycle leaves out — `[DwForceWhere]`, `[DwRequireWhere]`, `[DwAlias]` — was left out of a + shorter path reaching that type directly, and which of two members was declared first decided whether a forced + tenant scope applied. Fixed in 3.3.0: the three apply on every path within 4 segments that is not around a + cycle. - Every attribute except `DwEntity` derives from `DwPolicyAttribute` and has `Overridable:bool = false`. False places it at `PolicyLevel.SealedAttribute`; true places it at `PolicyLevel.OverridableAttribute`. - A type's attributes are read once and cached. A malformed one throws `ArgumentException` on every @@ -2013,7 +2051,28 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. ### DwDeny family - Each attribute refuses its features on that exact path. Several on one member all apply. -- A denial on a navigation (`Contact`) covers only `Contact`. `Contact.Email` is a separate field. +- A denial on a navigation (`Contact`) covers only `Contact`. `Contact.Email` is a separate field: it is declared + by an application's own type, so an attribute can be placed on it. Decorate it, or deny its path. +- A path that continues beneath a member whose type the framework declares is not a separate field (3.3.0). No + attribute can be placed on `Salary.Value` or `Salary.HasValue` on a `decimal?`, `Secret.Length` on a `string`, + `Born.Year` or `Born.Date.Year` on a `DateTime`, `Bag.Count` on a dictionary, or `Lines.Count` on an + application's own collection class — the collection's own member, not an element's — and no fragment named such + a path, so it resolved as allowed: a `[DwDenied] decimal?` was filtered on, sorted by, grouped by with its + values as the group keys, aggregated (`MAX(Salary.Value)`) and handed back by a dynamic projection, under + `Strict`. Such a path now takes every fragment of the member it reads, whichever provider supplied it — an + attribute and a store rule on `Salary` both cover `Salary.Value`: the deny effects per feature, the + `[DwOperators]` restriction (intersected), the `[DwCost]` weight and the audited features. A rule naming the + sub-path itself still applies alongside. + - One feature is one feature: `[DwNoWhere] Born` refuses `WHERE Born.Year` and still allows + `GROUP BY Born.Year`. A member nothing denies is read beneath exactly as before, so `Name.Length` still runs. + - It does not take what is said to the caller about the member: the alias, the required filter + (`[DwRequireWhere]` on `TenantId` is not satisfied by a filter on `TenantId.Value`), the forced scope, and the + descriptive facts — label, description, group, order, allowed values. + - Where the member it reads is transformed, `Select`, `Group` and `Aggregate` on the sub-path are refused + (section 18): there is nothing beneath the member to apply the transform to. + - A member only a subtype of the navigated type declares is not such a path: `Zone.Parent`, where a subclass of + Zone's type declares `Parent`, is decided by the fragments naming it, as before, so a grant of `Zone` under a + `"*"` deny does not grant it. - `[DwDeny(PolicyFeature.None)]` refuses nothing, and nothing reports it. - `[DwNoSelect]` leaves the field filterable, sortable, groupable and aggregatable, so it can still be counted. @@ -2033,7 +2092,9 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. - `Resolve()` returns `Allow` (every `Operator` when null) minus `Deny`. An operator in both lists is refused. `Allow = new Operator[0]` permits nothing, so the field cannot be filtered at all. - Every restriction matching the field is intersected: repeated attributes, rules at any level, and `"*"` - rules. A rule can only narrow the set, so `Overridable` changes nothing. + rules. A rule can only narrow the set, so `Overridable` changes nothing. A path beneath a member whose type the + framework declares intersects that member's restriction too (3.3.0), so a restriction on `Salary` holds for + `Salary.Value`. - It is checked on every WHERE condition at any depth and in every Segment set. It is also checked on a `Having` condition that reaches the field through a group key or an aggregate alias. - A failure throws `OperatorNotAllowed` in both tiers. If the field is also denied for Where, @@ -2063,7 +2124,11 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. - A nested path's column is matched by its path without dots. - Typed `FilterResult` and `SegmentResult` rows keep their member names. - An alias that stands for more than one path is not renamed. -- An alias is not applied on a type reached from itself (`Employee.Manager.Code`). +- An alias is not applied on a type reached from itself (`Employee.Manager.Code`). Until 3.3.0 a type first met + at the fourth segment counted as reached from itself wherever it was met again in the same walk, which dropped + the alias from a shorter path reaching that type directly. +- A path beneath a member whose type the framework declares does not take that member's alias (3.3.0): an alias + is a public name for the member, and `Salary.Value` is not the member. ### DwForceWhere @@ -2120,6 +2185,12 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. - A predicate declared on a navigated type is injected through the navigation, up to 4 segments, except onto a type reached from itself. For example, a query on `Order` injects `Buyer.TenantId`; through a collection the condition becomes `Any`. + - Until 3.3.0 a type first met at the fourth segment read as a type reached from itself wherever it was met + again in the same walk, so its forced scope was dropped from a shorter path reaching it directly, and which + of two members was declared first decided whether the scope applied. It applies on every path within 4 + segments that is not around a cycle now. A query that ran unscoped is scoped. + - A path beneath a member whose type the framework declares takes no forced scope from that member (3.3.0): the + scope is about the rows, not about the value one segment down. ### DwRequireWhere @@ -2139,7 +2210,11 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. - In a Segment, every condition set must satisfy it. - It is checked on every guarded call, so a lone `.Order(...)` or `.Page(...)` on the handle also throws. - It follows navigations the same way `[DwForceWhere]` does: a query on `Order` can require - `Buyer.Division`. + `Buyer.Division`, and since 3.3.0 on every path within 4 segments that is not around a cycle, so a type first + met at the fourth segment no longer drops it from a shorter path. A requirement a caller was never asked for + may now be demanded. +- A filter on a path beneath the member does not satisfy it (3.3.0): `[DwRequireWhere]` on `TenantId` asks for a + filter on `TenantId`, which `TenantId.Value` is not. - It is elected: a rule cannot lift a sealed requirement but may add one where none exists. A `"*"` rule cannot carry one. @@ -2167,6 +2242,8 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. - Not charged: a synthesized projection, `Having`, summary `Orders`, forced predicates and the group-size count. - An unweighted field costs `Caps.DefaultFieldCost` (default 1, may be 0). +- A path beneath a member whose type the framework declares costs that member's weight (3.3.0): `Notes.Length` + costs what `Notes` costs, where it used to cost the default. - A total above `Caps.MaxQueryCost` (default 1000) throws `QueryCostExceeded` in both tiers. In dry run it is only recorded. - The Convenience tier checks the total before gating. The Strict tier checks it after every field gate @@ -2185,10 +2262,11 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. - A field the type's `DefaultOrder` adds is recorded too (3.1.0): audited for `Order`, it is an `Order` use, `Effect` `Allow`, each time a guarded query orders by it (section 13). - Not recorded: - - columns returned because the request had no `Selects` - a `DefaultOrder` field left out for this caller, outside a dry run - forced predicates - - output transforms + - applying an output transform, which is no use of its own + - columns returned because the request had no `Selects` — until 3.3.0, which records every audited member the + query hands back (below) - If the context already holds `Caps.MaxAuditEvents` (default 10000) events, the query is refused, in both tiers and in dry run. `CapExceeded`, except under `Strict` outside a dry run, where it is the clause's own field refusal instead (3.3.0): only a real, audited field can reach the cap, so answering with the cap's own code @@ -2196,6 +2274,20 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. - A use is what the request reads: a field it names, a default-order field the query adds, and — since 3.3.0 — every audited member a projection the caller did not name hands back, recorded for `Select`. One event per query, not per row. Until 3.3.0 an empty `Selects` returned the value with nothing written down. +- A path beneath a member whose type the framework declares is audited as that member is (3.3.0): reading + `Salary.Value` records what reading `Salary` records, where it used to record nothing. +- A member the rows hand back where no path of the policy names it is recorded once the rows show it (3.3.0). + The gate records a use by path, before the query runs, and a member only a subtype of the row's type declares, + or one past the four segments the attribute walk reads, has no path it could ask about: handed back inside a + row returned whole or a navigation kept whole, it was read with nothing written down. The outbound walk's + second pass reports each one it meets and the terminal records it (section 22): one event per path per query, + `Feature` `Select`, `Effect` `Mask` where the member is transformed as well and `Allow` otherwise. + - Only a member its own `[DwAudit]` audits for Select, and only where the projection carries it: a member the + projection left out is not a read. + - A member the declared types hold within four segments is the gate's and is left to it, and so is a path the + projection spells out, however long — neither is recorded twice. + - Read only by a resolver that reads attributes. A model that declares neither an audit for Select nor a + transform anywhere pays for no second pass. - What is handed back is read strictly: a member kept whole records the audited paths inside it; a navigation nothing loads records nothing, since the caller receives null for it; a value the source does not carry records nothing. A dry run applies no projection, so everything the row carries is recorded, a denied member included. @@ -2361,6 +2453,8 @@ a column only a subtype maps, through the base the queried type's model, w a projection another provider ran nothing; its rules are its own left alone ``` +- The `Length, Year, HasValue` row says whether such a path is computable, and it is. Its *policy* is the + member's own since 3.3.0 (section 14), so it runs only where that member may be used for that feature. - A projection is read only as far as its initializer can be read: a nested initializer, a member copied from the entity, a value built and left empty, and a conditional over those, a null branch included. A member assigned nothing but a null is left alone. Past `MaxComplexDepth` (eight levels) it stops reading and stops speaking. Such @@ -2416,6 +2510,17 @@ no Selects while a denied field can reach the result (3.2.0) allowed members a GROUP BY a denied field throw throw FieldDeniedForGroup AGGREGATE a denied field, or a transformed field lacking AllowAggregate on a stage throw throw FieldDeniedForAggregate +any clause on a path the attribute walk cannot name: + Salary.Value beneath a framework-typed member, or a + path past four segments with MaxNavigationDepth + raised (3.3.0) the member's same the member's own code + own decision +SELECT a path beneath a transformed member, Bonus.Value: + no member there to apply the chain to (3.3.0) drop throw FieldDeniedForSelect +SELECT a transformed member past four segments (3.3.0) transformed transformed +GROUP BY or AGGREGATE a path of either kind whose member + is transformed (3.3.0) throw throw FieldDeniedForGroup / + FieldDeniedForAggregate field denied for Segment used anywhere in a Segment throw throw FieldDeniedForSegment Segment condition on a field denied for Select allowed throw FieldDeniedForSegment any other field refusal inside a Segment (3.1.0) the rows above throw Strict: FieldDeniedForSegment @@ -2557,6 +2662,26 @@ DefaultOrder field denied for Segment, in a Segment (3.1.0) left out l projects into T, so a T with no public parameterless constructor, an abstract one among them, fails there with `SelectTypeMustHaveParameterlessConstructor`; the dynamic terminals build their own class. - With nothing counted, `Selects` stays null and the query is the one an unguarded call runs. +- A path the attribute walk cannot name takes the policy of the member it reads (3.3.0, section 14): one that + continues beneath a member whose type the framework declares — `Salary.Value` and `Salary.HasValue` on a + `decimal?`, `Secret.Length` on a `string`, `Born.Year` on a `DateTime`, `Bag.Count` on a dictionary, + `Lines.Count` on an application's own collection class — and, where a host raised `MaxNavigationDepth`, one + longer than the walk's four segments. + - The deny effects per feature, the `[DwOperators]` restriction, the `[DwCost]` weight and the audited features + come from the member; its alias, its required filter, its forced scope and its descriptive facts do not. A + rule naming the sub-path itself still applies alongside, and `Explain` names the member's fragment as the + source. + - Where the member it reads is transformed, a path beneath it has no member to apply the chain to, so every + way it hands a value back is refused: `Select`, `Group` and `Aggregate`, with a `Selects` entry dropped under + `Convenience` as any field denied for Select is. A transformed member past the walk is itself a member, so + `Selects` naming it returns it transformed (section 18); only `Group` and `Aggregate` are refused there, + because a summary's own transform finds a generated row's columns by the type's list, which stops at four + segments. `Where` and `Order` follow the member's own decision and run on the stored value, as they do along + a named path. + - A member only a subtype of the navigated type declares is not such a path: it is decided by the fragments + naming it, as before. + - The member a path reads is found whatever the providers are, an attribute and a store rule alike. The + attributes past the walk's depth are read only by a resolver that reads attributes. - A path that matches nothing on `T` (3.1.0): - Convenience, and dry run in either tier: validation throws `LogicException` `ConditionMustHasValidFieldName` before any policy decision, as it does unguarded. @@ -2639,6 +2764,42 @@ alone Default [DwDefault] constant or type default, converted to mem selected paths and every path beneath a selected navigation. - Collections and navigations are followed, and null navigations are skipped. - An object shared by several rows is transformed once. +- The rows are also walked by run-time type (3.3.0), so a member that declares a transform attribute and was not + transformed along a path the policy names is transformed by its own attributes, exactly once: an object reached + both ways is not transformed twice. The walk of the declared types names the paths of four segments, and a + value can sit where none of them goes — a `[DwMask]` member five segments down an included or in-memory graph + (`B.C.D.E.Card`, while `B.C.D.Pin` four segments down was masked), a masked member only a subtype of the row's + type declares (`Dog.Chip` on rows typed `Animal`, in memory or in a TPH hierarchy), a masked member of an + object a dictionary holds, and the far side of a cycle — with no `Selects`, with a navigation named whole in + `Selects`, and in a dynamic projection holding a real object. Each came back exactly as stored, at the default + caps, under `Strict`. + - Only members that declare a transform or an audit for Select, or that can lead to one, are read, so a + navigation whose type can reach neither is never touched and a lazy loader behind it is not woken. A model + that declares neither anywhere pays for no second pass at all. + - The same pass reports each audited member it meets where the policy names no path to it, which the terminal + records (3.3.0, section 22). + - The transform is the member's own attributes. No rule can speak to such a member, since no path names it, + which is the same answer as "no runtime rule can unmask a field" (section 32). A resolver built over no + `AttributePolicyProvider` reads no attribute here either. + - It carries only what the result carries, as the first pass does; it runs in a dry run, as transforms always + have; and a transformed member with no setter fails the query with `InvalidOperationException`, as one along + a named path does. + - The trace records the path with its stages and the note + `(declared on the member; no path of the policy names it)`. + - Still a limit: a member typed `object`, or a collection that is not generic, says nothing about what it + holds and is not read into. +- A transform is applied to a member. Beneath that member the value is another type — `Bonus.Value` is a decimal + where `Bonus` is the member that is rounded — so there is no member to apply the chain to, and `Select`, `Group` + and `Aggregate` on such a path are refused (3.3.0): `FieldDeniedForSelect`, `FieldDeniedForGroup`, + `FieldDeniedForAggregate`, with a `Selects` entry dropped under `Convenience` as any field denied for Select is. +- A transformed member past the walk's depth is itself a member, so a row that carries it carries it transformed + (3.3.0). `Selects` naming it returns it transformed, in a typed projection and in a generated row alike: the + chains of the members a projection names past the walk are handed to the outbound walk beside the type's own + list and applied by path. A grouping key and an aggregated field are columns of a generated row, which a + summary's own transform finds by the type's list, and that list stops at four segments, so those two are + refused: `FieldDeniedForGroup`, `FieldDeniedForAggregate`. +- `Where` and `Order` run on stored values wherever a member is reached from, so on both kinds of path they + follow the member's own decision, as they do along a named path. - A transformed member with no setter, or a path missing on the runtime type, throws `InvalidOperationException`. - Segment results are transformed like Filter results. @@ -2650,7 +2811,13 @@ alone Default [DwDefault] constant or type default, converted to mem `AllowAggregate = true`. That includes a short-circuiting `[DwDefault]`, and the check overrides every Allow, sealed ones included. The field's own floor is the largest `MinGroupSize` among its stages. - On the guarded handle, `SelectDynamic`, `FilterDynamic`, `Group` and `Summary` throw - `TransformRequiresMaterialization` if the type has any transform for this caller. + `TransformRequiresMaterialization` on a type whose values are transformed on the way out for this caller: one + the policy names a transformed path on, and, since 3.3.0, one a row of which can hold a member that declares a + transform attribute anywhere in what the type can reach, which only a resolver that reads attributes is asked. + Until 3.3.0 a type whose only transforms sit off the named paths — a member only a subtype declares, one five + segments down, one of an object a dictionary holds — was handed the query, and its rows came back exactly as + stored. With no named column to list the refusal names the clause, `FieldPath` `"*"`, in both tiers. A type + nothing transforms anywhere still gets its query. - `AsUnguardedQueryable()` output is never transformed. - Transforms also run in dry run. @@ -2851,6 +3018,10 @@ Caps.MinGroupSize unset -> DwCaps.DefaultMinGroupSize = 5 (IsMinGroupSizeSet = - `GroupTooSmall` means the caller already used `"__dwGroupSize"` (case-insensitive) as an aggregate alias, a `Having` field or an `Orders` field. It throws in both tiers and in dry run. A small group never raises it. + - The walk over `Having` that looks for the name reads a null `Conditions` or `SubConditionGroups` as an empty + list (3.3.0). A request body sending `"conditions": null` or `"subConditionGroups": null` overwrites the + list's initializer, and such a summary used to fail guarded with a `NullReferenceException` wherever the + floor is on, which is the default, though it ran unguarded. It runs, and the floor still applies. - Dry run adds the column without the predicate. Each group below the floor gets its own decision (`"a group of {n}, below the group floor of {floor}"`) and stays in `Data`. - Transformed keys are handled in this order: @@ -2998,6 +3169,9 @@ What is recorded included navigation or an object of a row in memory (Select); Reason starts "left out:" (3.2.0) Dropped a member a Convenience caller named that can hold a denied field no path names (Select); Reason "it holds a field denied for Select where no path can name it" (3.2.0) + Denied an audited member no path of the policy names that the audit buffer has no room for (Select), + before the refusal that withholds the rows; Reason "MaxAuditEvents cap () reached with the + buffer undrained" (3.3.0, section 22) Denied a refusal: Strict denial, cap, cost, query string ("*"), required filter, segment inference, MaxAuditEvents; the throw follows unless dry run. Under Strict a name that matches nothing is Denied under that name, Reason "names nothing on " (3.1.0) @@ -3006,6 +3180,10 @@ What is recorded Masked | Mutated | Defaulted | Generalized once per transformed path per query (Select); Reason lists the stages ("Mask then Truncate"); summary key and aggregate columns use Feature Aggregate + Masked | Mutated | Defaulted | Generalized + a member the run-time-type pass transformed because no path of the policy names it (Select), + once per path per query; Reason is the stages followed by + " (declared on the member; no path of the policy names it)" (3.3.0, section 18) Allowed an aliased column renamed on output (dynamic and Summary rows); Reason "emitted as ''" Dropped "__dwGroupSize", Aggregate: the floor was applied; in dry run, one record per group below it ``` @@ -3217,11 +3395,29 @@ DwAuditEvent sealed class use, `Effect` `Allow`, each time a guarded query orders by it, as a caller's own order is. A dry run keeps a default field that enforcement would leave out, so it records that one too, with its `Order` effect (`Deny` for a field the caller may not order by) and `DryRun` true. +- An audited member the rows hand back where no path of the policy names it is recorded once the rows show it + (3.3.0). The gate records by path before the query runs, and a member only a subtype of the row's type + declares, or one past the four segments the attribute walk reads, has no path it could ask about, so it came + back inside a row or a navigation kept whole with nothing written down. The outbound walk's second pass + (section 18) reports each one and the terminal records it. + - One `DwAuditEvent` per path per query, not per row. `Feature` `Select`; `Effect` `Mask` where the member is + transformed as well, `Allow` otherwise; `EntityType` the queried type's `FullName`; `FieldPath` the path + through the rows, such as `B.C.D.E.Five`, or `Hidden` for a subtype's member at the root. + - Only a member its own `[DwAudit]` audits for Select, and only where the projection carries it. A member the + declared types hold within four segments is the gate's, and so is a path the projection spells out however + long it is, so neither is recorded twice. + - Recorded in a dry run too, as every audited use is, and read only by a resolver that reads attributes. + - At `Caps.MaxAuditEvents` it fails closed as the gate does, and the rows are withheld: under `Strict` outside + a dry run the clause's own refusal, `FieldDeniedForSelect` or `FieldDeniedForSegment` for a segment, with + `FieldPath` `"*"`; otherwise `CapExceeded`, whose `SourceOrigin` reads + `MaxAuditEvents cap () reached with the buffer undrained`. The trace records a `Denied` decision on the + path first, and an `AuditRefusals` event records the path. - Events are recorded whether the use was allowed, masked, dropped or denied, and in dry run too. - Nothing is recorded for: - a `DefaultOrder` field left out for this caller, outside a dry run: the query does not order by it, and the caller never named it; - - a projection the library synthesized; + - a projection the library synthesized, as an act of its own: since 3.3.0 every audited member it hands back + is recorded for `Select` (section 14); - `PolicySimulator`; - `Explain`; - schema building. @@ -3233,7 +3429,7 @@ DwAuditEvent sealed class origin at all (3.3.0). - Draining makes room again. - A sink must throw when it fails to write. It is passed per call and never stored in options, so a scoped sink works. -- ASP.NET Core package: `app.UseDwPolicyAudit()` drains the context stored on `HttpContext.Features` to the `IDwAuditSink` registered in DI, after each response. +- ASP.NET Core package: `app.UseDwPolicyAudit()` drains the context stored on `HttpContext.Features` to the `IDwAuditSink` registered in DI, after each response. The drain does not use the request's abort token: it has a budget of its own, thirty seconds (3.3.0, section 29). ### Refused queries — `DwPolicyOptions.AuditRefusals` (3.1.0) @@ -3799,6 +3995,7 @@ RedisPolicyStore: - INCRs the version. - **Upsert failures:** an EXEC that does not commit throws `InvalidOperationException`. After commit it PUBLISHes the version; a `RedisException` from that publish is swallowed and the poll catches up. - **Delete:** one transaction removes the rule and its owner entry and always INCRs the version, then PUBLISHes. +- **Concurrent writers (3.3.0):** both transactions are conditional on the rule's `owner` entry — equal to the owner just read, or absent where the rule was new. Where a rule lives is read before the transaction that moves or deletes it, so two writers of one rule could read the same answer: the slower one then cleaned up after a copy the faster had already moved and left that writer's copy behind, under a user nobody any longer wrote it for and with no owner entry pointing at it, which no later write or delete could find. The writer that loses the race now gets the `InvalidOperationException` a failed commit always raised, whose message ends "Another writer moved or removed the same rule in the meantime; write it again."; write it again. - **LoadAsync:** reads the version before the rules. A document it cannot read throws `ArgumentException`. - **LoadNarrowAsync:** one HGETALL per identity, skipping blank identities. Keys are lower-case, so casing never splits one user into two. - **WatchAsync:** subscribes to the channel. The provider still polls `{prefix}:version`. @@ -4112,11 +4309,13 @@ app.UseDwPolicyAudit(); static IApplicationBuilder UseDwPolic new DwPolicyAuditMiddleware(RequestDelegate next, ILogger? log = null) Task InvokeAsync(HttpContext http) ``` - **When it runs:** after the rest of the pipeline, in a `finally`, so also when the request threw. It then drains the request's pending audit events. +- **How long it has:** the drain does not use the request's abort token. It has a budget of its own, thirty seconds, which the caller cannot cancel and a hung sink cannot outlast (3.3.0). A client that closed the connection, as the rows arrived or the moment they had, used to cancel the write that follows the response: the sink threw, the middleware logged it, and the events went with the context — an audited read with nothing written down, for the price of a socket. - **Which context:** only the `DwPolicyContext` in `http.Features`, which `GetPolicyContextAsync` stores there. For a context you built yourself, call `http.Features.Set(context)`. With no context or no pending events it does nothing. - **Sink:** `IDwAuditSink`, resolved from `RequestServices` (so a scoped sink works), written through `DwPolicy.DrainAuditAsync`. - **Failures:** - No sink registered: logs a warning and discards the events. The warning ends "Register one, or stop recording them: remove [DwAudit] from the fields that produced them, or turn off DwPolicyOptions.AuditRefusals." (3.1.0). - A throwing sink: logs an error and does not rethrow. The unwritten events stay on the context and are lost with the request. + - A sink that does not finish inside the thirty-second budget is cancelled, and the cancellation is logged and dropped the same way (3.3.0). - The request's own exception is never replaced. - **Events:** the middleware records nothing itself. Events come from audited fields (`[DwAudit]`, or a rule's `facts.audit`), one `DwAuditEvent` per use, and, with `DwPolicyOptions.AuditRefusals` (3.1.0), one per refused guarded query, with `OccurredAt EntityType FieldPath Feature Effect Subjects Purpose Tier DryRun ErrorCode`. - **Placement:** register it before anything that touches audited fields. The source recommends placing it before authentication and routing. @@ -4175,7 +4374,10 @@ through `PolicySimulator` or in dry run to see the decisions). 9 CapExceeded MaxPageSize, MaxConditions, MaxConditionDepth, MaxConditionSets, recorded, except the MaxConditionValues, MaxAggregates, MaxOrderFields or MaxNavigationDepth audit buffer: throws exceeded; or the context already holds MaxAuditEvents undrained events, - which under Strict outside a dry run is refused as a field instead + which under Strict outside a dry run is refused as a field instead. The + buffer is also reached on the way out, by an audited member no path names + (3.3.0, section 22): the rows are withheld, FieldPath is the path under + Convenience and "*" under Strict 10 PolicyRequired a DynamicWhere extension method called on a throws [DwEntity(RequirePolicy = true)] type outside ApplyPolicy 11 RequiredFilterMissing a [DwRequireWhere] field has no AND-reachable condition using one of recorded @@ -4189,9 +4391,13 @@ through `PolicySimulator` or in dry run to see the decisions). 15 AmbiguousGroupKey two summary rows share their key values once transformed; under Strict throws FieldPath "*" and no SourceOrigin (3.3.0) 16 TransformRequiresMaterialization SelectDynamic, FilterDynamic, Group or Summary on the guarded handle throws - for a type with any transform for this caller; under Strict FieldPath - "*" rather than the transformed columns (3.3.0), and the origin, which - names the method and what to call instead, stays + for a type whose values are transformed on the way out for this caller: + one the policy names a transformed path on, or (3.3.0) one a row of which + can hold a member declaring a transform attribute anywhere in what the + type can reach. Under Strict FieldPath is "*" rather than the transformed + columns (3.3.0), and so it is in both tiers where the policy names no + transformed path; the origin, which names the method and what to call + instead, stays 17 StoreUnavailable a FailClosed store provider is degraded, or the context's pinned throws snapshot is older than MaxSnapshotAge 18 PolicyContextNotPrepared ApplyPolicy(ctx) sees a context that never went through PrepareAsync throws @@ -4376,6 +4582,11 @@ Read before generating policy attributes. 5. **`[DwDenied]` on a navigation denies only that path; `Contact.Email` stays open.** Either decorate the members of the navigated type, which applies on every path reaching them (up to 4 segments), or deny each child path. A `"*"` rule denies every field of the entity, and `"Contact.*"` is not a wildcard. + The contrast is a member whose type the framework declares: `Salary.Value`, `Secret.Length`, `Born.Year`, + `Bag.Count`. Nothing can be decorated there, so since 3.3.0 such a path takes the policy of the member it + reads — its denials, operators, cost and audit, never its alias, required filter, forced scope or + description (section 14). A navigation into an application's own type is still a separate field, because its + members can carry attributes of their own. 6. **The default token scope is the field path relative to the queried entity.** Equal member paths on two entities share tokens, while the same member reached through a navigation gets different tokens. Set `TokenScope` wherever tokens must or must not match. @@ -4401,22 +4612,33 @@ Read before generating policy attributes. `[DwAudit]` either. 15. **Aliases rename output columns only in dynamic filter results and summaries.** Typed results keep member names. -16. **`[DwAudit]` records only fields the query uses by name: those the request names, and a `DefaultOrder` - field it orders by (3.1.0).** A request without `Selects` returns audited columns with no Select event, and a - default field left out for the caller records nothing. +16. **`[DwAudit]` records what the request reads, not only what it names (3.3.0).** A field the request names + and a `DefaultOrder` field the query orders by (3.1.0), plus every audited member a projection the caller + did not name hands back, and every audited member the rows hand back where no path of the policy names it + (sections 14 and 22) — one event per path per query. A default field left out for the caller still records + nothing. Until 3.3.0 a request without `Selects` returned audited columns with no `Select` event at all, so a + deployment upgrading sees more events and can reach `Caps.MaxAuditEvents` where it did not. 17. **`AmbiguousGroupKey` compares only the transformed keys.** A summary mixing untransformed and transformed keys can be refused even though its rows differ. 18. **`[DwFormat]` ignores its format for a string value.** `[DwGeneralize(GeneralizeMode.Round, ...)]` plus `[DwFormat("C0")]` on a string member returns the plain rounded number. 19. **The composable `Group` and `Summary` return a query you materialize yourself.** The floor and the forced - predicates are applied, but nothing transforms or renames those rows, and a type with any transform for - this caller refuses both with `TransformRequiresMaterialization`. + predicates are applied, but nothing transforms or renames those rows, and a type whose values are + transformed on the way out for this caller refuses both with `TransformRequiresMaterialization` — since + 3.3.0 including a type whose only transform sits where the policy names no path, on a member a subtype + declares or five segments down (section 13). 20. **`ValidateModel` does not catch a malformed `[DwAlias]` name.** A blank, dotted or `"*"` alias throws `ArgumentException` on every guarded query of the type, and of any type that navigates to it. Since 3.1.0 it does report a malformed `[DwForceWhere]`. -21. **Raising `Caps.MaxNavigationDepth` above 4 opens paths no attribute covers.** - `AttributePolicyProvider.MaxDepth` is a constant 4, so a member 5 or more segments deep is allowed - and untransformed. +21. **Raising `Caps.MaxNavigationDepth` above 4 reaches past the attribute walk.** + `AttributePolicyProvider.MaxDepth` is a constant 4, so no fragment of the walk names a path of 5 or more + segments. Since 3.3.0 the attributes of the member at the end of such a path are read directly — the deny + family, `[DwOperators]`, the transform stages, `[DwCost]`, `[DwAudit]`, `[DwDescribe]` and allowed values — + by any resolver that reads attributes, and a transform declared there is applied wherever a row carries the + member (section 18), so `Selects` naming it returns it transformed, though it cannot be a grouping key or an + aggregated field. What is + declared about the queried entity itself is not read there, as it is not around a cycle: `[DwAlias]`, + `[DwRequireWhere]`, `[DwForceWhere]`. Until 3.3.0 such a member was allowed and untransformed. 22. **A guarded query over an in-memory source masks your objects.** `list.ApplyPolicy(ctx).ToList(filter)` without `Selects` returns the list's own instances and transforms them in place, so the list stays masked afterwards. EF Core queries run `AsNoTracking` and are unaffected. Query a copy, or send `Selects`. @@ -4493,6 +4715,9 @@ apply. - One cache per process, shared by every DbContext, request and thread. - It starts empty and is never shared between app instances. - `CacheExpose` is a static class, so there is nothing to inject. +- A lookup takes no lock and a hit allocates nothing (3.3.0). The configuration in force is read with one + volatile read rather than locked and copied on every lookup; `GetCacheConfigOptions()` still returns a copy, + because an instance a caller edited would be the one in force. ``` Store (CacheMemoryType) Key Value Holds @@ -4620,7 +4845,9 @@ CacheExpose.WarmupCache(typeof(Customer), "Name"); - These calls record an access before they look anything up: `GetTypeProperties`, `FindProperty`, `GetCollectionElementType` and `IsCollectionType`. `ValidatePropertyPath` records one only once the path has validated, so a path that fails validation records nothing (3.1.0). The query engine's own calls count too. -- LRU writes `DateTime.UtcNow.Ticks`, LFU adds 1, and FIFO records nothing. Only `EvictionStrategy` +- Under LRU a read refreshes the entry's last-access time, `DateTime.UtcNow.Ticks`, once it is a second old + rather than on every read (3.3.0): eviction only asks which entries are oldest, and an entry read a moment ago + is already among the newest. LFU adds 1 on every read, and FIFO records nothing. Only `EvictionStrategy` decides this; the `Enable*Tracking` flags have no runtime effect. - A record is written even when the key never enters the store, such as an internal field-type lookup. Eviction only deletes records of entries it removes, so `TrackingCounts` can exceed `CacheCounts`. @@ -4922,7 +5149,8 @@ MemoryCalculationInput ``` 3.3.0 A second host may configure the same posture, a path the query cannot compute is refused rather than run, - and Clone is public on Filter, Segment and Summary. + a path or a member the attribute walk cannot name is still policed, and Clone is public on Filter, Segment + and Summary. - New. DwPolicy.Configure, and AddDwPolicies with it, take a second call asking for the posture already in force and do nothing. A different posture still throws. The check is inside the lock that configures, so a caller needs no IsConfigured check of its own — that check is a check-then-act two hosts can both pass. @@ -4961,6 +5189,137 @@ MemoryCalculationInput - New. Clone() is public on Filter, Segment and Summary. A deep copy: the condition tree, the projection list, each order, the page, and a summary's GroupBy and Having. Every node is new; a condition's values are the caller's own objects, in a new list. + - Security fix and behaviour change. A path the attribute walk cannot name takes the policy of the + member it reads. The pipeline validates, and a provider translates, a path continuing beneath a member + whose type the framework declares: Salary.Value and Salary.HasValue on a decimal?, Secret.Length on a + string, Born.Year and Born.Date.Year on a DateTime, Bag.Count on a dictionary, Lines.Count on an + application's own collection class (the collection's own member, not an element's). No attribute can + be placed there and no fragment named such a path, so it resolved as allowed: a [DwDenied] decimal? + was filtered on, sorted by, grouped by with its values as the group keys, aggregated + (MAX(Salary.Value)) and handed back by a dynamic projection, under Strict; a transformed member gave + its stored value the same way; a [DwAudit] member was read with nothing recorded; a [DwCost] member + cost the default; and a [DwOperators] restriction did not hold. Such a path now takes every fragment + of the member it reads, whichever provider supplied it, an attribute and a store rule on Salary alike: + the deny effects per feature, the operator restriction (intersected), the cost weight and the audited + features. It does not take what is said to the caller about the member: the alias, the required filter + ([DwRequireWhere] on TenantId is not satisfied by a filter on TenantId.Value), the forced scope, and + the descriptive facts (label, description, group, order, allowed values). A rule naming the sub-path + itself still applies alongside. One feature is one feature: [DwNoWhere] Born refuses WHERE Born.Year + and still allows GROUP BY Born.Year, and a member nothing denies is read beneath exactly as before, so + Name.Length still runs. A member only a subtype of the navigated type declares is not such a path: + Zone.Parent, where a subclass of Zone's type declares Parent, is decided by the fragments naming it, + so a grant of Zone under a "*" deny does not grant it. Both tiers; present in 3.2.0 and earlier. + - Security fix and behaviour change. Past the attribute walk's depth, the member at the end of the path + is read directly. Caps.MaxNavigationDepth defaults to 4, the depth the walk reads to, and can be + raised; a request naming five or more segments then reached what no attribute fragment covered, and a + [DwDenied] member at segment five was filtered on, grouped by and returned, under Strict. Its + attributes are read now: the deny family, [DwOperators], the transform stages, [DwCost], [DwAudit], + [DwDescribe] and allowed values. What is declared about the queried entity itself is not read there, + as it is not around a cycle: [DwAlias], [DwRequireWhere], [DwForceWhere]. Only a resolver that reads + attributes does this, which every resolver DwPolicy.Configure builds does. Default configuration was + never exposed to this one. + - Behaviour change. Beneath a transformed member there is no member to apply the chain to, Bonus.Value + being a decimal where Bonus is what is rounded, so Select, Group and Aggregate on such a path are + refused: FieldDeniedForSelect, FieldDeniedForGroup and FieldDeniedForAggregate, with a Selects entry + dropped under Convenience as any field denied for Select is. A transformed member past the walk is + itself a member, so it comes back transformed where a row carries it, a typed projection and a + generated row alike: the chains of the members a projection names past the walk are handed to the + outbound walk beside the type's own list. Only Group and Aggregate are refused there, because a + summary's own transform finds a generated row's columns by the type's list, which stops at four + segments. Refusing Select there instead made the projection gate read a masked column five segments + down an Include chain as a denial and leave the whole included navigation out rather than mask the + column. Where and Order follow the member's own decision and run on stored values, as they do along a + named path. + - Security fix. A transform the outbound walk reached no path to is applied to the rows themselves. That + walk transformed along the paths the policy names, the declared types four segments deep, and a value + sitting elsewhere in the materialized rows came back exactly as stored: a [DwMask] member five + segments down an included or in-memory graph (B.C.D.E.Card, while B.C.D.Pin four segments down was + masked), a masked member only a subtype of the row's type declares (Dog.Chip on rows typed Animal, in + memory or in a TPH hierarchy), a masked member of an object a dictionary holds, and the far side of a + cycle, with no Selects, with a navigation named whole in Selects, and in a dynamic projection holding + a real object, at the default caps, under Strict. The rows are walked by run-time type as well now, + and a member that declares a transform attribute and was not transformed along a named path is + transformed by its own attributes, exactly once: an object reached both ways is not transformed twice. + Only members that declare a transform or can lead to one are read, so a navigation whose type can + reach neither a transform nor an audit for Select is never touched and a lazy loader behind it is not + woken, and a model that declares neither anywhere pays for no second pass. A transformed member with + no setter fails the query with InvalidOperationException, as one along a named path does; the trace + records the path with its stages and the note + "(declared on the member; no path of the policy names it)"; no rule can speak to such a + member, since no path names it; a resolver built over no AttributePolicyProvider reads no attribute + here either; and it runs in a dry run, as transforms always have. Default configuration, both tiers. + Still a limit: a member typed object, or a collection that is not generic, says nothing about what it + holds and is not read into. + - Security fix. The attribute walk returned at its depth limit with the type still marked as being + inside it, so a type first met at the fourth segment read as a cycle wherever it was met again in the + same walk, and what a cycle leaves out, [DwForceWhere], [DwRequireWhere] and [DwAlias], was left out + of a shorter path reaching that type directly. Which of two members was declared first decided whether + a forced tenant scope on a navigated type applied. The scope, the requirement and the alias now apply + on every path within four segments that is not around a cycle, as documented. A query that ran + unscoped is scoped, and a required filter may now be demanded. + - Fix. The page offset, (PageNumber - 1) * PageSize, was worked out in 32 bits and wrapped for a large + enough page number: a negative offset is an error on SQL Server and PostgreSQL, so the request became + a five-hundred, and the first page again on SQLite and in memory, so a page far past the last row + returned rows. It is worked out in 64 bits and held to int.MaxValue now, in Page and in the three + summary methods, guarded or not. A page past the last row is an empty page however far past it is, + as it always was for a page number that did not wrap. The policy layer caps PageSize (MaxPageSize) and + never PageNumber, so a guarded query took the same path. + - Security fix. The ASP.NET Core audit middleware drained a request's events with the request's own + abort token, so a client that closed the connection, as the rows arrived or the moment they had, + cancelled the write that follows the response: the sink threw, the middleware logged it, and the + events went with the context, an audited read with nothing written down. The drain has a budget of its + own now, thirty seconds, which the caller cannot cancel and a hung sink cannot outlast. + - Fix. RedisPolicyStore.UpsertAsync and DeleteAsync commit conditionally on the rule's owner entry. + Where a rule lives is read before the transaction that moves or deletes it, so two writers of one rule + could read the same answer: the slower one cleaned up after a copy the faster had already moved and + left that writer's copy behind, a rule applying to a caller nobody any longer wrote it for, with no + owner entry to find it by. The writer that loses the race gets the InvalidOperationException a failed + commit always raised, whose message now says another writer moved or removed the same rule and to + write it again, and should retry. + - Fix. DynamicWhere.ex names Microsoft.Extensions.Caching.Memory 6.0.2, the version patched for + CVE-2024-43483 (GHSA-qj66-m88j-hmgj). EF Core 6.0.22 asks for 6.0.1 or later and 6.0.1 is the last + version open to it, so a host on the EF Core 6 floor resolved a vulnerable version through all four + packages. A host on EF Core 8 or later already resolves a newer one and sees no change. + - Fix. A guarded Summary whose Having carried "conditions": null or "subConditionGroups": null failed + with a NullReferenceException wherever the group floor is on, Caps.MinGroupSize of 2 or more, which + the default of 5 is, although the same summary ran unguarded. A request body sending either overwrites + the list's initializer, and the floor's own walk over Having, which looks for its reserved alias, was + the one reader in the gate that did not check. It runs now, and the floor still applies. + - Performance. A property lookup takes no lock and allocates nothing on a hit. Every lookup of every + query, several per field, locked and copied the cache configuration, allocated an input object for the + tracking call and built a closure whether or not the entry was cached, and under LRU, the default, + wrote the entry's last-access time on every read. One million lookups of one cached member went from + 152 ms to 35 ms on one thread and from 2697 ms to 108 ms on eight, and from 167 MB allocated to 22 MB. + A last-access time is refreshed once it is a second old instead (section 33); GetCacheConfigOptions() + still returns a copy. + - Security fix and behaviour change. SelectDynamic, Group, FilterDynamic and Summary on the guarded + handle hand back a query for the caller to run, which the library never sees materialized, so they are + refused with TransformRequiresMaterialization on a type whose values are transformed on the way out. + Whether a type is one was read from the paths the policy names, so a type whose only transforms sit + off them, on a member only a subtype declares, five segments down, or on an object a dictionary holds, + was handed the query and its rows came back exactly as stored: the same gap the outbound walk's second + pass closed for the terminals, one method call away from them. The refusal asks what a row of the type + can hold as well, any transform attribute anywhere in what the type can reach, read only by a resolver + that reads attributes. With no named column to list it names the clause, FieldPath "*", in both tiers, + where under Convenience it otherwise lists the transformed columns. A type nothing transforms anywhere + still gets its query. + - Security fix and behaviour change. [DwAudit] records a member no path of the policy names, once the + rows show it. The gate records a use by path, before the query runs, and a member only a subtype of + the row's type declares, or one past the four segments the attribute walk reads, has no path it could + ask about: handed back inside a row returned whole or a navigation kept whole, it was read with + nothing written down. The outbound walk's second pass reports each audited member it meets where no + path names it, and the terminal records it: one DwAuditEvent per path per query, not per row, Feature + Select, Effect Mask where the member is transformed as well and Allow otherwise, EntityType the + queried type's full name, and FieldPath the path through the rows. Only a member its own [DwAudit] + audits for Select, and only where the projection carries it, since a member the projection left out is + not a read. A member the declared types hold within four segments is the gate's and is left to it, and + so is a path the projection spells out however long it is, so neither is recorded twice. At + Caps.MaxAuditEvents it fails closed as the gate does and the rows are withheld: under Strict outside a + dry run the clause's own refusal with FieldPath "*", FieldDeniedForSegment inside a segment, and + CapExceeded otherwise. Recorded in a dry run too, read only by a resolver that reads attributes, and a + model that declares neither an audit for Select nor a transform anywhere pays for no second pass. + Default configuration, both tiers; a deployment already running the control sees more events for such + models. - Docs. DwEntity(DefaultOrder)'s remarks said a projected query takes no default; it has since 3.2.0. The aggregation sections now lead with Caps.MinGroupSize shipping on at 5. @@ -5285,6 +5644,19 @@ MemoryCalculationInput translate it. - A default order reaches a projected row only through columns of the entity its `Select` reads: a projection over an anonymous or other intermediate row takes no default. +- A path beneath a member whose type the framework declares (`Salary.Value`, `Born.Year`) can carry no + attribute of its own. It takes that member's deny effects, operator restriction, cost and audit, and never its + alias, required filter, forced scope or description (3.3.0, section 14); a rule naming the path itself still + applies alongside. Where the member is transformed, such a path cannot be selected, grouped or aggregated at + all: there is no member beneath it to apply the chain to. +- Past the attribute walk's four segments, which only a raised `Caps.MaxNavigationDepth` lets a request name, the + member at a path's end is read for its own attributes (3.3.0, section 14), and what is declared about the + queried entity itself is not read there, as it is not around a cycle: `[DwAlias]`, `[DwRequireWhere]`, + `[DwForceWhere]`. A transformed member there cannot be a grouping key or an aggregated field, because a + summary's own transform finds a generated row's columns by the type's list, which stops at four segments. +- A transform the policy names no path to is applied to the rows by run-time type (3.3.0, section 18). A member + typed `object`, or a collection that is not generic, says nothing about what it holds and is not read into. + Type the member as what it holds. - A simulation reads T as a source it cannot see into (section 23): every denial beneath a member counts, and a synthesized `Clause.Selects` keeps only members holding a value. diff --git a/README.md b/README.md index a405278..3f47fca 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Dependencies (restored automatically): |---------|--------:| | `Microsoft.EntityFrameworkCore` | `6.0.22` | | `System.Linq.Dynamic.Core` | `1.6.7` | +| `Microsoft.Extensions.Caching.Memory` | `6.0.2` | | `Microsoft.Extensions.Configuration.Abstractions` | `6.0.0` | | `Microsoft.Extensions.Configuration.Binder` | `6.0.0` | | `Microsoft.Extensions.DependencyInjection.Abstractions` | `6.0.0` | @@ -377,13 +378,23 @@ The complete reference — every enum, class, extension method, validation rule, ## Version 3.3.0 highlights -**Upgrade note — two behaviour changes. Read these before bumping.** +**Upgrade note — several behaviour changes, most of them security fixes. Read these before bumping.** - **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a value's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor, and `IncludeTraceInResult` written as the tier's own answer is the answer the tier already gives. A type exposed under two names is reported under the last one, so two catalogues that resolve every name alike are still a different posture when the order differs. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. - **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query any provider but EF Core's own translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()`, or a host's own registered through `ReplaceService` — because such a provider may rewrite what EF Core cannot, and the library cannot tell one that does from one that passes straight through. The test is EF Core's own provider type, from EF Core's own assembly. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. - **Fixed (security): an audited field could be read with nothing recorded.** `[DwAudit]` answers who read a field, and a request that sent no `Selects` received the whole row with nothing written down — one token past the control. Every audited member a projection the caller did not name hands back is now recorded for `Select`, one event per query rather than per row. A deployment already running the control sees more events, and `DwCaps.MaxAuditEvents` refuses rather than dropping a record, so raise the cap or drain per request with `app.UseDwPolicyAudit()`. - **Fixed (security): four refusals named a field under `Strict`.** A strict refusal names no field, so that a denied one, a misspelling and a field that does not exist cannot be told apart. `AmbiguousFieldName` said the caller's name matched more than one field, and so at least one; it is refused as an unknown name is now, with the ambiguity kept in the trace. `AmbiguousGroupKey` reported the column behind the caller's alias and an origin saying its values are transformed; `TransformRequiresMaterialization` listed every transformed column on the type; `MissingHashSalt` and `MissingTokenVault` named the masked field a deployment had not configured for. The grouping key, the hash salt and the token vault report `"*"` and no origin now, and the transform refusal reports `"*"` and keeps an origin that names the method rather than a field. `Convenience` and a dry run, whichever switch declares it, are unchanged. - **Fixed (security): the audit cap answered differently for a real field.** An audited field records one event per use, and the query is refused rather than the record dropped once `DwCaps.MaxAuditEvents` is reached. That refusal carried `CapExceeded` and a `SourceOrigin` naming the cap, while a name matching nothing carried the ordinary field refusal and no origin — and an unknown name is never audited, so one guess per request told a caller which names are real and audited. Under `Strict`, outside a dry run, the cap now refuses with the clause's own code, `FieldPath` `"*"` and no origin; the request still fails and the trace still records which refusal it was. `Convenience` and a dry run are unchanged. +- **Fixed (security): a path the attribute walk cannot name had no policy at all.** No attribute can be placed beneath a member whose type the framework declares — `Salary.Value` on a `decimal?`, `Secret.Length`, `Born.Year`, `Bag.Count`, `Lines.Count` on an application's own collection class — and no fragment named such a path, so a `[DwDenied] decimal?` was filtered on, sorted by, grouped by with its values as the group keys, aggregated and handed back by a dynamic projection, under `Strict`; a transformed member gave its stored value, an audited one was read with nothing recorded, a weighted one cost the default. Such a path now takes the policy of the member it reads — denials, operators, cost and audit, from whichever provider supplied them — and never that member's alias, required filter, forced scope or description. A member only a subtype declares is not such a path. Raising `Caps.MaxNavigationDepth` above 4 opened the same hole past the walk's four segments; the attributes of the member at the end of such a path are read directly now. +- **Fixed (security): a transform the outbound walk reached no path to.** The walk transformed along the paths the policy names, four segments of the declared types, so a `[DwMask]` member five segments down an included graph, one only a subtype of the row's type declares, one on an object a dictionary holds and the far side of a cycle came back exactly as stored — default configuration, default caps, under `Strict`. The rows are walked by run-time type as well now, and a member that declares a transform and was not transformed along a named path is transformed by its own attributes, once. Only what can lead to such a member is read, so a model that declares neither a transform nor an audit for `Select` anywhere pays for no second pass. The four methods that hand back a query for the caller to run — `SelectDynamic`, `Group`, `FilterDynamic`, `Summary` — are refused with `TransformRequiresMaterialization` on such a type too, where they used to hand over the query and its rows as stored. +- **Fixed (security): an audited member no path names was read with nothing written down.** `[DwAudit]` records a use by path, in the gate, before the query runs, and a member only a subtype of the row's type declares, or one past the four segments the attribute walk reads, has no path it could ask about — so it came back inside a row or a navigation returned whole, unrecorded. The same pass that finds such members for transforms now reports them: one event per path per query, for `Select`, with `Effect` `Mask` where the member is transformed as well. At `DwCaps.MaxAuditEvents` it fails closed as the gate does and the rows are withheld. A deployment already running the control sees more events for such models. +- **Fixed (security): a forced scope could depend on which member was declared first.** The attribute walk returned at its depth limit with the type still marked as being inside it, so a type first met at the fourth segment read as a cycle wherever it was met again — and what a cycle leaves out, `[DwForceWhere]`, `[DwRequireWhere]` and `[DwAlias]`, was dropped from a shorter path reaching that type directly. All three now apply on every path within four segments that is not around a cycle: a query that ran unscoped is scoped, and a required filter may now be demanded. +- **Fixed (security): a caller who hung up cancelled the record of what they read.** `app.UseDwPolicyAudit()` drained a request's events with the request's own abort token, so a client that closed the connection cancelled the write that follows the response and the events were logged and dropped. The drain has a budget of its own now, thirty seconds, which the caller cannot cancel and a hung sink cannot outlast. +- **Fixed: a page number whose offset passes `Int32`.** The offset, `(PageNumber - 1) * PageSize`, was worked out in 32 bits and wrapped: a five-hundred on SQL Server and PostgreSQL, the first page again on SQLite and in memory. It is worked out in 64 bits and held to `int.MaxValue` now, in `Page` and in the three summary methods, so a page past the last row is an empty page however far past it is. +- **Fixed: two Redis writers moving one rule could leave a stale copy.** `RedisPolicyStore.UpsertAsync` and `DeleteAsync` commit conditionally on the rule's owner entry. The writer that loses the race gets the `InvalidOperationException` a failed commit always raised, and should write again. +- **Fixed: a guarded `Summary` whose `Having` carried a null list.** A request body sending `"conditions": null` or `"subConditionGroups": null` overwrites the list's initializer, and the group floor's own walk over `Having` was the one reader in the gate that did not check, so such a summary failed with a `NullReferenceException` wherever the floor is on — the default — though it ran unguarded. It runs, and the floor still applies. +- **Performance: a reflection-cache lookup takes no lock and allocates nothing on a hit.** Every lookup of every query locked and copied the cache configuration and, under `LRU`, wrote the entry's last-access time on every read. One million lookups of one cached member went from 152 ms to 35 ms on one thread and from 2,697 ms to 108 ms on eight, and from 167 MB allocated to 22 MB. A last-access time is refreshed once it is a second old instead. +- **Packaging: `Microsoft.Extensions.Caching.Memory` 6.0.2** is named directly, the version patched for CVE-2024-43483. EF Core 6.0.22 asks for 6.0.1 or later and 6.0.1 is the last version open to it, so a host on the EF Core 6 floor resolved a vulnerable version through all four packages. A host on EF Core 8 or later sees no change. - **Changed: `LastTrace` is set before a request is sanitized**, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. - **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. Every node is new; a condition's values stay the caller's own objects, in a new list. - **Docs:** `[DwEntity(DefaultOrder)]`'s own remarks said a projected query takes no default; it has since 3.2.0. `DwCaps.MinGroupSize` ships **on at 5**, so a guarded summary silently drops groups of fewer than five rows — the aggregation docs now lead with that instead of leaving it to the caps table. From 0452005ab0c6d23d4f5d604c92f7ce20305860bb Mon Sep 17 00:00:00 2001 From: Sajjad Hussain Date: Mon, 21 Sep 2026 05:09:29 +0300 Subject: [PATCH 49/54] build(tests): the test project restores with no open advisory Five packages the test project resolved carried an open advisory, none of them shipped: System.Net.Http 4.3.0 and System.Text.RegularExpressions 4.3.0 through xunit's NETStandard.Library graph, the native SQLite build EF Core's provider asks for (every SQLitePCLRaw.lib.e_sqlite3 up to 2.1.11), SSH.NET 2023.0.0 through Testcontainers 4.0.0, and on the floor leg System.Text.Json 6.0.0 through EF Core 6's SQLite provider. Each patched version is named directly, and Testcontainers moves to 4.15.0, which asks for SSH.NET 2026.0.0. Its builders take the image in the constructor now; the parameterless one is obsolete. dotnet list package --vulnerable --include-transitive is clean on both legs, and the floor leg still resolves EF Core 6.0.22. Co-Authored-By: Claude Fable 5.1 --- DynamicWhere.Tests/DynamicWhere.Tests.csproj | 20 +++++++++++++++++-- .../Policies/EfStoreConformanceTests.cs | 2 +- .../Policies/PolicyAdminControllerTests.cs | 2 +- .../PostgresTokenVaultConformanceTests.cs | 2 +- .../Policies/RedisStoreConformanceTests.cs | 2 +- .../RedisTokenVaultConformanceTests.cs | 2 +- 6 files changed, 23 insertions(+), 7 deletions(-) diff --git a/DynamicWhere.Tests/DynamicWhere.Tests.csproj b/DynamicWhere.Tests/DynamicWhere.Tests.csproj index 0ea1477..d221b97 100644 --- a/DynamicWhere.Tests/DynamicWhere.Tests.csproj +++ b/DynamicWhere.Tests/DynamicWhere.Tests.csproj @@ -41,6 +41,18 @@ + + + + + - - + + @@ -77,6 +89,10 @@ where the expression-tree differences between EF Core 6 and 8 would surface. The sales fixture is excluded because it maps DateOnly/TimeOnly, which EF Core 6 cannot. --> + + 3.3.0 - v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. No API or behaviour change in this package. + v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. One new constructor, and no behaviour change to the existing one. EfTokenVault takes a key: new EfTokenVault(Func<DbContext> contexts, byte[] key, bool retireUnkeyed = false). Without a key a row of DwPolicyTokens is keyed by the scope and a plain SHA-256 of the value, and a tokenized column is nearly always drawn from a space small enough to hash whole - phone numbers, national identifiers, card numbers - so a backup, a replica or a dump of the table gives back every value in it, and with them the value behind every token ever issued. Under a key of at least DwToken.MinimumKeyLength, sixteen bytes, held where the table is not, the row is keyed by an HMAC-SHA256 behind the prefix "hmac:", and the table and the key have to be taken together. The existing constructor is unchanged and unkeyed. A value met for the first time under the key is looked up under its unkeyed key too, and a token found there is the one written under the keyed key, so every token already issued is kept, at the cost of one more read for a value new to the store and, in retire mode, one more read per first-met value. The unkeyed row stays until retireUnkeyed is true, and a retiring vault deletes it the first time it meets the value, whether it wrote the keyed row or found it; a concurrent delete of the same row is not an error. Roll out in two steps: give every instance the key, then turn retireUnkeyed on, because an instance still running without the key mints a new token for a value whose unkeyed row is gone. No schema change and no migration: a keyed key is at most 326 characters against the 512 the Key column already holds. Delete the rows whose Key does not start with hmac: to clear what is left, knowing such a value gets a new token the next time it is met. v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. @@ -45,7 +45,7 @@ v3.0.0 — First release. A database-backed policy store for the DynamicWhere.ex THREE TABLES, NOT TWO. Alongside DwPolicyRules and DwPolicyVersion, this release adds DwPolicyTokens and DwPolicyTokenConfiguration — the vault behind MaskStrategy.Tokenize, read by EfTokenVault. A consumer applying the configurations to their own DbContext must apply all three and generate a migration for the new table; a deployment that never tokenizes can leave it out rather than migrating in a table it will never write to. The table holds the scope in the clear and a digest of the value, never the value itself, so it is an index into a secret rather than a searchable copy of the column it protects. Guard it as you would guard that column: reading it turns every token in every result back into what it stands for. -EfTokenVault caches every mapping it resolves, which is safe because a token is written once and never rewritten, and settles a concurrent mint through the primary key rather than by retrying. Requires DynamicWhere.ex 3.0.0. +Guard that table as you would guard the column it protects, and give the vault a key (3.3.0). EfTokenVault caches every mapping it resolves, which is safe because a token is written once and never rewritten, and settles a concurrent mint through the primary key rather than by retrying. Requires DynamicWhere.ex 3.0.0. https://doc.dynamicwhere.com git master diff --git a/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj b/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj index a1c92c3..0edc8b8 100644 --- a/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj +++ b/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj @@ -35,7 +35,7 @@ disagree, because publish.yml packs all three from one push and a provider shipped at a version its core was never built against is a dependency nobody tested. --> 3.3.0 - v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. No API change in this package, and one fix. RedisPolicyStore.UpsertAsync and DeleteAsync commit conditionally on the rule's owner entry. Where a rule lives is read before the transaction that moves or deletes it, so two writers of one rule could read the same answer: the slower one then cleaned up after a copy the faster had already moved and left that writer's copy behind, under a user nobody any longer wrote it for and with no owner entry pointing at it, which no later write or delete could find. The writer that loses the race now gets the InvalidOperationException a failed commit always raised, whose message says another writer moved or removed the same rule in the meantime and to write it again, and should retry. + v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. One new constructor and one fix. RedisPolicyStore.UpsertAsync and DeleteAsync commit conditionally on the rule's owner entry. Where a rule lives is read before the transaction that moves or deletes it, so two writers of one rule could read the same answer: the slower one then cleaned up after a copy the faster had already moved and left that writer's copy behind, under a user nobody any longer wrote it for and with no owner entry pointing at it, which no later write or delete could find. The writer that loses the race now gets the InvalidOperationException a failed commit always raised, whose message says another writer moved or removed the same rule in the meantime and to write it again, and should retry. RedisTokenVault takes a key: new RedisTokenVault(IConnectionMultiplexer redis, byte[] key, string prefix = null, bool retireUnkeyed = false). Without a key a field of the token hash is the scope and a plain SHA-256 of the value, and a tokenized column is nearly always drawn from a space small enough to hash whole - phone numbers, national identifiers, card numbers - so a backup, a replica or a dump of the hash gives back every value in it, and with them the value behind every token ever issued. Under a key of at least DwToken.MinimumKeyLength, sixteen bytes, held where the hash is not, the field is an HMAC-SHA256 behind the prefix "hmac:", and the hash and the key have to be taken together. The existing constructor is unchanged and unkeyed. A value met for the first time under the key is looked up under its unkeyed field too, and a token found there is the one written under the keyed field, so every token already issued is kept; both fields are read in one round trip, so a value new to the store costs two round trips instead of one. The unkeyed field stays until retireUnkeyed is true, and a retiring vault deletes it the first time it meets the value, whether it wrote the keyed field or found it. Roll out in two steps: give every instance the key, then turn retireUnkeyed on, because an instance still running without the key mints a new token for a value whose unkeyed field is gone. One hash holds both kinds of field while a deployment moves between them; HSCAN and delete what does not match hmac:* to clear the rest, knowing such a value gets a new token the next time it is met. v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. @@ -43,7 +43,7 @@ v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now require v3.0.0 — First release. A Redis-backed policy store for the DynamicWhere.ex field-level policy layer, introduced in DynamicWhere.ex 3.0.0. Holds runtime rules in Redis and invalidates through pub/sub with a poll behind it, because pub/sub is fire-and-forget and the poll is what bounds a dropped message. Passes the same store conformance suite as the in-memory and Entity Framework Core stores. -Also ships RedisTokenVault, the durable store behind MaskStrategy.Tokenize. The mapping lives in one hash under the same prefix as the rules, holding the scope in the clear and a digest of the value rather than the value itself. It caches every mapping it resolves, which is safe because a token is written once and never rewritten, and settles a concurrent mint with HSETNX rather than by retrying. Guard that hash as you would guard the column it protects: reading it turns every token in every result back into what it stands for. Two applications sharing one Redis want two prefixes, or a value tokenized in one is recognisable in the other. Requires DynamicWhere.ex 3.0.0. +Also ships RedisTokenVault, the durable store behind MaskStrategy.Tokenize. The mapping lives in one hash under the same prefix as the rules, holding the scope in the clear and a digest of the value rather than the value itself. It caches every mapping it resolves, which is safe because a token is written once and never rewritten, and settles a concurrent mint with HSETNX rather than by retrying. Guard that hash as you would guard the column it protects: reading it turns every token in every result back into what it stands for, unless the vault holds a key (3.3.0). Two applications sharing one Redis want two prefixes, or a value tokenized in one is recognisable in the other. Requires DynamicWhere.ex 3.0.0. https://doc.dynamicwhere.com git master diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index ccb1c0e..64f7e89 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -110,7 +110,7 @@ Specifies the logical data type of a condition value. The library uses this to c |-------|-------------|---------------------| | `Text` | String data | All text operators including case-insensitive variants (`I*`), `In`, `IsNull` | | `Guid` | GUID as string | `Equal`, `NotEqual`, `In`, `NotIn`, `IsNull`, `IsNotNull` | -| `Number` | Numeric value (byte → decimal) | `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `Between`, `NotBetween`, `In`, `NotIn`, `IsNull`, `IsNotNull` | +| `Number` | Numeric value (byte → decimal). The value is read as the expression parser reads it, in the invariant culture, and has to compare with the member (3.3.0) — see [Condition Validation Rules](#condition-validation-rules) | `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `Between`, `NotBetween`, `In`, `NotIn`, `IsNull`, `IsNotNull` | | `Boolean` | `true` / `false` | `Equal`, `NotEqual`, `IsNull`, `IsNotNull` | | `DateTime` | Full timestamp. Works on `DateTime` and `DateTimeOffset` members, nullable or not | `Equal`, `NotEqual`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `Between`, `NotBetween`, `IsNull`, `IsNotNull` | | `Date` | Calendar day, compared on both sides | Same as `DateTime` (compares the day only) | @@ -302,6 +302,8 @@ The library normalizes every element before validation/build: A value is read once to validate its format and again to build the predicate, so pass values that do not change: one whose `ToString()` answers differently each time is validated as one value and queried as another. Anything decoded from JSON is such a value already. No policy decision reads a value's content — only how many there are — so nothing a guard decides rests on which read won. +**A number is read as the expression parser reads it (3.3.0).** The builder writes a `Number` value into the generated expression unquoted, exactly as sent, so validation reads it the same way rather than through the host's culture. First the parser's grammar, in the invariant culture and ASCII digits only: optional white space, an optional minus, digits, an optional fraction — a point with a digit on both sides — and an optional exponent. No leading plus, no thousands separator, no trailing sign, no parentheses, no `NaN` and no `Infinity`; an integer must fit `UInt64`, or `Int64` when negative, while a real has no bound. Then, in a `Where` condition and for the operators that write the value into a comparison, whether that literal compares with the member the condition names — the parser itself is asked, against the member's declared type, so `1.5` is refused on an `int?` but not on an `int`, an exponent form on a `decimal`, an integer above `Int64.MaxValue` on a signed integral member, a negative number on a `ulong`, any number on a `string`, `bool`, `Guid`, `DateTime` or `char` member or on a collection of simple values, and a nullable enum under an ordering operator. A `Having` condition reads the grammar and stops, since an alias has no member type to ask about. Everything refused is `InvalidFormat`, the same in both policy tiers, and nothing that ran before is refused now. JavaScript writes `0.0000001` as `1e-7`, which a `decimal` member refuses; send it as the string `"0.0000001"`. See breaking point 38. + --- #### `ConditionGroup` @@ -388,7 +390,7 @@ Combines filtering, selecting, ordering, and pagination in a single object. | `Orders` | `List?` | Optional sort criteria | | `Page` | `PageBy?` | Optional pagination | -**`Clone()`** *(public since 3.3.0)* returns a deep copy — the condition tree with its groups and conditions, the projection list, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. +**`Clone()`** *(public since 3.3.0)* returns a deep copy — the condition tree with its groups and conditions, the projection list, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. A null entry inside a list is copied as a null entry rather than failing on it (3.3.0), so the refusal belongs to the method that runs the request and reads the same for a copy; it used to throw `NullReferenceException`. --- @@ -403,7 +405,7 @@ Combines multiple condition sets with set operations (Union / Intersect / Except | `Orders` | `List?` | Optional sort criteria | | `Page` | `PageBy?` | Optional pagination | -**`Clone()`** *(public since 3.3.0)* returns a deep copy — every condition set with its own condition group, the projection list, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. +**`Clone()`** *(public since 3.3.0)* returns a deep copy — every condition set with its own condition group, the projection list, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. A null entry inside a list is copied as a null entry rather than failing on it (3.3.0), so the refusal belongs to the method that runs the request and reads the same for a copy; it used to throw `NullReferenceException`. --- @@ -419,7 +421,7 @@ Combines filtering → grouping → having → ordering → pagination for aggre | `Orders` | `List?` | Sort on grouped result. Fields must be GroupBy fields or aggregate aliases | | `Page` | `PageBy?` | Optional pagination on grouped result | -**`Clone()`** *(public since 3.3.0)* returns a deep copy — the condition group, the group-by with its aggregates, the having clause, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. +**`Clone()`** *(public since 3.3.0)* returns a deep copy — the condition group, the group-by with its aggregates, the having clause, each order and the page — every node new, though the values a condition carries stay the caller's own objects in a new list — so reading the same request again with one part changed, the next page or another order, never edits what the caller handed in. Rebuilding a request around the caller's own clauses leaves both holding one condition tree, and a rewrite of either reaches both. A null entry inside a list is copied as a null entry rather than failing on it (3.3.0), so the refusal belongs to the method that runs the request and reads the same for a copy; it used to throw `NullReferenceException`. --- @@ -483,6 +485,7 @@ Projects only the specified fields into a new instance of `T`. Supports direct p **Validations:** - `query` and `fields` cannot be null. - `fields` must have at least one entry. +- No entry may be null or blank — `InvalidField` since 3.3.0, where it used to be an `ArgumentNullException` from the name lookup. - Every field must exist on `T` (case-insensitive, auto-normalized). - `T` must have a parameterless constructor. @@ -518,6 +521,7 @@ Multiple dotted fields sharing the same root segment are merged into the same ne **Validations:** - `query` and `fields` cannot be null. - `fields` must have at least one entry. +- No entry may be null or blank — `InvalidField` since 3.3.0, where it used to be an `ArgumentNullException` from the name lookup. - Every field must exist on `T` (case-insensitive, auto-normalized). **Returns:** `IQueryable` — a dynamic projected query where each element is an anonymous object. @@ -754,6 +758,8 @@ app.MapPost("/customers/search", async (Filter filter, AppDbContext db, Cancella ## Validation Rules +**Before any of these (3.3.0).** Every method that takes a shape walks its lists for a null entry first, with or without a policy, in both tiers, sync and async: the composables `Where(ConditionGroup)`, `Order(List)`, `Select`, `SelectDynamic`, `Group` and `Summary`, and every terminal for a `Filter`, a `Segment` and a `Summary`. `Filter` and `FilterDynamic` compose `Where`, `Order` and `Select`, so each list is walked as its clause is reached. Under `ApplyPolicy` the walk runs at the top of the sanitizer, before the caps and before the gate, because it is about the request's shape and not a policy decision. A null entry in `Conditions`, `SubConditionGroups`, `ConditionSets`, `Orders` or `AggregateBy` is `NullEntry(list)`; a `Selects` entry that is null or blank is `InvalidField`. A list that is itself null still means what it meant — most readers read it as empty. Before 3.3.0 a null entry surfaced as a `NullReferenceException` or an `ArgumentNullException` from inside the library. See breaking point 39. + ### Condition Validation Rules | Rule | Error Code | @@ -766,7 +772,7 @@ app.MapPost("/customers/search", async (Filter filter, AppDbContext db, Cancella | All other operators require exactly 1 value | `RequiredOneValue({Operator})` | | A null or blank value is **not** refused as such: it normalizes to `""`, which `Text` and `Enum` accept and every other DataType rejects on parsing | `InvalidFormat` — `ErrorCode.InvalidValue` exists but is never thrown | | `Guid` values must parse as `Guid` | `InvalidFormat` | -| `Number` values must parse as a numeric type | `InvalidFormat` | +| `Number` values must be a literal the expression parser reads — invariant, no thousands separator, no leading plus, no `NaN` — and, in a `Where` condition, one it can compare with the member the condition names (3.3.0) | `InvalidFormat` | | `Boolean` values must parse as `bool` | `InvalidFormat` | | `Date` / `DateTime` values must be ISO 8601, year-first, or a declared format | `InvalidFormat`, or `AmbiguousDateFormat` for a day/month-first date | @@ -1924,8 +1930,8 @@ new DwPolicyOptions |---|---|---| | Output | 64 hex characters (HMAC-SHA256) | 32 hex characters (16 random bytes) | | Derived from the value | yes | no | -| Reversed by | holding the salt | reading the vault | -| A weak secret | brute-forced offline | does not exist | +| Reversed by | holding the salt | reading the vault, and its key where it has one | +| A weak secret | brute-forced offline | only a vault key under 16 bytes, which is refused | | Survives a restart | always | only with a durable vault | | Discloses equality | yes | yes | @@ -1935,6 +1941,49 @@ process, which is right for a test and wrong for any column compared across rest mapping it resolves — a token is written once and never rewritten, so a cached answer cannot go stale. +**Give a durable vault a key (3.3.0).** A vault stores its mapping under the scope and a digest of +the value. Without a key that digest is a plain SHA-256, and a tokenized column is nearly always +drawn from a space small enough to hash whole — phone numbers, national identifiers, card numbers. +So a copy of the store, a backup or a replica or a dump, gives back every value in it, and with them +the value behind every token ever issued. Under a key held where the store is not, in configuration +or a secret manager, the digest is an HMAC-SHA256 and the store and the key have to be taken +together. Guard the store as you would guard the column it protects either way. + +```csharp +new DwPolicyOptions +{ + TokenVault = new RedisTokenVault(redis, key) // 16 bytes or more + // TokenVault = new EfTokenVault(() => new AppDbContext(opts), key) +} +``` + +The constructors that take no key are unchanged and unkeyed, and so is `DwToken.KeyFor(scope, +value)`. `DwToken.KeyFor(scope, value, key)` writes `hmac:{scope}:{64 lowercase hex}`, an HMAC-SHA256 +under the key over the scope, one zero byte and the value, so one value tokenized in two scopes +shares no digest; `DwToken.RequireKey` refuses a key that is null or shorter than +`DwToken.MinimumKeyLength` (16) and returns a copy of it, and `DwToken.KeyedPrefix` is the `hmac:` +an operator can tell the two kinds of key apart by. `InMemoryTokenVault` draws a random 32-byte key +of its own per instance — nothing to configure, no API change — since its mappings die with the +process anyway. + +**Adoption keeps every token already issued.** A keyed vault meeting a value with no keyed mapping +looks up the unkeyed mapping too, and the token found there is the one written under the keyed key, +so yesterday's export still lines up with today's. The unkeyed mapping stays until `retireUnkeyed` +is true, and a retiring vault deletes it the first time it meets the value, whether it wrote the +keyed mapping or found it. **Roll out in two steps: give every instance the key, then turn +`retireUnkeyed` on.** An instance still running without the key mints a *new* token for a value +whose unkeyed mapping is gone, and a value first met while keyed and unkeyed instances run side by +side can end up with two tokens. Unkeyed mappings of values never met again stay until an operator +deletes them — `HSCAN` the Redis token hash and delete the fields that do not match `hmac:*`, or +delete the rows of `DwPolicyTokens` whose `Key` does not start with `hmac:` — knowing such a value +gets a new token the next time it is met. Changing the key re-issues every token, unless unkeyed +mappings remain to adopt from. + +The cost is small and there is no schema change. Redis reads both fields in one round trip, so a +value new to the store costs two round trips instead of one; EF Core costs one more read for a new +value, and in retire mode one more read per first-met value. A keyed key is at most 326 characters +against the 512 the `Key` column already holds. + Tokens are namespaced by the field's own path, so two columns holding the same value get different tokens. Name a shared `TokenScope` when you want them to match: @@ -2322,7 +2371,8 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of | `ConditionsUniqueSort` | `AnyListOfConditionsMustHasUniqueSortValue` | Duplicate Sort in Conditions | | `SubConditionsGroupsUniqueSort` | `AnyListOfSubConditionsGroupsMustHasUniqueSortValue` | Duplicate Sort in SubConditionGroups | | `RequiredIntersection` | `ConditionsSetOfIndex[1-N]MustHasIntersection` | Missing Intersection on set index 1+ | -| `InvalidField` | `ConditionMustHasValidFieldName` | Empty or invalid field name. Under `ApplyPolicy` in the strict tier, outside a dry run, a name that matches nothing is refused as a `PolicyException` instead, like a denied field — see [Blocked-action semantics](#blocked-action-semantics) | +| `InvalidField` | `ConditionMustHasValidFieldName` | Empty or invalid field name, and since 3.3.0 a `Selects` entry that is null or blank, where it used to be an `ArgumentNullException` from the name lookup. Under `ApplyPolicy` in the strict tier, outside a dry run, a name that matches nothing is refused as a `PolicyException` instead, like a denied field — see [Blocked-action semantics](#blocked-action-semantics) | +| `NullEntry(list)` | `ListOf[{list}]MustNotHasNullEntry` | A list of the request shape holds a null entry — `Conditions`, `SubConditionGroups`, `ConditionSets`, `Orders` or `AggregateBy`, spelled as the shape declares it. New in 3.3.0: such an entry used to surface as a `NullReferenceException` from wherever it was first touched | | `StartsWithReservedName(path)` | `FieldPath[{path}]StartsWithReservedName` | A field path whose first segment is one of the expression parser's own words — `new`, `iif`, `np`, `isnull`, `is`, `as`, `cast`, `true`, `false`, `null`, whatever the letter case. Raised for every clause that takes a path, and for a `[DwAlias]` target. `LogicException.Subject` carries that first segment, trimmed. A `DefaultOrder` entry naming one is skipped like an unreadable entry, and reported by the startup scan. 3.1.0 | | `InvalidValue` | `ConditionValuesAreNullOrWhiteSpace` | Defined and never thrown. A null value normalizes to `""` and is judged by the DataType like any other string | | `RequiredValues` | `ConditionWithOperator[In-IIn-NotIn-INotIn]MustHasOneOrMoreValues` | In/NotIn with 0 values | @@ -2332,7 +2382,7 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of | `InvalidPageNumber` | `PageNumberMustBeGreaterThanZero` | PageNumber ≤ 0 | | `InvalidPageSize` | `PageSizeMustBeGreaterThanZero` | PageSize ≤ 0 | | `MustHaveFields` | `MustHasFields` | Empty fields list in Select | -| `InvalidFormat` | `InvalidFormat` | Value doesn't parse for declared DataType. For a date: not ISO 8601, year-first, or a declared format | +| `InvalidFormat` | `InvalidFormat` | Value doesn't parse for declared DataType. For a number (3.3.0): not a literal the expression parser reads, or not one it can compare with the member the condition names. For a date: not ISO 8601, year-first, or a declared format | | `AmbiguousDateFormat` | `AmbiguousDateFormat` | A date value that leads with a day or a month (`01/09/2026`) and matches no declared format, or one two accepted formats read differently. `LogicException.Subject` carries the field: its path, and under `ApplyPolicy` the name the caller wrote | | `SelectTypeMustHaveParameterlessConstructor` | `SelectTypeMustHaveParameterlessConstructor` | `Select` or `Filter.Selects` on a `T` the projection cannot construct. `LogicException.Subject` carries the type's name, `typeof(T).Name` | | `InvalidAlias` | `AggregationMustHasValidAlias` | Alias is not a plain identifier — empty, or carrying a dot, comma, space, or dash | @@ -2506,6 +2556,20 @@ These are numbered as this document numbers them. The website's [breaking-change 37. **`[DwAudit]` Records a Member No Path Names** Point 28 closed the read a request did not spell out; this closes the read the policy has no path for at all. The gate records a use by path, before the query runs, and a member only a subtype of the row's type declares, or one past the four segments the attribute walk reads, has no path it could ask about — so, handed back inside a row returned whole or a navigation kept whole, it was read with nothing written down. In a probe with four audited members, two were recorded. Fixed (security) in 3.3.0, in default configuration and both tiers: the outbound walk's second pass reports each audited member it meets where no path names it, and the terminal records it — one `DwAuditEvent` per path per query, not per row, `Feature` `Select`, `Effect` `Mask` where the member is transformed as well and `Allow` otherwise, and `FieldPath` the path through the rows. Only a member its own `[DwAudit]` audits for `Select`, and only where the projection carries it; a member the declared types hold within four segments is the gate's, and so is a path the projection spells out however long it is, so neither is recorded twice. Recorded in a dry run too, and read only by a resolver that reads attributes. At `DwCaps.MaxAuditEvents` it fails closed as the gate does and the rows are withheld: under `Strict` outside a dry run the clause's own refusal with `FieldPath` `"*"`, `FieldDeniedForSegment` inside a segment, and `CapExceeded` otherwise. A deployment already running the control sees more events for such models, and the cap can be reached by traffic that did not reach it before: raise it, or drain per request with `app.UseDwPolicyAudit()`. +38. **A `Number` Value Is Read the Way the Expression Parser Reads It** + The predicate builder writes a `DataType.Number` value into the generated expression unquoted, exactly as sent, and validation checked it with `byte`/`short`/`int`/`long`/`float`/`double`/`decimal` `TryParse` in the host's culture. The two disagreed. `"1,000"`, `"5-"`, `"+5"`, `".5"`, `"5."`, `"-.5"`, `"1.e5"`, `"NaN"`, `"Infinity"`, `"-Infinity"` and an integer past `UInt64` — or below `Int64` when negative — all passed validation and then threw `System.Linq.Dynamic.Core.Exceptions.ParseException` when the query was built, which a host maps to a server error; `"1,5"` passed on a German host and was refused on an English one; and `"NaN"` and `"Infinity"` were written into the expression as identifiers, so on a type with a member of that name the condition compared two columns instead of filtering. + + A value is read in two steps since 3.3.0. First the parser's own grammar, in the invariant culture and ASCII digits only: optional white space, an optional minus, digits, an optional fraction — a point with a digit on both sides — and an optional exponent. No leading plus, no thousands separator, no trailing sign, no parentheses, no `NaN` and no `Infinity`; an integer must fit `UInt64`, or `Int64` when negative, while a real has no bound, so `1e400` still reads as infinity. A suffix (`5L`, `5m`), hex and `- 5` are refused as they always were, though the parser would read them: nothing is accepted now that was not accepted before. Then, in a `Where` condition and for the operators that write the value into a comparison — `Equal`, `NotEqual`, `In`, `NotIn`, the four orderings, `Between` and `NotBetween` — the literal has to compare with the member the condition names, which the parser itself is asked, against the member's declared type. Refused there: a literal written with a point and no exponent (`1.5`) on a **nullable** integral member, where a non-nullable `int` still takes it; an exponent form (`1e5`, `1E-7`) on a `decimal` or `decimal?`, and a real with more digits than a `decimal` holds; an integer above `Int64.MaxValue` on a signed integral member, since such a literal reads as a `ulong` which none of them converts to; a negative number on a `ulong` or `ulong?`; any number on a `string`, `bool`, `Guid`, `DateTime` or `char` member, or on a collection of simple values such as `List`; and a nullable enum under an ordering operator, where equality still works. A `Having` condition reads the grammar and stops, since an alias has no member type to ask about. + + Every refusal is a `LogicException` with `InvalidFormat`, the same in both policy tiers, where a denied field is still refused by the gate before any value is read. Nothing that ran before is refused now: every value refused is one the parser refused. **Who is affected:** an endpoint that mapped `ParseException` to a five-hundred now gets a `LogicException` and a four-hundred, which is what it always should have been, and a client sending a locale-formatted number is refused on every host instead of working on some. A number a C# caller puts in `Values` is still written in the invariant culture and is unaffected, except that `double.NaN` is now `InvalidFormat`. JavaScript's `JSON.stringify(0.0000001)` is `1e-7`, which a `decimal` member refuses; send `"0.0000001"`. + +39. **A `null` Entry in a Request's List Is a Malformed Request** + A request body can say `"conditions": [null]`, `"subConditionGroups": [null]`, `"conditionSets": [null]`, `"orders": [null]`, `"aggregateBy": [null]` or `"selects": [null]`. Nothing read a list expecting that, so the null surfaced wherever it was first touched: a `NullReferenceException` from the sort-order check, from the ordering, or — under a policy — from inside the copy the sanitizer takes before it reads anything; and an `ArgumentNullException` for a null aggregate (parameter `"aggregate"`), a null summary order (parameter `"order"`) and, from the name lookup, a null or blank `Selects` entry (parameter `"name"`). A host maps those to a server error, for a request that was simply malformed. + + Since 3.3.0 each is a `LogicException`: `ListOf[Conditions]MustNotHasNullEntry`, `ListOf[SubConditionGroups]MustNotHasNullEntry`, `ListOf[ConditionSets]MustNotHasNullEntry`, `ListOf[Orders]MustNotHasNullEntry` and `ListOf[AggregateBy]MustNotHasNullEntry`. A `Selects` entry that is null **or** blank — empty or white space — is `ConditionMustHasValidFieldName`, the refusal a null or blank `GroupBy.Fields` entry has always had. The walk runs in every method that takes a shape, before anything else reads the lists, with or without a policy, in both tiers, sync and async; under `ApplyPolicy` it runs at the top of the sanitizer, before the caps and before the gate, because it is about the request's shape and not a policy decision. A list that is itself null still means what it meant, a `ConditionSet` whose `ConditionGroup` is null is still an `ArgumentNullException` as is a null `Summary.GroupBy`, and a null element inside `Condition.Values` still reads as the empty string. `Filter.Clone()`, `Segment.Clone()` and `Summary.Clone()` copy a null entry as a null entry instead of throwing, so the refusal belongs to the method that runs the request. + + **Who is affected:** any endpoint binding a request body it does not validate itself. Such a body used to produce a five-hundred and now produces a `LogicException`, which middleware written for this library already maps to a four-hundred. Code matching on `NullReferenceException`, or on the `ArgumentNullException` parameter names `"name"`, `"order"` or `"aggregate"`, to detect this needs updating. + --- ## License diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index a0d4ea1..854a47f 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -55,7 +55,7 @@ https://github.com/Sajadh92/DynamicWhere.ex icon.png README.md - v3.3.0 — A second host may configure the same posture, a path no database can compute is refused rather than run, a path or a member the attribute walk cannot name is still policed, and Clone is public on the three request types. + v3.3.0 — A second host may configure the same posture, a path no database can compute is refused rather than run, a path or a member the attribute walk cannot name is still policed, a malformed request is refused as one rather than reaching the caller as a server error, a token vault can hold a key, and Clone is public on the three request types. New: DwPolicy.Configure takes a second call asking for the posture already in force, and does nothing. Until now the first call won and every later one threw, so an integration suite starting several WebApplicationFactory hosts over one composition root had to read IsConfigured before registering — a check-then-act two hosts starting at once can both pass, after which one of them throws. The comparison is made inside the lock that does the configuring, so a caller needs no lock of its own, and it covers everything that decides what a query may do: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap value, the exposed entity catalogue with every name it answers to, and the kinds of policy source supplied, in order. Writing a cap's own default down is not a difference, and handing the posture in force back with a source beside it is: that call asks for the source, so it is refused rather than dropped. A posture differing in any of them is still refused with InvalidOperationException. The token vault, the service provider and the provider instances are not compared, because a second host builds its own and no two are ever the same reference; they stay as the first call left them, so a second host runs with the first host's vault and container. AddDwPolicies registers the posture in force rather than the instance it has just built, and the options handed to a second call are frozen so nothing goes on setting values that decide nothing. @@ -92,6 +92,12 @@ Performance: a property lookup takes no lock and allocates nothing on a hit. Eve Packaging: Microsoft.Extensions.Caching.Memory 6.0.2 is named directly, the version patched for CVE-2024-43483 (GHSA-qj66-m88j-hmgj). EF Core 6.0.22 asks for 6.0.1 or later, and 6.0.1 is the last version open to it, so a host on the EF Core 6 floor resolved a vulnerable version through this package and its three companions. A host on EF Core 8 or later already resolves a newer one and sees no change. Docs: [DwEntity(DefaultOrder)]'s own remarks still read as though a default order does not reach a projected query. It has since 3.2.0. DwCaps.MinGroupSize ships on at 5, so a guarded summary drops every group with fewer than five rows unless the deployment sets it to 1; the aggregation documentation now leads with that rather than leaving it to the caps table. +Fix and behaviour change: a DataType.Number value is read as the expression parser reads it, not as the host's culture does. The builder writes a number into the generated expression unquoted, exactly as sent, while validation checked it with byte, short, int, long, float, double and decimal TryParse in the host's culture, and the two disagreed. "1,000", "5-", "+5", ".5", "5.", "-.5", "1.e5", "NaN", "Infinity", "-Infinity" and an integer past UInt64, or below Int64 when negative, all passed validation and then threw the parser's own ParseException, which a host maps to a server error; "1,5" passed on a German host and was refused on an English one; and "NaN" and "Infinity" were written into the expression as identifiers, so on a type with a member of that name the condition compared two columns instead of filtering. A value is read in two steps now. First the parser's own grammar, in the invariant culture and ASCII digits only: optional white space, an optional minus, digits, an optional fraction with a digit on both sides of the point, and an optional exponent. No leading plus, no thousands separator, no trailing sign, no parentheses, no NaN and no Infinity; an integer must fit UInt64, or Int64 when negative, while a real has no bound, so 1e400 still reads as infinity. A suffix such as 5L or 5m, a hexadecimal literal and a minus standing apart from its digits are refused as they always were, though the parser would read them: nothing is accepted now that was not accepted before. Then, in a WHERE condition and for the operators that write the value into a comparison, whether that literal compares with the member the condition names — the parser itself is asked, against the member's declared type, a collection at the end of the path standing for itself and one along the path for its elements. Refused there, where the parser used to throw: a literal written with a point and no exponent on a nullable integral member, where a non-nullable int still takes 1.5; an exponent form on a decimal or a nullable decimal, and a real with more digits than a decimal holds; an integer above Int64.MaxValue on a signed integral member, because such a literal reads as an unsigned long which none of them converts to; a negative number on an unsigned long; any number on a string, bool, Guid, DateTime or char member, or on a collection of simple values; and a nullable enumeration under an ordering operator, where equality still works. A HAVING condition reads the grammar and stops, since an alias has no member type to ask about. Every refusal is a LogicException with InvalidFormat, the same in both tiers under a policy, where a denied field is still refused by the gate before any value is read. Nothing that ran before is refused now: every value refused is one the parser refused. An endpoint that mapped ParseException to a server error now gets a refusal it can turn into a four-hundred. A number a C# caller places in Values is still written in the invariant culture, except that double.NaN is now InvalidFormat, and JavaScript's JSON.stringify writes 0.0000001 as 1e-7, which a decimal member refuses: send it as a string. + +Fix and behaviour change: a null entry in a request's list is a malformed request, refused as one. A body can say "conditions": [null], "subConditionGroups": [null], "conditionSets": [null], "orders": [null], "aggregateBy": [null] or "selects": [null]. Nothing read a list expecting that, so the null surfaced wherever it was first touched: a NullReferenceException from the sort-order check, from the ordering, or, under a policy, from inside the copy the sanitizer takes before it reads anything, and an ArgumentNullException for a null aggregate, a null summary order and, from the name lookup, a null or blank Selects entry. A host maps those to a server error, for a request that was simply malformed. Every method that takes a shape walks its lists before anything else reads them now, with or without a policy, in both tiers, synchronous and asynchronous: the composable Where over a condition group, Order over a list, Select, SelectDynamic, Group and Summary, and every terminal for a Filter, a Segment and a Summary. Under a policy the walk runs at the top of the sanitizer, before the caps and before the gate, because it is about the request's shape and not a policy decision. A null condition, sub-group, condition set, order or aggregate is a LogicException naming the list, ListOf[Conditions]MustNotHasNullEntry and its kin. A Selects entry that is null or blank is ConditionMustHasValidFieldName, the refusal a null or blank grouping field has always had. A list that is itself null still means what it meant; a condition set whose condition group is null is still an ArgumentNullException, as is a null Summary.GroupBy; and a null element inside a condition's Values still reads as the empty string. Clone copies a null entry as a null entry rather than failing on it, so the refusal belongs to the method that runs the request and reads the same for a copy. Code matching on NullReferenceException, or on the parameter names "name", "order" and "aggregate", needs updating. + +New: a token vault can hold a key, so a copy of the store gives no value back. A vault stores its mapping under the scope and a digest of the value, and unkeyed that digest is a plain SHA-256. A tokenized column is nearly always drawn from a space small enough to hash whole — phone numbers, national identifiers, card numbers — so a backup, a replica or a dump of the store gives back every value in it, and with them the value behind every token ever issued. DwToken.KeyFor(scope, value, key) is an HMAC-SHA256 under a key of at least DwToken.MinimumKeyLength, sixteen bytes, over the scope, one zero byte and the value, written as DwToken.KeyedPrefix, "hmac:", the scope in the clear and sixty-four lowercase hexadecimal characters, so one value tokenized in two scopes shares no digest. DwToken.RequireKey refuses a key that is null or short and returns a copy of it, so a caller clearing or reusing its array cannot re-key a running vault. RedisTokenVault and EfTokenVault take the key in a constructor of their own, with a retireUnkeyed switch; the existing constructors and the unkeyed KeyFor are unchanged. InMemoryTokenVault draws a random thirty-two byte key of its own per instance, with nothing to configure and no API change, since its mappings die with the process anyway. A keyed vault meeting a value with no keyed mapping looks up the unkeyed mapping too and writes the token found there under the keyed key, so every token already issued is kept; the unkeyed mapping stays until retireUnkeyed is true, and a retiring vault deletes it the first time it meets the value. Roll out in two steps: give every instance the key, then turn retireUnkeyed on. An instance still running without the key mints a new token for a value whose unkeyed mapping is gone. No schema change: a keyed key is at most 326 characters against the 512 the Key column holds, and the cost is one more round trip or read for a value the store has not seen. + v3.2.0 — A row projected before ApplyPolicy keeps its nested objects and lists, denials the policy gate could not see are enforced, a declared default order reaches projected rows, and every asynchronous terminal takes a CancellationToken. diff --git a/OfficialWebsite/app/docs/ai/page.tsx b/OfficialWebsite/app/docs/ai/page.tsx index a8cc779..f40ce9b 100644 --- a/OfficialWebsite/app/docs/ai/page.tsx +++ b/OfficialWebsite/app/docs/ai/page.tsx @@ -96,7 +96,7 @@ DynamicWhere.ex code. It is the complete API surface.`}
          • Validation and errors. Every rule in the order it is - checked, all thirty error strings with what raises them, and the + checked, all thirty-one error strings with what raises them, and the other exception types a caller can receive.
          • diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index 0d2a2b2..fe223e6 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -17,12 +17,12 @@ export default function Page() {

            Breaking Changes & Known Limitations

            DynamicWhere.ex is intentionally opinionated about how queries are shaped. - The forty-three points below cover constraints, surprises, and corner cases — + The forty-five points below cover constraints, surprises, and corner cases — read them before designing an API around the library so you can pick the right entry points and avoid runtime exceptions in production.

            - Points 30 to 43 changed in 3.3.0. Under{" "} + Points 30 to 45 changed in 3.3.0. Under{" "} Strict, a path that exists on the type and names no value the query can compute is refused rather than run, where the provider used to throw and the caller saw a five-hundred (point 30).{" "} @@ -50,7 +50,12 @@ export default function Page() { caller to run are refused on a type whose only transformed member sits where the policy names no path (point 42), and{" "} [DwAudit] records a member no path names once the rows show - it (point 43). + it (point 43). Two shapes of malformed request that used to reach + the caller as a five-hundred are refused as requests now: a{" "} + Number value the expression parser cannot read, or cannot + compare with the member the condition names (point 44), and a{" "} + null entry inside one of the request's lists + (point 45). Points 25 to 29 changed in 3.2.0, and each is @@ -2030,10 +2035,189 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}app.UseDwPolicyAudit().

            +

            44. A Number Value Is Read the Way the Parser Reads It

            +

            + The predicate builder writes a DataType.Number value into the + generated expression unquoted, exactly as sent, and validation checked it + with byte / short / int /{" "} + long / float / double /{" "} + decimal TryParse in the host's culture. The + two disagreed. +

            + + "1,000", "5-",{" "} + "+5", ".5",{" "} + "5.", "-.5",{" "} + "1.e5", "NaN",{" "} + "Infinity", "-Infinity" and + an integer past UInt64 — or below Int64 when + negative — all passed validation and then threw{" "} + System.Linq.Dynamic.Core.Exceptions.ParseException when the + query was built, which a host maps to a server error.{" "} + "1,5" passed on a German host and was refused on an + English one. And "NaN" and{" "} + "Infinity" were written into the expression as + identifiers, so on a type with a member of that name the condition + compared two columns instead of filtering. Until{" "} + 3.3.0. + +

            + A value is read in two steps now. First the parser's own grammar, in + the invariant culture and ASCII digits only: optional white space, an + optional minus, digits, an optional fraction — a point with a digit on + both sides — and an optional exponent. No leading plus, no thousands + separator, no trailing sign, no parentheses, no NaN and no{" "} + Infinity. An integer must fit UInt64, or{" "} + Int64 when negative; a real has no bound, so{" "} + 1e400 still reads as infinity. A suffix (5L,{" "} + 5m), hex and - 5 are refused as they always + were, though the parser would read them. +

            +

            + Then, in a Where condition and for the operators that write + the value into a comparison — Equal, NotEqual,{" "} + In, NotIn, the four orderings,{" "} + Between and NotBetween — the literal has to + compare with the member the condition names. The parser itself is asked, + against the member's declared type, so these are refused where the + parser used to throw: +

            +
              +
            • + a literal written with a point and no exponent (1.5) on a{" "} + nullable integral member (int?,{" "} + long?, …). A non-nullable int still takes{" "} + 1.5, exactly as before; +
            • +
            • + an exponent form (1e5, 1E-7) on{" "} + decimal or decimal?, and a real with more + digits than a decimal holds; +
            • +
            • + an integer above Int64.MaxValue on a signed integral + member: it reads as a ulong, which none of them converts + to; +
            • +
            • a negative number on ulong or ulong?;
            • +
            • + any number on a string, bool,{" "} + Guid, DateTime or char member, or + on a collection of simple values such as List<int>; +
            • +
            • a nullable enum under an ordering operator — equality still works.
            • +
            +

            + A Having condition reads the grammar and stops there: an + alias names an aggregate, so there is no member type to ask the parser + about. Every refusal is LogicException with{" "} + InvalidFormat, and it is the same in both policy tiers — a + denied field is still FieldDeniedForWhere before any value is + read. Nothing that ran before is refused now: every value refused is one + the parser refused. +

            + + JavaScript's JSON.stringify(0.0000001) is{" "} + 1e-7, which a decimal member refuses. Send it as + the string "0.0000001". + +

            + Who is affected: an endpoint that passed a number through + from a caller and mapped ParseException to a{" "} + 500 now gets a LogicException and a{" "} + 400, which is what it always should have been. A client + sending a locale-formatted number — a comma decimal separator, a thousands + separator — is refused on every host instead of working on some. A number + a C# caller puts in Values is still written in the invariant + culture and is unaffected, except that double.NaN is now{" "} + InvalidFormat. See{" "} + DataType → Number values. +

            + +

            45. A null Entry in a Request's List Is a Malformed Request

            +

            + A request body can say {`"conditions": [null]`},{" "} + {`"subConditionGroups": [null]`},{" "} + {`"conditionSets": [null]`},{" "} + {`"orders": [null]`},{" "} + {`"aggregateBy": [null]`} or{" "} + {`"selects": [null]`}. Nothing read a list expecting that. +

            + + The null surfaced wherever it was first touched: a{" "} + NullReferenceException from the sort-order check, from the + ordering, or — under a policy — from inside the copy the sanitizer takes + before it reads anything; and an ArgumentNullException for + a null aggregate (parameter "aggregate"), a null + summary order (parameter "order") and, from the + name lookup, a null or blank Selects entry (parameter{" "} + "name"). A + host maps those to a five-hundred, for a request that was simply + malformed. Until 3.3.0. + +

            + Each is a LogicException now:{" "} + ListOf[Conditions]MustNotHasNullEntry,{" "} + ListOf[SubConditionGroups]MustNotHasNullEntry,{" "} + ListOf[ConditionSets]MustNotHasNullEntry,{" "} + ListOf[Orders]MustNotHasNullEntry and{" "} + ListOf[AggregateBy]MustNotHasNullEntry. A{" "} + Selects entry that is null or blank — empty or white + space — is ConditionMustHasValidFieldName, the refusal a null + or blank GroupBy.Fields entry has always had. +

            +

            + The walk runs in every method that takes a shape, before anything else + reads the lists, with or without a policy, in both tiers, sync and async: + the composables Where(ConditionGroup),{" "} + Order(List<OrderBy>), Select,{" "} + SelectDynamic, Group and Summary, + and every terminal for a Filter, a Segment and a{" "} + Summary. Filter and{" "} + FilterDynamic compose Where,{" "} + Order and Select, so each list is walked as its + clause is reached. Under ApplyPolicy it runs at the top of + the sanitizer, before the caps and before the gate: it is about the + request's shape, not a policy decision. +

            +
              +
            • + A list that is itself null still means what it meant — most + readers read it as empty. +
            • +
            • + A ConditionSet whose ConditionGroup is null is + still ArgumentNullException, and so is a null{" "} + Summary.GroupBy. +
            • +
            • + A null element inside Condition.Values still reads as the + empty string: Text and Enum compare with it, + and every other data type refuses it with InvalidFormat. +
            • +
            • + Filter.Clone(), Segment.Clone() and{" "} + Summary.Clone() copy a null entry as a null entry instead + of throwing NullReferenceException, so the refusal belongs + to the method that runs the request and reads the same for a copy. +
            • +
            +

            + Who is affected: any endpoint binding a request body it + does not validate itself. Such a body used to produce a{" "} + 500 and now produces a LogicException, which + middleware written for this library already maps to a{" "} + 400. Code matching on NullReferenceException or + on the ArgumentNullException parameter names{" "} + "name", "order" or{" "} + "aggregate" to detect this needs updating. See{" "} + Error Codes Reference. +

            +

            See also

            • - Error Codes Reference → the 30 stable + Error Codes Reference → the 31 stable validation messages.
            • diff --git a/OfficialWebsite/app/docs/classes/filter/page.tsx b/OfficialWebsite/app/docs/classes/filter/page.tsx index c68d70a..aaa1769 100644 --- a/OfficialWebsite/app/docs/classes/filter/page.tsx +++ b/OfficialWebsite/app/docs/classes/filter/page.tsx @@ -139,7 +139,9 @@ page2.Page!.PageNumber = 2; // the caller's own filter is untouched`}

              See also

              diff --git a/OfficialWebsite/app/docs/classes/segment/page.tsx b/OfficialWebsite/app/docs/classes/segment/page.tsx index d5ec291..b5653bb 100644 --- a/OfficialWebsite/app/docs/classes/segment/page.tsx +++ b/OfficialWebsite/app/docs/classes/segment/page.tsx @@ -186,7 +186,9 @@ page2.Page!.PageNumber = 2; // the caller's own segment is untouched`}< which leaves both requests holding one condition tree: a rewrite of either reaches both. The library has cloned before rewriting anything since 3.0; callers could not until now. A branch the caller left null - stays null. + stays null, and a null entry inside a list is copied as a null entry + rather than failing on it (3.3.0), so the refusal belongs to the method + that runs the request and reads the same for a copy.

              See also

              diff --git a/OfficialWebsite/app/docs/classes/summary/page.tsx b/OfficialWebsite/app/docs/classes/summary/page.tsx index 17742b0..06ddf28 100644 --- a/OfficialWebsite/app/docs/classes/summary/page.tsx +++ b/OfficialWebsite/app/docs/classes/summary/page.tsx @@ -186,7 +186,9 @@ page2.Page!.PageNumber = 2; // the caller's own summary is untouched`}< which leaves both requests holding one condition tree: a rewrite of either reaches both. The library has cloned before rewriting anything since 3.0; callers could not until now. A branch the caller left null - stays null. + stays null, and a null entry inside a list is copied as a null entry + rather than failing on it (3.3.0), so the refusal belongs to the method + that runs the request and reads the same for a copy.

              See also

              diff --git a/OfficialWebsite/app/docs/enums/data-type/page.tsx b/OfficialWebsite/app/docs/enums/data-type/page.tsx index 6f11a38..98c55bf 100644 --- a/OfficialWebsite/app/docs/enums/data-type/page.tsx +++ b/OfficialWebsite/app/docs/enums/data-type/page.tsx @@ -70,7 +70,10 @@ export default function Page() {
          • Any numeric value — byte through decimal{" "} (including short, int,{" "} - long, float, double). + long, float, double). The + value is read as the expression parser reads it, and has to compare + with the member (3.3.0) — see{" "} + Number values below. Equal, NotEqual,{" "} @@ -484,6 +487,102 @@ DwDates.Configure(new DwDateOptions().Bind(configuration.GetSection("DynamicWher "values": [0, 1.569] }`} +

            Number values (3.3.0)

            +

            + A number is written into the generated expression unquoted, exactly as + sent, so it is read the way the expression parser reads it rather than the + way the host's culture does. Two steps. +

            +

            + The grammar, in the invariant culture and ASCII digits + only: optional white space, an optional minus, digits, an optional + fraction — a point with a digit on both sides — and an optional exponent + (e or E, an optional sign, digits). No leading + plus, no thousands separator, no trailing sign, no parentheses, no{" "} + NaN and no Infinity. An integer, meaning one + with neither a fraction nor an exponent, must fit UInt64, or{" "} + Int64 when negative; a real has no bound, and{" "} + 1e400 reads as infinity. A suffix (5L,{" "} + 5m), hex (0x1F) and - 5 are refused + as they always were, though the parser would read them: nothing is + accepted now that was not accepted before. +

            +

            + The member. In a Where condition, for the + operators that write the value into a comparison —{" "} + Equal, NotEqual, In,{" "} + NotIn, GreaterThan,{" "} + GreaterThanOrEqual, LessThan,{" "} + LessThanOrEqual, Between and{" "} + NotBetween — the literal also has to compare with the member + the condition names. The parser itself is asked, against the member's + declared type: a collection at the end of the path stands for itself, one + along the path stands for its elements. Refused there, where the parser + used to throw: +

            +
              +
            • + a literal written with a point and no exponent (1.5,{" "} + 0.1, 1.0) on a nullable integral + member (int?, long?, …). A non-nullable{" "} + int still takes 1.5, exactly as before; +
            • +
            • + an exponent form (1e5, 1E-7) on{" "} + decimal or decimal?, and a real with more + digits than a decimal holds; +
            • +
            • + an integer above Int64.MaxValue on a signed integral member + (sbyte, short, int,{" "} + long, nullable or not): such a literal reads as a{" "} + ulong, which none of them converts to; +
            • +
            • + a negative number on ulong or ulong?; +
            • +
            • + any number on a string, bool,{" "} + Guid, DateTime or char member, or + on a collection of simple values such as{" "} + List<int> — a path through a collection,{" "} + Lines.Quantity, still works; +
            • +
            • + a nullable enum under an ordering operator. Equality works on it, and a + non-nullable enum orders. +
            • +
            +

            + A Having condition reads the grammar and stops: an alias + names an aggregate, so there is no member type to ask the parser about. + Every refusal is InvalidFormat, the same in both policy + tiers, and a denied field is still refused by the gate before any value is + read. +

            + + JavaScript writes 0.0000001 as 1e-7, which a{" "} + decimal member refuses. Send it as the string{" "} + "0.0000001". + + + The check was byte / short / int /{" "} + long / float / double /{" "} + decimal TryParse in the host's culture.{" "} + "1,000", "5-",{" "} + "+5", ".5",{" "} + "5.", "NaN",{" "} + "Infinity" and an integer past{" "} + UInt64 all passed and then threw{" "} + ParseException when the query was built, which a host maps to + a five-hundred. "1,5" passed on a German host and + was refused on an English one. And "NaN" and{" "} + "Infinity" were written into the expression as + identifiers, so on a type with a member of that name the condition + compared two columns. See{" "} + breaking point 44. + +

            Boolean

            {`{ "sort": 1, diff --git a/OfficialWebsite/app/docs/errors/page.tsx b/OfficialWebsite/app/docs/errors/page.tsx index 7ac4933..c52fd49 100644 --- a/OfficialWebsite/app/docs/errors/page.tsx +++ b/OfficialWebsite/app/docs/errors/page.tsx @@ -18,7 +18,7 @@ export default function Page() {

            Every validation failure in DynamicWhere.ex throws a{" "} LogicException (which inherits from Exception). - The Message property carries one of the 30 stable error strings + The Message property carries one of the 31 stable error strings listed below, so you can pattern‑match them in middleware and surface meaningful problems to API callers.

            @@ -39,16 +39,36 @@ export default function Page() {

            LogicException is the usual type, but not the only one a - caller sees. A null argument or a blank Selects entry raises{" "} - ArgumentNullException; a null element inside{" "} - Conditions, SubConditionGroups,{" "} - ConditionSets or Orders raises{" "} - NullReferenceException; a guarded type raises{" "} - PolicyException, which derives from{" "} + caller sees. A null argument raises ArgumentNullException; a + guarded type raises PolicyException, which derives from{" "} LogicException; and input that passes validation but not the expression parser raises ParseException from{" "} System.Linq.Dynamic.Core.

            + + A null element inside Conditions,{" "} + SubConditionGroups, ConditionSets,{" "} + Orders or AggregateBy used to raise{" "} + NullReferenceException, and a null or blank{" "} + Selects entry used to raise{" "} + ArgumentNullException from the name lookup — a five-hundred + for a request that was simply malformed. They are{" "} + {`ListOf[{list}]MustNotHasNullEntry`} and{" "} + ConditionMustHasValidFieldName now. A{" "} + ConditionSet whose ConditionGroup is null, and a + null Summary.GroupBy, are still{" "} + ArgumentNullException. See{" "} + breaking point 45. + + + A DataType.Number value is read as the expression parser + reads it, so one the parser cannot read — "1,000",{" "} + "+5", "NaN" — or cannot + compare with the member the condition names is{" "} + InvalidFormat at validation, where it used to pass validation + and throw ParseException when the query was built. See{" "} + breaking point 44. + Wrap the call in a try / catch (LogicException ex) and map it @@ -69,7 +89,7 @@ export default function Page() { });`}
            -

            All 30 error codes

            +

            All 31 error codes

            Codes wrapped in (parens) are parameterized — the bracketed token in the message is replaced at runtime with the offending operator, @@ -245,6 +265,10 @@ export default function Page() {

            InvalidFormat Value doesn't parse for declared DataType. A{" "} + Number value must be a literal the expression parser + reads — invariant, no thousands separator, no leading plus, no{" "} + NaN — and one it can compare with the member the + condition names (3.3.0). A{" "} Date / DateTime value must be ISO 8601, year-first, or a format declared through{" "} DwDates.Configure, read as the member's own date @@ -346,6 +370,20 @@ export default function Page() { projection
            NullEntry(list){`ListOf[{list}]MustNotHasNullEntry`} + A list of the request shape holds a null entry —{" "} + Conditions, SubConditionGroups,{" "} + ConditionSets, Orders or{" "} + AggregateBy, spelled as the shape declares it. New in{" "} + 3.3.0: such an entry used to surface as a{" "} + NullReferenceException from wherever it was first + touched. See{" "} + breaking point 45 +
            diff --git a/OfficialWebsite/app/docs/extensions/where/page.tsx b/OfficialWebsite/app/docs/extensions/where/page.tsx index 3550e0f..b63267d 100644 --- a/OfficialWebsite/app/docs/extensions/where/page.tsx +++ b/OfficialWebsite/app/docs/extensions/where/page.tsx @@ -122,8 +122,13 @@ export default function Page() { InvalidFormat.
          • - Number values must parse as a numeric type —{" "} - InvalidFormat. + Number values must be a literal the expression parser reads + — invariant, no thousands separator, no leading plus, no{" "} + NaN — and one it can compare with the member the condition + names, so 1.5 is refused on an int? and{" "} + 1e-7 on a decimal (3.3.0) —{" "} + InvalidFormat. The server's culture decides nothing. + See Condition validation.
          • Boolean values must parse as bool —{" "} diff --git a/OfficialWebsite/app/docs/policies/providers/page.tsx b/OfficialWebsite/app/docs/policies/providers/page.tsx index ff6e31d..376e32e 100644 --- a/OfficialWebsite/app/docs/policies/providers/page.tsx +++ b/OfficialWebsite/app/docs/policies/providers/page.tsx @@ -87,7 +87,7 @@ DwPolicy.Configure(options, provider);`} DwPolicyRulesOne row per rule, indexed by entity and field, and by subject. DwPolicyVersionA single row carrying the snapshot version. - DwPolicyTokensThe EfTokenVault mapping, one row per tokenized value. + DwPolicyTokensThe EfTokenVault mapping, one row per tokenized value. Key is the scope and a digest of the value — a plain SHA-256, or, where the vault holds a key, an HMAC-SHA256 behind hmac: (3.3.0). Either fits the 512 characters the column already holds, so a key needs no migration. diff --git a/OfficialWebsite/app/docs/policies/security/page.tsx b/OfficialWebsite/app/docs/policies/security/page.tsx index 066d461..ef5e467 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -574,6 +574,7 @@ true order. Add [DwNoOrder] unless that is intended.`}
          • Turn on AuditRefusals once an IDwAuditSink is registered, so a probe for hidden columns leaves a record.
          • Leave MinGroupSize alone unless you have a reason; setting it to 1 is a decision, not a default.
          • Prefer Tokenize over Hash where you can run a durable vault: neither hides equality, but only one of them can be undone by a leaked constant.
          • +
          • Give a durable token vault a key (3.3.0), and hold it where the store is not. Unkeyed, a mapping is stored under a plain digest of the value, and a tokenized column is nearly always drawn from a space small enough to hash whole — so a backup, a replica or a dump of the vault gives back every value in it, and with them the value behind every token ever issued. See Transforms.
          • Run DwPolicy.ValidateModel(...) at startup and treat its warnings as a checklist.
          • Put [DwEntity(RequirePolicy = true)] on anything sensitive, so a DynamicWhere call that forgets ApplyPolicy fails loudly.
          • Prefer [DwOperators] over allowing free filtering on a protected field.
          • diff --git a/OfficialWebsite/app/docs/policies/transforms/page.tsx b/OfficialWebsite/app/docs/policies/transforms/page.tsx index edea963..a7d449b 100644 --- a/OfficialWebsite/app/docs/policies/transforms/page.tsx +++ b/OfficialWebsite/app/docs/policies/transforms/page.tsx @@ -87,8 +87,8 @@ public string PassportNumber { get; set; } // stable per vault, useful for jo Output64 hex characters32 hex characters Derived from the valueyesno - Reversed byholding the saltreading the vault - A weak secretbrute-forced offlinedoes not exist + Reversed byholding the saltreading the vault, and its key where it has one + A weak secretbrute-forced offlineonly if you give the vault a short key, which is refused Survives a restartalwaysonly with a durable vault Discloses equalityyesyes @@ -98,7 +98,8 @@ public string PassportNumber { get; set; } // stable per vault, useful for jo deployment has ever emitted, and a guessable salt is recovered offline. A token is drawn at random the first time a value is seen and written into a vault, so the only way back is to read that vault — a store you can lock, - move and revoke separately from the data. + move and revoke separately from the data. Guard it as you would guard the + column it protects, and give it a key.

            {`new DwPolicyOptions { @@ -113,6 +114,61 @@ public string PassportNumber { get; set; } // stable per vault, useful for jo outside the process and cache every mapping they resolve, which they can do safely because a token is written once and never rewritten.

            + +

            Give a durable vault a key (3.3.0)

            +

            + A vault stores its mapping under the scope and a digest of the value. + Without a key that digest is a plain SHA-256, and a tokenized column is + nearly always drawn from a space small enough to hash whole — phone + numbers, national identifiers, card numbers. So a copy of the store, a + backup or a replica or a dump, gives back every value in it, and with them + the value behind every token ever issued. Under a key held where the store + is not — configuration, a secret manager — the digest is an HMAC-SHA256, + and the store and the key have to be taken together. +

            + {`new DwPolicyOptions +{ + TokenVault = new RedisTokenVault(redis, key) // 16 bytes or more + // TokenVault = new EfTokenVault(() => new AppDbContext(opts), key) +}`} +

            + The constructors that take no key are unchanged and unkeyed.{" "} + InMemoryTokenVault draws a random 32-byte key of its own per + instance — nothing to configure, and no API change — because its mappings + die with the process anyway. The key must be at least{" "} + DwToken.MinimumKeyLength (16) bytes; a shorter one is refused + by the constructor, as a short hash salt is refused. A keyed mapping's key starts + with DwToken.KeyedPrefix ("hmac:"), so + an operator can tell the two kinds apart in a store holding both. The + scope sits inside the digest as well as in front of it, so one value + tokenized in two scopes is two unrelated keys. +

            + + A keyed vault meeting a value with no keyed mapping looks up the unkeyed + mapping too, and the token found there is the one written under the keyed + key, so yesterday's export still lines up with today's. The + unkeyed mapping stays until retireUnkeyed: true, and a + retiring vault deletes it the first time it meets the value, whether it + wrote the keyed mapping or found it. Give every instance the key + first, and turn retireUnkeyed on only after that: an + instance still running without the key mints a new token for a + value whose unkeyed mapping is gone, and a value first met while keyed and + unkeyed instances run side by side can end up with two tokens. Unkeyed + mappings for values never met again stay until an operator removes them —{" "} + HSCAN the Redis token hash and delete the fields that do not + match hmac:*, or delete the rows of{" "} + DwPolicyTokens whose Key does not start with{" "} + hmac: — knowing such a value gets a new token the next time it + is met. Changing the key re-issues every token, unless unkeyed mappings + remain to adopt from. + +

            + The cost is small and there is no schema change. Redis reads both fields in + one round trip, so a value new to the store costs two round trips instead + of one; EF Core costs one more read for a new value, and in retire mode one + more read per first-met value. A keyed key is at most 326 characters + against the 512 the Key column already holds. +

            Tokens are namespaced by TokenScope, and where none is given the namespace falls back to the field's path relative to the entity diff --git a/OfficialWebsite/app/docs/policies/use-cases/page.tsx b/OfficialWebsite/app/docs/policies/use-cases/page.tsx index 31e88c7..28b8c84 100644 --- a/OfficialWebsite/app/docs/policies/use-cases/page.tsx +++ b/OfficialWebsite/app/docs/policies/use-cases/page.tsx @@ -210,7 +210,15 @@ public string EmployeeCode { get; set; } = string.Empty;`} {`[DwMask(MaskStrategy.Tokenize)] [DwNoOrder] public string NationalId { get; set; } = string.Empty;`} - {`new DwPolicyOptions { TokenVault = new EfTokenVault(() => new DwPolicyDbContext(opts)) }`} + {`new DwPolicyOptions { TokenVault = new EfTokenVault(() => new DwPolicyDbContext(opts), key) }`} +

            + Give the vault a key (3.3.0). Without one a row is keyed by a plain + SHA-256 of the value, and a national identifier comes from a space small + enough to hash whole, so a dump of the table is a dump of the column. + Under a key held outside the table the row is keyed by an HMAC, and the + table and the key have to be taken together. See{" "} + Transforms. +

            Why a token and not a hash. Two reasons, and the second is the one that decides it. @@ -229,12 +237,20 @@ public string NationalId { get; set; } = string.Empty;`}

          The library ships no reverse lookup on purpose, so erasure is a direct - operation against the store. The key is public so you can compute it: + operation against the store. Compute the key the mapping is stored under + and delete it:

          - {`string key = DwToken.KeyFor("patient-id", nationalId); + {`string unkeyed = DwToken.KeyFor("patient-id", nationalId); // a vault with no key +string keyed = DwToken.KeyFor("patient-id", nationalId, vaultKey); // a vault with one (3.3.0) -// EF Core: DELETE FROM DwPolicyTokens WHERE [Key] = @key -// Redis: HDEL dw:policy:tokens `} +// EF Core: DELETE FROM DwPolicyTokens WHERE [Key] IN (@unkeyed, @keyed) +// Redis: HDEL dw:policy:tokens `} +

          + The unkeyed key is a plain digest, so anyone can compute it — which is + exactly why a durable vault wants a key. The keyed one can only be + computed by a holder of the vault's key. A vault that has not retired + its unkeyed mappings may still hold one for the value, so delete both. +

          {/* ------------------------------------------------------------------ 6 */}

          6. One person, three entities, one token

          @@ -406,6 +422,11 @@ public string FullTextNotes { get; set; } = string.Empty;`} TokenVault the query is refused with MissingTokenVault + + a copy of the vault giving no value back + a vault key, 16+ bytes (3.3.0) + a mapping is keyed by a plain digest of the value, so a backup, a replica or a dump of the store gives back every value in it + a token joined across entities a shared TokenScope diff --git a/OfficialWebsite/app/docs/validation/condition/page.tsx b/OfficialWebsite/app/docs/validation/condition/page.tsx index 79f522b..dc3057f 100644 --- a/OfficialWebsite/app/docs/validation/condition/page.tsx +++ b/OfficialWebsite/app/docs/validation/condition/page.tsx @@ -83,7 +83,10 @@ export default function Page() { InvalidFormat - Number values must parse as a numeric type + + Number values must be a literal the expression parser + reads, and one it can compare with the member the condition names + InvalidFormat @@ -120,6 +123,27 @@ export default function Page() { breaking changes.
          + + The predicate builder writes a Number value into the + expression unquoted, exactly as sent, so validation reads it as the + expression parser reads it and not as the host's culture does. First + the parser's grammar, in the invariant culture: optional white space, + an optional minus, digits, an optional fraction with a digit on both sides + of the point, an optional exponent. No leading plus, no thousands + separator, no trailing sign, no NaN and no{" "} + Infinity; an integer must fit UInt64, or{" "} + Int64 when negative. Then, in a Where condition, + whether that literal compares with the member the condition names — the + parser itself is asked, so 1.5 is refused on an{" "} + int?, 1e-7 on a decimal, and any + number on a string. A Having condition reads the + grammar and stops, because an alias has no member type to ask about. Until{" "} + 3.3.0 the check was TryParse in the + host's culture and such values passed, then threw{" "} + ParseException when the query was built. See{" "} + breaking changes. + + Validation reads a date value exactly as the predicate builder does, with the member's own DateTime, DateTimeOffset or{" "} diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index 6e87c8d..c0d4382 100644 --- a/OfficialWebsite/public/llms.txt +++ b/OfficialWebsite/public/llms.txt @@ -216,9 +216,13 @@ Clone() on Filter, Segment and Summary public sin groups and conditions, the Selects list, each OrderBy, the Page, and a Summary's GroupBy with its AggregateBy list and its Having. A null - branch stays null. Use it to read one request - again with a different page or order without - editing what the caller handed in. + branch stays null, and a null entry inside a + list is copied as a null entry (3.3.0), so the + refusal is the running method's and reads the + same for a copy; it used to throw + NullReferenceException. Use it to read one + request again with a different page or order + without editing what the caller handed in. ``` ``` @@ -397,8 +401,8 @@ DataType Check Send Text none a string; a null element is "" and matches empty strings only Enum none member name in any case ("Pending", "pending") or its number Guid Guid.TryParse any Guid format: "D", upper-case, "N" (no hyphens) -Number TryParse as byte/short/int/long/float/double/decimal a JSON number or numeric string: 12, -3.5, "15.5", "1e3" - (server culture) +Number the parser's own grammar, then the member's type a JSON number or numeric string: 12, -3.5, "15.5", "1e3"; + (3.3.0); invariant, not the server's culture see the Number bullet below for what is refused Boolean bool.TryParse true / false as JSON booleans or strings in any case; 1 and 0 fail DateTime ISO 8601 / year-first / a declared format (3.1.0) ISO 8601: "2024-06-15T14:30:00", or with Z / an offset Date ISO 8601 / year-first / a declared format (3.1.0) ISO 8601 date: "2024-06-15"; the time is dropped on both sides @@ -406,9 +410,48 @@ Date ISO 8601 / year-first / a declared format (3.1.0) ISO 8601 date - **Null:** a null element is `""`. Text and Enum compare with the empty string; every other DataType throws `InvalidFormat`. To test for NULL use `IsNull` / `IsNotNull` with `"values": []`. -- **Number:** the token is embedded unquoted exactly as sent. A thousands separator (`"1,000"`) or `"NaN"` / - `"Infinity"` passes the culture TryParse, then throws `System.Linq.Dynamic.Core.Exceptions.ParseException` - when the query is built. Send invariant literals without separators. +- **Number (3.3.0 rewrote this):** the token is embedded unquoted exactly as sent, so a value is read the way the + parser reads it rather than the way the host's culture does. Two steps. + - **The grammar**, in the invariant culture, ASCII digits only: optional white space around it (space, tab, LF, + VT, FF, CR, exactly what `TryParse` allowed), an optional minus, digits, an optional fraction (a point with a + digit on both sides), an optional exponent (`e` or `E`, an optional sign, digits). No leading plus, no + thousands separator, no trailing sign, no parentheses, no `NaN` and no `Infinity`. An integer — no fraction, + no exponent — must fit `UInt64`, or `Int64` when negative. A real has no bound: `1e400` still reads as + infinity. + - A suffix (`5L`, `5m`, `5f`, `5d`), hex (`0x1F`), parentheses (`(5)`) and a minus standing apart from its + digits (`- 5`) are refused as they always were, though the parser would read them. Nothing is accepted now + that was not accepted before. + - **The member**, in a `WHERE` condition only, for `Equal`, `NotEqual`, `In`, `NotIn`, `GreaterThan`, + `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `Between` and `NotBetween`: the literal has to compare + with the member the condition names. The parser itself is asked, against the member's **declared** type — a + collection at the end of the path stands for itself, one along the path stands for its elements. Refused now, + where the parser used to throw: + - a literal written with a point and no exponent (`1.5`, `0.1`, `1.0`) on a **nullable** integral member + (`int?`, `long?`, …). A non-nullable `int` still takes `1.5`, exactly as before, and an exponent form + (`1e5`) still compares with an `int?`; + - an exponent form (`1e5`, `1E-7`) on `decimal` or `decimal?`, and a real with more digits than a `decimal` + holds (`79228162514264337593543950335.5`); + - an integer above `Int64.MaxValue` on a signed integral member (`sbyte`, `short`, `int`, `long`, nullable or + not): such a literal reads as a `ulong`, which none of them converts to. `byte`, `ushort`, `uint`, `ulong`, + `float`, `double` and `decimal` take it; + - a negative number on `ulong` or `ulong?`; + - any number on a `string`, `bool`, `Guid`, `DateTime` or `char` member, or on a collection of simple values + (`Scores` where `Scores` is a `List`; `Lines.Quantity` still works, since the path steps through); + - a nullable enum with an ordering operator. Equality works on it, and a non-nullable enum orders. + - A `Having` condition reads the grammar and stops there: an alias names an aggregate, so there is no member + type to ask the parser about. + - Every refusal is `LogicException` with `InvalidFormat`. Nothing that ran before is refused now: every value + refused is one the parser refused. Under `ApplyPolicy` it is the same in both tiers, and the gate still + answers first — a denied field is `FieldDeniedForWhere` before any value is read. + - Before 3.3.0 the check was `byte` / `short` / `int` / `long` / `float` / `double` / `decimal` `TryParse` in + the host's culture. `"1,000"`, `"5-"`, `"+5"`, `".5"`, `"5."`, `"-.5"`, `"1.e5"`, `"NaN"`, `"Infinity"`, + `"-Infinity"` and an integer past `UInt64` (or below `Int64` when negative) all passed validation and then + threw `System.Linq.Dynamic.Core.Exceptions.ParseException`, which a host maps to a server error. `"1,5"` + passed on a de-DE host and was refused on an en-US one. And `"NaN"` and `"Infinity"` were written into the + expression as identifiers, so on a type with a member of that name the condition compared two columns. + - A number a C# caller places in `Values` is still written in the invariant culture (the normalizer table + above), so `12.5` is `"12.5"` on every host. `double.NaN` in `Values` is now `InvalidFormat`. + - JavaScript's `JSON.stringify(0.0000001)` is `1e-7`, which a `decimal` member refuses. Send `"0.0000001"`. - **Date / DateTime (3.1.0 rewrote this):** the predicate is built from the member's own type. - **Which texts are dates (3.1.0).** Read against explicit formats, never the lenient parser; the server's culture and calendar decide nothing. @@ -611,8 +654,8 @@ Path Where predicate generated parser; it is refused now too, so one rule covers every clause. - The remedy for such a column: rename the CLR property and map the column with `[Column("New")]`. - **Errors.** A missing segment or a null / blank path throws `LogicException("ConditionMustHasValidFieldName")`, - the same string for Condition, OrderBy, GroupBy and AggregateBy paths. A null or blank `Selects` entry throws - `ArgumentNullException` instead. Under `ApplyPolicy` in the Strict tier, outside dry run, a path that matches + the same string for Condition, OrderBy, GroupBy, AggregateBy and, since 3.3.0, `Selects` paths — a null or blank + `Selects` entry threw `ArgumentNullException` before. Under `ApplyPolicy` in the Strict tier, outside dry run, a path that matches nothing is refused like a denied field instead, with a `PolicyException` (3.1.0, section 17). - **What counts as a collection:** arrays, `List<>`, `IList<>`, `ICollection<>`, `IEnumerable<>`, `HashSet<>`, `ISet<>`. Anything else (`IReadOnlyList<>`, `IReadOnlyCollection<>`, `Collection<>`, `ObservableCollection<>`, a @@ -684,8 +727,9 @@ against 3.1 still binds. ### Rules for every method - The first statement is the `[DwEntity(RequirePolicy = true)]` guard: on such a T, a call outside `ApplyPolicy` - throws `PolicyException` `PolicyRequired` (section 13). Then null arguments throw `ArgumentNullException`, then - the validation rules of section 8 throw `LogicException`. + throws `PolicyException` `PolicyRequired` (section 13). Then null arguments throw `ArgumentNullException`, then a + null entry in one of the shape's lists throws `ListOf[]MustNotHasNullEntry` (3.3.0, section 8), then the + validation rules of section 8 throw `LogicException`. - Composable methods validate and build when called, not when enumerated, so a bad field throws at the call. - The async terminals are `async` methods: every exception, validation included, surfaces at `await`. - Validation rewrites the caller's objects in place: path strings get the declared casing (`"price"` → `"Price"`) @@ -703,7 +747,8 @@ products.Select(["Id", "Name", "Category.Name", "OrderItems.Quantity"]) builds OrderItems = e.OrderItems.AsQueryable().Select(c => new OrderItem { Id = c.Id, Quantity = c.Quantity }).ToList() } ``` -- Errors: `fields` empty → `MustHasFields`; a null or blank entry → `ArgumentNullException`; an unknown path → +- Errors: `fields` empty → `MustHasFields`; a null or blank entry → `ConditionMustHasValidFieldName` (3.3.0; it was + `ArgumentNullException`); an unknown path → `ConditionMustHasValidFieldName`; no public parameterless constructor on T → `LogicException("SelectTypeMustHaveParameterlessConstructor")` with `Subject = typeof(T).Name` (checked at run time; the constraint is only `class`). Before 3.1.0 that message was an English sentence carrying the type @@ -864,7 +909,9 @@ then exactly as ToListAsync(Filter): Order(Orders) -> Page(Page) -> Select(Selec - `Orders` apply before the projection, as for `Filter`, so an order field need not be selected. Order is the database's: text sorts by collation and NULLs fall where the provider puts them. - Under `ApplyPolicy`, `Caps.MaxConditionSets` (default 10) bounds how many sets one statement carries (section 12). -- Validation: duplicate set Sort → `ListOfConditionsSetsMustHasUniqueSortValue`; a set after the first with a +- Validation: a null entry in `ConditionSets`, `Orders` or a condition list → + `ListOf[]MustNotHasNullEntry`, before anything else (3.3.0); duplicate set Sort → + `ListOfConditionsSetsMustHasUniqueSortValue`; a set after the first with a null `Intersection` → `ConditionsSetOfIndex[1-N]MustHasIntersection`; the first set's `Intersection` is ignored; a set with a null `ConditionGroup` → `ArgumentNullException`. Every clause is validated before the database is queried. @@ -947,8 +994,9 @@ LastOrDefault Select(f).OrderByDescending($).FirstOrDefault() any simple ty (`CategoryName`) — or an Alias, case-insensitive; else `SummaryOrderField[]MustExistInGroupByFieldsOrAggregateByAliases`. Emitted as ` asc|desc`, sorted by Sort, with no collection rewriting and no duplicate-Sort check. -- Summary validation order: `GroupBy` null (`ArgumentNullException`, parameter `GroupBy`) → GroupBy and AggregateBy - rules → Orders → Page → Having → ConditionGroup → Having DataType/Operator pairs. +- Summary validation order: a null entry in any list (3.3.0) → `GroupBy` null (`ArgumentNullException`, parameter + `GroupBy`) → GroupBy and AggregateBy rules → Orders → Page → Having → ConditionGroup → Having DataType/Operator + pairs. - Aggregation runs in SQL on stored values. Under `ApplyPolicy`, transformed fields need `AllowAggregate`, every guarded summary is subject to the group floor (section 19), and `Caps.MaxAggregates` (default 50, 3.1.0) bounds how many `AggregateBy` entries one summary sends (section 12). @@ -969,9 +1017,16 @@ derives from it, so catch `PolicyException` first. ``` String Raised when ConditionMustHasValidFieldName a Condition / OrderBy / GroupBy / AggregateBy / Having / Summary.Orders - field is null or blank, or a path does not resolve on T. Under - ApplyPolicy in the Strict tier, outside dry run, an unresolved - path is a PolicyException instead (3.1.0, section 17) + field is null or blank, or a path does not resolve on T; also a + Selects entry that is null or blank (3.3.0; it was an + ArgumentNullException). Under ApplyPolicy in the Strict tier, + outside dry run, an unresolved path is a PolicyException + instead (3.1.0, section 17) +ListOf[]MustNotHasNullEntry a list of the request shape holds a null entry (3.3.0). is + Conditions, SubConditionGroups, ConditionSets, Orders or + AggregateBy, spelled as the shape declares it. Before 3.3.0 a + null entry was a NullReferenceException from wherever it was + first touched FieldPath[]StartsWithReservedName a Condition / OrderBy / GroupBy / AggregateBy / Selects path, or a [DwAlias] target, whose first segment is one of the parser's own words — new, iif, np, isnull, is, as, cast, true, false, null, @@ -982,7 +1037,9 @@ ConditionWithOperator[]MustHasOnlyOneValue an operat ConditionWithOperator[Between-NotBetween]MustHasOnlyTwoValues Between / NotBetween without exactly 2 values ConditionWithOperator[In-IIn-NotIn-INotIn]MustHasOneOrMoreValues an In-family operator with no values ConditionWithOperator[IsNull-IsNotNull]MustHasNoValues IsNull / IsNotNull with any value -InvalidFormat a Guid / Number / Boolean / Date / DateTime value fails its TryParse +InvalidFormat a Guid / Boolean / Date / DateTime value fails its check, or a + Number value is not a literal the expression parser reads, or is + one it cannot compare with the member (3.3.0, section 4) AnyListOfConditionsMustHasUniqueSortValue two Conditions of one group (or Having group) share Sort AnyListOfSubConditionsGroupsMustHasUniqueSortValue two SubConditionGroups of one group share Sort ListOfConditionsSetsMustHasUniqueSortValue two ConditionSets share Sort @@ -1023,14 +1080,16 @@ The last one is a literal message; every other string above is a fixed code. Exa ### Other exceptions a caller can see ``` -ArgumentNullException a null query or shape argument; a null or blank Selects entry (parameter "name"); - Summary.GroupBy null (parameter "GroupBy"); a ConditionSet whose ConditionGroup is null -NullReferenceException a null element inside Conditions, SubConditionGroups, ConditionSets or Orders; - in memory, a null reference navigation inside a path +ArgumentNullException a null query or shape argument; Summary.GroupBy null (parameter "GroupBy"); a ConditionSet + whose ConditionGroup is null. A null or blank Selects entry was one until 3.3.0; it is + ConditionMustHasValidFieldName now +NullReferenceException in memory, a null reference navigation inside a path. A null element inside Conditions, + SubConditionGroups, ConditionSets, Orders or AggregateBy was one until 3.3.0; it is + ListOf[]MustNotHasNullEntry now PolicyException PolicyRequired from the [DwEntity(RequirePolicy = true)] guard; every refusal under ApplyPolicy ParseException System.Linq.Dynamic.Core.Exceptions.ParseException for input that passes validation but not - the parser: "1,000", "NaN", an enum name that is not a member, Contains on an enum-typed - member, a Where on a collection of simple values + the parser: an enum name that is not a member, Contains on an enum-typed member, a Where + on a collection of simple values. No Number value reaches it since 3.3.0 (section 4) InvalidOperationException async terminal on a non-EF source; typed Select through a reference navigation in memory; two group fields ending in the same segment; ordering by a non-comparable type in memory EF Core / provider exceptions unwrapped (for example SQLite NotSupportedException for ORDER BY on decimal) @@ -1049,7 +1108,8 @@ ConditionGroup Sort unique among Conditions → Sort unique among SubCondition each sub-group in Sort order (recursively) OrderBy Field null/blank → Field resolves → not ending on a collection of entities PageBy PageNumber >= 1 → PageSize >= 1 -Selects list not null (ArgumentNullException) → not empty → no blank entry → each resolves → constructor (Select) +Selects list not null (ArgumentNullException) → not empty → no null or blank entry → each resolves → + constructor (Select) GroupBy Fields not empty → each field: null/blank, resolves, unique, not complex → each AggregateBy: Alias identifier → Field (unless Count) → simple type → aggregator valid for type → Alias not a group field → Alias unique @@ -1058,6 +1118,18 @@ Segment set Sort unique → Intersection on later sets → each set's C Filter ConditionGroup → Orders → Page → Selects, each only when not null ``` +- **A null entry in a list is read before any of this (3.3.0).** Every method that takes a shape walks its lists for a + null entry first: the composables `Where(ConditionGroup)`, `Order(List)`, `Select`, `SelectDynamic`, + `Group`, `Summary`, and every terminal for a Filter, a Segment and a Summary, sync and async. `Filter` and + `FilterDynamic` compose `Where`, `Order` and `Select`, so each list is walked as that clause is reached. Under + `ApplyPolicy` the walk runs at the top of the sanitizer, before the caps and before the gate, because it is about + the request's shape and not about a policy decision — so a guarded Filter with a null `Orders` entry is refused + before its condition group is validated, where an unguarded one reaches the condition group first. Refusals: + `ListOf[Conditions]MustNotHasNullEntry`, `ListOf[SubConditionGroups]MustNotHasNullEntry`, + `ListOf[ConditionSets]MustNotHasNullEntry`, `ListOf[Orders]MustNotHasNullEntry`, + `ListOf[AggregateBy]MustNotHasNullEntry`, and `ConditionMustHasValidFieldName` for a `Selects` entry that is null + or blank. A list that is itself null still means what it meant — most readers read it as empty. + --- ## 9. JSON wire format @@ -1355,8 +1427,9 @@ Each of these compiles, passes validation, and returns something other than what 8. **Each `Order(...)` call replaces the previous ordering.** Put every key in one `List`. 9. **Some requests pass validation and then fail in the parser** with `System.Linq.Dynamic.Core.Exceptions.ParseException`: `Contains` / `StartsWith` / `EndsWith` on an enum-typed member, a `Where` - directly on a `List`, a Number value with a thousands separator or `NaN`, an enum name that is not a member. - Treat `ParseException` as a 400 too. + directly on a `List`, an enum name that is not a member. + Treat `ParseException` as a 400 too. Number values no longer reach it (3.3.0, section 4): one the parser cannot read, + or cannot compare with the member, is `InvalidFormat` at validation. 10. **Summary column names collide silently.** Two group fields ending in the same segment (`Name`, `Category.Name`) throw `InvalidOperationException` at run time, and an alias equal to a dot-stripped group field (`CategoryName` beside `Category.Name`) silently drops a column. @@ -2880,10 +2953,12 @@ Tokenize any value -> 32 lowercase h token. - `DwToken.New()` returns 16 cryptographic random bytes as 32 lowercase hex characters. - `DwToken.KeyFor(string scope, string value)` returns `scope + ":" + lowercase hex SHA-256(UTF-8 value)`. - `InMemoryTokenVault`, `RedisTokenVault` and `EfTokenVault` all store mappings under this key. There is no - reverse lookup. + A vault given a key stores under `DwToken.KeyFor(scope, value, key)` instead (3.3.0), which is + `"hmac:" + scope + ":" + the lowercase hex HMAC-SHA256`, so a copy of the store gives no value back. + There is no reverse lookup either way. Section 24. - `InMemoryTokenVault` lasts for the process and is unbounded. It exposes `Count` and `Clear()`, and a - restart issues new tokens. + restart issues new tokens. It draws a key of its own (3.3.0), so its mappings are keyed with nothing to + configure. - `Hash` and `Tokenize` both preserve equality, so the column still groups and joins, given the same salt or the same scope. @@ -3607,6 +3682,16 @@ DwToken static class; helpers for va New() -> string 32 lowercase hex characters from 16 RandomNumberGenerator bytes KeyFor(string scope, string value) -> string ":" blank scope → ArgumentException; null value → ArgumentNullException + KeyFor(string scope, string value, byte[] key) "hmac::<64 lowercase hex>" (3.3.0); HMAC-SHA256 under the + -> string key over UTF8(scope), one zero byte, UTF8(value), so one value in + two scopes shares no digest. Blank scope → ArgumentException; + null value or key → ArgumentNullException; key under + MinimumKeyLength → ArgumentException + RequireKey(byte[] key) -> byte[] (3.3.0) returns a copy, so a caller clearing or reusing its array + cannot re-key a running vault. null → ArgumentNullException; + under MinimumKeyLength → ArgumentException + MinimumKeyLength : const int = 16 (3.3.0) the fewest bytes a vault key may have + KeyedPrefix : const string = "hmac:" (3.3.0) what a keyed mapping's key starts with InMemoryTokenVault : IDwTokenVault sealed class; parameterless constructor GetOrCreate(string scope, string value) -> string @@ -3615,9 +3700,31 @@ InMemoryTokenVault : IDwTokenVault sealed class; parameterless ``` - `InMemoryTokenVault` is process-local, thread-safe and unbounded (no eviction). - - It is keyed by `DwToken.KeyFor`, so raw values are never keys. + - It draws a random 32-byte key of its own per instance and stores under `KeyFor(scope, value, key)` (3.3.0), so a + memory dump holds keyed digests rather than plain ones. Nothing to configure, and no API change: its mappings die + with the process, so the key can too. + - Raw values are never keys. - Tokens are lost on restart, and two instances or processes issue different tokens for one value. - `RedisTokenVault` (Redis package) and `EfTokenVault` (EF Core package) keep tokens across restarts and instances. + Each takes a key in a constructor of its own (3.3.0, sections 27 and 28); the constructors that do not take one are + unchanged and store under the unkeyed `KeyFor`. +- **Why a durable vault wants a key (3.3.0).** The unkeyed key is a plain digest, and a tokenized column is nearly + always drawn from a space small enough to hash whole — phone numbers, national identifiers, card numbers. So a copy + of the store, a backup or a replica or a dump, gives back every value in it, and with them the value behind every + token ever issued. Under a key held outside the store, in configuration or a secret manager, the store and the key + have to be taken together. Guard the store as you would guard the column it protects either way. + - **Adoption keeps every token already issued.** A keyed vault meeting a value with no keyed mapping looks up the + unkeyed mapping too, and the token found there is the one written under the keyed key, so yesterday's export + still lines up with today's. The unkeyed mapping stays until `retireUnkeyed` is true; a retiring vault deletes it + the first time it meets the value, whether it wrote the keyed mapping or found it. + - **Roll out in two steps:** give every instance the key, then turn `retireUnkeyed` on. An instance still running + without the key mints a *new* token for a value whose unkeyed mapping is gone, and a value first met while keyed + and unkeyed instances run side by side can end up with two tokens. + - Unkeyed mappings of values never met again stay until an operator deletes them — Redis: `HSCAN` the token hash + and delete the fields that do not match `hmac:*`; SQL: delete the rows of `DwPolicyTokens` whose `Key` does not + start with `hmac:` — knowing such a value gets a new token the next time it is met. + - Changing the key re-issues every token, unless unkeyed mappings remain to adopt from. Another key is another + vault. - A vault is read on the query path, once per value per row, from many threads. A remote vault must cache in process. - Implementation contract: - thread-safe; @@ -3974,8 +4081,10 @@ net6.0 dependencies: DynamicWhere.ex 3.3.0, StackExchange.Redis 2.8.24 names new RedisPolicyStore(IConnectionMultiplexer redis, string? prefix = null, Func? resolveType = null) : IDwPolicyWritableStore new RedisTokenVault(IConnectionMultiplexer redis, string? prefix = null) : IDwTokenVault +new RedisTokenVault(IConnectionMultiplexer redis, byte[] key, string? prefix = null, bool retireUnkeyed = false) (3.3.0) string GetOrCreate(string scope, string value) int CachedCount void ClearCache() null redis -> ArgumentNullException; the multiplexer is not owned and never disposed +null key -> ArgumentNullException; a key under DwToken.MinimumKeyLength (16) -> ArgumentException ``` Key layout, from the internal `RedisPolicyKeys` (prefix trimmed; blank -> `dw:policy`): @@ -3986,6 +4095,8 @@ Key layout, from the internal `RedisPolicyKeys` (prefix trimmed; blank -> `dw:po {prefix}:version string counter; absent reads as 0 {prefix}:version pub/sub channel; message = the new version {prefix}:tokens hash "{scope}:{sha256(value) lowercase hex}" -> 32 lowercase hex token + "hmac:{scope}:{hmac-sha256 lowercase hex}" for a keyed vault (3.3.0); one hash holds + both kinds while a deployment moves from one to the other ``` RedisPolicyStore: @@ -4007,7 +4118,11 @@ RedisTokenVault: - A field that disappears between the two calls throws `InvalidOperationException`. - A `RedisException` propagates. - **Query path:** Redis calls are synchronous and happen on a cache miss, during the query. -- **Protection:** the hash stores the scope in clear and an unkeyed SHA-256 of the value, never the value itself. Anyone who can read it can confirm a value they can guess, so guard it like the column it protects. +- **Protection:** the hash stores the scope in clear and a digest of the value, never the value itself. Unkeyed that digest is a plain SHA-256, and a tokenized column is nearly always drawn from a space small enough to hash whole, so a dump of the hash is a dump of the column. Guard it like the column it protects, and give the vault a key: under one the field is an HMAC, and the hash and the key have to be taken together. +- **Keyed (3.3.0):** `new RedisTokenVault(redis, key, prefix, retireUnkeyed)`. The key is held where the hash is not — configuration or a secret manager — and every instance sharing the hash takes the same one. + - **Adoption:** a value with no keyed field is looked up under its unkeyed field too, and the token found there is the one written under the keyed field, so every token already issued is kept. Both fields are read in one `HMGET`, so a value new to the store costs two round trips instead of one; a value already keyed still costs one, and a cached one none. + - **Retiring:** `retireUnkeyed: true` `HDEL`s the unkeyed field the first time this vault meets the value, once the keyed field is there, whether this vault wrote it or found it. Leave it false until every instance has the key (section 24). + - Minting under a key uses the same `HSETNX`-then-`HGET` race handling, with the same `InvalidOperationException` when the field disappears between the two calls. ```csharp IConnectionMultiplexer redis = await ConnectionMultiplexer.ConnectAsync(connectionString); @@ -4027,8 +4142,10 @@ namespace DynamicWhere.ex.Policies.EntityFrameworkCore no raw SQL; any EF C new EfPolicyStore(Func contexts, Func? resolveType = null) : IDwPolicyWritableStore new EfTokenVault(Func contexts) : IDwTokenVault +new EfTokenVault(Func contexts, byte[] key, bool retireUnkeyed = false) (3.3.0) string GetOrCreate(string scope, string value) int CachedCount void ClearCache() null contexts -> ArgumentNullException +null key -> ArgumentNullException; a key under DwToken.MinimumKeyLength (16) -> ArgumentException class DwPolicyDbContext(DbContextOptions options) : DbContext DbSet PolicyRules DbSet PolicyVersion DbSet PolicyTokens @@ -4049,7 +4166,9 @@ DwPolicyRules Id Guid PK, never generated CreatedBy string(256) CreatedAt DateTimeOffset? (UTC) UpdatedBy string(256) UpdatedAt DateTimeOffset? (UTC) index (SubjectKind, SubjectKeyNormalized, EntityType) index (EntityType, FieldPath) DwPolicyVersion Id int PK, never generated (one row, Id = 1) Version long, concurrency token UpdatedAt DateTimeOffset -DwPolicyTokens Key string(512) PK, never generated = "{scope}:{sha256 hex}" Scope string(256) required, indexed +DwPolicyTokens Key string(512) PK, never generated = "{scope}:{sha256 hex}", or "hmac:{scope}:{hmac-sha256 hex}" + under a key (3.3.0; no schema change — a keyed key is at most 326 characters) + Scope string(256) required, indexed Token string(64) required CreatedAt DateTimeOffset (written as UTC) DwPolicyRuleRecord Guid Id string SubjectKind string? SubjectKey string? SubjectKeyNormalized string EntityType @@ -4098,7 +4217,9 @@ EfPolicyStore: EfTokenVault: - **Contexts:** `contexts` is called only on a cache miss and the context is disposed. The model must include `DwPolicyTokenConfiguration`. - **Minting:** reads with no tracking, then inserts. On a `DbUpdateException` from a primary-key race it clears the change tracker and reads the winning row. If that row is still missing it throws `InvalidOperationException`. -- **Behaviour:** EF calls are synchronous. It caches in the process and uses the same scope-plus-digest keys as `RedisTokenVault`. +- **Behaviour:** EF calls are synchronous. It caches in the process and uses the same keys as `RedisTokenVault`. +- **Protection:** unkeyed, a row's `Key` is a plain digest of the value, and a tokenized column is nearly always drawn from a space small enough to hash whole, so reading the table turns every token in every result back into the value behind it. Guard it like the column it protects, and give the vault a key: under one the `Key` is an HMAC, and the table and the key have to be taken together. +- **Keyed (3.3.0):** `new EfTokenVault(contexts, key, retireUnkeyed)`. A value with no keyed row is looked up under its unkeyed key too and keeps the token that row gave it, so every token already issued is kept. It costs one more read for a value new to the store, and in retire mode one more read per first-met value. `retireUnkeyed: true` deletes the unkeyed row the first time this vault meets the value, once the keyed row is there, whether this vault wrote it or found it; a concurrent delete of the same row is not an error. Leave it false until every instance has the key (section 24). No schema change. --- @@ -4479,7 +4600,9 @@ catch (LogicException ex) Whoever holds the salt can recompute every digest. - **Low-entropy regulated ID with a right to erasure:** `[DwMask(MaskStrategy.Tokenize)]` + `[DwNoOrder]` + a durable vault. To erase, delete the vault entry at `DwToken.KeyFor(scope, value)`, where the scope - is `TokenScope` or the field path. + is `TokenScope` or the field path — or, where the vault holds a key, at + `DwToken.KeyFor(scope, value, key)` (3.3.0), which only a holder of the key can compute. A vault that has + not retired its unkeyed mappings may still hold one for the value; delete both. - **One subject across entities:** give every tokenized member that must match the same explicit `TokenScope`, e.g. `"patient-id"`. - **Different visibility per role:** @@ -5149,8 +5272,9 @@ MemoryCalculationInput ``` 3.3.0 A second host may configure the same posture, a path the query cannot compute is refused rather than run, - a path or a member the attribute walk cannot name is still policed, and Clone is public on Filter, Segment - and Summary. + a path or a member the attribute walk cannot name is still policed, a malformed request is refused as one + rather than reaching the caller as a server error, a token vault can hold a key, and Clone is public on + Filter, Segment and Summary. - New. DwPolicy.Configure, and AddDwPolicies with it, take a second call asking for the posture already in force and do nothing. A different posture still throws. The check is inside the lock that configures, so a caller needs no IsConfigured check of its own — that check is a check-then-act two hosts can both pass. @@ -5322,6 +5446,59 @@ MemoryCalculationInput models. - Docs. DwEntity(DefaultOrder)'s remarks said a projected query takes no default; it has since 3.2.0. The aggregation sections now lead with Caps.MinGroupSize shipping on at 5. + - Fix and behaviour change. A DataType.Number value is read as the expression parser reads it, not as the + host's culture does. The builder writes a number into the expression unquoted, exactly as sent, while + validation checked it with byte/short/int/long/float/double/decimal TryParse in the host's culture, and + the two disagreed: "1,000", "5-", "+5", ".5", "5.", "-.5", "1.e5", "NaN", "Infinity", "-Infinity" and an + integer past UInt64, or below Int64 when negative, all passed validation and then threw the parser's own + ParseException, which a host maps to a server error; "1,5" passed on a de-DE host and was refused on an + en-US one; and "NaN" and "Infinity" were written into the expression as identifiers, so on a type with a + member of that name the condition compared two columns. A value is read in two steps now. First the + parser's own grammar, in the invariant culture and ASCII digits only: optional white space, an optional + minus, digits, an optional fraction with a digit on both sides of the point, an optional exponent; an + integer must fit UInt64, or Int64 when negative, and a real has no bound. Then, in a WHERE condition and + for the operators that write the value into a comparison, whether the literal compares with the member the + condition names — the parser itself is asked, against the member's declared type, a collection at the end + of the path standing for itself. Refused there, where the parser used to throw: a literal written with a + point and no exponent on a nullable integral member, an exponent form or a real longer than a decimal + holds on a decimal, an integer above Int64.MaxValue on a signed integral member, a negative number on + ulong, any number on a string, bool, Guid, DateTime or char member or on a collection of simple values, + and a nullable enum under an ordering operator. A HAVING condition reads the grammar and stops, since an + alias has no member type to ask about. Every refusal is a LogicException with InvalidFormat, the same in + both tiers under a policy, where the gate still answers first. Nothing that ran before is refused now: + every value refused is one the parser refused. Section 4. + - Fix and behaviour change. A null entry in a request's list is a malformed request, refused as one. A body + can say "conditions": [null], "subConditionGroups": [null], "conditionSets": [null], "orders": [null], + "aggregateBy": [null] or "selects": [null]. Nothing read a list expecting that, so the null surfaced + wherever it was first touched: a NullReferenceException from the sort-order check, from the ordering, or, + under a policy, from the copy the sanitizer takes before it reads anything, and an ArgumentNullException + from the name lookup — a server error for a request that was simply malformed. Every method that takes a + shape walks its lists before anything else reads them now, with or without a policy, in both tiers, sync + and async: the composables Where(ConditionGroup), Order(List), Select, SelectDynamic, Group and + Summary, and every terminal for a Filter, a Segment and a Summary. Under a policy the walk runs at the top + of the sanitizer, before the caps and before the gate, because it is about the request's shape and not a + policy decision. A null condition, sub-group, condition set, order or aggregate is a LogicException naming + the list, ListOf[Conditions]MustNotHasNullEntry. A Selects entry that is null or blank is + ConditionMustHasValidFieldName, the refusal a null or blank GroupBy field has always had. A list that is + itself null still means what it meant; a ConditionSet whose ConditionGroup is null is still an + ArgumentNullException, as is a null Summary.GroupBy; and a null element inside Condition.Values still + reads as "". Clone copies a null entry as a null entry rather than failing on it, so the refusal is the + running method's and reads the same for a copy. Sections 2 and 8. + - New. A token vault can hold a key, so a copy of the store gives no value back. The key a vault stores a + mapping under is a plain SHA-256 of the value, and a tokenized column is nearly always drawn from a space + small enough to hash whole, so a backup, a replica or a dump of the store gives back every value in it, + and with them the value behind every token ever issued. DwToken.KeyFor(scope, value, key) is an HMAC-SHA256 + under a key of at least DwToken.MinimumKeyLength, sixteen bytes, over the scope, one zero byte and the + value, written DwToken.KeyedPrefix + scope + ":" + 64 lowercase hex characters, so one value in two scopes + shares no digest; DwToken.RequireKey refuses a key that is null or short and returns a copy of it. + RedisTokenVault and EfTokenVault take the key in a constructor of their own, with a retireUnkeyed switch; + the existing constructors are unchanged and unkeyed, and the unkeyed KeyFor is unchanged. + InMemoryTokenVault draws a random 32-byte key of its own per instance, with nothing to configure and no + API change. A keyed vault adopts the token an unkeyed mapping already gave a value, so every token already + issued is kept, and the unkeyed mapping stays until retireUnkeyed is on: give every instance the key + first, because one still running without it mints a new token for a value whose unkeyed mapping is gone. + No schema change — a keyed key is at most 326 characters against the 512 the Key column holds — and one + more round trip or read for a value the store has not seen. Sections 24, 27 and 28. 3.2.0 A projected row keeps its members, denials the gate could not see are enforced, a default order that reaches projected rows, and a CancellationToken on every async terminal. The security fixes refuse or diff --git a/README.md b/README.md index 3f47fca..3db76a2 100644 --- a/README.md +++ b/README.md @@ -378,7 +378,7 @@ The complete reference — every enum, class, extension method, validation rule, ## Version 3.3.0 highlights -**Upgrade note — several behaviour changes, most of them security fixes. Read these before bumping.** +**Upgrade note — several behaviour changes, most of them security fixes, and two shapes of malformed request that used to be a five-hundred are now a `LogicException`. Read these before bumping.** - **New: a second host may configure the same posture.** `DwPolicy.Configure`, and `AddDwPolicies` with it, used to throw on every call after the first, so an integration suite starting several `WebApplicationFactory` hosts over one composition root had to read `IsConfigured` first — a check-then-act two hosts starting at once can both pass. A second call asking for the posture already in force now does nothing and returns, and the comparison happens inside the lock that does the configuring, so no caller needs a lock of its own. A different posture is still refused: the tier, the dry-run, trace and refusal-audit flags, the hash salt, the store-failure mode, both intervals, every cap, the exposed entity catalogue and the kinds of policy source are all compared. Writing a value's own default down is not a difference: a host binding `"MinGroupSize": 5` from the documented sample and a host on the defaults enforce the same floor, and `IncludeTraceInResult` written as the tier's own answer is the answer the tier already gives. A type exposed under two names is reported under the last one, so two catalogues that resolve every name alike are still a different posture when the order differs. The token vault, the service provider and the provider instances are not, because a second host builds its own; they stay as the first call left them, so a second host runs with the first host's vault, container and rule stores. `AddDwPolicies` registers the posture in force rather than the instance it just built. - **Changed: under `Strict`, a path the query cannot compute is refused rather than run.** A member of a row's type is not always a value a database can produce: a getter such as `LocalizedText.IsEmpty` reads two columns in memory, so every check the policy made passed and EF Core then threw — a five-hundred where the tier promises a refusal. Such a path is now refused as an unknown name is, in every clause the database has to compute — a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. Not in `Selects`: EF Core evaluates the last projection on the client, so selecting such a member returns its value exactly as before. It is refused only where the whole set of members a container can produce is known: an entity's own model, and the initializers of a projection composed before `ApplyPolicy`, including a member that projection copies from the entity. Rows in memory, a framework member the provider translates such as `Length` or `Year`, anything beneath a column, the convenience tier and a dry run are all unchanged. So is a query any provider but EF Core's own translates — LinqKit's `AsExpandable()`, DelegateDecompiler's `Decompile()`, or a host's own registered through `ReplaceService` — because such a provider may rewrite what EF Core cannot, and the library cannot tell one that does from one that passes straight through. The test is EF Core's own provider type, from EF Core's own assembly. The rule is the model's: a member it maps nowhere is refused, so map such a member or name the columns beneath it. @@ -397,6 +397,9 @@ The complete reference — every enum, class, extension method, validation rule, - **Packaging: `Microsoft.Extensions.Caching.Memory` 6.0.2** is named directly, the version patched for CVE-2024-43483. EF Core 6.0.22 asks for 6.0.1 or later and 6.0.1 is the last version open to it, so a host on the EF Core 6 floor resolved a vulnerable version through all four packages. A host on EF Core 8 or later sees no change. - **Changed: `LastTrace` is set before a request is sanitized**, so a refused request leaves its trace readable rather than the previous request's. A strict refusal names no field on purpose, and the trace is where the real path and the reason live. - **New: `Clone()` is public on `Filter`, `Segment` and `Summary`.** It returns a deep copy — the condition tree, the projection list, each order, the page, and a summary's group-by and having clause — so reading the same request again with another page no longer means rebuilding it around the caller's own clauses, which leaves two requests sharing one condition tree. Every node is new; a condition's values stay the caller's own objects, in a new list. +- **Changed: a `Number` value is read the way the expression parser reads it.** The builder writes a number into the generated expression unquoted, exactly as sent, while validation checked it with `TryParse` in the host's culture, so `"1,000"`, `"+5"`, `".5"`, `"NaN"`, `"Infinity"` and an integer past `UInt64` all passed and then threw the parser's own `ParseException`, which a host maps to a five-hundred; `"1,5"` passed on a German host and was refused on an English one; and `"NaN"` and `"Infinity"` were written in as identifiers, so on a type with a member of that name the condition compared two columns. A value is now read against the parser's own grammar, in the invariant culture, and then — in a `Where` condition — against the member the condition names, which the parser itself is asked about, so `1.5` on an `int?`, `1e-7` on a `decimal` and any number on a `string` are refused as `InvalidFormat` rather than thrown at. Nothing that ran before is refused now. +- **Changed: a `null` entry in a request's list is a malformed request, refused as one.** A body can say `"conditions": [null]`, `"orders": [null]` or `"selects": [null]`, and nothing read a list expecting that, so the null surfaced wherever it was first touched — a `NullReferenceException` from the sort-order check, from the ordering, or from the copy the policy sanitizer takes, and an `ArgumentNullException` from the name lookup. Every method that takes a shape walks its lists first now, guarded or not: a null condition, sub-group, condition set, order or aggregate is `ListOf[Conditions]MustNotHasNullEntry` and its kin, and a null or blank `Selects` entry is `ConditionMustHasValidFieldName`. A list that is itself null still means what it meant, and `Clone()` copies a null entry rather than failing on it. +- **New: a token vault can hold a key, so a copy of the store gives no value back.** A vault stores its mapping under a plain SHA-256 of the value, and a tokenized column is nearly always drawn from a space small enough to hash whole, so a backup, a replica or a dump gives back every value in it and with them the value behind every token ever issued. `RedisTokenVault` and `EfTokenVault` take a key of 16 bytes or more in a constructor of their own and store an HMAC-SHA256 instead, so the store and the key have to be taken together; `InMemoryTokenVault` draws one of its own with nothing to configure. A keyed vault adopts the token an unkeyed mapping already gave a value, so every token already issued is kept — give every instance the key before turning `retireUnkeyed` on. No schema change. - **Docs:** `[DwEntity(DefaultOrder)]`'s own remarks said a projected query takes no default; it has since 3.2.0. `DwCaps.MinGroupSize` ships **on at 5**, so a guarded summary silently drops groups of fewer than five rows — the aggregation docs now lead with that instead of leaving it to the caps table. ## Version 3.2.0 highlights