diff --git a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs index b22de7f..6dba510 100644 --- a/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs +++ b/DynamicWhere.Tests/Policies/ConfigureOnceTests.cs @@ -77,6 +77,15 @@ private static DwPolicyOptions Copy() copy.Caps.MinGroupSize = inForce.Caps.MinGroupSize; } + foreach (KeyValuePair purpose in inForce.Caps.Purposes) + { + copy.Caps.Purposes[purpose.Key] = new DwPageCaps + { + MaxPageSize = purpose.Value.MaxPageSize, + DefaultPageSize = purpose.Value.DefaultPageSize + }; + } + foreach (KeyValuePair exposed in inForce.Entities.Entities) { copy.Entities.Expose(exposed.Key, exposed.Value); @@ -446,6 +455,83 @@ public void Every_value_on_the_posture_is_compared(Type declaring) } } + [Fact] + public void A_purpose_whose_page_caps_are_the_deployment_ones_is_the_same_posture() + { + // Compared by the caps that apply: a purpose writing out the deployment's numbers enforces + // what no entry enforces. + DwPolicyOptions same = Copy(); + + same.Caps.Purposes["zz-same"] = new DwPageCaps + { + MaxPageSize = DwPolicy.Options.Caps.MaxPageSize, + DefaultPageSize = DwPolicy.Options.Caps.DefaultPageSize == 0 ? null : DwPolicy.Options.Caps.DefaultPageSize + }; + + DwPolicyOptions inForce = DwPolicy.Options; + + DwPolicy.Configure(same); + + Assert.Same(inForce, DwPolicy.Options); + } + + [Fact] + public void A_purpose_with_other_page_caps_is_a_different_posture() + { + DwPolicyOptions larger = Copy(); + larger.Caps.Purposes["zz-export"] = new DwPageCaps { MaxPageSize = DwPolicy.Options.Caps.MaxPageSize + 1 }; + + Refused(larger); + + DwPolicyOptions defaulted = Copy(); + defaulted.Caps.Purposes["zz-export"] = new DwPageCaps { DefaultPageSize = DwPolicy.Options.Caps.DefaultPageSize + 1 }; + + Refused(defaulted); + } + + /// + /// Every property of the caps either sets a value the comparison walks above, or is one of the + /// two named here; and every value a purpose holds is compared. + /// + /// + /// The reflection walk above reads settable properties, and the purposes are a collection with no + /// setter, so this is what keeps a later collection on the caps from being forgotten the same way. + /// + [Fact] + public void Every_page_cap_a_purpose_holds_is_compared() + { + List unsettable = typeof(DwCaps) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.GetSetMethod() is null) + .Select(property => property.Name) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + + Assert.Equal(new[] { nameof(DwCaps.IsMinGroupSizeSet), nameof(DwCaps.Purposes) }, unsettable); + + List held = typeof(DwPageCaps) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.GetSetMethod() is not null) + .ToList(); + + Assert.NotEmpty(held); + + foreach (PropertyInfo property in held) + { + DwPolicyOptions different = Copy(); + DwPageCaps caps = new(); + + property.SetValue(caps, 7); + different.Caps.Purposes["zz-" + property.Name] = caps; + + // Seven differs from both deployment caps the bootstrap posture holds. + Assert.NotEqual(7, DwPolicy.Options.Caps.MaxPageSize); + Assert.NotEqual(7, DwPolicy.Options.Caps.DefaultPageSize); + + Refused(different); + } + } + /// A value of the same type that is not the one held. private static object? Other(object? held, Type type) => type switch { diff --git a/DynamicWhere.Tests/Policies/ConstructedMemberTests.cs b/DynamicWhere.Tests/Policies/ConstructedMemberTests.cs new file mode 100644 index 0000000..3217921 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ConstructedMemberTests.cs @@ -0,0 +1,482 @@ +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; + +namespace DynamicWhere.Tests.Policies +{ + // ---- DCMP's shape: flat columns, the pair composed in C# only ----------------------------------------- + + /// A shared kernel pair, as DCMP's LocalizedText is: a positional record struct. + public readonly record struct ZcText(string Ar, string En) + { + public bool IsEmpty => string.IsNullOrWhiteSpace(Ar) && string.IsNullOrWhiteSpace(En); + } + + /// The same pair as a class with a constructor, for a shape that is not a record. + public sealed class ZcClassText + { + public ZcClassText(string ar, string en) + { + Ar = ar; + En = en; + } + + public string Ar { get; } + + public string En { get; } + } + + /// A value built from one argument and one initializer binding. + public sealed class ZcMoney + { + public ZcMoney() + { + } + + public ZcMoney(decimal amount) => Amount = amount; + + public decimal Amount { get; set; } + + public string Currency { get; set; } = string.Empty; + } + + /// A type the model stores as a column, through a converter. + public readonly record struct ZcTag(string Value); + + public class ZcTenant + { + public int Id { get; set; } + + public string NameAr { get; set; } = string.Empty; + + public string NameEn { get; set; } = string.Empty; + + public decimal Amount { get; set; } + + public string Currency { get; set; } = string.Empty; + + public int Year { get; set; } + + public ZcTag Tag { get; set; } + + /// Not mapped: composed over the two columns, as DCMP's entities compose it. + public ZcText Name => new(NameAr, NameEn); + } + + public class ZcRow + { + public int Id { get; set; } + + /// No attribute, so what refuses a path beneath it is the shape of the projection. + public ZcText Name { get; set; } + + public ZcClassText? Label { get; set; } + + public ZcMoney? Money { get; set; } + + public DateTime Start { get; set; } + + public ZcTag Copy { get; set; } + } + + /// DCMP's row as it ships: the composed pair denied for filtering and sorting. + public class ZcDcmpRow + { + public int Id { get; set; } + + [DwNoWhere, DwNoOrder] + public ZcText Name { get; set; } + } + + /// A row that is itself built by a constructor, as a positional record row is. + public sealed record ZcRecordRow(int Id, string NameAr); + + public sealed class ZcContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZcContext(SqliteConnection connection) => _connection = connection; + + public DbSet Tenants => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().Ignore(tenant => tenant.Name); + model.Entity().Property(tenant => tenant.Tag) + .HasConversion(tag => tag.Value, value => new ZcTag(value)); + } + } + + /// + /// A member a projection builds by calling a constructor with arguments. EF Core follows a member + /// only through an initializer's binding, so a clause on a path through such a member failed inside + /// the provider: a five-hundred where the strict tier promises a refusal. DCMP-314. + /// + public sealed class ConstructedMemberTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly ZcContext _db; + + public ConstructedMemberTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZcContext(_connection); + _db.Database.EnsureCreated(); + _db.Tenants.Add(new ZcTenant + { + NameAr = "مالية", NameEn = "Finance", Amount = 10, Currency = "IQD", Year = 2026, Tag = new ZcTag("a") + }); + _db.Tenants.Add(new ZcTenant + { + NameAr = "صحة", NameEn = "Health", Amount = 20, Currency = "USD", Year = 2025, Tag = new ZcTag("b") + }); + _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, Operator op = Operator.Equal, DataType type = DataType.Text) + { + Condition condition = new() { Field = field, DataType = type, Operator = op }; + condition.Values.Add(value); + + return new Filter { ConditionGroup = new ConditionGroup { Conditions = { condition } } }; + } + + private static Filter OrderBy(string field) => new() { Orders = new List { new() { Field = field } } }; + + /// DCMP's projection as it is written today. + private IQueryable Constructed() => + _db.Tenants.Select(t => new ZcRow { Id = t.Id, Name = new ZcText(t.NameAr, t.NameEn) }); + + /// The same projection written with an initializer, which EF Core follows. + private IQueryable Initialized() => + _db.Tenants.Select(t => new ZcRow { Id = t.Id, Name = new ZcText { Ar = t.NameAr, En = t.NameEn } }); + + // ---- the refusal --------------------------------------------------------------------------------- + + [Theory] + [InlineData("Name.Ar", Operator.Equal, "صحة")] + [InlineData("Name.En", Operator.IContains, "fin")] + public void A_filter_through_a_constructed_member_is_refused_under_the_strict_tier( + string field, Operator op, string value) + { + PolicyException refusal = Assert.Throws( + () => Guard(Constructed(), DwTier.Strict).ToList(Where(field, value, op))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + Assert.Equal("*", refusal.FieldPath); + } + + [Fact] + public void A_sort_through_a_constructed_member_is_refused_under_the_strict_tier() + { + PolicyException refusal = Assert.Throws( + () => Guard(Constructed(), DwTier.Strict).ToList(OrderBy("Name.En"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, refusal.ErrorCode); + } + + [Fact] + public void A_getter_through_a_constructed_member_is_refused_as_it_is_through_an_initializer() + { + PolicyException refusal = Assert.Throws( + () => Guard(Constructed(), DwTier.Strict).ToList(Where("Name.IsEmpty", "true", type: DataType.Boolean))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void The_trace_says_why_the_path_was_refused() + { + PolicyQueryable guarded = Guard(Constructed(), DwTier.Strict); + + Assert.Throws(() => guarded.ToList(Where("Name.Ar", "x"))); + + Assert.Contains(guarded.LastTrace!.Decisions, decision => + decision.FieldPath == "Name.Ar" + && decision.Reason is { } reason + && reason.Contains("cannot compute", StringComparison.Ordinal)); + } + + [Fact] + public void A_class_built_by_its_constructor_is_refused_as_a_record_struct_is() + { + IQueryable rows = _db.Tenants.Select(t => new ZcRow { Id = t.Id, Label = new ZcClassText(t.NameAr, t.NameEn) }); + + PolicyException refusal = Assert.Throws( + () => Guard(rows, DwTier.Strict).ToList(Where("Label.Ar", "x"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_clause_on_the_constructed_member_itself_is_refused_under_the_strict_tier() + { + // EF Core can no more order by, or test for null, a value it would build on the client than it + // can read a member of one. + PolicyException order = Assert.Throws( + () => Guard(Constructed(), DwTier.Strict).ToList(OrderBy("Name"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, order.ErrorCode); + Assert.Equal("*", order.FieldPath); + + IQueryable rows = _db.Tenants.Select(t => new ZcRow { Id = t.Id, Label = new ZcClassText(t.NameAr, t.NameEn) }); + Condition present = new() { Field = "Label", DataType = DataType.Text, Operator = Operator.IsNotNull }; + + PolicyException where = Assert.Throws(() => Guard(rows, DwTier.Strict) + .ToList(new Filter { ConditionGroup = new ConditionGroup { Conditions = { present } } })); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, where.ErrorCode); + } + + [Fact] + public void The_constructed_member_itself_can_still_be_selected() + { + var result = Guard(Constructed(), DwTier.Strict).ToList(new Filter { Selects = new List { "Id", "Name" } }); + + Assert.Equal(2, result.Data.Count); + Assert.All(result.Data, row => Assert.False(row.Name.IsEmpty)); + } + + [Fact] + public void The_convenience_tier_leaves_a_clause_on_the_constructed_member_to_the_provider() + { + Assert.Throws(() => Guard(Constructed(), DwTier.Convenience).ToList(OrderBy("Name"))); + } + + [Fact] + public void A_group_and_a_segment_through_a_constructed_member_are_refused() + { + Summary summary = new() { GroupBy = new GroupBy { Fields = { "Name.Ar" } } }; + + PolicyException grouped = Assert.Throws( + () => Guard(Constructed(), DwTier.Strict).ToList(summary)); + + Assert.Equal(PolicyErrorCode.FieldDeniedForGroup, grouped.ErrorCode); + + Segment segment = new() + { + ConditionSets = + { + new ConditionSet { Sort = 1, ConditionGroup = Where("Name.Ar", "x").ConditionGroup! } + } + }; + + PolicyException segmented = Assert.ThrowsAny( + () => Guard(Constructed(), DwTier.Strict).ToListAsync(segment).GetAwaiter().GetResult()); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSegment, segmented.ErrorCode); + } + + [Fact] + public void A_row_built_by_its_own_constructor_refuses_every_member_a_clause_names() + { + // A positional record as the row: EF Core cannot filter new ZcRecordRow(t.Id, t.NameAr) on + // NameAr either, and every clause on it was a five-hundred. + IQueryable rows = _db.Tenants.Select(t => new ZcRecordRow(t.Id, t.NameAr)); + + PolicyException refusal = Assert.Throws( + () => Guard(rows, DwTier.Strict).ToList(Where("NameAr", "x"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + + // Nothing is refused where nothing is named: the count and the read need no member. + FilterResult all = Guard(rows, DwTier.Strict).ToList(new Filter()); + + Assert.Equal(2, all.Data.Count); + } + + [Fact] + public void A_member_an_initializer_beside_the_constructor_binds_stays_filterable() + { + IQueryable rows = _db.Tenants.Select(t => new ZcRow + { + Id = t.Id, + Money = new ZcMoney(t.Amount) { Currency = t.Currency } + }); + + FilterResult bound = Guard(rows, DwTier.Strict).ToList(Where("Money.Currency", "USD")); + + Assert.Single(bound.Data); + + PolicyException refusal = Assert.Throws( + () => Guard(rows, DwTier.Strict).ToList(Where("Money.Amount", "20", type: DataType.Number))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_member_one_constructing_branch_leaves_to_its_constructor_is_out_of_reach() + { + // Bound in one branch and not in the other: EF Core reads the member through both, so the + // branch that leaves it to the constructor decides. + IQueryable rows = _db.Tenants.Select(t => new ZcRow + { + Id = t.Id, + Money = t.Year > 2025 ? new ZcMoney(t.Amount) { Currency = t.Currency } : new ZcMoney(t.Amount) + }); + + PolicyException refusal = Assert.Throws( + () => Guard(rows, DwTier.Strict).ToList(Where("Money.Currency", "USD"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_constructor_in_one_branch_refuses_the_member_whatever_the_other_branch_binds() + { + IQueryable rows = _db.Tenants.Select(t => new ZcRow + { + Id = t.Id, + Name = t.Year > 2025 ? new ZcText(t.NameAr, t.NameEn) : new ZcText { Ar = t.NameAr, En = t.NameEn } + }); + + PolicyException refusal = Assert.Throws( + () => Guard(rows, DwTier.Strict).ToList(Where("Name.Ar", "x"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + // ---- what is left alone -------------------------------------------------------------------------- + + [Fact] + public void The_convenience_tier_is_left_as_the_unguarded_query_leaves_it() + { + // The shape-aware refusal is the strict tier's promise, as it has been since 3.3.0. + Exception thrown = Assert.ThrowsAny( + () => Guard(Constructed(), DwTier.Convenience).ToList(Where("Name.Ar", "x"))); + + Assert.IsNotType(thrown); + } + + [Fact] + public void A_framework_type_built_by_its_constructor_is_left_to_the_provider() + { + // new DateTime(y, 1, 1) is one a provider may translate, constructor and members alike. + IQueryable rows = _db.Tenants.Select(t => new ZcRow { Id = t.Id, Start = new DateTime(t.Year, 1, 1) }); + + try + { + Guard(rows, DwTier.Strict).ToList(Where("Start.Year", "2026", type: DataType.Number)); + } + catch (Exception thrown) + { + Assert.IsNotType(thrown); + } + } + + [Fact] + public void A_type_the_model_stores_as_a_column_is_left_to_the_provider() + { + IQueryable rows = _db.Tenants.Select(t => new ZcRow { Id = t.Id, Copy = new ZcTag(t.NameEn) }); + + try + { + Guard(rows, DwTier.Strict).ToList(Where("Copy.Value", "Health")); + } + catch (Exception thrown) + { + Assert.IsNotType(thrown); + } + } + + [Fact] + public void Selecting_a_path_through_a_constructed_member_still_works() + { + // EF Core evaluates the last projection on the client, so the row is built and read there. + FilterResult result = Guard(Constructed(), DwTier.Strict) + .ToList(new Filter { Selects = new List { "Name.Ar" }, Orders = new List { new() { Field = "Id" } } }); + + Assert.Equal(new[] { "مالية", "صحة" }, result.Data.Select(row => row.Name.Ar)); + } + + // ---- DCMP's row, unchanged -------------------------------------------------------------------- + + private IQueryable Dcmp() => + _db.Tenants.Select(t => new ZcDcmpRow { Id = t.Id, Name = new ZcText(t.NameAr, t.NameEn) }); + + [Theory] + [InlineData(DwTier.Strict, "Name")] + [InlineData(DwTier.Strict, "Name.Ar")] + [InlineData(DwTier.Strict, "Name.En")] + [InlineData(DwTier.Convenience, "Name.Ar")] + [InlineData(DwTier.Convenience, "Name.En")] + public void The_pair_and_its_halves_are_refused_as_filters_in_both_tiers(DwTier tier, string field) + { + // The halves take the pair's [DwNoWhere]: DCMP-314's 400, with no change to DCMP's row. + PolicyException refusal = Assert.Throws(() => Guard(Dcmp(), tier).ToList(Where(field, "x"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void The_halves_are_refused_as_sorts_under_the_strict_tier() + { + PolicyException refusal = Assert.Throws(() => Guard(Dcmp(), DwTier.Strict).ToList(OrderBy("Name.Ar"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, refusal.ErrorCode); + } + + [Fact] + public void The_halves_are_still_read_as_columns() + { + FilterResult result = Guard(Dcmp(), DwTier.Strict) + .ToList(new Filter { Selects = new List { "Name.Ar", "Name.En" }, Orders = new List { new() { Field = "Id" } } }); + + Assert.Equal(new[] { "Finance", "Health" }, result.Data.Select(row => row.Name.En)); + Assert.Equal(new[] { "مالية", "صحة" }, result.Data.Select(row => row.Name.Ar)); + } + + // ---- the form EF Core follows -------------------------------------------------------------------- + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Written_with_an_initializer_the_same_paths_filter_and_sort_on_the_columns(DwTier tier) + { + FilterResult ar = Guard(Initialized(), tier).ToList(Where("Name.Ar", "صحة")); + FilterResult en = Guard(Initialized(), tier).ToList(Where("Name.En", "fin", Operator.IContains)); + FilterResult sorted = Guard(Initialized(), tier).ToList(OrderBy("Name.En")); + + Assert.Equal("Health", Assert.Single(ar.Data).Name.En); + Assert.Equal("Finance", Assert.Single(en.Data).Name.En); + Assert.Equal(new[] { "Finance", "Health" }, sorted.Data.Select(row => row.Name.En)); + } + + [Fact] + public void Written_with_an_initializer_the_filter_reads_the_column_itself() + { + FilterResult result = Guard(Initialized(), DwTier.Convenience) + .ToList(Where("Name.En", "fin", Operator.IContains), getQueryString: true); + + Assert.Contains("\"NameEn\"", result.QueryString, StringComparison.Ordinal); + } + } +} diff --git a/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs b/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs index 7ffdc6d..3be3833 100644 --- a/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs +++ b/DynamicWhere.Tests/Policies/Dv5DocsProbes.cs @@ -331,17 +331,19 @@ public void Dv5_D_The_refusal_belongs_to_EF_Cores_own_provider_from_EF_Cores_own // ========================================================================================= // 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." + // Claim, since 3.4.0: "An anonymous type has no member refused here, and neither does a + // member assigned from a subquery. A row or a member an application type's constructor builds + // with arguments has every member the constructor leaves unbound refused: EF Core follows a + // member only through an initializer's binding." (3.3.0 left the constructor alone, and every + // clause on such a member went on to fail inside the provider.) // ========================================================================================= [Fact] - public void Dv5_E_A_row_no_object_initializer_builds_has_no_member_refused() + public void Dv5_E_An_anonymous_row_has_no_member_refused_and_a_constructed_one_has_what_it_leaves_unbound() { List wrong = new(); - // 1. An anonymous type. Nothing says which member each value sets. + // 1. An anonymous type. EF Core follows each member to its argument, so nothing is refused. var anonymous = _orders.Orders.Select(o => new { o.Id, o.Total }); Report("anonymous row, Total.IsZero", RowShape.Of(anonymous).Expresses("Total.IsZero")); @@ -351,18 +353,19 @@ public void Dv5_E_A_row_no_object_initializer_builds_has_no_member_refused() wrong.Add("an anonymous row had a member refused"); } - // 2. A constructor with arguments, and no initializer at all. + // 2. A constructor with arguments, and no initializer at all: nothing is bound, so every + // member is out of EF Core's reach, and the strict tier refuses where the provider threw. IQueryable built = _orders.Orders.Select(o => new Dv5Row(o.Id)); - foreach (string path in new[] { "Nest.Blank", "Money.IsZero", "Code" }) + foreach (string path in new[] { "Nest.Blank", "Money.IsZero", "Code", "Id" }) { bool? answer = RowShape.Of(built).Expresses(path); Report($"new Dv5Row(o.Id), {path}", answer); - if (answer == false) + if (answer != false) { - wrong.Add($"a constructor-built row had {path} refused"); + wrong.Add($"a constructor-built row answered {answer} for {path} rather than false"); } } @@ -373,27 +376,40 @@ public void Dv5_E_A_row_no_object_initializer_builds_has_no_member_refused() _out.WriteLine($"new Dv5Row(o.Id) Nest.Blank unguarded -> {builtRaw}"); _out.WriteLine($"new Dv5Row(o.Id) Nest.Blank guarded -> {builtGuarded}"); - if (builtRaw != builtGuarded) + if (!builtRaw.StartsWith("InvalidOperationException", StringComparison.Ordinal)) { - wrong.Add($"guarded and unguarded differ: {builtRaw} vs {builtGuarded}"); + wrong.Add($"the unguarded query no longer fails in the provider: {builtRaw}"); } - // 3. A constructor with arguments that DOES carry an object initializer. Recorded, and - // reported, because the text reads two ways. + if (!builtGuarded.StartsWith("PolicyException FieldDeniedForWhere path=*", StringComparison.Ordinal)) + { + wrong.Add($"the guarded query was not refused: {builtGuarded}"); + } + + // 3. A constructor with arguments that carries an object initializer: what the initializer + // binds is read as any initializer is, and what it leaves to the constructor is refused. 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? mixedBound = RowShape.Of(mixed).Expresses("Nest.A"); 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 = … } }, Nest.A", mixedBound); 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"); + if (mixedNested != false || mixedTop != false) + { + wrong.Add($"a constructor with an initializer answered {mixedNested} and {mixedTop} rather than false"); + } + + if (mixedBound != true) + { + wrong.Add($"a member the initializer binds answered {mixedBound} rather than true"); + } // 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. diff --git a/DynamicWhere.Tests/Policies/NullableStructPolicyTests.cs b/DynamicWhere.Tests/Policies/NullableStructPolicyTests.cs new file mode 100644 index 0000000..fc4b64a --- /dev/null +++ b/DynamicWhere.Tests/Policies/NullableStructPolicyTests.cs @@ -0,0 +1,205 @@ +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; + +namespace DynamicWhere.Tests.Policies +{ + public readonly record struct ZwIban(string Country, string Number); + + public struct ZwPair + { + public string Shown { get; set; } + + [DwDenied] + public string Hidden { get; set; } + + [DwMask(MaskStrategy.Full)] + public string Code { get; set; } + + [DwMask(MaskStrategy.Full, AllowAggregate = true, MinGroupSize = 3)] + public string Counted { get; set; } + } + + // Structs all the way down, so a typed selection builds the chain in memory as it would from EF Core. + public struct ZwDeep1 { public ZwDeep2 Next { get; set; } } + + public struct ZwDeep2 { public ZwDeep3 Next { get; set; } } + + public struct ZwDeep3 { public ZwDeep4 Next { get; set; } } + + public struct ZwDeep4 { public ZwPair? Pair { get; set; } } + + public class ZwRow + { + public int Id { get; set; } + + [DwDenied] + public ZwIban? Iban { get; set; } + + public ZwPair? Pair { get; set; } + + public ZwDeep1 First { get; set; } + } + + /// + /// A member of a nullable struct, reached as the query spells it: Iban.Value.Number. The + /// attribute walk looks through the nullable and names the member Iban.Number, and so do the + /// type's transforms and fragments, so a lookup by the query's spelling matched none of them: the + /// nullable member's denial, a denial on the struct's own member and a mask on one all missed it, in + /// every clause and both terminals, since the policy layer shipped. + /// + public sealed class NullableStructPolicyTests + { + private static List Source(int count = 1) => Enumerable.Range(1, count).Select(i => new ZwRow + { + Id = i, + Iban = new ZwIban("IQ", "123"), + Pair = new ZwPair { Shown = "s", Hidden = "SECRET", Code = "CODE", Counted = "C" }, + First = new ZwDeep1 { Next = new ZwDeep2 { Next = new ZwDeep3 { Next = new ZwDeep4 { Pair = new ZwPair { Code = "DEEP" } } } } } + }).ToList(); + + private static PolicyQueryable Guard(DwTier tier, int count = 1, int minGroupSize = 1, int depth = 4) + { + DwPolicyOptions options = new() { Tier = tier }; + + options.Caps.MinGroupSize = minGroupSize; + options.Caps.MaxNavigationDepth = depth; + + return Source(count).AsQueryable().ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + options, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + } + + private static Filter Where(string field, string value, DataType type = DataType.Text) + { + Condition condition = new() { Field = field, DataType = type, Operator = Operator.Equal }; + condition.Values.Add(value); + + return new Filter { ConditionGroup = new ConditionGroup { Conditions = { condition } } }; + } + + private static Filter Selecting(params string[] fields) => new() { Selects = fields.ToList() }; + + [Fact] + public void The_policy_names_a_nullable_struct_member_without_the_nullable() + { + Assert.Equal("Iban.Number", AttributePolicyProvider.PolicyPath(typeof(ZwRow), "Iban.Value.Number")); + Assert.Equal("Iban", AttributePolicyProvider.PolicyPath(typeof(ZwRow), "Iban.Value")); + Assert.Equal("Iban.HasValue", AttributePolicyProvider.PolicyPath(typeof(ZwRow), "Iban.HasValue")); + Assert.Equal("Id", AttributePolicyProvider.PolicyPath(typeof(ZwRow), "Id")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_member_of_a_denied_nullable_struct_is_refused_as_a_filter(DwTier tier) + { + PolicyException refusal = Assert.Throws(() => Guard(tier).ToList(Where("Iban.Value.Number", "123"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void Whether_a_denied_nullable_struct_has_a_value_is_refused_too() + { + PolicyException refusal = Assert.Throws( + () => Guard(DwTier.Strict).ToList(Where("Iban.HasValue", "true", DataType.Boolean))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_member_of_a_denied_nullable_struct_is_refused_as_a_sort_and_a_selection() + { + Assert.Equal(PolicyErrorCode.FieldDeniedForOrder, Assert.Throws(() => Guard(DwTier.Strict) + .ToList(new Filter { Orders = new List { new() { Field = "Iban.Value.Number" } } })).ErrorCode); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, Assert.Throws( + () => Guard(DwTier.Strict).ToList(Selecting("Iban.Value.Number"))).ErrorCode); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, Assert.Throws( + () => Guard(DwTier.Strict).ToListDynamic(Selecting("Iban.Value.Number"))).ErrorCode); + } + + [Fact] + public void A_denied_member_inside_a_nullable_struct_is_refused() + { + Assert.Throws(() => Guard(DwTier.Strict).ToList(Where("Pair.Value.Hidden", "SECRET"))); + Assert.Throws(() => Guard(DwTier.Strict).ToList(Selecting("Pair.Value.Hidden"))); + } + + [Fact] + public void An_allowed_member_inside_a_nullable_struct_stays_usable() + { + Assert.Single(Guard(DwTier.Strict).ToList(Where("Pair.Value.Shown", "s")).Data); + + ZwRow row = Guard(DwTier.Strict).ToList(Selecting("Pair.Value.Shown")).Data[0]; + + Assert.Equal("s", row.Pair!.Value.Shown); + Assert.Null(row.Pair.Value.Hidden); + } + + [Fact] + public void A_masked_member_inside_a_nullable_struct_is_masked_when_selected_through_value() + { + ZwRow typed = Guard(DwTier.Strict).ToList(Selecting("Pair.Value.Code")).Data[0]; + + Assert.StartsWith("*", typed.Pair!.Value.Code); + } + + [Fact] + public void The_dynamic_terminal_masks_it_where_its_generated_row_holds_it_under_value() + { + FilterResult result = Guard(DwTier.Strict).ToListDynamic(Selecting("Pair.Value.Code")); + + string json = System.Text.Json.JsonSerializer.Serialize(result.Data); + + Assert.DoesNotContain("CODE", json, StringComparison.Ordinal); + Assert.Contains("\"Code\":\"*", json, StringComparison.Ordinal); + } + + [Fact] + public void A_masked_member_inside_a_nullable_struct_is_masked_as_a_group_key() + { + SummaryResult result = Guard(DwTier.Convenience).ToList(new Summary { GroupBy = new GroupBy { Fields = { "Pair.Value.Code" } } }); + + string json = System.Text.Json.JsonSerializer.Serialize(result.Data); + + Assert.DoesNotContain("CODE", json, StringComparison.Ordinal); + } + + [Fact] + public void The_group_floor_a_nullable_struct_member_declares_is_applied() + { + // Counted allows aggregation and asks for groups of at least three; two rows are one group of two. + Summary summary = new() + { + GroupBy = new GroupBy + { + Fields = { "Pair.Value.Shown" }, + AggregateBy = { new AggregateBy { Field = "Pair.Value.Counted", Aggregator = Aggregator.Minimum, Alias = "N" } } + } + }; + + Assert.Empty(Guard(DwTier.Convenience, count: 2).ToList(summary).Data); + Assert.Single(Guard(DwTier.Convenience, count: 3).ToList(summary).Data); + } + + [Fact] + public void A_masked_member_past_the_walk_depth_inside_a_nullable_struct_is_masked() + { + ZwRow row = Guard(DwTier.Strict, depth: 8).ToList(Selecting("First.Next.Next.Next.Pair.Value.Code")).Data[0]; + + Assert.StartsWith("*", row.First.Next.Next.Next.Pair!.Value.Code); + } + } +} diff --git a/DynamicWhere.Tests/Policies/PolicyConfigurationTests.cs b/DynamicWhere.Tests/Policies/PolicyConfigurationTests.cs index 8c26609..447acec 100644 --- a/DynamicWhere.Tests/Policies/PolicyConfigurationTests.cs +++ b/DynamicWhere.Tests/Policies/PolicyConfigurationTests.cs @@ -102,20 +102,37 @@ public void A_key_nothing_answers_to_refuses_to_start() } /// Every setter's own validation still applies to a value that arrived from a file. + /// + /// As the documented InvalidOperationException, with the property's refusal inside: the binder calls + /// each setter by reflection, and a refusal used to arrive wrapped as a TargetInvocationException. + /// [Fact] public void A_value_the_property_refuses_is_refused_here_too() { // A cap below one. - Assert.ThrowsAny( + InvalidOperationException cap = Assert.Throws( () => new DwPolicyOptions().Bind(Section(("Caps:MaxPageSize", "0")))); + Assert.IsType(cap.InnerException); + // A salt short enough to be brute-forced offline. - Assert.ThrowsAny( + InvalidOperationException salt = Assert.Throws( () => new DwPolicyOptions().Bind(Section(("HashSalt", "pepper")))); + Assert.IsAssignableFrom(salt.InnerException); + // A snapshot age that is not a positive interval. - Assert.ThrowsAny( + InvalidOperationException age = Assert.Throws( () => new DwPolicyOptions().Bind(Section(("MaxSnapshotAge", "00:00:00")))); + + Assert.IsAssignableFrom(age.InnerException); + + // A purpose's page cap below one: a newer binder wraps a value inside a dictionary once more. + InvalidOperationException purpose = Assert.Throws( + () => new DwPolicyOptions().Bind(Section(("Caps:Purposes:excel:MaxPageSize", "0")))); + + Assert.IsType(purpose.InnerException); + Assert.StartsWith("A configured policy value was refused", purpose.Message, StringComparison.Ordinal); } [Fact] diff --git a/DynamicWhere.Tests/Policies/Pr6PrecisionProbes.cs b/DynamicWhere.Tests/Policies/Pr6PrecisionProbes.cs index a8985d0..eb22392 100644 --- a/DynamicWhere.Tests/Policies/Pr6PrecisionProbes.cs +++ b/DynamicWhere.Tests/Policies/Pr6PrecisionProbes.cs @@ -155,7 +155,7 @@ private static string Keys( try { ResultTransformer.Summary( - result, summary, KeyPolicy(), Caller(contextDryRun), options, + result, summary, typeof(Pr6KeyRow), KeyPolicy(), Caller(contextDryRun), options, new PolicyTrace(tier, optionsDryRun || contextDryRun)); return "OK"; diff --git a/DynamicWhere.Tests/Policies/PurposePageCapsTests.cs b/DynamicWhere.Tests/Policies/PurposePageCapsTests.cs new file mode 100644 index 0000000..1c3718e --- /dev/null +++ b/DynamicWhere.Tests/Policies/PurposePageCapsTests.cs @@ -0,0 +1,327 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +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.Policies.Storage; +using Microsoft.Extensions.Configuration; + +namespace DynamicWhere.Tests.Policies; + +/// +/// Page caps a declared purpose runs under. A search and an export read the same rows under the same +/// field policy, and the export was held to the search's page: it stopped at the first page, or walked +/// the rest one page at a time, a statement and a count per page and no snapshot of the whole. +/// +public class PurposePageCapsTests +{ + private static DwPolicyContext Caller(string? purpose = null) => + new DwPolicyContext { Purpose = purpose }.WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Resolver() => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + /// A deployment's screens at 1000 and 100, and three purposes of its own. + private static void Deployment(DwCaps caps) + { + caps.MaxPageSize = 1000; + caps.DefaultPageSize = 100; + caps.Purposes["excel"] = new DwPageCaps { MaxPageSize = 10_000, DefaultPageSize = 10_000 }; + caps.Purposes["audit-logs"] = new DwPageCaps { MaxPageSize = 5000 }; + caps.Purposes["autocomplete"] = new DwPageCaps { MaxPageSize = 20 }; + } + + private static Filter Sanitize(Filter filter, string? purpose, Action? configure = null, DwTier tier = DwTier.Strict) + { + DwPolicyOptions options = new() { Tier = tier }; + + (configure ?? Deployment)(options.Caps); + + return FilterSanitizer.Sanitize( + filter, Resolver(), Caller(purpose), options, new PolicyTrace(tier, dryRun: false)); + } + + private static Filter Paged(int size) => new() { Page = new PageBy { PageNumber = 1, PageSize = size } }; + + // ---- which caps a read runs under ------------------------------------------------------------ + + [Fact] + public void An_unpaged_read_under_a_purpose_is_given_the_purpose_default_page() + { + Filter result = Sanitize(new Filter(), "excel"); + + Assert.Equal(1, result.Page!.PageNumber); + Assert.Equal(10_000, result.Page.PageSize); + } + + [Fact] + public void A_read_declaring_no_purpose_keeps_the_deployment_caps() + { + Assert.Equal(100, Sanitize(new Filter(), null).Page!.PageSize); + + PolicyException refusal = Assert.Throws(() => Sanitize(Paged(1001), null)); + + Assert.Equal(PolicyErrorCode.CapExceeded, refusal.ErrorCode); + Assert.Contains("MaxPageSize cap (1000)", refusal.SourceOrigin!, StringComparison.Ordinal); + } + + [Fact] + public void A_purpose_raises_the_largest_page_a_read_may_ask_for_and_no_further() + { + Assert.Equal(10_000, Sanitize(Paged(10_000), "excel").Page!.PageSize); + + PolicyException refusal = Assert.Throws(() => Sanitize(Paged(10_001), "excel")); + + Assert.Equal(PolicyErrorCode.CapExceeded, refusal.ErrorCode); + Assert.Contains("MaxPageSize cap (10000)", refusal.SourceOrigin!, StringComparison.Ordinal); + } + + [Fact] + public void A_purpose_nobody_declared_runs_under_the_deployment_caps() + { + Assert.Equal(100, Sanitize(new Filter(), "pdf").Page!.PageSize); + Assert.Throws(() => Sanitize(Paged(1001), "pdf")); + } + + [Fact] + public void A_value_a_purpose_leaves_out_is_the_deployment_one() + { + // audit-logs names a maximum only, so its default page is the deployment's. + Assert.Equal(100, Sanitize(new Filter(), "audit-logs").Page!.PageSize); + Assert.Equal(5000, Sanitize(Paged(5000), "audit-logs").Page!.PageSize); + Assert.Throws(() => Sanitize(Paged(5001), "audit-logs")); + } + + [Fact] + public void A_purpose_can_lower_the_caps_and_its_default_is_bounded_by_its_own_maximum() + { + // The deployment's default of 100 is above autocomplete's maximum, so it is applied as 20. + Assert.Equal(20, Sanitize(new Filter(), "autocomplete").Page!.PageSize); + Assert.Throws(() => Sanitize(Paged(21), "autocomplete")); + } + + [Fact] + public void A_purpose_is_matched_without_regard_to_letter_case_or_outer_spaces() + { + Assert.Equal(10_000, Sanitize(new Filter(), " EXCEL ").Page!.PageSize); + } + + [Fact] + public void Several_names_may_share_one_set_of_caps() + { + DwPageCaps export = new() { MaxPageSize = 10_000, DefaultPageSize = 10_000 }; + + void Shared(DwCaps caps) + { + caps.DefaultPageSize = 100; + + foreach (string name in new[] { "excel", "csv", "pdf" }) + { + caps.Purposes[name] = export; + } + } + + foreach (string name in new[] { "excel", "csv", "pdf" }) + { + Assert.Equal(10_000, Sanitize(new Filter(), name, Shared).Page!.PageSize); + } + } + + [Fact] + public void A_purpose_page_is_a_page_the_caller_can_still_choose_within() + { + Filter result = Sanitize(new Filter { Page = new PageBy { PageNumber = 3, PageSize = 250 } }, "excel"); + + Assert.Equal(3, result.Page!.PageNumber); + Assert.Equal(250, result.Page.PageSize); + } + + [Fact] + public void The_convenience_tier_refuses_past_a_purpose_maximum_as_well() + { + Assert.Throws(() => Sanitize(Paged(10_001), "excel", tier: DwTier.Convenience)); + } + + [Fact] + public void A_summary_and_a_segment_follow_the_purpose_too() + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + Deployment(options.Caps); + + Summary summary = FilterSanitizer.Sanitize( + new Summary { GroupBy = new GroupBy { Fields = { "Name" } } }, + Resolver(), Caller("excel"), options, new PolicyTrace(DwTier.Strict, dryRun: false)); + + Assert.Equal(10_000, summary.Page!.PageSize); + + Assert.Throws(() => FilterSanitizer.Sanitize( + new Segment { Page = new PageBy { PageNumber = 1, PageSize = 10_001 } }, + Resolver(), Caller("excel"), options, new PolicyTrace(DwTier.Strict, dryRun: false))); + + Segment segment = FilterSanitizer.Sanitize( + new Segment { Page = new PageBy { PageNumber = 1, PageSize = 10_000 } }, + Resolver(), Caller("excel"), options, new PolicyTrace(DwTier.Strict, dryRun: false)); + + Assert.Equal(10_000, segment.Page!.PageSize); + } + + // ---- end to end ------------------------------------------------------------------------------ + + private static List Staff(int count) => + Enumerable.Range(1, count).Select(i => new SecuredEmployee { Id = i, Name = $"e{i}" }).ToList(); + + private static PolicyQueryable Guard(IEnumerable rows, string? purpose) + { + DwPolicyOptions options = new() { Tier = DwTier.Strict }; + + Deployment(options.Caps); + + return rows.AsQueryable().ApplyPolicy(Caller(purpose), options, Resolver()); + } + + [Fact] + public void An_export_reads_every_match_in_one_read_where_a_search_reads_one_page() + { + List staff = Staff(150); + + FilterResult search = Guard(staff, null).ToList(new Filter()); + FilterResult export = Guard(staff, "excel").ToList(new Filter()); + + Assert.Equal(100, search.Data.Count); + Assert.Equal(150, search.TotalCount); + Assert.Equal(150, export.Data.Count); + Assert.Equal(150, export.TotalCount); + } + + [Fact] + public void The_composable_page_follows_the_purpose() + { + List staff = Staff(3); + + Assert.Throws(() => Guard(staff, null).Page(new PageBy { PageNumber = 1, PageSize = 5000 })); + + Guard(staff, "excel").Page(new PageBy { PageNumber = 1, PageSize = 5000 }); + } + + // ---- what a purpose may hold ----------------------------------------------------------------- + + [Fact] + public void A_purpose_page_cap_below_one_is_refused_and_null_takes_the_deployment_one() + { + DwPageCaps caps = new(); + + Assert.Throws(() => caps.MaxPageSize = 0); + Assert.Throws(() => caps.DefaultPageSize = 0); + Assert.Throws(() => caps.DefaultPageSize = -1); + + caps.MaxPageSize = 5; + caps.MaxPageSize = null; + + Assert.Null(caps.MaxPageSize); + } + + [Fact] + public void A_purpose_needs_a_name() + { + DwCaps caps = new(); + + Assert.Throws(() => caps.Purposes[" "] = new DwPageCaps()); + Assert.Throws(() => caps.Purposes.Add("", new DwPageCaps())); + Assert.Throws(() => caps.Purposes["excel"] = null!); + Assert.False(caps.Purposes.TryGetValue(" ", out _)); + Assert.False(caps.Purposes.ContainsKey(null!)); + } + + [Fact] + public void A_name_is_stored_trimmed_and_read_back_whatever_its_case() + { + DwCaps caps = new(); + + caps.Purposes[" Excel "] = new DwPageCaps { MaxPageSize = 7 }; + + Assert.Equal("Excel", Assert.Single(caps.Purposes.Keys)); + Assert.Equal(7, caps.Purposes["excel"].MaxPageSize); + } + + [Fact] + public void Purpose_caps_freeze_with_the_posture() + { + DwPolicyOptions options = new(); + DwPageCaps excel = new() { MaxPageSize = 10_000 }; + + options.Caps.Purposes["excel"] = excel; + options.Freeze(); + + Assert.True(options.Caps.Purposes.IsReadOnly); + Assert.Throws(() => options.Caps.Purposes["csv"] = new DwPageCaps()); + Assert.Throws(() => options.Caps.Purposes.Remove("excel")); + Assert.Throws(() => options.Caps.Purposes.Clear()); + Assert.Throws(() => excel.MaxPageSize = 1); + Assert.Throws(() => excel.DefaultPageSize = 1); + } + + // ---- configuration --------------------------------------------------------------------------- + + private static IConfiguration Section(params (string Key, string Value)[] values) => + new ConfigurationBuilder() + .AddInMemoryCollection(values.ToDictionary(value => value.Key, value => (string?)value.Value)) + .Build(); + + [Fact] + public void Purposes_bind_from_configuration_by_name() + { + DwPolicyOptions options = new DwPolicyOptions().Bind(Section( + ("Caps:MaxPageSize", "1000"), + ("Caps:DefaultPageSize", "100"), + ("Caps:Purposes:excel:MaxPageSize", "10000"), + ("Caps:Purposes:excel:DefaultPageSize", "10000"), + ("Caps:Purposes:audit-logs:MaxPageSize", "5000"))); + + Assert.Equal(2, options.Caps.Purposes.Count); + Assert.Equal(10_000, options.Caps.Purposes["excel"].MaxPageSize); + Assert.Equal(10_000, options.Caps.Purposes["EXCEL"].DefaultPageSize); + Assert.Equal(5000, options.Caps.Purposes["audit-logs"].MaxPageSize); + Assert.Null(options.Caps.Purposes["audit-logs"].DefaultPageSize); + } + + [Theory] + [InlineData("Caps:Purposes:excel:MaxPagSize")] + [InlineData("Caps:Purposes:excel:MaxConditions")] + public void A_key_a_purpose_does_not_have_refuses_to_start(string key) + { + // A misspelt cap, and a cap other than the two page caps, which no purpose replaces. + Assert.Throws(() => new DwPolicyOptions().Bind(Section((key, "5")))); + } + + [Fact] + public void A_purpose_cap_configured_below_one_refuses_to_start() + { + Assert.Throws(() => new DwPolicyOptions().Bind(Section(("Caps:Purposes:excel:MaxPageSize", "0")))); + } + + // ---- the purpose itself ---------------------------------------------------------------------- + + [Fact] + public void A_context_stores_its_purpose_trimmed_and_a_blank_one_as_none() + { + Assert.Equal("excel", new DwPolicyContext { Purpose = " excel " }.Purpose); + Assert.Null(new DwPolicyContext { Purpose = " " }.Purpose); + Assert.Null(new DwPolicyContext { Purpose = null }.Purpose); + } + + [Fact] + public void A_purpose_bound_rule_and_a_purpose_cap_read_the_same_purpose() + { + PolicyRule rule = new( + DwSubjectKind.Global, null, typeof(SecuredEmployee).FullName!, "Name", PolicyFeature.Select, + PolicyEffect.Deny, purpose: "excel"); + + Assert.True(rule.MatchesPurpose(new DwPolicyContext { Purpose = " Excel " })); + } +} diff --git a/DynamicWhere.Tests/Policies/Rd8UnwalkedPathTests.cs b/DynamicWhere.Tests/Policies/Rd8UnwalkedPathTests.cs index 78cb2ca..456130e 100644 --- a/DynamicWhere.Tests/Policies/Rd8UnwalkedPathTests.cs +++ b/DynamicWhere.Tests/Policies/Rd8UnwalkedPathTests.cs @@ -344,18 +344,25 @@ public void A_path_beneath_a_transformed_member_carries_no_transform_of_its_own( [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")); + Assert.Equal("Salary", Reads("Salary.Value")); + Assert.Equal("Born", Reads("Born.Date.Year")); + Assert.Equal("Lines", Reads("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")); + Assert.Null(Reads("Lines.Id")); + Assert.Null(Reads("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")); + Assert.Null(Reads("Nothing.Value")); + Assert.Null(Reads("Lines.Nothing")); + + // Each governs what is beneath it, and none of the walk's own paths has anything above it. + Assert.Equal(new[] { "Salary" }, AttributePolicyProvider.Governing(typeof(Rd8Row), "Salary.Value").Above); + Assert.Empty(AttributePolicyProvider.Governing(typeof(Rd8Row), "Lines.Id").Above); + Assert.Empty(AttributePolicyProvider.Governing(typeof(Rd8Row), "Nothing.Value").Above); } + + private static string? Reads(string path) => AttributePolicyProvider.Governing(typeof(Rd8Row), path).Reads; } public class Rd8A { public int Id { get; set; } public Rd8B? B { get; set; } } diff --git a/DynamicWhere.Tests/Policies/S4SecurityProbesB.cs b/DynamicWhere.Tests/Policies/S4SecurityProbesB.cs index 8b7c8a0..41b7ed1 100644 --- a/DynamicWhere.Tests/Policies/S4SecurityProbesB.cs +++ b/DynamicWhere.Tests/Policies/S4SecurityProbesB.cs @@ -331,6 +331,7 @@ void Differs(string knob, Action change) 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.Purposes", o => o.Caps.Purposes["s4-export"] = new DwPageCaps { MaxPageSize = 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); @@ -565,6 +566,15 @@ private static DwPolicyOptions Posture() copy.Caps.MinGroupSize = inForce.Caps.MinGroupSize; } + foreach (KeyValuePair purpose in inForce.Caps.Purposes) + { + copy.Caps.Purposes[purpose.Key] = new DwPageCaps + { + MaxPageSize = purpose.Value.MaxPageSize, + DefaultPageSize = purpose.Value.DefaultPageSize + }; + } + foreach (KeyValuePair exposed in inForce.Entities.Entities) { copy.Entities.Expose(exposed.Key, exposed.Value); diff --git a/DynamicWhere.Tests/Policies/StructCollectionSelectTests.cs b/DynamicWhere.Tests/Policies/StructCollectionSelectTests.cs new file mode 100644 index 0000000..01fdf30 --- /dev/null +++ b/DynamicWhere.Tests/Policies/StructCollectionSelectTests.cs @@ -0,0 +1,219 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Result; +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 Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace DynamicWhere.Tests.Policies +{ + public struct ZlPair + { + public string Shown { get; set; } + + [DwDenied] + public string Hidden { get; set; } + } + + public class ZlRow + { + public int Id { get; set; } + + public List Pairs { get; set; } = new(); + + public ZlPair One { get; set; } + + public ZlPair[] Array { get; set; } = System.Array.Empty(); + + public List Maybe { get; set; } = new(); + + public List Numbers { get; set; } = new(); + } + + public class ZlOrder + { + public int Id { get; set; } + + public List Lines { get; set; } = new(); + } + + public class ZlLine + { + public int Id { get; set; } + + public int ZlOrderId { get; set; } + + public string Code { get; set; } = string.Empty; + + public string Secret { get; set; } = string.Empty; + } + + public sealed class ZlContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZlContext(SqliteConnection connection) => _connection = connection; + + public DbSet Orders => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + /// + /// A typed selection naming a path beneath a collection of structs. The typed projection bound + /// every collection of values whole, so Pairs.Shown handed back each element whole, a + /// [DwDenied] member included, in both tiers, while the policy had approved only + /// Pairs.Shown. Present in every release since the policy layer shipped. + /// + public sealed class StructCollectionSelectTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly ZlContext _db; + + public StructCollectionSelectTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZlContext(_connection); + _db.Database.EnsureCreated(); + _db.Orders.Add(new ZlOrder + { + Lines = { new ZlLine { Code = "A", Secret = "S1" }, new ZlLine { Code = "B", Secret = "S2" } } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static List Source() => new() + { + new ZlRow + { + Id = 1, + Pairs = { new ZlPair { Shown = "a", Hidden = "SECRET-A" }, new ZlPair { Shown = "b", Hidden = "SECRET-B" } }, + Array = new[] { new ZlPair { Shown = "x", Hidden = "SECRET-X" } }, + Maybe = { new ZlPair { Shown = "m", Hidden = "SECRET-M" }, null }, + Numbers = { 1, 2, 3 } + } + }; + + 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 Selecting(params string[] fields) => new() { Selects = fields.ToList() }; + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_sibling_of_a_denied_member_comes_back_without_it(DwTier tier) + { + FilterResult result = Guard(Source().AsQueryable(), tier).ToList(Selecting("Pairs.Shown")); + + Assert.Equal(new[] { "a", "b" }, result.Data[0].Pairs.Select(pair => pair.Shown)); + Assert.All(result.Data[0].Pairs, pair => Assert.Null(pair.Hidden)); + } + + [Fact] + public void The_denied_member_itself_and_the_collection_whole_are_refused() + { + Assert.Throws(() => Guard(Source().AsQueryable(), DwTier.Strict).ToList(Selecting("Pairs.Hidden"))); + Assert.Throws(() => Guard(Source().AsQueryable(), DwTier.Strict).ToList(Selecting("Pairs"))); + } + + [Fact] + public void An_array_of_structs_is_built_element_by_element_too() + { + List rows = Source().AsQueryable().Select(new List { "Array.Shown" }).ToList(); + + ZlPair only = Assert.Single(rows[0].Array); + + Assert.Equal("x", only.Shown); + Assert.Null(only.Hidden); + } + + [Fact] + public void A_collection_of_nullable_structs_carries_nothing_rather_than_everything() + { + List rows = Source().AsQueryable().Select(new List { "Maybe.Value.Shown", "Id" }).ToList(); + + Assert.Empty(rows[0].Maybe); + Assert.Equal(1, rows[0].Id); + } + + [Fact] + public void A_null_collection_in_memory_stays_null() + { + List source = new() { new ZlRow { Id = 1, Pairs = null! } }; + + List rows = source.AsQueryable().Select(new List { "Pairs.Shown", "Id" }).ToList(); + + Assert.Null(rows[0].Pairs); + } + + [Fact] + public void A_collection_of_scalars_is_still_bound_whole() + { + FilterResult result = Guard(Source().AsQueryable(), DwTier.Strict).ToList(Selecting("Numbers")); + + Assert.Equal(new[] { 1, 2, 3 }, result.Data[0].Numbers); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void With_no_selection_the_policy_narrows_a_struct_rather_than_leaving_it_out(DwTier tier) + { + // The gate read a struct as a type the core could not build, and left it out whole; the core + // builds one member by member now, so the allowed part comes back and the denied one does not. + IQueryable rows = _db.Orders.Select(order => new ZlRow + { + Id = order.Id, + One = new ZlPair { Shown = "one", Hidden = "SECRET-ONE" }, + Pairs = order.Lines.OrderBy(line => line.Id) + .Select(line => new ZlPair { Shown = line.Code, Hidden = line.Secret }) + .ToList() + }); + + PolicyQueryable guarded = Guard(rows, tier); + ZlRow row = guarded.ToList(new Filter()).Data[0]; + + Assert.Equal("one", row.One.Shown); + Assert.Null(row.One.Hidden); + Assert.Equal(new[] { "A", "B" }, row.Pairs.Select(pair => pair.Shown)); + Assert.All(row.Pairs, pair => Assert.Null(pair.Hidden)); + Assert.DoesNotContain(guarded.LastTrace!.Decisions, decision => + decision.Reason is { } reason && reason.Contains("cannot build", StringComparison.Ordinal)); + } + + [Fact] + public void A_projection_building_the_collection_in_the_database_is_narrowed_the_same_way() + { + IQueryable rows = _db.Orders.Select(order => new ZlRow + { + Id = order.Id, + Pairs = order.Lines.OrderBy(line => line.Id) + .Select(line => new ZlPair { Shown = line.Code, Hidden = line.Secret }) + .ToList() + }); + + FilterResult result = Guard(rows, DwTier.Strict).ToList(Selecting("Pairs.Shown")); + + Assert.Equal(new[] { "A", "B" }, result.Data[0].Pairs.Select(pair => pair.Shown)); + Assert.All(result.Data[0].Pairs, pair => Assert.Null(pair.Hidden)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/StructKeyTests.cs b/DynamicWhere.Tests/Policies/StructKeyTests.cs new file mode 100644 index 0000000..748018f --- /dev/null +++ b/DynamicWhere.Tests/Policies/StructKeyTests.cs @@ -0,0 +1,171 @@ +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Result; +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; + +namespace DynamicWhere.Tests.Policies +{ + public struct ZkRef + { + public int Id { get; set; } + + public string Why { get; set; } + } + + public struct ZkSealed + { + [DwDenied] + public int Id { get; set; } + + public string Shown { get; set; } + } + + public class ZkOwner + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class ZkEntity + { + public int Id { get; set; } + + public string Shown { get; set; } = string.Empty; + } + + public sealed class ZkContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZkContext(SqliteConnection connection) => _connection = connection; + + public DbSet Items => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + public class ZkRow + { + public int Id { get; set; } + + public ZkRef Ref { get; set; } + + public ZkRef? Maybe { get; set; } + + public ZkSealed Sealed { get; set; } + + public ZkOwner? Owner { get; set; } + } + + /// + /// The key of a node a selection passes through. The projection builder adds the key of a class it + /// builds and never of a struct, and the gate added it to both: a selection through a struct holding + /// an Id came back with that key beside what was named, and one whose Id is denied had + /// every other member's selection refused. + /// + public sealed class StructKeyTests + { + private static List Source() => new() + { + new ZkRow + { + Id = 1, + Ref = new ZkRef { Id = 7, Why = "why" }, + Maybe = new ZkRef { Id = 8, Why = "maybe" }, + Sealed = new ZkSealed { Id = 9, Shown = "shown" }, + Owner = new ZkOwner { Id = 5, Name = "owner" } + } + }; + + private static PolicyQueryable Guard(DwTier tier = DwTier.Strict) => + Source().AsQueryable().ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Selecting(params string[] fields) => new() { Selects = fields.ToList() }; + + [Fact] + public void A_selection_through_a_struct_carries_no_key_it_did_not_name() + { + ZkRow row = Guard().ToList(Selecting("Id", "Ref.Why", "Maybe.Value.Why")).Data[0]; + + Assert.Equal("why", row.Ref.Why); + Assert.Equal(0, row.Ref.Id); + Assert.Equal("maybe", row.Maybe!.Value.Why); + Assert.Equal(0, row.Maybe.Value.Id); + + string json = JsonSerializer.Serialize(Guard().ToListDynamic(Selecting("Id", "Ref.Why", "Maybe.Value.Why")).Data); + + Assert.Contains("\"Ref\":{\"Why\":\"why\"}", json, StringComparison.Ordinal); + Assert.Contains("\"Value\":{\"Why\":\"maybe\"}", json, StringComparison.Ordinal); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_denied_key_of_a_struct_does_not_refuse_its_other_members(DwTier tier) + { + ZkRow row = Guard(tier).ToList(Selecting("Id", "Sealed.Shown")).Data[0]; + + Assert.Equal("shown", row.Sealed.Shown); + Assert.Equal(0, row.Sealed.Id); + + string json = JsonSerializer.Serialize(Guard(tier).ToListDynamic(Selecting("Id", "Sealed.Shown")).Data); + + Assert.Contains("\"Sealed\":{\"Shown\":\"shown\"}", json, StringComparison.Ordinal); + } + + [Fact] + public void A_struct_whose_key_is_denied_is_narrowed_when_nothing_is_selected() + { + // Rows projected before ApplyPolicy, as a consumer's are: in memory an object member is left out + // whole whenever something beneath it is denied. + using SqliteConnection connection = new("DataSource=:memory:"); + connection.Open(); + + using ZkContext db = new(connection); + db.Database.EnsureCreated(); + db.Items.Add(new ZkEntity { Id = 9, Shown = "shown" }); + db.SaveChanges(); + + IQueryable rows = db.Items.AsNoTracking().Select(item => new ZkRow + { + Id = item.Id, + Sealed = new ZkSealed { Id = item.Id, Shown = item.Shown } + }); + + FilterResult result = rows.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })).ToList(new Filter()); + + Assert.Equal("shown", result.Data[0].Sealed.Shown); + Assert.Equal(0, result.Data[0].Sealed.Id); + } + + [Fact] + public void The_denied_key_itself_is_still_refused() + { + Assert.Throws(() => Guard().ToList(Selecting("Id", "Sealed.Id"))); + } + + [Fact] + public void A_class_navigation_still_carries_its_key() + { + string json = JsonSerializer.Serialize(Guard().ToListDynamic(Selecting("Id", "Owner.Name")).Data); + + Assert.Contains("\"Id\":5", json, StringComparison.Ordinal); + Assert.Contains("\"Name\":\"owner\"", json, StringComparison.Ordinal); + } + } +} diff --git a/DynamicWhere.Tests/Policies/StructPartPolicyTests.cs b/DynamicWhere.Tests/Policies/StructPartPolicyTests.cs new file mode 100644 index 0000000..6910327 --- /dev/null +++ b/DynamicWhere.Tests/Policies/StructPartPolicyTests.cs @@ -0,0 +1,364 @@ +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; + +namespace DynamicWhere.Tests.Policies +{ + public readonly record struct ZsIban(string Country, string Number); + + public struct ZsPair + { + public string Ar { get; set; } + + [DwNoOrder] + public string En { get; set; } + + [DwMask(MaskStrategy.Full)] + public string Code { get; set; } + } + + public struct ZsCard + { + [DwNoWhere] + public ZsPair Inner { get; set; } + + public string Label { get; set; } + } + + public class ZsOwner + { + public int Id { get; set; } + + [DwDenied] + public ZsIban Iban { get; set; } + + public ZsPair Name { get; set; } + } + + public class ZsLevel1 + { + public ZsLevel2? Next { get; set; } + } + + public class ZsLevel2 + { + public ZsLevel3? Next { get; set; } + } + + public class ZsLevel3 + { + public ZsLevel4? Next { get; set; } + } + + public class ZsLevel4 + { + [DwDenied] + public ZsIban Deep { get; set; } + + public ZsPair Pair { get; set; } + } + + public class ZsRow + { + public int Id { get; set; } + + [DwDenied] + public ZsIban Iban { get; set; } + + [DwNoWhere, DwNoOrder] + public ZsPair Name { get; set; } + + [DwNoSelect] + public ZsCard Card { get; set; } + + [DwDenied] + public List Ibans { get; set; } = new(); + + [DwDeny(PolicyFeature.All, Overridable = true)] + public ZsIban Soft { get; set; } + + [DwMask(MaskStrategy.Full)] + public ZsPair Masked { get; set; } + + /// A class: a navigation, whose members stay separate fields. + [DwDenied] + public ZsOwner? Owner { get; set; } + + public ZsLevel1? First { get; set; } + } + + /// + /// A path beneath a member holding an application's own struct takes that member's policy, as a path + /// beneath a framework type has since 3.3.0. A struct is a value, and a [DwDenied] on it left + /// its parts to be filtered on and, once the typed projection could build them, selected. + /// + public sealed class StructPartPolicyTests + { + private static DwPolicyContext Caller() => new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"); + + private static PolicyResolver Attributes(params IDwPolicyProvider[] extra) => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }.Concat(extra)); + + private static FieldPolicy Resolve(string path, params IDwPolicyProvider[] extra) => + Attributes(extra).Resolve(typeof(ZsRow), path, Caller()); + + private static readonly PolicyFeature[] Every = + { + PolicyFeature.Where, PolicyFeature.Select, PolicyFeature.Order, + PolicyFeature.Group, PolicyFeature.Aggregate, PolicyFeature.Segment + }; + + // ---- the resolver ---------------------------------------------------------------------------- + + [Fact] + public void A_part_of_a_denied_struct_is_denied_for_everything() + { + FieldPolicy part = Resolve("Iban.Number"); + + Assert.All(Every, feature => Assert.False(part.Allows(feature), feature.ToString())); + Assert.True(part.IsSealed); + } + + [Fact] + public void A_part_takes_only_the_features_its_struct_is_denied() + { + FieldPolicy part = Resolve("Name.Ar"); + + Assert.False(part.Allows(PolicyFeature.Where)); + Assert.False(part.Allows(PolicyFeature.Order)); + Assert.True(part.Allows(PolicyFeature.Select)); + Assert.True(part.Allows(PolicyFeature.Group)); + } + + [Fact] + public void A_part_keeps_what_its_own_member_declares() + { + // En carries [DwNoOrder] of its own, inside a struct no one denied. + FieldPolicy own = Attributes().Resolve(typeof(ZsOwner), "Name.En", Caller()); + + Assert.False(own.Allows(PolicyFeature.Order)); + Assert.True(own.Allows(PolicyFeature.Where)); + } + + [Fact] + public void Every_struct_a_path_passes_through_decides_it() + { + // Card is [DwNoSelect]; Inner, inside it, is [DwNoWhere]. + FieldPolicy part = Resolve("Card.Inner.Ar"); + + Assert.False(part.Allows(PolicyFeature.Select)); + Assert.False(part.Allows(PolicyFeature.Where)); + Assert.True(part.Allows(PolicyFeature.Order)); + } + + [Fact] + public void A_part_of_each_struct_in_a_collection_takes_the_collection_member_policy() + { + FieldPolicy part = Resolve("Ibans.Number"); + + Assert.All(Every, feature => Assert.False(part.Allows(feature), feature.ToString())); + } + + [Fact] + public void A_class_is_still_a_navigation_whose_members_are_separate_fields() + { + // Docs trap 5: a denial of Owner denies that path only. Its own struct member is denied by its + // own attribute, and so is every part of that struct. + Assert.True(Resolve("Owner.Id").Allows(PolicyFeature.Where)); + Assert.False(Resolve("Owner.Iban.Number").Allows(PolicyFeature.Where)); + Assert.True(Resolve("Owner.Name.Ar").Allows(PolicyFeature.Where)); + } + + [Fact] + public void A_runtime_rule_on_the_part_replaces_an_overridable_denial_of_the_struct() + { + FakePolicyProvider rule = new FakePolicyProvider() + .Add("Soft.Number", PolicyFeature.Where, PolicyEffect.Allow, PolicyLevel.DynamicRole); + + Assert.False(Resolve("Soft.Number").Allows(PolicyFeature.Where)); + Assert.True(Resolve("Soft.Number", rule).Allows(PolicyFeature.Where)); + } + + [Fact] + public void A_runtime_rule_on_the_part_cannot_replace_a_sealed_denial_of_the_struct() + { + FakePolicyProvider rule = new FakePolicyProvider() + .Add("Iban.Number", PolicyFeature.All, PolicyEffect.Allow, PolicyLevel.DynamicGlobal); + + Assert.False(Resolve("Iban.Number", rule).Allows(PolicyFeature.Where)); + } + + [Fact] + public void A_runtime_denial_of_the_struct_reaches_its_parts() + { + FakePolicyProvider rule = new FakePolicyProvider() + .Add("Name", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicRole); + + Assert.True(Resolve("Name.Ar").Allows(PolicyFeature.Select)); + Assert.False(Resolve("Name.Ar", rule).Allows(PolicyFeature.Select)); + } + + [Fact] + public void A_part_that_masks_itself_is_masked_and_stays_selectable() + { + // Code carries its own mask: the outbound walk applies it to that member, so nothing above it + // has to be refused. + FieldPolicy own = Attributes().Resolve(typeof(ZsOwner), "Name.Code", Caller()); + + Assert.True(own.IsTransformed); + Assert.True(own.Allows(PolicyFeature.Select)); + } + + [Fact] + public void A_part_of_a_masked_struct_hands_back_nothing_the_mask_would_have_covered() + { + // The chain is the struct's, and has no member of the path's to apply to. + FieldPolicy part = Resolve("Masked.Ar"); + + Assert.False(part.Allows(PolicyFeature.Select)); + Assert.False(part.Allows(PolicyFeature.Group)); + Assert.False(part.Allows(PolicyFeature.Aggregate)); + Assert.False(part.IsTransformed); + } + + [Fact] + public void A_struct_past_the_walk_depth_still_decides_its_parts() + { + // First.Next.Next.Next.Deep.Number: Deep is the fifth segment, which no fragment of the walk + // reaches, so its attributes are read directly. + FieldPolicy part = Resolve("First.Next.Next.Next.Deep.Number"); + + Assert.False(part.Allows(PolicyFeature.Where)); + Assert.False(part.Allows(PolicyFeature.Select)); + } + + [Fact] + public void A_part_past_the_walk_depth_keeps_its_own_mask() + { + // First.Next.Next.Next.Pair.Code: the walk names neither Pair nor Code, and Code's own mask is + // read directly, so the outbound walk still has a chain to apply. + FieldPolicy part = Resolve("First.Next.Next.Next.Pair.Code"); + + Assert.True(part.IsTransformed); + Assert.True(part.Allows(PolicyFeature.Select)); + Assert.False(part.Allows(PolicyFeature.Group)); + } + + [Fact] + public void The_explanation_names_the_attribute_that_decided_the_part() + { + PolicyExplanation explained = Attributes().Explain(typeof(ZsRow), "Iban.Number", Caller()); + + FeatureExplanation where = explained.Features.Single(feature => feature.Feature == PolicyFeature.Where); + + Assert.Equal(PolicyEffect.Deny, where.Effect); + Assert.NotNull(where.DecidedBy); + Assert.Contains("DwDenied", where.DecidedBy!.Origin, StringComparison.Ordinal); + } + + [Fact] + public void The_schema_describes_a_part_as_its_struct_decides_it() + { + DwEntityCatalog catalog = new(); + + catalog.Expose(typeof(ZsRow)); + catalog.Freeze(); + + DwPolicyOptions options = new(); + + options.Freeze(); + + PolicySchema schema = PolicySchemaBuilder.Describe( + typeof(ZsRow), catalog, new DwPolicyContext(), options, Attributes(), + new PolicySchemaRequest { Paths = new List { "Name" }, Depth = 1 }); + + PolicySchemaField ar = schema.Fields.Single(field => field.Path == "Name.Ar"); + + Assert.False(ar.CanWhere); + Assert.False(ar.CanOrder); + Assert.True(ar.CanSelect); + } + + // ---- a guarded query ------------------------------------------------------------------------- + + private static PolicyQueryable Guard(DwTier tier) => + new List + { + new() { Id = 1, Iban = new ZsIban("IQ", "123"), Name = new ZsPair { Ar = "أ", En = "a" } } + } + .AsQueryable() + .ApplyPolicy(Caller(), new DwPolicyOptions { Tier = tier }, Attributes()); + + private static Filter Where(string field, string value) + { + Condition condition = new() { Field = field, DataType = DataType.Text, Operator = Operator.Equal }; + condition.Values.Add(value); + + return new Filter { ConditionGroup = new ConditionGroup { Conditions = { condition } } }; + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_filter_on_a_part_of_a_denied_struct_is_refused_in_both_tiers(DwTier tier) + { + // An equality oracle on a sealed field: which rows hold IBAN 123. + PolicyException refusal = Assert.Throws(() => Guard(tier).ToList(Where("Iban.Number", "123"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Fact] + public void A_selection_of_a_part_of_a_denied_struct_is_refused_under_the_strict_tier() + { + PolicyException refusal = Assert.Throws(() => + Guard(DwTier.Strict).ToList(new Filter { Selects = new List { "Iban.Number" } })); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode); + } + + [Fact] + public void A_selection_of_a_part_of_a_denied_struct_carries_nothing_under_the_convenience_tier() + { + FilterResult result = Guard(DwTier.Convenience) + .ToList(new Filter { Selects = new List { "Id", "Iban.Number" } }); + + Assert.Null(result.Data[0].Iban.Number); + Assert.Equal(1, result.Data[0].Id); + } + + [Fact] + public void A_part_of_a_struct_denied_for_filtering_is_refused_where_and_returned_on_select() + { + // DCMP's LocalizedText Name, [DwNoWhere, DwNoOrder]: name.ar is refused as a filter and read + // as a column. + PolicyException refusal = Assert.Throws(() => Guard(DwTier.Strict).ToList(Where("Name.Ar", "أ"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + + FilterResult read = Guard(DwTier.Strict).ToList(new Filter { Selects = new List { "Name.Ar" } }); + + Assert.Equal("أ", read.Data[0].Name.Ar); + } + + [Fact] + public void A_group_on_a_part_of_a_denied_struct_is_refused() + { + Summary summary = new() { GroupBy = new GroupBy { Fields = { "Iban.Number" } } }; + + PolicyException refusal = Assert.Throws(() => Guard(DwTier.Strict).ToList(summary)); + + Assert.Equal(PolicyErrorCode.FieldDeniedForGroup, refusal.ErrorCode); + } + } +} diff --git a/DynamicWhere.Tests/Policies/StructTransformTests.cs b/DynamicWhere.Tests/Policies/StructTransformTests.cs new file mode 100644 index 0000000..4a3b852 --- /dev/null +++ b/DynamicWhere.Tests/Policies/StructTransformTests.cs @@ -0,0 +1,287 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Result; +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 Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace DynamicWhere.Tests.Policies +{ + /// Appends a mark, so a value transformed twice reads differently from one transformed once. + public sealed class ZtMark : IValueTransformer + { + public object? Transform(object? value, DwTransformContext context) => value is string text ? text + "!" : value; + } + + public struct ZtPair + { + public string Shown { get; set; } + + [DwMask(MaskStrategy.Full)] + public string Code { get; set; } + + [DwMutate(typeof(ZtMark))] + public string Marked { get; set; } + } + + public struct ZtOuter + { + public ZtPair Inner { get; set; } + } + + public class ZtOwner + { + public ZtPair Name { get; set; } + } + + public class ZtLevel1 { public ZtLevel2 Next { get; set; } = new(); } + + public class ZtLevel2 { public ZtLevel3 Next { get; set; } = new(); } + + public class ZtLevel3 { public ZtLevel4 Next { get; set; } = new(); } + + public class ZtLevel4 { public ZtPair Deep { get; set; } } + + public class ZtRow + { + public int Id { get; set; } + + public ZtPair Name { get; set; } + + public ZtPair? Maybe { get; set; } + + public List Pairs { get; set; } = new(); + + public ZtPair[] Array { get; set; } = System.Array.Empty(); + + public ZtOuter Outer { get; set; } + + public ZtOwner? Owner { get; set; } + + public ZtLevel1? First { get; set; } + } + + public class ZtReadOnlyRow + { + public ZtReadOnlyRow(ZtPair name) => Name = name; + + public int Id { get; set; } + + public ZtPair Name { get; } + } + + public class ZtSetRow + { + public int Id { get; set; } + + public HashSet Pairs { get; set; } = new(); + } + + public class ZtBagRow + { + public int Id { get; set; } + + public Dictionary Bag { get; set; } = new(); + } + + public class ZtEntity + { + public int Id { get; set; } + + public string Shown { get; set; } = string.Empty; + + public string Code { get; set; } = string.Empty; + } + + public sealed class ZtContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZtContext(SqliteConnection connection) => _connection = connection; + + public DbSet Items => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + /// + /// A transform declared on a struct's member. A struct is read as a copy in a box, and the setter + /// unboxed a second copy to write into, so every mask on a struct's member was applied to a + /// temporary and the stored value was emitted: in both tiers, from memory and from EF Core, since the + /// policy layer shipped. + /// + public sealed class StructTransformTests : IDisposable + { + private const string Masked = "*"; + + private readonly SqliteConnection _connection; + private readonly ZtContext _db; + + public StructTransformTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZtContext(_connection); + _db.Database.EnsureCreated(); + _db.Items.Add(new ZtEntity { Shown = "s", Code = "SECRET-EF" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static ZtPair Pair(string code) => new() { Shown = "s", Code = code, Marked = "m" }; + + private static List Source() => new() + { + new ZtRow + { + Id = 1, + Name = Pair("SECRET-NAME"), + Maybe = Pair("SECRET-MAYBE"), + Pairs = { Pair("SECRET-LIST-1"), Pair("SECRET-LIST-2") }, + Array = new[] { Pair("SECRET-ARRAY") }, + Outer = new ZtOuter { Inner = Pair("SECRET-OUTER") }, + Owner = new ZtOwner { Name = Pair("SECRET-OWNER") }, + First = new ZtLevel1 { Next = new ZtLevel2 { Next = new ZtLevel3 { Next = new ZtLevel4 { Deep = Pair("SECRET-DEEP") } } } } + } + }; + + 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 void AllMasked(ZtRow row) + { + Assert.StartsWith(Masked, row.Name.Code); + Assert.StartsWith(Masked, row.Maybe!.Value.Code); + Assert.All(row.Pairs, pair => Assert.StartsWith(Masked, pair.Code)); + Assert.All(row.Array, pair => Assert.StartsWith(Masked, pair.Code)); + Assert.StartsWith(Masked, row.Outer.Inner.Code); + Assert.StartsWith(Masked, row.Owner!.Name.Code); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_mask_on_a_struct_member_applies_wherever_the_struct_is_held(DwTier tier) + { + FilterResult result = Guard(Source().AsQueryable(), tier).ToList(new Filter()); + + AllMasked(result.Data[0]); + Assert.Equal("s", result.Data[0].Name.Shown); + } + + [Fact] + public void A_mask_on_a_struct_member_applies_when_the_struct_is_selected_whole() + { + FilterResult result = Guard(Source().AsQueryable(), DwTier.Strict) + .ToList(new Filter { Selects = new List { "Name", "Maybe", "Pairs", "Array", "Outer", "Owner" } }); + + AllMasked(result.Data[0]); + } + + [Fact] + public void A_transform_on_a_struct_member_is_applied_once() + { + // The pass along the policy's paths and the pass by run-time type both reach the member; the + // second must not transform what the first did. + ZtRow row = Guard(Source().AsQueryable(), DwTier.Strict).ToList(new Filter()).Data[0]; + + Assert.Equal("m!", row.Name.Marked); + Assert.Equal("m!", row.Maybe!.Value.Marked); + Assert.All(row.Pairs, pair => Assert.Equal("m!", pair.Marked)); + Assert.Equal("m!", row.Outer.Inner.Marked); + Assert.Equal("m!", row.Owner!.Name.Marked); + } + + [Fact] + public void A_struct_past_the_walk_depth_is_transformed_by_its_own_attributes_and_written_back() + { + ZtRow row = Guard(Source().AsQueryable(), DwTier.Strict).ToList(new Filter()).Data[0]; + + ZtPair deep = row.First!.Next.Next.Next.Deep; + + Assert.StartsWith(Masked, deep.Code); + Assert.Equal("m!", deep.Marked); + } + + [Fact] + public void The_source_rows_are_masked_in_place_as_class_members_are() + { + // A guarded query over rows in memory masks the caller's own objects, which is documented. + List source = Source(); + + Guard(source.AsQueryable(), DwTier.Strict).ToList(new Filter()); + + Assert.StartsWith(Masked, source[0].Name.Code); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_struct_member_a_projection_builds_in_the_database_is_masked(DwTier tier) + { + IQueryable rows = _db.Items.Select(item => new ZtRow + { + Id = item.Id, + Name = new ZtPair { Shown = item.Shown, Code = item.Code }, + Maybe = new ZtPair { Shown = item.Shown, Code = item.Code } + }); + + ZtRow none = Guard(rows, tier).ToList(new Filter()).Data[0]; + ZtRow part = Guard(rows, tier).ToList(new Filter { Selects = new List { "Name.Code", "Name.Shown" } }).Data[0]; + + Assert.StartsWith(Masked, none.Name.Code); + Assert.StartsWith(Masked, none.Maybe!.Value.Code); + Assert.StartsWith(Masked, part.Name.Code); + Assert.Equal("s", part.Name.Shown); + } + + [Fact] + public void A_struct_held_by_a_member_with_no_setter_fails_rather_than_escaping() + { + List source = new() { new ZtReadOnlyRow(Pair("SECRET")) { Id = 1 } }; + + InvalidOperationException refusal = Assert.Throws( + () => Guard(source.AsQueryable(), DwTier.Strict).ToList(new Filter())); + + Assert.Contains("no setter", refusal.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_struct_only_the_run_time_walk_reaches_fails_when_it_cannot_be_written_back() + { + // No path of the policy names a dictionary's values; the run-time walk finds the mask, and a + // pair's Value has no setter to take the masked struct back. + List source = new() { new ZtBagRow { Id = 1, Bag = { ["k"] = Pair("SECRET") } } }; + + InvalidOperationException refusal = Assert.Throws( + () => Guard(source.AsQueryable(), DwTier.Strict).ToList(new Filter())); + + Assert.Contains("cannot be written back", refusal.Message, StringComparison.Ordinal); + } + + [Fact] + public void Structs_in_a_collection_that_cannot_be_written_by_position_fail_rather_than_escaping() + { + List source = new() { new ZtSetRow { Id = 1, Pairs = { Pair("SECRET") } } }; + + InvalidOperationException refusal = Assert.Throws( + () => Guard(source.AsQueryable(), DwTier.Strict).ToList(new Filter())); + + Assert.Contains("list or an array", refusal.Message, StringComparison.Ordinal); + } + } +} diff --git a/DynamicWhere.Tests/ValueMemberSelectTests.cs b/DynamicWhere.Tests/ValueMemberSelectTests.cs new file mode 100644 index 0000000..3467cbe --- /dev/null +++ b/DynamicWhere.Tests/ValueMemberSelectTests.cs @@ -0,0 +1,329 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +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 Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace DynamicWhere.Tests +{ + public readonly record struct ZvText(string Ar, string En); + + /// A struct holding a struct, a collection and a class navigation. + public struct ZvCard + { + public ZvText Title { get; set; } + + public string Code { get; set; } + + public List Tags { get; set; } + + public ZvOwner? Owner { get; set; } + + /// Read-only, so no projection can set it. + public int Length => Code?.Length ?? 0; + } + + public class ZvOwner + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class ZvPerson + { + public int Id { get; set; } + + public string NameAr { get; set; } = string.Empty; + + public string NameEn { get; set; } = string.Empty; + + public string? AliasAr { get; set; } + + public DateTime Born { get; set; } + + public string Code { get; set; } = string.Empty; + } + + public class ZvRow + { + public int Id { get; set; } + + public ZvText Name { get; set; } + + public ZvText? Alias { get; set; } + + public ZvCard Card { get; set; } + + public DateTime Born { get; set; } + + public string Code { get; set; } = string.Empty; + } + + /// A struct with a member no caller may see, beside one anybody may. + public struct ZvSecretPair + { + public string Shown { get; set; } + + [DwDenied] + public string Hidden { get; set; } + } + + public class ZvSecretRow + { + public int Id { get; set; } + + public ZvSecretPair Pair { get; set; } + } + + public sealed class ZvContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZvContext(SqliteConnection connection) => _connection = connection; + + public DbSet People => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + /// + /// A selection naming a path beneath a member whose type is a value. The typed projection skipped + /// every such member, so it came back as its default with nothing said: Name.Ar over a + /// struct returned an empty name, guarded or not. + /// + public sealed class ValueMemberSelectTests : IDisposable + { + private static readonly DateTime FirstBorn = new(1990, 5, 17); + + private readonly SqliteConnection _connection; + private readonly ZvContext _db; + + public ValueMemberSelectTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZvContext(_connection); + _db.Database.EnsureCreated(); + _db.People.Add(new ZvPerson { NameAr = "مالية", NameEn = "Finance", AliasAr = "م", Born = FirstBorn, Code = "FIN" }); + _db.People.Add(new ZvPerson { NameAr = "صحة", NameEn = "Health", AliasAr = null, Born = new DateTime(1985, 1, 2), Code = "HLT" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private IQueryable Initialized() => + _db.People.OrderBy(p => p.Id).Select(p => new ZvRow + { + Id = p.Id, + Name = new ZvText { Ar = p.NameAr, En = p.NameEn }, + Alias = p.AliasAr == null ? null : new ZvText { Ar = p.AliasAr, En = p.NameEn }, + Card = new ZvCard + { + Title = new ZvText { Ar = p.NameAr, En = p.NameEn }, + Code = p.Code, + Tags = new List { p.Code } + }, + Born = p.Born, + Code = p.Code + }); + + private IQueryable Constructed() => + _db.People.OrderBy(p => p.Id).Select(p => new ZvRow { Id = p.Id, Name = new ZvText(p.NameAr, p.NameEn) }); + + private static List Pick(IQueryable rows, params string[] fields) => + rows.Select(fields.ToList()).ToList(); + + [Fact] + public void A_member_of_a_struct_comes_back_with_its_value() + { + List rows = Pick(Initialized(), "Name.Ar"); + + Assert.Equal(new[] { "مالية", "صحة" }, rows.Select(row => row.Name.Ar)); + Assert.All(rows, row => Assert.Null(row.Name.En)); + } + + [Fact] + public void Two_members_of_a_struct_come_back_together() + { + List rows = Pick(Initialized(), "Name.Ar", "Name.En"); + + Assert.Equal(new[] { "Finance", "Health" }, rows.Select(row => row.Name.En)); + Assert.Equal(new[] { "مالية", "صحة" }, rows.Select(row => row.Name.Ar)); + } + + [Fact] + public void A_struct_its_constructor_built_is_read_on_the_client() + { + List rows = Pick(Constructed(), "Name.En"); + + Assert.Equal(new[] { "Finance", "Health" }, rows.Select(row => row.Name.En)); + } + + [Fact] + public void A_nullable_struct_is_built_where_it_has_a_value_and_left_null_where_it_has_none() + { + // A nullable struct's members are named through Value, as the path validator reads them. + List rows = Pick(Initialized(), "Alias.Value.Ar"); + + Assert.Equal("م", rows[0].Alias!.Value.Ar); + Assert.Null(rows[0].Alias!.Value.En); + Assert.Null(rows[1].Alias); + } + + [Theory] + [InlineData("Alias.Value")] + [InlineData("Alias.HasValue")] + public void Naming_a_nullable_struct_whole_or_only_its_presence_leaves_it_unbound_as_before(string field) + { + // Value names the whole struct, and the policy names the struct's members, not Value's. + List rows = Pick(Initialized(), field, "Id"); + + Assert.Null(rows[0].Alias); + Assert.Equal(1, rows[0].Id); + } + + [Fact] + public void A_struct_inside_a_struct_is_built_level_by_level() + { + List rows = Pick(Initialized(), "Card.Title.En", "Card.Code"); + + Assert.Equal("Finance", rows[0].Card.Title.En); + Assert.Null(rows[0].Card.Title.Ar); + Assert.Equal("FIN", rows[0].Card.Code); + } + + [Fact] + public void A_collection_inside_a_struct_is_carried() + { + List rows = Pick(Initialized(), "Card.Tags"); + + Assert.Equal(new[] { "FIN" }, rows[0].Card.Tags); + } + + [Fact] + public void A_member_no_projection_can_set_stays_as_it_was() + { + // Length is read-only; the struct is built with what can be set and the rest keeps its default. + List rows = Pick(Initialized(), "Card.Length", "Card.Code"); + + Assert.Equal("FIN", rows[0].Card.Code); + Assert.Equal(3, rows[0].Card.Length); + } + + [Fact] + public void A_class_navigation_inside_a_struct_stays_unbound_and_its_siblings_are_built() + { + List rows = Pick(Initialized(), "Card.Owner.Name", "Card.Code"); + + Assert.Equal("FIN", rows[0].Card.Code); + Assert.Null(rows[0].Card.Owner); + } + + [Theory] + [InlineData("Card.Owner.Name")] + [InlineData("Card.Length")] + public void A_struct_nothing_named_in_can_be_set_is_left_unbound_rather_than_failing(string field) + { + // Read-only, or a class navigation the struct cannot hand to EF.Property: nothing to bind. + List rows = Pick(Initialized(), field, "Id"); + + Assert.Null(rows[0].Card.Code); + Assert.Equal(1, rows[0].Id); + } + + [Fact] + public void A_path_beneath_a_framework_type_leaves_the_member_unbound() + { + // Born.Year is not a value a typed row can hold apart from the date, and the date whole is + // more than the path names: a policy allowing Born.Year alone would hand back the day. + List rows = Pick(Initialized(), "Born.Year", "Code.Length", "Id"); + + Assert.Equal(default, rows[0].Born); + Assert.NotEqual("FIN", rows[0].Code); + Assert.Equal(1, rows[0].Id); + } + + [Fact] + public void Rows_in_memory_are_read_the_same_way() + { + List source = new() + { + new ZvRow { Id = 1, Name = new ZvText("a", "b"), Born = FirstBorn }, + }; + + List rows = source.AsQueryable().Select(new List { "Name.Ar" }).ToList(); + + Assert.Equal("a", rows[0].Name.Ar); + Assert.Null(rows[0].Name.En); + } + + [Fact] + public void The_filter_terminal_selects_through_a_struct_as_well() + { + FilterResult result = Initialized().ToList(new Filter { Selects = new List { "Name.Ar" } }); + + Assert.Equal("مالية", result.Data[0].Name.Ar); + } + + // ---- under a policy ------------------------------------------------------------------------------ + + 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() })); + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_guarded_selection_through_a_struct_returns_the_value(DwTier tier) + { + FilterResult result = Guard(Initialized(), tier) + .ToList(new Filter { Selects = new List { "Name.Ar" } }); + + Assert.Equal("مالية", result.Data[0].Name.Ar); + } + + [Fact] + public void A_sibling_of_a_denied_struct_member_comes_back_without_it() + { + List source = new() + { + new ZvSecretRow { Id = 1, Pair = new ZvSecretPair { Shown = "open", Hidden = "secret" } } + }; + + FilterResult result = Guard(source.AsQueryable(), DwTier.Convenience) + .ToList(new Filter { Selects = new List { "Pair.Shown" } }); + + Assert.Equal("open", result.Data[0].Pair.Shown); + Assert.Null(result.Data[0].Pair.Hidden); + } + + [Fact] + public void The_denied_struct_member_itself_is_refused() + { + List source = new() + { + new ZvSecretRow { Id = 1, Pair = new ZvSecretPair { Shown = "open", Hidden = "secret" } } + }; + + Assert.Throws(() => Guard(source.AsQueryable(), DwTier.Strict) + .ToList(new Filter { Selects = new List { "Pair.Hidden" } })); + } + } +} diff --git a/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj b/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj index 7da80b4..473f6f8 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.3.0 - v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. One security fix in this package, and two of the core's changes are visible through it. Security fix: the 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, 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. The middleware also drains more events: [DwAudit] now records the audited members a projection the caller never named hands back, and each audited member the rows hand back where no path of the policy names it, one event per path 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. + 3.4.0 + v3.4.0 — Released in lockstep with DynamicWhere.ex 3.4.0, which it now requires. No API change in this package. Three of the core's changes are visible through it. POST /schema describes each member of an application's own struct with the struct member's denials, because a path beneath such a member now takes that member's policy, and /explain names the struct member's attribute as the source of the decision. A context DwClaimsAdapter builds carries DwClaimsOptions.Purpose, which the core now stores trimmed, a blank one as none; where DwCaps.Purposes declares page caps for that purpose, every query under the context runs under them. /simulate has no source, so it cannot refuse a path through a member a projection builds with a constructor, as it cannot refuse any other path the query cannot compute. + +v3.3.0 — Released in lockstep with DynamicWhere.ex 3.3.0, which it now requires. One security fix in this package, and two of the core's changes are visible through it. Security fix: the 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, 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. The middleware also drains more events: [DwAudit] now records the audited members a projection the caller never named hands back, and each audited member the rows hand back where no path of the policy names it, one event per path 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. diff --git a/DynamicWhere.ex.Policies.EntityFrameworkCore/DynamicWhere.ex.Policies.EntityFrameworkCore.csproj b/DynamicWhere.ex.Policies.EntityFrameworkCore/DynamicWhere.ex.Policies.EntityFrameworkCore.csproj index f755c20..82f564f 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.3.0 - 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. + 3.4.0 + v3.4.0 — Released in lockstep with DynamicWhere.ex 3.4.0, which it now requires. No change in this package. A rule it stores that is bound to a purpose now matches a context whose purpose differs from the rule's only by outer spaces, because the core stores a context's purpose trimmed, as the rule's already was; and a rule denying a member that holds an application's own struct now denies every path beneath that member. + +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. diff --git a/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj b/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj index 0edc8b8..2b72605 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.3.0 - 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. + 3.4.0 + v3.4.0 — Released in lockstep with DynamicWhere.ex 3.4.0, which it now requires. No change in this package. A rule it stores that is bound to a purpose now matches a context whose purpose differs from the rule's only by outer spaces, because the core stores a context's purpose trimmed, as the rule's already was; and a rule denying a member that holds an application's own struct now denies every path beneath that member. + +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. diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index 64f7e89..f4138a8 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -1,6 +1,6 @@ # DynamicWhere.ex -**Version:** 3.3.0  |  **Target Framework:** .NET 6+  |  **License:** MIT (Free Forever) +**Version:** 3.4.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.3.0 +dotnet add package DynamicWhere.ex --version 3.4.0 ``` **Dependencies:** @@ -479,9 +479,14 @@ Projects only the specified fields into a new instance of `T`. Supports direct p | Dotted through reference navigation | Recursively projected | `"Category.Name"` | `Category: { Id: …, Name: "Electronics" }` | | Dotted through collection navigation | Per-element `Select().ToList()` | `"Category.Vendors.Id"` | `Category: { Vendors: [{ Id: 1 }, …] }` | | Multi-level (reference + collection) | Nested recursively | `"Category.Vendors.Product.Name"` | `Category: { Vendors: [{ Product: { Name: "…" } }] }` | +| Dotted through an application's own struct (3.4.0) | Built member by member | `"Name.Ar"` | `Name: { Ar: "…", En: null }` | +| Dotted through a collection of structs (3.4.0) | Per-element `Select().ToList()`, or `ToArray()` for an array | `"Pairs.Shown"` | `Pairs: [{ Shown: "…", Hidden: null }, …]` | +| Dotted beneath a value type the framework declares | Left unbound | `"CreatedAt.Year"` | `CreatedAt` keeps its default | > **Note:** For nested entities (reference or collection), the `Id` property is always automatically included alongside any requested sub-fields. +> **Structs (3.4.0).** An application's own struct — a value type outside the `System` namespaces — is built with exactly what was named, and no `Id` is added: `Name.En` keeps its default when only `Name.Ar` is named. Nested structs are built level by level, and a collection inside a struct is carried. A nullable struct is named through `Value` (`"Alias.Value.Ar"`), built where it has a value and null where it has none; `"Alias.Value"` whole, or `"Alias.HasValue"` alone, leaves it unbound. A collection of structs is built element by element into a `List`, any member type a `List` can be assigned to, or a `T[]`; a null collection stays null, and a collection of scalars is still bound whole. Still unbound: a class navigation inside a struct (its siblings are built), a struct or an element in which nothing named can be set, a collection of nullable structs, and a path beneath a framework value type (`"CreatedAt.Year"`, `"Code.Length"`) — a typed row cannot hold the year apart from the date, and binding the date whole would return more than the path names; `SelectDynamic` carries such a path as named. On EF Core, a list of structs the source projection builds with a collection initializer (`new List { new Pair { … } }`) cannot be selected from again: a path beneath it fails with `InvalidOperationException` ("could not be translated"), as it always did for `SelectDynamic`; a list the projection reads from a query is built as named. Until 3.4.0 the typed projection skipped every value-typed member a path went beneath and bound a collection of structs whole — see breaking points 43 and 44. + **Validations:** - `query` and `fields` cannot be null. - `fields` must have at least one entry. @@ -1592,10 +1597,11 @@ 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: +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; and since 3.4.0 a path beneath an application's own struct takes the struct member's policy 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`, `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. +- **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 class navigation is still a separate field, because its members can be decorated: a denial on `Contact` leaves `Contact.Email` open. A struct is not a navigation (the last point). +- **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. Since 3.4.0 the attributes of each application struct such a path passes through are read the same way. +- **Beneath a member holding an application's own struct** *(3.4.0)*. A struct — a value type outside the `System` namespaces — is a value, not a navigation, so a path beneath a member whose type is one, or a collection of them, takes that member's policy beside its own: with `[DwDenied]` on an `Iban` struct, `Iban.Number` is refused as `Iban` is. Taken from the struct member, from every provider: its deny effects per feature, its `[DwOperators]` restriction (intersected with the path's own), its `[DwCost]` weight and its audited features. Not taken: its alias, required filter, forced scope and descriptive facts, which come from the path's own member. The path's own attributes still apply (`[DwNoOrder]` on `LocalizedText.En`), and precedence decides as usual: a rule allowing `Soft.Number` replaces an overridable deny on `Soft`, and no rule replaces a sealed `[DwDenied]` on the struct. Every struct on the path decides it (`Card.Inner.Ar` takes `Card`'s and `Inner`'s), and a struct inside a class navigation counts (`Owner.Name.Ar` takes `Owner.Name`'s), where the class itself does not. A struct member that is itself transformed refuses `Select`, `Group` and `Aggregate` on its parts. `[DwNoWhere, DwNoOrder] public LocalizedText Name` refuses `Name.Ar` and `Name.En` as filters and, under `Strict`, as sorts, and both can still be selected. A member declared as a nullable struct (`Iban?`) is covered too: a query names its parts through `Value` (`Iban.Value.Number`), and the policy reads such a path as the attribute walk names it, dropping the `Value` — `Iban.Value.Number` is decided as `Iban.Number`, `Iban.Value` as `Iban` — and reads the nullable's own `Iban.HasValue` as the nullable member `Iban`. A framework nullable such as `Salary.Value` is unchanged. See breaking points 41 and 46. `[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. @@ -1722,6 +1728,7 @@ It is refused only where the whole set of members a container can produce is kno | 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 | +| …where that `Select` builds the member, or the row, by calling an application type's constructor with arguments, `new LocalizedText(t.NameAr, t.NameEn)` (3.4.0) | the members an initializer beside the constructor binds; EF Core follows no other | 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 | | A framework member such as `Length`, `Year` or `HasValue` | nothing — the provider translates it | Runs, as it always did | @@ -1732,7 +1739,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 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. +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. An anonymous type is left alone whole: EF Core follows each of its members to its argument, so no member of such a row is refused here. A row or a member an application type's constructor builds with arguments has every member the constructor leaves unbound refused, and a clause on the member itself (3.4.0), because EF Core follows a member only through an initializer's binding: `new LocalizedText(t.NameAr, t.NameEn).Ar` cannot be translated, while `new LocalizedText { Ar = t.NameAr, En = t.NameEn }.Ar` is the column. 3.3.0 read such a projection as saying nothing about which member each value sets and refused none of them, so every clause on one went on to fail inside the provider; see breaking point 42. 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. @@ -1757,9 +1764,9 @@ A `Selects` entry can name a navigation, such as `"Lines"`, rather than the fiel | that can carry a field denied for `Select` that no path names: past four segments, in a framework generic, on a subtype, or unasked under a `"*"` deny (3.2.0) | Narrowed to the allowed fields where the core can narrow it; `FieldDeniedForSelect` where it cannot | `FieldDeniedForSelect` | - The fields beneath a member are read the way the attribute walker reads them: through any collection type, and no deeper than its four segments. Since 3.2.0 a member typed `IReadOnlyList`, `IReadOnlyCollection`, `Collection` or an application's own collection no longer hides the denials beneath it. The providers' own rules are asked too, so a denied property with no setter and a rule on a path reached through a cycle count. -- A narrowing that cannot be built as it was gated is refused in both tiers (3.2.0). The core's typed projection adds the key, `Id`, of every nested node it builds, so a navigation narrowed around its own denied key would get the key back. The core reads a path only through an array, `List`, `IList`, `ICollection`, `IEnumerable`, `HashSet` or `ISet`, so a narrowing through any other collection fails its validation. And some members cannot be narrowed at all: a column, a complex property or a member stored as JSON, which EF Core reads whole; a member of a row in memory; and a member a projection builds some way the core cannot narrow. +- A narrowing that cannot be built as it was gated is refused in both tiers (3.2.0). The core's typed projection adds the key, `Id`, of every nested class it builds (never of a struct, which it builds with exactly what was named, 3.4.0), so a navigation narrowed around its own denied key would get the key back. The core reads a path only through an array, `List`, `IList`, `ICollection`, `IEnumerable`, `HashSet` or `ISet`, so a narrowing through any other collection fails its validation. And some members cannot be narrowed at all: a column, a complex property or a member stored as JSON, which EF Core reads whole; a member of a row in memory; and a member a projection builds some way the core cannot narrow. - A named member can also carry a field denied for `Select` that no path names (3.2.0): one deeper than four segments, one inside a framework generic such as `Dictionary`, or one a subtype of the member's type declares — a derived entity, a subclass, an interface's implementation. What it can carry is read from the source. On an entity it is read from the EF Core model, so only what loads counts: the navigation's columns, a converted one included, its owned chain at any depth, the navigations beneath it an include, an automatic include or a lazy loader fills, and each member the model does not map, read as its type, since its getter can hand out what EF Core loaded — for its type and every type the model derives from it. On a projected row it is the type the initializer constructs the member as, when it says, and otherwise the member's type and every loaded subtype of it, as on a row in memory. Under a policy with a `"*"` deny, a path the walk never asks about — past four segments, with no setter, or on a subtype — is a denied one unless the policy names it; around a cycle it always is. The `Strict` tier refuses such a member. The `Convenience` tier narrows it where the core can, which builds the declared type and so drops a subtype's fields; a path naming a framework generic itself narrows to nothing and is dropped. Where the core cannot narrow it — a column at the top of `T`, a member of a row in memory — both tiers refuse it. -- A navigation named through another, `Main.Lead`, gates the key of every node it passes through, which the core's projection adds, as a dotted path to a value always did (3.2.0). A denied key refuses the projection. Naming a field beside a denied key, `Lines.Name` when `Lines.Id` is denied, was already refused in both tiers. +- A navigation named through another, `Main.Lead`, gates the key of every node it passes through, which the core's projection adds, as a dotted path to a value always did (3.2.0). A denied key refuses the projection. Only a class's key: a struct's is neither added nor gated, since no builder adds it (3.4.0). Naming a field beside a denied key, `Lines.Name` when `Lines.Id` is denied, was already refused in both tiers. - Under `Convenience` the refusal names the denied key, the first denied field beneath the member, or, for a denial no path names, the member itself. Under `Strict` its `FieldPath` is `"*"`, as on every field refusal. The trace records the reason either way. ### A request that sends no Selects @@ -1797,6 +1804,8 @@ A value EF Core does not map is left out: computing it would make EF Core read t - **narrowed** otherwise, to the allowed fields beneath it, four segments deep, as a caller naming it would get it, where the core's narrowing translates: an object the projection's initializer builds, a list a subquery reads into a type the core can bind (not an array or a set), a navigation that is neither complex nor stored as JSON, or an entity's owned member not stored as JSON. The narrowing builds the declared type, so a subtype's fields are dropped. A field beneath it that can hold what the policy cannot name is left out; - **left out whole** otherwise, and recorded as `Dropped` on `Select` with a reason that starts `left out whole`. +An application's own struct holding a denied member is narrowed as a class is since 3.4.0, held directly or in a list, any type a list can be assigned to, or an array: the typed projection builds one member by member, so its allowed members come back and the denied one does not. Until then the gate read a struct as a type the core cannot build and left it out whole, with the reason `left out whole: the core cannot build '…', which it narrows into`, so its allowed members came back empty. A nullable struct, or structs in a collection of another shape, are still left out whole. + ```text left out whole: a scope forced beneath it cannot be applied to what it holds left out whole: it is a column, which EF Core reads whole @@ -1850,7 +1859,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()`. 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. +`[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 part of an application's struct is audited as its struct member is, beside its own `[DwAudit]` (3.4.0). 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. @@ -1998,6 +2007,8 @@ equality and no setting removes it. A field that cannot accept it wants `Fixed`, **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 member of a struct** *(3.4.0)*. A struct is read as a copy. The compiled setter used to unbox a second copy and write into that, so a mask, a generalization, a format, a truncation, a default or a mutation declared on a member of an application's own struct — `Hash` and `Tokenize` included — landed on a temporary and the stored value was emitted: in both tiers, from rows in memory and from EF Core, typed and dynamic, whether the struct came back in a whole row or was selected whole (a dynamic selection of a path beneath it builds an object of its own, and that one was transformed already). Fixed (security): the setter writes into the box in place, and both passes of the walk write each changed struct back where it was read from, innermost first — into the member that held it, into its position in a list or an array, or into the outer struct that held it. A struct in a whole row or selected whole, nullable or not, a list or an array of structs, a struct inside a struct or inside a class navigation, and a struct past the walk's depth are all transformed, and a member the policy names is transformed once, by the pass along its path. A class member was never affected. A struct a transform changed that cannot be written back fails the query with `InvalidOperationException` rather than hand back the stored value: one held by a member with no setter, structs in a collection that cannot be written by position such as a `HashSet`, and a struct that is a dictionary's value. Give such a member a setter, and hold such structs in a list or an array. A member inside a nullable struct is transformed where a selection names it through `Value` (`Pair.Value.Code`) as well: on the typed terminal, on the dynamic one, whose generated row holds the member under `Value` and which the walk steps through, as a group key (the `PairValueCode` column), and past the walk's depth, and the group floor a masked member declares applies to an aggregate over it (breaking point 46). See breaking point 45. + **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 @@ -2032,6 +2043,7 @@ The floor appends its own `Count` aggregate under the reserved alias `__dwGroupS |---|---|---| | `MaxPageSize` | 1000 | Largest page a caller may request | | `DefaultPageSize` | 0 (off) | The page a guarded query is given when it asks for none. `MaxPageSize` only ever read a page the caller sent, so the request with none was the one nothing bounded. Composable `Filter`, `FilterDynamic` and `Summary` return the query already paged; `Where`, `Order`, `Select` and `Group` take no page and are never given one. A `Segment` is paged in the database like a filter, so this bounds what it reads as well as what it returns | +| `Purposes` | empty | Page caps per declared purpose (3.4.0), which replace `MaxPageSize` and `DefaultPageSize` for a query whose context names that purpose. See [Page caps per purpose](#page-caps-per-purpose) | | `MaxConditions` | 50 | Conditions in one filter | | `MaxConditionDepth` | 10 | How deep condition groups may nest: the top group counts as one and each level of `SubConditionGroups` adds one. `MaxConditions` bounds the count and says nothing about the shape. Measured on the caller's groups, before forced predicates are injected — a summary's `ConditionGroup` and `Having` each, and every set of a segment. New in 3.1.0; see breaking point 16 | | `MaxConditionSets` | 10 | Condition sets in one segment, empty sets included. Every set adds a condition or a `NOT EXISTS` subquery to the one statement a segment becomes, and a set with no conditions passes every other cap, so this is what bounds that statement. New in 3.1.0; see breaking point 16 | @@ -2051,6 +2063,36 @@ An unguarded call is held to none of these caps. The structural caps — `MaxPag Options are frozen at startup. Every cap refuses a value below one, except two that accept zero: `DefaultFieldCost`, which is the posture for a model weighing only its few expensive fields and leaving the rest free, and `DefaultPageSize`, where zero means no page is supplied. `DefaultPageSize` is the only one that refuses nothing — it fills a page in rather than rejecting a request that carried none, and is bounded by `MaxPageSize`. +#### Page caps per purpose + +*New in 3.4.0.* `MaxPageSize` and `DefaultPageSize` bound what one response carries, and a deployment sets them for its screens. An export or a report reads the same rows under the same field policy and was held to the same page: it stopped at the first page, or walked the rest one page at a time — a statement and a count per page, and no single snapshot of the data. `DwCaps.Purposes` gives such a read page caps of its own without raising the screens'. + +```csharp +// appsettings: "Caps": { "MaxPageSize": 1000, "DefaultPageSize": 100, +// "Purposes": { "excel": { "MaxPageSize": 10000, "DefaultPageSize": 10000 } } } +// or in code, before Configure: +options.Caps.Purposes["excel"] = new DwPageCaps { MaxPageSize = 10_000, DefaultPageSize = 10_000 }; + +// The export handler. +context.Purpose = "excel"; // before the read, set by the host +FilterResult file = await rows + .ApplyPolicy(context) + .ToListAsync(filter, cancellationToken); // one statement, up to 10,000 rows +bool truncated = file.TotalCount > file.Data.Count; +``` + +`DwCaps.Purposes` is a `DwPurposeCaps`, an `IDictionary`, and a `DwPageCaps` holds `int? MaxPageSize` and `int? DefaultPageSize`. + +- A query runs under a purpose's page caps when its `DwPolicyContext.Purpose` names that purpose. The name is trimmed and compared without regard to letter case, the way a purpose-bound rule matches. A context naming no purpose, or one no entry names, runs under the deployment's caps, so a read that forgets to declare itself is bounded like a screen's. +- Since 3.4.0 `DwPolicyContext.Purpose` is stored trimmed, and a null, empty or blank value is stored as null, so `" export "` on a context means to a purpose-bound rule what it means to a purpose's caps (breaking point 40). +- A value left null takes the deployment's cap. A value set must be at least 1, or the setter throws `ArgumentOutOfRangeException`, so no purpose can switch a bound off. The default page a purpose runs under is bounded by the maximum it runs under, as the deployment's is by its own, and a purpose may lower the caps as well as raise them. With the deployment's `DefaultPageSize` at 0, a purpose that sets only `MaxPageSize` leaves a request with no page unpaged, as the deployment does. +- Only the two page caps are replaced. Every other cap applies to every purpose alike. +- A page above the purpose's maximum is refused, never trimmed: `CapExceeded`, `SourceOrigin` `"MaxPageSize cap (10000), request had 10001"`, in both tiers. It applies to `ToList` and `ToListAsync`, typed and dynamic, a `Summary`'s page, a `Segment`'s page, and the composable `Page(PageBy)`. +- Any name, and as many as a deployment has: `excel`, `csv`, `pdf`, `audit-logs`. Several names may share one `DwPageCaps` instance. A null or blank name is refused with `ArgumentException` and a null value with `ArgumentNullException`; `TryGetValue` and `ContainsKey` answer false for a null or blank name. Keys are stored trimmed. +- It binds from `Caps:Purposes::MaxPageSize` and `Caps:Purposes::DefaultPageSize` — as an environment variable, `DynamicWhere__Policies__Caps__Purposes__excel__MaxPageSize=10000` for a section at `DynamicWhere:Policies`. A misspelt key inside a purpose, or any other cap there such as `MaxConditions`, refuses to start, and so does a value below 1. +- It freezes with the posture: after startup, adding, replacing, removing or clearing a purpose, and setting a value on any `DwPageCaps` it holds, one assigned before startup included, throws `InvalidOperationException`, and `IsReadOnly` is true. +- **The purpose is the host's statement, never the caller's.** A request that could name its own purpose could name its own caps, and whatever purpose-bound grants exist. Set it in the endpoint that serves the read; never bind it from a request header or body. + ### Administration `app.MapDwPolicyAdmin(...)` mounts seven endpoints under `/dw-policies`, which is `DwPolicyAdminOptions.RoutePrefix`'s default and not a fixed path — set `o.RoutePrefix` to mount them anywhere. It **refuses to map without both `ReadPolicy` and `WritePolicy` named** — there is no default, and it fails at startup rather than on the first request. @@ -2157,6 +2199,15 @@ interval and a salt shorter than sixteen characters are all refused exactly as t the group floor's opt-out survives unchanged — saying nothing leaves it unset, writing `1` records a deliberate choice. +A purpose's page caps bind the same way, `Caps:Purposes:excel:MaxPageSize` (3.4.0). Inside a purpose +only `MaxPageSize` and `DefaultPageSize` answer, so a misspelt key there, or another cap such as +`MaxConditions`, refuses to start, and so does a value below one. + +Since 3.4.0 a value a property refuses is reported as the `InvalidOperationException` `Bind` documents: +the message starts `A configured policy value was refused:` and `InnerException` is the property's own +exception, however deep the binder wrapped it, a purpose's page cap inside its dictionary included. It +used to arrive as the binder's `TargetInvocationException`. See breaking point 47. + `HashSalt` binds like anything else, and configuration is the right channel for it through user secrets, an environment variable or a vault. A salt committed to `appsettings.json` is not a salt, and nothing here can tell the difference. @@ -2180,7 +2231,7 @@ What counts as the same posture: |---|---| | `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* | +| Every value on `Caps`, the floor that applies rather than whether it was written down, and every purpose's page caps by the caps that apply (3.4.0) | The provider *instances* | | 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 | | @@ -2188,7 +2239,10 @@ What counts as the same posture: 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. +differently, and the second posture is refused. Purposes are compared over the names either side +declares, by the `MaxPageSize` and `DefaultPageSize` each name runs under, a value left null reading as +the deployment's own: a purpose whose caps equal the deployment's is the same posture as no entry, and +one with any other page cap is refused (3.4.0). 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 @@ -2570,6 +2624,40 @@ These are numbered as this document numbers them. The website's [breaking-change **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. +40. **A Declared Purpose Can Carry Page Caps of Its Own, and a Context Stores Its Purpose Trimmed** + `DwCaps.MaxPageSize` and `DwCaps.DefaultPageSize` bound what one response carries, and a deployment sets them for its screens. An export or a report read the same rows under the same field policy and was held to the same page, so it stopped at the first page or walked the rest one page at a time — a statement and a count per page, and no single snapshot. Since 3.4.0 `DwCaps.Purposes` gives a declared purpose page caps of its own, and a query whose `DwPolicyContext.Purpose` names it runs under them; see [Page caps per purpose](#page-caps-per-purpose). A deployment that declares no purpose sees no change, and a second `DwPolicy.Configure` compares purposes too, by the caps that apply. + + Changed beside it: `DwPolicyContext.Purpose` is stored trimmed, and a null, empty or blank value is stored as null. A rule's purpose was already trimmed where the rule is built, so a context declaring `" export "` now matches a purpose-bound rule for `export`, which it used to miss. **Who is affected:** a host that set a purpose with surrounding spaces, whose purpose-bound rules now apply to it. + +41. **The Parts of a Struct Take the Struct Member's Policy** + A path beneath a member whose type is an application's own struct — a value type outside the `System` namespaces — or a collection of them takes that member's policy, as a path beneath a framework-typed member has since 3.3.0 (point 31). A struct is a value, not a navigation. Fixed (security) in 3.4.0, in both tiers: with `[DwDenied]` on an `Iban` struct, `Iban.Number` was filtered on — a test for a guessed value — sorted by, grouped by with its values as the group keys, and handed back by a dynamic projection, under `Strict`. Taken from the struct member, from every provider, attributes and rules alike: its denials, its operators (intersected with the path's own), its cost and its audit. Not taken: its alias, required filter, forced scope and descriptive facts, which come from the path's own member. The path's own attributes still apply beside the struct member's, and precedence decides as usual: a rule allowing `Soft.Number` replaces an overridable deny on `Soft`, and no rule replaces a sealed `[DwDenied]` on the struct. Every struct on the path decides it (`Card.Inner.Ar`), and a struct inside a class navigation counts (`Owner.Name.Ar` takes `Owner.Name`'s); the class itself does not, so a denial of `Customer` still denies only that path. A struct member that is itself transformed refuses `Select`, `Group` and `Aggregate` on its parts. Past the walk's depth the attributes of each struct the path passes through are read directly. `Explain` names the struct member's attribute as the source, and the schema reports the parts with its denials. `[DwNoWhere, DwNoOrder] LocalizedText Name` refuses `Name.Ar` and `Name.En` as filters and, under `Strict`, as sorts, and both can still be selected. + + **A nullable struct too:** its parts are named through `Value` (`Iban.Value.Number`), and since 3.4.0 the policy reads them as it names them, `Iban.Number` (point 46). **A filter on the whole struct** — a condition whose field is `Name` itself — fails in the builder with `InvalidOperationException` ("The binary operator NotEqual is not defined for the types '…' and 'System.Object'"), guarded or not, in both tiers; `[DwNoWhere]` on the member turns it into a refusal. **Who is affected:** a caller that filtered, sorted, grouped or selected a part of a denied, restricted, weighted or audited struct member is now refused, dropped, charged or recorded as for the member itself. + +42. **Under `Strict`, a Path Through a Member a Constructor Builds Is Refused** + EF Core follows a member only through an initializer's binding: `new LocalizedText(t.NameAr, t.NameEn).Ar` cannot be translated, while `new LocalizedText { Ar = t.NameAr, En = t.NameEn }.Ar` is `t.NameAr` and translates to the column. Until 3.4.0 the policy read a constructor with arguments as saying nothing about which member each value sets and refused none of them, so EF Core threw "could not be translated": a five-hundred where the strict tier promises a refusal. Under `Strict`, outside a dry run, a filter, an order, a grouping key, an aggregated field or a segment condition naming a path through such a member is now refused as an unknown name is — the clause's `FieldDeniedFor*` code, `FieldPath` `"*"` — and the trace records `the member exists on the type and the query cannot compute it, so it is refused as an unknown name is`. A row built by its own constructor, a positional record (`Select(t => new TenantRecord(t.Id, t.NameAr))`), has every member a clause names refused, and an unfiltered, unsorted read of it still works. A clause on the constructed member itself, `ORDER BY Name`, is refused too, and it can still be selected. Beside an initializer (`new Money(t.Amount) { Currency = t.Currency }`), a member the initializer binds stays usable and a member left to the constructor is refused. A constructor in either branch of a conditional refuses what that branch leaves unbound, whatever the other branch binds. Left alone, as before: an anonymous type, the framework's own types (`new DateTime(y, 1, 1)`), a collection, a type the model stores as a column (through a value converter, or a spatial point), a projection with no EF Core entity behind it, a provider that is not EF Core's own, rows in memory, `Convenience` and a dry run. `Selects` through such a member still works. **Who is affected:** no query that ran: each refused request failed inside the provider before. Build such a member with an initializer to make its parts usable. See [Blocked-action semantics](#blocked-action-semantics). + +43. **A Typed Selection Through a Struct Returns Its Values** + The typed projection — `Select`, and a `Filter`'s or a `Segment`'s `Selects` on the typed terminals — skipped every value-typed member a path went beneath, so `Select(["Name.Ar"])` over a `LocalizedText` struct returned the struct's default, an empty name, with nothing refused, guarded or not. Fixed in 3.4.0: an application's own struct is built member by member, as a class navigation is, so the row carries what was named and nothing beside it. Nested structs are built level by level, a collection inside a struct is carried, and a nullable struct is named through `Value` (`Alias.Value.Ar`) and built where it has a value. Still unbound: a class navigation inside a struct, a struct in which nothing named can be set, and a path beneath a framework-typed member (`Born.Year`, `Code.Length`) — a typed row cannot hold the year apart from the date, and binding the date whole would return more than the path names; `ToListDynamic` carries it as named. A transform declared on a member of the struct is applied to the row (point 45), and a projection the policy synthesizes for a request with no `Selects` narrows a struct holding a denied member as it narrows a class, where it used to leave the struct out whole (see [A request that sends no Selects](#a-request-that-sends-no-selects)). A guarded selection through a struct carries only what was named: the gate added the `Id` of every node a selected path passes through, as the builder adds a class's key, and read a struct as one, so `DeniedBy.Value.Why` came back with the struct's `Id` beside it, typed and dynamic, and a struct whose `Id` is denied had a selection of its other members refused. Neither builder adds a struct's key, so neither does the gate. **Who is affected:** a typed query naming a path beneath a struct now receives the values instead of defaults, and a guarded selection through a struct holding an `Id` no longer receives that key unless it names it. See [Extension Methods Reference](#extension-methods-reference). + +44. **A Typed Selection Beneath a Collection of Structs Carries Only What It Names** + The typed projection bound every collection of values whole. That is right for strings, numbers and dates, but a struct has members a policy names: `Pairs.Shown` over a `List` handed back every `Pair` whole, a `[DwDenied]` member included, in both tiers, while the policy had approved only `Pairs.Shown`. Selecting `Pairs` whole was, and is, refused when an element member is denied, and the dynamic terminals were never affected. Fixed (security) in 3.4.0, present since the policy layer shipped in 3.0.0: each element of a collection of an application's own structs is built member by member, `Pairs.Select(v => new Pair { Shown = v.Shown }).ToList()`, into a `List`, any member type a `List` can be assigned to, or a `T[]`. A null collection stays null. A collection of nullable structs, a collection of another shape, and an element in which nothing named can be set are left unbound: nothing is carried rather than everything. A collection of scalars is still bound whole. It works on EF Core where the projection reads the list from a query (`o.Lines.Select(l => new Pair { … }).ToList()`) and in memory. + + **Changed beside it:** a list a projection builds with a collection initializer (`Pairs = new List { new Pair { … } }`) is one EF Core cannot select from again, so a typed selection beneath it now fails with `InvalidOperationException` ("could not be translated"), guarded or not, as the dynamic terminals always did; it used to return each element whole. Build such a list from a query, or name the collection whole where nothing in it is denied. + +45. **A Transform on a Member of a Struct Is Applied** + The outbound walk read a struct as a boxed copy, and the compiled setter unboxed a second copy to write into, so a transform declared on a member of an application's own struct — `[DwMask]` with any strategy, `Hash` and `Tokenize` included, `[DwGeneralize]`, `[DwFormat]`, `[DwTruncate]`, `[DwDefault]` and `[DwMutate]` — landed on a temporary and the stored value was emitted. Fixed (security) in 3.4.0, present since the policy layer shipped in 3.0.0: in both tiers, from rows in memory and from EF Core, typed and dynamic, whether the struct came back in a whole row or was selected whole. A class member was never affected, nor a dynamic selection of a path beneath the struct, which builds an object of its own. The setter writes into the box in place now, and both passes of the walk write each changed struct back where it was read from, innermost first: into the member that held it, into its position in a list or an array, or into the outer struct that held it. Nullable structs returned whole, lists and arrays of structs, structs inside structs, a struct inside a class navigation and a struct past the attribute walk's depth are all transformed, and a member the policy names is transformed once, by the pass along its path, never again by the pass by run-time type. A source of rows in memory is still transformed in place, its structs included, as class members are. + + **Behaviour change (fail-closed):** a struct a transform changed that cannot be written back now fails the query with `InvalidOperationException` rather than emit the stored value — a struct held by a member with no setter (the message says it passes through a struct held by a member with no setter), structs held in a collection that cannot be written by position such as a `HashSet` (the message asks to hold the structs in a list or an array), and a struct that is a dictionary's value (the message says it cannot be written back where it was read from). **Who is affected:** every deployment with a transform on a struct's member now returns the transformed value where it returned the stored one, and one holding such a struct where it cannot be written back sees the query fail until the member gets a setter or the structs move into a list or an array. + +46. **A Member of a Nullable Struct Is Policed as the Policy Names It** + A query reaches a member of an `Iban?` through the nullable, `Iban.Value.Number`, because that is the member it compiles; the attribute walk looks through the nullable and names the same member `Iban.Number`, and so do the type's transforms, audits and fragments. Looked up as the query spells it, the path matched none of them. Fixed (security) in 3.4.0, present since the policy layer shipped in 3.0.0: a `[DwDenied]` on the nullable member, or on a member of the struct, did not stop a filter, a sort, a grouping key or a selection of that member, typed or dynamic, under `Strict`; `Iban.HasValue` was open; and a mask on a member of the struct was not applied when a selection named it through `Value`, as a result column or as a group key. The typed selection change of this release (point 43) had widened it to the typed terminal before release. + + A `Value` after a nullable struct of the application's is now dropped wherever the policy reads a path — the resolver, the outbound walk, the summary's key and aggregate transforms, the group floor and the past-depth transforms — so `Iban.Value.Number` is decided as `Iban.Number` and `Iban.Value` as `Iban`, and the nullable's own members, `HasValue` included, are read as the nullable member. A framework nullable, `Salary.Value`, is unchanged. With `[DwDenied] Iban? Iban`, `Iban.Value.Number` and `Iban.HasValue` are refused as `Iban` is: in every clause under `Strict`, and as a filter, a group or an aggregate under `Convenience`. A `[DwDenied]` member inside a nullable struct (`Pair.Value.Hidden`) is refused, an allowed one (`Pair.Value.Shown`) stays usable, and a mask on a member inside one (`Pair.Value.Code`) is applied on the typed terminal and on the dynamic one — the walk steps through `Value` on the generated row — as a group key (`PairValueCode`) and past the walk's depth; the group floor a masked member declares applies. `PolicyResolver.Resolve` and `Explain` report the path as the policy names it, `Iban.Number`; the trace keeps the caller's spelling. **Who is affected:** a caller that reached a denied, masked, restricted or audited member of a nullable struct through `Value` is refused, dropped, masked or recorded now, as through the member itself. + +47. **`Bind` Reports a Refused Value as `InvalidOperationException`** + `DwPolicyConfiguration.Bind` documents `InvalidOperationException` for a value a property refuses — a cap below one, a purpose's page cap below one, a hash salt too short, a snapshot age or refresh interval that is not positive. The binder calls each setter by reflection, so such a value arrived as a `TargetInvocationException`. Since 3.4.0 it arrives as the documented `InvalidOperationException`: the message starts `A configured policy value was refused:` and `InnerException` is the property's own exception (`ArgumentOutOfRangeException`, `ArgumentException`), however deep the binder wrapped it. A purpose's page cap is bound inside a dictionary, where Microsoft.Extensions.Configuration.Binder 8 adds an `InvalidOperationException` of its own around the `TargetInvocationException`; it is reported the same way. `AddDwPolicies` binds through `Bind`, so a host starting with such a value fails the same way. **Who is affected:** code that caught `TargetInvocationException` around `Bind` or `AddDwPolicies` catches `InvalidOperationException` instead; a host that let startup fail sees a different exception type. + --- ## License diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index b12b06d..eba572a 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -48,58 +48,32 @@ Sajjad H. Al-Khafaji Free Forever — Copyright © Sajjad H. Al-Khafaji MIT - 3.3.0 + 3.4.0 https://doc.dynamicwhere.com git master 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, 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. + v3.4.0 — Page caps per declared purpose, so an export reads every match in one statement while a search keeps its page; security fixes for struct members, a transform on one now applied, a member of a nullable struct now policed and a typed selection beneath a list of structs no longer handing back a denied member; a path through a member a constructor builds is refused rather than run; the parts of a struct take the struct member's policy; and a typed selection through a struct returns its values. -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. +Security fix: a transform declared on a member of an application's own struct was never applied. [DwMask], [DwGeneralize], [DwFormat], [DwTruncate], [DwDefault] and [DwMutate], Hash and Tokenize included, on a struct's member left the stored value in the result, in both tiers, from rows in memory and from EF Core, whenever the struct was carried by a whole row or selected whole, typed or dynamic. The outbound walk read the struct as a copy in a box, and the setter unboxed a second copy to write into, so each transform landed on a temporary. Present in every release since 3.0.0; a class member was never affected. The setter now writes into the box in place, and both passes of the walk write each changed struct back where it was read from, innermost first: into its member, into its position in a list or an array, or into the struct holding it. A member the policy names is transformed once. Behaviour change (fail-closed): a struct a transform changed that cannot be written back, held by a member with no setter, in a collection that cannot be written by position such as a HashSet, or as a dictionary's value, now fails the query with InvalidOperationException rather than emitting the stored value. -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: a member of a nullable struct was not policed. A query reaches a member of an Iban? through the nullable, Iban.Value.Number, while the attribute walk, the transforms and the fragments name the member Iban.Number, so a lookup by the query's spelling matched none of them: a [DwDenied] on the nullable member or on a member of the struct did not stop a filter, a sort, a grouping key or a selection of that member, Iban.HasValue was open, and a mask on a member of the struct was not applied when a selection named it through Value, as a result column or as a group key. Present in every release since 3.0.0; the typed selection fix below had widened it to the typed terminal before release. A Value after a nullable struct of the application's is now dropped wherever the policy reads a path: the resolver, the outbound walk, the summary's key and aggregate transforms, the group floor and the past-depth transforms. A nullable's own members, HasValue included, are read as the nullable member, and the walk reaches a member a dynamic projection's generated row holds under Value. -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: a typed selection naming a path beneath a collection of an application's own structs handed back each element whole. Selects ["Pairs.Shown"] over a List of Pair structs returned every Pair with all its members, a [DwDenied] one included, in both tiers, while the policy had approved Pairs.Shown alone; the typed projection bound every collection of values whole, which is right for strings, numbers and dates and wrong for a struct. Present in every release since 3.0.0; the dynamic terminal was never affected. Each element is now built member by member into a list or an array, a null collection stays null, and a collection of nullable structs or of another shape is left unbound rather than carried whole. Selecting the collection whole is refused when an element's member is denied, as it was. Behaviour change: a typed selection beneath a list of structs that an EF Core projection builds with a collection initializer, Pairs = new List<Pair> { new Pair { ... } }, now fails in EF Core's translation, as the dynamic terminal always did on that shape, where 3.3.0 returned every element whole; a list read from a subquery, a list in memory and an array are built element by element, and a list selected whole is returned as before. -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 (fail-closed): a path beneath a member holding an application's own struct, or a collection of them, takes that member's policy, as a path beneath a framework type has since 3.3.0. A struct is a value, not a navigation: with [DwDenied] on an Iban struct, Iban.Number could be filtered on, and so tested for a guessed value, sorted by, grouped by and returned by a dynamic projection; the typed projection returned it empty only because it skipped structs. The struct member's denials, operators, cost and audit decide every path beneath it, beside the path's own attributes; its alias, required filter, forced scope and description do not. A struct member that is transformed refuses Select, Group and Aggregate on its parts, since its chain has no member of theirs to apply to, while a part that declares its own transform is transformed as any member is. A class is still a navigation whose members are separate fields: a denial of Customer denies only that path. -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: DwCaps.Purposes gives a declared purpose page caps of its own. MaxPageSize and DefaultPageSize bound what one response carries, and a deployment sets them for its screens; an export, a report or a feed reads the same rows under the same field policy and was held to the same page, so it stopped at the first one or walked the rest a page at a time, one statement and one count per page and no single snapshot of the data. A purpose is any name, and a deployment may declare as many as it has: Caps:Purposes:excel:MaxPageSize = 10000 in configuration, or options.Caps.Purposes["excel"] = new DwPageCaps { MaxPageSize = 10_000, DefaultPageSize = 10_000 } in code, and several names may share one DwPageCaps. A query runs under a purpose's caps when its DwPolicyContext.Purpose names it, matched as a purpose-bound rule matches, trimmed and without regard to letter case; a purpose no entry names, or none, runs under the deployment's caps, so a read that forgets to declare itself is bounded like a screen's. A value a purpose leaves null takes the deployment's cap, a value it sets is at least one so no purpose can make a read unbounded, and the default page stays bounded by the maximum. Only the two page caps are replaced; every other cap applies to every purpose alike, and a page past a purpose's maximum is refused, never trimmed, as every cap is. The purpose is the host's statement and never the caller's: a request that could name its own purpose could name its own caps. Purposes bind from configuration with ErrorOnUnknownConfiguration, freeze with the posture, and are compared by the caps that apply when DwPolicy.Configure is called again. Bind now reports a value a property refuses, a cap below one or a salt too short, as the InvalidOperationException it documents, with the property's own refusal inside; it arrived as a TargetInvocationException. -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. +Behaviour change: DwPolicyContext.Purpose is stored trimmed, and a blank value is none. A rule's purpose was already trimmed where the rule is built, so a context declaring " export " now means to a purpose-bound rule what it means to a purpose's caps. -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. +Behaviour change: under the strict tier, a filter, order, grouping key, aggregated field, having or segment condition naming a path through a member a projection builds by calling an application type's constructor with arguments is refused as an unknown name is, where EF Core used to throw. EF Core follows a member only through an initializer's binding: new LocalizedText(t.NameAr, t.NameEn).Ar cannot be translated, and new LocalizedText { Ar = t.NameAr, En = t.NameEn }.Ar is t.NameAr. A row built by its own constructor, a positional record, has every member a clause names refused, and a clause on the constructed member itself, ORDER BY Name, is refused too; a member an initializer binds beside the constructor stays usable, and a conditional that builds the member with a constructor in either branch refuses what that branch leaves unbound. Left alone: an anonymous type, whose constructor names its members; the framework's own types, which a provider may translate; a type the model stores as a column somewhere, such as a spatial point; a projection with no entity behind it; and the convenience tier and a dry run, which still fail as an unguarded query does. A selection through such a member still works, because EF Core evaluates the last projection on the client. -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. +Fix: a typed selection naming a path beneath a struct returned the struct's default with nothing refused: Select(["Name.Ar"]) over a LocalizedText struct gave an empty name, guarded or not. The typed projection now builds an application's own struct member by member, as it builds a class navigation, so the row carries what was named and nothing beside it; a nullable one is named through Value, Alias.Value.Ar, and built where it has a value. A path beneath a framework type, Born.Year or Code.Length, still leaves the member unbound, because a typed row cannot hold the year apart from the date and the date whole would be more than the path names; the dynamic terminal carries such a path as it names it. A struct member that is itself a class navigation, a read-only member, the whole of a nullable struct's Value and its HasValue alone are left unbound as before. A projection the policy synthesizes, for a request with no Selects, narrows a struct holding a denied member as it narrows a class, where it used to leave the struct out whole with its allowed members empty; a nullable struct, or one in a collection that is not a list or an array, is still left out whole. And a guarded selection through a struct carries only what was named: the gate added the Id of every node a selected path passes through, as the builder adds a class's key, and read a struct as one, so DeniedBy.Value.Why came back with the struct's Id beside it, typed and dynamic, and a struct whose Id is denied had a selection of its other members refused and was left out of a synthesized projection. Neither builder adds a struct's key, so neither does the gate. -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. -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. - - -Earlier releases, in brief. The full notes of each are on the releases page, https://github.com/Sajadh92/DynamicWhere.ex/releases, and every behaviour change is on https://doc.dynamicwhere.com/docs/breaking-changes. +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. 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/DynamicWhere.ex/Policies/Config/DwCaps.cs b/DynamicWhere.ex/Policies/Config/DwCaps.cs index e72ba7d..fd4f01f 100644 --- a/DynamicWhere.ex/Policies/Config/DwCaps.cs +++ b/DynamicWhere.ex/Policies/Config/DwCaps.cs @@ -86,6 +86,46 @@ public int DefaultPageSize } } + /// + /// The page caps of each declared purpose, which replace and + /// for a query whose context names that purpose. + /// + /// + /// The two page caps bound what one response carries, and a deployment sets them for its screens. + /// An export, a report or a feed reads the same rows under the same field policy and needs more of + /// them in one statement. Declaring it a purpose gives it its own page caps and leaves the screens' + /// alone: + /// + /// "Caps": { + /// "MaxPageSize": 1000, + /// "DefaultPageSize": 100, + /// "Purposes": { + /// "excel": { "MaxPageSize": 10000, "DefaultPageSize": 10000 }, + /// "audit-logs": { "MaxPageSize": 5000 } + /// } + /// } + /// + /// + /// The purpose is DwPolicyContext.Purpose, which the host sets and a caller never can: a + /// request that could name its own purpose could name its own caps. A context naming a purpose not + /// listed here, or none, runs under the deployment's caps, so a read that forgets to declare itself + /// is bounded like a screen's rather than left unbounded. Only the page caps are replaced; every + /// other cap applies to every purpose alike. + /// + /// + public DwPurposeCaps Purposes { get; } = new(); + + /// The largest page a query may request under a purpose, or under none. + internal int MaxPageSizeFor(string? purpose) => + PageCapsFor(purpose)?.MaxPageSize ?? MaxPageSize; + + /// The page a query is given when it asks for none, under a purpose or under none. + internal int DefaultPageSizeFor(string? purpose) => + PageCapsFor(purpose)?.DefaultPageSize ?? DefaultPageSize; + + private DwPageCaps? PageCapsFor(string? purpose) => + purpose is not null && Purposes.TryGetValue(purpose, out DwPageCaps? caps) ? caps : null; + /// The most conditions one filter may contain, counted across every nested group. public int MaxConditions { @@ -358,8 +398,12 @@ public int MaxSchemaFields set => _maxSchemaFields = Set(value); } - /// Prevents any further change. - internal void Freeze() => _frozen = true; + /// Prevents any further change, to these caps and to every purpose's. + internal void Freeze() + { + _frozen = true; + Purposes.Freeze(); + } /// /// Guards a setter against post-startup mutation and against a nonsensical limit. diff --git a/DynamicWhere.ex/Policies/Config/DwPageCaps.cs b/DynamicWhere.ex/Policies/Config/DwPageCaps.cs new file mode 100644 index 0000000..1b9b58e --- /dev/null +++ b/DynamicWhere.ex/Policies/Config/DwPageCaps.cs @@ -0,0 +1,80 @@ +namespace DynamicWhere.ex.Policies.Config; + +/// +/// The page caps a query runs under when its context declares one purpose, in place of the +/// deployment's own and . +/// +/// +/// A search and an export read the same rows for different reasons. The search returns a page to a +/// screen, and its caps bound what one response can carry. The export writes every match to a file, +/// and under the search's caps it either stopped at the first page or walked the rest one page at a +/// time: many statements, each with its own count, none of them one snapshot of the data. A purpose +/// gives the export its own numbers without raising the search's. +/// +/// A value left null takes the deployment's cap, so a purpose that only needs a larger maximum names +/// only that. A value that is set is at least one: a purpose bounds a read differently, and cannot +/// make a read unbounded. +/// +/// +/// +/// +/// options.Caps.Purposes["excel"] = new DwPageCaps { MaxPageSize = 10_000, DefaultPageSize = 10_000 }; +/// +/// context.Purpose = "excel"; +/// FilterResult<TenantRow> file = await rows.ApplyPolicy(context).ToListAsync(filter, ct); +/// +/// +public sealed class DwPageCaps +{ + private bool _frozen; + private int? _maxPageSize; + private int? _defaultPageSize; + + /// + /// The largest page a query under this purpose may request, or null for + /// . + /// + /// Thrown when the value is below one. + /// Thrown after startup. + public int? MaxPageSize + { + get => _maxPageSize; + set => _maxPageSize = Set(value); + } + + /// + /// The page a query under this purpose is given when it asks for none, or null for + /// . + /// + /// + /// Bounded by the purpose's maximum as the deployment's default is by its own, so a default + /// above it is applied as the maximum. + /// + /// Thrown when the value is below one. + /// Thrown after startup. + public int? DefaultPageSize + { + get => _defaultPageSize; + set => _defaultPageSize = Set(value); + } + + /// Prevents any further change. + internal void Freeze() => _frozen = true; + + private int? Set(int? value) + { + if (_frozen) + { + throw new InvalidOperationException("Policy caps cannot be changed after startup."); + } + + if (value is < 1) + { + throw new ArgumentOutOfRangeException( + nameof(value), value, + "A purpose's page cap must be at least 1. Null takes the deployment's own cap."); + } + + return value; + } +} diff --git a/DynamicWhere.ex/Policies/Config/DwPolicy.cs b/DynamicWhere.ex/Policies/Config/DwPolicy.cs index 6d30e63..e2a9087 100644 --- a/DynamicWhere.ex/Policies/Config/DwPolicy.cs +++ b/DynamicWhere.ex/Policies/Config/DwPolicy.cs @@ -76,7 +76,8 @@ public static class DwPolicy /// /// 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 + /// intervals, every cap and every purpose's page caps, 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, @@ -204,7 +205,31 @@ private static bool SameCaps(DwCaps inForce, DwCaps asked) => && inForce.MinGroupSize == asked.MinGroupSize && inForce.SchemaDepth == asked.SchemaDepth && inForce.SchemaCycleLimit == asked.SchemaCycleLimit - && inForce.MaxSchemaFields == asked.MaxSchemaFields; + && inForce.MaxSchemaFields == asked.MaxSchemaFields + && SamePurposes(inForce, asked); + + /// + /// True when every purpose either set names runs under the same page caps in both. + /// + /// + /// By the caps that apply, as above: a purpose left null takes the deployment's own cap, so one set + /// writing that cap out and the other leaving it null enforce the same page, and a purpose whose + /// caps equal the deployment's is the same as no entry. The names of both sets are walked, so a + /// purpose only the second call declares is compared too. + /// + private static bool SamePurposes(DwCaps inForce, DwCaps asked) + { + foreach (string purpose in inForce.Purposes.Keys.Concat(asked.Purposes.Keys)) + { + if (inForce.MaxPageSizeFor(purpose) != asked.MaxPageSizeFor(purpose) + || inForce.DefaultPageSizeFor(purpose) != asked.DefaultPageSizeFor(purpose)) + { + return false; + } + } + + return true; + } /// True when both catalogues expose the same types and answer to the same names. /// diff --git a/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs b/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs index 6a07215..ce4f55c 100644 --- a/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs +++ b/DynamicWhere.ex/Policies/Config/DwPolicyConfiguration.cs @@ -1,3 +1,4 @@ +using System.Reflection; using DynamicWhere.ex.Policies.Resolution; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -58,7 +59,8 @@ public static class DwPolicyConfiguration /// /// Every setter's own validation still applies: a cap below one, a snapshot age that is not a /// positive interval, and a hash salt shorter than the minimum are all refused here exactly as - /// they are refused in code. + /// they are refused in code, as an whose inner exception + /// is the property's own, however deep the binder wrapped it. /// /// /// The group floor's opt-out survives this unchanged, because it lives in the setter rather @@ -84,11 +86,37 @@ public static DwPolicyOptions Bind(this DwPolicyOptions options, IConfiguration throw new ArgumentNullException(nameof(section)); } - section.Bind(options, binder => binder.ErrorOnUnknownConfiguration = true); + try + { + section.Bind(options, binder => binder.ErrorOnUnknownConfiguration = true); + } + catch (Exception failure) when (Refusal(failure) is { } setter) + { + // The binder calls each setter by reflection, so a value the property refuses arrives wrapped + // in a TargetInvocationException, and a newer binder wraps that again in its own + // InvalidOperationException for a value inside a dictionary, such as a purpose's cap. + // Reported one way, with the property's own reason inside. + throw new InvalidOperationException( + $"A configured policy value was refused: {setter.Message}", setter); + } return options; } + /// The exception a setter threw, wherever in the chain the binder put it; null when none did. + private static Exception? Refusal(Exception failure) + { + for (Exception? current = failure; current is not null; current = current.InnerException) + { + if (current is TargetInvocationException { InnerException: { } setter }) + { + return setter; + } + } + + return null; + } + /// /// Configures the policy layer from a configuration section, then from code. /// diff --git a/DynamicWhere.ex/Policies/Config/DwPurposeCaps.cs b/DynamicWhere.ex/Policies/Config/DwPurposeCaps.cs new file mode 100644 index 0000000..e8cef44 --- /dev/null +++ b/DynamicWhere.ex/Policies/Config/DwPurposeCaps.cs @@ -0,0 +1,151 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +namespace DynamicWhere.ex.Policies.Config; + +/// +/// The page caps of every declared purpose, by the purpose's name. +/// +/// +/// Any name, and as many as a deployment has: excel, csv, pdf, +/// audit-logs. A name is trimmed and matched without regard to letter case, which is how a +/// purpose-bound rule matches the same context, so one purpose means one thing to a rule and to a +/// cap. A context that declares no purpose, or one named nowhere here, runs under the deployment's +/// caps. +/// +/// A dictionary, so a configuration section binds straight onto it, +/// Caps:Purposes:excel:MaxPageSize, and an environment variable can set one, +/// DynamicWhere__Policies__Caps__Purposes__excel__MaxPageSize. Several names may share one +/// . Frozen with the rest of the caps. +/// +/// +public sealed class DwPurposeCaps : IDictionary +{ + private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); + private bool _frozen; + + /// The page caps of one purpose. + /// The purpose's name. + /// Thrown when the name is null or blank. + /// Thrown when the value set is null. + /// Thrown when reading a purpose that has none. + /// Thrown when setting after startup. + public DwPageCaps this[string purpose] + { + get => _entries[Name(purpose)]; + set + { + Guard(); + _entries[Name(purpose)] = value ?? throw new ArgumentNullException(nameof(value)); + } + } + + /// + public ICollection Keys => _entries.Keys; + + /// + public ICollection Values => _entries.Values; + + /// + public int Count => _entries.Count; + + /// True once the caps are frozen. + public bool IsReadOnly => _frozen; + + /// + /// + /// Thrown when the name is null or blank, or already has page caps. + /// + public void Add(string key, DwPageCaps value) + { + Guard(); + _entries.Add(Name(key), value ?? throw new ArgumentNullException(nameof(value))); + } + + /// + public void Add(KeyValuePair item) => Add(item.Key, item.Value); + + /// + public void Clear() + { + Guard(); + _entries.Clear(); + } + + /// + public bool Contains(KeyValuePair item) => + TryGetValue(item.Key, out DwPageCaps? caps) && ReferenceEquals(caps, item.Value); + + /// + public bool ContainsKey(string key) => TryGetValue(key, out _); + + /// + public void CopyTo(KeyValuePair[] array, int arrayIndex) => + ((ICollection>)_entries).CopyTo(array, arrayIndex); + + /// + public IEnumerator> GetEnumerator() => _entries.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + public bool Remove(string key) + { + Guard(); + + return _entries.Remove(Name(key)); + } + + /// + public bool Remove(KeyValuePair item) + { + Guard(); + + return Contains(item) && _entries.Remove(Name(item.Key)); + } + + /// The page caps of a purpose, when it has any. + /// A null or blank name has none, rather than being refused: a context need not declare a purpose. + public bool TryGetValue(string key, [MaybeNullWhen(false)] out DwPageCaps value) + { + if (string.IsNullOrWhiteSpace(key)) + { + value = null; + + return false; + } + + return _entries.TryGetValue(key.Trim(), out value); + } + + /// Prevents any further change, to the names and to each purpose's caps. + internal void Freeze() + { + _frozen = true; + + foreach (DwPageCaps caps in _entries.Values) + { + caps.Freeze(); + } + } + + private void Guard() + { + if (_frozen) + { + throw new InvalidOperationException("Policy caps cannot be changed after startup."); + } + } + + private static string Name(string purpose) + { + if (string.IsNullOrWhiteSpace(purpose)) + { + throw new ArgumentException( + "A purpose's page caps need the purpose's name.", nameof(purpose)); + } + + return purpose.Trim(); + } +} diff --git a/DynamicWhere.ex/Policies/Context/DwPolicyContext.cs b/DynamicWhere.ex/Policies/Context/DwPolicyContext.cs index b5462f8..05745c3 100644 --- a/DynamicWhere.ex/Policies/Context/DwPolicyContext.cs +++ b/DynamicWhere.ex/Policies/Context/DwPolicyContext.cs @@ -67,9 +67,22 @@ public sealed class DwPolicyContext internal void MarkPrepared() => IsPrepared = true; /// - /// An optional declared purpose for the query, for purpose-bound rules. + /// An optional declared purpose for the query, for purpose-bound rules and for the page caps + /// DwCaps.Purposes gives that purpose. /// - public string? Purpose { get; set; } + /// + /// The host's statement about why it reads, never the caller's: a request that could name its own + /// purpose could name its own caps, and whichever purpose-bound grants exist. Stored trimmed, and a + /// blank value is none, so " excel" means to a rule what it means to a cap. A rule's purpose is + /// trimmed where the rule is built and both compare without regard to letter case. + /// + public string? Purpose + { + get => _purpose; + set => _purpose = string.IsNullOrWhiteSpace(value) ? null : value!.Trim(); + } + + private string? _purpose; /// /// Adds a subject. Adding the same kind and identity twice is a no-op. diff --git a/DynamicWhere.ex/Policies/Masking/GraphWalker.cs b/DynamicWhere.ex/Policies/Masking/GraphWalker.cs index 706b53f..5ec6b9a 100644 --- a/DynamicWhere.ex/Policies/Masking/GraphWalker.cs +++ b/DynamicWhere.ex/Policies/Masking/GraphWalker.cs @@ -98,6 +98,10 @@ internal static IReadOnlyList Apply( return Array.Empty(); } + // As the policy names each path, which is how the transforms are keyed: a projection naming + // Pair.Value.Code carries the member the type's list calls Pair.Code. + projected = projected?.Select(path => AttributePolicyProvider.PolicyPath(entityType, path)).ToList(); + // Reference identity, not equality. Two rows can share one referenced object, and // transforming it twice would hash a hash or truncate a truncation. // @@ -219,17 +223,42 @@ private static void ApplyPath( IReadOnlyCollection owners = roots; - for (int i = 0; i < segments.Length - 1; i++) - { - owners = Descend(owners, segments[i], path); + // A struct on the way down is a copy in a box, so what is written into it has to be written + // back into whatever held it, innermost first, once the leaf is done. + List? writeBacks = null; - if (owners.Count == 0) + try + { + for (int i = 0; i < segments.Length - 1; i++) { - return; + owners = Descend(owners, segments[i], path, ref writeBacks); + + if (owners.Count == 0) + { + return; + } } + + Transform(owners, segments[^1], path, chain, context, options, trace, done, ref writeBacks); + } + finally + { + WriteBack(writeBacks); } + } - string member = segments[^1]; + /// Transforms one member on every owner a path reached. + private static void Transform( + IReadOnlyCollection owners, + string member, + string path, + ValueTransform chain, + DwPolicyContext context, + DwPolicyOptions options, + PolicyTrace trace, + HashSet<(object Owner, string Member)>? done, + ref List? writeBacks) + { bool transformed = false; // One entry, not a dictionary. A materialized result is one runtime type in every case that @@ -239,8 +268,13 @@ private static void ApplyPath( Type? resolvedFor = null; Accessors accessors = default; - foreach (object owner in owners) + foreach (object reached in owners) { + if (Through(reached, member, path, ref writeBacks) is not { } owner) + { + continue; + } + Type type = owner.GetType(); if (!ReferenceEquals(type, resolvedFor)) @@ -286,15 +320,20 @@ private static void ApplyPath( /// it means the result is not the shape the policy was resolved against. /// private static HashSet Descend( - IReadOnlyCollection owners, string segment, string path) + IReadOnlyCollection owners, string segment, string path, ref List? writeBacks) { HashSet next = new(ReferenceComparer.Instance); Type? resolvedFor = null; Accessors accessors = default; - foreach (object owner in owners) + foreach (object reached in owners) { + if (Through(reached, segment, path, ref writeBacks) is not { } owner) + { + continue; + } + Type type = owner.GetType(); if (!ReferenceEquals(type, resolvedFor)) @@ -312,23 +351,132 @@ private static HashSet Descend( if (value is IEnumerable collection and not string) { - foreach (object? element in collection) - { - if (element is not null) - { - next.Add(element); - } - } + Elements(collection, path, next, ref writeBacks); continue; } + if (value.GetType().IsValueType) + { + (writeBacks ??= new List()).Add(Into(owner, value, accessors.Set, path)); + } + next.Add(value); } return next; } + /// + /// The object to read a segment from: the owner, or what a generated row holds under Value + /// when that is where the segment is; null when that is nothing. + /// + /// + /// The policy names a nullable struct's members without the nullable, Pair.Code. A typed row + /// reads the same way, since the nullable arrives as the struct in a box. A dynamic projection builds + /// what the query spelled, Pair.Value.Code, so its generated row holds the member one object + /// further down, and the walk steps through Value to reach it: skipping it would leave the + /// member exactly as stored. + /// + private static object? Through(object owner, string segment, string path, ref List? writeBacks) + { + Type type = owner.GetType(); + + if (!typeof(DynamicClass).IsAssignableFrom(type) + || CacheReflection.FindProperty(type, segment) is not null + || CacheReflection.FindProperty(type, nameof(Nullable.Value)) is not { } wrapped) + { + return owner; + } + + object? held = MutatorCache.Getter(wrapped)(owner); + + if (held is not null && held.GetType().IsValueType) + { + (writeBacks ??= new List()).Add(Into(owner, held, MutatorCache.Setter(wrapped), path)); + } + + return held; + } + + /// + /// Adds a collection's elements to the next level, each struct among them with the write that puts + /// it back where it was read from. + /// + /// + /// Thrown when a collection holds structs and cannot be written by position. The transform would + /// otherwise land in copies and every element would be emitted as stored. + /// + private static void Elements(IEnumerable collection, string path, HashSet next, ref List? writeBacks) + { + IList? positions = collection as IList; + + int index = -1; + + foreach (object? element in collection) + { + index++; + + if (element is null) + { + continue; + } + + if (element.GetType().IsValueType) + { + if (positions is null || positions.IsReadOnly) + { + throw new InvalidOperationException( + $"'{path}' is transformed by policy and passes through a collection of structs " + + $"({collection.GetType().Name}) that cannot be written by position, so the transformed " + + "values cannot replace the real ones. Hold the structs in a list or an array."); + } + + int at = index; + + (writeBacks ??= new List()).Add(() => positions[at] = element); + } + + next.Add(element); + } + } + + /// The write that puts a struct, changed in its box, back into the member it was read from. + /// + /// Thrown when the member has no setter. The transform would land in a copy and the struct would be + /// emitted as stored. + /// + private static Action Into(object owner, object box, Action? set, string path) + { + if (set is null) + { + throw new InvalidOperationException( + $"'{path}' is transformed by policy and passes through a struct held by a member with no " + + "setter, so the transformed value cannot replace the real one. Give the member a setter, or " + + "project into a type that has one."); + } + + return () => set(owner, box); + } + + /// Runs the write-backs a walk collected, innermost first. + /// + /// A struct inside a struct is written into its outer box before that box is written into what held + /// it, so the outermost write carries every change beneath it. + /// + private static void WriteBack(List? writeBacks) + { + if (writeBacks is null) + { + return; + } + + for (int i = writeBacks.Count - 1; i >= 0; i--) + { + writeBacks[i](); + } + } + /// /// One property of one runtime type, with its compiled accessors already resolved. /// @@ -371,10 +519,14 @@ private static IReadOnlyList ApplyUnwalked( Dictionary recorded = new(StringComparer.Ordinal); Dictionary read = new(StringComparer.Ordinal); + // Every struct the walk read into, in the order it was met, so the ones a transform changed are + // written back innermost first once the walk is done. + List carries = new(); + foreach (object root in roots) { seen.Add(root); - pending.Push(new Visit(root, entityType, 0, string.Empty)); + pending.Push(new Visit(root, entityType, 0, string.Empty, null)); } while (pending.Count > 0) @@ -396,7 +548,11 @@ private static IReadOnlyList ApplyUnwalked( bool carried = IsCarried(path, projected); - if (member.Transform is { } chain && carried && done.Add((visit.Node, member.Property.Name))) + // A struct is a fresh box on every read, so what the pass above did to it is not in done. + // A member the policy names on one was transformed there, along its path, and written back. + bool handled = visit.Carry is not null && named is not null; + + if (member.Transform is { } chain && carried && !handled && done.Add((visit.Node, member.Property.Name))) { if (member.Set is null) { @@ -410,6 +566,11 @@ private static IReadOnlyList ApplyUnwalked( chain, member.Get(visit.Node), member.Property.PropertyType, new DwTransformContext(visit.Node, path, context), options)); + if (visit.Carry is not null) + { + visit.Carry.Changed = true; + } + recorded[path] = chain; } @@ -430,16 +591,33 @@ private static IReadOnlyList ApplyUnwalked( ? null : AttributePolicyProvider.NavigationTypeOf(named.PropertyType); - foreach (object held in Held(member.Get(visit.Node))) + object? value = member.Get(visit.Node); + + foreach ((object held, int position) in Held(value)) { - if (seen.Add(held)) + if (!seen.Add(held)) { - pending.Push(new Visit(held, beneath, visit.Depth + 1, path)); + continue; } + + Carry? carry = null; + + if (held.GetType().IsValueType) + { + carry = new Carry(visit.Carry, path, WriteFor(visit.Node, member.Set, value, held, position)); + carries.Add(carry); + } + + pending.Push(new Visit(held, beneath, visit.Depth + 1, path, carry)); } } } + for (int i = carries.Count - 1; i >= 0; i--) + { + carries[i].Settle(); + } + foreach (KeyValuePair entry in recorded) { trace.Add(new PolicyDecision( @@ -463,8 +641,85 @@ private static IReadOnlyList ApplyUnwalked( return reads; } - /// One object of the result, where it stands, and the declared type the policy read it as. - private readonly record struct Visit(object Node, Type? Declared, int Depth, string Path); + /// + /// One object of the result, where it stands, the declared type the policy read it as, and, for a + /// struct, what writes it back. + /// + private readonly record struct Visit(object Node, Type? Declared, int Depth, string Path, Carry? Carry); + + /// + /// A struct the walk read into, changed or not, and the write that puts it back where it was read + /// from. + /// + /// + /// Written back only when something inside it changed, so a struct no transform touched is never + /// written, and one held where it cannot be written back fails only when a transform landed in it. + /// + private sealed class Carry + { + private readonly Carry? _outer; + private readonly string _path; + private readonly Action? _write; + + internal Carry(Carry? outer, string path, Action? write) + { + _outer = outer; + _path = path; + _write = write; + } + + /// True once a transform landed in this struct, or in one it holds. + internal bool Changed { get; set; } + + /// Writes the struct back when it changed, and marks the struct holding it as changed. + /// + /// Thrown when it changed and cannot be written back: the transformed value would be dropped with + /// the copy and the stored one emitted. + /// + internal void Settle() + { + if (!Changed) + { + return; + } + + if (_write is null) + { + throw new InvalidOperationException( + $"'{_path}' holds a struct whose member is transformed by policy, and it cannot be " + + "written back where it was read from, so the transformed value cannot replace the real " + + "one. Give the member a setter, and hold structs in a list or an array."); + } + + _write(); + + if (_outer is not null) + { + _outer.Changed = true; + } + } + } + + /// + /// The write that puts a struct read from a member back into it: into the member itself, or into the + /// position of the collection it was read from. Null when neither can be written. + /// + /// The object the member was read from. + /// The member's setter, or null when it has none. + /// What the member held. + /// The struct, in its box. + /// The struct's position in the collection the member held, or -1 for the member's own value. + private static Action? WriteFor(object owner, Action? set, object? value, object held, int position) + { + if (position < 0) + { + return set is null ? null : () => set(owner, held); + } + + return value is IList positions && !positions.IsReadOnly + ? () => positions[position] = held + : null; + } /// One readable member of a run-time type that transforms or is audited, or can lead to one. private sealed record Member( @@ -531,8 +786,11 @@ private static bool Reads(Type 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) + /// + /// The objects a value holds, each with its position in the collection it came from: itself, at -1, + /// or each element when it is a collection. + /// + private static IEnumerable<(object Held, int Position)> Held(object? value) { if (value is null or string) { @@ -541,11 +799,15 @@ private static IEnumerable Held(object? value) if (value is IEnumerable collection) { + int position = -1; + foreach (object? element in collection) { + position++; + if (element is not null and not string && !element.GetType().IsPrimitive) { - yield return element; + yield return (element, position); } } @@ -556,7 +818,7 @@ private static IEnumerable Held(object? value) } } - yield return value; + yield return (value, -1); } /// diff --git a/DynamicWhere.ex/Policies/Masking/MutatorCache.cs b/DynamicWhere.ex/Policies/Masking/MutatorCache.cs index 5c6ca5f..136ae3a 100644 --- a/DynamicWhere.ex/Policies/Masking/MutatorCache.cs +++ b/DynamicWhere.ex/Policies/Masking/MutatorCache.cs @@ -89,6 +89,12 @@ internal static bool Write(PropertyInfo property, object instance, object? value /// Compiles (instance, value) => ((T)instance).Property = (TValue)value, or null when /// the property cannot be written. /// + /// + /// A struct is written through the box it arrives in. A conversion unboxes a copy, and a property + /// set on the copy changed nothing the caller holds: a mask applied to a struct's member was + /// applied to a temporary and the row kept the stored value. reads + /// the box in place, so the write lands in it, and the walker writes the box back to its owner. + /// private static Action? BuildSetter(PropertyInfo property) { if (!property.CanWrite || property.SetMethod is null) @@ -99,7 +105,9 @@ internal static bool Write(PropertyInfo property, object instance, object? value ParameterExpression instance = Expression.Parameter(typeof(object), "instance"); ParameterExpression value = Expression.Parameter(typeof(object), "value"); - UnaryExpression typedInstance = Expression.Convert(instance, property.DeclaringType!); + Expression typedInstance = property.DeclaringType!.IsValueType + ? Expression.Unbox(instance, property.DeclaringType) + : Expression.Convert(instance, property.DeclaringType); UnaryExpression typedValue = Expression.Convert(value, property.PropertyType); BinaryExpression assign = Expression.Assign( diff --git a/DynamicWhere.ex/Policies/Resolution/AttributePolicyProvider.cs b/DynamicWhere.ex/Policies/Resolution/AttributePolicyProvider.cs index 9da9f59..5ba9668 100644 --- a/DynamicWhere.ex/Policies/Resolution/AttributePolicyProvider.cs +++ b/DynamicWhere.ex/Policies/Resolution/AttributePolicyProvider.cs @@ -2,6 +2,7 @@ using System.Collections.ObjectModel; using System.Reflection; using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Optimization.Cache.Source; using DynamicWhere.ex.Policies.Attributes; using DynamicWhere.ex.Policies.Context; using DynamicWhere.ex.Policies.DTOs; @@ -224,32 +225,102 @@ private static void Emit( } /// - /// The member whose policy decides a path that continues beneath it, or null when the path is one - /// the walk names or names nothing. + /// A path as the policy names it: without the Value a query writes after a nullable struct. + /// + /// The entity being queried. + /// A dotted path, as the path validator accepts it. + /// + /// A query reaches a member of an Iban? through the nullable, Iban.Value.Number, because + /// that is the member it compiles. The attribute walk looks through the nullable and names the same + /// member Iban.Number, and so do the type's transforms, audits and fragments. Looked up as the + /// query spells it, the path matched none of them: a [DwDenied] on the Iban, or on a + /// member of the struct, and a mask on one, did not reach it. So a Value after a nullable + /// struct of the application's is dropped, before a member of it or at the end, where it names the + /// struct whole. HasValue stays: it is the nullable's own, and reads it + /// as the nullable member. A framework nullable, Salary.Value, is left as it is. + /// + internal static string PolicyPath(Type entityType, string path) + { + if (path.IndexOf('.') < 0 || path.IndexOf(nameof(Nullable.Value), StringComparison.OrdinalIgnoreCase) < 0) + { + return path; + } + + string[] segments = path.Split('.'); + List? kept = null; + Type type = entityType; + + for (int i = 0; i < segments.Length; i++) + { + Type? underlying = Nullable.GetUnderlyingType(type); + + if (i > 0 + && underlying is not null + && NavigationTypeOf(underlying) == underlying + && string.Equals(segments[i], nameof(Nullable.Value), StringComparison.OrdinalIgnoreCase)) + { + kept ??= segments.Take(i).ToList(); + type = underlying; + + continue; + } + + kept?.Add(segments[i]); + + PropertyInfo? property = Find(underlying ?? type, segments[i]) ?? Find(type, segments[i]); + + if (property is null) + { + return kept is null ? path : string.Join('.', kept.Concat(segments.Skip(i + 1))); + } + + Type next = property.PropertyType; + type = CacheReflection.GetCollectionElementType(next) ?? next; + } + + return kept is null ? path : string.Join('.', kept); + } + + /// + /// The members whose policy decides a path that continues beneath them, outermost first, and the one + /// among them the path reads, when the path goes beneath a member the framework declares. /// /// 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. + /// Two kinds of member decide the paths beneath them. + /// + /// + /// A member the framework declares the type of, where no attribute can be placed beneath it: + /// 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 path reads that member, which is + /// Reads, and nothing beneath it is walked. + /// + /// + /// A member holding an application's own struct, or a collection of them (3.4.0). A struct is a + /// value, not a navigation: Iban.Number is part of the Iban, and a + /// [DwDenied] on the Iban that left Iban.Number to be filtered on and + /// selected was a sealed field one segment away from having no policy at all. Its members are + /// still walked, since they can carry attributes of their own, and both decide. + /// + /// + /// A class is still a navigation, whose members are separate fields: a denial of Customer does + /// not deny Customer.Name. A member only a subtype declares is decided by the fragments naming + /// it, so that a grant of Zone does not grant what a subtype of Zone declares. /// - internal static string? Governing(Type entityType, string path) + internal static (IReadOnlyList Above, string? Reads) Governing(Type entityType, string path) { // Asked of every path a query resolves, and nearly every one of them is a single member. if (path.IndexOf('.') < 0) { - return null; + return (Array.Empty(), null); } string[] segments = path.Split('.'); Type type = entityType; + List? above = null; for (int i = 0; i < segments.Length - 1; i++) { @@ -257,33 +328,50 @@ private static void Emit( if (property is null) { - return null; + break; } Type? navigation = NavigationTypeOf(property.PropertyType); if (navigation is null) { - return string.Join('.', segments, 0, i + 1); + string reads = string.Join('.', segments, 0, i + 1); + + (above ??= new List()).Add(reads); + + return (above, reads); } if (Find(navigation, segments[i + 1]) is null) { // 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. + // collection class, reads the member above it, and so does a nullable's own, HasValue on + // an Iban?. Anything else is a subtype's member or names nothing, and is decided as it + // always was: by the fragments naming it. Type container = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + bool nullables = container != property.PropertyType && Find(property.PropertyType, segments[i + 1]) is not null; + + if (nullables || (container != navigation && Find(container, segments[i + 1]) is not null)) + { + string reads = string.Join('.', segments, 0, i + 1); + + (above ??= new List()).Add(reads); + + return (above, reads); + } - return container != navigation && Find(container, segments[i + 1]) is not null - ? string.Join('.', segments, 0, i + 1) - : null; + break; + } + + if (navigation.IsValueType) + { + (above ??= new List()).Add(string.Join('.', segments, 0, i + 1)); } type = navigation; } - return null; + return (above ?? (IReadOnlyList)Array.Empty(), null); } /// diff --git a/DynamicWhere.ex/Policies/Resolution/PolicyResolver.cs b/DynamicWhere.ex/Policies/Resolution/PolicyResolver.cs index 7f3c395..61be46a 100644 --- a/DynamicWhere.ex/Policies/Resolution/PolicyResolver.cs +++ b/DynamicWhere.ex/Policies/Resolution/PolicyResolver.cs @@ -105,8 +105,11 @@ 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, List named, bool beneath, bool past) = - Candidates(entityType, path, context); + // As the policy names it: Iban.Value.Number is the member the attribute walk calls Iban.Number. + path = AttributePolicyProvider.PolicyPath(entityType, path); + + (List candidates, List named, List own, + List above, bool beneath, bool past) = Candidates(entityType, path, context); Dictionary effects = new(); List sources = new(); @@ -152,9 +155,11 @@ public FieldPolicy Resolve(Type entityType, string fieldPath, DwPolicyContext co // A transform is applied to a member, by the outbound walk, which reads the type's transforms by // the paths the attribute walk names. Two kinds of path stand outside that list. // - // One continues beneath the member: Bonus.Value is a decimal where Bonus is what is rounded, - // so there is no member to apply the chain to, and every way the path hands a value back would - // hand back the stored one. Select, Group and Aggregate are refused. + // One continues beneath a member that is transformed: Bonus.Value is a decimal where Bonus is + // what is rounded, and Price.Amount is part of a Price struct that is masked whole, so the chain + // has no member of the path's to apply to, and every way the path hands a value back would hand + // back the stored one. Select, Group and Aggregate are refused. A chain the path's own member + // declares, on Price.Amount itself, is applied to that member as any other is. // // The other is the member itself, past the walk's depth. A row carries it as a member, so the // outbound walk transforms it there as it does anywhere else, and Select stays what the @@ -162,15 +167,20 @@ public FieldPolicy Resolve(Type entityType, string fieldPath, DwPolicyContext co // summary's own transform finds by the type's list and would not find: those two are refused. // Filtering and ordering run on stored values wherever a member is reached from, so they // follow the member's own decision on both kinds of path. - if ((beneath || past) && transform is not null && !transform.IsEmpty) + ValueTransform? over = beneath ? ElectTransform(above) : null; + ValueTransform? mine = ElectTransform(own); + + if (over is { IsEmpty: false }) { effects[PolicyFeature.Group] = PolicyEffect.Deny; effects[PolicyFeature.Aggregate] = PolicyEffect.Deny; + effects[PolicyFeature.Select] = PolicyEffect.Deny; + } - if (beneath) - { - effects[PolicyFeature.Select] = PolicyEffect.Deny; - } + if (past && mine is { IsEmpty: false }) + { + 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 @@ -185,40 +195,45 @@ public FieldPolicy Resolve(Type entityType, string fieldPath, DwPolicyContext co ElectAlias(named), CollectForced(named), ElectRequired(named), - beneath ? ElectTransform(named) : transform, - beneath ? Carried(ElectFacts(named), candidates) : ElectFacts(candidates)); + beneath ? mine : transform, + beneath ? Carried(ElectFacts(own), 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 fragments that decide one path: those naming it, and those of every member above it whose + /// policy decides what is beneath it. /// /// The entity being queried. /// The normalized path. /// The caller. /// - /// Every fragment that decides the path; the ones naming it exactly, a "*" rule included; - /// whether the path continues beneath a member it reads; and whether the attributes of a member - /// past the walk's depth were read for it. + /// Every fragment that decides the path; the ones naming it exactly, a "*" rule included; the + /// path's own, which adds what its member declares past the walk's depth; those of the members above + /// it; whether the path continues beneath such a member; and whether the attributes of a member past + /// the walk's depth were read for it. /// /// - /// A fragment that does not match is a field left allowed, and two kinds of path matched nothing + /// A fragment that does not match is a field left allowed, and three 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, by a resolver that reads attributes - /// at all. The first is about which fragments match and not where they came from, so it holds for - /// every resolver. + /// Salary covers Salary.Value as an attribute's does. One continues beneath an + /// application's own struct, Iban.Number, which is part of the value above it: every fragment + /// of that member applies too, beside the path's own (3.4.0). The last 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, and of each struct it passes through, are read directly, by + /// a resolver that reads attributes at all. The first two are about which fragments match and not + /// where they came from, so they hold for every resolver. /// - private (List All, List Named, bool Beneath, bool Past) Candidates( + private (List All, List Named, List Own, + List Above, bool Beneath, bool Past) Candidates( Type entityType, string path, DwPolicyContext context) { - string? governing = AttributePolicyProvider.Governing(entityType, path); + (IReadOnlyList governing, string? reads) = AttributePolicyProvider.Governing(entityType, path); List all = new(); List named = new(); + List above = new(); foreach (PolicyFragment fragment in Sweep(entityType, context)) { @@ -227,25 +242,55 @@ public FieldPolicy Resolve(Type entityType, string fieldPath, DwPolicyContext co all.Add(fragment); named.Add(fragment); } - else if (governing is not null && fragment.Matches(governing)) + else if (MatchesAny(fragment, governing)) { all.Add(fragment); + above.Add(fragment); } } + List own = new(named); bool past = false; if (ReadsAttributes) { // Of the member the path reads, where there is one: B.C.D.E.Born.Year is decided by Born. - IReadOnlyList declared = AttributePolicyProvider.Unwalked(entityType, governing ?? path); + IReadOnlyList declared = AttributePolicyProvider.Unwalked(entityType, reads ?? path); past = declared.Count > 0; all.AddRange(declared); + (reads is null ? own : above).AddRange(declared); + + // And of every struct the path passes through past the walk's depth: A.B.C.D.Price.Amount is + // part of Price as much as Price.Amount is. + foreach (string value in governing) + { + if (!string.Equals(value, reads, StringComparison.Ordinal)) + { + IReadOnlyList held = AttributePolicyProvider.Unwalked(entityType, value); + + all.AddRange(held); + above.AddRange(held); + } + } } - return (all, named, governing is not null, past); + return (all, named, own, above, governing.Count > 0, past); + } + + /// True when a fragment names one of the paths given. + private static bool MatchesAny(PolicyFragment fragment, IReadOnlyList paths) + { + for (int i = 0; i < paths.Count; i++) + { + if (fragment.Matches(paths[i])) + { + return true; + } + } + + return false; } /// True when attributes are among the sources this resolver was built over. @@ -315,7 +360,7 @@ public PolicyExplanation Explain(Type entityType, string fieldPath, DwPolicyCont { FieldPolicy policy = Resolve(entityType, fieldPath, context); - string path = PolicyFragment.NormalizePath(fieldPath); + string path = AttributePolicyProvider.PolicyPath(entityType, PolicyFragment.NormalizePath(fieldPath)); // 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. diff --git a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs index d6633d7..e45496a 100644 --- a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs +++ b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs @@ -130,7 +130,7 @@ internal static Filter Sanitize( // After the caps, so a page the caller did write is still refused when it is too large, // and a page the caller did not write is bounded rather than unbounded. - working.Page = DefaultPage(working.Page, options); + working.Page = DefaultPage(working.Page, options, context); long cost = Cost(working, gate); @@ -248,7 +248,7 @@ internal static Summary Sanitize( // After the caps, so a page the caller did write is still refused when it is too large, // and a page the caller did not write is bounded rather than unbounded. - working.Page = DefaultPage(working.Page, options); + working.Page = DefaultPage(working.Page, options, context); long cost = Cost(working, gate); @@ -291,7 +291,7 @@ internal static Summary Sanitize( try { GroupFloor.Inject( - working, GroupFloor.For(working, gate.TypePolicy, gate.Options), gate.IsDryRun, trace); + working, GroupFloor.For(working, typeof(T), gate.TypePolicy, gate.Options), gate.IsDryRun, trace); } catch (ArgumentException taken) { @@ -728,7 +728,7 @@ internal static Segment Sanitize( // After the caps, so a page the caller did write is still refused when it is too large, // and a page the caller did not write is bounded rather than unbounded. - working.Page = DefaultPage(working.Page, options); + working.Page = DefaultPage(working.Page, options, context); long cost = Cost(working, gate); @@ -1101,10 +1101,16 @@ private static void EnforceNavigationDepth(Segment segment, Gate gate) /// Bounded by MaxPageSize, so the two cannot be configured into contradicting each other: /// a default above the maximum would hand out a page the same query could not have asked for. /// + /// + /// Both are the caps of the purpose the context declares, where has + /// any for it, and the deployment's otherwise. + /// /// - private static PageBy? DefaultPage(PageBy? page, DwPolicyOptions options) + private static PageBy? DefaultPage(PageBy? page, DwPolicyOptions options, DwPolicyContext context) { - if (page is not null || options.Caps.DefaultPageSize == 0) + int size = options.Caps.DefaultPageSizeFor(context.Purpose); + + if (page is not null || size == 0) { return page; } @@ -1112,7 +1118,7 @@ private static void EnforceNavigationDepth(Segment segment, Gate gate) return new PageBy { PageNumber = 1, - PageSize = Math.Min(options.Caps.DefaultPageSize, options.Caps.MaxPageSize) + PageSize = Math.Min(size, options.Caps.MaxPageSizeFor(context.Purpose)) }; } @@ -1818,8 +1824,9 @@ void Keep(string path) /// Gates the key of every nested node a path passes through, and keeps the ones that survive. /// /// - /// The projection builder adds the key of each nested node whether the caller named it or not, - /// so the key is in the result whatever this decides. Naming it here is what lets the outbound + /// The projection builder adds the key of each nested class it builds whether the caller named it + /// or not, so the key is in the result whatever this decides. A struct's is never added, and is + /// neither kept nor gated here (see Gate.HasKey). Naming it here is what lets the outbound /// transform see it as carried — without that a masked nested key leaves unmasked, because the /// walker matches a carried path against the projection and the caller never wrote this one. /// @@ -2126,7 +2133,7 @@ private static void SynthesizeSelects(Filter filter, Gate gate, RowShape rows) /// /// A field whose type can hold what the policy cannot name, a framework collection of a policed /// type or a member typed , is left out of the narrowing: the core projects it - /// whole. The builder adds the key of every node it narrows, so a denied key leaves the member out, + /// whole. The builder adds the key of every class it narrows, so a denied key leaves the member out, /// and so does a path the core cannot project. /// private static string? Narrowed(string member, List survivors, Gate gate, RowShape rows, List allowed) @@ -3059,11 +3066,18 @@ internal bool IsNavigation(string path) /// /// True when the type a path names carries a key the projection builder will add. /// + /// + /// The builder adds the key of a class it builds and never of a struct: a struct is built with + /// exactly what was named, in memory and on EF Core, typed and dynamic. Read as a class, a struct + /// holding an Id had that key added to every selection through it, so a caller naming + /// DeniedBy.Value.Why received Id beside it, and one whose Id is denied had the + /// selection of every other member refused for a key no builder would have carried. + /// internal bool HasKey(string path) { Type? type = TypeAt(path); - return type is not null && CacheReflection.FindProperty(type, "Id") is not null; + return type is not null && !type.IsValueType && CacheReflection.FindProperty(type, "Id") is not null; } /// @@ -3256,6 +3270,11 @@ private static bool Resolves(Type root, string path) /// construct, an interface, an abstract class or one without a public parameterless constructor, /// which it skips; null when it can build them all. /// + /// + /// An application's own struct needs no constructor: since 3.4.0 the typed projection builds one + /// member by member, held directly or in a list or an array, as it builds a class. A nullable one, + /// or one in a collection of another shape, it leaves unbound, so those still count as unbuilt. + /// internal Type? Unbuildable(string member, IEnumerable paths) { HashSet nodes = new(StringComparer.OrdinalIgnoreCase) { member }; @@ -3272,6 +3291,7 @@ private static bool Resolves(Type root, string path) { if (DeclaredType(node) is { } declared && AttributePolicyProvider.NavigationTypeOf(declared) is { } type + && !BuildsStruct(declared, type) && (type.IsAbstract || type.GetConstructor(Type.EmptyTypes) is null)) { return type; @@ -3281,6 +3301,14 @@ private static bool Resolves(Type root, string path) return null; } + /// True when the typed projection builds this struct member by member where it is declared. + private static bool BuildsStruct(Type declared, Type type) => + type.IsValueType + && (declared == type + || declared == type.MakeArrayType() + || (CacheReflection.GetCollectionElementType(declared) == type + && declared.IsAssignableFrom(typeof(List<>).MakeGenericType(type)))); + /// Every fragment the providers hold for this type and caller, read once per query. private IReadOnlyList Fragments => _fragments ??= _resolver.Fragments(_entityType, _context); @@ -3813,8 +3841,10 @@ internal readonly record struct TypeFacts(bool DeniesSelect, bool HoldsObject, b { string node = path[..cut]; + // A struct is built with what was named and no key beside it (see HasKey). if (!nodes.Add(node) || TypeAt(_entityType, node) is not { } type + || type.IsValueType || CacheReflection.FindProperty(type, "Id") is not { } key) { continue; @@ -3975,12 +4005,14 @@ internal void CheckOrderCount(int count) } } - /// Refuses a page larger than the budget allows. + /// Refuses a page larger than the budget allows, the declared purpose's where it has one. internal void CheckPage(PageBy? page) { - if (page is not null && page.PageSize > _options.Caps.MaxPageSize) + int limit = _options.Caps.MaxPageSizeFor(_context.Purpose); + + if (page is not null && page.PageSize > limit) { - Raise(Cap("MaxPageSize", _options.Caps.MaxPageSize, page.PageSize, WholeClause)); + Raise(Cap("MaxPageSize", limit, page.PageSize, WholeClause)); } } diff --git a/DynamicWhere.ex/Policies/Source/GroupFloor.cs b/DynamicWhere.ex/Policies/Source/GroupFloor.cs index 417af16..9644721 100644 --- a/DynamicWhere.ex/Policies/Source/GroupFloor.cs +++ b/DynamicWhere.ex/Policies/Source/GroupFloor.cs @@ -35,6 +35,7 @@ internal static class GroupFloor /// The smallest group this summary may report. /// /// The summary, after grouping has been canonicalized. + /// The type the summary is over, which says how the policy names each field. /// The type's policy, which carries each field's own floor. /// The posture, which carries the global floor. /// The effective floor, or one when nothing sets one. @@ -47,7 +48,7 @@ internal static class GroupFloor /// transforms sets no floor of its own and leaves the global setting to decide. /// /// - internal static int For(Summary summary, TypePolicy policy, DwPolicyOptions options) + internal static int For(Summary summary, Type entityType, TypePolicy policy, DwPolicyOptions options) { int floor = options.Caps.MinGroupSize; @@ -63,7 +64,8 @@ internal static int For(Summary summary, TypePolicy policy, DwPolicyOptions opti continue; } - if (policy.Transforms.TryGetValue(aggregate.Field!, out ValueTransform? transform) + if (policy.Transforms.TryGetValue( + Resolution.AttributePolicyProvider.PolicyPath(entityType, aggregate.Field!), out ValueTransform? transform) && transform.MinGroupSize > floor) { floor = transform.MinGroupSize; diff --git a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs index 4c703cf..9ee45fd 100644 --- a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs +++ b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs @@ -180,8 +180,11 @@ private void RecordUnnamed(Masking.GraphWalker.UnnamedRead read, PolicyTrace tra Dictionary? found = null; - foreach (string path in projected) + foreach (string spelled in projected) { + // Keyed as the policy names the path, which is how the outbound walk navigates the rows. + string path = AttributePolicyProvider.PolicyPath(typeof(T), spelled); + if (path.Count(character => character == '.') < AttributePolicyProvider.MaxDepth || TypePolicy.Transforms.ContainsKey(path)) { @@ -411,12 +414,12 @@ public SummaryResult ToList(Summary summary, bool getQueryString = false) // // It reads a column this library added and nothing has transformed, so running first // costs it nothing. - int floor = GroupFloor.For(sanitized, TypePolicy, _options); + int floor = GroupFloor.For(sanitized, typeof(T), TypePolicy, _options); ResultTransformer.Suppress( result, floor, _options.DryRun || _context.DryRun, trace); - ResultTransformer.Summary(result, sanitized, TypePolicy, _context, _options, trace); + ResultTransformer.Summary(result, sanitized, typeof(T), TypePolicy, _context, _options, trace); // After the collision check, which reads the real column names. A summary key is a // generated column like any other, so it follows the same vocabulary the schema @@ -480,12 +483,12 @@ public async Task ToListAsync( // // It reads a column this library added and nothing has transformed, so running first // costs it nothing. - int floor = GroupFloor.For(sanitized, TypePolicy, _options); + int floor = GroupFloor.For(sanitized, typeof(T), TypePolicy, _options); ResultTransformer.Suppress( result, floor, _options.DryRun || _context.DryRun, trace); - ResultTransformer.Summary(result, sanitized, TypePolicy, _context, _options, trace); + ResultTransformer.Summary(result, sanitized, typeof(T), TypePolicy, _context, _options, trace); // After the collision check, which reads the real column names. A summary key is a // generated column like any other, so it follows the same vocabulary the schema diff --git a/DynamicWhere.ex/Policies/Source/ResultTransformer.cs b/DynamicWhere.ex/Policies/Source/ResultTransformer.cs index 44e712a..b94db4a 100644 --- a/DynamicWhere.ex/Policies/Source/ResultTransformer.cs +++ b/DynamicWhere.ex/Policies/Source/ResultTransformer.cs @@ -310,6 +310,7 @@ size is null internal static void Summary( SummaryResult result, Summary summary, + Type entityType, TypePolicy policy, DwPolicyContext context, DwPolicyOptions options, @@ -320,8 +321,8 @@ internal static void Summary( return; } - List keys = KeyColumns(summary, policy); - List aggregates = AggregateColumns(summary, policy); + List keys = KeyColumns(summary, entityType, policy); + List aggregates = AggregateColumns(summary, entityType, policy); if (keys.Count == 0 && aggregates.Count == 0) { @@ -347,7 +348,7 @@ internal static void Summary( /// 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) + private static List KeyColumns(Summary summary, Type entityType, TypePolicy policy) { List columns = new(); @@ -358,7 +359,7 @@ private static List KeyColumns(Summary summary, TypePolicy policy) foreach (string field in summary.GroupBy.Fields) { - if (policy.Transforms.TryGetValue(field, out ValueTransform? chain)) + if (policy.Transforms.TryGetValue(Resolution.AttributePolicyProvider.PolicyPath(entityType, field), out ValueTransform? chain)) { columns.Add(new Column(field.Replace(".", string.Empty), field, chain)); } @@ -375,7 +376,7 @@ private static List KeyColumns(Summary summary, TypePolicy policy) /// 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) + private static List AggregateColumns(Summary summary, Type entityType, TypePolicy policy) { List columns = new(); @@ -388,7 +389,7 @@ private static List AggregateColumns(Summary summary, TypePolicy policy) { if (!string.IsNullOrWhiteSpace(aggregate.Field) && !string.IsNullOrWhiteSpace(aggregate.Alias) - && policy.Transforms.TryGetValue(aggregate.Field!, out ValueTransform? chain)) + && policy.Transforms.TryGetValue(Resolution.AttributePolicyProvider.PolicyPath(entityType, aggregate.Field!), out ValueTransform? chain)) { columns.Add(new Column(aggregate.Alias!, aggregate.Field!, chain)); } diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs index 5fb45d6..ad557ed 100644 --- a/DynamicWhere.ex/Policies/Source/RowShape.cs +++ b/DynamicWhere.ex/Policies/Source/RowShape.cs @@ -4,6 +4,7 @@ using System.Reflection; using System.Runtime.CompilerServices; using DynamicWhere.ex.Optimization.Cache.Source; +using DynamicWhere.ex.Policies.Resolution; using DynamicWhere.ex.Source; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -61,6 +62,9 @@ internal sealed class RowShape /// The complex properties of each entity type, read once per entity type. private static readonly ConditionalWeakTable> ComplexByType = new(); + /// The types a model stores as a column somewhere, read once per model. + private static readonly ConditionalWeakTable> ColumnTypesByModel = new(); + private readonly HashSet? _assigned; // What a projection's initializer assigns beneath a member, by the member's path from the row. @@ -72,6 +76,13 @@ 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; + // A member a projection builds by calling an application type's constructor with arguments, by the + // member's path from the row ("" for the row itself), with the members an initializer beside the + // constructor binds. "Name" -> { } for Name = new LocalizedText(x.NameAr, x.NameEn). EF Core follows + // a member only through a binding, so a path through any other member of what the constructor + // builds is one no translated clause can reach, whatever else builds the same member. + private readonly Dictionary>? _constructed; + // 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". @@ -100,13 +111,15 @@ private RowShape( Dictionary>? initialized = null, Dictionary? copied = null, bool translated = false, - HashSet? opaque = null) + HashSet? opaque = null, + Dictionary>? constructed = null) : this(kind) { _assigned = assigned; _initialized = initialized; _copied = copied; _opaque = opaque; + _constructed = constructed; _translated = translated; _narrowable.UnionWith(narrowable); Built = built; @@ -300,11 +313,14 @@ internal bool Materializes(string path) /// /// False is only ever returned where the shape knows the whole set of members a container can /// 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; 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. + /// projection itself — or where it knows EF Core cannot follow a member at all: through an + /// application type's constructor called with arguments, new LocalizedText(x.NameAr, x.NameEn), + /// which the row carries and no clause reaches. 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; 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 @@ -348,6 +364,29 @@ internal bool Materializes(string path) return null; } + if (_constructed is { Count: > 0 }) + { + // Asked first, at every level from the row down: whatever else builds the member, a + // constructor building it in one place is where EF Core stops. + for (int i = 0; i < segments.Length; i++) + { + string level = string.Join(".", segments, 0, i); + + if (_constructed.TryGetValue(level, out HashSet? bound) && !bound.Contains(segments[i])) + { + return false; + } + } + + // The member itself as well: EF Core can neither compare nor order a value it would have to + // build on the client, so ORDER BY Name over new LocalizedText(...) fails in the provider as + // Name.Ar does. + if (_constructed.ContainsKey(string.Join(".", segments))) + { + return false; + } + } + if (_opaque is not null && _opaque.Contains(string.Join(".", segments))) { // The initializer assigns the member from something this shape cannot read, so it says @@ -795,20 +834,28 @@ private static RowShape Projected(IQueryable source) where T : class Expression body = DefaultOrder.StripConversions(selector.Body); Dictionary built = new(StringComparer.OrdinalIgnoreCase); + ParameterExpression row = selector.Parameters[0]; + bool efCore = source.Provider is IAsyncQueryProvider; + IEntityType? rowSource = efCore ? QueryRoot.EntityType(source.Expression, row.Type) : null; + Dictionary> constructed = new(StringComparer.OrdinalIgnoreCase); + if (body is not MemberInitExpression initializer) { // A constructor with arguments says nothing about which member each argument sets: every - // member counts as assigned, and none can be narrowed. + // member counts as assigned, and none can be narrowed. Nor can EF Core follow one to the + // argument that set it, so none of them is a value a clause can compute: a row built as + // new TenantRow(t.Code, t.NameAr) is filtered on nothing. + if (body is NewExpression created && Constructs(created, rowSource)) + { + RecordConstructed(constructed, string.Empty, Array.Empty()); + } + return new RowShape( RowKind.Projected, null, Array.Empty(), body.Type, built, - translated: EfCoreOwns(source.Provider)); + translated: EfCoreOwns(source.Provider), constructed: constructed); } List narrowable = new(); - - ParameterExpression row = selector.Parameters[0]; - bool efCore = source.Provider is IAsyncQueryProvider; - IEntityType? rowSource = efCore ? QueryRoot.EntityType(source.Expression, row.Type) : null; HashSet? assigned = initializer.NewExpression.Arguments.Count > 0 ? null : new HashSet(StringComparer.OrdinalIgnoreCase); @@ -817,7 +864,7 @@ private static RowShape Projected(IQueryable source) where T : class Dictionary copied = new(StringComparer.OrdinalIgnoreCase); HashSet opaque = new(StringComparer.OrdinalIgnoreCase); - ReadAssignments(initializer, string.Empty, row, rowSource, initialized, copied, opaque, depth: 0); + ReadAssignments(initializer, string.Empty, row, rowSource, initialized, copied, opaque, constructed, depth: 0); Forget(initialized, copied, opaque); foreach (MemberBinding binding in initializer.Bindings) @@ -845,7 +892,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), opaque); + EfCoreOwns(source.Provider), opaque, constructed); } /// @@ -858,7 +905,9 @@ private static RowShape Projected(IQueryable source) where T : class /// 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. + /// it builds is left unrecorded rather than recorded as incomplete. What it does say is where EF + /// Core stops: a member the initializer binds is one it follows, and any other member of an + /// application type built that way is recorded as one no clause reaches. /// /// private static void ReadAssignments( @@ -869,6 +918,7 @@ private static void ReadAssignments( Dictionary> initialized, Dictionary copied, HashSet opaque, + Dictionary> constructed, int depth) { if (depth > MaxComplexDepth) @@ -889,7 +939,7 @@ private static void ReadAssignments( string path = prefix.Length == 0 ? binding.Member.Name : $"{prefix}.{binding.Member.Name}"; - if (!ReadValue(assignment.Expression, path, row, rowSource, initialized, copied, opaque, depth)) + if (!ReadValue(assignment.Expression, path, row, rowSource, initialized, copied, opaque, constructed, depth)) { opaque.Add(path); } @@ -899,6 +949,10 @@ private static void ReadAssignments( { Record(initialized, prefix, members); } + else if (Constructs(initializer.NewExpression, rowSource)) + { + RecordConstructed(constructed, prefix, members); + } } /// @@ -922,6 +976,7 @@ private static bool ReadValue( Dictionary> initialized, Dictionary copied, HashSet opaque, + Dictionary> constructed, int depth) { Expression assigned = DefaultOrder.StripConversions(value); @@ -929,7 +984,7 @@ private static bool ReadValue( switch (assigned) { case MemberInitExpression nested: - ReadAssignments(nested, path, row, rowSource, initialized, copied, opaque, depth + 1); + ReadAssignments(nested, path, row, rowSource, initialized, copied, opaque, constructed, depth + 1); return true; @@ -938,6 +993,15 @@ private static bool ReadValue( return true; + case NewExpression created when Constructs(created, rowSource): + // Name = new LocalizedText(x.NameAr, x.NameEn). The row carries both halves and EF Core + // can reach neither: it follows a member through an initializer's binding, never + // through a constructor's argument. new LocalizedText { Ar = x.NameAr } is the form + // it follows. + RecordConstructed(constructed, 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. @@ -946,8 +1010,8 @@ private static bool ReadValue( 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); + return ReadValue(choice.IfTrue, path, row, rowSource, initialized, copied, opaque, constructed, depth) + & ReadValue(choice.IfFalse, path, row, rowSource, initialized, copied, opaque, constructed, depth); default: if (rowSource is null || MemberChain(assigned, row, operators: false) is not { } chain) @@ -982,6 +1046,77 @@ private static void Record(Dictionary> initialized, stri initialized[path] = new HashSet(members, StringComparer.OrdinalIgnoreCase); } + /// + /// Records a level a constructor builds, with the members an initializer beside it binds, kept to + /// those every constructing branch of the member binds. + /// + /// + /// Intersected where unions. EF Core reads a member of a conditional through + /// both branches, so a member one constructing branch leaves to its constructor is out of reach + /// however the other branch sets it. + /// + private static void RecordConstructed( + Dictionary> constructed, string path, IEnumerable bound) + { + if (constructed.TryGetValue(path, out HashSet? already)) + { + already.IntersectWith(bound); + + return; + } + + constructed[path] = new HashSet(bound, StringComparer.OrdinalIgnoreCase); + } + + /// + /// True when a projection builds a value by calling an application type's constructor with + /// arguments, in a query over a model that never stores that type as a column. + /// + /// + /// Each condition is a way the answer could be wrong, and each is kept out: + /// + /// + /// With set, as an anonymous type's constructor has, EF Core + /// does follow each member to its argument. + /// + /// + /// The framework's own types, new DateTime(y, m, 1), are ones a provider translates, + /// constructor and members alike; so is a collection, which is not a value with members. + /// + /// + /// A type the model stores as a column somewhere, a spatial point or a range, has a mapping the + /// provider may translate a constructor of. Only a type the model never stores is certainly + /// one only C# builds. + /// + /// + /// Without the entity the projection reads, there is no model to ask, and nothing is said. + /// + /// + /// + private static bool Constructs(NewExpression created, IEntityType? rowSource) => + created.Arguments.Count > 0 + && created.Members is null + && rowSource is not null + && AttributePolicyProvider.NavigationTypeOf(created.Type) == created.Type + && !ColumnTypes(rowSource.Model).Contains(created.Type); + + /// Every type the model stores as a column, underlying types of nullable ones included. + private static HashSet ColumnTypes(IModel model) => + ColumnTypesByModel.GetValue(model, static read => + { + HashSet types = new(); + + foreach (IEntityType entity in read.GetEntityTypes()) + { + foreach (IProperty property in entity.GetProperties()) + { + types.Add(Nullable.GetUnderlyingType(property.ClrType) ?? property.ClrType); + } + } + + return types; + }); + /// /// 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. diff --git a/DynamicWhere.ex/Source/Converter.cs b/DynamicWhere.ex/Source/Converter.cs index 61269b5..a590225 100644 --- a/DynamicWhere.ex/Source/Converter.cs +++ b/DynamicWhere.ex/Source/Converter.cs @@ -106,6 +106,12 @@ private static MemberInitExpression BuildTypedMemberInitExpression(Type type, Ex var navType = navProp.PropertyType; if (navType == typeof(string) || navType.IsValueType) { + // A path beneath a struct, a string or a date: ValueMembers decides what the row carries. + if (ValueMembers.Bind(navProp, instance, childNode, BuildTypedMemberInitExpression) is { } bound) + { + bindings.Add(bound); + } + continue; } @@ -120,7 +126,12 @@ private static MemberInitExpression BuildTypedMemberInitExpression(Type type, Ex // Scalar collections (e.g., List) can be bound directly. if (elementType.IsValueType || elementType == typeof(string)) { - bindings.Add(Expression.Bind(navProp, Expression.Property(instance, navProp))); + // Scalars whole, and a collection of structs element by element: ValueMembers decides. + if (ValueMembers.BindElements(navProp, instance, childNode, elementType, BuildTypedMemberInitExpression) is { } elements) + { + bindings.Add(elements); + } + continue; } diff --git a/DynamicWhere.ex/Source/ValueMembers.cs b/DynamicWhere.ex/Source/ValueMembers.cs new file mode 100644 index 0000000..3b07556 --- /dev/null +++ b/DynamicWhere.ex/Source/ValueMembers.cs @@ -0,0 +1,211 @@ +using System.Linq.Expressions; +using System.Reflection; +using DynamicWhere.ex.Optimization.Cache.Source; +using DynamicWhere.ex.Policies.Resolution; + +namespace DynamicWhere.ex.Source; + +/// +/// What a typed projection binds to a member whose type is a struct, or a collection of values, when the +/// selection names a path beneath it. +/// +/// +/// The typed projection builds a class navigation member by member and used to skip every +/// value-typed member a path went beneath. The member was never bound, so it came back as its +/// default with nothing refused and nothing said: Name.Ar over a LocalizedText struct +/// returned an empty name, guarded or not. +/// +/// +/// An application's own struct is now built member by member, as a class navigation is, so the +/// row carries what was named and nothing beside it. A one is read +/// through Value, Alias.Value.Ar, and built where it has a value; naming +/// Alias.Value whole, or only Alias.HasValue, leaves it unbound as before. +/// +/// +/// A framework type stays unbound. Born.Year names a value no typed row can hold apart +/// from the date, and binding the date whole would hand back more than the path names: a policy +/// allowing Born.Year alone would return the day of birth. The dynamic terminal carries +/// such a path as it names it. +/// +/// +/// The member is read with a plain member access rather than through EF.Property: a struct is +/// not a navigation, and a member access is what EF Core follows through a projection's initializer +/// and evaluates on the client where it cannot. +/// +internal static class ValueMembers +{ + /// + /// The binding for a struct-typed member the selection goes beneath, or null to leave the member + /// unbound. + /// + /// The member, already known to be writable. + /// The expression the member is read from. + /// What the selection names beneath the member. + /// Builds one level of the projection, as the typed projection does. + internal static MemberAssignment? Bind( + PropertyInfo member, + Expression instance, + ProjectionNode node, + Func build) + { + Type type = member.PropertyType; + Type? wrapped = Nullable.GetUnderlyingType(type); + Type underlying = wrapped ?? type; + + // An application's own struct and nothing else: not a framework type, not a string, and not a + // struct that is itself a collection. + if (!underlying.IsValueType || AttributePolicyProvider.NavigationTypeOf(underlying) != underlying) + { + return null; + } + + Expression access = Expression.Property(instance, member); + + if (wrapped is null) + { + MemberInitExpression whole = build(underlying, access, Within(underlying, node)); + + // Nothing named could be set: a read-only member, or only a class navigation. A struct built + // empty is a constant EF Core refuses to hold in a projection, so it stays unbound as before. + return whole.Bindings.Count == 0 ? null : Expression.Bind(member, whole); + } + + // A nullable struct is named through Value: Alias.Value.Ar. Naming Value itself asks for the + // whole struct, and HasValue alone for none of it; both stay unbound as before. The policy names + // the struct's members, not Value's, and a struct built empty is a constant EF Core refuses to + // hold in a projection. + if (node.Scalars.Contains(nameof(Nullable.Value)) + || !node.Children.TryGetValue(nameof(Nullable.Value), out ProjectionNode? inner)) + { + return null; + } + + MemberInitExpression built = build( + underlying, Expression.Property(access, nameof(Nullable.Value)), Within(underlying, inner)); + + if (built.Bindings.Count == 0) + { + return null; + } + + return Expression.Bind( + member, + Expression.Condition( + Expression.Property(access, nameof(Nullable.HasValue)), + Expression.Convert(built, type), + Expression.Constant(null, type))); + } + + /// + /// The binding for a collection of values the selection goes beneath: the collection whole where + /// its elements are scalars, each element built member by member where they are an application's + /// own structs, and null to leave it unbound. + /// + /// The collection member, already known to be writable. + /// The expression the member is read from. + /// What the selection names beneath each element. + /// The collection's element type, a value type or a string. + /// Builds one level of the projection, as the typed projection does. + /// + /// The typed projection bound every collection of values whole. That is right for strings, numbers + /// and dates, which have no member a policy names. A struct has: Pairs.Shown over a + /// List<Pair> handed back every Pair whole, a [DwDenied] member included, in + /// both tiers, while the policy had approved Pairs.Shown alone. Each element is now built as a + /// class element is, Pairs.Select(v => new Pair { Shown = v.Shown }).ToList(), into a list or an + /// array. A collection of nullable structs, one of another shape, and an element nothing named can be + /// set in are left unbound: nothing is carried rather than everything. + /// + internal static MemberAssignment? BindElements( + PropertyInfo member, + Expression instance, + ProjectionNode node, + Type elementType, + Func build) + { + Expression access = Expression.Property(instance, member); + Type underlying = Nullable.GetUnderlyingType(elementType) ?? elementType; + + if (!underlying.IsValueType || AttributePolicyProvider.NavigationTypeOf(underlying) != underlying) + { + return Expression.Bind(member, access); + } + + if (underlying != elementType) + { + return null; + } + + ParameterExpression element = Expression.Parameter(elementType, "v"); + MemberInitExpression each = build(elementType, element, Within(elementType, node)); + + if (each.Bindings.Count == 0) + { + return null; + } + + // Read as the class elements are: AsQueryable, then Select, then back to the member's shape. + Expression selected = Expression.Call( + typeof(Queryable), + nameof(Queryable.Select), + new[] { elementType, elementType }, + Expression.Call(typeof(Queryable), nameof(Queryable.AsQueryable), new[] { elementType }, access), + Expression.Lambda(each, element)); + + Type holder = member.PropertyType; + Expression? filled = null; + + if (holder.IsAssignableFrom(typeof(List<>).MakeGenericType(elementType))) + { + filled = Expression.Call(typeof(Enumerable), nameof(Enumerable.ToList), new[] { elementType }, selected); + } + else if (holder == elementType.MakeArrayType()) + { + filled = Expression.Call(typeof(Enumerable), nameof(Enumerable.ToArray), new[] { elementType }, selected); + } + + if (filled is null) + { + return null; + } + + // A null collection stays null, as it did when it was bound whole: AsQueryable refuses one. + return Expression.Bind( + member, + Expression.Condition( + Expression.Equal(access, Expression.Constant(null, holder)), + Expression.Constant(null, holder), + Expression.Convert(filled, holder))); + } + + /// + /// What the selection names beneath a struct, less the class navigations in it. + /// + /// + /// The typed projection reads a class navigation through EF.Property, whose instance is an + /// object, and a struct cannot be handed to it without a conversion the builder does not make. Such + /// a navigation stays unbound, as the whole struct did before; the struct's other members are built. + /// + private static ProjectionNode Within(Type type, ProjectionNode node) + { + ProjectionNode within = new(node.Type); + + within.Scalars.UnionWith(node.Scalars); + + foreach (KeyValuePair child in node.Children) + { + if (CacheReflection.FindProperty(type, child.Key) is not { } property) + { + continue; + } + + Type held = property.PropertyType; + + if (held.IsValueType || held == typeof(string) || CacheReflection.IsCollectionType(held)) + { + within.Children[child.Key] = child.Value; + } + } + + return within; + } +} diff --git a/OfficialWebsite/app/docs/ai/page.tsx b/OfficialWebsite/app/docs/ai/page.tsx index f40ce9b..20de275 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.3.0 from the source, by + The reference is written against version 3.4.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 fe223e6..57e8546 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -17,10 +17,30 @@ export default function Page() {

Breaking Changes & Known Limitations

DynamicWhere.ex is intentionally opinionated about how queries are shaped. - The forty-five points below cover constraints, surprises, and corner cases — + The fifty-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 46 to 54 changed in 3.4.0. A declared purpose + can carry page caps of its own, so an export reads every match in one + statement while a screen keeps its page (point 46), and a context + stores its purpose trimmed (point 47). Four security fixes: the + parts of a struct take the struct member's policy, where a denied + struct's parts were filtered on and returned (point 48); a typed + selection beneath a collection of structs carries only what it names, + where each element came back whole (point 51); a transform on a + struct's member is applied, where every one was skipped, with a + struct that cannot be written back now failing the query + (point 52); and a member of a nullable struct is policed as the + policy names it, where a path through Value matched + nothing (point 53). Under Strict, a path through a + member a constructor builds is refused rather than failing in the + provider (point 49), and a typed selection through a struct returns + its values rather than its default (point 50). And{" "} + Bind reports a value a property refuses as the{" "} + InvalidOperationException it documents (point 54). + Points 30 to 45 changed in 3.3.0. Under{" "} Strict, a path that exists on the type and names no value @@ -1533,11 +1553,15 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}

- 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 + An anonymous type is left alone whole: EF Core follows each of its + members to its argument, so no member of such a row is refused here. + Since 3.4.0 a row or a member an application type's + constructor builds with arguments has every member the constructor + leaves unbound refused, and a clause on the member itself, because EF + Core follows a member only through an + initializer's binding; 3.3.0 left such a projection alone, and every + clause on one failed inside the provider (point 49). 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 @@ -2214,6 +2238,508 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}Error Codes Reference.

+

46. A Declared Purpose Can Carry Page Caps of Its Own

+

+ MaxPageSize and DefaultPageSize bound what one + response carries, and a deployment sets them for its screens. An export + or a report read the same rows under the same field policy and was held + to the same page: it stopped at the first page, or walked the rest one + page at a time — a statement and a count per page, and no single + snapshot of the data. +

+

+ Since 3.4.0{" "} + DwCaps.Purposes{" "} + gives a declared purpose page caps of its own. It is a{" "} + DwPurposeCaps, an{" "} + IDictionary<string, DwPageCaps>, and a{" "} + DwPageCaps holds int? MaxPageSize and{" "} + int? DefaultPageSize. A query runs under them when its{" "} + DwPolicyContext.Purpose names that purpose, trimmed and + compared without regard to letter case, as a purpose-bound rule matches. +

+ {`// appsettings: "Caps": { "MaxPageSize": 1000, "DefaultPageSize": 100, +// "Purposes": { "excel": { "MaxPageSize": 10000, "DefaultPageSize": 10000 } } } + +context.Purpose = "excel"; // before the read, set by the host +FilterResult file = await rows + .ApplyPolicy(context) + .ToListAsync(filter, cancellationToken); // one statement, up to 10,000 rows +bool truncated = file.TotalCount > file.Data.Count;`} +
    +
  • + A context naming no purpose, or one no entry names, runs under the + deployment's caps, so a read that forgets to declare itself is + bounded like a screen's. +
  • +
  • + A value left null takes the deployment's cap. A value set is at + least 1, so no purpose can switch a bound off, and the default page + a purpose runs under is bounded by the maximum it runs under. +
  • +
  • + Only the two page caps are replaced. A page above the purpose's + maximum is refused with CapExceeded, in both tiers, never + trimmed: ToList and ToListAsync, typed and + dynamic, a Summary's page, a Segment's + page and the composable Page(PageBy). +
  • +
  • + It binds from Caps:Purposes:<name>:MaxPageSize and{" "} + :DefaultPageSize, where any other key refuses to start, + freezes with the posture, and is compared by the caps that apply when{" "} + DwPolicy.Configure is called again. +
  • +
+ + A request that could name its own purpose could name its own page caps, + and whatever purpose-bound grants exist. Set it in the endpoint that + serves the export; never bind it from a request header, a query string + or a body. + +

+ Who is affected: nobody who declares no purpose. A + second DwPolicy.Configure declaring a purpose the first did + not, with caps that differ from the deployment's, asks for a + different posture and is refused. +

+ +

47. A Context Stores Its Purpose Trimmed

+

+ Setting DwPolicyContext.Purpose stores the value trimmed, + and a null, empty or blank value is stored as null. A rule's purpose + was already trimmed where the rule is built, and both compare without + regard to letter case, so a context declaring{" "} + " export " now matches a purpose-bound rule for{" "} + export — it used to match none — and means to a rule what it + means to a purpose's page caps (point 46). +

+

+ Who is affected: a host that set a purpose with + surrounding spaces: its purpose-bound rules apply to it now. A blank + purpose reads back as null. +

+ +

48. The Parts of a Struct Take the Struct Member's Policy

+

+ A path beneath a member whose type is an application's own struct — + a value type outside the System namespaces — or a + collection of them takes that member's policy, as a path beneath a + framework-typed member has since 3.3.0 (point 37). A struct is a + value, not a navigation. The attribute walk read one as a navigation + whose members are separate fields, so a denial of the struct reached + none of them. +

+ + With [DwDenied] on an Iban struct,{" "} + Iban.Number was filtered on — a test for a guessed value — + sorted by, grouped by with its values as the group keys and handed back + by a dynamic projection, under Strict. Until{" "} + 3.4.0, in both tiers. + +
    +
  • + Taken from the struct member, from every provider, attributes and rules + alike: its deny effects per feature, its [DwOperators]{" "} + restriction (intersected with the path's own), its{" "} + [DwCost] weight and its audited features. Not taken: its + alias, required filter, forced scope and descriptive facts, which come + from the path's own member. +
  • +
  • + The path's own attributes still apply beside the struct + member's ([DwNoOrder] on LocalizedText.En), + and precedence decides as usual: a rule allowing{" "} + Soft.Number replaces an overridable deny on{" "} + Soft, and no rule replaces a sealed{" "} + [DwDenied] on the struct. +
  • +
  • + Every struct on the path decides it: Card.Inner.Ar takes{" "} + Card's and Inner's policy. A struct + inside a class navigation counts (Owner.Name.Ar takes{" "} + Owner.Name's); the class itself does not, so a denial + of Customer still denies only that path. +
  • +
  • + A struct member that is itself transformed refuses Select,{" "} + Group and Aggregate on its parts; a part that + declares its own transform is transformed as any member is. +
  • +
  • + Past the attribute walk's depth the attributes of each struct the + path passes through, and of the path's own member, are read + directly. +
  • +
  • + PolicyResolver.Explain names the struct member's + attribute as DecidedBy, and{" "} + PolicySchemaBuilder.Describe (POST /schema) + reports each part with its struct member's denials. +
  • +
+ {`// Name.Ar and Name.En: FieldDeniedForWhere, and FieldDeniedForOrder under Strict; still selectable +[DwNoWhere, DwNoOrder] +public LocalizedText Name { get; set; }`} + + A member declared Iban? is named through{" "} + Value — Iban.Value.Number — and since 3.4.0 + the policy reads such a path as it names it, Iban.Number, + so the same rules hold there (point 53). + + + A condition whose field is the struct itself — Name, not{" "} + Name.Ar — fails with InvalidOperationException{" "} + ("The binary operator NotEqual is not defined for the types + '…' and 'System.Object'."), guarded or not, in + both tiers. [DwNoWhere] on the member turns it into a + refusal. + +

+ Who is affected: a caller that filtered, sorted, grouped + or selected a part of a denied, restricted, weighted or audited struct + member is refused, dropped, charged or recorded now, as for the member + itself: a filter, a grouping or an aggregate on a denied part is refused + in both tiers, and a select or an order is refused under{" "} + Strict and dropped under Convenience. +

+ +

49. Under Strict, a Path Through a Member a Constructor Builds Is Refused

+

+ EF Core follows a member only through an initializer's binding.{" "} + new LocalizedText(t.NameAr, t.NameEn).Ar cannot be + translated, while{" "} + new LocalizedText { Ar = t.NameAr, En = t.NameEn }.Ar{" "} + is t.NameAr and translates to the column. Point 30 left a + projection built with a constructor with arguments alone, reading it as + saying nothing about which member each value sets. +

+ + A filter, an order, a grouping key, an aggregated field or a segment + condition naming Name.Ar over{" "} + new LocalizedText(t.NameAr, t.NameEn) passed every check the + policy made, and EF Core threw InvalidOperationException{" "} + ("could not be translated"). Until 3.4.0. + +

+ Under Strict, outside a dry run, such a path is now refused + as an unknown name is: the clause's own FieldDeniedFor*{" "} + code and FieldPath "*", and the trace + records{" "} + the member exists on the type and the query cannot compute it, so it is refused as an unknown name is. +

+
    +
  • + A row built by its own constructor, a positional record row + (Select(t => new TenantRecord(t.Id, t.NameAr))), has + every member a clause names refused; an unfiltered, unsorted read of it + still works. +
  • +
  • + A clause on the constructed member itself,{" "} + ORDER BY Name, is refused too: EF Core can neither order + by nor compare a value it would have to build on the client. It can + still be selected. +
  • +
  • + A constructor with an initializer beside it + (new Money(t.Amount) { Currency = t.Currency }): a + member the initializer binds (Money.Currency) stays usable, + and a member left to the constructor (Money.Amount) is + refused. +
  • +
  • + A conditional: a constructor in either branch refuses what that branch + leaves unbound, whatever the other branch binds, because EF Core reads + the member through both. +
  • +
  • + Left alone, as before: an anonymous type, whose constructor names its + members; the framework's own types + (new DateTime(y, 1, 1)), which a provider may translate; a + collection; a type the EF Core model stores as a column on any entity, + through a value converter or as a spatial point; a projection with no EF + Core entity behind it; a provider that is not EF Core's own; rows + in memory; the convenience tier and a dry run, which fail as the + unguarded query does. +
  • +
  • + A selection through such a member still works: EF Core evaluates the + last projection on the client. +
  • +
+

+ Who is affected: no query that ran — each refused + request failed inside the provider before. To make the parts usable, + build the member with an initializer. +

+ +

50. A Typed Selection Through a Struct Returns Its Values

+

+ The typed projection —{" "} + Select<T>, and + a Filter's or a Segment's{" "} + Selects on the typed terminals — skipped every value-typed + member a path went beneath. +

+ + Select(["Name.Ar"]) over a{" "} + LocalizedText struct returned the struct's default, an + empty name, with nothing refused, guarded or not. Until{" "} + 3.4.0. + +

+ An application's own struct is built member by member now, as a + class navigation is: the row carries what was named and nothing beside + it, so Name.En keeps its default when only{" "} + Name.Ar is named. Nested structs are built level by level, a + collection inside a struct is carried, and the struct is read with a + plain member access, so it works on EF Core, through the + projection's initializer or evaluated on the client, and in memory. + A nullable struct is named through Value{" "} + (Alias.Value.Ar), as the path validator reads it, and built + where it has a value; Alias.Value named whole, or{" "} + Alias.HasValue alone, leaves it unbound. +

+
    +
  • + Still unbound: a class navigation inside a struct, whose siblings are + built; a struct in which nothing named can be set; and a path beneath a + framework-typed member (Born.Year,{" "} + Code.Length) — a typed row cannot hold the year apart from + the date, and binding the date whole would return more than the path + names. The dynamic terminals carry such a path as it is named. +
  • +
  • + A projection the policy synthesizes for a request with no{" "} + Selects narrows a member holding an application's own + struct with a denied member, held directly or in a list, any type a list + can be assigned to, or an array, as it narrows a class: the allowed + members come back and the denied one does not. It used to read a struct + as a type the core cannot build and leave it out whole —{" "} + left out whole: the core cannot build '…', which it narrows into{" "} + — so the allowed members came back empty. A nullable struct, or structs + in a collection of another shape, are still left out whole. +
  • +
+

+ A guarded selection through a struct carries only what was named. The + gate added the key, Id, of every node a selected path + passes through, since the builder adds a class's key, and read a + struct as one: DeniedBy.Value.Why came back with the + struct's Id beside it, typed and dynamic, and a struct + whose Id is denied had a selection of its other members + refused and was left out of a synthesized projection. Neither builder + adds a struct's key, so neither does the gate. +

+

+ Who is affected: a typed query naming a path beneath a + struct receives the values instead of defaults, a guarded query with + no Selects over a row holding such a struct receives its + allowed members instead of an empty struct, and a guarded selection + through a struct holding an Id no longer receives that key + unless it names it. +

+ +

51. A Typed Selection Beneath a Collection of Structs Carries Only What It Names

+

+ The typed projection bound every collection of values whole. That is + right for strings, numbers and dates, which have no member a policy + names. A struct has. +

+ + Pairs.Shown over a List<Pair> handed back + every Pair whole, a [DwDenied] member included, + in both tiers, while the policy had approved only{" "} + Pairs.Shown. Selecting Pairs whole was, and is, + refused when an element member is denied, and the dynamic terminals were + never affected: they project{" "} + Pairs.Select(v => new(v.Shown)). Present since the policy + layer shipped in 3.0.0. + +

+ Each element of a collection of an application's own structs is + built member by member now,{" "} + Pairs.Select(v => new Pair { Shown = v.Shown }).ToList(), + into a List<T>, any member type a{" "} + List<T> can be assigned to, or a T[]. It + works on EF Core, where the projection reads the list from a query, and in + memory. +

+
    +
  • A null collection stays null.
  • +
  • + A collection of nullable structs, a collection of another shape, and an + element in which nothing named can be set are left unbound: nothing is + carried rather than everything. +
  • +
  • + A collection of scalars — strings, numbers, dates — is still bound + whole. +
  • +
+ + Pairs = new List<Pair> { new Pair { … } }{" "} + in the source projection is a list EF Core cannot select from again, so a + typed selection beneath it now fails with{" "} + InvalidOperationException ("could not be + translated"), guarded or not, as the dynamic terminals always did; it + used to return each element whole. Build such a list from a query,{" "} + o.Lines.Select(l => new Pair { … }).ToList(), or + name the collection whole where nothing in it is denied. + +

+ Who is affected: a typed selection naming a member + beneath a collection of structs receives that member and nothing beside + it. One over a list a projection builds with a collection initializer, on + EF Core, fails until the list is built from a query. +

+ +

52. A Transform on a Member of a Struct Is Applied

+

+ The outbound walk read a struct as a boxed copy, and the compiled setter + unboxed a second copy to write into, so a transform declared on a member + of an application's own struct landed on a temporary. +

+ + [DwMask] with any strategy — Hash and{" "} + Tokenize included — [DwGeneralize],{" "} + [DwFormat], [DwTruncate],{" "} + [DwDefault] and [DwMutate] on a struct's + member emitted the stored value: in both tiers, from rows in memory and + from EF Core, typed and dynamic, wherever the struct came back in a whole + row or was selected whole. A class member was never affected, nor a + dynamic selection of a path beneath the struct, which builds an object of + its own. Present since the policy layer shipped in 3.0.0. + +

+ The setter writes into the box in place now, and both passes of the walk + write each changed struct back where it was read from, innermost first: + into the member that held it, into its position in a list or an array, or + into the outer struct that held it. Nullable structs returned whole, lists + and arrays of structs, structs inside structs, a struct inside a class + navigation and a struct past the attribute walk's depth are all + transformed, and a member the policy names is transformed once, by the + pass along its path. A source of rows in memory is still transformed in + place, its structs included, as class members are. +

+ + A struct a transform changed that cannot be written back now fails the + query with InvalidOperationException rather than emit the + stored value: a struct held by a member with no setter (the message says + it passes through a struct held by a member with no setter), structs + held in a collection that cannot be written by position such as a{" "} + HashSet<T> (the message asks to hold the structs in a + list or an array), and a struct that is a dictionary's value (the + message says it cannot be written back where it was read from). + +

+ Who is affected: every deployment with a transform on a + struct's member returns the transformed value where it returned the + stored one. One that holds such a struct where it cannot be written back + sees the query fail until the member gets a setter or the structs move + into a list or an array. A member inside a nullable struct that a + selection names through Value is transformed as well + (point 53). See{" "} + Transforms. +

+ +

53. A Member of a Nullable Struct Is Policed as the Policy Names It

+

+ A query reaches a member of an Iban? through the nullable,{" "} + Iban.Value.Number, because that is the member it compiles. + The attribute walk looks through the nullable and names the same member{" "} + Iban.Number, and so do the type's transforms, audits + and fragments. Looked up as the query spells it, the path matched none of + them. +

+ + A [DwDenied] on the nullable member, or on a member of the + struct, did not stop a filter, a sort, a grouping key or a selection of + that member, typed or dynamic, under Strict;{" "} + Iban.HasValue was open; and a mask on a member of the struct + was not applied when a selection named it through Value, as + a result column or as a group key. Present since the policy layer shipped + in 3.0.0; the typed selection change of this release (point 50) had + widened it to the typed terminal before release. + +

+ A Value after a nullable struct of the application's is + now dropped wherever the policy reads a path — the resolver, the outbound + walk, the summary's key and aggregate transforms, the group floor and + the past-depth transforms — so Iban.Value.Number is decided + as Iban.Number and Iban.Value as{" "} + Iban, and the nullable's own members,{" "} + HasValue included, are read as the nullable member. A + framework nullable, Salary.Value, is unchanged. +

+
    +
  • + With [DwDenied] Iban? Iban,{" "} + Iban.Value.Number and Iban.HasValue are + refused as Iban is: in every clause under{" "} + Strict, and as a filter, a group or an aggregate under{" "} + Convenience. +
  • +
  • + A [DwDenied] member inside a nullable struct + (Pair.Value.Hidden) is refused, and an allowed one + (Pair.Value.Shown) stays usable. +
  • +
  • + A mask on a member inside a nullable struct + (Pair.Value.Code) is applied where a selection names it + through Value: on the typed terminal, on the dynamic one — + the walk steps through Value on the generated row — as a + group key (PairValueCode) and past the walk's depth. + The group floor a masked member declares applies. +
  • +
  • + PolicyResolver.Resolve and Explain report the + path as the policy names it, Iban.Number; the trace keeps + the caller's spelling. +
  • +
+

+ Who is affected: a caller that reached a denied, masked, + restricted or audited member of a nullable struct through{" "} + Value is refused, dropped, masked or recorded now, as + through the member itself. +

+ +

54. Bind Reports a Refused Value as InvalidOperationException

+

+ DwPolicyConfiguration.Bind documents{" "} + InvalidOperationException for a value a property refuses: a + cap below one, a purpose's page cap below one, a hash salt too short, + a snapshot age or refresh interval that is not positive. The binder calls + each setter by reflection, so such a value arrived as a{" "} + TargetInvocationException instead. Changed in{" "} + 3.4.0. +

+

+ It arrives as the documented InvalidOperationException now. + The message starts{" "} + A configured policy value was refused: and{" "} + InnerException is the property's own exception + (ArgumentOutOfRangeException,{" "} + ArgumentException), however deep the binder wrapped it. A + purpose's page cap is bound inside a dictionary, where + Microsoft.Extensions.Configuration.Binder 8 adds an{" "} + InvalidOperationException of its own around the{" "} + TargetInvocationException; it is reported the same way.{" "} + AddDwPolicies binds + through Bind, so a host that starts with such a value fails + the same way. +

+

+ Who is affected: code that caught{" "} + TargetInvocationException around Bind or{" "} + AddDwPolicies catches InvalidOperationException{" "} + instead. A host that let startup fail sees a different exception type. + See Configuration from a file. +

+

See also

  • @@ -2264,6 +2790,30 @@ await query.ToListAsync(filter, cancellationToken); // the new overload`}Materialization →{" "} context for point 28: every async terminal and its overloads.
  • +
  • + Page caps per purpose →{" "} + context for points 46 and 47. +
  • +
  • + Access control →{" "} + context for point 48: the parts of a struct. +
  • +
  • + Select<T> → structs{" "} + context for points 50 and 51. +
  • +
  • + Transforms → through the graph{" "} + context for point 52. +
  • +
  • + Access control → a nullable struct{" "} + context for point 53. +
  • +
  • + Configuration from a file →{" "} + context for point 54. +
); diff --git a/OfficialWebsite/app/docs/classes/filter-result/page.tsx b/OfficialWebsite/app/docs/classes/filter-result/page.tsx index 15d6dd4..9a6b0ac 100644 --- a/OfficialWebsite/app/docs/classes/filter-result/page.tsx +++ b/OfficialWebsite/app/docs/classes/filter-result/page.tsx @@ -138,7 +138,10 @@ export default function Page() { gives a request with no Page page 1 at that size, capped at MaxPageSize, so{" "} PageNumber, PageSize and PageCount come - back as for any paged request. It never replaces a page the caller sent. + back as for any paged request. It never replaces a page the caller sent. A + context declaring a purpose with page caps of its own in{" "} + DwCaps.Purposes{" "} + takes each of the two caps that purpose sets from it instead (3.4.0).

C# usage

diff --git a/OfficialWebsite/app/docs/classes/segment-result/page.tsx b/OfficialWebsite/app/docs/classes/segment-result/page.tsx index eb41e49..75c5627 100644 --- a/OfficialWebsite/app/docs/classes/segment-result/page.tsx +++ b/OfficialWebsite/app/docs/classes/segment-result/page.tsx @@ -142,7 +142,8 @@ export default function Page() { PageNumber and PageSize still report{" "} 0 when no page was sent. The exception is a guarded query when the deployment sets{" "} - DwCaps.DefaultPageSize: + DwCaps.DefaultPageSize, + or the context declares a purpose with a default page of its own (3.4.0): the query is given page 1 at that size, and reports it.
diff --git a/OfficialWebsite/app/docs/classes/summary-result/page.tsx b/OfficialWebsite/app/docs/classes/summary-result/page.tsx index 3828306..246cba4 100644 --- a/OfficialWebsite/app/docs/classes/summary-result/page.tsx +++ b/OfficialWebsite/app/docs/classes/summary-result/page.tsx @@ -124,7 +124,8 @@ export default function Page() { — or 0 when there are no groups. PageNumber and{" "} PageSize are unchanged: both still 0 when no page was sent. The exception is a guarded query when the deployment sets{" "} - DwCaps.DefaultPageSize: + DwCaps.DefaultPageSize, + or the context declares a purpose with a default page of its own (3.4.0): the query is given page 1 at that size, and reports it. diff --git a/OfficialWebsite/app/docs/extensions/select/page.tsx b/OfficialWebsite/app/docs/extensions/select/page.tsx index 37627e8..0fd250b 100644 --- a/OfficialWebsite/app/docs/extensions/select/page.tsx +++ b/OfficialWebsite/app/docs/extensions/select/page.tsx @@ -107,6 +107,31 @@ export default function Page() { {`Category: { Vendors: [{ Product: { Name: "…" } }] }`} + + Dotted through an application's own struct (3.4.0) + Built member by member + "Name.Ar" + + {`Name: { Ar: "…", En: null }`} + + + + Dotted through a collection of structs (3.4.0) + + Per-element Select().ToList(), or{" "} + ToArray() for an array + + "Pairs.Shown" + + {`Pairs: [{ Shown: "…", Hidden: null }, …]`} + + + + Dotted beneath a value type the framework declares + Left unbound + "CreatedAt.Year" + CreatedAt keeps its default + @@ -116,6 +141,52 @@ export default function Page() { but only when the nested type actually declares an Id. +

Structs (3.4.0)

+

+ An application's own struct — a value type outside the{" "} + System namespaces — is built with exactly what was named, + and no Id is added: Name.En keeps its default + when only Name.Ar is named. Nested structs are built level + by level, a collection inside a struct is carried, and the struct is + read with a plain member access, so it works on EF Core and in memory. + Until 3.4.0 every value-typed member a path went beneath was skipped, so{" "} + Select(["Name.Ar"]) returned the struct's + default, an empty name. +

+
    +
  • + A nullable struct is named through Value:{" "} + "Alias.Value.Ar" builds it where it has a value + and leaves it null where it has none. "Alias.Value"{" "} + whole, or "Alias.HasValue" alone, leaves it + unbound. +
  • +
  • + A collection of structs is built element by element, into a{" "} + List<T>, any member type a list can be assigned to, + or a T[]. A null collection stays null, and a collection + of scalars is still bound whole. Until 3.4.0 the collection was bound + whole, so each element came back with every member. +
  • +
  • + Left unbound: a class navigation inside a struct (its siblings are + built), a struct or an element in which nothing named can be set, a + collection of nullable structs, and a path beneath a framework value + type such as "CreatedAt.Year" — a typed row + cannot hold the year apart from the date.{" "} + SelectDynamic{" "} + carries such a path as it is named. +
  • +
  • + On EF Core, a list of structs the source projection builds with a + collection initializer, new List<Pair> { new Pair { … } }, + cannot be selected from again: a path beneath it fails with{" "} + InvalidOperationException ("could not be + translated"), as it always did for SelectDynamic. A + list the projection reads from a query is built as named. +
  • +
+

Validations

  • query and fields cannot be null.
  • diff --git a/OfficialWebsite/app/docs/installation/page.tsx b/OfficialWebsite/app/docs/installation/page.tsx index c2d3d06..9a15997 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.3.0`} + {`dotnet add package DynamicWhere.ex --version 3.4.0`}

    Package Manager (Visual Studio)

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

    PackageReference (csproj)

    {` - + `}

    Dependencies

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

    - Version 3.3.0. Target framework .NET 6+. + Version 3.4.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.3.0`} + {`dotnet add package DynamicWhere.ex --version 3.4.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 d6fb0fd..d90d419 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.3.0`} + {`dotnet add package DynamicWhere.ex.Policies.AspNetCore --version 3.4.0`} {`app.MapDwPolicyAdmin(options => { options.RoutePrefix = "/dw-policies"; // the default; mount it anywhere diff --git a/OfficialWebsite/app/docs/policies/attributes/page.tsx b/OfficialWebsite/app/docs/policies/attributes/page.tsx index 3877dbe..2706417 100644 --- a/OfficialWebsite/app/docs/policies/attributes/page.tsx +++ b/OfficialWebsite/app/docs/policies/attributes/page.tsx @@ -233,8 +233,8 @@ 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 + + A denial on a class 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 @@ -255,6 +255,51 @@ public string EmployeeCode { get; set; }`}
    rules naming it. Until 3.3.0 no fragment named such a path, so it resolved as allowed. + + A struct is a value, not a navigation. A path beneath a member whose + type is an application's own struct — a value type outside the{" "} + System namespaces — or a collection of them takes that + member's policy beside its own: its deny effects, its{" "} + [DwOperators] restriction (intersected with the + path's own), its [DwCost] weight and its audited + features, from whichever provider supplied them, and never its alias, + required filter, forced scope or description. The part's own + attributes still apply, and precedence decides as usual: a sealed + attribute beats every rule, so no rule reopens a part of a sealed{" "} + [DwDenied] struct, and a rule beats an overridable + attribute. Every struct on the path decides it, and a struct inside a + class navigation counts where the class itself does not. A struct + member that is itself transformed refuses Select,{" "} + Group and Aggregate on its parts. Until 3.4.0 + a [DwDenied] struct's parts were filtered on, sorted + by, grouped by and handed back by a dynamic projection. + + {`public readonly record struct LocalizedText(string Ar, string En); +public readonly record struct Iban(string Country, string Number); + +public class TenantRow +{ + // Name.Ar and Name.En: refused as filters and sorts, still selectable. + [DwNoWhere, DwNoOrder] + public LocalizedText Name { get; set; } + + // Iban.Number: refused in every clause, as Iban is. + [DwDenied] + public Iban Iban { get; set; } +}`} + + A query names a part of an Iban? through the nullable,{" "} + Iban.Value.Number, where the attribute walk names it{" "} + Iban.Number, so the policy reads such a path as the walk + names it: the Value is dropped, and the nullable's own{" "} + Iban.HasValue is read as the member Iban. + With [DwDenied] on an Iban?, both are + refused as Iban is, a [DwDenied] member inside + a nullable struct is refused, and a mask on one is applied where a + selection names it through Value. A framework nullable,{" "} + Salary.Value, is unchanged. Until 3.4.0 such a path matched + no rule and was allowed, in every clause and both terminals. + An access-control attribute on another declaration of a member applies to its path too: on the interface member a class or its subtype @@ -305,7 +350,9 @@ public string Department { get; set; }`} 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. + satisfy a [DwRequireWhere] on TenantId. Nor does + a part of an application's struct take any of them from its struct + member (3.4.0).

    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 ff8055d..a444472 100644 --- a/OfficialWebsite/app/docs/policies/configuration/page.tsx +++ b/OfficialWebsite/app/docs/policies/configuration/page.tsx @@ -7,7 +7,7 @@ import Callout from "@/components/Callout"; export const metadata: Metadata = { title: "Policy Configuration — options, caps, tiers and defaults", description: "Every DynamicWhere.ex policy option and cap with its default: tiers, what a denied field does to a projection, dry run, the trace on a result, refusal auditing, hash salt, store failure modes, query cost budget, MinGroupSize, plus startup validation and the twenty-two error codes.", - keywords: ["DwPolicyOptions", "DwCaps", "MaxQueryCost", "MinGroupSize", "policy configuration"], + keywords: ["DwPolicyOptions", "DwCaps", "DwPageCaps", "MaxQueryCost", "MinGroupSize", "policy configuration"], alternates: { canonical: "https://doc.dynamicwhere.com/docs/policies/configuration/" }, }; @@ -101,7 +101,8 @@ export default function Page() {
  • A narrowing that cannot be built as it was gated is refused in both tiers (3.2.0). The core's typed projection adds the key,{" "} - Id, of every nested node it builds, so a navigation + Id, of every nested class it builds (never of a struct, + which it builds with exactly what was named, 3.4.0), so a navigation narrowed around its own denied key would get the key back. The core reads a path only through an array, List<T>,{" "} IList<T>, ICollection<T>,{" "} @@ -141,7 +142,9 @@ export default function Page() { A navigation named through another, Main.Lead, gates the key of every node it passes through, which the core's projection adds, as a dotted path to a value always did (3.2.0). A denied key - refuses the projection. Naming a field beside a denied key,{" "} + refuses the projection. Only a class's key: a struct's is + neither added nor gated, since no builder adds it (3.4.0). Naming a + field beside a denied key,{" "} Lines.Name when Lines.Id is denied, was already refused in both tiers.
  • @@ -356,6 +359,17 @@ left out whole: it can hold what the policy cannot name`} The last one is recorded on the field beneath the member that the narrowing leaves out.

    + + A member holding an application's own struct with a denied member + in it — held directly, or in a list, any type a list can be assigned + to, or an array — is narrowed: the typed projection builds a struct + member by member, so its allowed members come back and the denied one + does not. Until 3.4.0 the gate read a struct as a type the core cannot + build and left it out whole, with{" "} + left out whole: the core cannot build '…', which it narrows into, + so its allowed members came back empty. A nullable struct, or structs in + a collection of another shape, are still left out whole. +

    Never kept

      @@ -584,6 +598,7 @@ PolicyTrace? trace = guarded.LastTrace;`} MaxPageSize1000Largest page a caller may request. DefaultPageSize0Page given to a guarded query that asked for none. Zero, the default, leaves it unpaged. + PurposesemptyPage caps per declared purpose (3.4.0), which replace the two above for a query whose context names that purpose. See Page caps per purpose. MaxConditions50Conditions in one filter. MaxConditionDepth10How deep a filter may nest its condition groups, counting the root group as one. MaxConditionSets10Condition sets in one segment. Each set adds to the one statement a segment becomes. @@ -747,6 +762,114 @@ PolicyTrace? trace = guarded.LastTrace;`} IsMinGroupSizeSet reports which of the two happened.

      +

      Page caps per purpose (3.4.0)

      +

      + MaxPageSize and DefaultPageSize bound what + one response carries, and a deployment sets them for its screens. An + export or a report reads the same rows under the same field policy and + was held to the same page: it stopped at the first page, or walked the + rest one page at a time — a statement and a count per page, and no + single snapshot of the data. DwCaps.Purposes gives such a + read page caps of its own without raising the screens'. +

      + {`"Caps": { + "MaxPageSize": 1000, + "DefaultPageSize": 100, + "Purposes": { + "excel": { "MaxPageSize": 10000, "DefaultPageSize": 10000 }, + "audit-logs": { "MaxPageSize": 5000 } + } +}`} + {`// or in code, before Configure +options.Caps.Purposes["excel"] = new DwPageCaps { MaxPageSize = 10_000, DefaultPageSize = 10_000 }; + +// the export handler: the host declares the purpose, the caller never does +context.Purpose = "excel"; // before the read +FilterResult file = await rows + .ApplyPolicy(context) + .ToListAsync(filter, cancellationToken); // one statement, up to 10,000 rows +bool truncated = file.TotalCount > file.Data.Count;`} +

      + DwCaps.Purposes is a DwPurposeCaps, an{" "} + IDictionary<string, DwPageCaps>, and a{" "} + DwPageCaps holds int? MaxPageSize and{" "} + int? DefaultPageSize. +

      +
        +
      • + A query runs under a purpose's page caps when its{" "} + DwPolicyContext.Purpose names that purpose. The name is + trimmed and compared without regard to letter case, the way a + purpose-bound rule matches. A context naming no purpose, or one no + entry names, runs under the deployment's caps, so a read that + forgets to declare itself is bounded like a screen's. +
      • +
      • + The context stores its purpose trimmed since 3.4.0, and a null, + empty or blank value as null, so " export "{" "} + means to a purpose-bound rule what it means to a purpose's caps; + it used to match no rule. +
      • +
      • + A value left null takes the deployment's cap. A value set must be + at least 1 — ArgumentOutOfRangeException otherwise — so + no purpose can switch a bound off. The default page a purpose runs + under is bounded by the maximum it runs under, as the deployment's + is by its own, and a purpose may lower the caps as well as raise them. With the + deployment's DefaultPageSize at 0, a purpose that sets + only MaxPageSize leaves a request with no page unpaged, + as the deployment does. +
      • +
      • + Only the two page caps are replaced. Every other cap applies to every + purpose alike. +
      • +
      • + A page above the purpose's maximum is refused, never trimmed:{" "} + CapExceeded, SourceOrigin{" "} + "MaxPageSize cap (10000), request had 10001", in + both tiers. It applies to ToList and{" "} + ToListAsync, typed and dynamic, a{" "} + Summary's page, a Segment's page, and + the composable Page(PageBy). +
      • +
      • + Any name, and as many as a deployment has — excel,{" "} + csv, pdf, audit-logs — and + several names may share one DwPageCaps. A null or blank + name is refused with ArgumentException and a null value + with ArgumentNullException; TryGetValue and{" "} + ContainsKey answer false for a null or blank name. Keys + are stored trimmed. +
      • +
      • + It binds from Caps:Purposes:<name>:MaxPageSize and{" "} + Caps:Purposes:<name>:DefaultPageSize, or, for a + section at DynamicWhere:Policies, from the environment + variable{" "} + DynamicWhere__Policies__Caps__Purposes__excel__MaxPageSize=10000. + A misspelt key inside a purpose, or any other cap there such as{" "} + MaxConditions, refuses to start, and so does a value + below 1. +
      • +
      • + It freezes with the posture: after startup, adding, replacing, + removing or clearing a purpose, and setting a value on any{" "} + DwPageCaps it holds, throws{" "} + InvalidOperationException, and IsReadOnly is + true. A second DwPolicy.Configure compares purposes by + the caps that apply — see{" "} + Configuring twice. +
      • +
      + + A request that could name its own purpose could name its own page caps, + and whatever purpose-bound grants exist. Set it in the endpoint that + serves the export; never bind it from a request header, a query string + or a body. A purpose no entry names runs under the deployment's caps + with nothing said, so a misspelt name bounds the export like a screen. + +

      Dry run

      DryRun traces every decision without enforcing any of them, @@ -953,7 +1076,7 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so Services - Every value on Caps — the floor that applies, not whether it was written down + Every value on Caps — the floor that applies, not whether it was written down — and every purpose's page caps, by the caps that apply (3.4.0) The provider instances @@ -974,7 +1097,12 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so 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. + and the second posture is refused. Purposes are compared over the names + either side declares, by the MaxPageSize and{" "} + DefaultPageSize each name runs under, a value left null + reading as the deployment's own: a purpose whose caps equal the + deployment's is the same posture as no entry, and one with any other + page cap is refused (3.4.0).

      The three on the right are objects a host builds for itself, and a @@ -1016,7 +1144,8 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so "DefaultPageSize": 100, "MinGroupSize": 5, "SchemaDepth": 2, - "MaxSchemaFields": 2000 + "MaxSchemaFields": 2000, + "Purposes": { "excel": { "MaxPageSize": 10000, "DefaultPageSize": 10000 } } } } } @@ -1039,7 +1168,22 @@ Ticket: DefaultOrder names 'Region', which its attributes deny for segments, so sixteen characters are all refused exactly as they are in code. The group floor's opt-out survives unchanged, because it lives in the setter: saying nothing leaves it unset, writing 1 records a - deliberate choice. + deliberate choice. A purpose's page caps bind the same way (3.4.0): + inside Caps:Purposes:<name> only{" "} + MaxPageSize and DefaultPageSize answer, so a + misspelt key there, or another cap, refuses to start, and so does a + value below one. +

      +

      + Since 3.4.0 a value a property refuses is reported as the{" "} + InvalidOperationException Bind documents: the + message starts{" "} + A configured policy value was refused: and{" "} + InnerException is the property's own exception, + however deep the binder wrapped it, a purpose's page cap inside its + dictionary included. It used to arrive as the binder's{" "} + TargetInvocationException. See{" "} + breaking point 54.

      HashSalt binds like anything else, and configuration is the diff --git a/OfficialWebsite/app/docs/policies/providers/page.tsx b/OfficialWebsite/app/docs/policies/providers/page.tsx index 376e32e..f0ab719 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.3.0`} + {`dotnet add package DynamicWhere.ex.Policies.Redis --version 3.4.0`} {`var redis = await ConnectionMultiplexer.ConnectAsync(connectionString); var store = new RedisPolicyStore(redis); @@ -50,7 +50,7 @@ DwPolicy.Configure(options, provider);`}

      Entity Framework Core

      - {`dotnet add package DynamicWhere.ex.Policies.EntityFrameworkCore --version 3.3.0`} + {`dotnet add package DynamicWhere.ex.Policies.EntityFrameworkCore --version 3.4.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 ef5e467..7498024 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -300,10 +300,16 @@ 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. 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 + that projection copies from the entity. An anonymous type is left + alone: EF Core follows each of its members to its argument. Since{" "} + 3.4.0 a member or a row an application type's + constructor builds with arguments is refused through every member the + constructor leaves unbound, and as a clause on the member itself, + because EF Core follows a member only + through an initializer's binding: new LocalizedText(t.NameAr, + t.NameEn).Ar cannot be translated, where{" "} + new LocalizedText { Ar = t.NameAr, En = t.NameEn }.Ar{" "} + is the column. 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 @@ -339,7 +345,7 @@ true order. Add [DwNoOrder] unless that is intended.`} denial holds on every path that reaches it, and it has to hold whether 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. + until 3.3.0 or 3.4.0. All are closed.

      @@ -481,6 +487,54 @@ true order. Add [DwNoOrder] unless that is intended.`} forced scope and the description. + + + + + + + + + + + + + + + + + + + + + + + + + +
      AttackControl
      Name a path beneath a denied member holding an application's own struct: Iban.Number beneath a [DwDenied] Iban + Such a path takes the struct member's policy since{" "} + 3.4.0, beside its own: its deny effects, its{" "} + [DwOperators] restriction, its [DwCost]{" "} + weight and its audited features. A struct is a value, and it was + read as a navigation whose members are separate fields, so{" "} + Iban.Number was filtered on — a test for a guessed + value — sorted by, grouped by with its values as the group keys and + handed back by a dynamic projection, under Strict. +
      Name a member of a nullable struct through Value: Iban.Value.Number or Iban.HasValue beneath a [DwDenied] Iban?, a [DwDenied] Pair.Value.Hidden, a masked Pair.Value.Code + Policed since 3.4.0. The query spells the path + through the nullable where the policy names it without, so the + lookup matched nothing: each was filtered on, sorted by, grouped by + and selected, typed and dynamic, and a mask was not applied. The + policy now drops the Value after a nullable struct of + the application's and reads HasValue as the member + itself. Present since the policy layer shipped. +
      Name one member of each struct in a collection in a typed selection: Pairs.Shown over a List<Pair> whose Pair.Hidden is denied + Each element is built with what was named since{" "} + 3.4.0. The typed projection bound a collection of + values whole, so every Pair came back whole, the denied + member included, in both tiers, while the policy had approved only{" "} + Pairs.Shown. Present since the policy layer shipped; the + dynamic terminals were never affected. +
      Read a masked member of an application's own struct: [DwMask] on LocalizedText.Code, in a whole row or with the struct selected whole + Transformed since 3.4.0. The struct was read as a + copy and the setter wrote into a second one, so every transform on a + struct's member landed on a temporary and the stored value came + back, typed and dynamic, in both tiers. Each changed struct is now + written back where it was read from, and one that cannot be — held + by a member with no setter, in a set, as a dictionary's value — + fails the query instead. +
      Raise Caps.MaxNavigationDepth above 4 and name a denied member five or more segments out @@ -581,6 +635,7 @@ true order. Add [DwNoOrder] unless that is intended.`}
    • Hold a policed type in a list, never in a dictionary, another framework generic or a member typed object. The policy has no paths into any of them.
    • Scope a list's elements where the row is built. A forced scope on the element type filters the rows that hold the list, never the elements.
    • Set DwCaps.DefaultPageSize if the API does not page for itself. It ships off, and the request MaxPageSize never bounded is the one that sent no page at all.
    • +
    • Give an export or a report a purpose of its own in DwCaps.Purposes (3.4.0) rather than raising the screens' MaxPageSize, and set DwPolicyContext.Purpose in the endpoint that serves it. Never bind the purpose from a request: a caller who could name it could name its caps, and whatever purpose-bound grants exist.
    • Keep DwCaps.MaxConditionSets near the number of sets your clients really send. A set with no conditions passes every other cap, and every set adds a condition or a subquery to the statement a segment becomes.
    • diff --git a/OfficialWebsite/app/docs/policies/transforms/page.tsx b/OfficialWebsite/app/docs/policies/transforms/page.tsx index a7d449b..b6cb5ea 100644 --- a/OfficialWebsite/app/docs/policies/transforms/page.tsx +++ b/OfficialWebsite/app/docs/policies/transforms/page.tsx @@ -254,6 +254,42 @@ public string SalaryBand { get; set; } = string.Empty;`} types, four segments deep — which is what makes it incapable of missing a path because it failed to recognise a navigation.

      + + A struct is read as a copy, and the setter the walk used unboxed a + second copy to write into, so a mask, generalization, format, + truncation, default or mutation declared on a member of an + application's own struct — Hash and{" "} + Tokenize included — landed on a temporary and the stored + value was emitted: in both tiers, from rows in memory and from EF Core, + typed and dynamic, wherever the struct came back in a whole row or was + selected whole, since the policy layer shipped. A class member was + never affected. The setter writes into the box in place now, and both + passes of the walk write each changed struct back where it was read + from, innermost first: into the member that held it, into its position + in a list or an array, or into the outer struct that held it. A + member the policy names is transformed once, by the pass along its + path. + + + A struct a transform changed that cannot be written back where it was + read from fails the query with InvalidOperationException{" "} + rather than hand back the stored value: a struct held by a member with + no setter, structs in a collection that cannot be written by position + such as a HashSet<T>, and a struct that is a + dictionary's value. Give the member a setter, and hold such structs + in a list or an array. + + + A selection naming a member inside a nullable struct through{" "} + Value, Pair.Value.Code, is transformed: on the + typed terminal, on the dynamic one — whose generated row holds the + member under Value, and the walk steps through it — as a + group key, and past the walk's depth, and the group floor a masked + member declares applies to an aggregate over it. Until 3.4.0 the path + was looked up as the query spells it, matched no transform, and the + value came back as stored. See{" "} + Access control. +

      A value no path of the policy names

      @@ -314,7 +350,10 @@ public string SalaryBand { get; set; } = string.Empty;`} 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 + would hand back is the stored one. So, since 3.4.0, is a part of a + struct member that is itself transformed — Price.Amount{" "} + with [DwMask] on Price; a part that declares + its own transform is transformed as any member is. A transformed member past four segments, which only a raised{" "} MaxNavigationDepth{" "} lets a request name, is a member, so naming it in Selects{" "} diff --git a/OfficialWebsite/app/docs/policies/use-cases/page.tsx b/OfficialWebsite/app/docs/policies/use-cases/page.tsx index 28b8c84..2e5f313 100644 --- a/OfficialWebsite/app/docs/policies/use-cases/page.tsx +++ b/OfficialWebsite/app/docs/policies/use-cases/page.tsx @@ -382,6 +382,83 @@ public string FullTextNotes { get; set; } = string.Empty;`} ErrorCode.

      + {/* ----------------------------------------------------------------- 10 */} +

      10. An export needs every match in one read; a screen needs a page

      +

      + MaxPageSize and DefaultPageSize are set for the + screens. Raising them for an export raises them for every caller, and + leaving them makes the export stop at the first page, or walk the rest a + page at a time: a statement and a count per page, and no single snapshot + of the data. A declared purpose (3.4.0) gives the export its own two page + caps and leaves the screens' alone. +

      + {`// once, before Configure (or Caps:Purposes:excel:MaxPageSize in configuration) +options.Caps.Purposes["excel"] = new DwPageCaps { MaxPageSize = 10_000, DefaultPageSize = 10_000 }; + +// in the export endpoint, and nowhere else +context.Purpose = "excel"; +FilterResult file = await rows.ApplyPolicy(context).ToListAsync(filter, cancellationToken); +bool truncated = file.TotalCount > file.Data.Count;`} +

      + What the caller gets. Every match up to 10,000 in one + statement, under the same field policy as the screen: the same denials, + masks, forced scopes and group floor. A page past 10,000 is refused with{" "} + CapExceeded, never trimmed, and a screen that declares no + purpose keeps its own caps. +

      + + The purpose is the host's statement about why it reads. Bound from a + header, a query string or a body, it lets any caller ask for the largest + page any purpose allows, and for whatever a purpose-bound rule grants. + Set it in the endpoint that serves the export. A purpose no entry names + runs under the deployment's caps with nothing said, so a misspelt name + bounds the export like a screen. + + + {/* ----------------------------------------------------------------- 11 */} +

      11. A value composed over two columns

      +

      + A shared kernel type pairs two columns, an Arabic and an English name, + into one value the row carries. How the projection builds it decides + what a caller can do with its halves, and the struct member's + attributes decide what the policy lets them do (3.4.0). +

      + {`public readonly record struct LocalizedText(string Ar, string En); + +public class TenantRow +{ + public int Id { get; set; } + + [DwNoWhere, DwNoOrder] + public LocalizedText Name { get; set; } +} + +// An initializer: EF Core follows Name.Ar to the NameAr column. +IQueryable rows = db.Tenants.Select(t => new TenantRow +{ + Id = t.Id, + Name = new LocalizedText { Ar = t.NameAr, En = t.NameEn } +});`} +

      + What the caller gets. Name.Ar and{" "} + Name.En can be selected, and come back with their values. A + filter on either is refused with FieldDeniedForWhere and a + sort with FieldDeniedForOrder under Strict, + because the parts of a struct take the struct member's denials. Drop + the two attributes and both halves filter and sort on their columns. +

      + + new LocalizedText(t.NameAr, t.NameEn) hands EF Core a + constructor, and EF Core follows a member only through an + initializer's binding. Under Strict every filter, sort, + grouping or aggregate on Name.Ar is then refused as an + unknown name is, and elsewhere EF Core throws; a selection still works. + Keep [DwNoWhere] on the member either way: a filter on{" "} + Name itself, the whole struct, fails in the builder with{" "} + InvalidOperationException, and the attribute turns that + into a refusal. + + {/* --------------------------------------------------------------- recap */}

      The pairs, in one table

      @@ -437,6 +514,16 @@ public string FullTextNotes { get; set; } = string.Empty;`}

      [DwEntity(RequirePolicy)] a path that forgets ApplyPolicy returns everything
      a purpose's page caps (3.4.0)the purpose set by the hosta caller picks its own caps, and whatever purpose-bound rules grant
      a composed value's halves in a clausean initializer, not a constructorevery filter, sort or grouping on a half is refused under Strict, and fails in the provider elsewhere
      diff --git a/OfficialWebsite/lib/nav.ts b/OfficialWebsite/lib/nav.ts index ce5a9b4..7c672e3 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.3.0", + version: "3.4.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 cac3161..73c838b 100644 --- a/OfficialWebsite/package.json +++ b/OfficialWebsite/package.json @@ -1,6 +1,6 @@ { "name": "dynamicwhere-docs", - "version": "3.3.0", + "version": "3.4.0", "private": true, "scripts": { "dev": "next dev", diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index c0d4382..5500513 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.3.0 · targets net6.0 · runs on .NET 6, 7, 8, 9, 10 · EF Core 6+ · MIT +> Version 3.4.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.3.0 +dotnet add package DynamicWhere.ex --version 3.4.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 @@ -111,6 +111,7 @@ DynamicWhere.ex.Exceptions LogicException PolicyException DynamicWhere.ex.Policies.Source PolicyExtensions (ApplyPolicy) PolicyQueryable DynamicWhere.ex.Policies.Config DwPolicy DwPolicyOptions DwCaps DwPolicyConfiguration (AddDwPolicies, Bind) + DwPurposeCaps DwPageCaps (3.4.0) DynamicWhere.ex.Policies.Context DwPolicyContext DwSubject DynamicWhere.ex.Policies.Attributes DwPolicyAttribute and the 22 Dw*Attribute types DynamicWhere.ex.Policies.Enums PolicyFeature PolicyEffect PolicyAction PolicyLevel PolicyErrorCode @@ -240,7 +241,8 @@ PageCount (int)Math.Ceiling((double)TotalCount / PageSize), or 1 when no Pag on FilterResult, SummaryResult and SegmentResult alike. Before 3.1.0 an unpaged Filter or Summary reported PageCount = TotalCount (one page per row) and an unpaged Segment with sets reported 0. Unpaged, PageNumber and PageSize stay 0 — except on a guarded query when DwCaps.DefaultPageSize is - set, which gives it page 1 at min(DefaultPageSize, MaxPageSize) (section 12) + set, which gives it page 1 at min(DefaultPageSize, MaxPageSize); a context declaring a purpose that + DwCaps.Purposes names takes both caps from that purpose where it sets them (3.4.0, section 12) Data the page. Typed rows are whole T objects even with Selects: unselected members keep constructor defaults QueryString with getQueryString: true, EF Core ToQueryString() of the data query (after order, page and projection; never the count query), otherwise null. Always null for Segment, which has no such parameter. @@ -766,7 +768,36 @@ products.Select(["Id", "Name", "Category.Name", "OrderItems.Quantity"]) builds collection gives an empty list. Deeper paths recurse the same way. - Skipped silently, keeping the constructor default: members without a public setter; collections whose element type has no parameterless constructor; collection members whose declared type cannot hold a `List` - (`HashSet<>`, `ISet<>`, arrays); a dotted path into a string or struct member (`"CreatedAt.Year"`). + (`HashSet<>`, `ISet<>`, arrays); a dotted path beneath a string or a value type the framework declares + (`"CreatedAt.Year"`, `"Code.Length"`). A typed row cannot hold the year apart from the date, and binding the + date whole would return more than the path names: a policy allowing `CreatedAt.Year` alone would hand back the + whole date. `SelectDynamic` and `ToListDynamic` carry such a path as it is named. +- A dotted path beneath an application's own struct (a value type outside the `System` namespaces), `"Name.Ar"` on + a `LocalizedText Name`, builds the struct member by member, as a class navigation is (3.4.0). The row carries + what was named and nothing beside it: `Name.En` keeps its default when only `Name.Ar` is named, and no `Id` is + added. A struct inside a struct is built level by level (`"Card.Title.En"`), and a collection inside one is + carried (`"Card.Tags"`). The struct is read with a plain member access, so it works on EF Core, through the + projection's initializer or evaluated on the client, and in memory. The same holds for a `Filter`'s or a + `Segment`'s `Selects` on the typed terminals. Until 3.4.0 every value-typed member a path went beneath was + skipped, so `Select(["Name.Ar"])` returned the struct's default, an empty name, with nothing refused, guarded or + not. + - A nullable struct is named through `Value`, as the path validator reads it: `"Alias.Value.Ar"` builds the + struct where it has a value and leaves the member null where it has none. `"Alias.Value"` named whole, or + `"Alias.HasValue"` alone, leaves the member unbound, as before. + - Still unbound: a class navigation inside a struct, whose siblings are built; and a struct in which nothing + named can be set, which is left unbound rather than built empty. + - A collection of an application's own structs is built element by element the same way (3.4.0): + `"Pairs.Shown"` on a `List` builds `Pairs.Select(v => new Pair { Shown = v.Shown }).ToList()`, into a + `List`, any member type a `List` can be assigned to, or a `T[]`. A null collection stays null. A + collection of nullable structs, a collection of another shape, and an element in which nothing named can be + set are left unbound: nothing is carried rather than everything. A collection of scalars (strings, numbers, + dates) is still bound whole. Until 3.4.0 the collection was bound whole, so each element came back with every + member. + - On EF Core that works where the projection reads the list from a query, + `o.Lines.Select(l => new Pair { … }).ToList()`, and in memory. A list the projection builds with a collection + initializer, `new List { new Pair { … } }`, is one EF Core cannot select from again: a selection beneath + it fails with `InvalidOperationException` ("could not be translated"), as the dynamic terminals always did + (section 34, limits). - If `"Category"` and `"Category.Name"` are both listed, the dotted projection wins. Duplicate paths collapse. - **EF Core only for reference navigations:** a dotted reference-navigation path reads `EF.Property`, so on an in-memory source it throws `InvalidOperationException("The EF.Property method may only be used within Entity @@ -1200,7 +1231,8 @@ and enum-typed members and group keys in `data`. ```jsonc { - "pageNumber": 1, // 0 when the request had no page, unless DwCaps.DefaultPageSize gave a guarded query one + "pageNumber": 1, // 0 when the request had no page, unless DwCaps.DefaultPageSize, or the DefaultPageSize of + // the purpose the context declares (3.4.0), gave a guarded query one "pageSize": 10, // 0 when the request had no page, with the same exception "pageCount": 5, // no page: 1, or 0 with no rows "totalCount": 42, // before paging @@ -1446,6 +1478,13 @@ Each of these compiles, passes validation, and returns something other than what refused only after the database has been hit: `ToListDynamic` / `ToListAsyncDynamic` run the `COUNT` query before `Orders`, `Page` and `Selects` are validated. The `LogicException` is the same one either way; the round trip is not undone. +17. **A filter on a whole struct-typed member throws in the builder.** A condition whose `Field` is the struct itself + (`"Name"` on a `LocalizedText` struct, not `"Name.Ar"`) fails with `InvalidOperationException` ("The binary + operator NotEqual is not defined for the types '' and 'System.Object'."): the null guard compares + the struct with null. Guarded or not, in both tiers, in memory and on EF Core. Filter on the struct's members. + Under a policy `[DwNoWhere]` on the member turns the error into a refusal, and since 3.4.0 it refuses a filter + on the parts too (section 14); to keep the parts filterable, deny the member with + `[DwDeny(PolicyFeature.Where, Overridable = true)]` and allow each part with a rule. --- @@ -1549,14 +1588,18 @@ 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, the exposed catalogue — the same types under the same - 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. + `MaxSnapshotAge`, `RefreshInterval`, every `DwCaps` value and every purpose's page caps (3.4.0), the exposed + catalogue — the same types under the same 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. + - Purposes (3.4.0) are compared by the caps that apply, over the names either side declares: for each name, the + `MaxPageSize` and the `DefaultPageSize` that name runs under, a value left null reading as the deployment's + own. A purpose whose caps equal the deployment's is the same posture as no entry; a purpose with any other page + cap is a different posture and is refused. - 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. @@ -1618,10 +1661,17 @@ builder.Services.AddSingleton(options); - `options.Bind(section)` is the DynamicWhere extension and binds with `ErrorOnUnknownConfiguration = true`. - `section.Bind(options)` is Microsoft's binder and silently ignores unknown keys. Do not use it. - A key that no property answers to (`Caps:MinGropSize`, `Teir`) → `InvalidOperationException` at bind time. -- A value its setter refuses also fails the bind: - - a cap below its minimum; + - Inside a purpose (3.4.0) only `MaxPageSize` and `DefaultPageSize` answer: a misspelt key there, or any other + cap (`Caps:Purposes:excel:MaxConditions`, which no purpose replaces), refuses to start the same way. +- A value its setter refuses also fails the bind, as `InvalidOperationException` (3.4.0): + - a cap below its minimum, a purpose's page cap below 1 included (3.4.0); - a `HashSalt` of 1–15 characters; - a non-positive `MaxSnapshotAge` or `RefreshInterval`. + - The message starts `A configured policy value was refused:` and `InnerException` is the setter's own + exception (`ArgumentOutOfRangeException`, `ArgumentException`), however deep the binder wrapped it: a + purpose's page cap is bound inside a dictionary, where Microsoft.Extensions.Configuration.Binder 8 adds an + `InvalidOperationException` of its own, and is reported the same way. Until 3.4.0 it arrived as the + binder's `TargetInvocationException`. - Binding onto frozen options fails. - An empty or missing section leaves every default in place, and `Caps.IsMinGroupSizeSet` stays false. - `TokenVault`, `Services` and `Entities` are objects, not values. They cannot come from configuration; set them in `configure`. @@ -1641,6 +1691,7 @@ Caps:MaxPageSize Caps:DefaultPageSize Caps:MaxConditions Caps:MaxConditionDep Caps:MaxConditionValues Caps:MaxAggregates Caps:MaxOrderFields Caps:MaxNavigationDepth Caps:MaxQueryCost Caps:DefaultFieldCost Caps:MaxAuditEvents Caps:MinGroupSize Caps:SchemaDepth Caps:SchemaCycleLimit Caps:MaxSchemaFields integers +Caps:Purposes::MaxPageSize Caps:Purposes::DefaultPageSize 3.4.0; integers >= 1; is any purpose ``` ```jsonc @@ -1734,6 +1785,9 @@ DwCaps sealed class; DwPolicyOptions.Caps. Setters: frozen → InvalidOperat composable Filter, FilterDynamic and Summary return the query already paged, so page through the request's Page, not a chained Page(). Where, Order, Select and Group take no page and are never given one. + Purposes empty - 3.4.0. DwPurposeCaps, get-only: the page caps of each declared purpose, + which replace MaxPageSize and DefaultPageSize for a query whose context + names that purpose (below); every other cap applies to all alike as the two above MaxConditions 50 1 conditions at every nesting depth; Summary: ConditionGroup + Having; Segment: all condition sets together CapExceeded (9) MaxConditionDepth 10 1 3.1.0. How deep SubConditionGroups nest, root group = depth 1. Summary: @@ -1799,6 +1853,87 @@ DwCaps sealed class; DwPolicyOptions.Caps. Setters: frozen → InvalidOperat - Every guarded grouping path applies it: `ToList` / `ToListAsync(Summary)` and the composable `Group` and `Summary`. - `MinGroupSize = 1` switches the floor off with no warning. `IsMinGroupSizeSet` distinguishes that from a deployment that never set it. +### Page caps per purpose — DwCaps.Purposes (3.4.0) + +`MaxPageSize` and `DefaultPageSize` bound what one response carries, and a deployment sets them for its screens. An +export or a report reads the same rows under the same field policy and was held to the same page: it stopped at the +first page, or walked the rest one page at a time, a statement and a count per page and no single snapshot of the +data. A purpose gives such a read page caps of its own without raising the screens'. + +``` +DwPurposeCaps : IDictionary sealed class, DynamicWhere.ex.Policies.Config; DwCaps.Purposes + this[string purpose] : DwPageCaps get: a name with none → KeyNotFoundException; + set: a null value → ArgumentNullException + Add(string key, DwPageCaps value) a name already there, in any case → ArgumentException + Add(KeyValuePair item) + Remove(string key) -> bool + Remove(KeyValuePair item) -> bool only where that name holds that instance + Clear() + TryGetValue(string key, out DwPageCaps value) -> bool a null or blank name → false + ContainsKey(string key) -> bool a null or blank name → false + Contains(KeyValuePair item) -> bool that name holds that instance + Keys : ICollection trimmed, casing as first written + Values : ICollection Count : int + IsReadOnly : bool true once frozen + CopyTo(KeyValuePair[] array, int arrayIndex) GetEnumerator() + a null or blank name → ArgumentException (the indexer, Add, Remove); + once frozen → InvalidOperationException (the indexer's set, Add, Remove, Clear) + +DwPageCaps sealed class, DynamicWhere.ex.Policies.Config + DwPageCaps() + MaxPageSize : int? { get; set; } null = DwCaps.MaxPageSize; below 1 → ArgumentOutOfRangeException + DefaultPageSize : int? { get; set; } null = DwCaps.DefaultPageSize; below 1 → ArgumentOutOfRangeException + a set once the posture is frozen → InvalidOperationException, for an instance assigned before startup too +``` + +```csharp +// appsettings: "Caps": { "MaxPageSize": 1000, "DefaultPageSize": 100, +// "Purposes": { "excel": { "MaxPageSize": 10000, "DefaultPageSize": 10000 } } } +// or in code, before Configure: +options.Caps.Purposes["excel"] = new DwPageCaps { MaxPageSize = 10_000, DefaultPageSize = 10_000 }; +options.Caps.Purposes["audit-logs"] = new DwPageCaps { MaxPageSize = 5000 }; // default page: the deployment's + +// The export handler. +context.Purpose = "excel"; // before the read, set by the host +FilterResult file = await rows + .ApplyPolicy(context) + .ToListAsync(filter, cancellationToken); // one statement, up to 10,000 rows +bool truncated = file.TotalCount > file.Data.Count; +``` + +- A query runs under a purpose's page caps when its `DwPolicyContext.Purpose` names that purpose. The name is + trimmed and compared without regard to letter case, the way a purpose-bound rule matches (section 25). A context + naming no purpose, or one no entry names, runs under the deployment's caps, so a read that forgets to declare + itself is bounded like a screen's. +- Only the two page caps are replaced. Every other cap, the cost budget, the field policy and the group floor apply + to every purpose alike. +- A value left null takes the deployment's cap. A value set is at least 1, so a purpose bounds a read differently + and cannot switch a bound off. The default page a purpose runs under is bounded by the maximum it runs under, as + the deployment's is by its own: page 1 at min(DefaultPageSize, MaxPageSize), each the purpose's where it sets one, so + a default above the maximum is applied as the maximum. A purpose may lower the caps as well as raise them. + - With the deployment's `DefaultPageSize` at 0 (off), a purpose that sets only `MaxPageSize` leaves a request with + no page unpaged, as the deployment does. Give the purpose a `DefaultPageSize` to bound one. +- A page above the purpose's maximum is refused, never trimmed: `CapExceeded` (9), `SourceOrigin` + `"MaxPageSize cap (10000), request had 10001"`, `FieldPath` `"*"`, in both tiers. The + purpose's caps apply wherever the deployment's do: `ToList`, `ToListAsync` and the dynamic pair with a `Filter`, a + `Summary`'s page, a `Segment`'s page, and the composable `Page(PageBy)`, `Filter`, `FilterDynamic` and `Summary`. +- A simulation copies the context's purpose, so `PolicySimulator` and `/simulate` answer under the same caps. +- Names: any, and as many as a deployment has (`excel`, `csv`, `pdf`, `audit-logs`). Several names may share one + `DwPageCaps` instance. Keys are stored trimmed. +- Configuration: `Caps:Purposes::MaxPageSize` and `Caps:Purposes::DefaultPageSize`, bound by `Bind` and + `AddDwPolicies` with `ErrorOnUnknownConfiguration`. A misspelt key inside a purpose, or any other cap there + (`MaxConditions`), refuses to start with `InvalidOperationException`, and so does a value below 1. + Environment variables, for a section at `DynamicWhere:Policies`: + `DynamicWhere__Policies__Caps__Purposes__excel__MaxPageSize=10000`. +- Frozen with the posture: after startup, adding, replacing, removing or clearing a purpose, and setting a value on + any `DwPageCaps` it holds, one assigned before startup included, throws `InvalidOperationException`, and + `IsReadOnly` is true. +- A second `DwPolicy.Configure` compares purposes by the caps that apply (above). +- **The purpose is the host's statement, never the caller's.** A request that could name its own purpose could name + its own page caps, and whatever purpose-bound grants exist. Set it in the endpoint that serves the read; never + bind it from a request header, a query string or a body. `DwClaimsOptions.Purpose` is a constant for the same + reason (section 29). + ### DwPolicyContext and DwSubject ``` @@ -1809,7 +1944,9 @@ DwPolicyContext sealed class IsPrepared : bool { get; } 3.1.0. True once DwPolicy.PrepareAsync has run against it, with or without a store. ApplyPolicy(ctx) refuses a context where this is false - Purpose : string? { get; set; } matched by purpose-bound rules; copied into audit events + Purpose : string? { get; set; } stored trimmed, a blank value as null (3.4.0); matched by + purpose-bound rules and by DwCaps.Purposes; copied into + audit events PendingAuditEvents : IReadOnlyList a copy of the undrained buffer WithSubject(DwSubjectKind kind, string identity) -> DwPolicyContext mutates this instance, returns this WithValue(string key, object? value) -> DwPolicyContext mutates this instance, returns this @@ -1839,6 +1976,10 @@ DwSubjectKind Global=0 Tenant=1 Role=2 User=3 Custom=4 - Calling `PrepareAsync` again replaces what was pinned. - A context serves the one snapshot pinned at `PrepareAsync`. Once it is older than `MaxSnapshotAge`, store-backed queries throw `StoreUnavailable`, so never cache contexts across requests. - Queries may share one context concurrently, because the audit buffer is locked. Subjects and values are not synchronized: finish building before querying. +- `Purpose` is stored trimmed, and a null, empty or blank value is stored as null (3.4.0). A rule's purpose was + already trimmed where the rule is built, so `" export "` on a context now means to a purpose-bound rule what it + means to a purpose's page caps; it used to match no rule. The host sets it, never the caller (page caps per + purpose, above). - There is no public correlation id or attachment API; `IsPrepared` (3.1.0) says only that `PrepareAsync` ran. --- @@ -2058,7 +2199,8 @@ AttributeUsage (every attribute: Inherited = true) 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`. + on, grouped by and returned, under `Strict`. Since 3.4.0 the attributes of each application struct such a path + passes through are read the same way, so `A.B.C.D.Price.Amount` takes `Price`'s policy as `Price.Amount` does. - 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 @@ -2124,8 +2266,9 @@ 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: it is declared - by an application's own type, so an attribute can be placed on it. Decorate it, or deny its path. +- A denial on a class 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 + struct is not a navigation: a denial on one covers its parts (3.4.0, below). - 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 @@ -2146,6 +2289,45 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. - 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. +- A path beneath a member whose type is an application's own struct — a value type outside the `System` + namespaces, nullable or not — or a collection of them takes that member's policy as well (3.4.0). A struct is a + value, not a navigation: with `[DwDenied]` on an `Iban` struct, `Iban.Number` was filtered on (a test for a + guessed value), sorted by, grouped by with its values as the group keys and handed back by a dynamic projection, + under `Strict`. + - Taken from the struct member, from every provider, attributes and rules alike: its deny effects per feature, + its `[DwOperators]` restriction (intersected with the path's own), its `[DwCost]` weight and its audited + features. Not taken: its alias, required filter, forced scope and descriptive facts (label, description, + group, order, allowed values), which come from the path's own member. + - The path's own attributes still apply beside the struct member's (`[DwNoOrder]` on `LocalizedText.En`), and + precedence decides between them as usual: a sealed attribute beats every rule and a rule beats an overridable + attribute, so a rule allowing `Soft.Number` replaces an overridable deny on `Soft`, and no rule replaces a + sealed `[DwDenied]` on the struct. + - Every struct on the path decides it: `Card.Inner.Ar` takes both `Card`'s and `Inner`'s policy. A struct inside + a class navigation counts (`Owner.Name.Ar` takes `Owner.Name`'s); the class itself does not. + - A struct member that is itself transformed (`[DwMask]` on `Price`) refuses `Select`, `Group` and `Aggregate` + on its parts: its chain has no member of theirs to apply to. A part that declares its own transform is + transformed as any member is and stays selectable (section 18). + - Past the attribute walk's depth the attributes of each struct the path passes through, and of the path's own + member, are read directly. + - `Explain` names the struct member's attribute as `DecidedBy`, and `PolicySchemaBuilder.Describe` + (`POST /schema`) reports each part with its struct member's denials. + - A member declared as a nullable struct (`Iban?`) is covered too (3.4.0). A query names its parts through + `Value` (`Iban.Value.Number`), where the attribute walk, the transforms and the fragments name them without it + (`Iban.Number`), so the policy reads such a path as the walk names it: a `Value` after a nullable struct of + the application's is dropped, `Iban.Value.Number` is decided as `Iban.Number` and `Iban.Value` as `Iban`, and + the nullable's own members, `Iban.HasValue` among them, are read as the nullable member `Iban`. A framework + nullable is unchanged: `Salary.Value` reads `Salary`, as in 3.3.0. With `[DwDenied] Iban? Iban`, + `Iban.Value.Number` and `Iban.HasValue` are decided as `Iban` is, typed and dynamic: refused in every clause + under `Strict`, and under `Convenience` refused as a filter, a group or an aggregate and dropped as a select + or a sort. A `[DwDenied]` member inside a nullable struct (`Pair.Value.Hidden`) is decided the same way, an + allowed one (`Pair.Value.Shown`) stays usable, and a mask on a member inside one is applied where a selection + names it through `Value` (section 18). `Resolve` and `Explain` report the path as the policy names it, `FieldPath` `Iban.Number`; the trace + keeps the caller's spelling. Until 3.4.0 such a path matched no fragment and was allowed, in every clause and + both terminals; the typed selection change of this release (section 6) had widened it to the typed terminal + before it was fixed. + - `[DwNoWhere, DwNoOrder] public LocalizedText Name { get; set; }` on a row refuses `Name.Ar` and `Name.En` as + filters (`FieldDeniedForWhere`, both tiers) and as sorts (`FieldDeniedForOrder` under `Strict`; dropped under + `Convenience`), and both can still be selected. - `[DwDeny(PolicyFeature.None)]` refuses nothing, and nothing reports it. - `[DwNoSelect]` leaves the field filterable, sortable, groupable and aggregatable, so it can still be counted. @@ -2167,7 +2349,8 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. - 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. 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`. + `Salary.Value`, and so does a part of an application's struct with its struct member's (3.4.0): a restriction + on `Iban` holds for `Iban.Number`, intersected with any on `Number`. - 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, @@ -2201,7 +2384,8 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. 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. + is a public name for the member, and `Salary.Value` is not the member. Nor does a part of an application's + struct take its struct member's alias (3.4.0). ### DwForceWhere @@ -2263,7 +2447,8 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. 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. + scope is about the rows, not about the value one segment down. Nor does a part of an application's struct + take one from its struct member (3.4.0). ### DwRequireWhere @@ -2287,7 +2472,8 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. 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. + filter on `TenantId`, which `TenantId.Value` is not. A part of a struct takes no requirement from its struct + member either (3.4.0). - It is elected: a rule cannot lift a sealed requirement but may add one where none exists. A `"*"` rule cannot carry one. @@ -2316,7 +2502,8 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. 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. + costs what `Notes` costs, where it used to cost the default. A part of an application's struct takes its struct + member's weight beside its own (3.4.0), elected as below: of two sealed attribute weights, the larger. - 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 @@ -2348,7 +2535,8 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. 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. + `Salary.Value` records what reading `Salary` records, where it used to record nothing. A part of an + application's struct is audited as its struct member is, beside its own `[DwAudit]` (3.4.0). - 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 @@ -2498,7 +2686,9 @@ 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 a filter or an order inside a `Segment`. +compute: a filter, an order, a grouping key, an aggregated field, and a filter or an order inside a `Segment`. Since +3.4.0 that includes a path through a member the projection builds by calling an application type's constructor with +arguments, which EF Core cannot follow (below). `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. @@ -2515,6 +2705,9 @@ Select(...) before ApplyPolicy building the row that initializer's assignme 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 + ... a member or a row it builds by calling an the members an initializer beside the + application type's constructor with constructor binds; EF Core follows no other + arguments (3.4.0) refused rows in memory (EnumerableQuery) nothing; the getter runs 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 @@ -2543,8 +2736,30 @@ a projection another provider ran nothing; its rules are its 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. +- An anonymous type has no member refused here, and neither does a member assigned from a subquery (below): EF Core + follows each member of an anonymous type to its argument. +- A row or a member an application type's constructor builds with arguments has every member the constructor leaves + unbound refused (3.4.0): EF Core follows a member only through an initializer's binding. So + `new LocalizedText(t.NameAr, t.NameEn).Ar` cannot be translated, while + `new LocalizedText { Ar = t.NameAr, En = t.NameEn }.Ar` is `t.NameAr` and translates to the column. Build such a + member with an initializer. The trace records the reason given below. + - A row built by its own constructor, a positional record row (`Select(t => new TenantRecord(t.Id, t.NameAr))`), + has every member a clause names refused. An unfiltered, unsorted read of it still works. + - A clause on the constructed member itself is refused too (`ORDER BY Name`, `Label IsNotNull`): EF Core can + neither order by nor compare a value it would have to build on the client. Selecting it still works. + - A constructor with an initializer beside it (`new Money(t.Amount) { Currency = t.Currency }`): a member the + initializer binds (`Money.Currency`) stays usable, and a member left to the constructor (`Money.Amount`) is + refused. + - A conditional: a constructor in either branch refuses what that branch leaves unbound, whatever the other + branch binds, because EF Core reads the member through both branches. + - Left alone, as before: an anonymous type, whose constructor names its members; the framework's own types + (`new DateTime(y, 1, 1)`), which a provider may translate; a collection; a type the EF Core model stores as a + column on any entity, through a value converter or as a spatial point; a projection with no EF Core entity + behind it; a provider that is not EF Core's own; rows in memory. `Convenience` and a dry run fail as the + unguarded query does. + - A selection through such a member still works: EF Core evaluates the last projection on the client. + - 3.3.0 read a constructor with arguments as saying nothing about which member each value sets and refused no + member of such a row, so every clause on one went on to fail inside the provider: a five-hundred. - 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. @@ -2588,8 +2803,15 @@ any clause on a path the attribute walk cannot name: path past four segments with MaxNavigationDepth raised (3.3.0) the member's same the member's own code own decision +any clause on a part of an application's struct, + Iban.Number, or of a struct in a collection (3.4.0) the struct same the struct member's or the + member's and its part's own code + own decision +WHERE, ORDER, GROUP or AGGREGATE on a path through a + member a projection builds by constructor (3.4.0) provider throws throw that clause's FieldDeniedFor* SELECT a path beneath a transformed member, Bonus.Value: - no member there to apply the chain to (3.3.0) drop throw FieldDeniedForSelect + no member there to apply the chain to (3.3.0), or a + part of a transformed struct member (3.4.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 / @@ -2638,12 +2860,13 @@ DefaultOrder field denied for Segment, in a Segment (3.1.0) left out l cannot narrow it (a column, complex or JSON member at the top of T, or a member of a row in memory) it is refused in both tiers. - A narrowing that cannot be built as gated is refused with `FieldDeniedForSelect`, in both tiers (3.2.0). The - core's typed projection adds the key (`Id`) of every nested node it builds, so a narrowing whose nodes carry a - denied key would return it; a path through a collection the core does not unwrap fails its validation; and a + core's typed projection adds the key (`Id`) of every nested class it builds (never of a struct, which it builds + with exactly what was named, 3.4.0), so a narrowing whose nodes carry a denied key would return it; a path through a collection the core does not unwrap fails its validation; and a column, complex property or JSON-stored member, a member of a row in memory, or one a projection builds some way the core cannot narrow, cannot be narrowed at all. - A navigation named through another, `Main.Lead`, gates the key of every node it passes through, which the - builder adds, as a dotted path to a value always did (3.2.0). A denied one refuses the projection. + builder adds, as a dotted path to a value always did (3.2.0). A denied one refuses the projection. Only a + class's key: a struct's is neither added nor gated, since no builder adds it (3.4.0). - "allowed members" (3.2.0; "allowed scalars" before) applies when `Selects` is null or empty and a field is denied for Select where its value can reach the result. It runs for a whole-`Filter` terminal and for a Segment, not for a single-clause composable call. @@ -2719,6 +2942,12 @@ DefaultOrder field denied for Segment, in a Segment (3.1.0) left out l denied key the core's projection would add back; a path the core cannot project; a node type the core cannot construct (an interface, an abstract class, one with no public parameterless constructor); nothing beneath it left to select; + - an application's own struct holding a denied member is narrowed as a class is (3.4.0): the typed projection + builds one member by member, held directly or in a list, any type a list can be assigned to, or an array, so + its allowed members come back and the denied one does not. Until 3.4.0 the gate read a struct as a type the + core cannot build and left it out whole (`left out whole: the core cannot build '…', which it narrows into`), + so its allowed members came back empty. A nullable struct, or structs in a collection of another shape, are + still left out whole; - Never kept: an entity's navigation, included or not, since projecting it would load it (under Convenience, name it in `Selects` to get it narrowed; under Strict, name its allowed fields); an object held by a row in memory, since a kept object is the caller's own and a transform would change it in place; a member with no setter; a @@ -2755,6 +2984,11 @@ DefaultOrder field denied for Segment, in a Segment (3.1.0) left out l 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 part of an application's struct takes the policy of every struct member above it, beside its own (3.4.0, + section 14): `Iban.Number` beneath a `[DwDenied] Iban` is refused in every clause, as `Iban` is, and so is + `Iban.Value.Number` beneath a `[DwDenied] Iban?`, which the policy reads as `Iban.Number`. The same split + as above holds: the deny effects, operators, cost and audit come from the struct member, and its alias, required + filter, forced scope and descriptive facts do not. - 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. @@ -2865,6 +3099,9 @@ alone Default [DwDefault] constant or type default, converted to mem 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. + So is a part of a struct member that is itself transformed (3.4.0): `Price.Amount`, with `[DwMask]` on `Price`. + A part that declares its own transform (`[DwMask]` on `Price.Amount` itself) is transformed as any member is and + stays selectable (below). - 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 @@ -2875,6 +3112,28 @@ alone Default [DwDefault] constant or type default, converted to mem 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`. +- A transform declared on a member of an application's own struct is applied to the row (3.4.0). A struct is read + as a copy, and the compiled setter used to unbox a second copy and write into that, so every mask, + generalization, format, truncation, default or mutation on a struct's member, `Hash` and `Tokenize` included, + landed on a temporary and the stored value was emitted: in both tiers, from rows in memory and from EF Core, + typed and dynamic, whether the struct came back in a whole row or was selected whole. (A dynamic selection of a + path beneath it builds an object of its own, and that one was transformed already.) The setter writes into the + box in place now, and both passes of the walk write each changed struct back where it was read from, innermost + first: into the member that held it, into its position in a list or an array, or into the outer struct that held + it. A struct in a whole row or selected whole, nullable or not, a list or an array of structs, a struct inside a + struct or inside a class navigation, and a struct past the walk's depth are all transformed, and a member the + policy names is transformed once, by the pass along its path. A class member was never affected. + - Fail-closed: a struct a transform changed that cannot be written back fails the query with + `InvalidOperationException` rather than hand back the stored value. A struct held by a member with no setter + ("…passes through a struct held by a member with no setter…"), structs in a collection that cannot be written + by position, such as a `HashSet` ("…Hold the structs in a list or an array."), and a struct that is a + dictionary's value ("…cannot be written back where it was read from…"). + - A source of rows in memory is still transformed in place, its structs included (section 32, trap 22). + - A member inside a nullable struct is transformed where a selection names it through `Value` + (`Pair.Value.Code`) as well (3.4.0): on the typed terminal; on the dynamic one, whose generated row holds the + member under `Value` and which the walk steps through; as a group key (the `PairValueCode` column); and past + the walk's depth. The group floor a masked member declares applies to an aggregate over it. Until 3.4.0 the + path was looked up as the query spells it, matched no transform, and the value came back as stored. - Segment results are transformed like Filter results. - In a summary, each transformed grouping-key column and each aliased aggregate of a transformed field gets that field's chain. @@ -3283,6 +3542,9 @@ PolicyExplanation why = DwPolicy.Resolver.Explain(typeof(Employee), "Salary", ca - `PolicyResolver.Explain` is the only explain API in the core package. - `new PolicyResolver(...)` does not add `AttributePolicyProvider`; only `DwPolicy.Configure` does. Include it yourself, or attributes are ignored. - `fieldPath` must be the canonical property path. It is trimmed, empty segments are dropped, and matching is case-insensitive. + - A `Value` after a nullable struct of the application's is dropped (3.4.0), so `Iban.Value.Number` resolves and + explains as `Iban.Number`, and the `FieldPath` of the `FieldPolicy` and of the `PolicyExplanation` is that + spelling. A framework nullable, `Salary.Value`, is left as written. - An alias is not translated, and the path is not checked for existence. - Either mistake explains as Allow for every feature, because only wildcard fragments match. - Errors: @@ -3291,6 +3553,9 @@ PolicyExplanation why = DwPolicy.Resolver.Explain(typeof(Employee), "Salary", ca - a provider returning null or a null fragment → `InvalidOperationException` naming the provider. - Resolution uses the context: with a store configured, an unprepared context throws `PolicyContextNotPrepared`. - Explaining records no audit events. +- A path beneath a framework-typed member (3.3.0) or a part of an application's struct (3.4.0) is explained from the + same fragments `Resolve` decides it from, so `DecidedBy` names the member above it where that member decided: + `DwDeniedAttribute` on `Iban` for `Iban.Number`. ``` PolicyExplanation sealed class, DynamicWhere.ex.Policies.DTOs @@ -3612,6 +3877,9 @@ PolicySchemaNode sealed class - its type maps to a `DataType`, and - the caller may use it for at least one feature. - A fully denied field (`[DwDenied]`) never appears. Audited fields are not marked. +- A part of an application's struct is described as its struct member's policy leaves it (3.4.0): + `[DwNoWhere, DwNoOrder]` on `LocalizedText Name` lists `Name.Ar` with `CanWhere` and `CanOrder` false and + `CanSelect` true. - The entity is level 1; a root path of n segments is level n + 1. A root at level L walks to `min(L + Depth - 1, MaxNavigationDepth)`. - A larger `Depth` is clamped, never refused; read `Depth` and `MaxDepth` back. - `Depth < 1` → `ArgumentException`. @@ -3809,6 +4077,9 @@ How a rule applies, evaluated on every query: - A rule on a navigation does not cover the fields beneath it. - **Subject:** a Custom identity carries no dimension name, so every Custom subject with the same value matches. - **Purpose:** a rule with a purpose never applies to a caller whose `Purpose` is null. Write a denial that must always hold without a purpose. + Both sides are trimmed and compared ignoring case: the rule where it is built, the context where `Purpose` is set + (3.4.0), so `" Export "` on a context matches a rule for `export`; it used to match nothing. The same name selects + the purpose's page caps (section 12). - **Validity window:** tested against UtcNow on each query, never at load. - **Disabled rules** never reach a snapshot. - **Transform stages** elect one winner per stage. A rule can add a stage on top of a sealed chain but cannot replace a sealed stage. @@ -4410,7 +4681,8 @@ DwClaimsOptions IDictionary SubjectClaimTypes claim type -> kind; empty; keys case-insensitive IDictionary ValueClaimTypes context key -> claim type; empty; keys case-insensitive bool AllowAnonymous = false - string? Purpose = null copied to DwPolicyContext.Purpose + string? Purpose = null copied to DwPolicyContext.Purpose, which stores + it trimmed (3.4.0); a constant, never a claim ``` - **Two builders:** `CreateContextAsync` is `FromClaims` followed by `DwPolicy.PrepareAsync`. A `FromClaims` context is unprepared, and since 3.1.0 `ApplyPolicy(context)` refuses it with `PolicyContextNotPrepared` whether or not a store is configured: prepare it before querying, or use `CreateContextAsync`. `FromClaims` is for adding to the context before preparing it. - **Refusals:** @@ -4492,8 +4764,9 @@ through `PolicySimulator` or in dry run to see the decisions). every other field refusal inside a Segment, codes 1–5 included 7 AllSelectsDenied Convenience dropped every requested select (FieldPath "*") recorded 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 +9 CapExceeded MaxPageSize (the declared purpose's, where DwCaps.Purposes has one, recorded, except the + 3.4.0), MaxConditions, MaxConditionDepth, MaxConditionSets, audit buffer: throws + MaxConditionValues, MaxAggregates, MaxOrderFields or MaxNavigationDepth exceeded; or the context already holds MaxAuditEvents undrained events, 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 @@ -4616,6 +4889,16 @@ catch (LogicException ex) `[DwAllowedValues("Active", "Suspended", "Closed")]`. - **Refuse unscoped scans, allow scoped ones:** `[DwRequireWhere]` on `Department`. - **Expensive, sensitive field:** `[DwCost(10)]` + `[DwAudit(PolicyFeature.Select | PolicyFeature.Where)]`. +- **An export larger than a screen's page** (3.4.0): + `options.Caps.Purposes["excel"] = new DwPageCaps { MaxPageSize = 10_000, DefaultPageSize = 10_000 }`, and + `context.Purpose = "excel"` in the export endpoint alone. The screens keep their page; the export reads every match + up to 10,000 in one statement, and a page past that is refused. `TotalCount` above `Data.Count` says the file was + cut short. +- **A value composed over two columns** (3.4.0): project it with an initializer, + `Name = new LocalizedText { Ar = t.NameAr, En = t.NameEn }`, so `Name.Ar` and `Name.En` filter and sort on the + columns. A constructor, `new LocalizedText(t.NameAr, t.NameEn)`, leaves every clause on them refused under + `Strict`. `[DwNoWhere, DwNoOrder]` on the member refuses the value and its parts as filters and sorts and keeps + them selectable. ``` This needs or else @@ -4626,6 +4909,8 @@ MaskStrategy.Hash HashSalt of 16+ characters MissingHashSalt MaskStrategy.Tokenize TokenVault MissingTokenVault tokens that must match one explicit TokenScope the scope follows each field path any policy [DwEntity(RequirePolicy = true)] a DynamicWhere call without ApplyPolicy reads all +a purpose's page caps the purpose set by the host a caller picks its own caps and purpose-bound grants +a composed value's parts an initializer, not a constructor every clause on a part is refused under Strict ``` ### Inference channels @@ -4702,14 +4987,16 @@ Read before generating policy attributes. and `In`, so a caller can confirm a known value but cannot sweep for one. 4. **A forced predicate wraps the caller's group; it never merges into it.** The result is `(A OR B) AND TenantId = 5`. Do not hand-build the equivalent. -5. **`[DwDenied]` on a navigation denies only that path; `Contact.Email` stays open.** Either decorate the +5. **`[DwDenied]` on a class 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. + A class is a navigation whose members are separate fields. Two kinds of member are not, and a path beneath + them takes their policy: one whose type the framework declares (`Salary.Value`, `Secret.Length`, + `Born.Year`, `Bag.Count`, since 3.3.0), where nothing can be decorated, and one holding an application's own + struct or a collection of them (`Iban.Number`, since 3.4.0), because a struct is a value. Either takes the + member's denials, operators, cost and audit, never its alias, required filter, forced scope or description, + and a struct's parts keep their own attributes beside it (section 14). A member declared as a nullable + struct is covered too, its parts named through `Value` (trap 41). 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. @@ -4761,7 +5048,8 @@ Read before generating policy attributes. 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. + `[DwRequireWhere]`, `[DwForceWhere]`. Until 3.3.0 such a member was allowed and untransformed. Since 3.4.0 the + attributes of each application struct such a path passes through are read the same way. 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`. @@ -4825,6 +5113,26 @@ Read before generating policy attributes. `DefaultOrder` are gone rather than merged. `[DwEntity(DefaultOrder = "Id")]` on a subclass of a `RequirePolicy` type lets a DynamicWhere call on the subclass run without `ApplyPolicy`. Repeat every setting on the derived type. +39. **The purpose is the host's, never the caller's (3.4.0).** `DwPolicyContext.Purpose` selects a purpose's page + caps in `DwCaps.Purposes` and every purpose-bound rule. Bound from a request header, a query string or a body, it + lets a caller pick the largest page any purpose allows, and whatever a purpose-bound rule grants. Set it in the + endpoint that serves the export. A purpose no entry names runs under the deployment's caps with nothing said, + so a misspelt name, in the configuration or in code, bounds the export like a screen. +40. **A projection built with a constructor hides its members from every clause (3.4.0).** EF Core follows a member + only through an initializer's binding, so `Select(t => new Row { Name = new LocalizedText(t.NameAr, t.NameEn) })` + leaves `Name.Ar` impossible to filter, order, group or aggregate: under `Strict` it is refused as an unknown name + is, and elsewhere EF Core throws. Write `new LocalizedText { Ar = t.NameAr, En = t.NameEn }`. `Selects` is + unaffected. +41. **A struct's parts share its denial, a nullable struct's included (3.4.0).** `[DwNoWhere]` on a + `LocalizedText Name` refuses a filter on `Name.Ar` too, where a class navigation's denial would not. On a + `LocalizedText? Alias` it refuses `Alias.Value.Ar`, which the policy reads as `Alias.Ar`, and `Alias.HasValue`. + To keep one part filterable, deny the member with `Overridable = true` and allow the part with a rule. +42. **A struct a transform changes must be writable where it is held (3.4.0).** A struct is a copy, so a masked + member of one is written back into the member, list position or outer struct that held it. A struct held by a + member with no setter, structs in a collection that cannot be written by position (a `HashSet`), and a + struct that is a dictionary's value cannot be, and the query fails with `InvalidOperationException` rather than + return the stored value. Give such a member a setter and hold such structs in a list or an array. Until 3.4.0 + no transform on a struct's member was applied at all (section 18). --- @@ -5271,6 +5579,127 @@ MemoryCalculationInput ### History ``` +3.4.0 Page caps per declared purpose; the parts of a struct, a nullable one's included, policed as the struct is, + and a transform on a struct's member applied; a path through a member a constructor builds refused under + Strict rather than failing in the provider; and a typed selection through a struct, or beneath a collection of + them, that carries what it names and nothing beside it. + - New. DwCaps.Purposes, a DwPurposeCaps (IDictionary), gives a declared purpose page caps + of its own: DwPageCaps.MaxPageSize and DefaultPageSize, each an int? whose null takes the deployment's + cap. MaxPageSize and DefaultPageSize bound what one response carries and are set for screens; an export + or a report read the same rows under the same field policy and was held to the same page, so it stopped + at the first page or walked the rest one page at a time, a statement and a count per page and no single + snapshot. A query runs under a purpose's caps when its DwPolicyContext.Purpose names that purpose, + trimmed and compared without regard to letter case as a purpose-bound rule is; a context naming no + purpose, or one no entry names, runs under the deployment's caps. A value set is at least 1 + (ArgumentOutOfRangeException), so no purpose can switch a bound off, and the default page a purpose runs + under is bounded by the maximum it runs under. Only the two page caps are replaced. A page above the purpose's maximum is + refused with CapExceeded, in both tiers, never trimmed, wherever a guarded call takes a page: the Filter + terminals, a Summary's page, a Segment's page and the composable Page. Any name and as many as a + deployment has; several names may share one DwPageCaps; a null or blank name → ArgumentException, a null + value → ArgumentNullException, and TryGetValue and ContainsKey answer false for one. It binds from + Caps:Purposes::MaxPageSize and :DefaultPageSize, where any other key refuses to start; it freezes + with the posture, each DwPageCaps it holds included; and a second Configure compares purposes by the + caps that apply. The purpose is the host's statement, never the caller's: a request that could name its + own purpose could name its own caps. Section 12. + - Behaviour change. DwPolicyContext.Purpose is stored trimmed, and a null, empty or blank value as null. A + rule's purpose was already trimmed where the rule is built, so " export " on a context now matches a + purpose-bound rule for export, which it used to miss, and means to a rule what it means to a purpose's + page caps. + - Security fix and behaviour change. A path beneath a member whose type is an application's own struct, or + a collection of them, takes that member's policy, as a path beneath a framework-typed member has since + 3.3.0. A struct is a value, not a navigation: with [DwDenied] on an Iban struct, Iban.Number was filtered + on (a test for a guessed value), sorted by, grouped by with its values as the group keys and handed back + by a dynamic projection, under Strict. Taken from the struct member, from every provider: its denials, + its operators (intersected with the path's own), its cost and its audit; not its alias, required filter, + forced scope or descriptive facts, which come from the path's own member. The path's own attributes still + apply beside the struct member's, and precedence decides as usual: a rule allowing Soft.Number replaces + an overridable deny on Soft, and no rule replaces a sealed [DwDenied] on the struct. Every struct on the + path decides it (Card.Inner.Ar), a struct inside a class navigation counts (Owner.Name.Ar), and the class + itself does not: a denial of Customer still denies only that path. A transformed struct member refuses + Select, Group and Aggregate on its parts. Past the walk's depth the attributes of each struct the path + passes through are read directly. Explain names the struct member's attribute, and the schema reports + the parts with its denials. A nullable struct's parts are covered by the next bullet. Both tiers; present + in 3.3.0 and earlier. + - Security fix. A member of a nullable struct is policed as the policy names it. A query reaches a member of + an Iban? through the nullable, Iban.Value.Number, while the attribute walk, the transforms and the + fragments name it Iban.Number, so a lookup by the query's spelling matched none of them: a [DwDenied] on + the nullable member or on a member of the struct did not stop a filter, a sort, a grouping key or a + selection of that member, Iban.HasValue was open, and a mask on a member of the struct was not applied + when a selection named it through Value, as a result column or as a group key. Present since the policy + layer shipped in 3.0.0; this release's typed selection change had widened it to the typed terminal before + release. A Value after a nullable struct of the application's is dropped now wherever the policy reads a + path — the resolver, the outbound walk, the summary's key and aggregate transforms, the group floor and + the past-depth transforms — so Iban.Value.Number is decided as Iban.Number and Iban.Value as Iban; a + nullable's own members, HasValue included, are read as the nullable member; and the walk steps through + Value on a dynamic projection's generated row. A framework nullable, Salary.Value, is unchanged. Resolve + and Explain report the path as the policy names it. Section 14. + - Behaviour change. Under Strict outside a dry run, a filter, an order, a grouping key, an aggregated field + or a segment condition naming a path through a member the projection builds by calling an application + type's constructor with arguments is refused as an unknown name is (the clause's FieldDeniedFor* code, + FieldPath "*"), where EF Core threw "could not be translated" and the caller saw a five-hundred. EF Core + follows a member only through an initializer's binding: new LocalizedText(t.NameAr, t.NameEn).Ar cannot + be translated, and new LocalizedText { Ar = t.NameAr, En = t.NameEn }.Ar is the column. A row built by its + own constructor has every member a clause names refused, and so is a clause on the constructed member + itself; a member an initializer beside the constructor binds stays usable; a constructor in either + branch of a conditional refuses what that branch leaves unbound. Left alone: an anonymous type, the framework's own types, a collection, a type the model stores + as a column, a projection with no EF Core entity behind it, a provider that is not EF Core's own, rows in + memory, Convenience and a dry run. A selection through such a member still works. This replaces the + 3.3.0 statement that such a projection has no member refused. Section 17. + - Fix. A typed selection through a struct returns its values. Select(List), and a Filter's or a + Segment's Selects on the typed terminals, skipped every value-typed member a path went beneath, so + Select(["Name.Ar"]) over a LocalizedText struct returned the struct's default, an empty name, with + nothing refused, guarded or not. An application's own struct is built member by member now, as a class + navigation is: the row carries what was named and nothing beside it, nested structs are built level by + level, a collection inside one is carried, and a nullable struct is named through Value and built where + it has one. Still unbound: a class navigation inside a struct, a struct in which nothing named can be set, + and a path beneath a framework-typed member (Born.Year), which a typed row cannot hold apart from the + date; the dynamic terminals carry that as it is named. Section 6. + - Behaviour change. A guarded selection through a struct carries only what was named. The gate added the + key (Id) of every node a selected path passes through, since the builder adds a class's key, and read a + struct as one: DeniedBy.Value.Why came back with the struct's Id beside it, typed and dynamic, and a + struct whose Id is denied had a selection of its other members refused and was left out of a + synthesized projection. Neither builder adds a struct's key, so the gate adds and gates a class's key + only. Section 17. + - Security fix. A typed selection beneath a collection of structs carries only what it names. The typed + projection bound every collection of values whole, so Pairs.Shown over a List handed back each + element whole, a [DwDenied] member included, in both tiers, while the policy had approved only + Pairs.Shown. Selecting Pairs whole was, and is, refused when an element member is denied, and the dynamic + terminals were never affected: they project Pairs.Select(v => new(v.Shown)). Each element of a + collection of an application's own structs is built member by member now, + Pairs.Select(v => new Pair { Shown = v.Shown }).ToList(), into a List, any member type a List can + be assigned to, or a T[]. A null collection stays null; a collection of nullable structs, one of another + shape, and an element in which nothing named can be set are left unbound, carrying nothing rather than + everything; a collection of scalars is still bound whole. It works on EF Core where the projection reads + the list from a query, and in memory. Behaviour change beside it: a typed selection beneath a list a + projection builds with a collection initializer now fails on EF Core as the dynamic one always did + (limits, below). Present since the policy layer shipped in 3.0.0. Section 6. + - Fix. A projection the policy synthesizes for a request with no Selects narrows a member holding an + application's own struct that has a denied member, held directly or in a list, any type a list can be + assigned to, or an array, as it narrows a class: the allowed members come back and the denied one does + not. The gate read a struct as a type the core could not build and left it out whole ("left out whole: + the core cannot build '…', which it narrows into"), so its allowed members came back empty. A nullable + struct, or structs in a collection of another shape, are still left out whole. Section 17. + - Security fix and behaviour change. A transform declared on a member of an application's own struct is + applied. The outbound walk read the struct as a boxed copy and the compiled setter unboxed a second copy + to write into, so [DwMask] (Hash and Tokenize included), [DwGeneralize], [DwFormat], [DwTruncate], + [DwDefault] and [DwMutate] on a struct's member landed on a temporary and the stored value was emitted: in + both tiers, from rows in memory and from EF Core, typed and dynamic, whether the struct came back in a + whole row or was selected whole. A class member was never affected, nor a dynamic selection of a path + beneath the struct, which builds an object of its own. The setter writes into the box in + place now, and both passes of the walk write each changed struct back where it was read from, innermost + first: into the member that held it, into its position in a list or an array, or into the outer struct + that held it. A member the policy names is transformed once, by the pass along its path. Fail-closed: a + changed struct that cannot be written back — held by a member with no setter, in a collection that + cannot be written by position such as a HashSet, or as a dictionary's value — fails the query with + InvalidOperationException rather than emit the stored value. Present since the policy layer shipped in + 3.0.0. Section 18. + - Behaviour change. Bind reports a value a property refuses — a cap below 1, a purpose's page cap below 1, a + hash salt too short, a snapshot age or refresh interval that is not positive — as the + InvalidOperationException it documents, with the property's own exception as InnerException however + deep the binder wrapped it. The binder calls each setter by reflection, so such a value arrived as a + TargetInvocationException. Section 12. + - Docs. A filter on a whole struct-typed member throws InvalidOperationException from the builder, + guarded or not (section 11, trap 17). 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, 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 @@ -5831,11 +6260,33 @@ MemoryCalculationInput 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 sort on a whole struct-typed member fails on EF Core however the projection builds it, since EF Core cannot + order by a composite value; under `Strict` a constructor-built one is refused first (3.4.0). `[DwNoOrder]` on the + member keeps it out of the query in both tiers. - 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. +- A filter on a whole struct-typed member throws `InvalidOperationException` from the builder, guarded or not, in + both tiers (section 11, trap 17). Filter on its members; under a policy `[DwNoWhere]` on the member makes it a + refusal. +- A path through a member an application type's constructor builds with arguments cannot be filtered, ordered, + grouped or aggregated on EF Core: under `Strict` it is refused (3.4.0, section 17), and elsewhere EF Core throws. + Build the member with an initializer, which EF Core follows to the column. +- A typed selection leaves unbound a class navigation inside a struct, and a path beneath a framework-typed member + (`Born.Year`, `Code.Length`), which a typed row cannot hold apart from the value above it (section 6). The dynamic + terminals carry both as they are named. +- A struct a transform changed is written back where it was read from (3.4.0, section 18), so one that cannot be + fails the query with `InvalidOperationException`: a struct held by a member with no setter, structs in a + collection that cannot be written by position (`HashSet`), a struct that is a dictionary's value. Give the + member a setter, and hold such structs in a list or an array. +- A selection beneath a collection of structs that a projection builds with a collection initializer + (`Pairs = new List { new Pair { Shown = t.A, Hidden = t.B } }`) fails on EF Core with + `InvalidOperationException` ("could not be translated"), guarded or not: the dynamic terminals always did, and + since 3.4.0 the typed ones do too, where they returned each element whole (section 6). A list the projection + reads from a query (`o.Lines.Select(l => new Pair { … }).ToList()`) is narrowed as named. Build the list from a + query, or name the collection whole where nothing in it is denied. --- @@ -6027,3 +6478,26 @@ app.MapPost("/employees/search", async (Filter filter, HttpContext http, AppDbCo catch (LogicException ex) { return Results.BadRequest(new { error = ex.Message }); } }); ``` + +### An export under a purpose (3.4.0) + +```csharp +// Program.cs — the screens' caps come from configuration; the export declares a purpose of its own +builder.Services.AddDwPolicies(builder.Configuration.GetSection("DynamicWhere:Policies"), options => +{ + options.Entities.Expose("Tenant"); + options.Caps.Purposes["excel"] = new DwPageCaps { MaxPageSize = 10_000, DefaultPageSize = 10_000 }; +}); + +// the export endpoint: the host names the purpose, never the request +app.MapPost("/tenants/export", async (Filter filter, HttpContext http, AppDbContext db, CancellationToken ct) => +{ + DwPolicyContext caller = await DwPolicy.PrepareAsync(new DwPolicyContext { Purpose = "excel" } + .WithSubject(DwSubjectKind.User, http.User.FindFirstValue(ClaimTypes.NameIdentifier)!)); + + FilterResult file = await db.Tenants.ApplyPolicy(caller).ToListAsync(filter, ct); // one statement + bool truncated = file.TotalCount > file.Data.Count; // cut at 10,000 + + return Results.Ok(new { file.Data, file.TotalCount, truncated }); +}); +``` diff --git a/README.md b/README.md index 3db76a2..c7f08e0 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.3.0 +dotnet add package DynamicWhere.ex --version 3.4.0 ``` Or via Package Manager: ```powershell -Install-Package DynamicWhere.ex -Version 3.3.0 +Install-Package DynamicWhere.ex -Version 3.4.0 ``` Dependencies (restored automatically): @@ -376,6 +376,21 @@ The complete reference — every enum, class, extension method, validation rule, --- +## Version 3.4.0 highlights + +**Upgrade note — four security fixes, and changes that refuse or fail what used to run, beside a new way to give an export its own page. Read these before bumping.** + +- **New: page caps per declared purpose.** `MaxPageSize` and `DefaultPageSize` bound what one response carries, and a deployment sets them for its screens, so an export or a report reading the same rows under the same field policy stopped at the first page or walked the rest one page at a time — a statement and a count per page, and no single snapshot. `DwCaps.Purposes` gives a declared purpose page caps of its own: `options.Caps.Purposes["excel"] = new DwPageCaps { MaxPageSize = 10_000, DefaultPageSize = 10_000 }`, or `Caps:Purposes:excel:MaxPageSize` in configuration, and a query whose `DwPolicyContext.Purpose` is `"excel"` runs under them. A purpose no entry names, or none, runs under the deployment's caps; a value left null takes the deployment's; a value set is at least 1, so no purpose can switch a bound off; only the two page caps are replaced; and a page past a purpose's maximum is refused, never trimmed. The purpose is the host's statement, never the caller's: set it in the endpoint that serves the export, never from a header or a body. +- **Changed: a context stores its purpose trimmed.** `DwPolicyContext.Purpose` trims what it is given and stores a blank value as null, so `" export "` now matches a purpose-bound rule for `export`, which it used to miss, and means to a rule what it means to a purpose's caps. +- **Fixed (security): the parts of a struct took nothing from the struct.** A path beneath a member holding an application's own struct had no policy of its own, so with `[DwDenied]` on an `Iban` struct, `Iban.Number` was filtered on, sorted by, grouped by and handed back by a dynamic projection, under `Strict`. A struct is a value, not a navigation: such a path now takes the struct member's denials, operators, cost and audit, beside its own attributes, and never its alias, required filter, forced scope or description. A class navigation is unchanged — a denial of `Customer` still denies only that path. +- **Fixed (security): a member of a nullable struct had no policy at all.** A query reaches a member of an `Iban?` through the nullable, `Iban.Value.Number`, while the policy names it `Iban.Number`, so the lookup matched nothing: a `[DwDenied]` on the member, or on a member of the struct, did not stop a filter, a sort, a grouping key or a selection of it, typed or dynamic, `Iban.HasValue` was open, and a mask on a member of the struct was not applied when a selection named it through `Value` — in every release since 3.0.0, and this release's typed selection change had widened it before it shipped. The policy now reads such a path as it names it, dropping the `Value`, and reads `HasValue` as the nullable member itself; a framework nullable such as `Salary.Value` is unchanged. +- **Fixed (security): a typed selection beneath a collection of structs returned each element whole.** `Pairs.Shown` over a `List` handed back every `Pair`, a `[DwDenied]` member included, in both tiers, in every release since the policy layer shipped. Each element is now built with what was named and nothing beside it, into a list or an array. On EF Core a list a projection builds with a collection initializer cannot be selected from again, so a typed selection beneath one now fails, as the dynamic one always did. +- **Changed: under `Strict`, a path through a member a constructor builds is refused rather than run.** EF Core follows a member only through an initializer's binding, so `new LocalizedText(t.NameAr, t.NameEn).Ar` could not be translated and every clause on it reached the caller as a five-hundred. It is refused now as an unknown name is, and so is a clause on the constructed member itself (`ORDER BY Name`). Build such a member with an initializer, `new LocalizedText { Ar = t.NameAr, En = t.NameEn }`, and its parts filter and sort on the columns; a selection through it works either way. +- **Fixed: a typed selection through a struct returned its default.** `Select(["Name.Ar"])` over a `LocalizedText` struct came back as an empty name, guarded or not. An application's own struct is built member by member now, and a nullable one through `Value`. A path beneath a framework type (`Born.Year`) stays unbound. A projection the policy synthesizes for a request with no `Selects` now narrows a struct holding a denied member, as it narrows a class, where it left the struct out whole and its allowed members came back empty. And a guarded selection through a struct no longer adds the struct's `Id` beside what was named, nor refuses the struct's other members where that `Id` is denied: no builder adds a struct's key. +- **Fixed (security): a transform on a struct's member was never applied.** A struct is read as a copy, and the setter the outbound walk used wrote the masked value into a second copy, so `[DwMask]` — `Hash` and `Tokenize` included — `[DwGeneralize]`, `[DwFormat]`, `[DwTruncate]`, `[DwDefault]` and `[DwMutate]` on a member of an application's own struct emitted the stored value wherever the struct came back whole, typed or dynamic, in both tiers, in memory and from EF Core, since the policy layer shipped. Each changed struct is now written back where it was read from: the member that held it, its position in a list or an array, or the outer struct. **Fail-closed:** a changed struct that cannot be written back — held by a member with no setter, in a collection that cannot be written by position such as a `HashSet`, or as a dictionary's value — fails the query with `InvalidOperationException` rather than hand back the stored value. +- **Changed: `Bind` reports a refused value as the `InvalidOperationException` it documents.** A cap below 1, a purpose's page cap below 1, a salt too short or a snapshot age that is not positive arrived as the binder's `TargetInvocationException`; `Bind` and `AddDwPolicies` now throw `InvalidOperationException`, with the property's own exception inside however deep the binder wrapped it. +- **Known limit:** a filter on a whole struct-typed member — a condition whose field is the struct itself — throws `InvalidOperationException` from the builder, guarded or not. Put `[DwNoWhere]` on such a member. + ## Version 3.3.0 highlights **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.**