diff --git a/DynamicWhere.Tests/CancellationTests.cs b/DynamicWhere.Tests/CancellationTests.cs new file mode 100644 index 0000000..d5d3af1 --- /dev/null +++ b/DynamicWhere.Tests/CancellationTests.cs @@ -0,0 +1,316 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Classes.Result; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Query; +using System.Collections; +using System.Data.Common; +using System.Linq.Expressions; + +namespace DynamicWhere.Tests; + +public class CancelledRow +{ + public int Id { get; set; } + + public string Team { get; set; } = string.Empty; + + public int Points { get; set; } +} + +public sealed class CancellationContext : DbContext +{ + private readonly SqliteConnection _connection; + private readonly IInterceptor[] _interceptors; + + public CancellationContext(SqliteConnection connection, params IInterceptor[] interceptors) + { + _connection = connection; + _interceptors = interceptors; + } + + public DbSet Rows => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => + options.UseSqlite(_connection).AddInterceptors(_interceptors); +} + +/// +/// Cancels a token just before the numbered command runs, and counts the commands that finished, so a +/// test can tell which read the token reached. +/// +public sealed class CancelBeforeCommand : DbCommandInterceptor +{ + private readonly CancellationTokenSource _source; + private readonly int _at; + private int _started; + + public CancelBeforeCommand(CancellationTokenSource source, int at) + { + _source = source; + _at = at; + } + + public int Finished { get; private set; } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (++_started == _at) + { + _source.Cancel(); + } + + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } + + public override ValueTask ReaderExecutedAsync( + DbCommand command, + CommandExecutedEventData eventData, + DbDataReader result, + CancellationToken cancellationToken = default) + { + Finished++; + + return base.ReaderExecutedAsync(command, eventData, result, cancellationToken); + } +} + +/// An async provider that fails as it builds the query, before any task exists. +public sealed class UntranslatableQuery : IQueryable, IAsyncQueryProvider +{ + public UntranslatableQuery() => Expression = Expression.Constant(this); + + public Type ElementType => typeof(T); + + public Expression Expression { get; } + + public IQueryProvider Provider => this; + + public IEnumerator GetEnumerator() => throw new InvalidOperationException("untranslatable"); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IQueryable CreateQuery(Expression expression) => this; + + public IQueryable CreateQuery(Expression expression) => throw new InvalidOperationException("untranslatable"); + + public object Execute(Expression expression) => throw new InvalidOperationException("untranslatable"); + + public TResult Execute(Expression expression) => throw new InvalidOperationException("untranslatable"); + + public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("untranslatable"); +} + +/// +/// Every asynchronous terminal takes a , guarded or not, and hands it to +/// the provider. +/// +/// +/// Before 3.2.0 no method took one, so a request its client had abandoned could not cancel its read. +/// The overloads sit beside the ones without a token rather than replacing them: a parameter added to +/// an existing method changes its signature, which a caller compiled against the old one cannot find. +/// +/// Runs on the EF Core 6 leg too, because a dynamic read reaches EF Core's own asynchronous operators by +/// reflection, and a method found by name is exactly what differs between versions. +/// +/// +public sealed class CancellationTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly CancellationContext _db; + + public CancellationTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _db = new CancellationContext(_connection); + _db.Database.EnsureCreated(); + + for (int i = 1; i <= 6; i++) + { + _db.Rows.Add(new CancelledRow { Id = i, Team = i % 2 == 0 ? "even" : "odd", Points = i * 10 }); + } + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static CancellationToken Canceled() + { + using CancellationTokenSource source = new(); + + source.Cancel(); + + return source.Token; + } + + private static Filter Paged() => new() + { + Orders = new List { new() { Sort = 1, Field = "Id", Direction = Direction.Ascending } }, + Page = new PageBy { PageNumber = 1, PageSize = 4 } + }; + + private static Filter PagedDynamic() + { + Filter filter = Paged(); + + filter.Selects = new List { "Id", "Team" }; + + return filter; + } + + private static Summary ByTeam() => new() + { + GroupBy = new GroupBy + { + Fields = new List { "Team" }, + AggregateBy = new List + { + new() { Field = "Points", Alias = "Total", Aggregator = Aggregator.Sumation } + } + }, + Orders = new List { new() { Sort = 1, Field = "Team", Direction = Direction.Ascending } } + }; + + private static Segment OddOrFirst() => new() + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Team", DataType = DataType.Text, Operator = Operator.Equal, Values = { "odd" } } } + } + }, + new ConditionSet + { + Sort = 2, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Id", DataType = DataType.Number, Operator = Operator.Equal, Values = { 2 } } } + } + } + } + }; + + private PolicyQueryable Guarded() => + _db.Rows.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + [Fact] + public async Task A_canceled_token_stops_every_unguarded_async_terminal() + { + CancellationToken canceled = Canceled(); + + await Assert.ThrowsAnyAsync(() => _db.Rows.ToListAsync(Paged(), canceled)); + await Assert.ThrowsAnyAsync(() => _db.Rows.ToListAsync(Paged(), true, canceled)); + await Assert.ThrowsAnyAsync(() => _db.Rows.ToListAsyncDynamic(PagedDynamic(), canceled)); + await Assert.ThrowsAnyAsync(() => _db.Rows.ToListAsyncDynamic(PagedDynamic(), true, canceled)); + await Assert.ThrowsAnyAsync(() => _db.Rows.ToListAsync(ByTeam(), canceled)); + await Assert.ThrowsAnyAsync(() => _db.Rows.ToListAsync(ByTeam(), true, canceled)); + await Assert.ThrowsAnyAsync(() => _db.Rows.ToListAsync(OddOrFirst(), canceled)); + } + + [Fact] + public async Task A_canceled_token_stops_every_guarded_async_terminal() + { + CancellationToken canceled = Canceled(); + + await Assert.ThrowsAnyAsync(() => Guarded().ToListAsync(Paged(), canceled)); + await Assert.ThrowsAnyAsync(() => Guarded().ToListAsyncDynamic(PagedDynamic(), canceled)); + await Assert.ThrowsAnyAsync(() => Guarded().ToListAsync(ByTeam(), canceled)); + await Assert.ThrowsAnyAsync(() => Guarded().ToListAsync(OddOrFirst(), canceled)); + } + + /// + /// Each terminal counts, then reads. Canceling just before the first command shows the count took the + /// token; canceling just before the second shows the read did, after a count that finished. + /// + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task The_token_reaches_both_the_count_and_the_read(int at) + { + async Task Check(Func run) + { + using CancellationTokenSource source = new(); + CancelBeforeCommand interceptor = new(source, at); + using CancellationContext db = new(_connection, interceptor); + + await Assert.ThrowsAnyAsync(() => run(db, source.Token)); + Assert.Equal(at - 1, interceptor.Finished); + } + + await Check((db, token) => db.Rows.ToListAsync(Paged(), token)); + await Check((db, token) => db.Rows.ToListAsyncDynamic(PagedDynamic(), token)); + await Check((db, token) => db.Rows.ToListAsync(ByTeam(), token)); + await Check((db, token) => db.Rows.ToListAsync(OddOrFirst(), token)); + } + + /// + /// A grouped count reaches EF Core's operator by reflection. A query it cannot build fails with its own + /// exception, as the synchronous count it replaced did, not one wrapped by the reflection call. + /// + [Fact] + public async Task A_provider_failure_leaves_the_grouped_count_as_itself() + { + InvalidOperationException failure = await Assert.ThrowsAsync( + () => AsyncReads.CountAsync(new UntranslatableQuery(), CancellationToken.None)); + + Assert.Equal("untranslatable", failure.Message); + } + + /// A token that is never canceled changes nothing: each overload answers as the one without a token does. + [Fact] + public async Task A_live_token_returns_what_the_overload_without_one_returns() + { + using CancellationTokenSource live = new(); + + FilterResult plain = await _db.Rows.ToListAsync(Paged()); + FilterResult tokened = await _db.Rows.ToListAsync(Paged(), live.Token); + Assert.Equal(plain.Data.Select(row => row.Id), tokened.Data.Select(row => row.Id)); + Assert.Equal((plain.TotalCount, plain.PageCount), (tokened.TotalCount, tokened.PageCount)); + + FilterResult dynamicRows = await _db.Rows.ToListAsyncDynamic(PagedDynamic(), live.Token); + Assert.Equal(new[] { 1, 2, 3, 4 }, dynamicRows.Data.Select(row => (int)row.Id)); + Assert.Equal(6, dynamicRows.TotalCount); + + SummaryResult summary = await _db.Rows.ToListAsync(ByTeam(), live.Token); + Assert.Equal(2, summary.TotalCount); + Assert.Equal(new[] { 120, 90 }, summary.Data.Select(row => (int)row.Total)); + + SegmentResult segment = await _db.Rows.ToListAsync(OddOrFirst(), live.Token); + Assert.Equal(new[] { 1, 2, 3, 5 }, segment.Data!.Select(row => row.Id).OrderBy(id => id)); + + Assert.Equal(new[] { 1, 2, 3, 4 }, (await Guarded().ToListAsync(Paged(), live.Token)).Data.Select(row => row.Id)); + Assert.Equal(new[] { 1, 2, 3, 4 }, (await Guarded().ToListAsyncDynamic(PagedDynamic(), live.Token)).Data.Select(row => (int)row.Id)); + Assert.Equal(new[] { 120, 90 }, (await Guarded().ToListAsync(ByTeam(), live.Token)).Data.Select(row => (int)row.Total)); + Assert.Equal(4, (await Guarded().ToListAsync(OddOrFirst(), live.Token)).TotalCount); + } +} diff --git a/DynamicWhere.Tests/ComplexMemberTests.cs b/DynamicWhere.Tests/ComplexMemberTests.cs new file mode 100644 index 0000000..1a61f1d --- /dev/null +++ b/DynamicWhere.Tests/ComplexMemberTests.cs @@ -0,0 +1,215 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace DynamicWhere.Tests; + +/// An entity with a denied scalar and a complex property with nothing denied beneath it. +public class CxCrate +{ + public int Id { get; set; } + + [DwDenied] + public string? Label { get; set; } + + public CxSize Size { get; set; } = new(); +} + +public class CxSize +{ + public int Width { get; set; } + + public int Height { get; set; } +} + +public class CxSecretSize +{ + public int Width { get; set; } + + [DwDenied] + public int Height { get; set; } +} + +/// An entity whose only denial sits inside its complex property. +public class CxSealedBox +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public CxSecretSize Inner { get; set; } = new(); +} + +/// A row projected from , carrying its complex value. +[DwEntity(RequirePolicy = true)] +public sealed class CxBoxRow +{ + public int Id { get; set; } + + [DwDenied] + public string? Tenant { get; set; } + + public CxSecretSize Inner { get; set; } = new(); +} + +/// An owned member stored as JSON, with a denial beneath it. +public class CxJsonShelf +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public CxJsonMeta Meta { get; set; } = new(); +} + +public class CxJsonMeta +{ + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Code { get; set; } +} + +/// A primitive collection named with a word the expression parser keeps. +public class CxCastEntity +{ + public int Id { get; set; } + + [DwDenied] + public string? Secret { get; set; } + + public List Cast { get; set; } = new(); +} + +public sealed class ComplexMemberContext : DbContext +{ + private readonly SqliteConnection _connection; + + public ComplexMemberContext(SqliteConnection connection) => _connection = connection; + + public DbSet Crates => Set(); + + public DbSet Boxes => Set(); + + public DbSet JsonShelves => Set(); + + public DbSet CastEntities => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().ComplexProperty(crate => crate.Size); + model.Entity().ComplexProperty(box => box.Inner); + model.Entity().OwnsOne(shelf => shelf.Meta, meta => meta.ToJson()); + } +} + +/// +/// An EF Core complex property, which EF Core 8 loads with the entity on every query. A synthesized +/// projection keeps it whole, and leaves it out whole when something beneath it is denied: the core +/// narrows a nested object behind a comparison with null, which EF Core refuses for a complex type. +/// +/// Not on the EF Core 6 leg, which has no complex properties. +public sealed class ComplexMemberTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly ComplexMemberContext _db; + + public ComplexMemberTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _db = new ComplexMemberContext(_connection); + _db.Database.EnsureCreated(); + + _db.Crates.Add(new CxCrate { Label = "label-secret", Size = new CxSize { Width = 4, Height = 5 } }); + _db.Boxes.Add(new CxSealedBox { Name = "B1", Inner = new CxSecretSize { Width = 2, Height = 9 } }); + _db.JsonShelves.Add(new CxJsonShelf { Name = "J1", Meta = new CxJsonMeta { Label = "L", Code = "json-secret" } }); + _db.CastEntities.Add(new CxCastEntity { Secret = "s", Cast = new List { "a" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + [Fact] + public void A_complex_property_with_nothing_denied_beneath_is_kept_whole() + { + CxCrate crate = Guard(_db.Crates).ToList(new Filter()).Data.Single(); + + Assert.Null(crate.Label); + Assert.Equal((4, 5), (crate.Size.Width, crate.Size.Height)); + } + + /// + /// A projected row carrying a complex value: the core's narrowing compares it to null, which EF Core + /// refuses, so with a denial beneath it the value is left out whole rather than failing the query. + /// + [Fact] + public void A_projected_complex_value_with_a_denial_beneath_is_left_out() + { + PolicyQueryable guarded = Guard(_db.Boxes.Select(b => new CxBoxRow { Id = b.Id, Tenant = b.Name, Inner = b.Inner })); + + CxBoxRow row = guarded.ToList(new Filter()).Data.Single(); + + Assert.Equal((0, 0), (row.Inner.Width, row.Inner.Height)); + Assert.Contains(guarded.LastTrace!.Decisions, decision => decision.FieldPath == "Inner" + && decision.Reason == "left out whole: the projection builds it in a way the core cannot narrow"); + } + + /// An owned member stored as JSON cannot be narrowed by EF Core, so it is left out whole. + [Fact] + public void An_owned_member_stored_as_json_with_a_denial_beneath_is_left_out() + { + PolicyQueryable guarded = Guard(_db.JsonShelves); + + CxJsonShelf shelf = guarded.ToList(new Filter()).Data.Single(); + + Assert.Equal("J1", shelf.Name); + Assert.Null(shelf.Meta.Code); + Assert.Contains(guarded.LastTrace!.Decisions, decision => decision.FieldPath == "Meta" + && decision.Reason == "left out whole: it is stored as JSON, which EF Core cannot narrow"); + } + + /// A primitive collection named with a parser word cannot be projected, so it is skipped. + [Fact] + public void A_primitive_collection_named_with_a_parser_word_is_skipped() + { + CxCastEntity entity = Guard(_db.CastEntities).ToList(new Filter()).Data.Single(); + + Assert.Null(entity.Secret); + } + + [Fact] + public void A_complex_property_with_a_denial_beneath_is_left_out_whole() + { + PolicyQueryable guarded = Guard(_db.Boxes); + + CxSealedBox box = guarded.ToList(new Filter()).Data.Single(); + + Assert.Equal("B1", box.Name); + Assert.Equal((0, 0), (box.Inner.Width, box.Inner.Height)); + Assert.Contains( + guarded.LastTrace!.Decisions, + decision => decision.FieldPath == "Inner" + && decision.Reason == "left out whole: it is a complex property, which EF Core cannot narrow"); + } +} diff --git a/DynamicWhere.Tests/DefaultOrderTests.cs b/DynamicWhere.Tests/DefaultOrderTests.cs index 80469f3..e7a0709 100644 --- a/DynamicWhere.Tests/DefaultOrderTests.cs +++ b/DynamicWhere.Tests/DefaultOrderTests.cs @@ -108,6 +108,20 @@ public class AuditedTicket public int Rank { get; set; } } +/// A default reaching through a reference, for the projections that do and do not assign it. +[DwEntity(DefaultOrder = "Owner.Name desc, Id")] +public class OwnedTicket +{ + public int Id { get; set; } + + public TicketOwner? Owner { get; set; } +} + +public class TicketOwner +{ + public string Name { get; set; } = string.Empty; +} + /// A type that declares no default, and so is never ordered by one. public class PlainTicket { @@ -537,6 +551,111 @@ public void A_denial_a_rule_can_lift_is_a_warning_and_so_is_one_for_segments() unsegmented.Warnings); } + // ------------------------------------------------------------------- a projected source (3.2.0) + + /// + /// DCMP's A9. A projection that builds the type in an initializer and assigns every field the + /// default names hides nothing, so the default applies: EF Core translates the order through the + /// members the projection assigned. It used to be left unordered, like every projection. + /// + [Fact] + public async Task A_projection_that_assigns_every_default_field_takes_the_default() + { + IQueryable rows = _db.Tickets.Select(t => new Ticket { Id = t.Id, Priority = t.Priority, Title = t.Title }); + + PolicyQueryable guarded = rows.ApplyPolicy(Caller(), Options(), Resolver()); + + Assert.Equal(ByDefault, Ids(guarded.ToList(new Filter()).Data)); + Assert.Equal(ByDefault, Ids((await guarded.ToListAsync(new Filter())).Data)); + Assert.Equal(new[] { 2, 4 }, Ids(guarded.Page(FirstTwo()).AsUnguardedQueryable().ToList())); + Assert.Equal(new[] { 2, 4, 3, 5 }, Ids((await guarded.ToListAsync(UrgentOrFive())).Data!)); + } + + /// A projection that leaves out a field the default names keeps whatever order it had. + [Fact] + public void A_projection_that_leaves_a_default_field_out_keeps_its_own_order() + { + IQueryable rows = _db.Tickets.Select(t => new Ticket { Id = t.Id, Title = t.Title }); + + Assert.Equal(AsWritten, Ids(rows.ApplyPolicy(Caller(), Options(), Resolver()).ToList(new Filter()).Data)); + } + + /// DCMP's A10 and A11: a source ordered before its projection keeps that order, and a caller's order wins. + [Fact] + public void A_source_ordered_before_its_projection_keeps_that_order_and_the_callers_order_wins() + { + IQueryable rows = _db.Tickets.OrderBy(t => t.Title) + .Select(t => new Ticket { Id = t.Id, Priority = t.Priority, Title = t.Title }); + + Assert.Equal(AsWritten, Ids(rows.ApplyPolicy(Caller(), Options(), Resolver()).ToList(new Filter()).Data)); + Assert.Equal( + new[] { 5, 4, 3, 2, 1 }, + Ids(rows.ApplyPolicy(Caller(), Options(), Resolver()) + .ToList(new Filter { Orders = new List { By("Id", Direction.Descending) } }).Data)); + } + + /// + /// The default is for the rows the caller's source makes. A projection the caller composes on the + /// guarded handle afterwards stays in its own order, even when it keeps every field the default names. + /// + [Fact] + public void A_projection_composed_after_ApplyPolicy_stays_unordered_even_with_every_default_field() + { + PolicyQueryable projected = Guarded().Select(new List { "Id", "Priority" }); + + Assert.Equal(new[] { 1, 2 }, Ids(projected.Page(FirstTwo()).AsUnguardedQueryable().ToList())); + Assert.Equal(AsWritten, Ids(projected.ToList(new Filter()).Data)); + } + + /// + /// A composed Filter whose orders were all dropped sent orders, so nothing later in the chain adds a + /// default in their place, exactly as a composed Order whose fields were all dropped does. + /// + [Fact] + public void A_filter_composed_with_orders_that_were_all_dropped_gets_no_default_later() + { + PolicyQueryable guarded = Guarded( + new FakePolicyProvider().Add("Title", PolicyFeature.Order, PolicyEffect.Deny, PolicyLevel.DynamicGlobal)); + + PolicyQueryable filtered = guarded.Filter(new Filter { Orders = new List { By("Title") } }); + + Assert.Equal(new[] { 1, 2 }, Ids(filtered.Page(FirstTwo()).AsUnguardedQueryable().ToList())); + } + + /// + /// A nested default is assigned only when every level of its path is an initializer that assigns + /// the next member. Any other expression along the way, or a member left out, hides it. + /// + [Fact] + public void A_nested_default_is_assigned_only_through_initializers() + { + OwnedTicket[] source = + { + new() { Id = 1, Owner = new TicketOwner { Name = "b" } }, + new() { Id = 2, Owner = new TicketOwner { Name = "c" } }, + new() { Id = 3, Owner = new TicketOwner { Name = "a" } } + }; + + IQueryable initialized = source.AsQueryable() + .Select(t => new OwnedTicket { Id = t.Id, Owner = new TicketOwner { Name = t.Owner!.Name } }); + IQueryable assignedWhole = source.AsQueryable() + .Select(t => new OwnedTicket { Id = t.Id, Owner = t.Owner }); + IQueryable leftOut = source.AsQueryable() + .Select(t => new OwnedTicket { Id = t.Id }); + + Assert.False(DefaultOrder.HidesDefault(initialized.Expression, typeof(OwnedTicket))); + Assert.True(DefaultOrder.HidesDefault(assignedWhole.Expression, typeof(OwnedTicket))); + Assert.True(DefaultOrder.HidesDefault(leftOut.Expression, typeof(OwnedTicket))); + Assert.False(DefaultOrder.HidesDefault(source.AsQueryable().Expression, typeof(OwnedTicket))); + + Assert.Equal( + new[] { 2, 1, 3 }, + initialized.ApplyPolicy(Caller(), Options(), Resolver()).ToList(new Filter()).Data.Select(row => row.Id)); + Assert.Equal( + new[] { 1, 2, 3 }, + assignedWhole.ApplyPolicy(Caller(), Options(), Resolver()).ToList(new Filter()).Data.Select(row => row.Id)); + } + [Fact] public void An_audited_default_field_is_recorded_only_when_the_query_orders_by_it() { diff --git a/DynamicWhere.Tests/DynamicWhere.Tests.csproj b/DynamicWhere.Tests/DynamicWhere.Tests.csproj index 679f213..48e78ca 100644 --- a/DynamicWhere.Tests/DynamicWhere.Tests.csproj +++ b/DynamicWhere.Tests/DynamicWhere.Tests.csproj @@ -27,6 +27,9 @@ + + @@ -79,6 +82,10 @@ primary key it reads from the EF Core model, both of which differ between 6 and 8. It carries its own SQLite model. --> + + diff --git a/DynamicWhere.Tests/Policies/ProjectedRowTests.cs b/DynamicWhere.Tests/Policies/ProjectedRowTests.cs new file mode 100644 index 0000000..51a7a31 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ProjectedRowTests.cs @@ -0,0 +1,702 @@ +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 DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace DynamicWhere.Tests.Policies; + +// ------------------------------------------------------------------------------------ the database + +/// A role, scoped to a tenant or, with none, a platform template. +public class PrRole +{ + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string NameAr { get; set; } = string.Empty; + + public string NameEn { get; set; } = string.Empty; + + public int? TenantId { get; set; } +} + +/// A permission, with a value no row built from it may carry out. +public class PrPermission +{ + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string Secret { get; set; } = string.Empty; +} + +/// A permission granted to a role. +public class PrGrant +{ + public int Id { get; set; } + + public int RoleId { get; set; } + + public int PermissionId { get; set; } + + public PrPermission Permission { get; set; } = null!; +} + +/// An entity with nothing denied at the top: every denial sits beneath a member. +public class PrShelf +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Owned, so EF Core loads it with every shelf. + public PrLocation? Location { get; set; } + + /// Owned as well, and a collection. + public List Tags { get; set; } = new(); + + /// A navigation, loaded only when something includes it. + public List Books { get; set; } = new(); +} + +public class PrLocation +{ + public string Aisle { get; set; } = string.Empty; + + [DwDenied] + public string? Code { get; set; } +} + +public class PrTag +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwDenied] + public string? Hidden { get; set; } +} + +public class PrBook +{ + public int Id { get; set; } + + public int PrShelfId { get; set; } + + public string Title { get; set; } = string.Empty; + + [DwDenied] + public string? Isbn { get; set; } +} + +/// An entity with a denied scalar, an owned member with nothing denied beneath it, and a blob. +public class PrBin +{ + public int Id { get; set; } + + [DwDenied] + public string? Label { get; set; } + + public PrPlace? Place { get; set; } + + public byte[] Photo { get; set; } = Array.Empty(); +} + +public class PrPlace +{ + public string Aisle { get; set; } = string.Empty; + + public int Row { get; set; } +} + +/// An entity whose children's key no caller may see. +public class PrKeyParent +{ + public int Id { get; set; } + + public List Kids { get; set; } = new(); +} + +public class PrKeyChild +{ + [DwDenied] + public int Id { get; set; } + + public int PrKeyParentId { get; set; } + + public string Name { get; set; } = string.Empty; +} + +public sealed class ProjectedRowContext : DbContext +{ + private readonly SqliteConnection _connection; + + public ProjectedRowContext(SqliteConnection connection) => _connection = connection; + + public DbSet Roles => Set(); + + public DbSet Permissions => Set(); + + public DbSet Grants => Set(); + + public DbSet Shelves => Set(); + + public DbSet Bins => Set(); + + public DbSet KeyParents => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(shelf => shelf.Location); + model.Entity().OwnsMany(shelf => shelf.Tags, tag => tag.HasKey(t => t.Id)); + model.Entity().OwnsOne(bin => bin.Place); + } +} + +// ------------------------------------------------------------------------------------ the rows + +/// DCMP's role row: a denied, forced tenant beside a nested object, a list of objects and a list of values. +[DwEntity(RequirePolicy = true)] +public sealed class PrRoleRow +{ + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwDenied, DwForceWhere(Operator.Equal, ContextValue = "TenantId", AllowNull = true)] + public int? TenantId { get; set; } + + [DwNoWhere, DwNoOrder] + public PrName? Name { get; set; } + + [DwNoWhere, DwNoOrder] + public List Contents { get; set; } = new(); + + [DwNoWhere, DwNoOrder] + public List Codes { get; set; } = new(); +} + +public sealed class PrName +{ + public string Ar { get; set; } = string.Empty; + + public string En { get; set; } = string.Empty; +} + +public sealed class PrGrantRow +{ + public string Code { get; set; } = string.Empty; + + public string NameEn { get; set; } = string.Empty; +} + +/// A row with nothing denied at the top and a denial inside its list. +[DwEntity(RequirePolicy = true)] +public sealed class PrOpenRow +{ + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwNoWhere, DwNoOrder] + public List Contents { get; set; } = new(); +} + +public sealed class PrSecretGrantRow +{ + public string Code { get; set; } = string.Empty; + + [DwDenied] + public string? Secret { get; set; } +} + +/// A row whose list elements carry a key no caller may see, which the core adds to any narrowing. +[DwEntity(RequirePolicy = true)] +public sealed class PrKeyedRow +{ + public int Id { get; set; } + + [DwNoWhere, DwNoOrder] + public List Contents { get; set; } = new(); +} + +public sealed class PrKeyedGrantRow +{ + [DwDenied] + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; +} + +/// A row whose list is typed as a collection the core does not unwrap. +[DwEntity(RequirePolicy = true)] +public sealed class PrReadOnlyRow +{ + public int Id { get; set; } + + [DwNoWhere, DwNoOrder] + public IReadOnlyList Contents { get; set; } = new List(); + + [DwNoWhere, DwNoOrder] + public IReadOnlyList Clean { get; set; } = new List(); +} + +/// +/// A guarded query over rows its source builds, and over an entity whose denials sit beneath its +/// members: what a caller who sends no projection receives, and what one who names a member does. +/// +/// +/// DCMP projects every read into its own row type before guarding it, and a row there carries a +/// select-denied tenant. The projection the library synthesized for that denial kept scalars only, so +/// every nested object and list came back empty. Checking it found that a denial only beneath a member +/// synthesized nothing at all, and the whole row, denied values included, came back. +/// +public sealed class ProjectedRowTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly ProjectedRowContext _db; + + public ProjectedRowTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _db = new ProjectedRowContext(_connection); + _db.Database.EnsureCreated(); + + PrPermission read = new() { Code = "read", Secret = "s-read" }; + PrPermission write = new() { Code = "write", Secret = "s-write" }; + PrRole ownRole = new() { Code = "R1", NameAr = "دور", NameEn = "Role one", TenantId = 1 }; + PrRole otherRole = new() { Code = "R2", NameAr = "دور", NameEn = "Role two", TenantId = 2 }; + PrRole template = new() { Code = "T0", NameAr = "قالب", NameEn = "Template", TenantId = null }; + + _db.AddRange(read, write, ownRole, otherRole, template); + _db.SaveChanges(); + + _db.Grants.AddRange( + new PrGrant { RoleId = ownRole.Id, PermissionId = read.Id }, + new PrGrant { RoleId = ownRole.Id, PermissionId = write.Id }, + new PrGrant { RoleId = otherRole.Id, PermissionId = read.Id }, + new PrGrant { RoleId = template.Id, PermissionId = read.Id }); + + _db.Shelves.Add(new PrShelf + { + Name = "S1", + Location = new PrLocation { Aisle = "A7", Code = "location-secret" }, + Tags = { new PrTag { Name = "t1", Hidden = "tag-secret" } }, + Books = { new PrBook { Title = "B1", Isbn = "isbn-secret" } } + }); + + _db.Bins.Add(new PrBin + { + Label = "label-secret", Place = new PrPlace { Aisle = "B2", Row = 3 }, Photo = new byte[] { 1, 2, 3 } + }); + + _db.KeyParents.Add(new PrKeyParent { Kids = { new PrKeyChild { Name = "k1" } } }); + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static DwPolicyContext Caller() => + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1").WithValue("TenantId", 1); + + private static DwPolicyOptions Options(DwTier tier, bool dryRun = false) => + new() { Tier = tier, DryRun = dryRun }; + + private static PolicyResolver Resolver() => new(new IDwPolicyProvider[] { new AttributePolicyProvider() }); + + private static PolicyQueryable Guard(IQueryable source, DwTier tier, bool dryRun = false) where T : class => + source.ApplyPolicy(Caller(), Options(tier, dryRun), Resolver()); + + private static Filter Everything() => new(); + + private static Filter Selecting(params string[] fields) => new() { Selects = fields.ToList() }; + + private IQueryable RoleRows() => _db.Roles.AsNoTracking().Select(role => new PrRoleRow + { + Id = role.Id, + Code = role.Code, + TenantId = role.TenantId, + Name = new PrName { Ar = role.NameAr, En = role.NameEn }, + Contents = _db.Grants + .Where(grant => grant.RoleId == role.Id) + .OrderBy(grant => grant.Permission.Code) + .Select(grant => new PrGrantRow { Code = grant.Permission.Code, NameEn = grant.Permission.Code + "!" }) + .ToList(), + Codes = _db.Grants + .Where(grant => grant.RoleId == role.Id) + .Select(grant => grant.Permission.Code) + .OrderBy(code => code) + .ToList() + }); + + private IQueryable OpenRows() => _db.Roles.AsNoTracking().Select(role => new PrOpenRow + { + Id = role.Id, + Code = role.Code, + Contents = _db.Grants + .Where(grant => grant.RoleId == role.Id) + .OrderBy(grant => grant.Permission.Code) + .Select(grant => new PrSecretGrantRow { Code = grant.Permission.Code, Secret = grant.Permission.Secret }) + .ToList() + }); + + private IQueryable KeyedRows() => _db.Roles.AsNoTracking().Select(role => new PrKeyedRow + { + Id = role.Id, + Contents = _db.Grants + .Where(grant => grant.RoleId == role.Id) + .Select(grant => new PrKeyedGrantRow { Id = grant.Permission.Id, Code = grant.Permission.Code }) + .ToList() + }); + + private IQueryable ReadOnlyRows() => _db.Roles.AsNoTracking().Select(role => new PrReadOnlyRow + { + Id = role.Id, + Contents = _db.Grants + .Where(grant => grant.RoleId == role.Id) + .Select(grant => new PrSecretGrantRow { Code = grant.Permission.Code, Secret = grant.Permission.Secret }) + .ToList(), + Clean = _db.Grants + .Where(grant => grant.RoleId == role.Id) + .Select(grant => new PrGrantRow { Code = grant.Permission.Code, NameEn = grant.Permission.Code }) + .ToList() + }); + + private static string Describe(PrRoleRow row) => + $"{row.Code}|{row.TenantId?.ToString() ?? "-"}|{row.Name?.En ?? "-"}|" + + $"{string.Join(",", row.Contents.Select(grant => grant.Code + "/" + grant.NameEn))}|{string.Join(",", row.Codes)}"; + + private static string Describe(PrOpenRow row) => + $"{row.Code}:{string.Join(",", row.Contents.Select(grant => grant.Code + "/" + (grant.Secret ?? "-")))}"; + + // ------------------------------------------------------------------ DW-12: a projected row stays whole + + /// + /// DCMP's B1b, B1f and C1. The denied tenant is stripped and every other member arrives as the + /// source built it: the nested name, the list of grants and the list of codes. + /// + [Theory] + [InlineData(DwTier.Convenience)] + [InlineData(DwTier.Strict)] + public async Task A_projected_row_keeps_its_nested_objects_and_lists_when_a_scalar_is_denied(DwTier tier) + { + string[] expected = + { + "R1|-|Role one|read/read!,write/write!|read,write", + "T0|-|Template|read/read!|read" + }; + + Assert.Equal(expected, Guard(RoleRows(), tier).ToList(Everything()).Data.Select(Describe).OrderBy(x => x)); + Assert.Equal(expected, (await Guard(RoleRows(), tier).ToListAsync(Everything())).Data.Select(Describe).OrderBy(x => x)); + + FilterResult dynamicRows = await Guard(RoleRows(), tier).ToListAsyncDynamic(Everything()); + dynamic first = dynamicRows.Data.Single(row => (string)row.Code == "R1"); + Assert.Equal("Role one", (string)first.Name.En); + Assert.Equal(2, ((IEnumerable)first.Contents).Count()); + Assert.Equal(new[] { "read", "write" }, (IEnumerable)first.Codes); + Assert.Null(((object)first).GetType().GetProperty("TenantId")); + + SegmentResult segment = await Guard(RoleRows(), tier).ToListAsync(new Segment()); + Assert.Equal(expected, segment.Data!.Select(Describe).OrderBy(x => x)); + } + + /// The same row, read without the library, for comparison: nothing is lost but the tenant. + [Fact] + public void The_guarded_row_matches_what_plain_EF_Core_returns_less_the_denied_field() + { + List plain = RoleRows().Where(row => row.TenantId == 1 || row.TenantId == null).ToList() + .Select(row => { row.TenantId = null; return Describe(row); }) + .OrderBy(x => x) + .ToList(); + + Assert.Equal(plain, Guard(RoleRows(), DwTier.Strict).ToList(Everything()).Data.Select(Describe).OrderBy(x => x)); + } + + /// The tenant stays denied everywhere else: a condition on it is refused, and the scope still applies. + [Theory] + [InlineData(DwTier.Convenience)] + [InlineData(DwTier.Strict)] + public void The_denied_field_is_still_refused_as_a_condition(DwTier tier) + { + Filter byTenant = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "TenantId", DataType = DataType.Number, Operator = Operator.Equal, Values = { 2 } } } + } + }; + + PolicyException refused = Assert.Throws(() => Guard(RoleRows(), tier).ToList(byTenant)); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refused.ErrorCode); + } + + /// A member the caller names is carried whole when nothing beneath it is denied, as it was. + [Fact] + public void A_member_named_by_the_caller_is_carried_whole() + { + PrRoleRow row = Guard(RoleRows(), DwTier.Strict) + .ToList(Selecting("Code", "Name", "Contents", "Codes")).Data.Single(r => r.Code == "R1"); + + Assert.Equal("R1|-|Role one|read/read!,write/write!|read,write", Describe(row)); + } + + // ------------------------------------------------------------------ F1: a denial beneath a member + + /// + /// Nothing is denied at the top of this row, so nothing used to be synthesized and the whole list, + /// its denied values included, came back. The list is now narrowed around the denial. + /// + [Theory] + [InlineData(DwTier.Convenience)] + [InlineData(DwTier.Strict)] + public async Task A_denial_beneath_a_projected_member_narrows_it(DwTier tier) + { + string[] expected = { "R1:read/-,write/-", "R2:read/-", "T0:read/-" }; + + PolicyQueryable guarded = Guard(OpenRows(), tier); + + Assert.Equal(expected, guarded.ToList(Everything()).Data.Select(Describe).OrderBy(x => x)); + Assert.Contains( + guarded.LastTrace!.Decisions, + decision => decision is { FieldPath: "Contents.Secret", Feature: PolicyFeature.Select, Action: PolicyAction.Dropped }); + + Assert.Equal(expected, (await Guard(OpenRows(), tier).ToListAsync(Everything())).Data.Select(Describe).OrderBy(x => x)); + Assert.Equal(expected, (await Guard(OpenRows(), tier).ToListAsync(new Segment())).Data!.Select(Describe).OrderBy(x => x)); + + FilterResult dynamicRows = Guard(OpenRows(), tier).ToListDynamic(Everything()); + foreach (dynamic row in dynamicRows.Data) + { + foreach (object grant in (System.Collections.IEnumerable)row.Contents) + { + Assert.Null(grant.GetType().GetProperty("Secret")); + } + } + } + + /// A dry run enforces nothing, as it never has: the rows come back whole and the decision is recorded. + [Fact] + public void A_dry_run_records_the_denial_beneath_and_returns_the_row_whole() + { + PolicyQueryable guarded = Guard(OpenRows(), DwTier.Strict, dryRun: true); + + Assert.Contains("R1:read/s-read,write/s-write", guarded.ToList(Everything()).Data.Select(Describe)); + Assert.Contains(guarded.LastTrace!.Decisions, decision => decision.FieldPath == "Contents.Secret"); + } + + /// + /// An included navigation of an entity used to come back whole whenever nothing at the top was + /// denied. It is now left out, as a navigation always was once a projection was needed; the owned + /// members, which EF Core loads with every shelf, are narrowed and kept. + /// + [Theory] + [InlineData(DwTier.Convenience)] + [InlineData(DwTier.Strict)] + public async Task An_entity_with_a_denial_beneath_leaves_out_its_navigations_and_narrows_what_it_owns(DwTier tier) + { + PrShelf shelf = (await Guard(_db.Shelves.Include(s => s.Books), tier).ToListAsync(Everything())).Data.Single(); + + Assert.Equal("S1", shelf.Name); + Assert.Empty(shelf.Books); + Assert.Equal(("A7", (string?)null), (shelf.Location!.Aisle, shelf.Location.Code)); + Assert.Equal(("t1", (string?)null), (shelf.Tags.Single().Name, shelf.Tags.Single().Hidden)); + + dynamic row = Guard(_db.Shelves.Include(s => s.Books), tier).ToListDynamic(Everything()).Data.Single(); + Assert.Null(((object)row).GetType().GetProperty("Books")); + Assert.Null(((object)row.Location).GetType().GetProperty("Code")); + } + + /// + /// A projection that hands back the entity itself builds no rows: its navigations hold a value only + /// when something includes them, so they are left out as an entity query's are. Kept, they would be + /// loaded, and the guarded query would return more than the unguarded one. + /// + [Fact] + public void A_projection_returning_entities_is_read_as_an_entity_query() + { + PrShelf shelf = Guard(_db.Shelves.Select(s => s), DwTier.Strict).ToList(Everything()).Data.Single(); + + Assert.Empty(shelf.Books); + Assert.Equal(("A7", (string?)null), (shelf.Location!.Aisle, shelf.Location.Code)); + } + + /// + /// A top-level denial on an entity keeps what EF Core loads with it: the owned member whole and + /// the blob, which a projection of scalars alone used to empty. + /// + [Fact] + public void An_entity_keeps_its_owned_member_and_its_blob_beside_a_denied_scalar() + { + PrBin bin = Guard(_db.Bins, DwTier.Strict).ToList(Everything()).Data.Single(); + + Assert.Null(bin.Label); + Assert.Equal(("B2", 3), (bin.Place!.Aisle, bin.Place.Row)); + Assert.Equal(new byte[] { 1, 2, 3 }, bin.Photo); + } + + /// + /// Rows in memory keep their values and leave every object member out once a projection is needed, + /// as 3.1.0 did. A member kept from them would be the caller's own object, which a transform would + /// then change in place, and a denial only beneath a member now needs that projection too. + /// + [Fact] + public void Rows_in_memory_keep_their_values_and_leave_their_objects_out() + { + PrRoleRow[] roles = + { + new() { Id = 1, Code = "R1", TenantId = 1, Name = new PrName { En = "One" }, Contents = { new PrGrantRow { Code = "read" } }, Codes = { "read" } } + }; + PrOpenRow[] open = { new() { Id = 1, Code = "R1", Contents = { new PrSecretGrantRow { Code = "read", Secret = "s-read" } } } }; + + PrRoleRow role = roles.AsQueryable().ApplyPolicy(Caller(), Options(DwTier.Strict), Resolver()).ToList(Everything()).Data.Single(); + PolicyQueryable guarded = open.AsQueryable().ApplyPolicy(Caller(), Options(DwTier.Strict), Resolver()); + PrOpenRow row = guarded.ToList(Everything()).Data.Single(); + + Assert.Equal("R1|-|-||read", Describe(role)); + Assert.Equal(("R1", 0), (row.Code, row.Contents.Count)); + Assert.Equal("s-read", open[0].Contents[0].Secret); + Assert.Contains( + guarded.LastTrace!.Decisions, + decision => decision is { FieldPath: "Contents.Secret", Action: PolicyAction.Dropped }); + } + + // ------------------------------------------------------------------ F2: the key the builder adds + + /// A synthesized projection cannot narrow a list whose key is denied, so it leaves the list out. + [Theory] + [InlineData(DwTier.Convenience)] + [InlineData(DwTier.Strict)] + public void A_member_whose_key_is_denied_is_left_out_whole(DwTier tier) + { + PolicyQueryable guarded = Guard(KeyedRows(), tier); + + Assert.All(guarded.ToList(Everything()).Data, row => Assert.Empty(row.Contents)); + Assert.Contains( + guarded.LastTrace!.Decisions, + decision => decision.FieldPath == "Contents" && decision.Reason == "left out whole: the projection would add its key 'Contents.Id', which is denied"); + } + + /// + /// Naming a navigation whose key is denied used to drop the key from the list in the convenience + /// tier, and the builder added it straight back. It is refused in both tiers, as naming a sibling + /// of the key already was. + /// + [Theory] + [InlineData(DwTier.Convenience)] + [InlineData(DwTier.Strict)] + public void Naming_a_navigation_whose_key_is_denied_is_refused(DwTier tier) + { + PolicyException projected = Assert.Throws( + () => Guard(KeyedRows(), tier).ToList(Selecting("Id", "Contents"))); + PolicyException entity = Assert.Throws( + () => Guard(_db.KeyParents, tier).ToList(Selecting("Id", "Kids"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, projected.ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, entity.ErrorCode); + Assert.Equal(tier == DwTier.Strict ? "*" : "Contents.Id", projected.FieldPath); + Assert.Equal(tier == DwTier.Strict ? "*" : "Kids.Id", entity.FieldPath); + } + + // ------------------------------------------------------------------ F3: a list typed IReadOnlyList + + /// + /// The projection gate read collections through a narrower list than the policy, so a list typed + /// IReadOnlyList<T> hid its denied field: naming it carried the value out in both tiers. + /// + [Theory] + [InlineData(DwTier.Convenience)] + [InlineData(DwTier.Strict)] + public void Naming_a_read_only_list_with_a_denial_beneath_is_refused(DwTier tier) + { + PolicyException typed = Assert.Throws( + () => Guard(ReadOnlyRows(), tier).ToList(Selecting("Id", "Contents"))); + PolicyException dynamicRows = Assert.Throws( + () => Guard(ReadOnlyRows(), tier).ToListDynamic(Selecting("Id", "Contents"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, typed.ErrorCode); + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, dynamicRows.ErrorCode); + Assert.Equal(tier == DwTier.Strict ? "*" : "Contents.Secret", typed.FieldPath); + } + + /// + /// With no projection sent, the read-only list with a denial beneath is left out, since the core + /// cannot project a path through it; the clean one beside it is carried whole. + /// + [Fact] + public void A_read_only_list_is_kept_whole_when_clean_and_left_out_when_it_cannot_be_narrowed() + { + PolicyQueryable guarded = Guard(ReadOnlyRows(), DwTier.Strict); + PrReadOnlyRow row = guarded.ToList(Everything()).Data.Single(r => r.Id == 1); + + Assert.Empty(row.Contents); + Assert.Equal(new[] { "read", "write" }, row.Clean.Select(grant => grant.Code).OrderBy(x => x)); + Assert.Contains( + guarded.LastTrace!.Decisions, + decision => decision.FieldPath == "Contents" && decision.Reason == "left out whole: the core cannot project 'Contents.Code'"); + Assert.Equal( + new[] { "read", "write" }, + Guard(ReadOnlyRows(), DwTier.Strict).ToList(Selecting("Id", "Clean")).Data + .Single(r => r.Id == 1).Clean.Select(grant => grant.Code).OrderBy(x => x)); + } + + // ------------------------------------------------------------------ the gate, without a database + + /// A type with nothing denied anywhere keeps its null projection, on every source. + [Fact] + public void Nothing_is_synthesized_when_nothing_is_denied_at_any_depth() + { + foreach (RowShape rows in new[] { RowShape.Unknown, RowShape.InMemory }) + { + Filter sanitized = FilterSanitizer.Sanitize( + new Filter(), Resolver(), Caller(), Options(DwTier.Strict), new PolicyTrace(DwTier.Strict, dryRun: false), rows: rows); + + Assert.Null(sanitized.Selects); + } + } + + /// What each source keeps of the same type, with one denial beneath a list. + [Fact] + public void Each_source_keeps_what_it_carries() + { + List? Synthesized(RowShape rows) => FilterSanitizer.Sanitize( + new Filter(), Resolver(), Caller(), Options(DwTier.Strict), new PolicyTrace(DwTier.Strict, dryRun: false), rows: rows).Selects; + + Assert.Equal(new[] { "Code", "Id" }, Synthesized(RowShape.Unknown)!.OrderBy(x => x, StringComparer.Ordinal)); + Assert.Equal(new[] { "Code", "Contents.Code", "Id" }, Synthesized(RowShape.Of(OpenRows()))!.OrderBy(x => x, StringComparer.Ordinal)); + Assert.Equal(new[] { "Code", "Id" }, Synthesized(RowShape.InMemory)!.OrderBy(x => x, StringComparer.Ordinal)); + } +} + +/// The shelf's shape with no attribute anywhere. +public class PrShelfWithoutDenials +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public PrPlace? Place { get; set; } + + public List Contents { get; set; } = new(); +} diff --git a/DynamicWhere.Tests/Policies/ProjectionReachTests.cs b/DynamicWhere.Tests/Policies/ProjectionReachTests.cs new file mode 100644 index 0000000..c50748e --- /dev/null +++ b/DynamicWhere.Tests/Policies/ProjectionReachTests.cs @@ -0,0 +1,1249 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using System.Text.Json.Serialization; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace DynamicWhere.Tests.Policies +{ + // --------------------------------------------------------------------------------- the database + + public class RcCustomer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Cards { get; set; } = new(); + + public List Orders { get; set; } = new(); + } + + public class RcCard + { + public int Id { get; set; } + + public int RcCustomerId { get; set; } + + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pan { get; set; } + } + + public class RcOrder + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public int RcCustomerId { get; set; } + + public RcCustomer? Customer { get; set; } + + public List Lines { get; set; } = new(); + } + + public class RcLine + { + public int Id { get; set; } + + public int RcOrderId { get; set; } + + public string Sku { get; set; } = string.Empty; + + [DwDenied] + public string? Cost { get; set; } + } + + public class RcPerson + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwDenied] + public string? TaxId { get; set; } + } + + public class RcAccount + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public int RcPersonId { get; set; } + + public RcPerson? Person { get; set; } + } + + /// The root of a hierarchy whose derived type declares a denied field. + public class RcParty + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class RcCompany : RcParty + { + [DwDenied] + public string? TaxSecret { get; set; } + } + + public class RcDeal + { + public int Id { get; set; } + + public string Note { get; set; } = string.Empty; + + public int RcPartyId { get; set; } + + public RcParty? Party { get; set; } + } + + /// An abstract root, which no projection can build. + public abstract class RcAsset + { + public int Id { get; set; } + + public string Label { get; set; } = string.Empty; + } + + public class RcVault : RcAsset + { + [DwDenied] + public string? Combination { get; set; } + } + + /// A value EF Core converts to text, holding a type with a denied field. + public class RcCharge + { + public string Sku { get; set; } = string.Empty; + + [DwDenied] + public string? Amount { get; set; } + } + + public class RcClient + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public Dictionary Charges { get; set; } = new(); + } + + public class RcPurchase + { + public int Id { get; set; } + + public int RcClientId { get; set; } + + public RcClient? Client { get; set; } + } + + /// A navigation whose entity owns a chain deeper than the walker goes. + public class RcHolding + { + public int Id { get; set; } + + public int RcKeeperId { get; set; } + + public RcKeeper? Keeper { get; set; } + } + + public class RcKeeper + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public RcProfile Profile { get; set; } = new(); + } + + public class RcProfile + { + public string A { get; set; } = string.Empty; + + public RcDetail Detail { get; set; } = new(); + } + + public class RcDetail + { + public string B { get; set; } = string.Empty; + + public RcInner Inner { get; set; } = new(); + } + + public class RcInner + { + public string C { get; set; } = string.Empty; + + [DwDenied] + public string? Secret { get; set; } + } + + /// An automatically included navigation with a denied field. + public class RcBadgeHolder + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public int RcBadgeId { get; set; } + + public RcBadge? Badge { get; set; } + } + + public class RcBadge + { + public int Id { get; set; } + + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pin { get; set; } + } + + /// A member EF Core does not map that can hold an object of any type. + public class RcBag + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public int RcBagOwnerId { get; set; } + + public RcBagOwner? Owner { get; set; } + + [NotMapped] + public object? Extra { get; set; } + } + + public class RcBagOwner + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + /// An owned member with a mapped property that has no setter. + public class RcShop + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? Notes { get; set; } + + public RcAddress Address { get; set; } = new(); + } + + public class RcAddress + { + public RcAddress() + { + } + + public RcAddress(string city, string? zip) + { + City = city; + Zip = zip; + } + + public string City { get; set; } = string.Empty; + + public string? Zip { get; } + } + + /// An owned member every path of which the walk asks about, and an included navigation. + public class RcStore + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public RcPlace Place { get; set; } = new(); + + public int RcStoreKeeperId { get; set; } + + public RcStoreKeeper? Keeper { get; set; } + } + + public class RcPlace + { + public string City { get; set; } = string.Empty; + + public string Zone { get; set; } = string.Empty; + } + + public class RcStoreKeeper + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + /// An owned chain whose last owned type exposes a mapped field through a getter the model does not map. + public class RcRoom + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public RcShelf Shelf { get; set; } = new(); + } + + public class RcShelf + { + public string Label { get; set; } = string.Empty; + + public RcBox Box { get; set; } = new(); + } + + public class RcBox + { + public string Label { get; set; } = string.Empty; + + public RcLid Lid { get; set; } = new(); + } + + public class RcLid + { + private string? _code; + + public string Colour { get; set; } = string.Empty; + + [NotMapped] + public RcCodeView Code => new() { Value = _code }; + + public void Seal(string code) => _code = code; + } + + public class RcCodeView + { + [DwDenied] + public string? Value { get; set; } + } + + public sealed class ProjectionReachContext : DbContext + { + private readonly SqliteConnection _connection; + + public ProjectionReachContext(SqliteConnection connection) => _connection = connection; + + public DbSet Customers => Set(); + + public DbSet Orders => Set(); + + public DbSet People => Set(); + + public DbSet Accounts => Set(); + + public DbSet Parties => Set(); + + public DbSet Deals => Set(); + + public DbSet Assets => Set(); + + public DbSet Purchases => Set(); + + public DbSet Holdings => Set(); + + public DbSet BadgeHolders => Set(); + + public DbSet Bags => Set(); + + public DbSet Shops => Set(); + + public DbSet Stores => Set(); + + public DbSet Rooms => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity(); + model.Entity(); + model.Entity().Property(c => c.Charges).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!); + model.Entity().OwnsOne( + k => k.Profile, p => p.OwnsOne(x => x.Detail, d => d.OwnsOne(y => y.Inner))); + model.Entity().Navigation(h => h.Badge).AutoInclude(); + model.Entity().OwnsOne(s => s.Address, a => a.Property(x => x.Zip)); + model.Entity().OwnsOne(s => s.Place); + model.Entity().OwnsOne( + r => r.Shelf, s => s.OwnsOne(x => x.Box, b => b.OwnsOne(y => y.Lid, l => l.Property("_code")))); + } + } + + // --------------------------------------------------------------------------------- lazy loaders + + /// A lazy loader delegate the constructor takes and keeps in a field. + public class RcFieldBlog + { + private readonly Action? _loader; + private List? _posts; + + public RcFieldBlog() + { + } + + private RcFieldBlog(Action lazyLoader) => _loader = lazyLoader; + + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Posts + { + get + { + _loader?.Invoke(this, nameof(Posts)); + + return _posts ??= new List(); + } + set => _posts = value; + } + } + + public class RcFieldPost + { + public int Id { get; set; } + + public int RcFieldBlogId { get; set; } + + public string Title { get; set; } = string.Empty; + + [DwDenied] + public string? Draft { get; set; } + } + + /// A lazy loader delegate kept in a property not named LazyLoader. + public class RcNamedBlog + { + private List? _posts; + + public RcNamedBlog() + { + } + + private RcNamedBlog(Action lazyLoader) => Loader = lazyLoader; + + private Action? Loader { get; set; } + + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Posts + { + get + { + Loader?.Invoke(this, nameof(Posts)); + + return _posts ??= new List(); + } + set => _posts = value; + } + } + + public class RcNamedPost + { + public int Id { get; set; } + + public int RcNamedBlogId { get; set; } + + public string Title { get; set; } = string.Empty; + + [DwDenied] + public string? Draft { get; set; } + } + + public sealed class ProjectionReachLazyContext : DbContext + { + private readonly SqliteConnection _connection; + + public ProjectionReachLazyContext(SqliteConnection connection) => _connection = connection; + + public DbSet FieldBlogs => Set(); + + public DbSet NamedBlogs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().Navigation(b => b.Posts).HasField("_posts"); + model.Entity().Navigation(b => b.Posts).HasField("_posts"); + } + } + + // --------------------------------------------------------------------------------- rows + + /// A row whose member a constructor sets, before its initializer runs. + public class RcHolder + { + public RcHolder() + { + } + + public RcHolder(RcPerson person) => Person = person; + + public int Id { get; set; } + + public RcPerson? Person { get; set; } + } + + public class RcDealRow + { + public int Id { get; set; } + + [DwDenied] + public string? Note { get; set; } + + public RcParty? Party { get; set; } + } + + public sealed class RcDeepRow + { + public int Id { get; set; } + + public string? Tenant { get; set; } + + public RcL1? First { get; set; } + } + + public sealed class RcL1 + { + public RcL2? Next { get; set; } + } + + public sealed class RcL2 + { + public RcL3? Next { get; set; } + } + + public sealed class RcL3 + { + public string Name { get; set; } = string.Empty; + + public RcL4? Next { get; set; } + } + + public sealed class RcL4 + { + public string? Secret { get; set; } + } + + /// A row holding policed objects through a collection that is not generic. + public sealed class RcBagRow + { + public int Id { get; set; } + + [DwDenied] + public string? Note { get; set; } + + public IEnumerable? Items { get; set; } + } + + public sealed class RcNode + { + public string Name { get; set; } = string.Empty; + + public RcNode? Next { get; set; } + } + + public sealed class RcLink + { + public int Id { get; set; } + + public RcNode? Head { get; set; } + } + + /// A base type whose subtype's field a rule denies, no attribute anywhere. + public class RcPet + { + public string Name { get; set; } = string.Empty; + } + + public class RcHamster : RcPet + { + public string? Tag { get; set; } + } + + public class RcCage + { + public int Id { get; set; } + + public RcPet? Pet { get; set; } + } + + /// A row in memory holding an object of any type, with nothing denied anywhere. + public class RcNote + { + public int Id { get; set; } + + public object? Payload { get; set; } + } + + /// Rows in memory whose subtype declares a denied field. + public class RcAnimal + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class RcDog : RcAnimal + { + [DwDenied] + public string? Chip { get; set; } + } + + // --------------------------------------------------------------------------------- the tests + + /// + /// What a denied value can reach, read from what the source actually loads, and what a member can + /// hold that no path names: a derived type's field, a path a policy denying by default never names, + /// a collection that is not generic. Each test asserts the safe outcome, so a failing one is a leak. + /// + public sealed class ProjectionReachTests : IDisposable + { + private static readonly JsonSerializerOptions Json = new() { ReferenceHandler = ReferenceHandler.IgnoreCycles }; + + private readonly SqliteConnection _connection; + private readonly ProjectionReachContext _db; + + public ProjectionReachTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _db = new ProjectionReachContext(_connection); + _db.Database.EnsureCreated(); + + _db.Customers.Add(new RcCustomer + { + Name = "C1", + Cards = { new RcCard { Label = "visa", Pan = "pan-secret" } }, + Orders = { new RcOrder { Code = "O1", Lines = { new RcLine { Sku = "S1", Cost = "cost-secret" } } } } + }); + _db.Accounts.Add(new RcAccount { Name = "A1", Person = new RcPerson { Name = "P1", TaxId = "tax-secret" } }); + _db.Deals.Add(new RcDeal { Note = "note", Party = new RcCompany { Name = "Acme", TaxSecret = "company-secret" } }); + _db.Assets.Add(new RcVault { Label = "V1", Combination = "combination-secret" }); + _db.Purchases.Add(new RcPurchase + { + Client = new RcClient { Name = "K1", Charges = { ["a"] = new RcCharge { Sku = "S1", Amount = "amount-secret" } } } + }); + _db.Holdings.Add(new RcHolding + { + Keeper = new RcKeeper + { + Name = "O1", + Profile = new RcProfile { A = "a", Detail = new RcDetail { B = "b", Inner = new RcInner { C = "c", Secret = "deep-secret" } } } + } + }); + _db.BadgeHolders.Add(new RcBadgeHolder { Name = "H1", Badge = new RcBadge { Label = "gold", Pin = "pin-secret" } }); + _db.Bags.Add(new RcBag { Name = "B1", Owner = new RcBagOwner { Name = "W1" } }); + _db.Shops.Add(new RcShop { Name = "S1", Notes = "notes", Address = new RcAddress("Basra", "zip-secret") }); + _db.Stores.Add(new RcStore { Name = "T1", Place = new RcPlace { City = "Basra", Zone = "Z1" }, Keeper = new RcStoreKeeper { Name = "K" } }); + + RcRoom room = new() { Name = "R1", Shelf = new RcShelf { Label = "s", Box = new RcBox { Label = "b", Lid = new RcLid { Colour = "red" } } } }; + room.Shelf.Box.Lid.Seal("lid-secret"); + _db.Rooms.Add(room); + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static DwPolicyContext Caller() => + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1").WithValue("TenantId", 1); + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict, params IDwPolicyProvider[] more) + where T : class => + source.ApplyPolicy( + Caller(), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }.Concat(more).ToArray())); + + private static Filter Selecting(params string[] fields) => new() { Selects = fields.ToList() }; + + /// A policy that denies Select on every field except those it names. + private static FakePolicyProvider DenyingAllBut(params string[] allowed) + { + FakePolicyProvider rules = new FakePolicyProvider() + .Add("*", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + foreach (string path in allowed) + { + rules.Add(path, PolicyFeature.Select, PolicyEffect.Allow, PolicyLevel.DynamicGlobal); + } + + return rules; + } + + /// Everything a row holds, as a serializer would send it. + private static string Sent(object? rows) => JsonSerializer.Serialize(rows, Json); + + /// + /// True when anything reachable from a value holds the text, read by each object's runtime type: a + /// serializer that writes the declared type would miss a derived type's field. + /// + private static bool Holds(object? value, string text) + { + HashSet seen = new(ReferenceEqualityComparer.Instance); + Stack pending = new(); + + pending.Push(value); + + while (pending.Count > 0) + { + object? current = pending.Pop(); + + if (current is null || current is ValueType) + { + continue; + } + + if (current is string held) + { + if (held == text) + { + return true; + } + + continue; + } + + if (!seen.Add(current)) + { + continue; + } + + if (current is IEnumerable items) + { + foreach (object? item in items) + { + pending.Push(item); + } + + continue; + } + + foreach (System.Reflection.PropertyInfo property in current.GetType().GetProperties()) + { + if (property.GetIndexParameters().Length == 0 && property.CanRead) + { + pending.Push(property.GetValue(current)); + } + } + } + + return false; + } + + private static void Refused(Action query) + { + PolicyException refusal = Assert.Throws(query); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refusal.ErrorCode); + } + + // ---------------------------------------------------------------- includes read from another root + + /// + /// A later operator that reaches the rows through a navigation keeps what the includes loaded, named + /// from the query's root: Select(o => o.Customer) after an include of the customer's cards. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public async Task An_include_named_from_another_root_counts_as_loading_every_navigation(DwTier tier) + { + IQueryable source = _db.Orders + .Include(o => o.Customer).ThenInclude(c => c!.Cards) + .Select(o => o.Customer!); + + Assert.Contains("pan-secret", Sent(source.AsNoTracking().ToList())); + + Assert.DoesNotContain("pan-secret", Sent(Guard(source, tier).ToList(new Filter()).Data)); + Assert.DoesNotContain("pan-secret", Sent(Guard(source, tier).ToListDynamic(new Filter()).Data)); + Assert.DoesNotContain("pan-secret", Sent((await Guard(source, tier).ToListAsync(new Filter(), CancellationToken.None)).Data)); + Assert.DoesNotContain("pan-secret", Sent((await Guard(source, tier).ToListAsync(new Segment(), CancellationToken.None)).Data)); + + IQueryable named = _db.Orders.Include("Customer.Cards").Select(o => o.Customer!); + + Assert.DoesNotContain("pan-secret", Sent(Guard(named, tier).ToList(new Filter()).Data)); + } + + [Fact] + public void An_include_named_from_another_root_is_read_through_SelectMany_and_Join() + { + IQueryable flattened = _db.Customers + .Include(c => c.Orders).ThenInclude(o => o.Lines) + .SelectMany(c => c.Orders); + IQueryable selected = _db.Customers + .Include(c => c.Orders).ThenInclude(o => o.Lines) + .SelectMany(c => c.Orders, (c, o) => o); + IQueryable joined = _db.Orders + .Include(o => o.Customer).ThenInclude(c => c!.Cards) + .Join(_db.Accounts, o => o.Id, a => a.Id, (o, a) => o.Customer!); + + Assert.Contains("cost-secret", Sent(flattened.AsNoTracking().ToList())); + + Assert.DoesNotContain("cost-secret", Sent(Guard(flattened).ToList(new Filter()).Data)); + Assert.DoesNotContain("cost-secret", Sent(Guard(selected).ToList(new Filter()).Data)); + Assert.DoesNotContain("pan-secret", Sent(Guard(joined).ToList(new Filter()).Data)); + } + + // ---------------------------------------------------------------- projections the outer Select hides + + /// + /// A projection behind a Select that builds nothing, a member of an anonymous row or a + /// conditional still loads what it assigns, although the rows are the entity's type. + /// + [Fact] + public void A_projection_behind_another_Select_is_not_read_as_an_entity_query() + { + IQueryable identity = _db.Accounts + .Select(a => new RcAccount { Id = a.Id, Name = a.Name, Person = a.Person }) + .Select(x => x); + IQueryable member = _db.Accounts + .Select(a => new { Row = new RcAccount { Id = a.Id, Name = a.Name, Person = a.Person } }) + .Select(x => x.Row); + IQueryable conditional = _db.Accounts + .Select(a => a.Id > 0 + ? new RcAccount { Id = a.Id, Name = a.Name, Person = a.Person } + : new RcAccount { Id = a.Id, Name = a.Name }); + + Assert.Contains("tax-secret", Sent(identity.ToList())); + + Assert.DoesNotContain("tax-secret", Sent(Guard(identity).ToList(new Filter()).Data)); + Assert.DoesNotContain("tax-secret", Sent(Guard(member).ToList(new Filter()).Data)); + Assert.DoesNotContain("tax-secret", Sent(Guard(conditional).ToList(new Filter()).Data)); + } + + /// + /// A projection beneath a Join does not make the join's rows: they are the result selector's, + /// which here puts the person back. The rows are read as the entity they are, every navigation loaded. + /// + [Fact] + public void A_projection_beneath_a_Join_is_not_the_one_that_makes_the_rows() + { + IQueryable rows = _db.Accounts + .Select(a => new RcAccount { Id = a.Id, RcPersonId = a.RcPersonId }) + .Join(_db.People, a => a.RcPersonId, p => p.Id, (a, p) => new RcAccount { Id = a.Id, Person = p }); + + RowShape shape = RowShape.Of(rows); + + Assert.Equal(RowKind.Entity, shape.Kind); + Assert.True(shape.Materializes("Person.TaxId")); + Assert.Contains("tax-secret", Sent(rows.ToList())); + + // EF Core may refuse to read a member the result selector did not assign; it never returns the value. + Exception? refused = Record.Exception(() => Assert.DoesNotContain("tax-secret", Sent(Guard(rows).ToList(new Filter()).Data))); + + Assert.True(refused is null or InvalidOperationException, refused?.ToString()); + } + + /// A constructor with arguments sets members its initializer never names. + [Fact] + public void An_initializer_after_a_constructor_with_arguments_counts_every_member_as_assigned() + { + IQueryable rows = _db.People.Select(p => new RcHolder(p) { Id = p.Id }); + + Assert.Contains("tax-secret", Sent(rows.ToList())); + Assert.DoesNotContain("tax-secret", Sent(Guard(rows).ToList(new Filter()).Data)); + } + + // ---------------------------------------------------------------- lazy loaders the model keeps no record of + + /// + /// A lazy loader delegate the constructor takes gets no service property, whether the class keeps it + /// in a field or in a property of another name; it still fills the navigation after the query. + /// + [Fact] + public void A_lazy_loader_kept_where_the_model_has_no_record_of_it_counts_as_loading() + { + using SqliteConnection connection = new("DataSource=:memory:"); + connection.Open(); + + using ProjectionReachLazyContext db = new(connection); + db.Database.EnsureCreated(); + db.FieldBlogs.Add(new RcFieldBlog { Name = "B1", Posts = { new RcFieldPost { Title = "T", Draft = "field-draft" } } }); + db.NamedBlogs.Add(new RcNamedBlog { Name = "B2", Posts = { new RcNamedPost { Title = "T", Draft = "named-draft" } } }); + db.SaveChanges(); + db.ChangeTracker.Clear(); + + RcFieldBlog field = Guard(db.FieldBlogs).ToList(new Filter()).Data.Single(); + RcNamedBlog named = Guard(db.NamedBlogs).ToList(new Filter()).Data.Single(); + + Assert.DoesNotContain(field.Posts, post => post.Draft == "field-draft"); + Assert.DoesNotContain(named.Posts, post => post.Draft == "named-draft"); + } + + // ---------------------------------------------------------------- a rule in another letter case + + /// + /// A rule spelled in another letter case, on a path deeper than the walk, reaches a projected row's + /// assigned member: whether or not anything else is denied, it asks for the projection. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_rule_in_another_letter_case_reaches_an_assigned_member(bool somethingElseDenied) + { + IQueryable rows = _db.Customers.Select(c => new RcDeepRow + { + Id = c.Id, + Tenant = c.Name, + First = new RcL1 { Next = new RcL2 { Next = new RcL3 { Name = c.Name, Next = new RcL4 { Secret = "camel-secret" } } } } + }); + + FakePolicyProvider rules = new FakePolicyProvider() + .Add("first.next.next.next.secret", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + if (somethingElseDenied) + { + rules.Add("tenant", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + } + + RcDeepRow row = Guard(rows, DwTier.Strict, rules).ToList(new Filter()).Data.Single(); + + Assert.Null(row.First?.Next?.Next?.Next?.Secret); + Assert.Equal("C1", row.First?.Next?.Next?.Name); + } + + // ---------------------------------------------------------------- what a member can hold + + /// A collection that is not generic holds objects, whatever they carry, so it is never clean. + [Fact] + public void A_collection_that_is_not_generic_is_never_kept_whole() + { + IQueryable rows = _db.Customers.Select(c => new RcBagRow { Id = c.Id, Note = c.Name, Items = c.Cards.ToList() }); + + PolicyQueryable guarded = Guard(rows); + RcBagRow row = guarded.ToList(new Filter()).Data.Single(); + + Assert.Null(row.Items); + Assert.Contains(guarded.LastTrace!.Decisions, d => d.FieldPath == "Items" && d.Action == PolicyAction.Dropped); + } + + /// + /// A member declared as a base type holds a derived entity, whose own denied field the base type + /// never names: a projected row narrows it to the base type rather than keeping it whole. + /// + [Fact] + public void A_member_declared_as_a_base_type_is_narrowed_to_it() + { + IQueryable rows = _db.Deals.Select(d => new RcDealRow { Id = d.Id, Note = d.Note, Party = d.Party }); + + Assert.True(Holds(rows.ToList(), "company-secret")); + + RcDealRow row = Guard(rows).ToList(new Filter()).Data.Single(); + + Assert.False(Holds(row, "company-secret")); + Assert.IsNotType(row.Party); + Assert.Equal("Acme", row.Party!.Name); + } + + /// + /// A query over the root of a hierarchy returns each row as its derived type. A derived type's denied + /// field asks for the projection, which builds the root type. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_denied_field_of_a_derived_type_projects_the_root(DwTier tier) + { + PolicyQueryable guarded = Guard(_db.Parties, tier); + RcParty party = guarded.ToList(new Filter()).Data.Single(); + + Assert.IsNotType(party); + Assert.Equal("Acme", party.Name); + Assert.Contains(guarded.LastTrace!.Decisions, d => d.FieldPath == "TaxSecret" && d.Action == PolicyAction.Dropped); + } + + /// + /// An abstract root cannot be built, so the typed terminal refuses the projection a derived type's denial + /// asks for, as it refuses any type with no public parameterless constructor. The dynamic terminal builds + /// its own class and returns the root's allowed members. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void An_abstract_root_that_needs_a_projection_is_never_returned_whole(DwTier tier) + { + LogicException refusal = Assert.Throws(() => Guard(_db.Assets, tier).ToList(new Filter())); + + Assert.Equal(ErrorCode.SelectTypeMustHaveParameterlessConstructor, refusal.Message); + + List rows = Guard(_db.Assets, tier).ToListDynamic(new Filter()).Data!; + + Assert.Equal("V1", (string)Assert.Single(rows).Label); + Assert.False(Holds(rows, "combination-secret")); + + // Its concrete type can be queried, and is projected like any other. + Assert.Null(Guard(_db.Assets.OfType(), tier).ToList(new Filter()).Data.Single().Combination); + } + + /// Rows in memory can be any loaded subtype, and are projected to the rows' type. + [Fact] + public void Rows_in_memory_of_a_subtype_with_a_denied_field_are_projected() + { + RcAnimal[] rows = { new RcDog { Id = 1, Name = "Rex", Chip = "chip-secret" } }; + + RcAnimal row = Guard(rows.AsQueryable()).ToList(new Filter()).Data.Single(); + + Assert.IsNotType(row); + Assert.Equal("Rex", row.Name); + } + + /// + /// A navigation named in Selects, or included, declared as the root of a hierarchy, holds the + /// derived type's denied field. Strict refuses naming it; Convenience narrows it to the root type. + /// An included one is left out of the projection it asks for. + /// + [Fact] + public void A_navigation_declared_as_a_base_type_carries_the_derived_type() + { + Refused(() => Guard(_db.Deals, DwTier.Strict).ToList(Selecting("Id", "Party"))); + + RcDeal named = Guard(_db.Deals, DwTier.Convenience).ToList(Selecting("Id", "Party")).Data.Single(); + RcDeal included = Guard(_db.Deals.Include(d => d.Party)).ToList(new Filter()).Data.Single(); + + Assert.True(Holds(_db.Deals.Include(d => d.Party).AsNoTracking().ToList(), "company-secret")); + Assert.Equal("Acme", named.Party!.Name); + Assert.False(Holds(named, "company-secret")); + Assert.False(Holds(included, "company-secret")); + } + + // ---------------------------------------------------------------- a policy that denies what it does not name + + /// + /// Under a "*" deny, a path the walk never asks about is denied: one around a cycle, one + /// deeper than the walk, and a property with no setter. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Under_a_deny_by_default_policy_a_member_named_is_never_kept_whole(DwTier tier) + { + RcLink[] links = { new() { Id = 1, Head = new RcNode { Name = "n1", Next = new RcNode { Name = "n2-secret" } } } }; + + // In memory a member cannot be narrowed, so it is refused in both tiers. + Refused(() => Guard(links.AsQueryable(), tier, DenyingAllBut("Id", "Head", "Head.Name")).ToList(Selecting("Id", "Head"))); + + FakePolicyProvider shop = DenyingAllBut("Id", "Name", "Address", "Address.City"); + + if (tier == DwTier.Strict) + { + Refused(() => Guard(_db.Shops, tier, shop).ToList(Selecting("Id", "Address"))); + } + else + { + RcShop row = Guard(_db.Shops, tier, shop).ToList(Selecting("Id", "Address")).Data.Single(); + + Assert.Equal(("Basra", (string?)null), (row.Address.City, row.Address.Zip)); + } + } + + [Fact] + public void Under_a_deny_by_default_policy_a_synthesized_projection_narrows_what_the_walk_misses() + { + IQueryable deep = _db.Customers.Select(c => new RcDeepRow + { + Id = c.Id, + Tenant = c.Name, + First = new RcL1 { Next = new RcL2 { Next = new RcL3 { Name = c.Name, Next = new RcL4 { Secret = "deep-secret" } } } } + }); + + RcDeepRow row = Guard(deep, DwTier.Strict, DenyingAllBut("Id", "First", "First.Next", "First.Next.Next", "First.Next.Next.Name")) + .ToList(new Filter()).Data.Single(); + + Assert.Null(row.First?.Next?.Next?.Next?.Secret); + Assert.Equal("C1", row.First?.Next?.Next?.Name); + + // Notes denied, and with nothing else denied: the owned address is narrowed either way. + foreach (FakePolicyProvider rules in new[] + { + DenyingAllBut("Id", "Name", "Address", "Address.City"), + DenyingAllBut("Id", "Name", "Notes", "Address", "Address.City") + }) + { + RcShop shop = Guard(_db.Shops, DwTier.Strict, rules).ToList(new Filter()).Data.Single(); + + Assert.Equal(("Basra", (string?)null), (shop.Address.City, shop.Address.Zip)); + } + } + + /// + /// A deny-by-default policy that names every path of an owned member asks for nothing: the entity is + /// read as loaded, its included navigation with it. + /// + [Fact] + public void Under_a_deny_by_default_policy_a_member_the_walk_covers_is_kept() + { + FakePolicyProvider rules = DenyingAllBut( + "Id", "Name", "Place", "Place.City", "Place.Zone", "RcStoreKeeperId", "Keeper", "Keeper.Id", "Keeper.Name"); + + PolicyQueryable guarded = Guard(_db.Stores.Include(s => s.Keeper), DwTier.Strict, rules); + RcStore store = guarded.ToList(new Filter()).Data.Single(); + + Assert.Equal(("Z1", "K"), (store.Place.Zone, store.Keeper?.Name)); + Assert.DoesNotContain(guarded.LastTrace!.Decisions, d => d.Action == PolicyAction.Dropped); + } + + // ---------------------------------------------------------------- what a navigation's entity loads + + /// + /// Naming a navigation loads its entity whole, its owned chain included, however deep. Strict refuses + /// it; Convenience narrows it to the paths the walk can name. + /// + [Fact] + public void A_navigation_named_whose_owned_chain_holds_a_denial_past_the_walk_is_not_returned_whole() + { + Refused(() => Guard(_db.Holdings, DwTier.Strict).ToList(Selecting("Id", "Keeper"))); + + RcHolding holding = Guard(_db.Holdings, DwTier.Convenience).ToList(Selecting("Id", "Keeper")).Data.Single(); + + Assert.Equal("O1", holding.Keeper!.Name); + Assert.DoesNotContain("deep-secret", Sent(holding)); + } + + /// A column of a navigation's entity holding a framework collection of a policed type. + [Fact] + public void A_navigation_named_whose_column_holds_a_policed_type_is_not_returned_whole() + { + Assert.Contains("amount-secret", Sent(_db.Purchases.Include(p => p.Client).ToList())); + + Refused(() => Guard(_db.Purchases, DwTier.Strict).ToList(Selecting("Id", "Client"))); + Refused(() => Guard(_db.Purchases, DwTier.Strict).ToList(Selecting("Id", "Client.Charges"))); + + // Convenience narrows the navigation around the column, and the column itself to nothing. + RcPurchase purchase = Guard(_db.Purchases, DwTier.Convenience).ToList(Selecting("Id", "Client")).Data.Single(); + PolicyQueryable guarded = Guard(_db.Purchases, DwTier.Convenience); + RcPurchase column = guarded.ToList(Selecting("Id", "Client.Charges")).Data.Single(); + + Assert.Equal("K1", purchase.Client!.Name); + Assert.False(Holds(purchase, "amount-secret")); + Assert.False(Holds(column, "amount-secret")); + Assert.Contains(guarded.LastTrace!.Decisions, d => d.FieldPath == "Client.Charges" && d.Action == PolicyAction.Dropped); + } + + /// + /// An included navigation whose own navigation holds the denial, unloaded, asks for nothing: the + /// entity comes back as loaded, the included navigation with it. + /// + [Fact] + public void An_included_navigation_with_a_denial_only_beneath_what_it_does_not_load_is_kept() + { + PolicyQueryable guarded = Guard(_db.Orders.Include(o => o.Customer)); + RcOrder order = guarded.ToList(new Filter()).Data.Single(); + + Assert.Equal("C1", order.Customer?.Name); + Assert.DoesNotContain(guarded.LastTrace!.Decisions, d => d.Action == PolicyAction.Dropped); + } + + /// An automatically included navigation loads with every query, and so does its denied field. + [Fact] + public void An_automatically_included_navigation_counts_as_loaded() + { + Assert.Contains("pin-secret", Sent(_db.BadgeHolders.ToList())); + + PolicyQueryable guarded = Guard(_db.BadgeHolders); + RcBadgeHolder holder = guarded.ToList(new Filter()).Data.Single(); + + Assert.DoesNotContain("pin-secret", Sent(holder)); + Assert.Contains(guarded.LastTrace!.Decisions, d => d.FieldPath == "Badge.Pin" && d.Action == PolicyAction.Dropped); + } + + /// + /// A getter the model does not map, past the walker's four segments in an owned chain, hands out a + /// mapped field: what it returns counts as loaded, so its denied field asks for the projection. + /// + [Fact] + public void A_getter_the_model_does_not_map_past_the_walk_counts_as_loaded() + { + Assert.True(Holds(_db.Rooms.AsNoTracking().ToList(), "lid-secret")); + + PolicyQueryable guarded = Guard(_db.Rooms); + RcRoom room = guarded.ToList(new Filter()).Data.Single(); + + Assert.False(Holds(room, "lid-secret")); + Assert.Equal("red", room.Shelf.Box.Lid.Colour); + } + + /// + /// A rule on a field only a subtype declares, reached through a member declared as the base type, is + /// enforced like an attribute there would be: named or not, the field does not come back. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_rule_on_a_subtype_field_through_a_base_typed_member_is_enforced(DwTier tier) + { + RcCage[] rows = { new() { Id = 1, Pet = new RcHamster { Name = "Ham", Tag = "tag-by-rule" } } }; + FakePolicyProvider rules = new FakePolicyProvider() + .Add("Pet.Tag", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + Assert.False(Holds(Guard(rows.AsQueryable(), tier, rules).ToList(new Filter()).Data, "tag-by-rule")); + + Exception? named = Record.Exception(() => + Assert.False(Holds(Guard(rows.AsQueryable(), tier, rules).ToList(Selecting("Id", "Pet")).Data, "tag-by-rule"))); + + Assert.True(named is null or PolicyException, named?.ToString()); + } + + /// + /// With nothing denied, a row in memory holding an object of any type is returned as it is: such a + /// member asks for no projection on its own. + /// + [Fact] + public void A_row_in_memory_holding_an_object_with_nothing_denied_is_returned_as_it_is() + { + RcNote[] rows = { new() { Id = 1, Payload = new Dictionary { ["a"] = 1 } } }; + + PolicyQueryable guarded = Guard(rows.AsQueryable()); + + Assert.Same(rows[0], guarded.ToList(new Filter()).Data.Single()); + Assert.DoesNotContain(guarded.LastTrace!.Decisions, d => d.Action == PolicyAction.Dropped); + } + + /// + /// A member that can hold an object of any type asks for no projection: the policy cannot see into it + /// whether or not one is built. The entity comes back as loaded, its included navigation with it. + /// + [Fact] + public void A_member_that_can_hold_anything_asks_for_nothing() + { + PolicyQueryable guarded = Guard(_db.Bags.Include(b => b.Owner)); + RcBag bag = guarded.ToList(new Filter()).Data.Single(); + + Assert.Equal("W1", bag.Owner?.Name); + Assert.DoesNotContain(guarded.LastTrace!.Decisions, d => d.Action == PolicyAction.Dropped); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ProjectionReviewTests.cs b/DynamicWhere.Tests/Policies/ProjectionReviewTests.cs new file mode 100644 index 0000000..69b4433 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ProjectionReviewTests.cs @@ -0,0 +1,959 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.RegularExpressions; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace SystemsCorp.Payroll +{ + /// An application type whose namespace only starts with the word System. + public sealed class RvPay + { + public string? Grade { get; set; } + + [DwDenied] + public string? Salary { get; set; } + } +} + +namespace DynamicWhere.Tests.Policies +{ + // --------------------------------------------------------------------------------- the database + + public class RvRole + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public int? KeeperId { get; set; } + + public RvKeeper? Keeper { get; set; } + } + + public class RvKeeper + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwDenied] + public string? Ssn { get; set; } + } + + /// An entity with values EF Core maps through converters, and a denial only beneath a navigation. + public class RvAsset + { + public int Id { get; set; } + + public Uri? Home { get; set; } + + public RvMoney Price { get; set; } + + [NotMapped] + public List Tags { get; set; } = new(); + + public int? KeeperId { get; set; } + + public RvKeeper? Keeper { get; set; } + } + + public readonly record struct RvMoney(decimal Amount); + + /// The same shape with a denied column, so a projection is always needed. + public class RvSealedAsset + { + public int Id { get; set; } + + [DwDenied] + public string? Serial { get; set; } + + public Uri? Home { get; set; } + + public RvMoney Price { get; set; } + + [NotMapped] + public string Display => Serial ?? "none"; + + [NotMapped] + public List Tags { get; set; } = new(); + } + + /// A table-per-hierarchy base with a denial only beneath a navigation it does not load. + public abstract class RvVehicle + { + public int Id { get; set; } + + public string Plate { get; set; } = string.Empty; + + public int? KeeperId { get; set; } + + public RvKeeper? Keeper { get; set; } + } + + public class RvTruck : RvVehicle + { + public int Axles { get; set; } + } + + /// An entity whose owned member has a denied property with no setter. + public class RvShop + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public RvAddress Address { get; set; } = new(); + } + + public class RvAddress + { + public RvAddress() + { + } + + public RvAddress(string city, string? zip) + { + City = city; + Zip = zip; + } + + public string City { get; set; } = string.Empty; + + [DwDenied] + public string? Zip { get; } + } + + /// An entity whose denied members are not simple values: nothing else is denied. + public class RvVault + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwDenied] + public byte[] Blob { get; set; } = Array.Empty(); + + [DwDenied] + public RvVaultPlace? Place { get; set; } + } + + public class RvVaultPlace + { + public string Aisle { get; set; } = string.Empty; + } + + /// An entity whose child's key no caller may see, reached through another navigation. + public class RvHouse + { + public int Id { get; set; } + + public int? MainId { get; set; } + + public RvGroup? Main { get; set; } + } + + public class RvGroup + { + [DwDenied] + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public int? LeadId { get; set; } + + public RvLead? Lead { get; set; } + } + + public class RvLead + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + /// An entity whose children are scoped to a tenant: the scope filters parents, not children. + public class RvFamily + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Kids { get; set; } = new(); + } + + public class RvKid + { + public int Id { get; set; } + + public int RvFamilyId { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwForceWhere(Operator.Equal, ContextValue = "TenantId")] + public int TenantId { get; set; } + } + + /// An entity whose posts arrive through an injected lazy loader. + public class RvBlog + { + private List? _posts; + + public RvBlog() + { + } + + private RvBlog(ILazyLoader lazyLoader) => LazyLoader = lazyLoader; + + private ILazyLoader? LazyLoader { get; set; } + + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Posts + { + get + { + if (LazyLoader is not null) + { + LazyLoader.Load(this, ref _posts); + } + + return _posts ??= new List(); + } + set => _posts = value; + } + } + + public class RvPost + { + public int Id { get; set; } + + public int RvBlogId { get; set; } + + public string Title { get; set; } = string.Empty; + + [DwDenied] + public string? Draft { get; set; } + } + + public sealed class ProjectionReviewContext : DbContext + { + private readonly SqliteConnection _connection; + + public ProjectionReviewContext(SqliteConnection connection) => _connection = connection; + + public DbSet Roles => Set(); + + public DbSet Assets => Set(); + + public DbSet SealedAssets => Set(); + + public DbSet Vehicles => Set(); + + public DbSet Shops => Set(); + + public DbSet Vaults => Set(); + + public DbSet Houses => Set(); + + public DbSet Families => Set(); + + public DbSet Blogs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().Property(a => a.Price).HasConversion(m => m.Amount, a => new RvMoney(a)); + model.Entity().Property(a => a.Price).HasConversion(m => m.Amount, a => new RvMoney(a)); + model.Entity(); + model.Entity().OwnsOne(s => s.Address, a => a.Property(x => x.Zip)); + model.Entity().OwnsOne(v => v.Place); + model.Entity().Navigation(b => b.Posts).HasField("_posts"); + } + } + + // --------------------------------------------------------------------------------- the rows + + [DwEntity(RequirePolicy = true)] + public sealed class RvRoleRow + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwDenied] + public string? Tenant { get; set; } + + public RvKeeper? Keeper { get; set; } + } + + public sealed class RvContact + { + public RvContact() + { + } + + public RvContact(string email) => Email = email; + + public string Phone { get; set; } = string.Empty; + + [DwDenied] + public string? Email { get; } + } + + [DwEntity(RequirePolicy = true)] + public sealed class RvContactRow + { + public int Id { get; set; } + + [DwDenied] + public string? Tenant { get; set; } + + public RvContact? Contact { get; set; } + } + + public sealed class RvLine + { + public string Sku { get; set; } = string.Empty; + + [DwDenied] + public string? Cost { get; set; } + } + + /// Framework types holding a policed type: the walker does not enter them. + [DwEntity(RequirePolicy = true)] + public sealed class RvKeyedRow + { + public int Id { get; set; } + + public Dictionary BySku { get; set; } = new(); + + public object? Payload { get; set; } + } + + public sealed class RvLevel1 + { + public RvLevel2? Next { get; set; } + } + + public sealed class RvLevel2 + { + public RvLevel3? Next { get; set; } + } + + public sealed class RvLevel3 + { + public string Name { get; set; } = string.Empty; + + public RvLevel4? Next { get; set; } + } + + public sealed class RvLevel4 + { + [DwDenied] + public string? Secret { get; set; } + } + + /// A denial five segments down, deeper than the walker gives a fragment. + [DwEntity(RequirePolicy = true)] + public sealed class RvDeepRow + { + public int Id { get; set; } + + [DwDenied] + public string? Tenant { get; set; } + + public RvLevel1? First { get; set; } + } + + public sealed class RvStaffRow + { + public int Id { get; set; } + + public SystemsCorp.Payroll.RvPay? Pay { get; set; } + } + + [DwEntity(DefaultOrder = "Label desc")] + public sealed class RvLabelRow + { + public int Id { get; set; } + + public string Label { get; set; } = string.Empty; + } + + [DwEntity(RequirePolicy = true)] + public sealed class RvReservedRow + { + public int Id { get; set; } + + [DwDenied] + public string? Tenant { get; set; } + + public List Cast { get; set; } = new(); + } + + public sealed class RvItem + { + public string Name { get; set; } = string.Empty; + + [DwDenied] + public string? Hidden { get; set; } + } + + [DwEntity(RequirePolicy = true)] + public sealed class RvArrayRow + { + public int Id { get; set; } + + public RvItem[] Items { get; set; } = Array.Empty(); + } + + /// + /// What the 3.2.0 review found in the first cut of the synthesized projection, and the fixes for + /// it: denials the gate could not see, projections EF Core cannot translate, and members the first + /// cut lost that 3.1.0 returned. + /// + public sealed class ProjectionReviewTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly ProjectionReviewContext _db; + + public ProjectionReviewTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _db = new ProjectionReviewContext(_connection); + _db.Database.EnsureCreated(); + + RvKeeper keeper = new() { Name = "K", Ssn = "ssn-secret" }; + + _db.Roles.Add(new RvRole { Code = "R1", Keeper = keeper }); + _db.Assets.Add(new RvAsset { Home = new Uri("https://example.test/"), Price = new RvMoney(12.5m), Keeper = keeper }); + _db.SealedAssets.Add(new RvSealedAsset { Serial = "serial-secret", Home = new Uri("https://example.test/"), Price = new RvMoney(7m) }); + _db.Vehicles.Add(new RvTruck { Plate = "T42", Axles = 3, Keeper = keeper }); + _db.Shops.Add(new RvShop { Name = "S1", Address = new RvAddress("Basra", "zip-secret") }); + _db.Vaults.Add(new RvVault { Name = "V1", Blob = new byte[] { 9 }, Place = new RvVaultPlace { Aisle = "A1" } }); + _db.Houses.Add(new RvHouse { Main = new RvGroup { Name = "G", Lead = new RvLead { Name = "L" } } }); + _db.Families.Add(new RvFamily + { + Name = "F1", + Kids = { new RvKid { Name = "own", TenantId = 1 }, new RvKid { Name = "other", TenantId = 2 } } + }); + _db.Blogs.Add(new RvBlog { Name = "B1", Posts = { new RvPost { Title = "P1", Draft = "draft-secret" } } }); + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static DwPolicyContext Caller() => + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1").WithValue("TenantId", 1); + + private static PolicyResolver Resolver(params IDwPolicyProvider[] more) => + new(new IDwPolicyProvider[] { new AttributePolicyProvider() }.Concat(more).ToArray()); + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict, params IDwPolicyProvider[] more) + where T : class => + source.ApplyPolicy(Caller(), new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, Resolver(more)); + + private static Filter Selecting(params string[] fields) => new() { Selects = fields.ToList() }; + + // ---------------------------------------------------------------- entity queries keep 3.1.0's shape + + /// + /// A denial beneath a navigation the query does not load reaches nothing, so it needs no + /// projection: the entity comes back as EF Core loads it, converted members and all. + /// + [Fact] + public void A_denial_beneath_a_navigation_nothing_loads_leaves_the_entity_as_loaded() + { + PolicyQueryable guarded = Guard(_db.Assets); + RvAsset asset = guarded.ToList(new Filter()).Data.Single(); + + Assert.Equal(("https://example.test/", 12.5m), (asset.Home!.ToString(), asset.Price.Amount)); + Assert.DoesNotContain(guarded.LastTrace!.Decisions, decision => decision.Action == PolicyAction.Dropped); + } + + /// A hierarchy keeps its runtime types and its abstract base, as it did in 3.1.0. + [Fact] + public void A_hierarchy_is_still_read_as_entities() + { + RvVehicle vehicle = Guard(_db.Vehicles).ToList(new Filter()).Data.Single(); + + Assert.Equal(3, Assert.IsType(vehicle).Axles); + } + + /// + /// When a projection is needed, an entity keeps every mapped column, converted ones included, and + /// leaves out a member EF Core does not map, which would make it read the denied column. + /// + [Fact] + public void A_needed_projection_keeps_converted_columns_and_skips_unmapped_members() + { + PolicyQueryable guarded = Guard(_db.SealedAssets, DwTier.Convenience); + FilterResultOf result = new(guarded.ToList(new Filter(), getQueryString: true)); + + Assert.Equal(("https://example.test/", 7m, "none"), (result.Row.Home!.ToString(), result.Row.Price.Amount, result.Row.Display)); + Assert.DoesNotContain("Serial", result.Sql, StringComparison.OrdinalIgnoreCase); + } + + /// + /// A denied member that holds no simple value, a blob or an owned object, used to be passed over, + /// so with nothing else denied the whole entity came back with it. + /// + [Fact] + public void A_denied_member_that_is_not_a_simple_value_is_withheld() + { + RvVault vault = Guard(_db.Vaults).ToList(new Filter()).Data.Single(); + + Assert.Equal("V1", vault.Name); + Assert.Empty(vault.Blob); + Assert.Null(vault.Place); + } + + /// A denied owned property with no setter is left out of the owned member, which is kept. + [Theory] + [InlineData(DwTier.Convenience)] + [InlineData(DwTier.Strict)] + public void A_denied_property_with_no_setter_beneath_an_owned_member_is_left_out(DwTier tier) + { + RvShop shop = Guard(_db.Shops, tier).ToList(new Filter()).Data.Single(); + + Assert.Equal(("Basra", (string?)null), (shop.Address.City, shop.Address.Zip)); + + if (tier == DwTier.Strict) + { + Assert.Throws(() => Guard(_db.Shops, tier).ToList(Selecting("Id", "Address"))); + } + else + { + RvAddress named = Guard(_db.Shops, tier).ToList(Selecting("Id", "Address")).Data.Single().Address; + + Assert.Equal(("Basra", (string?)null), (named.City, named.Zip)); + } + } + + /// + /// A navigation a lazy loader can fill carries its denied values out after the query, so its + /// entity needs the projection, whose rows have no loader. + /// + [Fact] + public void A_navigation_a_lazy_loader_can_fill_needs_the_projection() + { + PolicyQueryable guarded = Guard(_db.Blogs); + RvBlog blog = guarded.ToList(new Filter()).Data.Single(); + + Assert.Equal("B1", blog.Name); + Assert.Empty(blog.Posts); + Assert.Contains(guarded.LastTrace!.Decisions, decision => decision.FieldPath == "Posts.Draft"); + } + + /// + /// A forced scope on a list's elements filters the rows that hold the list, never the elements, + /// so a list kept whole would carry every tenant's children. A projection that is needed anyway + /// leaves it out whole; the scope alone asks for none, as it never did. + /// + [Fact] + public void A_list_whose_elements_carry_a_forced_scope_is_left_out_of_a_needed_projection() + { + PolicyQueryable guarded = Guard(_db.Families.Select(f => new RvFamilyRow + { + Id = f.Id, + Tenant = f.Name, + Kids = f.Kids.Select(k => new RvKidRow { Name = k.Name, TenantId = k.TenantId }).ToList() + })); + + RvFamilyRow row = guarded.ToList(new Filter()).Data.Single(); + + Assert.Empty(row.Kids); + Assert.Contains(guarded.LastTrace!.Decisions, decision => decision.FieldPath == "Kids" + && decision.Reason == "left out whole: a scope forced beneath it cannot be applied to what it holds"); + } + + /// + /// A navigation named through another keeps the key of each node it passes through, and a + /// denied one refuses the projection, as naming a sibling of the key always did. + /// + [Theory] + [InlineData(DwTier.Convenience)] + [InlineData(DwTier.Strict)] + public void A_navigation_named_through_one_whose_key_is_denied_is_refused(DwTier tier) + { + PolicyException refused = Assert.Throws( + () => Guard(_db.Houses, tier).ToList(Selecting("Id", "Main.Lead"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForSelect, refused.ErrorCode); + } + + // ---------------------------------------------------------------- projected rows + + /// + /// A member the projection never assigns holds its default and is not carried: narrowing it + /// would read it through EF.Property, which EF Core cannot translate for an unbound member. + /// + [Fact] + public void A_member_the_projection_does_not_assign_is_not_narrowed() + { + RvRoleRow row = Guard(_db.Roles.Select(r => new RvRoleRow { Id = r.Id, Code = r.Code })) + .ToList(new Filter()).Data.Single(); + + Assert.Equal(("R1", (RvKeeper?)null), (row.Code, row.Keeper)); + } + + /// A composed projection feeds the terminal only what it selected. + [Fact] + public void A_composed_projection_then_a_terminal_runs() + { + Assert.Equal("R1", Guard(_db.Roles).Filter(new Filter()).ToList(new Filter()).Data.Single().Code); + Assert.Equal("R1", Guard(_db.Roles).Select(new List { "Id", "Code" }).ToList(new Filter()).Data.Single().Code); + } + + /// + /// A property with no setter is not one the gate can narrow a member around, and a member built + /// by a constructor is not one the core can narrow: such a member is left out whole. + /// + [Fact] + public void A_member_built_by_a_constructor_with_a_hidden_denial_is_left_out() + { + PolicyQueryable guarded = Guard( + _db.Roles.Select(r => new RvContactRow { Id = r.Id, Contact = new RvContact(r.Code) })); + + Assert.Null(guarded.ToList(new Filter()).Data.Single().Contact); + Assert.Contains(guarded.LastTrace!.Decisions, decision => decision.FieldPath == "Contact" + && decision.Reason == "left out whole: the projection builds it in a way the core cannot narrow"); + } + + /// + /// A denial five segments down has no fragment, since the walker stops at four. A projection + /// carrying the member whole used to return it; the member is now narrowed to what the policy + /// can speak about. + /// + [Fact] + public void A_denial_deeper_than_the_walker_goes_is_not_carried() + { + RvDeepRow row = Guard(_db.Roles.Select(r => new RvDeepRow + { + Id = r.Id, + First = new RvLevel1 { Next = new RvLevel2 { Next = new RvLevel3 { Name = r.Code, Next = new RvLevel4 { Secret = "deep-secret" } } } } + })).ToList(new Filter()).Data.Single(); + + Assert.Equal("R1", row.First!.Next!.Next!.Name); + Assert.Null(row.First.Next.Next.Next?.Secret); + } + + /// + /// The same member named in Selects: the strict tier refuses a denial it cannot name, and the + /// convenience tier narrows it away. + /// + [Theory] + [InlineData(DwTier.Convenience)] + [InlineData(DwTier.Strict)] + public void Naming_a_member_with_a_denial_deeper_than_the_walker_goes(DwTier tier) + { + IQueryable rows = _db.Roles.Select(r => new RvDeepRow + { + Id = r.Id, + First = new RvLevel1 { Next = new RvLevel2 { Next = new RvLevel3 { Name = r.Code, Next = new RvLevel4 { Secret = "deep-secret" } } } } + }); + + if (tier == DwTier.Strict) + { + Assert.Throws(() => Guard(rows, tier).ToList(Selecting("Id", "First"))); + + return; + } + + RvDeepRow row = Guard(rows, tier).ToList(Selecting("Id", "First")).Data.Single(); + + Assert.Null(row.First!.Next!.Next!.Next?.Secret); + } + + /// A default order computed on the client cannot be translated, so it is not applied. + [Fact] + public void A_default_computed_by_an_application_method_is_not_applied() + { + IQueryable rows = _db.Roles.Select(r => new RvLabelRow { Id = r.Id, Label = Decorate(r.Code) }); + + Assert.Equal("[R1]", Guard(rows).ToList(new Filter()).Data.Single().Label); + } + + private static string Decorate(string code) => "[" + code + "]"; + + /// + /// A default applies only to a column: a framework method EF Core cannot translate, such as + /// Regex.Replace, computes the field on the client, and ordering by it would make the guarded + /// query fail where the unguarded one ran. A column read directly, through a navigation or through + /// EF.Property is ordered by. + /// + [Fact] + public void A_default_applies_only_to_a_column_the_projection_assigns() + { + IQueryable replaced = _db.Roles.Select(r => new RvLabelRow { Id = r.Id, Label = Regex.Replace(r.Code, "R", "X") }); + IQueryable joined = _db.Roles.Select(r => new RvLabelRow { Id = r.Id, Label = r.Code + "!" }); + IQueryable direct = _db.Roles.Select(r => new RvLabelRow { Id = r.Id, Label = r.Code }); + IQueryable navigated = _db.Roles.Select(r => new RvLabelRow { Id = r.Id, Label = r.Keeper!.Name }); + IQueryable byName = _db.Roles.Select(r => new RvLabelRow { Id = r.Id, Label = EF.Property(r, "Code") }); + + Assert.True(DefaultOrder.HidesDefault(replaced.Expression, typeof(RvLabelRow))); + Assert.True(DefaultOrder.HidesDefault(joined.Expression, typeof(RvLabelRow))); + Assert.False(DefaultOrder.HidesDefault(direct.Expression, typeof(RvLabelRow))); + Assert.False(DefaultOrder.HidesDefault(navigated.Expression, typeof(RvLabelRow))); + Assert.False(DefaultOrder.HidesDefault(byName.Expression, typeof(RvLabelRow))); + + Assert.Equal("X1", Guard(replaced).ToList(new Filter()).Data.Single().Label); + } + + /// A member named with a word the parser keeps cannot be projected, so it is skipped. + [Fact] + public void A_member_named_with_a_parser_word_is_skipped() + { + RvReservedRow row = Guard(_db.Roles.Select(r => new RvReservedRow + { + Id = r.Id, + Tenant = r.Code, + Cast = _db.Roles.Where(x => x.Id == r.Id).Select(x => x.Code).ToList() + })) + .ToList(new Filter()).Data.Single(); + + Assert.Equal((1, (string?)null), (row.Id, row.Tenant)); + } + + /// An array needing narrowing is left out, since the core can only bind a list. + [Fact] + public void An_array_that_needs_narrowing_is_left_out() + { + PolicyQueryable guarded = Guard(_db.Roles.Select(r => new RvArrayRow + { + Id = r.Id, + Items = _db.Roles.Where(x => x.Id == r.Id).Select(x => new RvItem { Name = x.Code, Hidden = "hidden" }).ToArray() + })); + + Assert.Empty(guarded.ToList(new Filter()).Data.Single().Items); + Assert.Contains(guarded.LastTrace!.Decisions, decision => decision.FieldPath == "Items" + && decision.Reason!.StartsWith("left out whole", StringComparison.Ordinal)); + } + + // ---------------------------------------------------------------- what the policy cannot see + + /// + /// A framework collection of a policed type, and a member typed object, are not entered by the + /// walker. Rows in memory leave them out once a projection is needed, which a denial inside them + /// now asks for; naming one is refused. + /// + [Theory] + [InlineData(DwTier.Convenience)] + [InlineData(DwTier.Strict)] + public void A_framework_collection_of_a_policed_type_is_not_returned(DwTier tier) + { + RvKeyedRow[] rows = + { + new() { Id = 1, BySku = { ["a"] = new RvLine { Sku = "a", Cost = "cost-secret" } }, Payload = new RvLine { Cost = "payload-secret" } } + }; + + RvKeyedRow row = Guard(rows.AsQueryable(), tier).ToList(new Filter()).Data.Single(); + + Assert.Empty(row.BySku); + Assert.Null(row.Payload); + Assert.Throws(() => Guard(rows.AsQueryable(), tier).ToList(Selecting("Id", "BySku"))); + } + + /// + /// A namespace that only starts with the word System is the application's. The walker read it + /// as the framework's and put no fragment beneath its types, so a denied field there was + /// returned, filtered on and ordered by. + /// + [Theory] + [InlineData(DwTier.Convenience)] + [InlineData(DwTier.Strict)] + public void A_namespace_starting_with_System_is_policed(DwTier tier) + { + RvStaffRow[] rows = { new() { Id = 1, Pay = new SystemsCorp.Payroll.RvPay { Grade = "G1", Salary = "salary-secret" } } }; + + Filter where = new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = "Pay.Salary", DataType = DataType.Text, Operator = Operator.Equal, Values = { "x" } } } + } + }; + + Assert.Throws(() => Guard(rows.AsQueryable(), tier).ToList(where)); + Assert.Contains( + new AttributePolicyProvider().GetFragments(typeof(RvStaffRow), Caller()), + fragment => fragment.FieldPath == "Pay.Salary"); + } + + /// A runtime rule on a path reached through a cycle is found beneath a member, as its fragment names it. + [Fact] + public void A_rule_on_a_path_through_a_cycle_is_seen_beneath_a_member() + { + RvLink[] rows = { new() { Id = 1, Head = new RvNode { Name = "n1", Next = new RvNode { Name = "n2-secret" } } } }; + FakePolicyProvider rule = new FakePolicyProvider().Add( + "Head.Next.Name", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + PolicyQueryable guarded = Guard(rows.AsQueryable(), DwTier.Convenience, rule); + + Assert.Null(guarded.ToList(new Filter()).Data.Single().Head); + Assert.Throws(() => Guard(rows.AsQueryable(), DwTier.Convenience, rule).ToList(Selecting("Id", "Head"))); + } + + /// + /// A rule on a path the type does not have, one left behind by a member since removed, holds nothing + /// a projection could carry, so it does not stop a caller naming the member above it. + /// + [Fact] + public void A_rule_on_a_path_that_does_not_exist_beneath_a_member_changes_nothing() + { + RvLink[] rows = { new() { Id = 1, Head = new RvNode { Name = "n1" } } }; + FakePolicyProvider stale = new FakePolicyProvider().Add( + "Head.Fax", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + RvLink row = Guard(rows.AsQueryable(), DwTier.Strict, stale).ToList(Selecting("Id", "Head")).Data.Single(); + + Assert.Equal("n1", row.Head!.Name); + } + + /// + /// A navigation EF Core's lazy-loading proxies can fill counts as loaded as well, and the rows the + /// projection returns are plain objects, with no proxy left to load it. + /// + [Fact] + public void A_navigation_a_lazy_loading_proxy_can_fill_needs_the_projection() + { + using SqliteConnection connection = new("DataSource=:memory:"); + connection.Open(); + + using ProxyContext proxies = new(connection); + + proxies.Database.EnsureCreated(); + proxies.Blogs.Add(new PxBlog { Name = "X1", Posts = { new PxPost { Title = "T", Draft = "proxy-draft" } } }); + proxies.SaveChanges(); + proxies.ChangeTracker.Clear(); + + PolicyQueryable guarded = Guard(proxies.Blogs); + PxBlog blog = guarded.ToList(new Filter()).Data.Single(); + + Assert.Equal("X1", blog.Name); + Assert.Empty(blog.Posts); + Assert.Equal(typeof(PxBlog), blog.GetType()); + Assert.Contains(guarded.LastTrace!.Decisions, decision => decision.FieldPath == "Posts.Draft"); + } + + /// + /// An include on a join's inner source loads a navigation of the rows the join returns, and it is + /// not on the chain the paths are read from, so every navigation counts as loaded. + /// + [Fact] + public void An_include_anywhere_in_the_query_counts() + { + IQueryable rows = _db.Assets.Join(_db.Roles.Include(r => r.Keeper), a => a.Id, r => r.Id, (a, r) => r); + + PolicyQueryable guarded = Guard(rows); + + Assert.Null(guarded.ToList(new Filter()).Data.Single().Keeper); + Assert.Contains(guarded.LastTrace!.Decisions, decision => decision.FieldPath == "Keeper.Ssn"); + } + + /// Holds a reference to a copy of the result and its SQL, to keep a test to one statement per line. + private sealed class FilterResultOf + where T : class + { + internal FilterResultOf(DynamicWhere.ex.Classes.Result.FilterResult result) + { + Row = result.Data.Single(); + Sql = result.QueryString ?? string.Empty; + } + + internal T Row { get; } + + internal string Sql { get; } + } + } + + public sealed class RvKidRow + { + public string Name { get; set; } = string.Empty; + + [DwForceWhere(Operator.Equal, ContextValue = "TenantId")] + public int TenantId { get; set; } + } + + [DwEntity(RequirePolicy = true)] + public sealed class RvFamilyRow + { + public int Id { get; set; } + + [DwDenied] + public string? Tenant { get; set; } + + public List Kids { get; set; } = new(); + } + + public class PxBlog + { + public virtual int Id { get; set; } + + public virtual string Name { get; set; } = string.Empty; + + public virtual List Posts { get; set; } = new(); + } + + public class PxPost + { + public virtual int Id { get; set; } + + public virtual int PxBlogId { get; set; } + + public virtual string Title { get; set; } = string.Empty; + + [DwDenied] + public virtual string? Draft { get; set; } + } + + public sealed class ProxyContext : DbContext + { + private readonly SqliteConnection _connection; + + public ProxyContext(SqliteConnection connection) => _connection = connection; + + public DbSet Blogs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => + options.UseSqlite(_connection).UseLazyLoadingProxies(); + } + + public sealed class RvNode + { + public string Name { get; set; } = string.Empty; + + public RvNode? Next { get; set; } + } + + [DwEntity(RequirePolicy = true)] + public sealed class RvLink + { + public int Id { get; set; } + + public RvNode? Head { get; set; } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewCollectionClassTests.cs b/DynamicWhere.Tests/Policies/ReviewCollectionClassTests.cs new file mode 100644 index 0000000..b907114 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewCollectionClassTests.cs @@ -0,0 +1,176 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================ an application's own generic collections: models + + /// An application collection of values that declares a member of its own. + public class ZxTagSet : List + { + [DwDenied] + public string? OwnerSecret { get; set; } + } + + public class ZxPlainCard + { + public string Label { get; set; } = string.Empty; + } + + /// An application collection of objects that declares a member of its own. + public class ZxCardList : List + { + [DwDenied] + public string? MasterKey { get; set; } + } + + /// An application dictionary that declares a member of its own. + public class ZxPropertyBag : Dictionary + { + [DwDenied] + public string? BagSecret { get; set; } + } + + public class ZxBagRow + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public ZxTagSet Tags { get; set; } = new(); + } + + public class ZxListRow + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public ZxCardList Cards { get; set; } = new(); + } + + public class ZxPropsRow + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public ZxPropertyBag Props { get; set; } = new(); + } + + /// The same, beside a top-level denial, so a projection is built for another reason. + public class ZxBagRowDenied + { + public int Id { get; set; } + + [DwDenied] + public string? Ssn { get; set; } + + public ZxTagSet Tags { get; set; } = new(); + + public ZxCardList Cards { get; set; } = new(); + } + + // ============================================================================ an application's own generic collections: tests + + /// + /// An application's own collection class, deriving from List<string>, List<T> or a Dictionary, declares + /// members beside the elements it holds, and they are read as any type's are. + /// + public sealed class ReviewCollectionClassTests + { + private readonly ITestOutputHelper _out; + + public ReviewCollectionClassTests(ITestOutputHelper output) => _out = output; + + private object? Read(IQueryable source, DwTier tier, bool dynamic = false) where T : class + { + PolicyQueryable guarded = ZxKit.Guard(source, tier); + object? data = ZxKit.SafeRead(() => dynamic ? guarded.ToListDynamic(new Filter()).Data : guarded.ToList(new Filter()).Data); + + _out.WriteLine($"{typeof(T).Name} {tier} dynamic={dynamic}: sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + return data; + } + + [Theory] + [InlineData(DwTier.Strict, false)] + [InlineData(DwTier.Convenience, false)] + [InlineData(DwTier.Strict, true)] + public void Zx_K1_a_collection_of_values_that_declares_a_denied_member(DwTier tier, bool dynamic) + { + ZxBagRow[] rows = { new() { Id = 1, Name = "r1", Tags = new ZxTagSet { "a", "b" } } }; + rows[0].Tags.OwnerSecret = "tagset-owner-secret"; + + object? data = Read(rows.AsQueryable(), tier, dynamic); + + Assert.False(ZxKit.Holds(data, "tagset-owner-secret")); + } + + [Theory] + [InlineData(DwTier.Strict, false)] + [InlineData(DwTier.Convenience, false)] + [InlineData(DwTier.Strict, true)] + public void Zx_K2_a_collection_of_objects_that_declares_a_denied_member(DwTier tier, bool dynamic) + { + ZxListRow[] rows = { new() { Id = 1, Name = "r1", Cards = new ZxCardList { new() { Label = "visa" } } } }; + rows[0].Cards.MasterKey = "cardlist-master-secret"; + + object? data = Read(rows.AsQueryable(), tier, dynamic); + + Assert.False(ZxKit.Holds(data, "cardlist-master-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_K3_a_dictionary_that_declares_a_denied_member(DwTier tier) + { + ZxPropsRow[] rows = { new() { Id = 1, Name = "r1", Props = new ZxPropertyBag { ["k"] = "v" } } }; + rows[0].Props.BagSecret = "bag-own-secret"; + + object? data = Read(rows.AsQueryable(), tier); + + Assert.False(ZxKit.Holds(data, "bag-own-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_K4_beside_a_top_level_denial_the_projection_keeps_the_collection(DwTier tier) + { + ZxBagRowDenied[] rows = + { + new() { Id = 1, Ssn = "ssn-secret", Tags = new ZxTagSet { "a" }, Cards = new ZxCardList { new() { Label = "visa" } } } + }; + rows[0].Tags.OwnerSecret = "tagset-owner-secret-2"; + rows[0].Cards.MasterKey = "cardlist-master-secret-2"; + + object? data = Read(rows.AsQueryable(), tier); + + Assert.False(ZxKit.Holds(data, "ssn-secret")); + Assert.False(ZxKit.Holds(data, "tagset-owner-secret-2")); + Assert.False(ZxKit.Holds(data, "cardlist-master-secret-2")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_K5_naming_the_collection_of_values_in_selects(DwTier tier) + { + ZxBagRow[] rows = { new() { Id = 1, Name = "r1", Tags = new ZxTagSet { "a" } } }; + rows[0].Tags.OwnerSecret = "tagset-owner-secret-3"; + + PolicyQueryable guarded = ZxKit.Guard(rows.AsQueryable(), tier); + object? data = ZxKit.SafeRead(() => guarded.ToList(ZxKit.Selecting("Id", "Tags")).Data); + + _out.WriteLine($"{tier}: sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + Assert.False(ZxKit.Holds(data, "tagset-owner-secret-3")); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewConvertedColumnTests.cs b/DynamicWhere.Tests/Policies/ReviewConvertedColumnTests.cs new file mode 100644 index 0000000..ec106b0 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewConvertedColumnTests.cs @@ -0,0 +1,144 @@ +using System.Collections; +using System.Reflection; +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================ converted columns holding policed types + + /// The denial is declared on the interface member, which applies to the class's member too. + public interface IZvVaultCard + { + [DwDenied] + string? Pan { get; } + } + + public class ZvVaultCard : IZvVaultCard + { + public string Label { get; set; } = string.Empty; + + public string? Pan { get; set; } + } + + /// Control: the same card with the attribute on its own member. + public class ZvVaultCardDirect + { + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pan { get; set; } + } + + public class ZvVault + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwDenied] + public string? Note { get; set; } + + public Dictionary Cards { get; set; } = new(); + + public Dictionary DirectCards { get; set; } = new(); + } + + public sealed class ZvConvertedContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZvConvertedContext(SqliteConnection connection) => _connection = connection; + + public DbSet Vaults => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().Property(v => v.Cards).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!); + model.Entity().Property(v => v.DirectCards).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!); + } + } + + public sealed class ReviewConvertedColumnTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZvConvertedContext _db; + + public ReviewConvertedColumnTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZvConvertedContext(_connection); + _db.Database.EnsureCreated(); + _db.Vaults.Add(new ZvVault + { + Name = "V1", + Note = "note-secret", + Cards = { ["a"] = new ZvVaultCard { Label = "visa", Pan = "iface-column-pan" } }, + DirectCards = { ["b"] = new ZvVaultCardDirect { Label = "mc", Pan = "direct-column-pan" } } + }); + _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, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + /// + /// 3.1.0 left every non-scalar member out once Note asked for a projection. The column is now kept whole when + /// the policy reads nothing denied in it; a denial declared on the interface member is not read there. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_F1_a_converted_column_holding_a_type_whose_interface_denies_a_member(DwTier tier) + { + PolicyQueryable guarded = Guard(_db.Vaults, tier); + string sent; + + try + { + sent = JsonSerializer.Serialize(guarded.ToList(new Filter()).Data); + } + catch (PolicyException refusal) when (refusal.ErrorCode == PolicyErrorCode.FieldDeniedForSelect) + { + sent = "refused"; + } + + _out.WriteLine("sent: " + sent + " decisions: " + + string.Join(" | ", guarded.LastTrace?.Decisions.Where(d => d.Action != PolicyAction.Allowed) + .Select(d => $"{d.FieldPath} {d.Action}") ?? Array.Empty())); + + Assert.DoesNotContain("note-secret", sent); + Assert.DoesNotContain("direct-column-pan", sent); + Assert.DoesNotContain("iface-column-pan", sent); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewConverterPrecisionTests.cs b/DynamicWhere.Tests/Policies/ReviewConverterPrecisionTests.cs new file mode 100644 index 0000000..22e036c --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewConverterPrecisionTests.cs @@ -0,0 +1,383 @@ +using System.Collections; +using System.Globalization; +using System.Net; +using System.Numerics; +using System.Text.Json; +using System.Text.Json.Nodes; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Xunit.Abstractions; + +// A converted column beside a top-level [DwDenied] (Secret), which builds a projection. An everyday converted column +// comes back with its value; one that can hold an object of any type is left out, since the converter is the +// application's code and the policy cannot see what it hands back. + +namespace DynamicWhere.Tests.Policies +{ + public enum ZycStatus + { + Draft, + Active + } + + public readonly record struct ZycAccountId(int Value); + + public sealed record ZycAccountKey(Guid Value); + + public sealed record ZycMoney(decimal Amount, string Currency); + + public class ZycPrefs + { + public string Theme { get; set; } = string.Empty; + + public int FontSize { get; set; } + + public List Recent { get; set; } = new(); + } + + /// A JSON envelope holding an arbitrary payload: genuinely able to hold any object. + public class ZycEnvelope + { + public string Kind { get; set; } = string.Empty; + + public object? Data { get; set; } + } + + public class ZycPoint + { + public double X { get; set; } + + public double Y { get; set; } + } + + public class ZycAccount + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// A top-level denial: every guarded query over the account builds a projection. + [DwDenied] + public string? Secret { get; set; } + + public ZycStatus Status { get; set; } + + public DateTime CreatedUtc { get; set; } + + public List Tags { get; set; } = new(); + + public string[] Aliases { get; set; } = Array.Empty(); + + public HashSet Codes { get; set; } = new(); + + public ZycAccountId Ref { get; set; } + + public ZycAccountKey Key { get; set; } = new(Guid.Empty); + + public ZycMoney Price { get; set; } = new(0, "IQD"); + + public ZycPrefs Prefs { get; set; } = new(); + + public List History { get; set; } = new(); + + public ZycPoint Location { get; set; } = new(); + + public Dictionary Labels { get; set; } = new(); + + public Dictionary> Groups { get; set; } = new(); + + public Dictionary Attributes { get; set; } = new(); + + public Dictionary Bag { get; set; } = new(); + + public ZycEnvelope Envelope { get; set; } = new(); + + public BitArray Flags { get; set; } = new(0); + + public Uri? Site { get; set; } + + public IPAddress? Ip { get; set; } + + public CultureInfo? Culture { get; set; } + + public Version? Schema { get; set; } + + public BigInteger Big { get; set; } + + public JsonObject? Extra { get; set; } + + public JsonElement Element { get; set; } + + public object? Setting { get; set; } + } + + public sealed class ZycContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZycContext(SqliteConnection connection) => _connection = connection; + + public DbSet Accounts => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + private static string J(T value) => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null); + + private static T U(string text) => JsonSerializer.Deserialize(text, (JsonSerializerOptions?)null)!; + + private static ValueComparer ByJson() => new( + (left, right) => J(left) == J(right), + value => J(value).GetHashCode(), + value => U(J(value))); + + protected override void OnModelCreating(ModelBuilder model) + { + var account = model.Entity(); + + account.Property(a => a.Status).HasConversion(); + account.Property(a => a.CreatedUtc).HasConversion(v => v.ToUniversalTime(), v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); + account.Property(a => a.Tags).HasConversion(v => string.Join(',', v), v => v.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(), ByJson>()); + account.Property(a => a.Aliases).HasConversion(v => string.Join(',', v), v => v.Split(',', StringSplitOptions.RemoveEmptyEntries), ByJson()); + account.Property(a => a.Codes).HasConversion(v => J(v), v => U>(v), ByJson>()); + account.Property(a => a.Ref).HasConversion(v => v.Value, v => new ZycAccountId(v)); + account.Property(a => a.Key).HasConversion(v => v.Value, v => new ZycAccountKey(v)); + account.Property(a => a.Price).HasConversion(v => J(v), v => U(v)); + account.Property(a => a.Prefs).HasConversion(v => J(v), v => U(v), ByJson()); + account.Property(a => a.History).HasConversion(v => J(v), v => U>(v), ByJson>()); + account.Property(a => a.Location).HasConversion(v => J(v), v => U(v), ByJson()); + account.Property(a => a.Labels).HasConversion(v => J(v), v => U>(v), ByJson>()); + account.Property(a => a.Groups).HasConversion(v => J(v), v => U>>(v), ByJson>>()); + account.Property(a => a.Attributes).HasConversion(v => J(v), v => U>(v), ByJson>()); + account.Property(a => a.Bag).HasConversion(v => J(v), v => U>(v), ByJson>()); + account.Property(a => a.Envelope).HasConversion(v => J(v), v => U(v), ByJson()); + account.Property(a => a.Flags).HasConversion( + v => string.Concat(v.Cast().Select(bit => bit ? '1' : '0')), + v => new BitArray(v.Select(c => c == '1').ToArray())); + account.Property(a => a.Site).HasConversion(v => v == null ? null : v.ToString(), v => v == null ? null : new Uri(v)); + account.Property(a => a.Ip).HasConversion(v => v == null ? null : v.ToString(), v => v == null ? null : IPAddress.Parse(v)); + account.Property(a => a.Culture).HasConversion(v => v == null ? null : v.Name, v => v == null ? null : CultureInfo.GetCultureInfo(v)); + account.Property(a => a.Schema).HasConversion(v => v == null ? null : v.ToString(), v => v == null ? null : Version.Parse(v)); + account.Property(a => a.Big).HasConversion(v => v.ToString(), v => BigInteger.Parse(v)); + account.Property(a => a.Extra).HasConversion(v => v == null ? null : v.ToJsonString((JsonSerializerOptions?)null), v => v == null ? null : (JsonObject)JsonNode.Parse(v, null, default)!); + account.Property(a => a.Element).HasConversion(v => v.GetRawText(), v => JsonDocument.Parse(v, default).RootElement.Clone()); + account.Property(a => a.Setting).HasConversion(v => J(v), v => U(v)); + } + } + + public sealed class ReviewConverterPrecisionTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZycContext _db; + + public ReviewConverterPrecisionTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZycContext(_connection); + _db.Database.EnsureCreated(); + + _db.Accounts.Add(new ZycAccount + { + Name = "A1", + Secret = "zyc-secret", + Status = ZycStatus.Active, + CreatedUtc = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc), + Tags = { "a", "b" }, + Aliases = new[] { "x" }, + Codes = { 7 }, + Ref = new ZycAccountId(42), + Key = new ZycAccountKey(Guid.Parse("11111111-2222-3333-4444-555555555555")), + Price = new ZycMoney(9.5m, "USD"), + Prefs = new ZycPrefs { Theme = "dark", FontSize = 12, Recent = { "r" } }, + History = { new ZycPrefs { Theme = "light" } }, + Location = new ZycPoint { X = 1, Y = 2 }, + Labels = { ["team"] = "blue" }, + Groups = { ["g"] = new List { "m" } }, + Attributes = { ["n"] = JsonDocument.Parse("5").RootElement.Clone() }, + Bag = { ["colour"] = "red" }, + Envelope = new ZycEnvelope { Kind = "note", Data = "hello" }, + Flags = new BitArray(new[] { true, false, true }), + Site = new Uri("https://example.org/a"), + Ip = IPAddress.Parse("10.0.0.1"), + Culture = CultureInfo.GetCultureInfo("ar-IQ"), + Schema = new Version(1, 2), + Big = BigInteger.Parse("123456789012345678901234567890"), + Extra = new JsonObject { ["p"] = 1 }, + Element = JsonDocument.Parse("{\"q\":2}").RootElement.Clone(), + Setting = "on" + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + /// What an unguarded read returns for each column, compared with the guarded read. + private static readonly Dictionary> Holds = new() + { + ["Status"] = a => a.Status == ZycStatus.Active, + ["CreatedUtc"] = a => a.CreatedUtc.Year == 2026, + ["Tags"] = a => a.Tags.SequenceEqual(new[] { "a", "b" }), + ["Aliases"] = a => a.Aliases.SequenceEqual(new[] { "x" }), + ["Codes"] = a => a.Codes.Contains(7), + ["Ref"] = a => a.Ref.Value == 42, + ["Key"] = a => a.Key.Value != Guid.Empty, + ["Price"] = a => a.Price.Amount == 9.5m, + ["Prefs"] = a => a.Prefs.Theme == "dark", + ["History"] = a => a.History.Count == 1, + ["Location"] = a => a.Location.Y == 2, + ["Labels"] = a => a.Labels.ContainsKey("team"), + ["Groups"] = a => a.Groups.ContainsKey("g"), + ["Attributes"] = a => a.Attributes.ContainsKey("n"), + ["Bag"] = a => a.Bag.ContainsKey("colour"), + ["Envelope"] = a => a.Envelope.Kind == "note", + ["Flags"] = a => a.Flags.Length == 3, + ["Site"] = a => a.Site is not null, + ["Ip"] = a => a.Ip is not null, + ["Culture"] = a => a.Culture is not null, + ["Schema"] = a => a.Schema is not null, + ["Big"] = a => !a.Big.IsZero, + ["Extra"] = a => a.Extra is not null, + ["Element"] = a => a.Element.ValueKind == JsonValueKind.Object, + ["Setting"] = a => a.Setting is not null + }; + + /// + /// Docs check (llms.txt 3.2.0 history): "The trace records a member left out only when a projection is built." + /// A dry run builds none and returns the rows whole. + /// + [Fact] + public void Zy_C_a_dry_run_returns_the_row_whole_and_records_what_it_would_leave_out() + { + PolicyQueryable guarded = _db.Accounts.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, DryRun = true, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + ZycAccount row = guarded.ToList(new Filter()).Data.Single(); + string leftOut = string.Join(" | ", guarded.LastTrace?.Decisions + .Where(d => d.Reason?.StartsWith("left out", StringComparison.Ordinal) == true) + .Select(d => $"{d.FieldPath}: {d.Reason}") ?? Array.Empty()); + + ZyLog.Write(_out, $"C dry run: secretReturned={row.Secret is not null} bagReturned={row.Bag.Count > 0} leftOut=[{leftOut}]"); + + // A dry run drops nothing, and records what a real run would leave out. + Assert.True(row.Bag.Count > 0); + Assert.Contains("Bag: left out whole: it can hold what the policy cannot name", leftOut); + } + + [Theory] + [InlineData("Status")] + [InlineData("CreatedUtc")] + [InlineData("Tags")] + [InlineData("Aliases")] + [InlineData("Codes")] + [InlineData("Ref")] + [InlineData("Key")] + [InlineData("Price")] + [InlineData("Prefs")] + [InlineData("History")] + [InlineData("Location")] + [InlineData("Labels")] + [InlineData("Groups")] + [InlineData("Attributes")] + [InlineData("Flags")] + [InlineData("Site")] + [InlineData("Ip")] + [InlineData("Culture")] + [InlineData("Schema")] + [InlineData("Big")] + [InlineData("Extra")] + [InlineData("Element")] + public void Zy_C_a_converted_column_beside_a_top_level_denial_is_kept(string column) + { + ZycAccount unguarded = _db.Accounts.AsNoTracking().Single(); + + Assert.True(Holds[column](unguarded), "the unguarded read itself lost " + column); + + PolicyQueryable guarded = _db.Accounts.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + ZycAccount row; + + try + { + row = guarded.ToList(new Filter()).Data.Single(); + } + catch (Exception e) + { + ZyLog.Write(_out, $"C {column}: THREW {e.GetType().Name} {e.Message.Split('\n')[0]}"); + + throw; + } + + string about = string.Join(" | ", guarded.LastTrace?.Decisions + .Where(d => d.Action != PolicyAction.Allowed && d.FieldPath.StartsWith(column, StringComparison.Ordinal)) + .Select(d => $"{d.FieldPath} {d.Action}: {d.Reason}") ?? Array.Empty()); + + bool kept = Holds[column](row); + + ZyLog.Write(_out, $"C {column}: {(kept ? "KEPT" : "LEFT OUT")} secretKept={row.Secret is not null} [{about}]"); + + Assert.Null(row.Secret); + Assert.True(kept, $"{column} left out: {about}"); + } + + [Theory] + [InlineData("Bag")] + [InlineData("Envelope")] + [InlineData("Setting")] + public void Zy_C_a_converted_column_that_can_hold_any_object_is_left_out_beside_a_top_level_denial(string column) + { + ZycAccount unguarded = _db.Accounts.AsNoTracking().Single(); + + Assert.True(Holds[column](unguarded), "the unguarded read itself lost " + column); + + PolicyQueryable guarded = _db.Accounts.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + ZycAccount row; + + try + { + row = guarded.ToList(new Filter()).Data.Single(); + } + catch (Exception e) + { + ZyLog.Write(_out, $"C {column}: THREW {e.GetType().Name} {e.Message.Split('\n')[0]}"); + + throw; + } + + string about = string.Join(" | ", guarded.LastTrace?.Decisions + .Where(d => d.Action != PolicyAction.Allowed && d.FieldPath.StartsWith(column, StringComparison.Ordinal)) + .Select(d => $"{d.FieldPath} {d.Action}: {d.Reason}") ?? Array.Empty()); + + bool kept = Holds[column](row); + + ZyLog.Write(_out, $"C {column}: {(kept ? "KEPT" : "LEFT OUT")} secretKept={row.Secret is not null} [{about}]"); + + Assert.Null(row.Secret); + Assert.False(kept, $"{column} kept: {about}"); + Assert.Contains($"{column} Dropped: left out whole: it can hold what the policy cannot name", about); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewConverterTests.cs b/DynamicWhere.Tests/Policies/ReviewConverterTests.cs new file mode 100644 index 0000000..dac010b --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewConverterTests.cs @@ -0,0 +1,189 @@ +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================ converters the model does not name: models + + public class ZxIssued + { + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pan { get; set; } + } + + /// The application's own converter: writes any object as JSON and reads it back as ZxIssued. + public sealed class ZxPayloadConverter : ValueConverter + { + public ZxPayloadConverter() + : base(v => Write(v), s => Read(s)) + { + } + + private static string Write(object? value) => + value is null ? "null" : JsonSerializer.Serialize(value, value.GetType()); + + private static object? Read(string text) => JsonSerializer.Deserialize(text); + } + + public class ZxConvEvent + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + + [DwDenied] + public string? Actor { get; set; } + + public object? Payload { get; set; } + } + + /// How the converter is configured. + public enum ZxConverterStyle + { + /// Control: HasConversion(new converter()). + Instance, + + /// HasConversion<TConverter>(). + GenericType, + + /// HasConversion(typeof(TConverter)). + TypeObject, + + /// ConfigureConventions: Properties<object>().HaveConversion<TConverter>(). + Convention + } + + public abstract class ZxConvContextBase : DbContext + { + private readonly SqliteConnection _connection; + + protected ZxConvContextBase(SqliteConnection connection) => _connection = connection; + + public DbSet Events => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + public sealed class ZxConvInstanceContext : ZxConvContextBase + { + public ZxConvInstanceContext(SqliteConnection connection) : base(connection) { } + + protected override void OnModelCreating(ModelBuilder model) => + model.Entity().Property(e => e.Payload).HasConversion(new ZxPayloadConverter()); + } + + public sealed class ZxConvGenericTypeContext : ZxConvContextBase + { + public ZxConvGenericTypeContext(SqliteConnection connection) : base(connection) { } + + protected override void OnModelCreating(ModelBuilder model) => + model.Entity().Property(e => e.Payload).HasConversion(); + } + + public sealed class ZxConvTypeObjectContext : ZxConvContextBase + { + public ZxConvTypeObjectContext(SqliteConnection connection) : base(connection) { } + + protected override void OnModelCreating(ModelBuilder model) => + model.Entity().Property(e => e.Payload).HasConversion(typeof(ZxPayloadConverter)); + } + + public sealed class ZxConvConventionContext : ZxConvContextBase + { + public ZxConvConventionContext(SqliteConnection connection) : base(connection) { } + + protected override void ConfigureConventions(ModelConfigurationBuilder configuration) => + configuration.Properties().HaveConversion(); + + protected override void OnModelCreating(ModelBuilder model) => + model.Entity().Property(e => e.Payload); + } + + // ============================================================================ converters configured every way: tests + + /// + /// A converted column that can hold any object is left out once a projection is built for another field, + /// however the converter is configured: an instance, a type, or a lambda. + /// + public sealed class ReviewConverterTests + { + private readonly ITestOutputHelper _out; + + public ReviewConverterTests(ITestOutputHelper output) => _out = output; + + private static ZxConvContextBase Create(ZxConverterStyle style, SqliteConnection connection) => style switch + { + ZxConverterStyle.Instance => new ZxConvInstanceContext(connection), + ZxConverterStyle.GenericType => new ZxConvGenericTypeContext(connection), + ZxConverterStyle.TypeObject => new ZxConvTypeObjectContext(connection), + _ => new ZxConvConventionContext(connection) + }; + + [Theory] + [InlineData(ZxConverterStyle.Instance, DwTier.Strict)] + [InlineData(ZxConverterStyle.GenericType, DwTier.Strict)] + [InlineData(ZxConverterStyle.GenericType, DwTier.Convenience)] + [InlineData(ZxConverterStyle.TypeObject, DwTier.Strict)] + [InlineData(ZxConverterStyle.Convention, DwTier.Strict)] + public void Zx_C1_a_converted_object_column_beside_a_top_level_denial(ZxConverterStyle style, DwTier tier) + { + using SqliteConnection connection = new("DataSource=:memory:"); + connection.Open(); + + ZxConvContextBase db; + + try + { + db = Create(style, connection); + db.Database.EnsureCreated(); + } + catch (Exception e) + { + _out.WriteLine($"{style}: model not built: {e.GetType().Name}: {e.Message.Split('\n')[0]}"); + + return; + } + + using (db) + { + db.Events.Add(new ZxConvEvent + { + Kind = "CardIssued", Actor = "conv-actor-secret", Payload = new ZxIssued { Label = "visa", Pan = $"conv-pan-{style}" } + }); + db.SaveChanges(); + db.ChangeTracker.Clear(); + + IProperty payload = db.Model.FindEntityType(typeof(ZxConvEvent))!.FindProperty(nameof(ZxConvEvent.Payload))!; + + _out.WriteLine($"{style}: GetValueConverter()={payload.GetValueConverter()?.GetType().Name ?? "null"} " + + $"mapping.Converter={payload.GetTypeMapping().Converter?.GetType().Name ?? "null"}"); + + Assert.True(ZxKit.Holds(db.Events.AsNoTracking().ToList(), $"conv-pan-{style}")); + + PolicyQueryable guarded = ZxKit.Guard(db.Events, tier); + object? data = ZxKit.SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine($"{style} {tier}: sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + Assert.False(ZxKit.Holds(data, "conv-actor-secret")); + Assert.False(ZxKit.Holds(data, $"conv-pan-{style}")); + + object? dynamicData = ZxKit.SafeRead(() => ZxKit.Guard(db.Events, tier).ToListDynamic(new Filter()).Data); + + _out.WriteLine($"{style} {tier} dynamic: sent={ZxKit.Json(dynamicData)}"); + + Assert.False(ZxKit.Holds(dynamicData, $"conv-pan-{style}")); + } + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewDeclarationPrecisionTests.cs b/DynamicWhere.Tests/Policies/ReviewDeclarationPrecisionTests.cs new file mode 100644 index 0000000..9fda2b7 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewDeclarationPrecisionTests.cs @@ -0,0 +1,308 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// A member that no declaration a row can run through the queried path denies stays filterable and is returned: +// a sibling implementer's denial, a private or static member of the same name, an explicit implementation. + +namespace DynamicWhere.Tests.Policies +{ + // ---- D1: one implementer of a widely shared interface denies its implementation ------------------------- + + public interface IZydAuditable + { + string? CreatedBy { get; } + } + + public class ZydInvoice : IZydAuditable + { + public int Id { get; set; } + + public string? CreatedBy { get; set; } + } + + public class ZydPayslip : IZydAuditable + { + public int Id { get; set; } + + [DwDenied] + public string? CreatedBy { get; set; } + } + + // ---- D2: a subclass hides the member with a private one ----------------------------------------------- + + public class ZydNoteDto + { + public int Id { get; set; } + + public string Text { get; set; } = string.Empty; + } + + public class ZydRedactedNoteDto : ZydNoteDto + { + [DwDenied] + private new string Text { get; set; } = string.Empty; + + public void Seal(string text) => Text = text; + } + + // ---- D3: a subclass declares a static member of the same name ------------------------------------------- + + public class ZydRateDto + { + public int Id { get; set; } + + public decimal Rate { get; set; } + } + + public class ZydRateDefaultsDto : ZydRateDto + { + [DwDenied] + public static new decimal Rate { get; set; } + } + + // ---- D4: a subclass implements an interface explicitly with a denied member of the same name ----------- + + public interface IZydCoded + { + string Code { get; } + } + + public class ZydBadgeDto + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + } + + public class ZydSecretBadgeDto : ZydBadgeDto, IZydCoded + { + [DwDenied] + string IZydCoded.Code => "explicit-secret"; + } + + // ---- D5: a class with its own public member and an explicit implementation of a denying interface ------ + + public interface IZydSerialed + { + [DwDenied] + string Serial { get; } + } + + public class ZydDevice : IZydSerialed + { + public int Id { get; set; } + + public string Serial { get; set; } = string.Empty; + + string IZydSerialed.Serial => "device-internal-serial"; + } + + // ---- D6: a sibling variant hides the member with new to withhold it in its own shape (documented limit) -- + + public class ZydEmployeeDto + { + public int Id { get; set; } + + public decimal Salary { get; set; } + } + + public class ZydPublicEmployeeDto : ZydEmployeeDto + { + [DwDenied] + public new decimal Salary { get; set; } + } + + // ---- D7: an entity, and a generic interface one unrelated generic class implements over another argument -- + + public interface IZydKeyed + { + TKey Key { get; } + } + + public class ZydTicket : IZydKeyed + { + public int Id { get; set; } + + public int Key { get; set; } + + public string Title { get; set; } = string.Empty; + } + + public class ZydVaultEntry : IZydKeyed + { + [DwDenied] + public string Key { get; set; } = string.Empty; + + public T? Value { get; set; } + } + + public sealed class ZydContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZydContext(SqliteConnection connection) => _connection = connection; + + public DbSet Invoices => Set(); + + public DbSet Tickets => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + public sealed class ReviewDeclarationPrecisionTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZydContext _db; + + public ReviewDeclarationPrecisionTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZydContext(_connection); + _db.Database.EnsureCreated(); + _db.Invoices.Add(new ZydInvoice { CreatedBy = "alice" }); + _db.Tickets.Add(new ZydTicket { Key = 7, Title = "T" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = + { + new Condition + { + Field = field, + DataType = decimal.TryParse(value, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out _) ? DataType.Number : DataType.Text, + Operator = Operator.Equal, + Values = { value } + } + } + } + }; + + /// Filters on the member, then reads the rows whole; logs both and returns them. + private (string Filtered, List Rows, PolicyQueryable Guarded) Probe(string label, IQueryable source, string field, string value) + where T : class + { + string filtered; + + try + { + filtered = "ran rows=" + Guard(source).ToList(Where(field, value)).Data.Count; + } + catch (PolicyException refusal) + { + filtered = refusal.ErrorCode.ToString(); + } + + PolicyQueryable guarded = Guard(source); + List rows = guarded.ToList(new Filter()).Data; + + ZyLog.Write(_out, $"D {label}: Where({field})={filtered}; whole: dropped=[{ZyLog.Decisions(guarded)}]"); + + return (filtered, rows, guarded); + } + + [Fact] + public void Zy_D1_an_entity_implementing_a_shared_interface_is_not_denied_by_a_sibling_implementer() + { + (string filtered, List rows, PolicyQueryable guarded) = + Probe("D1 entity beside a denying sibling implementer", _db.Invoices, "CreatedBy", "alice"); + + Assert.Equal("ran rows=1", filtered); + Assert.Equal("alice", rows.Single().CreatedBy); + Assert.False(ZyLog.AnyDropped(guarded), ZyLog.Decisions(guarded)); + } + + [Fact] + public void Zy_D2_a_private_member_a_subclass_hides_with_is_never_run_through_the_base_path() + { + ZydNoteDto[] notes = { new() { Id = 1, Text = "hello" } }; + + (string filtered, List rows, PolicyQueryable guarded) = + Probe("D2 private new in a subclass", notes.AsQueryable(), "Text", "hello"); + + Assert.Equal("ran rows=1", filtered); + Assert.Equal("hello", rows.Single().Text); + Assert.False(ZyLog.AnyDropped(guarded), ZyLog.Decisions(guarded)); + } + + [Fact] + public void Zy_D3_a_static_member_of_the_same_name_is_not_a_declaration_a_row_runs() + { + ZydRateDto[] rates = { new() { Id = 1, Rate = 2.5m } }; + + (string filtered, List rows, PolicyQueryable guarded) = + Probe("D3 static new in a subclass", rates.AsQueryable(), "Rate", "2.5"); + + Assert.StartsWith("ran", filtered); + Assert.Equal(2.5m, rows.Single().Rate); + Assert.False(ZyLog.AnyDropped(guarded), ZyLog.Decisions(guarded)); + } + + [Fact] + public void Zy_D4_an_explicit_implementation_in_a_subclass_does_not_deny_the_base_member() + { + ZydBadgeDto[] badges = { new() { Id = 1, Code = "B1" } }; + + (string filtered, List rows, PolicyQueryable guarded) = + Probe("D4 explicit implementation in a subclass", badges.AsQueryable(), "Code", "B1"); + + Assert.Equal("ran rows=1", filtered); + Assert.Equal("B1", rows.Single().Code); + Assert.False(ZyLog.AnyDropped(guarded), ZyLog.Decisions(guarded)); + } + + [Fact] + public void Zy_D5_a_public_member_beside_an_explicit_implementation_of_a_denying_interface() + { + ZydDevice[] devices = { new() { Id = 1, Serial = "S1" } }; + + (string filtered, List rows, PolicyQueryable guarded) = + Probe("D5 public member beside an explicit denying implementation", devices.AsQueryable(), "Serial", "S1"); + + Assert.Equal("ran rows=1", filtered); + Assert.Equal("S1", rows.Single().Serial); + Assert.False(ZyLog.AnyDropped(guarded), ZyLog.Decisions(guarded)); + } + + [Fact] + public void Zy_D7_an_entity_implementing_a_generic_interface_is_not_denied_by_an_open_generic_over_another_argument() + { + (string filtered, List rows, PolicyQueryable guarded) = + Probe("D7 entity beside an open generic implementer of IZydKeyed", _db.Tickets, "Title", "T"); + + Assert.Equal("ran rows=1", filtered); + Assert.Equal(7, rows.Single().Key); + Assert.False(ZyLog.AnyDropped(guarded), ZyLog.Decisions(guarded)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewDefaultOrderTests.cs b/DynamicWhere.Tests/Policies/ReviewDefaultOrderTests.cs new file mode 100644 index 0000000..4679c80 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewDefaultOrderTests.cs @@ -0,0 +1,189 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// DCMP's DW-13 shapes after 6c9e171's column-only rule: a projected row must still take its declared +// default when it assigns every default field a mapped column, directly, through a reference +// navigation or through EF.Property on a shadow property. + +namespace DynamicWhere.Tests.Policies +{ + public class ZbTask + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public int? ZbTaskGroupId { get; set; } + + public ZbTaskGroup? Group { get; set; } + } + + public class ZbTaskGroup + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + [DwEntity(DefaultOrder = "Code desc")] + public class ZbTaskRow + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + } + + /// DCMP's shape with a denied member, so the guard also synthesizes a projection. + [DwEntity(DefaultOrder = "Code desc")] + public class ZbTaskSecretRow + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + [DwDenied] + public string? Secret { get; set; } + } + + [DwEntity(DefaultOrder = "Seq")] + public class ZbTaskSeqRow + { + public int Id { get; set; } + + public short Seq { get; set; } + } + + [DwEntity(DefaultOrder = "GroupName desc")] + public class ZbTaskGroupRow + { + public int Id { get; set; } + + public string GroupName { get; set; } = string.Empty; + } + + public sealed class ZbDefaultOrderContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZbDefaultOrderContext(SqliteConnection connection) => _connection = connection; + + public DbSet Tasks => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) => + model.Entity().Property("status_seq"); + } + + public sealed class ReviewDefaultOrderTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZbDefaultOrderContext _db; + + public ReviewDefaultOrderTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _db = new ZbDefaultOrderContext(_connection); + _db.Database.EnsureCreated(); + + // Inserted B, C, A: none of the expected orders is the insertion order. + foreach ((string code, short seq, string group) in new[] { ("B", (short)2, "g2"), ("C", (short)1, "g3"), ("A", (short)3, "g1") }) + { + ZbTask task = new() { Code = code, Group = new ZbTaskGroup { Name = group } }; + + _db.Tasks.Add(task); + _db.Entry(task).Property("status_seq").CurrentValue = seq; + } + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private string Log(string label, IEnumerable ids) + { + string order = string.Join(",", ids.Select(id => _db.Tasks.AsNoTracking().Single(t => t.Id == id).Code)); + string line = $"{label}: {order}"; + + _out.WriteLine(line); + + return order; + } + + [Fact] + public void Zb_G1_a_projected_row_assigning_a_column_takes_Code_desc() + { + IQueryable rows = _db.Tasks.Select(t => new ZbTaskRow { Id = t.Id, Code = t.Code }); + + Assert.Equal("C,B,A", Log("G1 typed", Guard(rows).ToList(new Filter()).Data.Select(r => r.Id))); + Assert.Equal("C,B", Log("G1 paged", Guard(rows).ToList(new Filter { Page = new PageBy { PageNumber = 1, PageSize = 2 } }).Data.Select(r => r.Id))); + } + + [Fact] + public void Zb_G1b_a_projected_row_with_a_denied_member_still_takes_Code_desc() + { + IQueryable rows = _db.Tasks.Select(t => new ZbTaskSecretRow { Id = t.Id, Code = t.Code, Secret = t.Code }); + + Assert.Equal("C,B,A", Log("G1b typed", Guard(rows).ToList(new Filter()).Data.Select(r => r.Id))); + } + + [Fact] + public void Zb_G2_a_projected_row_bound_through_EF_Property_takes_its_default() + { + IQueryable rows = _db.Tasks.Select(t => new ZbTaskSeqRow { Id = t.Id, Seq = EF.Property(t, "status_seq") }); + + Assert.Equal("C,B,A", Log("G2 typed", Guard(rows).ToList(new Filter()).Data.Select(r => r.Id))); + } + + [Fact] + public void Zb_G3_a_projected_row_reading_through_a_reference_navigation_takes_its_default() + { + IQueryable rows = _db.Tasks.Select(t => new ZbTaskGroupRow { Id = t.Id, GroupName = t.Group!.Name }); + + Assert.Equal("C,B,A", Log("G3 typed", Guard(rows).ToList(new Filter()).Data.Select(r => r.Id))); + } + + /// Informational: a computed value is left unordered by design since 6c9e171. + [Fact] + public void Zb_G4_a_projected_row_assigning_a_computed_value() + { + IQueryable rows = _db.Tasks.Select(t => new ZbTaskRow { Id = t.Id, Code = t.Code.Trim() }); + + Log("G4 typed (Trim)", Guard(rows).ToList(new Filter()).Data.Select(r => r.Id)); + } + + /// Informational: a projection over an anonymous intermediate row. + [Fact] + public void Zb_G5_a_projected_row_over_an_anonymous_intermediate() + { + IQueryable rows = _db.Tasks.Select(t => new { t.Id, t.Code }).Select(x => new ZbTaskRow { Id = x.Id, Code = x.Code }); + + Log("G5 typed (anonymous intermediate)", Guard(rows).ToList(new Filter()).Data.Select(r => r.Id)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewDenialsElsewhereTests.cs b/DynamicWhere.Tests/Policies/ReviewDenialsElsewhereTests.cs new file mode 100644 index 0000000..421e742 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewDenialsElsewhereTests.cs @@ -0,0 +1,1131 @@ +using System.Collections; +using System.Reflection; +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Policies.Storage; +using DynamicWhere.ex.Policies.Validation; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================ denials on other declarations: models + + // ---- A1: a generic interface, implemented by an open generic class that denies the member ---------- + + public interface IZvHolder + { + int Id { get; } + + string? Secret { get; } + } + + public class ZvHolderImpl : IZvHolder + { + public int Id { get; set; } + + [DwDenied] + public string? Secret { get; set; } + } + + /// Control: the same interface shape, implemented by a closed class. + public interface IZvClosedHolder + { + int Id { get; } + + string? Secret { get; } + } + + public class ZvIntClosedHolder : IZvClosedHolder + { + public int Id { get; set; } + + [DwDenied] + public string? Secret { get; set; } + } + + /// Control: a plain interface implemented by an open generic class. + public interface IZvPlainHolder + { + int Id { get; } + + string? Secret { get; } + } + + public class ZvGenPlainHolder : IZvPlainHolder + { + public int Id { get; set; } + + [DwDenied] + public string? Secret { get; set; } + } + + public class ZvHolderRow + { + public int Id { get; set; } + + public IZvHolder? Holder { get; set; } + } + + // ---- A1b: a generic base class whose open generic subclass overrides and denies --------------------- + + public class ZvBox + { + public int Id { get; set; } + + public virtual string? Code { get; set; } + } + + public class ZvSecretBox : ZvBox + { + [DwDenied] + public override string? Code + { + get => base.Code; + set => base.Code = value; + } + } + + // ---- A2: an explicit interface implementation that denies the member -------------------------------- + + public interface IZvLocker + { + int Id { get; } + + string? Pin { get; } + } + + public class ZvLocker : IZvLocker + { + private string? _pin; + + public int Id { get; set; } + + [DwDenied] + string? IZvLocker.Pin => _pin; + + public void Seal(string pin) => _pin = pin; + } + + public class ZvLockerRoom + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public Dictionary Lockers { get; set; } = new(); + } + + // ---- A3: an interface member's denial, on a member a subtype of the queried type implements --------- + + public interface IZvTaxed + { + [DwDenied] + string? TaxId { get; } + } + + public class ZvFirm + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class ZvTaxedFirm : ZvFirm, IZvTaxed + { + public string? TaxId { get; set; } + } + + /// Control: a subtype with the attribute on its own member, on a base of its own. + public class ZvFirmB + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class ZvDirectTaxedFirm : ZvFirmB + { + [DwDenied] + public string? TaxNumber { get; set; } + } + + public class ZvFirmHolder + { + public int Id { get; set; } + + public ZvFirm? Firm { get; set; } + } + + // ---- A4: an interface member's denial, reached through a framework generic ------------------------- + + public interface IZvCardish + { + [DwDenied] + string? Pan { get; } + } + + public class ZvCardImpl : IZvCardish + { + public string Label { get; set; } = string.Empty; + + public string? Pan { get; set; } + } + + public class ZvWallet + { + public int Id { get; set; } + + public string Owner { get; set; } = string.Empty; + + public Dictionary Cards { get; set; } = new(); + } + + public class ZvCardDirect + { + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pan { get; set; } + } + + public class ZvWalletDirect + { + public int Id { get; set; } + + public string Owner { get; set; } = string.Empty; + + public Dictionary Cards { get; set; } = new(); + } + + // ---- A5: an override of the setter alone ---------------------------------------------------------- + + public class ZvGadget + { + public int Id { get; set; } + + public virtual string? Code { get; set; } + } + + public class ZvSetOnlyGadget : ZvGadget + { + [DwDenied] + public override string? Code + { + set => base.Code = value; + } + } + + // ---- A6: a member hidden with new rather than overridden -------------------------------------------- + + public class ZvDevice + { + public int Id { get; set; } + + public string? Serial { get; set; } + } + + public class ZvHiddenDevice : ZvDevice + { + [DwDenied] + public new string? Serial + { + get => base.Serial; + set => base.Serial = value; + } + } + + /// A member hidden with new that keeps its own value, as new usually does. + public class ZvDevice2 + { + public int Id { get; set; } + + public string? Serial { get; set; } + } + + public class ZvHiddenDevice2 : ZvDevice2 + { + [DwDenied] + public new string? Serial { get; set; } + } + + // ---- A7: an override two levels down, of an abstract member ---------------------------------------- + + public abstract class ZvInstrument + { + public int Id { get; set; } + + public abstract string? Code { get; set; } + } + + public class ZvMidInstrument : ZvInstrument + { + public override string? Code { get; set; } + } + + public class ZvLeafInstrument : ZvMidInstrument + { + [DwDenied] + public override string? Code + { + get => base.Code; + set => base.Code = value; + } + } + + // ---- A8: an overridable and a sealed denial on a subtype's overrides -------------------------------- + + public class ZvMeter + { + public int Id { get; set; } + + public virtual string? Reading { get; set; } + + public virtual string? Serial { get; set; } + } + + public class ZvSmartMeter : ZvMeter + { + [DwNoWhere(Overridable = true)] + public override string? Reading + { + get => base.Reading; + set => base.Reading = value; + } + + [DwNoWhere] + public override string? Serial + { + get => base.Serial; + set => base.Serial = value; + } + } + + public class ZvAnalogMeter : ZvMeter + { + } + + // ---- A9: a transform on a subtype's override, which is documented as read from the declaration walked + + public class ZvContactCard + { + public int Id { get; set; } + + public virtual string? Phone { get; set; } + } + + public class ZvMaskedContactCard : ZvContactCard + { + [DwMask(MaskStrategy.Full)] + public override string? Phone + { + get => base.Phone; + set => base.Phone = value; + } + } + + // ---- A10: a rule on a subtype's member through a base-typed member, spelled in another letter case + + public class ZvKennel + { + public int Id { get; set; } + + public ZvKennelPet? Pet { get; set; } + } + + public class ZvKennelPet + { + public string Name { get; set; } = string.Empty; + } + + public class ZvKennelHamster : ZvKennelPet + { + public string? Tag { get; set; } + } + + // ---- B1: siblings, and a concrete type's own subtype ---------------------------------------------- + + public class ZvCar + { + public int Id { get; set; } + + public virtual string? Plate { get; set; } + } + + public class ZvArmoredCar : ZvCar + { + [DwDenied] + public override string? Plate + { + get => base.Plate; + set => base.Plate = value; + } + } + + public class ZvTaxi : ZvCar + { + } + + public class ZvLimo : ZvTaxi + { + [DwNoOrder] + public override string? Plate + { + get => base.Plate; + set => base.Plate = value; + } + } + + // ---- B2: a widely shared interface, one implementer denies ------------------------------------------- + + public interface IZvNamed + { + string Name { get; } + } + + public class ZvCity : IZvNamed + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class ZvSpy : IZvNamed + { + public int Id { get; set; } + + [DwDenied] + public string Name { get; set; } = string.Empty; + } + + public class ZvPin + { + public int Id { get; set; } + + public IZvNamed? Place { get; set; } + } + + // ---- B3: the startup scan over a default order a subtype's override denies overridably ------------- + + [DwEntity(DefaultOrder = "Rank")] + public class ZvRanked + { + public int Id { get; set; } + + public virtual int Rank { get; set; } + } + + public class ZvSecretlyRanked : ZvRanked + { + [DwNoOrder(Overridable = true)] + public override int Rank + { + get => base.Rank; + set => base.Rank = value; + } + } + + /// Control: the same denial on the type's own member, which the scan reads as a warning. + [DwEntity(DefaultOrder = "Rank")] + public class ZvOwnRanked + { + public int Id { get; set; } + + [DwNoOrder(Overridable = true)] + public int Rank { get; set; } + } + + // ---- A3 on EF Core: a TPH root whose derived entity implements the interface ------------------------ + + public class ZvEfFirm + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class ZvEfTaxedFirm : ZvEfFirm, IZvTaxed + { + public string? TaxId { get; set; } + } + + /// An entity implementing the shared interface one unrelated DTO denies. + public class ZvEfCity : IZvNamed + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + /// An entity the model maps alone. + public class ZvEfVehicle + { + public int Id { get; set; } + + public virtual string? Code { get; set; } + } + + /// A class the model does not map, which EF Core can never materialize for ZvEfVehicle's set. + public class ZvVehicleView : ZvEfVehicle + { + [DwDenied] + public override string? Code + { + get => base.Code; + set => base.Code = value; + } + } + + public sealed class ZvDenialsContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZvDenialsContext(SqliteConnection connection) => _connection = connection; + + public DbSet Firms => Set(); + + public DbSet Cities => Set(); + + public DbSet Vehicles => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) => model.Entity(); + } + + // ============================================================================ denials on other declarations: tests + + /// + /// The denials read from another declaration of a member: an interface member, an implementation, an override + /// and a member hidden with new, through every path a row reaches them by. + /// + public sealed class ReviewDenialsElsewhereTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZvDenialsContext _db; + + public ReviewDenialsElsewhereTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZvDenialsContext(_connection); + _db.Database.EnsureCreated(); + _db.Firms.Add(new ZvEfTaxedFirm { Name = "Acme", TaxId = "ef-tax-secret" }); + _db.Firms.Add(new ZvEfFirm { Name = "Plain" }); + _db.Cities.Add(new ZvEfCity { Name = "Basra" }); + _db.Vehicles.Add(new ZvEfVehicle { Code = "V-1" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict, params IDwPolicyProvider[] more) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }.Concat(more).ToArray())); + + private static Filter Where(string field, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = DataType.Text, Operator = Operator.Equal, Values = { value } } } + } + }; + + private static Filter OrderedBy(string field) => new() { Orders = new List { new() { Field = field } } }; + + private static Summary GroupedBy(string field) => new() + { + GroupBy = new GroupBy + { + Fields = new List { field }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + } + }; + + /// The error code a guarded call refused with, or null when it ran. + private string Code(Action run) + { + try + { + run(); + + return "ran"; + } + catch (PolicyException refusal) + { + return refusal.ErrorCode.ToString(); + } + catch (Exception other) + { + return $"{other.GetType().Name}: {other.Message.Split('\n')[0]}"; + } + } + + private static IEnumerable Denials(Type type, string path) => + new AttributePolicyProvider().GetFragments(type, new DwPolicyContext()) + .Where(f => f.Effect == PolicyEffect.Deny && string.Equals(f.FieldPath, path, StringComparison.Ordinal)); + + /// Everything reachable from a value, read by runtime type, including explicit interface members. + private static bool Holds(object? value, string text) + { + HashSet seen = new(ReferenceEqualityComparer.Instance); + Stack pending = new(); + + pending.Push(value); + + while (pending.Count > 0) + { + object? current = pending.Pop(); + + if (current is null || current is ValueType || current is MemberInfo) + { + continue; + } + + if (current is string held) + { + if (held == text) + { + return true; + } + + continue; + } + + if (!seen.Add(current)) + { + continue; + } + + if (current is IDictionary map) + { + foreach (object? item in map.Values) + { + pending.Push(item); + } + + continue; + } + + if (current is IEnumerable items) + { + foreach (object? item in items) + { + pending.Push(item); + } + + continue; + } + + foreach (PropertyInfo property in current.GetType().GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (property.GetIndexParameters().Length != 0 || !property.CanRead) + { + continue; + } + + try + { + pending.Push(property.GetValue(current)); + } + catch + { + // A getter that throws holds nothing readable. + } + } + } + + return false; + } + + private static object? SafeRead(Func read) + { + try + { + return read(); + } + catch (PolicyException refusal) when (refusal.ErrorCode is PolicyErrorCode.FieldDeniedForSelect or PolicyErrorCode.AllSelectsDenied) + { + return null; + } + catch (LogicException) + { + // The core refused to build the projection: nothing returned. + return null; + } + } + + // ------------------------------------------------------------------------------------------------ A1 + + [Fact] + public void Zv_A1_a_generic_interface_implemented_by_an_open_generic_class_carries_its_denial() + { + _out.WriteLine("IZvHolder.Secret denials: " + Denials(typeof(IZvHolder), "Secret").Count()); + _out.WriteLine("control IZvClosedHolder.Secret denials: " + Denials(typeof(IZvClosedHolder), "Secret").Count()); + _out.WriteLine("control IZvPlainHolder.Secret denials: " + Denials(typeof(IZvPlainHolder), "Secret").Count()); + + Assert.NotEmpty(Denials(typeof(IZvClosedHolder), "Secret")); + Assert.NotEmpty(Denials(typeof(IZvPlainHolder), "Secret")); + Assert.NotEmpty(Denials(typeof(IZvHolder), "Secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_A1_filtering_a_generic_interface_on_the_member_its_open_generic_implementation_denies(DwTier tier) + { + IZvHolder[] rows = { new ZvHolderImpl { Id = 1, Secret = "generic-iface-secret" } }; + + string where = Code(() => Guard(rows.AsQueryable(), tier).ToList(Where("Secret", "generic-iface-secret"))); + string order = Code(() => Guard(rows.AsQueryable(), tier).ToList(OrderedBy("Secret"))); + string group = Code(() => Guard(rows.AsQueryable(), tier).ToList(GroupedBy("Secret"))); + + _out.WriteLine($"where={where} order={order} group={group}"); + + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), where); + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForGroup), group); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_A1_rows_read_through_a_generic_interface_whose_open_generic_implementation_denies(DwTier tier) + { + IZvHolder[] rows = { new ZvHolderImpl { Id = 1, Secret = "generic-iface-secret" } }; + + PolicyQueryable> guarded = Guard(rows.AsQueryable(), tier); + object? data = SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine("result: " + JsonSerializer.Serialize(data) + " decisions: " + + string.Join(" | ", guarded.LastTrace?.Decisions.Select(d => $"{d.FieldPath} {d.Action}") ?? Array.Empty())); + + Assert.False(Holds(data, "generic-iface-secret")); + } + + [Fact] + public void Zv_A1_a_member_typed_as_the_generic_interface() + { + ZvHolderRow[] rows = { new() { Id = 1, Holder = new ZvHolderImpl { Id = 2, Secret = "generic-member-secret" } } }; + + string where = Code(() => Guard(rows.AsQueryable()).ToList(Where("Holder.Secret", "generic-member-secret"))); + object? whole = SafeRead(() => Guard(rows.AsQueryable()).ToList(new Filter()).Data); + + _out.WriteLine($"where on Holder.Secret={where}; whole result holds it={Holds(whole, "generic-member-secret")}"); + + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), where); + Assert.False(Holds(whole, "generic-member-secret")); + } + + [Fact] + public void Zv_A1_control_a_closed_implementation_of_the_generic_interface_is_refused() + { + IZvClosedHolder[] rows = { new ZvIntClosedHolder { Id = 1, Secret = "closed-secret" } }; + + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), Code(() => Guard(rows.AsQueryable()).ToList(Where("Secret", "closed-secret")))); + } + + [Fact] + public void Zv_A1b_a_generic_base_class_whose_open_generic_subclass_overrides_and_denies() + { + ZvBox[] rows = { new ZvSecretBox { Id = 1, Code = "box-secret" } }; + + _out.WriteLine("ZvBox.Code denials: " + Denials(typeof(ZvBox), "Code").Count()); + + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), Code(() => Guard(rows.AsQueryable()).ToList(Where("Code", "box-secret")))); + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable()).ToList(new Filter()).Data), "box-secret")); + } + + // ------------------------------------------------------------------------------------------------ A2 + + [Fact] + public void Zv_A2_an_explicit_interface_implementation_denies_the_interface_path() + { + ZvLocker locker = new() { Id = 1 }; + locker.Seal("pin-secret"); + IZvLocker[] rows = { locker }; + + _out.WriteLine("IZvLocker.Pin denials: " + Denials(typeof(IZvLocker), "Pin").Count()); + + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), Code(() => Guard(rows.AsQueryable()).ToList(Where("Pin", "pin-secret")))); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_A2_an_explicit_implementation_held_in_a_framework_generic(DwTier tier) + { + ZvLocker locker = new() { Id = 1 }; + locker.Seal("pin-secret"); + ZvLockerRoom[] rows = { new() { Id = 1, Name = "R", Lockers = { ["a"] = locker } } }; + + object? unguarded = rows; + Assert.Contains("pin-secret", JsonSerializer.Serialize(unguarded)); + + object? guarded = SafeRead(() => Guard(rows.AsQueryable(), tier).ToList(new Filter()).Data); + object? named = SafeRead(() => Guard(rows.AsQueryable(), tier).ToList(new Filter { Selects = new List { "Id", "Lockers" } }).Data); + + _out.WriteLine("whole: " + JsonSerializer.Serialize(guarded)); + _out.WriteLine("named: " + JsonSerializer.Serialize(named)); + + Assert.DoesNotContain("pin-secret", JsonSerializer.Serialize(guarded)); + Assert.DoesNotContain("pin-secret", JsonSerializer.Serialize(named)); + } + + // ------------------------------------------------------------------------------------------------ A3 + + [Fact] + public void Zv_A3_control_the_class_that_implements_the_interface_is_policed() + { + ZvTaxedFirm[] rows = { new() { Id = 1, Name = "Acme", TaxId = "tax-secret" } }; + + Assert.NotEmpty(Denials(typeof(ZvTaxedFirm), "TaxId")); + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), Code(() => Guard(rows.AsQueryable()).ToList(Where("TaxId", "tax-secret")))); + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable()).ToList(new Filter()).Data), "tax-secret")); + } + + [Fact] + public void Zv_A3_control_a_subtypes_own_attribute_through_the_base_type() + { + ZvFirmB[] rows = { new ZvDirectTaxedFirm { Id = 1, Name = "Acme", TaxNumber = "direct-tax-secret" } }; + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable()).ToList(new Filter()).Data), "direct-tax-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_A3_rows_in_memory_read_through_a_base_type_whose_subtype_implements_the_denying_interface(DwTier tier) + { + ZvFirm[] rows = { new ZvTaxedFirm { Id = 1, Name = "Acme", TaxId = "tax-secret" } }; + + PolicyQueryable guarded = Guard(rows.AsQueryable(), tier); + object? data = SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine("types: " + string.Join(",", ((IEnumerable?)data ?? Array.Empty()).Cast().Select(r => r.GetType().Name))); + + Assert.False(Holds(data, "tax-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_A3_a_base_typed_member_holding_the_subtype(DwTier tier) + { + ZvFirmHolder[] rows = { new() { Id = 1, Firm = new ZvTaxedFirm { Id = 2, Name = "Acme", TaxId = "tax-secret" } } }; + + object? whole = SafeRead(() => Guard(rows.AsQueryable(), tier).ToList(new Filter()).Data); + object? named = SafeRead(() => Guard(rows.AsQueryable(), tier).ToList(new Filter { Selects = new List { "Id", "Firm" } }).Data); + + Assert.False(Holds(whole, "tax-secret")); + Assert.False(Holds(named, "tax-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_A3_an_entity_hierarchy_root_whose_derived_entity_implements_the_denying_interface(DwTier tier) + { + Assert.True(Holds(_db.Firms.AsNoTracking().ToList(), "ef-tax-secret")); + + PolicyQueryable guarded = Guard(_db.Firms, tier); + object? data = SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine("types: " + string.Join(",", ((IEnumerable?)data ?? Array.Empty()).Cast().Select(r => r.GetType().Name))); + + Assert.False(Holds(data, "ef-tax-secret")); + Assert.False(Holds(SafeRead(() => Guard(_db.Firms, tier).ToListDynamic(new Filter()).Data), "ef-tax-secret")); + } + + [Fact] + public void Zv_A3_control_the_derived_entity_queried_itself_is_policed() + { + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), Code(() => Guard(_db.Firms.OfType()).ToList(Where("TaxId", "x")))); + Assert.False(Holds(SafeRead(() => Guard(_db.Firms.OfType()).ToList(new Filter()).Data), "ef-tax-secret")); + } + + // ------------------------------------------------------------------------------------------------ A4 + + [Fact] + public void Zv_A4_control_a_framework_generic_of_a_class_that_denies_its_own_member() + { + ZvWalletDirect[] rows = { new() { Id = 1, Owner = "O", Cards = { ["a"] = new ZvCardDirect { Label = "visa", Pan = "direct-pan-secret" } } } }; + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable()).ToList(new Filter()).Data), "direct-pan-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_A4_a_framework_generic_of_a_class_whose_interface_denies_the_member(DwTier tier) + { + ZvWallet[] rows = { new() { Id = 1, Owner = "O", Cards = { ["a"] = new ZvCardImpl { Label = "visa", Pan = "iface-pan-secret" } } } }; + + object? whole = SafeRead(() => Guard(rows.AsQueryable(), tier).ToList(new Filter()).Data); + object? named = SafeRead(() => Guard(rows.AsQueryable(), tier).ToList(new Filter { Selects = new List { "Id", "Cards" } }).Data); + + _out.WriteLine("whole: " + JsonSerializer.Serialize(whole)); + _out.WriteLine("named: " + JsonSerializer.Serialize(named)); + + Assert.False(Holds(whole, "iface-pan-secret")); + Assert.False(Holds(named, "iface-pan-secret")); + } + + // ------------------------------------------------------------------------------------------------ A5 + + [Fact] + public void Zv_A5_an_override_of_the_setter_alone() + { + PropertyInfo declared = typeof(ZvSetOnlyGadget).GetProperty("Code")!; + + _out.WriteLine($"ZvSetOnlyGadget.Code: declaring={declared.DeclaringType!.Name} canRead={declared.CanRead}"); + _out.WriteLine("ZvSetOnlyGadget.Code denials: " + Denials(typeof(ZvSetOnlyGadget), "Code").Count()); + _out.WriteLine("ZvGadget.Code denials: " + Denials(typeof(ZvGadget), "Code").Count()); + + ZvGadget[] rows = { new ZvSetOnlyGadget { Id = 1, Code = "setter-secret" } }; + ZvSetOnlyGadget[] own = { new() { Id = 1, Code = "setter-secret" } }; + + string direct = Code(() => Guard(own.AsQueryable()).ToList(Where("Code", "setter-secret"))); + string through = Code(() => Guard(rows.AsQueryable()).ToList(Where("Code", "setter-secret"))); + + _out.WriteLine($"queried as the subtype: {direct}; through the base type: {through}"); + + object? data = SafeRead(() => Guard(rows.AsQueryable()).ToList(new Filter()).Data); + string sent = JsonSerializer.Serialize(data); + + // The runtime type's PropertyInfo has no getter, so a reflection walk misses it; a serializer does not. + _out.WriteLine("through the base type, the result sent: " + sent); + + // The same member of the same rows: whatever the subtype says, the base path should say too. + Assert.Equal(direct, through); + Assert.DoesNotContain("setter-secret", sent); + } + + // ------------------------------------------------------------------------------------------------ A6 + + [Fact] + public void Zv_A6_a_member_hidden_with_new_denies_the_base_path() + { + ZvDevice[] rows = { new ZvHiddenDevice { Id = 1, Serial = "hidden-secret" } }; + ZvHiddenDevice[] own = { new() { Id = 1, Serial = "hidden-secret" } }; + + string direct = Code(() => Guard(own.AsQueryable()).ToList(Where("Serial", "hidden-secret"))); + string through = Code(() => Guard(rows.AsQueryable()).ToList(Where("Serial", "hidden-secret"))); + object? data = SafeRead(() => Guard(rows.AsQueryable()).ToList(new Filter()).Data); + + _out.WriteLine($"queried as the subtype: {direct}; through the base type: {through}; result holds: {Holds(data, "hidden-secret")}"); + + // Whether the new member reads the one it hides cannot be told, so the base path is denied too. + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), direct); + Assert.Equal(direct, through); + Assert.False(Holds(data, "hidden-secret")); + } + + [Fact] + public void Zv_A6_a_member_hidden_with_new_that_keeps_its_own_value() + { + ZvDevice2[] rows = { new ZvHiddenDevice2 { Id = 1, Serial = "hidden-own-secret" } }; + ((ZvDevice2)rows[0]).Serial = "public-serial"; + + PolicyQueryable guarded = Guard(rows.AsQueryable()); + object? data = SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine("types: " + string.Join(",", ((IEnumerable?)data ?? Array.Empty()).Cast().Select(r => r.GetType().Name)) + + "; decisions: " + string.Join(" | ", guarded.LastTrace?.Decisions.Select(d => $"{d.FieldPath} {d.Action}") ?? Array.Empty())); + + // A row of the subtype still holds the hidden member's own value; a runtime-type serializer writes it. + Assert.False(Holds(data, "hidden-own-secret")); + } + + // ------------------------------------------------------------------------------------------------ A7 + + [Fact] + public void Zv_A7_an_override_two_levels_down_of_an_abstract_member() + { + ZvInstrument[] root = { new ZvLeafInstrument { Id = 1, Code = "leaf-secret" } }; + ZvMidInstrument[] mid = { new ZvLeafInstrument { Id = 1, Code = "leaf-secret" } }; + + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), Code(() => Guard(root.AsQueryable()).ToList(Where("Code", "leaf-secret")))); + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), Code(() => Guard(mid.AsQueryable()).ToList(Where("Code", "leaf-secret")))); + Assert.False(Holds(SafeRead(() => Guard(mid.AsQueryable()).ToList(new Filter()).Data), "leaf-secret")); + } + + // ------------------------------------------------------------------------------------------------ A8 + + [Fact] + public void Zv_A8_a_rule_lifts_an_overridable_subtype_denial_and_not_a_sealed_one() + { + ZvMeter[] rows = { new ZvSmartMeter { Id = 1, Reading = "r", Serial = "s" } }; + FakePolicyProvider allow = new FakePolicyProvider() + .Add("Reading", PolicyFeature.Where, PolicyEffect.Allow, PolicyLevel.DynamicGlobal) + .Add("Serial", PolicyFeature.Where, PolicyEffect.Allow, PolicyLevel.DynamicGlobal); + + string readingWithout = Code(() => Guard(rows.AsQueryable()).ToList(Where("Reading", "r"))); + string readingLifted = Code(() => Guard(rows.AsQueryable(), DwTier.Strict, allow).ToList(Where("Reading", "r"))); + string serialLifted = Code(() => Guard(rows.AsQueryable(), DwTier.Strict, allow).ToList(Where("Serial", "s"))); + + _out.WriteLine($"Reading unlifted={readingWithout} lifted={readingLifted}; Serial with an allow rule={serialLifted}"); + + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), readingWithout); + Assert.Equal("ran", readingLifted); + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), serialLifted); + } + + [Fact] + public void Zv_A8_the_store_refuses_a_rule_on_the_base_path_a_subtype_seals_and_not_on_a_sibling() + { + PolicyRule onBase = new(DwSubjectKind.Global, null, typeof(ZvMeter).FullName!, "Serial", PolicyFeature.Where, PolicyEffect.Allow); + PolicyRule onSibling = new(DwSubjectKind.Global, null, typeof(ZvAnalogMeter).FullName!, "Serial", PolicyFeature.Where, PolicyEffect.Allow); + PolicyRule overridable = new(DwSubjectKind.Global, null, typeof(ZvMeter).FullName!, "Reading", PolicyFeature.Where, PolicyEffect.Allow); + + Func resolve = name => name == typeof(ZvMeter).FullName ? typeof(ZvMeter) + : name == typeof(ZvAnalogMeter).FullName ? typeof(ZvAnalogMeter) : null; + + Exception? baseRefusal = Record.Exception(() => SealedFields.Refuse(onBase, resolve, "rule")); + Exception? siblingRefusal = Record.Exception(() => SealedFields.Refuse(onSibling, resolve, "rule")); + Exception? overridableRefusal = Record.Exception(() => SealedFields.Refuse(overridable, resolve, "rule")); + + _out.WriteLine($"base: {baseRefusal?.Message}"); + _out.WriteLine($"sibling: {siblingRefusal?.Message}"); + _out.WriteLine($"overridable: {overridableRefusal?.Message}"); + + Assert.IsType(baseRefusal); + Assert.Null(siblingRefusal); + Assert.Null(overridableRefusal); + } + + // ------------------------------------------------------------------------------------------------ A9 + + // ------------------------------------------------------------------------------------------------ A10 + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_A10_a_rule_on_a_subtype_member_spelled_in_another_case_is_enforced(DwTier tier) + { + ZvKennel[] rows = { new() { Id = 1, Pet = new ZvKennelHamster { Name = "Ham", Tag = "case-tag-secret" } } }; + FakePolicyProvider rules = new FakePolicyProvider() + .Add("pet.TAG", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), tier, rules).ToList(new Filter()).Data), "case-tag-secret")); + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), tier, rules) + .ToList(new Filter { Selects = new List { "Id", "Pet" } }).Data), "case-tag-secret")); + } + + // ------------------------------------------------------------------------------------------------ B1 + + [Fact] + public void Zv_B1_a_sibling_override_does_not_reach_a_concrete_type_and_its_own_subtype_does() + { + ZvTaxi[] taxis = { new() { Id = 1, Plate = "taxi-plate" } }; + + Assert.Empty(Denials(typeof(ZvTaxi), "Plate").Where(f => (f.Features & PolicyFeature.Where) != 0)); + Assert.Equal("ran", Code(() => Guard(taxis.AsQueryable()).ToList(Where("Plate", "taxi-plate")))); + Assert.Equal("ran", Code(() => Guard(taxis.AsQueryable()).ToList(GroupedBy("Plate")))); + Assert.True(Holds(Guard(taxis.AsQueryable()).ToList(new Filter()).Data, "taxi-plate")); + + // ZvLimo, a subtype of ZvTaxi, denies ordering on Plate: that reaches ZvTaxi's path. + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForOrder), Code(() => Guard(taxis.AsQueryable()).ToList(OrderedBy("Plate")))); + + // ZvArmoredCar, a sibling, denies everything: the base path is denied, ZvTaxi's is not. + ZvCar[] cars = { new ZvTaxi { Id = 1, Plate = "taxi-plate" } }; + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), Code(() => Guard(cars.AsQueryable()).ToList(Where("Plate", "taxi-plate")))); + } + + // ------------------------------------------------------------------------------------------------ B2 + + [Fact] + public void Zv_B2_one_implementer_of_a_shared_interface_denies_the_interface_path_for_every_row() + { + ZvCity[] cities = { new() { Id = 1, Name = "Basra" } }; + IZvNamed[] named = { new ZvCity { Id = 1, Name = "Basra" } }; + ZvPin[] pins = { new() { Id = 1, Place = new ZvCity { Id = 2, Name = "Basra" } } }; + + string city = Code(() => Guard(cities.AsQueryable()).ToList(Where("Name", "Basra"))); + string iface = Code(() => Guard(named.AsQueryable()).ToList(Where("Name", "Basra"))); + string member = Code(() => Guard(pins.AsQueryable()).ToList(Where("Place.Name", "Basra"))); + string whole = Code(() => Guard(pins.AsQueryable()).ToList(new Filter())); + + _out.WriteLine($"class={city} interface={iface} interface-typed member={member} whole row={whole}"); + + Assert.Equal("ran", city); + } + + /// + /// An EF Core query over one entity set, read through the interface the entity implements: an unrelated + /// implementer's denial (ZvSpy, not even an entity) decides the interface path for every row. + /// + [Fact] + public void Zv_B2_an_entity_set_read_through_a_shared_interface_takes_an_unrelated_implementers_denial() + { + IQueryable cities = _db.Cities; + + string concrete = Code(() => Guard(_db.Cities).ToList(Where("Name", "Basra"))); + string where = Code(() => Guard(cities).ToList(Where("Name", "Basra"))); + string whole = Code(() => Guard(cities).ToList(new Filter())); + string dynamic = Code(() => Guard(cities).ToListDynamic(new Filter())); + + _out.WriteLine($"concrete set Where={concrete}; through the interface: Where={where} ToList={whole} ToListDynamic={dynamic}"); + + Assert.Equal("ran", concrete); + } + + /// + /// A class the EF Core model does not map overrides and denies an entity's member. EF Core never returns + /// one from the entity's set, but the walk reads every loaded subtype, so the entity's own path is denied: + /// a documented limit, which fails closed. + /// + [Fact] + public void Zv_B5_a_subclass_EF_Core_does_not_map_still_denies_the_entitys_path() + { + Assert.Null(_db.Model.FindEntityType(typeof(ZvVehicleView))); + + string where = Code(() => Guard(_db.Vehicles).ToList(Where("Code", "V-1"))); + object? data = SafeRead(() => Guard(_db.Vehicles).ToList(new Filter()).Data); + + _out.WriteLine($"Where={where} whole={JsonSerializer.Serialize(data)}"); + + Assert.Equal(nameof(PolicyErrorCode.FieldDeniedForWhere), where); + Assert.False(Holds(data, "V-1")); + } + + // ------------------------------------------------------------------------------------------------ B3 + + [Fact] + public void Zv_B3_the_startup_scan_reads_a_subtypes_overridable_denial_of_a_default_order_as_a_warning() + { + PolicyModelReport subtype = PolicyModelValidator.Inspect(new[] { typeof(ZvRanked) }); + PolicyModelReport own = PolicyModelValidator.Inspect(new[] { typeof(ZvOwnRanked) }); + + _out.WriteLine("subtype's override: errors=[" + string.Join(" | ", subtype.Errors) + "] warnings=[" + string.Join(" | ", subtype.Warnings) + "]"); + _out.WriteLine("own member: errors=[" + string.Join(" | ", own.Errors) + "] warnings=[" + string.Join(" | ", own.Warnings) + "]"); + + // The control: an overridable denial on the type's own member is a warning. + Assert.Empty(own.Errors); + Assert.NotEmpty(own.Warnings); + + // The same overridable denial on a subtype's override should read the same way. + Assert.Empty(subtype.Errors); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewGateTests.cs b/DynamicWhere.Tests/Policies/ReviewGateTests.cs new file mode 100644 index 0000000..d769b78 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewGateTests.cs @@ -0,0 +1,240 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +// An application's own collection classes, members sharing a name or hidden with new, and the framework's value collections. + +namespace DynamicWhere.Tests.Policies +{ + /// An application list of strings with a plain member of its own (a paged list, a tag set with a note). + public class ZwLabelSet : List + { + public string? Note { get; set; } + } + + /// The ASP.NET Core tutorial's PaginatedList shape, over strings. + public class ZwPagedNames : List + { + public int PageIndex { get; set; } + + public bool HasNextPage => false; + } + + /// An application list of strings whose own member is denied. + public class ZwSecretTags : List + { + [DwDenied] + public string? OwnerSecret { get; set; } + } + + public class ZwLabelRow + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwDenied] + public string? Ssn { get; set; } + + public ZwLabelSet Labels { get; set; } = new(); + + public ZwPagedNames Names { get; set; } = new(); + } + + public class ZwPlainLabelRow + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public ZwLabelSet Labels { get; set; } = new(); + } + + public class ZwNestedTagRow + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Sets { get; set; } = new(); + + public ZwSecretTags[] Arr { get; set; } = Array.Empty(); + } + + public class ZwDeclaredFrameworkRow + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Tags { get; set; } = new(); + + public IEnumerable Seq { get; set; } = Array.Empty(); + } + + public class ZwCaseRow + { + public int Id { get; set; } + + public string? Code { get; set; } + + [DwDenied] + public string? CODE { get; set; } + } + + public class ZwHideValueBase + { + public int Id { get; set; } + + [DwDenied] + public string? Code { get; set; } + } + + public class ZwHideValueDerived : ZwHideValueBase + { + public new string? Code { get; set; } + } + + public class ZwItemsBase + { + public int Id { get; set; } + + public List? Items { get; set; } + } + + public class ZwItemsDerived : ZwItemsBase + { + public new List? Items { get; set; } + } + + public sealed class ReviewGateTests + { + private readonly ITestOutputHelper _out; + + public ReviewGateTests(ITestOutputHelper output) => _out = output; + + private static PolicyQueryable GuardWith(IQueryable source, DwTier tier, params IDwPolicyProvider[] more) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }.Concat(more).ToArray())); + + private object? Read(string label, IQueryable source, DwTier tier, Filter? filter = null, bool dynamic = false, params IDwPolicyProvider[] more) where T : class + { + PolicyQueryable guarded = GuardWith(source, tier, more); + object? data = null; + string code = ZwKit.Code(() => data = dynamic ? guarded.ToListDynamic(filter ?? new Filter()).Data : guarded.ToList(filter ?? new Filter()).Data); + + _out.WriteLine($"{label} {tier} dynamic={dynamic}: code={code} sent={ZwKit.Json(data)} trace=[{ZwKit.Trace(guarded)}]"); + + return data; + } + + // ------------------------------------------------------------------ O: OwnsMembers on everyday list subclasses + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zw_O1_a_list_of_strings_subclass_with_a_plain_member_is_kept_beside_a_top_level_denial(DwTier tier) + { + ZwLabelRow[] rows = + { + new() { Id = 1, Name = "r1", Ssn = "zw-ssn-secret", Labels = new ZwLabelSet { "a", "b" }, Names = new ZwPagedNames { "x", "y" } } + }; + rows[0].Labels.Note = "note"; + + object? data = Read("O1", rows.AsQueryable(), tier); + List sent = Assert.IsType>(data); + + Assert.False(ZwKit.Holds(data, "zw-ssn-secret")); + Assert.Equal(2, sent[0].Labels.Count); + Assert.Equal(2, sent[0].Names.Count); + } + + [Fact] + public void Zw_O2_under_a_star_deny_a_list_of_strings_subclass_granted_by_name_is_kept() + { + ZwPlainLabelRow[] rows = { new() { Id = 1, Name = "r1", Labels = new ZwLabelSet { "a", "b" } } }; + + FakePolicyProvider rules = new FakePolicyProvider() + .Add("*", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal) + .Add("Id", PolicyFeature.Select, PolicyEffect.Allow, PolicyLevel.DynamicGlobal) + .Add("Name", PolicyFeature.Select, PolicyEffect.Allow, PolicyLevel.DynamicGlobal) + .Add("Labels", PolicyFeature.Select, PolicyEffect.Allow, PolicyLevel.DynamicGlobal); + + object? whole = Read("O2 none", rows.AsQueryable(), DwTier.Convenience, null, false, rules); + object? named = Read("O2 named", rows.AsQueryable(), DwTier.Convenience, new Filter { Selects = new List { "Id", "Labels" } }, false, rules); + + List a = Assert.IsType>(whole); + List b = Assert.IsType>(named); + + Assert.Equal(2, a[0].Labels.Count); + Assert.Equal(2, b[0].Labels.Count); + } + + // ------------------------------------------------------------------ K: an application collection's own members, one level removed + + [Theory] + [InlineData(DwTier.Strict, false)] + [InlineData(DwTier.Convenience, false)] + [InlineData(DwTier.Strict, true)] + public void Zw_K1_a_list_and_an_array_of_a_collection_class_with_a_denied_member(DwTier tier, bool dynamic) + { + ZwSecretTags tags = new() { "a" }; + tags.OwnerSecret = "zw-nested-owner-secret"; + + ZwNestedTagRow[] rows = { new() { Id = 1, Name = "r1", Sets = new List { tags }, Arr = new[] { tags } } }; + + object? data = Read("K1", rows.AsQueryable(), tier, null, dynamic); + + Assert.False(ZwKit.Holds(data, "zw-nested-owner-secret")); + } + + // ------------------------------------------------------------------ S: shared names + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zw_S1_two_value_members_differing_only_in_case_the_denied_one_is_withheld(DwTier tier) + { + ZwCaseRow[] rows = { new() { Id = 1, Code = "open", CODE = "zw-case-secret" } }; + + object? data = Read("S1", rows.AsQueryable(), tier); + + Assert.False(ZwKit.Holds(data, "zw-case-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zw_S2_a_denied_value_member_hidden_with_new_by_another_value(DwTier tier) + { + ZwHideValueDerived row = new() { Id = 1, Code = "open" }; + ((ZwHideValueBase)row).Code = "zw-hidden-base-secret"; + + object? data = Read("S2", new[] { row }.AsQueryable(), tier); + + Assert.False(ZwKit.Holds(data, "zw-hidden-base-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zw_S3_a_list_of_values_hidden_by_a_list_of_cards(DwTier tier) + { + ZwItemsDerived row = new() { Id = 1, Items = new List { new() { Label = "visa", Pan = "zw-items-pan-secret" } } }; + ((ZwItemsBase)row).Items = new List { "a" }; + + object? data = Read("S3", new[] { row }.AsQueryable(), tier); + object? named = Read("S3 named", new[] { row }.AsQueryable(), tier, new Filter { Selects = new List { "Id", "Items" } }); + + Assert.False(ZwKit.Holds(data, "zw-items-pan-secret")); + Assert.False(ZwKit.Holds(named, "zw-items-pan-secret")); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs b/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs new file mode 100644 index 0000000..48ae8e1 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewJoinEvalTests.cs @@ -0,0 +1,694 @@ +using System.Collections; +using System.Data.Common; +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using DynamicWhere.ex.Classes.Complex; +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; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Xunit.Abstractions; + +// Joins on a filtered, tagged or untracked set, and anonymous carriers: a query the tree holds inline is read where it +// stands, not evaluated, so these read as plain chains. + +namespace DynamicWhere.Tests.Policies +{ + public class ZwTier + { + public int Id { get; set; } + + public string Label { get; set; } = string.Empty; + } + + public class ZwCustomer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Region { get; set; } = string.Empty; + + public int ZwTierId { get; set; } + + /// Automatically included; nothing beneath it is denied. + public ZwTier? Tier { get; set; } + + /// A converted JSON bag. + public Dictionary Meta { get; set; } = new(); + + /// The only denial is beneath it; nothing includes it. + public List Cards { get; set; } = new(); + + public List Orders { get; set; } = new(); + } + + public class ZwCard + { + public int Id { get; set; } + + public int ZwCustomerId { get; set; } + + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pan { get; set; } + } + + public class ZwChannel + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class ZwOrder + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string Region { get; set; } = string.Empty; + + public int ZwCustomerId { get; set; } + + public ZwCustomer? Customer { get; set; } + + public int ZwChannelId { get; set; } + + /// Automatically included. + public ZwChannel? Channel { get; set; } + + public Dictionary Meta { get; set; } = new(); + } + + /// Counts the commands a context sends. + public sealed class ZwCommandCounter : DbCommandInterceptor + { + public int Readers; + + public override InterceptionResult ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result) + { + Interlocked.Increment(ref Readers); + + return result; + } + } + + public sealed class ZwContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZwContext(SqliteConnection connection, ZwCommandCounter? counter = null) + { + _connection = connection; + Counter = counter; + } + + public ZwCommandCounter? Counter { get; } + + public DbSet Customers => Set(); + + public DbSet Orders => Set(); + + public DbSet Cards => Set(); + + /// An ordinary method on the context (not a mapped function) that returns a query with an include. + public IQueryable CustomersWithCardsNamed(string name) => Customers.Include(c => c.Cards).Where(c => c.Name == name); + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlite(_connection); + + if (Counter is not null) + { + options.AddInterceptors(Counter); + } + } + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().ToTable("ZwCustomers"); + model.Entity().ToTable("ZwOrders"); + model.Entity().Navigation(c => c.Tier).AutoInclude(); + model.Entity().Navigation(o => o.Channel).AutoInclude(); + model.Entity().Property(c => c.Meta).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!); + model.Entity().Property(o => o.Meta).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!); + } + } + + /// A repository whose methods the guard now evaluates. + public sealed class ZwRepository + { + private readonly ZwContext _db; + private IQueryable? _level2; + + public ZwRepository(ZwContext db) => _db = db; + + public int Calls; + + public int FlipCalls; + + /// Counts its calls (a log line, a metric, an audit record). + public IQueryable Customers() + { + Interlocked.Increment(ref Calls); + + return _db.Customers; + } + + /// Resolves which customers the caller may see with a query of its own, then hands back a query. + public IQueryable Visible() + { + Interlocked.Increment(ref Calls); + + List ids = _db.Customers.AsNoTracking().Where(c => c.Region != string.Empty).Select(c => c.Id).ToList(); + + return _db.Customers.Where(c => ids.Contains(c.Id)); + } + + /// A stateful method: the first call hands back the plain set, every later one includes the cards. + public IQueryable Flip() + { + int call = Interlocked.Increment(ref FlipCalls); + + return call == 1 ? _db.Customers : _db.Customers.Include(c => c.Cards); + } + + /// The same as a property getter. + public IQueryable FlipSet + { + get + { + int call = Interlocked.Increment(ref FlipCalls); + + return call == 1 ? _db.Customers : _db.Customers.Include(c => c.Cards); + } + } + + /// A query that captures a built query two levels down. + public IQueryable Level1() + { + _level2 ??= _db.Customers.Select(c => new ZwCustomer { Id = c.Id, Name = c.Name, Region = c.Region, Cards = c.Cards }); + + IQueryable level2 = _level2; + + return _db.Customers.Where(c => c.Name != string.Empty).SelectMany(c => level2.Where(x => x.Id == c.Id)); + } + + /// Customers already in memory, handed out as a query. + public IQueryable InMemory(List held) => held.AsQueryable(); + } + + internal static class ZwKit + { + private static readonly JsonSerializerOptions JsonOptions = new() { ReferenceHandler = ReferenceHandler.IgnoreCycles }; + + internal static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + internal static string Shape(Expression expression) + { + string Try(Func read) + { + try + { + return read().ToString(); + } + catch (Exception e) + { + return "threw " + e.GetType().Name; + } + } + + return $"reshapes={Try(() => QueryRoot.Reshapes(expression))} builds={Try(() => QueryRoot.Builds(expression))}"; + } + + internal static string Trace(PolicyQueryable guarded) where T : class => + string.Join(" | ", guarded.LastTrace?.Decisions + .Where(d => d.Action != PolicyAction.Allowed) + .Select(d => $"{d.FieldPath} {d.Action}: {d.Reason}") ?? Array.Empty()); + + internal static bool AnyDropped(PolicyQueryable guarded) where T : class => + guarded.LastTrace?.Decisions.Any(d => d.Action == PolicyAction.Dropped) ?? false; + + internal static string Json(object? value) + { + try + { + return JsonSerializer.Serialize(value, JsonOptions); + } + catch (Exception e) + { + return $""; + } + } + + internal static string Code(Action run) + { + try + { + run(); + + return "ran"; + } + catch (PolicyException refusal) + { + return $"{refusal.ErrorCode}({refusal.FieldPath}; {refusal.SourceOrigin})"; + } + catch (Exception other) + { + return $"{other.GetType().Name}: {other.Message.Split('\n')[0]}"; + } + } + + internal static object? SafeRead(Func read) + { + try + { + return read(); + } + catch (PolicyException) + { + return null; + } + catch (LogicException) + { + return null; + } + } + + /// Everything reachable from a value, read by runtime type (public and non-public properties and fields). + internal static bool Holds(object? value, string text) + { + HashSet seen = new(ReferenceEqualityComparer.Instance); + Stack pending = new(); + + pending.Push(value); + + while (pending.Count > 0) + { + object? current = pending.Pop(); + + if (current is null || current is MemberInfo || current is Delegate + || (current.GetType().Namespace ?? string.Empty).StartsWith("Microsoft.", StringComparison.Ordinal)) + { + continue; + } + + if (current is string held) + { + if (held.Contains(text, StringComparison.Ordinal)) + { + return true; + } + + continue; + } + + if (current.GetType().IsPrimitive || current is decimal || current is DateTime || current is Guid) + { + continue; + } + + if (!current.GetType().IsValueType && !seen.Add(current)) + { + continue; + } + + if (current is IDictionary map) + { + foreach (DictionaryEntry item in map) + { + pending.Push(item.Key); + pending.Push(item.Value); + } + } + else if (current is IEnumerable items) + { + try + { + foreach (object? item in items) + { + pending.Push(item); + } + } + catch + { + // Unreadable. + } + } + + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + + foreach (PropertyInfo property in current.GetType().GetProperties(all)) + { + if (property.GetIndexParameters().Length != 0 || !property.CanRead) + { + continue; + } + + try + { + pending.Push(property.GetValue(current)); + } + catch + { + // Unreadable. + } + } + + for (Type? type = current.GetType(); type is not null && type != typeof(object); type = type.BaseType) + { + if (type.Namespace is { } ns && (ns == "System" || ns.StartsWith("System.", StringComparison.Ordinal) + || ns.StartsWith("Microsoft.", StringComparison.Ordinal))) + { + continue; + } + + foreach (FieldInfo field in type.GetFields(all | BindingFlags.DeclaredOnly)) + { + try + { + pending.Push(field.GetValue(current)); + } + catch + { + // Unreadable. + } + } + } + } + + return false; + } + } + + public sealed class ReviewJoinEvalTests : IDisposable + { + private const string Secret = "zw-pan-secret"; + + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZwCommandCounter _counter = new(); + private readonly ZwContext _db; + private readonly ZwRepository _repo; + + public ReviewJoinEvalTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZwContext(_connection, _counter); + _db.Database.EnsureCreated(); + _repo = new ZwRepository(_db); + + ZwTier gold = new() { Label = "gold" }; + ZwChannel web = new() { Name = "web" }; + + ZwCustomer c1 = new() + { + Name = "C1", + Region = "north", + Tier = gold, + Meta = { ["k"] = "v1" }, + Cards = { new ZwCard { Label = "visa", Pan = Secret } } + }; + ZwCustomer c2 = new() { Name = "C2", Region = "south", Tier = gold, Meta = { ["k"] = "v2" } }; + + c1.Orders.Add(new ZwOrder { Code = "O1", Region = "north", Channel = web, Meta = { ["k"] = "o1" } }); + c1.Orders.Add(new ZwOrder { Code = "O2", Region = "south", Channel = web, Meta = { ["k"] = "o2" } }); + c2.Orders.Add(new ZwOrder { Code = "O3", Region = "south", Channel = web, Meta = { ["k"] = "o3" } }); + + _db.Customers.AddRange(c1, c2); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private (List? Rows, PolicyQueryable Guarded) Run(string label, IQueryable source) where T : class + { + string shape = ZwKit.Shape(source.Expression); + PolicyQueryable guarded = ZwKit.Guard(source); + + try + { + int count = source.AsNoTracking().ToList().Count; + + _out.WriteLine($"{label}: unguarded OK rows={count} {shape}"); + } + catch (Exception e) + { + _out.WriteLine($"{label}: unguarded THREW {e.GetType().Name} {e.Message.Split('\n')[0]} {shape}"); + + return (null, guarded); + } + + _db.ChangeTracker.Clear(); + + List rows = guarded.ToList(new Filter()).Data; + + _out.WriteLine($"{label}: guarded rows={rows.Count} trace=[{ZwKit.Trace(guarded)}]"); + + return (rows, guarded); + } + + private void CustomersAsLoaded(string label, IQueryable source, int count) + { + (List? rows, PolicyQueryable guarded) = Run(label, source); + + Assert.NotNull(rows); + Assert.Equal(count, rows!.Count); + Assert.False(ZwKit.AnyDropped(guarded), ZwKit.Trace(guarded)); + Assert.All(rows, row => Assert.Equal("gold", row.Tier?.Label)); + Assert.All(rows, row => Assert.True(row.Meta.ContainsKey("k"), "Meta left out")); + } + + private void OrdersAsLoaded(string label, IQueryable source, int count) + { + (List? rows, PolicyQueryable guarded) = Run(label, source); + + Assert.NotNull(rows); + Assert.Equal(count, rows!.Count); + Assert.False(ZwKit.AnyDropped(guarded), ZwKit.Trace(guarded)); + Assert.All(rows, row => Assert.Equal("web", row.Channel?.Name)); + Assert.All(rows, row => Assert.True(row.Meta.ContainsKey("k"), "Meta left out")); + } + + // ------------------------------------------------------------------ J: joins whose inner sequence is not a bare set + + [Fact] + public void Zw_J0_control_join_on_a_bare_set() + { + CustomersAsLoaded("J0 Join(_db.Customers)", _db.Orders.Join(_db.Customers, o => o.ZwCustomerId, c => c.Id, (o, c) => c), 3); + } + + [Fact] + public void Zw_J1_join_on_a_filtered_set() + { + CustomersAsLoaded( + "J1 Join(_db.Customers.Where(..))", + _db.Orders.Join(_db.Customers.Where(c => c.Region != string.Empty), o => o.ZwCustomerId, c => c.Id, (o, c) => c), + 3); + } + + [Fact] + public void Zw_J2_query_syntax_join_on_an_untracked_set() + { + IQueryable source = + from o in _db.Orders + join c in _db.Customers.AsNoTracking() on o.ZwCustomerId equals c.Id + select c; + + CustomersAsLoaded("J2 join c in _db.Customers.AsNoTracking()", source, 3); + } + + [Fact] + public void Zw_J3_left_join_on_a_filtered_set() + { + IQueryable source = + from o in _db.Orders + join c in _db.Customers.Where(x => x.Region != string.Empty) on o.ZwCustomerId equals c.Id into cs + from c in cs.DefaultIfEmpty() + select c; + + CustomersAsLoaded("J3 left join on a filtered set", source, 3); + } + + [Fact] + public void Zw_J4_join_on_a_filtered_set_returning_the_outer_rows() + { + OrdersAsLoaded( + "J4 Join(_db.Customers.Where(..), (o, c) => o)", + _db.Orders.Join(_db.Customers.Where(c => c.Region != string.Empty), o => o.ZwCustomerId, c => c.Id, (o, c) => o), + 3); + } + + [Fact] + public void Zw_J5_join_on_a_tagged_set() + { + CustomersAsLoaded( + "J5 Join(_db.Customers.TagWith(..))", + _db.Orders.Join(_db.Customers.TagWith("lookup"), o => o.ZwCustomerId, c => c.Id, (o, c) => c), + 3); + } + + [Fact] + public void Zw_J6_where_then_join_on_a_filtered_order_set() + { + OrdersAsLoaded( + "J6 _db.Orders.Where(..).Join(_db.Orders.Where(..), (a, b) => a)", + _db.Orders.Where(o => o.Code != string.Empty) + .Join(_db.Orders.Where(o => o.Region != string.Empty), a => a.Id, b => b.Id, (a, b) => a), + 3); + } + + // ------------------------------------------------------------------ E: what the evaluation of user code costs + + [Fact] + public void Zw_E4_a_repository_query_capturing_a_built_query_two_levels_down() + { + IQueryable source = _db.Orders.SelectMany(o => _repo.Level1().Where(c => c.Id == o.ZwCustomerId)); + string unguarded; + + try + { + unguarded = ZwKit.Holds(source.AsNoTracking().ToList(), Secret) ? "HOLDS PAN" : "no pan"; + } + catch (Exception e) + { + unguarded = $"THREW {e.GetType().Name}: {e.Message.Split('\n')[0]}"; + } + + PolicyQueryable guarded = ZwKit.Guard(source); + object? data; + + try + { + data = ZwKit.SafeRead(() => guarded.ToList(new Filter()).Data); + } + catch (InvalidOperationException) when (typeof(DbContext).Assembly.GetName().Version!.Major < 7) + { + // EF Core 6 cannot translate the projection this shape needs, so the guarded read fails closed. + return; + } + + _out.WriteLine($"E4 {ZwKit.Shape(source.Expression)} unguarded={unguarded} sent={ZwKit.Json(data)} trace=[{ZwKit.Trace(guarded)}]"); + + Assert.False(ZwKit.Holds(data, Secret)); + } + + [Fact] + public void Zw_E5_a_repository_method_handing_out_rows_in_memory() + { + List held = _db.Customers.Include(c => c.Cards).AsNoTracking().ToList(); + IQueryable source = _db.Orders.SelectMany(o => _repo.InMemory(held).Where(c => c.Id == o.ZwCustomerId)); + + string code = ZwKit.Code(() => source.AsNoTracking().ToList()); + PolicyQueryable guarded = ZwKit.Guard(source); + object? data = null; + string guardedCode = ZwKit.Code(() => data = guarded.ToList(new Filter()).Data); + + _out.WriteLine($"E5 {ZwKit.Shape(source.Expression)} unguarded={code} guarded={guardedCode} sent={ZwKit.Json(data)}"); + + Assert.False(ZwKit.Holds(data, Secret)); + } + + [Fact] + public void Zw_E6_a_context_method_that_includes_read_through_a_correlated_argument() + { + IQueryable source = _db.Orders.SelectMany(o => _db.CustomersWithCardsNamed(o.Code)); + + string code = ZwKit.Code(() => source.AsNoTracking().ToList()); + PolicyQueryable guarded = ZwKit.Guard(source); + object? data = null; + string guardedCode = ZwKit.Code(() => data = guarded.ToList(new Filter()).Data); + + _out.WriteLine($"E6 {ZwKit.Shape(source.Expression)} unguarded={code} guarded={guardedCode} sent={ZwKit.Json(data)}"); + + Assert.False(ZwKit.Holds(data, Secret)); + } + + // ------------------------------------------------------------------ A: anonymous carriers under the caller's tracking + + [Theory] + [InlineData("AsTracking")] + [InlineData("IdentityResolution")] + [InlineData("None")] + public void Zw_A1_an_anonymous_carrier_with_the_cards_in_a_context_that_tracks_them(string tracking) + { + // Earlier in the unit of work the application read the cards with tracking. + _ = _db.Cards.ToList(); + + IQueryable orders = tracking switch + { + "AsTracking" => _db.Orders.AsTracking(), + "IdentityResolution" => _db.Orders.AsNoTrackingWithIdentityResolution(), + _ => _db.Orders + }; + + IQueryable source = orders + .Select(o => new { o.Customer, Cards = o.Customer!.Cards.ToList() }) + .Select(x => x.Customer!); + + PolicyQueryable guarded = ZwKit.Guard(source); + object? data = ZwKit.SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine($"A1 {tracking} {ZwKit.Shape(source.Expression)} tracked={_db.ChangeTracker.Entries().Count()} sent={ZwKit.Json(data)} trace=[{ZwKit.Trace(guarded)}]"); + + Assert.False(ZwKit.Holds(data, Secret)); + } + + [Theory] + [InlineData("AsTracking")] + [InlineData("IdentityResolution")] + public void Zw_A2_the_carrier_is_the_last_projection_before_a_member_read(string tracking) + { + _ = _db.Cards.ToList(); + + IQueryable orders = tracking == "AsTracking" ? _db.Orders.AsTracking() : _db.Orders.AsNoTrackingWithIdentityResolution(); + + IQueryable source = orders + .Select(o => new { Order = o, Owner = o.Customer, o.Customer!.Cards }) + .Where(x => x.Cards.Count >= 0) + .Select(x => x.Owner!); + + PolicyQueryable guarded = ZwKit.Guard(source); + object? data = ZwKit.SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine($"A2 {tracking} {ZwKit.Shape(source.Expression)} sent={ZwKit.Json(data)} trace=[{ZwKit.Trace(guarded)}]"); + + Assert.False(ZwKit.Holds(data, Secret)); + } + + [Fact] + public void Zw_A3_control_the_anonymous_carrier_query_reads_as_it_did() + { + CustomersAsLoaded( + "A3 Select(o => new { o.Customer, o.Code }).Select(x => x.Customer)", + _db.Orders.Select(o => new { o.Customer, o.Code }).Select(x => x.Customer!), + 3); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewJoinRootTests.cs b/DynamicWhere.Tests/Policies/ReviewJoinRootTests.cs new file mode 100644 index 0000000..181b45c --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewJoinRootTests.cs @@ -0,0 +1,224 @@ +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// A join on a filtered set over roots a typed projection cannot build (DDD, constructor-bound, abstract): read from the model, not projected. +// The only denial (ZwKey.Secret) sits beneath Owner.Keys, which nothing loads. + +namespace DynamicWhere.Tests.Policies +{ + public class ZwOwner + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Keys { get; set; } = new(); + + public List Invoices { get; set; } = new(); + + public List Members { get; set; } = new(); + + public List Payments { get; set; } = new(); + } + + public class ZwKey + { + public int Id { get; set; } + + public int ZwOwnerId { get; set; } + + [DwDenied] + public string? Secret { get; set; } + } + + /// DDD style: a private constructor for EF Core, a public one for the domain, private setters. + public class ZwInvoice + { + private ZwInvoice() + { + } + + public ZwInvoice(string number) => Number = number; + + public int Id { get; private set; } + + public string Number { get; private set; } = string.Empty; + + public int ZwOwnerId { get; private set; } + + public ZwOwner? Owner { get; private set; } + } + + /// Constructor-bound: EF Core binds it, and it has no parameterless constructor. + public class ZwMember + { + public ZwMember(int id, string name, int zwOwnerId) + { + Id = id; + Name = name; + ZwOwnerId = zwOwnerId; + } + + public int Id { get; private set; } + + public string Name { get; private set; } + + public int ZwOwnerId { get; private set; } + + public ZwOwner? Owner { get; private set; } + } + + public abstract class ZwPayment + { + public int Id { get; set; } + + public long Cents { get; set; } + + public int ZwOwnerId { get; set; } + + public ZwOwner? Owner { get; set; } + } + + public class ZwCardPayment : ZwPayment + { + public string Last4 { get; set; } = string.Empty; + } + + public class ZwCashPayment : ZwPayment + { + public string Till { get; set; } = string.Empty; + } + + public sealed class ZwOwnerContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZwOwnerContext(SqliteConnection connection) => _connection = connection; + + public DbSet Owners => Set(); + + public DbSet Payments => Set(); + + public DbSet Members => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity(); + model.Entity(); + model.Entity().HasOne(m => m.Owner).WithMany(o => o.Members).HasForeignKey(m => m.ZwOwnerId); + } + } + + public sealed class ReviewJoinRootTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZwOwnerContext _db; + + public ReviewJoinRootTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZwOwnerContext(_connection); + _db.Database.EnsureCreated(); + + ZwOwner owner = new() + { + Name = "W1", + Keys = { new ZwKey { Secret = "zw-key-secret" } }, + Payments = { new ZwCardPayment { Cents = 100, Last4 = "4242" }, new ZwCashPayment { Cents = 200, Till = "T1" } }, + Invoices = { new ZwInvoice("INV-1") } + }; + + _db.Owners.Add(owner); + _db.SaveChanges(); + _db.Members.Add(new ZwMember(0, "M1", owner.Id)); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private string Typed(string label, IQueryable source) where T : class + { + string unguarded = JsonSerializer.Serialize(source.AsNoTracking().ToList().Select(r => r.GetType().Name)); + PolicyQueryable guarded = ZwKit.Guard(source); + string outcome; + + _db.ChangeTracker.Clear(); + + try + { + List rows = guarded.ToList(new Filter()).Data; + + outcome = $"OK types={JsonSerializer.Serialize(rows.Select(r => r.GetType().Name))} json={JsonSerializer.Serialize(rows)}"; + } + catch (Exception e) + { + outcome = $"THREW {e.GetType().Name} {(e is PolicyException p ? p.ErrorCode.ToString() : string.Empty)} {e.Message.Split('\n')[0]}"; + } + + _out.WriteLine($"{label}: {ZwKit.Shape(source.Expression)} unguarded types={unguarded} guarded {outcome} trace=[{ZwKit.Trace(guarded)}]"); + + return outcome; + } + + [Fact] + public void Zw_D0_control_a_DDD_aggregate_joined_on_a_bare_set() + { + string outcome = Typed("D0", _db.Owners.Join(_db.Set(), o => o.Id, i => i.ZwOwnerId, (o, i) => i)); + + Assert.StartsWith("OK", outcome); + Assert.Contains("INV-1", outcome); + } + + [Fact] + public void Zw_D1_a_DDD_aggregate_joined_on_a_filtered_set() + { + string outcome = Typed("D1", _db.Owners.Join(_db.Set().Where(i => i.Number != string.Empty), o => o.Id, i => i.ZwOwnerId, (o, i) => i)); + + Assert.StartsWith("OK", outcome); + Assert.Contains("INV-1", outcome); + } + + [Fact] + public void Zw_D2_a_constructor_bound_entity_joined_on_a_filtered_set() + { + string outcome = Typed("D2", _db.Owners.Join(_db.Members.Where(m => m.Name != string.Empty), o => o.Id, m => m.ZwOwnerId, (o, m) => m)); + + Assert.StartsWith("OK", outcome); + } + + [Fact] + public void Zw_D3_abstract_rows_joined_on_a_filtered_set() + { + string outcome = Typed("D3", _db.Owners.Join(_db.Payments.Where(p => p.Cents > 0), o => o.Id, p => p.ZwOwnerId, (o, p) => p)); + + Assert.StartsWith("OK", outcome); + Assert.Contains("ZwCardPayment", outcome); + } + + [Fact] + public void Zw_D4_a_captured_filtered_query_as_the_inner_sequence() + { + IQueryable named = _db.Members.Where(m => m.Name != string.Empty); + + string outcome = Typed("D4", _db.Owners.Join(named, o => o.Id, m => m.ZwOwnerId, (o, m) => m)); + + Assert.StartsWith("OK", outcome); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewLeakTests.cs b/DynamicWhere.Tests/Policies/ReviewLeakTests.cs new file mode 100644 index 0000000..b85da7f --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewLeakTests.cs @@ -0,0 +1,746 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations.Schema; +using DynamicWhere.ex.Classes.Complex; +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 +{ + // ============================================================================ round 3 probes: models + + // ---- P1: an unmapped getter over a private, mapped field ---------------------------------------- + + public class ZrEmployee + { + private string? _band; + + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Computed from a private field EF Core maps as a column; the model does not map this member. + [NotMapped] + public ZrPay Pay => new() { Grade = "G", Band = _band }; + + public void SetBand(string band) => _band = band; + } + + public class ZrPay + { + public string? Grade { get; set; } + + [DwDenied] + public string? Band { get; set; } + } + + // ---- P1b: the same inside an owned type ----------------------------------------------------------- + + public class ZrClinic + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public ZrContact Contact { get; set; } = new(); + } + + public class ZrContact + { + private string? _phone; + + public string City { get; set; } = string.Empty; + + [NotMapped] + public ZrPhoneView Phone => new() { Number = _phone }; + + public void SetPhone(string phone) => _phone = phone; + } + + public class ZrPhoneView + { + [DwDenied] + public string? Number { get; set; } + } + + // ---- P2: an unmapped getter exposing a private, automatically included navigation ------------------ + + public class ZrLedger + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + private List Entries { get; set; } = new(); + + [NotMapped] + public IReadOnlyList Items => Entries; + + public void Add(ZrEntry entry) => Entries.Add(entry); + } + + public class ZrEntry + { + public int Id { get; set; } + + public int ZrLedgerId { get; set; } + + public string Memo { get; set; } = string.Empty; + + [DwDenied] + public string? Amount { get; set; } + } + + /// An entity that includes the ledger, so the ledger is a loaded navigation beneath the row. + public class ZrBook + { + public int Id { get; set; } + + public string Title { get; set; } = string.Empty; + + public int ZrLedgerId { get; set; } + + public ZrLedger? Ledger { get; set; } + } + + // ---- P3: generic subtypes, which the subtype search never lists ----------------------------------- + + public class ZrCreature + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class ZrTagged : ZrCreature + { + [DwDenied] + public string? Secret { get; set; } + + public TTag? Tag { get; set; } + } + + public class ZrPen + { + public int Id { get; set; } + + public string Label { get; set; } = string.Empty; + + public ZrCreature? Pet { get; set; } + } + + /// An EF Core hierarchy whose derived entity is a closed generic type. + public class ZrOrg + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class ZrGenOrg : ZrOrg + { + [DwDenied] + public string? Secret { get; set; } + } + + /// A derived entity whose field no attribute denies, for a rule to deny instead. + public class ZrBank : ZrOrg + { + public string? Swift { get; set; } + } + + public class ZrLoan + { + public int Id { get; set; } + + public string Note { get; set; } = string.Empty; + + public int ZrOrgId { get; set; } + + public ZrOrg? Org { get; set; } + } + + public class ZrLoanRow + { + public int Id { get; set; } + + public string Note { get; set; } = string.Empty; + + public ZrOrg? Org { get; set; } + } + + // ---- P4: a member typed as a framework base class --------------------------------------------------- + + public class ZrDeclinedException : Exception + { + public ZrDeclinedException() + : base("declined") + { + } + + [DwDenied] + public string? Pan { get; set; } + } + + public class ZrJobResult + { + public int Id { get; set; } + + public string Status { get; set; } = string.Empty; + + public Exception? Error { get; set; } + } + + // ---- P5: a rule on a subtype's field, reached through a base-typed member --------------------------- + + public class ZrCat : ZrCreature + { + public string? Microchip { get; set; } + } + + // ---- P8: two members differing only in letter case ------------------------------------------------- + + public class ZrCaseRow + { + public int Id { get; set; } + + public ZrCardInfo INFO { get; set; } = new(); + + public ZrSafeInfo Info { get; set; } = new(); + } + + public class ZrCardInfo + { + [DwDenied] + public string? Pan { get; set; } + } + + public class ZrSafeInfo + { + public string? Label { get; set; } + } + + // ---- P6: a Concat of two projections --------------------------------------------------------------- + + public class ZrAcctRow + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public ZrPersonView? Person { get; set; } + } + + public class ZrPersonView + { + public string? Name { get; set; } + + [DwDenied] + public string? TaxId { get; set; } + } + + // ---- P9: many-to-many with an explicit join entity carrying a denied payload ------------------------ + + public class ZrPost + { + public int Id { get; set; } + + public string Title { get; set; } = string.Empty; + + public List Tags { get; set; } = new(); + + public List PostTags { get; set; } = new(); + } + + public class ZrTag + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Posts { get; set; } = new(); + + public List PostTags { get; set; } = new(); + } + + public class ZrPostTag + { + public int ZrPostId { get; set; } + + public int ZrTagId { get; set; } + + public ZrPost? Post { get; set; } + + public ZrTag? Tag { get; set; } + + [DwDenied] + public string? AddedBy { get; set; } + } + + public sealed class ZrProbeContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZrProbeContext(SqliteConnection connection) => _connection = connection; + + public DbSet Employees => Set(); + + public DbSet Clinics => Set(); + + public DbSet Ledgers => Set(); + + public DbSet Books => Set(); + + public DbSet Orgs => Set(); + + public DbSet Loans => Set(); + + public DbSet Posts => Set(); + + public DbSet Tags => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().Property("_band"); + model.Entity().OwnsOne(c => c.Contact, o => o.Property("_phone")); + model.Entity(b => + { + b.HasMany("Entries").WithOne().HasForeignKey(e => e.ZrLedgerId); + b.Navigation("Entries").AutoInclude(); + }); + model.Entity>(); + model.Entity(); + model.Entity() + .HasMany(p => p.Tags) + .WithMany(t => t.Posts) + .UsingEntity( + j => j.HasOne(pt => pt.Tag).WithMany(t => t.PostTags).HasForeignKey(pt => pt.ZrTagId), + j => j.HasOne(pt => pt.Post).WithMany(p => p.PostTags).HasForeignKey(pt => pt.ZrPostId), + j => j.HasKey(pt => new { pt.ZrPostId, pt.ZrTagId })); + } + } + + // ============================================================================ round 3 probes: tests + + /// + /// Round 3 adversarial probes. Every assertion states the safe outcome, so a red test is a leak. A refusal with + /// FieldDeniedForSelect is a safe outcome. + /// + public sealed class ReviewLeakTests : IDisposable + { + private readonly SqliteConnection _connection; + private readonly ZrProbeContext _db; + + public ReviewLeakTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _db = new ZrProbeContext(_connection); + _db.Database.EnsureCreated(); + + ZrEmployee employee = new() { Name = "E1" }; + employee.SetBand("band-secret"); + _db.Employees.Add(employee); + + ZrClinic clinic = new() { Name = "C1", Contact = new ZrContact { City = "Basra" } }; + clinic.Contact.SetPhone("phone-secret"); + _db.Clinics.Add(clinic); + + ZrLedger ledger = new() { Name = "L1" }; + ledger.Add(new ZrEntry { Memo = "m", Amount = "amount-secret" }); + _db.Ledgers.Add(ledger); + _db.Books.Add(new ZrBook { Title = "B1", Ledger = ledger }); + + _db.Loans.Add(new ZrLoan { Note = "gen", Org = new ZrGenOrg { Name = "G", Secret = "generic-entity-secret" } }); + _db.Loans.Add(new ZrLoan { Note = "bank", Org = new ZrBank { Name = "K", Swift = "swift-by-rule" } }); + + ZrPost post = new() { Title = "P1" }; + ZrTag tag = new() { Name = "T1" }; + _db.Posts.Add(post); + _db.Tags.Add(tag); + _db.SaveChanges(); + _db.Set().Add(new ZrPostTag { ZrPostId = post.Id, ZrTagId = tag.Id, AddedBy = "join-secret" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static DwPolicyContext Caller() => + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1").WithValue("TenantId", 1); + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict, params IDwPolicyProvider[] more) + where T : class => + source.ApplyPolicy( + Caller(), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }.Concat(more).ToArray())); + + private static Filter Selecting(params string[] fields) => new() { Selects = fields.ToList() }; + + private static FakePolicyProvider Denying(params string[] paths) + { + FakePolicyProvider rules = new(); + + foreach (string path in paths) + { + rules.Add(path, PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + } + + return rules; + } + + private static FakePolicyProvider DenyingAllBut(params string[] allowed) + { + FakePolicyProvider rules = new FakePolicyProvider() + .Add("*", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + foreach (string path in allowed) + { + rules.Add(path, PolicyFeature.Select, PolicyEffect.Allow, PolicyLevel.DynamicGlobal); + } + + return rules; + } + + /// + /// True when anything reachable from a value holds the text, read by each object's runtime type, through + /// every readable public property (getter-only ones included, as a serializer writes them). + /// + private static bool Holds(object? value, string text) + { + HashSet seen = new(ReferenceEqualityComparer.Instance); + Stack pending = new(); + + pending.Push(value); + + while (pending.Count > 0) + { + object? current = pending.Pop(); + + if (current is null || current is ValueType || current is System.Reflection.MemberInfo) + { + continue; + } + + if (current is string held) + { + if (held == text) + { + return true; + } + + continue; + } + + if (!seen.Add(current)) + { + continue; + } + + if (current is IEnumerable items) + { + foreach (object? item in items) + { + pending.Push(item); + } + + continue; + } + + foreach (System.Reflection.PropertyInfo property in current.GetType().GetProperties()) + { + if (property.GetIndexParameters().Length != 0 || !property.CanRead) + { + continue; + } + + object? read; + + try + { + read = property.GetValue(current); + } + catch + { + continue; + } + + pending.Push(read); + } + } + + return false; + } + + /// Runs a guarded read; a FieldDeniedForSelect refusal is safe and reads as no rows. + private static object? SafeRead(Func read) + { + try + { + return read(); + } + catch (PolicyException refusal) when (refusal.ErrorCode == PolicyErrorCode.FieldDeniedForSelect) + { + return null; + } + } + + // ------------------------------------------------------------------------------------------------ P1 + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P1_an_unmapped_getter_over_a_private_mapped_field_does_not_carry_the_denied_value(DwTier tier) + { + // Unguarded, the entity carries the value through the getter. + Assert.True(Holds(_db.Employees.AsNoTracking().ToList(), "band-secret")); + + Assert.False(Holds(SafeRead(() => Guard(_db.Employees, tier).ToList(new Filter()).Data), "band-secret")); + } + + [Fact] + public void Zr_P1_dynamic_terminal() + { + Assert.False(Holds(SafeRead(() => Guard(_db.Employees).ToListDynamic(new Filter()).Data), "band-secret")); + } + + [Fact] + public async Task Zr_P1_segment_terminal() + { + object? rows = null; + + try + { + rows = (await Guard(_db.Employees).ToListAsync(new Segment(), CancellationToken.None)).Data; + } + catch (PolicyException refusal) when (refusal.ErrorCode == PolicyErrorCode.FieldDeniedForSelect) + { + } + + Assert.False(Holds(rows, "band-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P1b_an_owned_type_with_an_unmapped_getter_over_a_private_mapped_field(DwTier tier) + { + Assert.True(Holds(_db.Clinics.AsNoTracking().ToList(), "phone-secret")); + + Assert.False(Holds(SafeRead(() => Guard(_db.Clinics, tier).ToList(new Filter()).Data), "phone-secret")); + } + + // ------------------------------------------------------------------------------------------------ P2 + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P2_an_unmapped_getter_exposing_a_private_auto_included_navigation(DwTier tier) + { + Assert.True(Holds(_db.Ledgers.AsNoTracking().ToList(), "amount-secret")); + + Assert.False(Holds(SafeRead(() => Guard(_db.Ledgers, tier).ToList(new Filter()).Data), "amount-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P2b_the_same_beneath_an_included_navigation(DwTier tier) + { + IQueryable source = _db.Books.Include(b => b.Ledger); + + Assert.True(Holds(source.AsNoTracking().ToList(), "amount-secret")); + + Assert.False(Holds(SafeRead(() => Guard(source, tier).ToList(new Filter()).Data), "amount-secret")); + } + + // ------------------------------------------------------------------------------------------------ P3 + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P3_rows_in_memory_of_a_generic_subtype_with_a_denied_field(DwTier tier) + { + ZrCreature[] rows = { new ZrTagged { Id = 1, Name = "Rex", Secret = "generic-secret", Tag = 7 } }; + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), tier).ToList(new Filter()).Data), "generic-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P3b_a_base_typed_member_in_memory_holding_a_generic_subtype(DwTier tier) + { + ZrPen[] rows = { new() { Id = 1, Label = "pen", Pet = new ZrTagged { Id = 2, Name = "Rex", Secret = "generic-secret" } } }; + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), tier).ToList(new Filter()).Data), "generic-secret")); + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), tier).ToList(Selecting("Id", "Pet")).Data), "generic-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P3c_a_projected_member_holding_a_closed_generic_derived_entity(DwTier tier) + { + IQueryable rows = _db.Loans.Select(l => new ZrLoanRow { Id = l.Id, Note = l.Note, Org = l.Org }); + + Assert.True(Holds(rows.ToList(), "generic-entity-secret")); + + Assert.False(Holds(SafeRead(() => Guard(rows, tier).ToList(new Filter()).Data), "generic-entity-secret")); + Assert.False(Holds(SafeRead(() => Guard(rows, tier).ToList(Selecting("Id", "Org")).Data), "generic-entity-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P3c_control_the_entity_query_reads_the_model(DwTier tier) + { + IQueryable source = _db.Loans.Include(l => l.Org); + + Assert.True(Holds(source.AsNoTracking().ToList(), "generic-entity-secret")); + Assert.False(Holds(SafeRead(() => Guard(source, tier).ToList(new Filter()).Data), "generic-entity-secret")); + Assert.False(Holds(SafeRead(() => Guard(_db.Orgs, tier).ToList(new Filter()).Data), "generic-entity-secret")); + } + + // ------------------------------------------------------------------------------------------------ P4 + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P4_a_member_typed_as_a_framework_base_class_holding_an_application_subtype(DwTier tier) + { + ZrJobResult[] rows = { new() { Id = 1, Status = "failed", Error = new ZrDeclinedException { Pan = "exception-secret" } } }; + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), tier).ToList(new Filter()).Data), "exception-secret")); + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), tier).ToList(Selecting("Id", "Error")).Data), "exception-secret")); + } + + [Fact] + public void Zr_P4b_under_a_deny_by_default_policy() + { + ZrJobResult[] rows = { new() { Id = 1, Status = "failed", Error = new ZrDeclinedException { Pan = "exception-secret" } } }; + + FakePolicyProvider rules = DenyingAllBut("Id", "Error"); + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), DwTier.Strict, rules).ToList(Selecting("Id", "Error")).Data), "exception-secret")); + } + + // ------------------------------------------------------------------------------------------------ P5 + + [Fact] + public void Zr_P5_control_a_rule_on_a_subtype_field_at_the_root_is_enforced() + { + ZrCreature[] rows = { new ZrCat { Id = 1, Name = "Tom", Microchip = "chip-by-rule" } }; + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), DwTier.Strict, Denying("Microchip")).ToList(new Filter()).Data), "chip-by-rule")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P5_a_rule_on_a_subtype_field_through_a_base_typed_member_in_memory(DwTier tier) + { + ZrPen[] rows = { new() { Id = 1, Label = "pen", Pet = new ZrCat { Id = 2, Name = "Tom", Microchip = "chip-by-rule" } } }; + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), tier, Denying("Pet.Microchip")).ToList(new Filter()).Data), "chip-by-rule")); + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), tier, Denying("Pet.Microchip")).ToList(Selecting("Id", "Pet")).Data), "chip-by-rule")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P5_control_the_entity_query_enforces_the_rule(DwTier tier) + { + Assert.True(Holds(_db.Loans.Include(l => l.Org).AsNoTracking().ToList(), "swift-by-rule")); + + Assert.False(Holds(SafeRead(() => Guard(_db.Loans.Include(l => l.Org), tier, Denying("Org.Swift")).ToList(new Filter()).Data), "swift-by-rule")); + Assert.False(Holds(SafeRead(() => Guard(_db.Loans, tier, Denying("Org.Swift")).ToList(Selecting("Id", "Org")).Data), "swift-by-rule")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P5_a_rule_on_a_subtype_field_through_a_projected_member(DwTier tier) + { + IQueryable rows = _db.Loans.Select(l => new ZrLoanRow { Id = l.Id, Note = l.Note, Org = l.Org }); + + Assert.False(Holds(SafeRead(() => Guard(rows, tier, Denying("Org.Swift")).ToList(new Filter()).Data), "swift-by-rule")); + Assert.False(Holds(SafeRead(() => Guard(rows, tier, Denying("Org.Swift")).ToList(Selecting("Id", "Org")).Data), "swift-by-rule")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P5_a_rule_on_a_subtype_field_through_the_composable_Select(DwTier tier) + { + Assert.False(Holds( + SafeRead(() => Guard(_db.Loans, tier, Denying("Org.Swift")).Select(new List { "Id", "Org" }).ToList(new Filter()).Data), + "swift-by-rule")); + } + + // ------------------------------------------------------------------------------------------------ P6 + + [Fact] + public void Zr_P6_a_Concat_of_two_projections_assigning_different_members() + { + IQueryable rows = _db.Employees + .Select(e => new ZrAcctRow { Id = e.Id, Name = e.Name }) + .Concat(_db.Employees.Select(e => new ZrAcctRow + { + Id = e.Id, + Name = e.Name, + Person = new ZrPersonView { Name = e.Name, TaxId = "concat-secret" } + })); + + Exception? unguarded = Record.Exception(() => rows.ToList()); + + if (unguarded is not null) + { + // EF Core cannot run it at all, so nothing can leak through it. + return; + } + + Assert.False(Holds(SafeRead(() => Guard(rows).ToList(new Filter()).Data), "concat-secret")); + } + + // ------------------------------------------------------------------------------------------------ P8 + + [Fact] + public void Zr_P8_two_members_differing_only_in_letter_case() + { + ZrCaseRow[] rows = { new() { Id = 1, INFO = new ZrCardInfo { Pan = "case-secret" }, Info = new ZrSafeInfo { Label = "ok" } } }; + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable()).ToList(new Filter()).Data), "case-secret")); + } + + // ------------------------------------------------------------------------------------------------ P9 + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P9_including_a_skip_navigation_does_not_carry_the_join_entity_payload(DwTier tier) + { + IQueryable source = _db.Posts.Include(p => p.Tags); + + bool unguardedLeaks = Holds(source.AsNoTracking().ToList(), "join-secret"); + + Assert.False(Holds(SafeRead(() => Guard(source, tier).ToList(new Filter()).Data), "join-secret"), + $"unguarded leaks: {unguardedLeaks}"); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewLeakTests2.cs b/DynamicWhere.Tests/Policies/ReviewLeakTests2.cs new file mode 100644 index 0000000..80f0fea --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewLeakTests2.cs @@ -0,0 +1,450 @@ +using System.Collections; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================ round 3, batch 2: models + + // ---- P10: a derived type overrides a member its base declares, and denies it there ---------------- + + public class ZrVehicle + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public virtual string? Code { get; set; } + } + + public class ZrArmored : ZrVehicle + { + /// Overridden to deny it here; it reads and writes the base's storage, which EF Core maps. + [DwDenied] + public override string? Code + { + get => base.Code; + set => base.Code = value; + } + } + + public class ZrConvoy + { + public int Id { get; set; } + + public string Route { get; set; } = string.Empty; + + public int ZrVehicleId { get; set; } + + public ZrVehicle? Lead { get; set; } + } + + public class ZrConvoyRow + { + public int Id { get; set; } + + public ZrVehicle? Lead { get; set; } + } + + // ---- P11: a projection that builds a subtype of T -------------------------------------------------- + + public class ZrOrgDto + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class ZrBankDto : ZrOrgDto + { + [DwDenied] + public string? Swift { get; set; } + } + + // ---- P3d: a base type whose only subtype is generic, under a policy denying what it does not name --- + + public class ZrShape + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + } + + public class ZrShapeOf : ZrShape + { + public string? Hidden { get; set; } + } + + public class ZrCanvas + { + public int Id { get; set; } + + public ZrShape? Shape { get; set; } + } + + // ---- P12: an asynchronous lazy-loader delegate ------------------------------------------------------ + + public class ZrAsyncBlog + { + private readonly Func? _loader; + private List? _posts; + + public ZrAsyncBlog() + { + } + + private ZrAsyncBlog(Func lazyLoader) => _loader = lazyLoader; + + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Posts + { + get + { + _loader?.Invoke(this, CancellationToken.None, nameof(Posts)).GetAwaiter().GetResult(); + + return _posts ??= new List(); + } + set => _posts = value; + } + + public bool HasLoader => _loader is not null; + } + + public class ZrAsyncPost + { + public int Id { get; set; } + + public int ZrAsyncBlogId { get; set; } + + public string Title { get; set; } = string.Empty; + + [DwDenied] + public string? Draft { get; set; } + } + + public sealed class ZrProbeContext2 : DbContext + { + private readonly SqliteConnection _connection; + + public ZrProbeContext2(SqliteConnection connection) => _connection = connection; + + public DbSet Vehicles => Set(); + + public DbSet Convoys => Set(); + + public DbSet AsyncBlogs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity(); + model.Entity().Navigation(b => b.Posts).HasField("_posts"); + model.Entity().Ignore(b => b.HasLoader); + } + } + + // ============================================================================ round 3, batch 2: tests + + public sealed class ReviewLeakTests2 : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZrProbeContext2 _db; + + public ReviewLeakTests2(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _db = new ZrProbeContext2(_connection); + _db.Database.EnsureCreated(); + + _db.Convoys.Add(new ZrConvoy { Route = "R1", Lead = new ZrArmored { Name = "A1", Code = "override-secret" } }); + _db.AsyncBlogs.Add(new ZrAsyncBlog { Name = "B1", Posts = { new ZrAsyncPost { Title = "T", Draft = "async-draft" } } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict, params IDwPolicyProvider[] more) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }.Concat(more).ToArray())); + + private static Filter Selecting(params string[] fields) => new() { Selects = fields.ToList() }; + + private static FakePolicyProvider DenyingAllBut(params string[] allowed) + { + FakePolicyProvider rules = new FakePolicyProvider() + .Add("*", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + foreach (string path in allowed) + { + rules.Add(path, PolicyFeature.Select, PolicyEffect.Allow, PolicyLevel.DynamicGlobal); + } + + return rules; + } + + private static bool Holds(object? value, string text) + { + HashSet seen = new(ReferenceEqualityComparer.Instance); + Stack pending = new(); + + pending.Push(value); + + while (pending.Count > 0) + { + object? current = pending.Pop(); + + if (current is null || current is ValueType || current is MemberInfo) + { + continue; + } + + if (current is string held) + { + if (held == text) + { + return true; + } + + continue; + } + + if (!seen.Add(current)) + { + continue; + } + + if (current is IEnumerable items) + { + foreach (object? item in items) + { + pending.Push(item); + } + + continue; + } + + foreach (PropertyInfo property in current.GetType().GetProperties()) + { + if (property.GetIndexParameters().Length != 0 || !property.CanRead) + { + continue; + } + + try + { + pending.Push(property.GetValue(current)); + } + catch + { + // A getter that throws holds nothing readable. + } + } + } + + return false; + } + + private static object? SafeRead(Func read) + { + try + { + return read(); + } + catch (PolicyException refusal) when (refusal.ErrorCode == PolicyErrorCode.FieldDeniedForSelect) + { + return null; + } + } + + // ------------------------------------------------------------------------------------------------ P10 + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P10_rows_in_memory_of_a_subtype_that_denies_an_overridden_member(DwTier tier) + { + ZrVehicle[] rows = { new ZrArmored { Id = 1, Name = "A1", Code = "override-secret" } }; + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), tier).ToList(new Filter()).Data), "override-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P10_an_entity_hierarchy_root_whose_derived_type_denies_an_overridden_column(DwTier tier) + { + Assert.True(Holds(_db.Vehicles.AsNoTracking().ToList(), "override-secret")); + + Assert.False(Holds(SafeRead(() => Guard(_db.Vehicles, tier).ToList(new Filter()).Data), "override-secret")); + Assert.False(Holds(SafeRead(() => Guard(_db.Vehicles, tier).ToListDynamic(new Filter()).Data), "override-secret")); + } + + /// The same member read through the base type is also groupable, so a summary returns it as a key. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P10_a_summary_grouped_by_the_overridden_member(DwTier tier) + { + Summary summary = new() + { + GroupBy = new DynamicWhere.ex.Classes.Core.GroupBy + { + Fields = new List { "Code" }, + AggregateBy = new List + { + new() { Alias = "Total", Aggregator = DynamicWhere.ex.Enums.Aggregator.Count } + } + } + }; + + string sent; + + try + { + sent = System.Text.Json.JsonSerializer.Serialize(Guard(_db.Vehicles, tier).ToList(summary).Data); + } + catch (PolicyException refusal) when (refusal.ErrorCode is PolicyErrorCode.FieldDeniedForGroup or PolicyErrorCode.FieldDeniedForSelect) + { + return; + } + + _out.WriteLine(sent); + + Assert.DoesNotContain("override-secret", sent); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P10_an_included_base_typed_navigation(DwTier tier) + { + IQueryable source = _db.Convoys.Include(c => c.Lead); + + Assert.True(Holds(source.AsNoTracking().ToList(), "override-secret")); + + Assert.False(Holds(SafeRead(() => Guard(source, tier).ToList(new Filter()).Data), "override-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P10_a_base_typed_navigation_named_in_Selects(DwTier tier) + { + Assert.False(Holds(SafeRead(() => Guard(_db.Convoys, tier).ToList(Selecting("Id", "Lead")).Data), "override-secret")); + Assert.False(Holds(SafeRead(() => Guard(_db.Convoys, tier).ToList(Selecting("Id", "Lead.Code")).Data), "override-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P10_control_a_projected_base_typed_member_is_scanned(DwTier tier) + { + IQueryable rows = _db.Convoys.Select(c => new ZrConvoyRow { Id = c.Id, Lead = c.Lead }); + + Assert.False(Holds(SafeRead(() => Guard(rows, tier).ToList(new Filter()).Data), "override-secret")); + } + + // ------------------------------------------------------------------------------------------------ P11 + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P11_a_projection_that_builds_a_subtype_of_T(DwTier tier) + { + // A repository returning IQueryable from a projection into the derived DTO. + IQueryable covariant = _db.Convoys.Select(c => new ZrBankDto { Id = c.Id, Name = c.Route, Swift = c.Route + "-swift-secret" }); + IQueryable declared = _db.Convoys.Select(c => new ZrBankDto { Id = c.Id, Name = c.Route, Swift = c.Route + "-swift-secret" }); + + Assert.True(Holds(covariant.ToList(), "R1-swift-secret")); + + Assert.False(Holds(SafeRead(() => Guard(covariant, tier).ToList(new Filter()).Data), "R1-swift-secret")); + Assert.False(Holds(SafeRead(() => Guard(declared, tier).ToList(new Filter()).Data), "R1-swift-secret")); + Assert.False(Holds(SafeRead(() => Guard(covariant, tier).ToListDynamic(new Filter()).Data), "R1-swift-secret")); + } + + [Fact] + public void Zr_P11_control_the_same_rows_in_memory_are_projected() + { + ZrOrgDto[] rows = { new ZrBankDto { Id = 1, Name = "R1", Swift = "R1-swift-secret" } }; + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable()).ToList(new Filter()).Data), "R1-swift-secret")); + } + + // ------------------------------------------------------------------------------------------------ P3d + + [Fact] + public void Zr_P3d_a_generic_only_subtype_under_a_deny_by_default_policy() + { + ZrCanvas[] rows = { new() { Id = 1, Shape = new ZrShapeOf { Id = 2, Kind = "k", Hidden = "unnamed-secret" } } }; + + FakePolicyProvider rules = DenyingAllBut("Id", "Shape", "Shape.Id", "Shape.Kind"); + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), DwTier.Strict, rules).ToList(Selecting("Id", "Shape")).Data), "unnamed-secret")); + } + + [Fact] + public void Zr_P3d_control_a_non_generic_subtype_under_the_same_policy_is_refused() + { + ZrPen[] rows = { new() { Id = 1, Label = "pen", Pet = new ZrCat { Id = 2, Name = "Tom", Microchip = "unnamed-secret" } } }; + + FakePolicyProvider rules = DenyingAllBut("Id", "Pet", "Pet.Id", "Pet.Name"); + + Assert.False(Holds(SafeRead(() => Guard(rows.AsQueryable(), DwTier.Strict, rules).ToList(Selecting("Id", "Pet")).Data), "unnamed-secret")); + } + + // ------------------------------------------------------------------------------------------------ P12 + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P12_an_asynchronous_lazy_loader_delegate(DwTier tier) + { + ZrAsyncBlog unguarded = _db.AsyncBlogs.AsNoTracking().ToList().Single(); + + try + { + _out.WriteLine($"EF injected the async loader: {unguarded.HasLoader}; unguarded posts: {unguarded.Posts.Count}"); + } + catch (InvalidOperationException detached) + { + // EF Core 6 refuses to lazy-load an entity read with AsNoTracking, so nothing can load after the query. + _out.WriteLine($"no lazy loading of an untracked entity here: {detached.Message}"); + + return; + } + + ZrAsyncBlog guarded = Guard(_db.AsyncBlogs, tier).ToList(new Filter()).Data.Single(); + + _out.WriteLine($"guarded row has loader: {guarded.HasLoader}"); + + Assert.DoesNotContain(guarded.Posts, post => post.Draft == "async-draft"); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewLeakTests3.cs b/DynamicWhere.Tests/Policies/ReviewLeakTests3.cs new file mode 100644 index 0000000..a0420f5 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewLeakTests3.cs @@ -0,0 +1,174 @@ +using System.Collections; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +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 +{ + // ============================================================================ round 3, batch 3: models + + /// An application collection that implements only the non-generic IEnumerable. + public sealed class ZrLooseBag : IEnumerable + { + private readonly List _items = new(); + + public void Add(object item) => _items.Add(item); + + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + } + + public class ZrVoucher + { + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Code { get; set; } + } + + public class ZrWallet + { + public int Id { get; set; } + + public string Owner { get; set; } = string.Empty; + + public ZrLooseBag Vouchers { get; set; } = new(); + } + + // ============================================================================ round 3, batch 3: tests + + public sealed class ReviewLeakTests3 : IDisposable + { + private readonly SqliteConnection _connection; + private readonly ZrProbeContext _db; + + public ReviewLeakTests3() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _db = new ZrProbeContext(_connection); + _db.Database.EnsureCreated(); + + _db.Loans.Add(new ZrLoan { Note = "gen", Org = new ZrGenOrg { Name = "G", Secret = "generic-entity-secret" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict, params IDwPolicyProvider[] more) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }.Concat(more).ToArray())); + + private static Filter Selecting(params string[] fields) => new() { Selects = fields.ToList() }; + + private static bool Holds(object? value, string text) + { + HashSet seen = new(ReferenceEqualityComparer.Instance); + Stack pending = new(); + + pending.Push(value); + + while (pending.Count > 0) + { + object? current = pending.Pop(); + + if (current is null || current is ValueType || current is MemberInfo) + { + continue; + } + + if (current is string held) + { + if (held == text) + { + return true; + } + + continue; + } + + if (!seen.Add(current)) + { + continue; + } + + if (current is IEnumerable items) + { + foreach (object? item in items) + { + pending.Push(item); + } + + continue; + } + + foreach (PropertyInfo property in current.GetType().GetProperties()) + { + if (property.GetIndexParameters().Length != 0 || !property.CanRead) + { + continue; + } + + try + { + pending.Push(property.GetValue(current)); + } + catch + { + // A getter that throws holds nothing readable. + } + } + } + + return false; + } + + private static object? SafeRead(Func read) + { + try + { + return read(); + } + catch (PolicyException refusal) when (refusal.ErrorCode == PolicyErrorCode.FieldDeniedForSelect) + { + return null; + } + } + + // ------------------------------------------------------------------------------------------------ P3e + + /// + /// An entity query re-rooted by Select reads no model, so its derived types come from the subtype search, + /// which never lists a generic one. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P3e_an_entity_query_re_rooted_by_Select_holding_a_closed_generic_derived_entity(DwTier tier) + { + IQueryable source = _db.Loans.Select(l => l.Org!); + + Assert.True(Holds(source.AsNoTracking().ToList(), "generic-entity-secret")); + + Assert.False(Holds(SafeRead(() => Guard(source, tier).ToList(new Filter()).Data), "generic-entity-secret")); + } + + // ------------------------------------------------------------------------------------------------ P13 + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewLeakTests4.cs b/DynamicWhere.Tests/Policies/ReviewLeakTests4.cs new file mode 100644 index 0000000..84c30bd --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewLeakTests4.cs @@ -0,0 +1,142 @@ +using DynamicWhere.ex.Classes.Complex; +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 +{ + // ---- P10b: an interface declares the member; the implementing class denies it ------------------------- + + public interface IZrAccount + { + int Id { get; } + + string Holder { get; } + + string? Iban { get; } + } + + public class ZrAccountEntity : IZrAccount + { + public int Id { get; set; } + + public string Holder { get; set; } = string.Empty; + + [DwDenied] + public string? Iban { get; set; } + } + + public class ZrStatement + { + public int Id { get; set; } + + public IZrAccount? Account { get; set; } + } + + public sealed class ZrProbeContext4 : DbContext + { + private readonly SqliteConnection _connection; + + public ZrProbeContext4(SqliteConnection connection) => _connection = connection; + + public DbSet Accounts => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + public sealed class ReviewLeakTests4 : IDisposable + { + private readonly SqliteConnection _connection; + private readonly ZrProbeContext4 _db; + + public ReviewLeakTests4() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZrProbeContext4(_connection); + _db.Database.EnsureCreated(); + _db.Accounts.Add(new ZrAccountEntity { Holder = "H", Iban = "iban-secret" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static bool Leaks(Func> read) + { + try + { + return read().Any(row => row is ZrAccountEntity { Iban: "iban-secret" } + || row is ZrStatement { Account: ZrAccountEntity { Iban: "iban-secret" } }); + } + catch (PolicyException refusal) when (refusal.ErrorCode is PolicyErrorCode.FieldDeniedForSelect or PolicyErrorCode.AllSelectsDenied) + { + return false; + } + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P10b_rows_in_memory_read_through_the_interface_that_declares_the_denied_member(DwTier tier) + { + IZrAccount[] rows = { new ZrAccountEntity { Id = 1, Holder = "H", Iban = "iban-secret" } }; + + Assert.False(Leaks(() => Guard(rows.AsQueryable(), tier).ToList(new Filter()).Data)); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P10b_an_entity_query_read_through_the_interface(DwTier tier) + { + IQueryable source = _db.Accounts; + + Exception? unguarded = Record.Exception(() => source.AsNoTracking().ToList()); + + if (unguarded is not null) + { + // EF Core cannot run the query through the interface at all. + return; + } + + Assert.False(Leaks(() => Guard(source, tier).ToList(new Filter()).Data)); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P10b_a_member_typed_as_the_interface(DwTier tier) + { + ZrStatement[] rows = { new() { Id = 1, Account = new ZrAccountEntity { Id = 2, Holder = "H", Iban = "iban-secret" } } }; + + Assert.False(Leaks(() => Guard(rows.AsQueryable(), tier).ToList(new Filter()).Data)); + Assert.False(Leaks(() => Guard(rows.AsQueryable(), tier).ToList(new Filter { Selects = new List { "Id", "Account" } }).Data)); + } + + [Fact] + public void Zr_P10b_control_the_class_itself_is_policed() + { + ZrAccountEntity[] rows = { new() { Id = 1, Holder = "H", Iban = "iban-secret" } }; + + Assert.False(Leaks(() => Guard(rows.AsQueryable()).ToList(new Filter()).Data)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewLeakTests5.cs b/DynamicWhere.Tests/Policies/ReviewLeakTests5.cs new file mode 100644 index 0000000..920786e --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewLeakTests5.cs @@ -0,0 +1,121 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- P12b: an entity that takes the DbContext in its constructor and loads its navigation with it ----- + + public class ZrCtxBlog + { + private readonly ZrProbeContext5? _context; + private List? _posts; + + public ZrCtxBlog() + { + } + + private ZrCtxBlog(ZrProbeContext5 context) => _context = context; + + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Loaded on first read through the context EF Core injected, the pattern EF Core documents. + public List Posts + { + get => _posts ??= _context?.Set().AsNoTracking().Where(p => p.ZrCtxBlogId == Id).ToList() + ?? new List(); + set => _posts = value; + } + } + + public class ZrCtxPost + { + public int Id { get; set; } + + public int ZrCtxBlogId { get; set; } + + public string Title { get; set; } = string.Empty; + + [DwDenied] + public string? Draft { get; set; } + } + + public sealed class ZrProbeContext5 : DbContext + { + private readonly SqliteConnection _connection; + + public ZrProbeContext5(SqliteConnection connection) => _connection = connection; + + public DbSet Blogs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) => + model.Entity().Navigation(b => b.Posts).HasField("_posts"); + } + + public sealed class ReviewLeakTests5 : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZrProbeContext5 _db; + + public ReviewLeakTests5(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZrProbeContext5(_connection); + _db.Database.EnsureCreated(); + _db.Blogs.Add(new ZrCtxBlog { Name = "B1", Posts = { new ZrCtxPost { Title = "T", Draft = "context-draft" } } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P12b_a_navigation_its_entity_loads_through_an_injected_DbContext(DwTier tier) + { + ZrCtxBlog unguarded = _db.Blogs.AsNoTracking().ToList().Single(); + + _out.WriteLine($"unguarded drafts: {string.Join(",", unguarded.Posts.Select(p => p.Draft))}"); + + ZrCtxBlog guarded; + + try + { + guarded = Guard(_db.Blogs, tier).ToList(new Filter()).Data.Single(); + } + catch (PolicyException refusal) when (refusal.ErrorCode == PolicyErrorCode.FieldDeniedForSelect) + { + return; + } + + Assert.DoesNotContain(guarded.Posts, post => post.Draft == "context-draft"); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewLeakTests6.cs b/DynamicWhere.Tests/Policies/ReviewLeakTests6.cs new file mode 100644 index 0000000..5c6ea69 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewLeakTests6.cs @@ -0,0 +1,96 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ---- P1c: a top-level denial forces the projection, and the owned member is still kept whole ----------- + + public class ZrClinic2 + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwDenied] + public string? Notes { get; set; } + + public ZrContact Contact { get; set; } = new(); + } + + public sealed class ZrProbeContext6 : DbContext + { + private readonly SqliteConnection _connection; + + public ZrProbeContext6(SqliteConnection connection) => _connection = connection; + + public DbSet Clinics => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) => + model.Entity().OwnsOne(c => c.Contact, o => o.Property("_phone")); + } + + public sealed class ReviewLeakTests6 : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZrProbeContext6 _db; + + public ReviewLeakTests6(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZrProbeContext6(_connection); + _db.Database.EnsureCreated(); + + ZrClinic2 clinic = new() { Name = "C1", Notes = "notes-secret", Contact = new ZrContact { City = "Basra" } }; + clinic.Contact.SetPhone("phone-secret"); + _db.Clinics.Add(clinic); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zr_P1c_an_owned_member_kept_whole_by_a_synthesized_projection(DwTier tier) + { + PolicyQueryable guarded = _db.Clinics.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + ZrClinic2 row = guarded.ToList(new Filter()).Data.Single(); + + foreach (PolicyDecision decision in guarded.LastTrace!.Decisions) + { + _out.WriteLine($"trace: {decision.FieldPath} {decision.Feature} {decision.Action} {decision.Reason}"); + } + + // The projection ran: the top-level denial is withheld. + Assert.Null(row.Notes); + Assert.Equal("Basra", row.Contact.City); + + // And the owned member it kept whole carries the value its unmapped getter exposes. + Assert.Null(row.Contact.Phone.Number); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewLeakTests7.cs b/DynamicWhere.Tests/Policies/ReviewLeakTests7.cs new file mode 100644 index 0000000..6a32ad8 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewLeakTests7.cs @@ -0,0 +1,228 @@ +using System.Collections; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; + +namespace DynamicWhere.Tests.Policies +{ + // ---- an interface a subtype adds over a member it inherits --------------------------------------------- + + public interface IZwCarded + { + [DwDenied] + string? Pan { get; } + } + + public class ZwAccount + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? Pan { get; set; } + } + + /// Declares nothing of its own: the base class's member implements the denied interface member. + public class ZwCardAccount : ZwAccount, IZwCarded + { + } + + // ---- an interface implemented by an override whose base declaration denies ---------------------------- + + public interface IZwPinned + { + int Id { get; } + + string? Pin { get; } + } + + public class ZwPinBase + { + public int Id { get; set; } + + [DwDenied] + public virtual string? Pin { get; set; } + } + + public class ZwPinned : ZwPinBase, IZwPinned + { + public override string? Pin + { + get => base.Pin; + set => base.Pin = value; + } + } + + public class ZwPinHolder + { + public int Id { get; set; } + + public string Label { get; set; } = string.Empty; + + public List Pins { get; set; } = new(); + } + + // ---- one class implementing two instantiations of a generic interface, one of them denied -------------- + + public interface IZwSlot + { + string? Code { get; } + } + + public class ZwTwoSlots : IZwSlot, IZwSlot + { + public int Id { get; set; } + + public string? Open { get; set; } + + public string? Sealed { get; set; } + + string? IZwSlot.Code => Open; + + [DwDenied] + string? IZwSlot.Code => Sealed; + } + + /// + /// A denial on another declaration of a member than the one walked: an interface a subtype adds over a + /// member it inherits, and an implementation that is an override of a denied member. + /// + public class ReviewLeakTests7 + { + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static Filter Where(string field, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Field = field, DataType = DataType.Text, Operator = Operator.Equal, Values = { value } } } + } + }; + + /// True when a value, read by runtime type and through every collection, holds the text. + private static bool Holds(object? value, string text) + { + HashSet seen = new(ReferenceEqualityComparer.Instance); + Stack pending = new(); + + pending.Push(value); + + while (pending.Count > 0) + { + object? current = pending.Pop(); + + if (current is null || current is ValueType) + { + continue; + } + + if (current is string held) + { + if (held == text) + { + return true; + } + + continue; + } + + if (!seen.Add(current)) + { + continue; + } + + if (current is IEnumerable items) + { + foreach (object? item in items) + { + pending.Push(item); + } + + continue; + } + + foreach (PropertyInfo property in current.GetType().GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (property.GetIndexParameters().Length == 0 && property.CanRead) + { + pending.Push(property.GetValue(current)); + } + } + } + + return false; + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void An_interface_a_subtype_adds_over_an_inherited_member_denies_the_base_path(DwTier tier) + { + ZwAccount[] rows = { new ZwCardAccount { Id = 1, Name = "a", Pan = "inherited-pan-secret" } }; + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(rows.AsQueryable(), tier).ToList(Where("Pan", "inherited-pan-secret"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + Assert.False(Holds(Guard(rows.AsQueryable(), tier).ToList(new Filter()).Data, "inherited-pan-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void An_implementation_that_overrides_a_denied_member_denies_the_interface_path(DwTier tier) + { + IZwPinned[] rows = { new ZwPinned { Id = 1, Pin = "override-pin-secret" } }; + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(rows.AsQueryable(), tier).ToList(Where("Pin", "override-pin-secret"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_list_of_the_interface_is_not_returned_holding_the_denied_value(DwTier tier) + { + ZwPinHolder[] rows = + { + new() { Id = 1, Label = "desk", Pins = { new ZwPinned { Id = 2, Pin = "listed-pin-secret" } } } + }; + + object? data = Guard(rows.AsQueryable(), tier).ToList(new Filter()).Data; + + Assert.False(Holds(data, "listed-pin-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void A_denial_on_one_instantiation_of_a_generic_interface_leaves_the_other_alone(DwTier tier) + { + ZwTwoSlots row = new() { Id = 1, Open = "open", Sealed = "sealed-secret" }; + IZwSlot[] open = { row }; + IZwSlot[] sealedRows = { row }; + + Assert.Single(Guard(open.AsQueryable(), tier).ToList(Where("Code", "open")).Data); + + PolicyException refusal = Assert.ThrowsAny( + () => Guard(sealedRows.AsQueryable(), tier).ToList(Where("Code", "sealed-secret"))); + + Assert.Equal(PolicyErrorCode.FieldDeniedForWhere, refusal.ErrorCode); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewObjectMemberTests.cs b/DynamicWhere.Tests/Policies/ReviewObjectMemberTests.cs new file mode 100644 index 0000000..363fcc8 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewObjectMemberTests.cs @@ -0,0 +1,453 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations.Schema; +using System.Reflection; +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================ members that can hold any object: models + + /// The application type an event's payload column is converted to and from. + public class ZvCardIssued + { + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pan { get; set; } + } + + /// An event whose payload column is typed object and converted to an application type. + public class ZvEvent + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + + public object? Payload { get; set; } + } + + /// The same, with a top-level denial that asks for a projection on its own. + public class ZvAuditedEvent + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + + [DwDenied] + public string? Actor { get; set; } + + public object? Payload { get; set; } + } + + /// An owned member holding the converted payload, beside a top-level denial. + public class ZvEnvelope + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + + [DwDenied] + public string? Actor { get; set; } + + public ZvMeta Meta { get; set; } = new(); + } + + public class ZvMeta + { + public string Tag { get; set; } = string.Empty; + + public object? Payload { get; set; } + } + + /// An unmapped getter typed object, over a private automatically included navigation. + public class ZvKeyring + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + private List Keys { get; set; } = new(); + + [NotMapped] + public object Items => Keys; + + public void Add(ZvKey key) => Keys.Add(key); + } + + /// Control: the same getter typed as the element list, which the policy reads. + public class ZvTypedKeyring + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + private List Keys { get; set; } = new(); + + [NotMapped] + public IReadOnlyList Items => Keys; + + public void Add(ZvTypedKey key) => Keys.Add(key); + } + + public class ZvKey + { + public int Id { get; set; } + + public int ZvKeyringId { get; set; } + + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Secret { get; set; } + } + + public class ZvTypedKey + { + public int Id { get; set; } + + public int ZvTypedKeyringId { get; set; } + + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Secret { get; set; } + } + + /// + /// An entity whose unmapped member the application fills once EF Core has read it, beside a top-level denial. + /// + public class ZvNotedEvent + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + + [DwDenied] + public string? Actor { get; set; } + + [NotMapped] + public object? Note { get; set; } + } + + /// The same, one level down: an owned member whose unmapped member the application fills. + public class ZvNoteHolder + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + + [DwDenied] + public string? Actor { get; set; } + + public ZvNoteMeta Meta { get; set; } = new(); + } + + public class ZvNoteMeta + { + public string Tag { get; set; } = string.Empty; + + [NotMapped] + public object? Note { get; set; } + } + + public sealed class ZvObjectContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZvObjectContext(SqliteConnection connection) => _connection = connection; + + public DbSet Events => Set(); + + public DbSet AuditedEvents => Set(); + + public DbSet Envelopes => Set(); + + public DbSet Keyrings => Set(); + + public DbSet TypedKeyrings => Set(); + + public DbSet NotedEvents => Set(); + + public DbSet NoteHolders => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + private static string Write(object? value) => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null); + + private static object? Read(string text) => JsonSerializer.Deserialize(text, (JsonSerializerOptions?)null); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().Property(e => e.Payload).HasConversion(v => Write(v), s => Read(s)); + model.Entity().Property(e => e.Payload).HasConversion(v => Write(v), s => Read(s)); + model.Entity().OwnsOne(e => e.Meta, m => m.Property(x => x.Payload).HasConversion(v => Write(v), s => Read(s))); + model.Entity().OwnsOne(h => h.Meta); + model.Entity(b => + { + b.HasMany("Keys").WithOne().HasForeignKey(k => k.ZvKeyringId); + b.Navigation("Keys").AutoInclude(); + }); + model.Entity(b => + { + b.HasMany("Keys").WithOne().HasForeignKey(k => k.ZvTypedKeyringId); + b.Navigation("Keys").AutoInclude(); + }); + } + } + + // ============================================================================ members that can hold any object: tests + + /// + /// A member typed object asks for no projection. What EF Core materializes holds no application object, but a + /// converted column can, so once a projection is built for another reason it is left out rather than kept + /// whole; an unmapped getter typed as the list it hands out is read as that list. + /// + public sealed class ReviewObjectMemberTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZvObjectContext _db; + + public ReviewObjectMemberTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZvObjectContext(_connection); + _db.Database.EnsureCreated(); + + _db.Events.Add(new ZvEvent { Kind = "CardIssued", Payload = new ZvCardIssued { Label = "visa", Pan = "event-pan-secret" } }); + _db.AuditedEvents.Add(new ZvAuditedEvent + { + Kind = "CardIssued", Actor = "actor-secret", Payload = new ZvCardIssued { Label = "visa", Pan = "audited-pan-secret" } + }); + _db.Envelopes.Add(new ZvEnvelope + { + Kind = "CardIssued", Actor = "actor-secret", + Meta = new ZvMeta { Tag = "t", Payload = new ZvCardIssued { Label = "visa", Pan = "owned-pan-secret" } } + }); + + ZvKeyring keyring = new() { Name = "K1" }; + keyring.Add(new ZvKey { Label = "front", Secret = "key-secret" }); + _db.Keyrings.Add(keyring); + + ZvTypedKeyring typed = new() { Name = "K2" }; + typed.Add(new ZvTypedKey { Label = "back", Secret = "typed-key-secret" }); + _db.TypedKeyrings.Add(typed); + + _db.NotedEvents.Add(new ZvNotedEvent { Kind = "Noted", Actor = "noted-actor-secret" }); + _db.NoteHolders.Add(new ZvNoteHolder { Kind = "Held", Actor = "holder-actor-secret", Meta = new ZvNoteMeta { Tag = "t1" } }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static bool Holds(object? value, string text) + { + HashSet seen = new(ReferenceEqualityComparer.Instance); + Stack pending = new(); + + pending.Push(value); + + while (pending.Count > 0) + { + object? current = pending.Pop(); + + if (current is null || current is ValueType || current is MemberInfo) + { + continue; + } + + if (current is string held) + { + if (held == text) + { + return true; + } + + continue; + } + + if (!seen.Add(current)) + { + continue; + } + + if (current is IEnumerable items) + { + foreach (object? item in items) + { + pending.Push(item); + } + + continue; + } + + foreach (PropertyInfo property in current.GetType().GetProperties()) + { + if (property.GetIndexParameters().Length != 0 || !property.CanRead) + { + continue; + } + + try + { + pending.Push(property.GetValue(current)); + } + catch + { + // A getter that throws holds nothing readable. + } + } + } + + return false; + } + + private static object? SafeRead(Func read) + { + try + { + return read(); + } + catch (PolicyException refusal) when (refusal.ErrorCode is PolicyErrorCode.FieldDeniedForSelect or PolicyErrorCode.AllSelectsDenied) + { + return null; + } + } + + private string Describe(PolicyQueryable guarded, object? data) where T : class => + "sent=" + JsonSerializer.Serialize(data) + " decisions=[" + + string.Join(" | ", guarded.LastTrace?.Decisions + .Where(d => d.Action != PolicyAction.Allowed) + .Select(d => $"{d.FieldPath} {d.Action}: {d.Reason}") ?? Array.Empty()) + "]"; + + // ------------------------------------------------------------------------ a converted column typed object + + /// + /// 3.1.0 synthesized a projection of scalars only whenever a top-level field was denied, so this column was + /// dropped with the denied Actor. Now a synthesized projection keeps an entity's object column whole. + /// + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_D5_a_converted_object_column_beside_a_top_level_denial(DwTier tier) + { + Assert.True(Holds(_db.AuditedEvents.AsNoTracking().ToList(), "audited-pan-secret")); + + PolicyQueryable guarded = Guard(_db.AuditedEvents, tier); + object? data = SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine(Describe(guarded, data)); + + Assert.False(Holds(data, "actor-secret")); + Assert.False(Holds(data, "audited-pan-secret")); + } + + /// An owned member holding the converted column, beside a top-level denial. + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_D5_an_owned_member_holding_a_converted_object_column_beside_a_top_level_denial(DwTier tier) + { + Assert.True(Holds(_db.Envelopes.AsNoTracking().ToList(), "owned-pan-secret")); + + PolicyQueryable guarded = Guard(_db.Envelopes, tier); + object? data = SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine(Describe(guarded, data)); + + Assert.False(Holds(data, "owned-pan-secret")); + } + + // ------------------------------------------------------------------------ an unmapped getter typed object + + [Fact] + public void Zv_D6_control_an_unmapped_getter_typed_as_the_list_is_read() + { + Assert.True(Holds(_db.TypedKeyrings.AsNoTracking().ToList(), "typed-key-secret")); + + PolicyQueryable guarded = Guard(_db.TypedKeyrings); + object? data = SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine(Describe(guarded, data)); + + Assert.False(Holds(data, "typed-key-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_D5_an_unmapped_member_the_application_fills_beside_a_top_level_denial(DwTier tier) + { + _db.ChangeTracker.Clear(); + _db.ChangeTracker.Tracked += (_, tracked) => + { + if (tracked.FromQuery && tracked.Entry.Entity is ZvNotedEvent noted) + { + noted.Note = new ZvCardIssued { Label = "memo", Pan = "unmapped-pan-secret" }; + } + }; + + Assert.True(Holds(_db.NotedEvents.ToList(), "unmapped-pan-secret")); + + _db.ChangeTracker.Clear(); + + object? data = Guard(_db.NotedEvents, tier).ToList(new Filter()).Data; + + Assert.False(Holds(data, "noted-actor-secret")); + Assert.False(Holds(data, "unmapped-pan-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_D5_an_owned_member_whose_unmapped_member_the_application_fills(DwTier tier) + { + _db.ChangeTracker.Clear(); + _db.ChangeTracker.Tracked += (_, tracked) => + { + if (tracked.FromQuery && tracked.Entry.Entity is ZvNoteMeta meta) + { + meta.Note = new ZvCardIssued { Label = "memo", Pan = "owned-unmapped-secret" }; + } + }; + + Assert.True(Holds(_db.NoteHolders.ToList(), "owned-unmapped-secret")); + + _db.ChangeTracker.Clear(); + + PolicyQueryable guarded = Guard(_db.NoteHolders, tier); + object? data = guarded.ToList(new Filter()).Data; + + _out.WriteLine(Describe(guarded, data)); + + Assert.False(Holds(data, "holder-actor-secret")); + Assert.False(Holds(data, "owned-unmapped-secret")); + Assert.Contains("t1", JsonSerializer.Serialize(data)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewOpaqueCollectionTests.cs b/DynamicWhere.Tests/Policies/ReviewOpaqueCollectionTests.cs new file mode 100644 index 0000000..301d96c --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewOpaqueCollectionTests.cs @@ -0,0 +1,180 @@ +using System.Collections; +using System.Reflection; +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================ an application class that implements IEnumerable + + /// + /// An application type with its own policed members that also enumerates, non-generically. It can hold + /// anything, and its own members are still read. + /// + public class ZvLedgerBag : IEnumerable + { + public string Owner { get; set; } = string.Empty; + + [DwDenied] + public string? AccountNo { get; set; } + + public IEnumerator GetEnumerator() => Array.Empty().GetEnumerator(); + } + + public class ZvBagHolder + { + public int Id { get; set; } + + public Dictionary Bags { get; set; } = new(); + } + + public class ZvBagBase + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class ZvBagSub : ZvBagBase + { + public ZvLedgerBag? Bag { get; set; } + } + + public sealed class ReviewOpaqueCollectionTests + { + private readonly ITestOutputHelper _out; + + public ReviewOpaqueCollectionTests(ITestOutputHelper output) => _out = output; + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + /// Everything reachable, read by runtime type; an IEnumerable is read as its properties too. + private static bool Holds(object? value, string text) + { + HashSet seen = new(ReferenceEqualityComparer.Instance); + Stack pending = new(); + + pending.Push(value); + + while (pending.Count > 0) + { + object? current = pending.Pop(); + + if (current is null || current is ValueType || current is MemberInfo) + { + continue; + } + + if (current is string held) + { + if (held == text) + { + return true; + } + + continue; + } + + if (!seen.Add(current)) + { + continue; + } + + if (current is IDictionary map) + { + foreach (object? item in map.Values) + { + pending.Push(item); + } + + continue; + } + + if (current is IEnumerable items && current.GetType().Namespace?.StartsWith("System", StringComparison.Ordinal) == true) + { + foreach (object? item in items) + { + pending.Push(item); + } + + continue; + } + + foreach (PropertyInfo property in current.GetType().GetProperties()) + { + if (property.GetIndexParameters().Length != 0 || !property.CanRead) + { + continue; + } + + try + { + pending.Push(property.GetValue(current)); + } + catch + { + // A getter that throws holds nothing readable. + } + } + } + + return false; + } + + private static object? SafeRead(Func read) + { + try + { + return read(); + } + catch (PolicyException refusal) when (refusal.ErrorCode is PolicyErrorCode.FieldDeniedForSelect or PolicyErrorCode.AllSelectsDenied) + { + return null; + } + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_E1_an_enumerable_application_type_inside_a_framework_generic(DwTier tier) + { + ZvBagHolder[] rows = { new() { Id = 1, Bags = { ["a"] = new ZvLedgerBag { Owner = "o", AccountNo = "acct-secret" } } } }; + + PolicyQueryable guarded = Guard(rows.AsQueryable(), tier); + object? data = SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine("holds: " + Holds(data, "acct-secret") + "; decisions: " + + string.Join(" | ", guarded.LastTrace?.Decisions.Where(d => d.Action != PolicyAction.Allowed).Select(d => $"{d.FieldPath} {d.Action}") ?? Array.Empty())); + + Assert.False(Holds(data, "acct-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_E2_rows_in_memory_whose_subtype_holds_an_enumerable_application_type(DwTier tier) + { + ZvBagBase[] rows = { new ZvBagSub { Id = 1, Name = "n", Bag = new ZvLedgerBag { Owner = "o", AccountNo = "acct-secret" } } }; + + PolicyQueryable guarded = Guard(rows.AsQueryable(), tier); + object? data = SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine("holds: " + Holds(data, "acct-secret") + "; types: " + + string.Join(",", ((IEnumerable?)data ?? Array.Empty()).Cast().Select(r => r.GetType().Name))); + + Assert.False(Holds(data, "acct-secret")); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewOverBlockTests.cs b/DynamicWhere.Tests/Policies/ReviewOverBlockTests.cs new file mode 100644 index 0000000..df7bda1 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewOverBlockTests.cs @@ -0,0 +1,994 @@ +using System.Collections; +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; +using Zb.Geo; + +// Probes for over-blocking in 6c9e171. Every test asserts what an ordinary query should get when +// nothing denied can reach the result (or what 3.1.0 / f7e1cc6 returned), so a failing test is a +// candidate over-block. Each test also writes its outcome, run with a detailed console logger. + +namespace Zb.Geo +{ + /// + /// Shaped like NetTopologySuite's Geometry: an application-namespace type with an object member + /// (NTS has Geometry.UserData) and subtypes. + /// + public abstract class ZbGeometry + { + public int Srid { get; set; } + + public object? UserData { get; set; } + } + + public class ZbPoint : ZbGeometry + { + public double X { get; set; } + + public double Y { get; set; } + } + + public class ZbPolygon : ZbGeometry + { + public List Shell { get; set; } = new(); + } +} + +namespace DynamicWhere.Tests.Policies +{ + // ------------------------------------------------------------ columns holding objects, no denials + + public class ZbTicket + { + public int Id { get; set; } + + public string Title { get; set; } = string.Empty; + + public Dictionary Extra { get; set; } = new(); + } + + public class ZbComment + { + public int Id { get; set; } + + public string Text { get; set; } = string.Empty; + + public int ZbTicketId { get; set; } + + public ZbTicket? Ticket { get; set; } + } + + public class ZbSensor + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public BitArray Flags { get; set; } = new(4); + } + + public class ZbSite + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public ZbPoint? Location { get; set; } + } + + public class ZbVisit + { + public int Id { get; set; } + + public string Note { get; set; } = string.Empty; + + public int ZbSiteId { get; set; } + + public ZbSite? Site { get; set; } + } + + public class ZbProfileHolder + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public ZbSettings Settings { get; set; } = new(); + } + + public class ZbSettings + { + public string Theme { get; set; } = string.Empty; + + public Dictionary Prefs { get; set; } = new(); + } + + // ------------------------------------------------------------ reshaped chains + + public class ZbCustomer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Cards { get; set; } = new(); + + public List Orders { get; set; } = new(); + } + + public class ZbCard + { + public int Id { get; set; } + + public int ZbCustomerId { get; set; } + + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pan { get; set; } + } + + public class ZbOrder + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public int ZbCustomerId { get; set; } + + public ZbCustomer? Customer { get; set; } + + public List Lines { get; set; } = new(); + } + + public class ZbLine + { + public int Id { get; set; } + + public int ZbOrderId { get; set; } + + public string Sku { get; set; } = string.Empty; + + public string? Cost { get; set; } + } + + /// A constructor-bound entity: EF Core binds it, and it has no parameterless constructor. + public class ZbMember + { + public ZbMember(int id, string name) + { + Id = id; + Name = name; + } + + public int Id { get; private set; } + + public string Name { get; private set; } + + public List Badges { get; private set; } = new(); + } + + public class ZbBadge + { + public int Id { get; set; } + + public int ZbMemberId { get; set; } + + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pin { get; set; } + } + + public class ZbLink + { + public int Id { get; set; } + + public int ZbMemberId { get; set; } + + public ZbMember? Member { get; set; } + } + + /// A shopper whose tier is auto-included and holds nothing denied; a denial sits beneath unloaded wallets. + public class ZbShopper + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public int ZbTierId { get; set; } + + public ZbTier? Tier { get; set; } + + public List Wallets { get; set; } = new(); + } + + public class ZbTier + { + public int Id { get; set; } + + public string Label { get; set; } = string.Empty; + } + + public class ZbWallet + { + public int Id { get; set; } + + public int ZbShopperId { get; set; } + + [DwDenied] + public string? Iban { get; set; } + } + + public class ZbBasket + { + public int Id { get; set; } + + public int ZbShopperId { get; set; } + + public ZbShopper? Shopper { get; set; } + } + + // ------------------------------------------------------------ hierarchies + + public abstract class ZbPayment + { + public int Id { get; set; } + + public long Cents { get; set; } + } + + public class ZbCardPayment : ZbPayment + { + [DwDenied] + public string? Pan { get; set; } + } + + public class ZbCashPayment : ZbPayment + { + public string Till { get; set; } = string.Empty; + } + + /// An abstract root; no denial anywhere, one derived type holds a JSON bag. + public abstract class ZbEvent + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + } + + public class ZbClickEvent : ZbEvent + { + public Dictionary Data { get; set; } = new(); + } + + public class ZbViewEvent : ZbEvent + { + public string Page { get; set; } = string.Empty; + } + + /// A concrete root; no denial anywhere, one derived type holds a JSON bag. + public class ZbDoc + { + public int Id { get; set; } + + public string Title { get; set; } = string.Empty; + } + + public class ZbRichDoc : ZbDoc + { + public Dictionary Meta { get; set; } = new(); + } + + // ------------------------------------------------------------ rows + + public interface IZbContact + { + string Name { get; set; } + } + + public class ZbContactRow : IZbContact + { + public string Name { get; set; } = string.Empty; + } + + /// Another implementor of the interface, somewhere in the application, with a denial. + public class ZbVipContact : IZbContact + { + public string Name { get; set; } = string.Empty; + + [DwDenied] + public string? Phone { get; set; } + } + + public class ZbCustomerRow + { + public int Id { get; set; } + + public IZbContact? Contact { get; set; } + } + + public class ZbPersonDto + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public ZbAddressDto? Address { get; set; } + } + + /// A subclass of the DTO elsewhere in the application, with a denial. + public class ZbStaffDto : ZbPersonDto + { + [DwDenied] + public string? Salary { get; set; } + } + + public class ZbAddressDto + { + public string City { get; set; } = string.Empty; + } + + public class ZbOrderRow + { + public ZbOrderRow() + { + } + + public ZbOrderRow(int id) => Id = id; + + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public List Lines { get; set; } = new(); + } + + public class ZbLineRow + { + public string Sku { get; set; } = string.Empty; + + [DwDenied] + public string? Cost { get; set; } + } + + public class ZbPlaceDto + { + public string City { get; set; } = string.Empty; + + public string Display => City.ToUpperInvariant(); + } + + public class ZbPlaceRow + { + public int Id { get; set; } + + public ZbPlaceDto? Place { get; set; } + } + + public class ZbZoneDto + { + public string Code { get; set; } = string.Empty; + } + + /// A subclass with no denial at all. + public class ZbSubZoneDto : ZbZoneDto + { + public string Parent { get; set; } = string.Empty; + } + + public class ZbZoneRow + { + public int Id { get; set; } + + public ZbZoneDto? Zone { get; set; } + } + + // ------------------------------------------------------------ the database + + public sealed class ZbProbeContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZbProbeContext(SqliteConnection connection) => _connection = connection; + + public DbSet Tickets => Set(); + + public DbSet Comments => Set(); + + public DbSet Sensors => Set(); + + public DbSet Sites => Set(); + + public DbSet Visits => Set(); + + public DbSet ProfileHolders => Set(); + + public DbSet Customers => Set(); + + public DbSet Orders => Set(); + + public DbSet Members => Set(); + + public DbSet Links => Set(); + + public DbSet Payments => Set(); + + public DbSet Events => Set(); + + public DbSet Docs => Set(); + + public DbSet Shoppers => Set(); + + public DbSet Baskets => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().Property(t => t.Extra).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!); + + model.Entity().Property(s => s.Flags).HasConversion( + value => string.Concat(value.Cast().Select(bit => bit ? '1' : '0')), + text => new BitArray(text.Select(c => c == '1').ToArray())); + + model.Entity().Property(s => s.Location).HasConversion( + value => value == null ? null : $"{value.X};{value.Y}", + text => text == null ? null : new ZbPoint { X = double.Parse(text.Split(';', StringSplitOptions.None)[0]), Y = double.Parse(text.Split(';', StringSplitOptions.None)[1]) }); + + model.Entity().OwnsOne(p => p.Settings, s => s.Property(x => x.Prefs).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!)); + + model.Entity().HasMany(m => m.Badges).WithOne().HasForeignKey(b => b.ZbMemberId); + + model.Entity(); + model.Entity(); + + model.Entity().Property(e => e.Data).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!); + model.Entity(); + + model.Entity().Navigation(s => s.Tier).AutoInclude(); + + model.Entity().Property(d => d.Meta).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!); + } + } + + // ------------------------------------------------------------ the probes + + public sealed class ReviewOverBlockTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZbProbeContext _db; + + public ReviewOverBlockTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + _db = new ZbProbeContext(_connection); + _db.Database.EnsureCreated(); + + ZbTicket ticket = new() { Title = "T1", Extra = { ["colour"] = "red" } }; + + _db.Tickets.Add(ticket); + _db.Comments.Add(new ZbComment { Text = "c1", Ticket = ticket }); + _db.Sensors.Add(new ZbSensor { Name = "S1", Flags = new BitArray(new[] { true, false, true, true }) }); + + ZbSite site = new() { Name = "Basra", Location = new ZbPoint { X = 30.5, Y = 47.8 } }; + + _db.Sites.Add(site); + _db.Visits.Add(new ZbVisit { Note = "v1", Site = site }); + _db.ProfileHolders.Add(new ZbProfileHolder { Name = "P1", Settings = new ZbSettings { Theme = "dark", Prefs = { ["lang"] = "ar" } } }); + + ZbCustomer withOrders = new() + { + Name = "C1", + Cards = { new ZbCard { Label = "visa", Pan = "pan-secret" } }, + Orders = { new ZbOrder { Code = "O1", Lines = { new ZbLine { Sku = "S1", Cost = "9" } } } } + }; + + _db.Customers.Add(withOrders); + _db.Customers.Add(new ZbCustomer { Name = "C2-no-orders" }); + + ZbMember member = new(0, "M1"); + + member.Badges.Add(new ZbBadge { Label = "gold", Pin = "pin-secret" }); + _db.Members.Add(member); + _db.Links.Add(new ZbLink { Member = member }); + + _db.Payments.Add(new ZbCardPayment { Cents = 100, Pan = "payment-pan-secret" }); + _db.Payments.Add(new ZbCashPayment { Cents = 200, Till = "till-1" }); + + _db.Events.Add(new ZbClickEvent { Kind = "click", Data = { ["x"] = 1 } }); + _db.Events.Add(new ZbViewEvent { Kind = "view", Page = "/home" }); + + ZbShopper shopper = new() { Name = "S1", Tier = new ZbTier { Label = "gold" }, Wallets = { new ZbWallet { Iban = "iban-secret" } } }; + + _db.Shoppers.Add(shopper); + _db.Baskets.Add(new ZbBasket { Shopper = shopper }); + + _db.Docs.Add(new ZbDoc { Title = "plain" }); + _db.Docs.Add(new ZbRichDoc { Title = "rich", Meta = { ["pages"] = 3 } }); + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static DwPolicyContext Caller() => + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1").WithValue("TenantId", 1); + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict, params IDwPolicyProvider[] more) + where T : class => + source.ApplyPolicy( + Caller(), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() }.Concat(more).ToArray())); + + private static FakePolicyProvider DenyingAllBut(params string[] allowed) + { + FakePolicyProvider rules = new FakePolicyProvider() + .Add("*", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + foreach (string path in allowed) + { + rules.Add(path, PolicyFeature.Select, PolicyEffect.Allow, PolicyLevel.DynamicGlobal); + } + + return rules; + } + + private static string Dropped(PolicyQueryable guarded) where T : class => + string.Join(" | ", guarded.LastTrace?.Decisions + .Where(d => d.Action != PolicyAction.Allowed) + .Select(d => $"{d.FieldPath} {d.Action}: {d.Reason}") ?? Array.Empty()); + + private static bool AnyDropped(PolicyQueryable guarded) where T : class => + guarded.LastTrace?.Decisions.Any(d => d.Action == PolicyAction.Dropped) ?? false; + + /// Runs a guarded call and describes what came back, or what it threw. + private string Outcome(string label, PolicyQueryable guarded, Func, IEnumerable> run) + where T : class + { + string line; + + try + { + List rows = run(guarded).Cast().ToList(); + + line = $"{label}: OK rows={rows.Count} types=[{string.Join(",", rows.Select(r => r?.GetType().Name ?? "null"))}]" + + $" json={JsonSerializer.Serialize(rows)} decisions=[{Dropped(guarded)}]"; + } + catch (Exception e) + { + string code = e is PolicyException refusal ? refusal.ErrorCode.ToString() : string.Empty; + + line = $"{label}: THREW {e.GetType().Name} {code} {e.Message.Split('\n')[0]} decisions=[{Dropped(guarded)}]"; + } + + _out.WriteLine(line); + + return line; + } + + // ================================================================ A: columns that hold objects + + [Fact] + public void Zb_A1_entity_with_an_object_bag_column_and_no_denial_is_returned_as_loaded() + { + PolicyQueryable guarded = Guard(_db.Tickets); + + Outcome("A1 typed", Guard(_db.Tickets), g => g.ToList(new Filter()).Data); + + ZbTicket ticket = guarded.ToList(new Filter()).Data.Single(); + + Assert.False(AnyDropped(guarded), Dropped(guarded)); + Assert.True(ticket.Extra.ContainsKey("colour")); + } + + [Fact] + public void Zb_A2_entity_with_a_BitArray_column_and_no_denial_is_returned_as_loaded() + { + PolicyQueryable guarded = Guard(_db.Sensors); + + Outcome("A2 typed", Guard(_db.Sensors), g => g.ToList(new Filter()).Data); + + ZbSensor sensor = guarded.ToList(new Filter()).Data.Single(); + + Assert.False(AnyDropped(guarded), Dropped(guarded)); + Assert.Equal(4, sensor.Flags.Length); + Assert.True(sensor.Flags[3]); + } + + [Fact] + public void Zb_A3_entity_with_a_geometry_like_column_and_no_denial_is_returned_as_loaded() + { + PolicyQueryable guarded = Guard(_db.Sites); + + Outcome("A3 typed", Guard(_db.Sites), g => g.ToList(new Filter()).Data); + + ZbSite site = guarded.ToList(new Filter()).Data.Single(); + + Assert.False(AnyDropped(guarded), Dropped(guarded)); + Assert.NotNull(site.Location); + } + + [Fact] + public void Zb_A4_an_included_navigation_whose_entity_holds_an_object_bag_is_kept() + { + PolicyQueryable guarded = Guard(_db.Comments.Include(c => c.Ticket)); + + Outcome("A4 typed", Guard(_db.Comments.Include(c => c.Ticket)), g => g.ToList(new Filter()).Data); + + ZbComment comment = guarded.ToList(new Filter()).Data.Single(); + + Assert.False(AnyDropped(guarded), Dropped(guarded)); + Assert.NotNull(comment.Ticket); + } + + [Fact] + public void Zb_A5_an_included_navigation_whose_entity_holds_a_geometry_is_kept() + { + PolicyQueryable guarded = Guard(_db.Visits.Include(v => v.Site)); + + Outcome("A5 typed", Guard(_db.Visits.Include(v => v.Site)), g => g.ToList(new Filter()).Data); + + ZbVisit visit = guarded.ToList(new Filter()).Data.Single(); + + Assert.False(AnyDropped(guarded), Dropped(guarded)); + Assert.NotNull(visit.Site); + } + + [Fact] + public void Zb_A6_an_owned_member_holding_an_object_bag_is_kept() + { + PolicyQueryable guarded = Guard(_db.ProfileHolders); + + Outcome("A6 typed", Guard(_db.ProfileHolders), g => g.ToList(new Filter()).Data); + + ZbProfileHolder holder = guarded.ToList(new Filter()).Data.Single(); + + Assert.False(AnyDropped(guarded), Dropped(guarded)); + Assert.True(holder.Settings.Prefs.ContainsKey("lang")); + } + + // ================================================================ B: reshaped chains, nothing loaded beneath + + [Fact] + public void Zb_B1_Select_to_a_navigation_with_nothing_loaded_beneath_is_not_projected() + { + IQueryable source = _db.Orders.Select(o => o.Customer!); + + Outcome("B1 unguarded", Guard(_db.Customers.Where(c => false)), _ => source.ToList()); + Outcome("B1 typed", Guard(source), g => g.ToList(new Filter()).Data); + _db.ChangeTracker.Clear(); + + PolicyQueryable guarded = Guard(source); + ZbCustomer customer = guarded.ToList(new Filter()).Data.Single(); + + Assert.Equal("C1", customer.Name); + Assert.False(AnyDropped(guarded), Dropped(guarded)); + } + + [Fact] + public void Zb_B2_SelectMany_with_nothing_loaded_beneath_is_not_projected() + { + IQueryable source = _db.Customers.SelectMany(c => c.Orders); + + Outcome("B2 typed", Guard(source), g => g.ToList(new Filter()).Data); + _db.ChangeTracker.Clear(); + + PolicyQueryable guarded = Guard(source); + + Assert.Single(guarded.ToList(new Filter()).Data); + Assert.False(AnyDropped(guarded), Dropped(guarded)); + } + + [Fact] + public void Zb_B10_a_filter_inside_a_reshaping_lambda_loads_nothing() + { + // A method call, and a value constructed, that only feed a predicate hand a row nothing. + IQueryable source = _db.Customers.SelectMany(c => c.Orders.Where( + o => EF.Functions.Like(o.Code, "%") && o.Id < new DateTime(2100, 1, 1).Year)); + + Outcome("B10 typed", Guard(source), g => g.ToList(new Filter()).Data); + _db.ChangeTracker.Clear(); + + PolicyQueryable guarded = Guard(source); + + Assert.Single(guarded.ToList(new Filter()).Data); + Assert.False(AnyDropped(guarded), Dropped(guarded)); + } + + [Fact] + public void Zb_B3_Join_with_nothing_loaded_beneath_is_not_projected() + { + IQueryable source = _db.Orders.Join(_db.Customers, o => o.ZbCustomerId, c => c.Id, (o, c) => c); + + Outcome("B3 typed", Guard(source), g => g.ToList(new Filter()).Data); + + PolicyQueryable guarded = Guard(source); + + Assert.Single(guarded.ToList(new Filter()).Data); + Assert.False(AnyDropped(guarded), Dropped(guarded)); + } + + [Fact] + public void Zb_B4_GroupBy_First_with_nothing_loaded_beneath_runs_unprojected() + { + // EF Core 6 cannot count this shape, guarded or not; the core's count fails there before the policy has a say. + if (typeof(DbContext).Assembly.GetName().Version!.Major < 7) + { + return; + } + + IQueryable source = _db.Orders.GroupBy(o => o.ZbCustomerId).Select(g => g.OrderBy(o => o.Id).First()); + + Outcome("B4 unguarded", Guard(_db.Orders.Where(o => false)), _ => source.ToList()); + Outcome("B4 typed", Guard(source), g => g.ToList(new Filter()).Data); + Outcome("B4 typed paged", Guard(source), g => g.ToList(new Filter { Page = new PageBy { PageNumber = 1, PageSize = 10 } }).Data); + + PolicyQueryable guarded = Guard(source); + + Assert.Single(guarded.ToList(new Filter()).Data); + Assert.False(AnyDropped(guarded), Dropped(guarded)); + } + + [Fact] + public void Zb_B5_Select_to_a_constructor_bound_entity_with_nothing_loaded_beneath_runs() + { + IQueryable source = _db.Links.Select(l => l.Member!); + + Outcome("B5 unguarded", Guard(_db.Members.Where(m => false)), _ => source.ToList()); + Outcome("B5 typed", Guard(source), g => g.ToList(new Filter()).Data); + Outcome("B5 dynamic", Guard(source), g => g.ToListDynamic(new Filter()).Data); + Outcome("B5 direct typed (no reshape)", Guard(_db.Members), g => g.ToList(new Filter()).Data); + + Assert.Equal("M1", Guard(source).ToList(new Filter()).Data.Single().Name); + } + + [Fact] + public void Zb_B6_Select_FirstOrDefault_with_a_missing_row_runs_as_it_does_unguarded() + { + IQueryable source = _db.Customers.OrderBy(c => c.Id).Select(c => c.Orders.OrderBy(o => o.Id).FirstOrDefault()!); + + Outcome("B6 unguarded", Guard(_db.Orders.Where(o => false)), _ => source.ToList()); + Outcome("B6 typed", Guard(source), g => g.ToList(new Filter()).Data); + + Assert.Equal(2, Guard(source).ToList(new Filter()).Data.Count); + } + + [Fact] + public void Zb_B7_left_join_through_DefaultIfEmpty_runs_as_it_does_unguarded() + { + IQueryable source = _db.Customers.SelectMany(c => c.Orders.DefaultIfEmpty(), (c, o) => o!); + + Outcome("B7 unguarded", Guard(_db.Orders.Where(o => false)), _ => source.ToList()); + Outcome("B7 typed", Guard(source), g => g.ToList(new Filter()).Data); + + Assert.Equal(2, Guard(source).ToList(new Filter()).Data.Count); + } + + /// + /// The needless projection over a reshaped chain drops an auto-included navigation that holds nothing + /// denied: only the unloaded wallets hold a denial. + /// + [Fact] + public void Zb_B9_Select_to_a_navigation_keeps_its_auto_included_navigation() + { + IQueryable source = _db.Baskets.Select(b => b.Shopper!); + + Outcome("B9 unguarded", Guard(_db.Shoppers.Where(s => false)), _ => source.AsNoTracking().ToList()); + Outcome("B9 typed", Guard(source), g => g.ToList(new Filter()).Data); + Outcome("B9 direct (no reshape)", Guard(_db.Shoppers), g => g.ToList(new Filter()).Data); + + Assert.Equal("gold", Guard(_db.Shoppers).ToList(new Filter()).Data.Single().Tier?.Label); + Assert.Equal("gold", Guard(source).ToList(new Filter()).Data.Single().Tier?.Label); + } + + /// + /// Informational: the same chains with a top-level denial, which forces the synthesized projection in + /// every version. Shows whether the exceptions above are the projection's own fragility. + /// + [Fact] + public void Zb_B8_the_same_chains_with_a_top_level_denial_in_every_version() + { + FakePolicyProvider code = new FakePolicyProvider().Add("Code", PolicyFeature.Select, PolicyEffect.Deny, PolicyLevel.DynamicGlobal); + + Outcome("B8 FirstOrDefault + top-level denial", Guard(_db.Customers.OrderBy(c => c.Id).Select(c => c.Orders.OrderBy(o => o.Id).FirstOrDefault()!), DwTier.Strict, code), g => g.ToList(new Filter()).Data); + Outcome("B8 DefaultIfEmpty + top-level denial", Guard(_db.Customers.SelectMany(c => c.Orders.DefaultIfEmpty(), (c, o) => o!), DwTier.Strict, code), g => g.ToList(new Filter()).Data); + Outcome("B8 GroupBy First + top-level denial", Guard(_db.Orders.GroupBy(o => o.ZbCustomerId).Select(g => g.OrderBy(o => o.Id).First()), DwTier.Strict, code), g => g.ToList(new Filter()).Data); + } + + // ================================================================ C: hierarchies + + [Fact] + public void Zb_C1_an_abstract_root_with_no_denial_anywhere_runs() + { + Outcome("C1 typed", Guard(_db.Events), g => g.ToList(new Filter()).Data); + Outcome("C1 dynamic", Guard(_db.Events), g => g.ToListDynamic(new Filter()).Data); + + List events = Guard(_db.Events).ToList(new Filter()).Data; + + Assert.Contains(events, e => e is ZbClickEvent click && click.Data.ContainsKey("x")); + } + + [Fact] + public void Zb_C2_a_concrete_root_with_no_denial_anywhere_keeps_its_derived_rows() + { + PolicyQueryable guarded = Guard(_db.Docs); + + Outcome("C2 typed", Guard(_db.Docs), g => g.ToList(new Filter()).Data); + + List docs = guarded.ToList(new Filter()).Data; + + Assert.False(AnyDropped(guarded), Dropped(guarded)); + Assert.Contains(docs, d => d is ZbRichDoc rich && rich.Meta.ContainsKey("pages")); + } + + /// Informational: an abstract root whose derived type declares a denied field. + [Fact] + public void Zb_C3_an_abstract_root_whose_derived_type_has_a_denial() + { + string typed = Outcome("C3 typed", Guard(_db.Payments), g => g.ToList(new Filter()).Data); + string dynamic = Outcome("C3 dynamic", Guard(_db.Payments), g => g.ToListDynamic(new Filter()).Data); + string convenience = Outcome("C3 typed convenience", Guard(_db.Payments, DwTier.Convenience), g => g.ToList(new Filter()).Data); + + Assert.DoesNotContain("THREW", dynamic); + } + + // ================================================================ D: interfaces and DTO base classes + + [Fact] + public void Zb_D1_a_projected_member_declared_as_an_interface_keeps_the_value_it_was_built_with() + { + IQueryable rows = _db.Customers.OrderBy(c => c.Id) + .Select(c => new ZbCustomerRow { Id = c.Id, Contact = new ZbContactRow { Name = c.Name } }); + + Outcome("D1 typed", Guard(rows), g => g.ToList(new Filter()).Data); + + PolicyQueryable guarded = Guard(rows); + ZbCustomerRow row = guarded.ToList(new Filter()).Data.First(); + + Assert.Equal("C1", row.Contact?.Name); + Assert.False(AnyDropped(guarded), Dropped(guarded)); + } + + [Fact] + public void Zb_D2_an_in_memory_member_declared_as_an_interface_whose_other_implementation_is_denied_is_left_out() + { + ZbCustomerRow[] rows = { new() { Id = 1, Contact = new ZbContactRow { Name = "n1" } } }; + + Outcome("D2 typed", Guard(rows.AsQueryable()), g => g.ToList(new Filter()).Data); + + PolicyQueryable guarded = Guard(rows.AsQueryable()); + ZbCustomerRow row = guarded.ToList(new Filter()).Data.Single(); + + // In memory the member can hold any implementation, one with a denied field among them, and an + // object of a row in memory is never kept once a projection is needed. + Assert.Null(row.Contact); + Assert.True(AnyDropped(guarded), Dropped(guarded)); + } + + [Fact] + public void Zb_D3_in_memory_rows_of_a_DTO_a_subclass_of_which_has_a_denial_are_projected() + { + ZbPersonDto[] rows = { new() { Id = 1, Name = "n1", Address = new ZbAddressDto { City = "Basra" } } }; + + Outcome("D3 typed", Guard(rows.AsQueryable()), g => g.ToList(new Filter()).Data); + + PolicyQueryable guarded = Guard(rows.AsQueryable()); + ZbPersonDto row = guarded.ToList(new Filter()).Data.Single(); + + // A loaded subclass declares a denied field, and a row in memory can be one: the rows are projected + // to the declared type, which leaves their objects out. + Assert.NotSame(rows[0], row); + Assert.Null(row.Address); + Assert.Equal("n1", row.Name); + } + + // ================================================================ E: constructor plus initializer + + [Fact] + public void Zb_E1_an_initializer_after_a_constructor_still_narrows_the_members_it_assigns() + { + IQueryable rows = _db.Orders.Select(o => new ZbOrderRow(o.Id) + { + Code = o.Code, + Lines = o.Lines.Select(l => new ZbLineRow { Sku = l.Sku, Cost = l.Cost }).ToList() + }); + + Outcome("E1 typed", Guard(rows), g => g.ToList(new Filter()).Data); + + ZbOrderRow row = Guard(rows).ToList(new Filter()).Data.Single(); + + Assert.Equal("O1", row.Code); + Assert.Equal("S1", Assert.Single(row.Lines).Sku); + Assert.Null(row.Lines[0].Cost); + } + + // ================================================================ F: deny-by-default, every path allowed + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zb_F1_deny_by_default_allowing_every_path_keeps_an_in_memory_member_with_a_computed_property(DwTier tier) + { + ZbPlaceRow[] rows = { new() { Id = 1, Place = new ZbPlaceDto { City = "Basra" } } }; + FakePolicyProvider rules = DenyingAllBut("Id", "Place", "Place.City", "Place.Display"); + + Outcome($"F1 {tier} named", Guard(rows.AsQueryable(), tier, rules), g => g.ToList(new Filter { Selects = new List { "Id", "Place" } }).Data); + Outcome($"F1 {tier} none", Guard(rows.AsQueryable(), tier, rules), g => g.ToList(new Filter()).Data); + + ZbPlaceRow row = Guard(rows.AsQueryable(), tier, rules).ToList(new Filter { Selects = new List { "Id", "Place" } }).Data.Single(); + + Assert.Equal("Basra", row.Place?.City); + } + + [Fact] + public void Zb_F2_deny_by_default_allowing_every_path_keeps_a_projected_member_with_a_computed_property() + { + IQueryable rows = _db.Customers.OrderBy(c => c.Id).Select(c => new ZbPlaceRow { Id = c.Id, Place = new ZbPlaceDto { City = c.Name } }); + FakePolicyProvider rules = DenyingAllBut("Id", "Place", "Place.City", "Place.Display"); + + Outcome("F2 none", Guard(rows, DwTier.Strict, rules), g => g.ToList(new Filter()).Data); + Outcome("F2 named", Guard(rows, DwTier.Strict, rules), g => g.ToList(new Filter { Selects = new List { "Id", "Place" } }).Data); + + PolicyQueryable guarded = Guard(rows, DwTier.Strict, rules); + ZbPlaceRow row = guarded.ToList(new Filter()).Data.First(); + + Assert.Equal("C1", row.Place?.City); + + // Naming the member, every path of which the policy allows, is not refused. + ZbPlaceRow named = Guard(rows, DwTier.Strict, rules).ToList(new Filter { Selects = new List { "Id", "Place" } }).Data.First(); + + Assert.Equal("C1", named.Place?.City); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zb_F3_deny_by_default_keeps_a_member_only_when_every_subtype_path_is_named(DwTier tier) + { + ZbZoneRow[] rows = { new() { Id = 1, Zone = new ZbZoneDto { Code = "Z1" } } }; + FakePolicyProvider rules = DenyingAllBut("Id", "Zone", "Zone.Code"); + + Outcome($"F3 {tier} named", Guard(rows.AsQueryable(), tier, rules), g => g.ToList(new Filter { Selects = new List { "Id", "Zone" } }).Data); + + // The subclass declares Parent, which the policy does not name, and a row in memory can hold it. + Assert.Throws(() => Guard(rows.AsQueryable(), tier, rules).ToList(new Filter { Selects = new List { "Id", "Zone" } })); + + // Named too, every path a Zone can hold is granted, and the member is kept whole. + FakePolicyProvider named = DenyingAllBut("Id", "Zone", "Zone.Code", "Zone.Parent"); + ZbZoneRow row = Guard(rows.AsQueryable(), tier, named).ToList(new Filter { Selects = new List { "Id", "Zone" } }).Data.Single(); + + Assert.Equal("Z1", row.Zone?.Code); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewOwnedPrecisionTests.cs b/DynamicWhere.Tests/Policies/ReviewOwnedPrecisionTests.cs new file mode 100644 index 0000000..3cb684c --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewOwnedPrecisionTests.cs @@ -0,0 +1,168 @@ +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// A converted JSON bag inside an owned member, beside a top-level denial. A converter can hand back any object, so +// the owned member is narrowed around the bag: it comes back with its plain fields, and without the bag. + +namespace DynamicWhere.Tests.Policies +{ + public class ZyoSettings + { + public string Theme { get; set; } = string.Empty; + + public Dictionary Prefs { get; set; } = new(); + } + + public class ZyoAddress + { + public string City { get; set; } = string.Empty; + + public Dictionary Extra { get; set; } = new(); + } + + public class ZyoProfile + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwDenied] + public string? Secret { get; set; } + + public ZyoSettings Settings { get; set; } = new(); + + public List Addresses { get; set; } = new(); + } + + public sealed class ZyoContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZyoContext(SqliteConnection connection) => _connection = connection; + + public DbSet Profiles => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().OwnsOne(p => p.Settings, s => s.Property(x => x.Prefs).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!)); + model.Entity().OwnsMany(p => p.Addresses, a => + { + a.WithOwner().HasForeignKey("ZyoProfileId"); + a.Property("Id"); + a.HasKey("Id"); + a.Property(x => x.Extra).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!); + }); + } + } + + public sealed class ReviewOwnedPrecisionTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyoContext _db; + + public ReviewOwnedPrecisionTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyoContext(_connection); + _db.Database.EnsureCreated(); + _db.Profiles.Add(new ZyoProfile + { + Name = "P1", + Secret = "zyo-secret", + Settings = new ZyoSettings { Theme = "dark", Prefs = { ["lang"] = "ar" } }, + Addresses = { new ZyoAddress { City = "Basra", Extra = { ["floor"] = 3 } } } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private (ZyoProfile? Row, string Outcome) Run() + { + PolicyQueryable guarded = _db.Profiles.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + try + { + ZyoProfile row = guarded.ToList(new Filter()).Data.Single(); + string outcome = $"OK json={JsonSerializer.Serialize(row)} dropped=[{ZyLog.Decisions(guarded)}]"; + + ZyLog.Write(_out, "O owned: " + outcome); + + return (row, outcome); + } + catch (Exception e) + { + string outcome = $"THREW {e.GetType().Name} {e.Message.Split('\n')[0]} dropped=[{ZyLog.Decisions(guarded)}]"; + + ZyLog.Write(_out, "O owned: " + outcome); + + return (null, outcome); + } + } + + [Fact] + public void Zy_O1_an_owned_member_holding_a_converted_bag_keeps_its_plain_fields() + { + (ZyoProfile? row, string outcome) = Run(); + + Assert.NotNull(row); + Assert.Null(row!.Secret); + Assert.Equal("dark", row.Settings?.Theme); + } + + [Fact] + public void Zy_O2_an_owned_member_holding_a_converted_bag_is_narrowed_around_the_bag() + { + (ZyoProfile? row, string outcome) = Run(); + + Assert.NotNull(row); + Assert.NotNull(row!.Settings); + Assert.False(row.Settings!.Prefs.ContainsKey("lang"), outcome); + Assert.Contains("Settings.Prefs Dropped: left out whole: it can hold what the policy cannot name", outcome); + } + + [Fact] + public void Zy_O3_an_owned_collection_holding_a_converted_bag_keeps_its_elements() + { + (ZyoProfile? row, string outcome) = Run(); + + Assert.NotNull(row); + Assert.Equal("Basra", Assert.Single(row!.Addresses).City); + } + + [Fact] + public void Zy_O4_an_owned_collection_holding_a_converted_bag_is_narrowed_around_the_bag() + { + (ZyoProfile? row, string outcome) = Run(); + + Assert.NotNull(row); + Assert.False(Assert.Single(row!.Addresses).Extra.ContainsKey("floor"), outcome); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewReshapeImpactTests.cs b/DynamicWhere.Tests/Policies/ReviewReshapeImpactTests.cs new file mode 100644 index 0000000..67e17af --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewReshapeImpactTests.cs @@ -0,0 +1,323 @@ +using System.Linq.Expressions; +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// What the needless projection behind a reshaping lambda costs on shapes other than a plain entity: an abstract +// hierarchy, a concrete root with derived rows, and a constructor-bound entity. The only denial (ZyiKey.Secret) sits +// beneath Owner.Keys, which nothing loads. Each test asserts the rows come back as the unguarded query returns them. + +namespace DynamicWhere.Tests.Policies +{ + public class ZyiOwner + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Keys { get; set; } = new(); + + public List Payments { get; set; } = new(); + + public List Docs { get; set; } = new(); + + public List Members { get; set; } = new(); + + public List Invoices { get; set; } = new(); + } + + /// DDD style: a private constructor for EF Core, a public one for the domain, private setters. + public class ZyiInvoice + { + private ZyiInvoice() + { + } + + public ZyiInvoice(string number) => Number = number; + + public int Id { get; private set; } + + public string Number { get; private set; } = string.Empty; + + public int ZyiOwnerId { get; private set; } + + public ZyiOwner? Owner { get; private set; } + } + + public class ZyiKey + { + public int Id { get; set; } + + public int ZyiOwnerId { get; set; } + + [DwDenied] + public string? Secret { get; set; } + } + + public abstract class ZyiPayment + { + public int Id { get; set; } + + public long Cents { get; set; } + + public int ZyiOwnerId { get; set; } + + public ZyiOwner? Owner { get; set; } + } + + public class ZyiCardPayment : ZyiPayment + { + public string Last4 { get; set; } = string.Empty; + } + + public class ZyiCashPayment : ZyiPayment + { + public string Till { get; set; } = string.Empty; + } + + public class ZyiDoc + { + public int Id { get; set; } + + public string Title { get; set; } = string.Empty; + + public int ZyiOwnerId { get; set; } + + public ZyiOwner? Owner { get; set; } + } + + public class ZyiRichDoc : ZyiDoc + { + public int Pages { get; set; } + } + + /// Constructor-bound: EF Core binds it, and it has no parameterless constructor. + public class ZyiMember + { + public ZyiMember(int id, string name, int zyiOwnerId) + { + Id = id; + Name = name; + ZyiOwnerId = zyiOwnerId; + } + + public int Id { get; private set; } + + public string Name { get; private set; } + + public int ZyiOwnerId { get; private set; } + + public ZyiOwner? Owner { get; private set; } + } + + public static class ZyiSpecs + { + public static readonly Expression> Paid = p => p.Cents > 0; + + public static readonly Expression> Titled = d => d.Title != string.Empty; + + public static readonly Expression> Named = m => m.Name != string.Empty; + + public static readonly Expression> Numbered = i => i.Number != string.Empty; + } + + public sealed class ZyiContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZyiContext(SqliteConnection connection) => _connection = connection; + + public DbSet Owners => Set(); + + public DbSet Payments => Set(); + + public DbSet Docs => Set(); + + public DbSet Members => Set(); + + /// A queryable function, as EF Core maps a table-valued function (not mapped here; only its shape is read). + public IQueryable DocsOf(int ownerId) => Docs.Where(d => d.ZyiOwnerId == ownerId); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity(); + model.Entity(); + model.Entity(); + model.Entity().HasOne(m => m.Owner).WithMany(o => o.Members).HasForeignKey(m => m.ZyiOwnerId); + } + } + + public sealed class ReviewReshapeImpactTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyiContext _db; + + public ReviewReshapeImpactTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyiContext(_connection); + _db.Database.EnsureCreated(); + + ZyiOwner owner = new() + { + Name = "W1", + Keys = { new ZyiKey { Secret = "zyi-secret" } }, + Payments = { new ZyiCardPayment { Cents = 100, Last4 = "4242" }, new ZyiCashPayment { Cents = 200, Till = "T1" } }, + Docs = { new ZyiDoc { Title = "plain" }, new ZyiRichDoc { Title = "rich", Pages = 3 } }, + Invoices = { new ZyiInvoice("INV-1") } + }; + + _db.Owners.Add(owner); + _db.SaveChanges(); + _db.Members.Add(new ZyiMember(0, "M1", owner.Id)); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private string Typed(string label, IQueryable source) where T : class + { + string unguarded = JsonSerializer.Serialize(source.AsNoTracking().ToList().Select(r => r.GetType().Name)); + PolicyQueryable guarded = Guard(source); + string outcome; + + _db.ChangeTracker.Clear(); + + try + { + List rows = guarded.ToList(new Filter()).Data; + + outcome = $"OK types={JsonSerializer.Serialize(rows.Select(r => r.GetType().Name))} json={JsonSerializer.Serialize(rows)}"; + } + catch (Exception e) + { + outcome = $"THREW {e.GetType().Name} {(e is PolicyException p ? p.ErrorCode.ToString() : string.Empty)} {e.Message.Split('\n')[0]}"; + } + + ZyLog.Write(_out, $"I {label}: {ZyLog.Shape(source.Expression)} unguarded types={unguarded} guarded {outcome} dropped=[{ZyLog.Decisions(guarded)}]"); + + return outcome; + } + + [Fact] + public void Zy_I1_control_abstract_rows_through_a_navigation_collection() + { + string outcome = Typed("I1 control SelectMany(o => o.Payments)", _db.Owners.SelectMany(o => o.Payments)); + + Assert.StartsWith("OK", outcome); + Assert.Contains("ZyiCardPayment", outcome); + } + + [Fact] + public void Zy_I2_abstract_rows_through_a_navigation_collection_with_a_specification() + { + string outcome = Typed("I2 SelectMany(o => o.Payments.AsQueryable().Where(spec))", _db.Owners.SelectMany(o => o.Payments.AsQueryable().Where(ZyiSpecs.Paid))); + + Assert.StartsWith("OK", outcome); + Assert.Contains("ZyiCardPayment", outcome); + } + + [Fact] + public void Zy_I3_control_derived_rows_of_a_concrete_root() + { + string outcome = Typed("I3 control SelectMany(o => o.Docs)", _db.Owners.SelectMany(o => o.Docs)); + + Assert.Contains("ZyiRichDoc", outcome); + } + + [Fact] + public void Zy_I4_derived_rows_of_a_concrete_root_with_a_specification() + { + string outcome = Typed("I4 SelectMany(o => o.Docs.AsQueryable().Where(spec))", _db.Owners.SelectMany(o => o.Docs.AsQueryable().Where(ZyiSpecs.Titled))); + + Assert.Contains("ZyiRichDoc", outcome); + } + + [Fact] + public void Zy_I5_control_a_constructor_bound_entity() + { + string outcome = Typed("I5 control SelectMany(o => o.Members)", _db.Owners.SelectMany(o => o.Members)); + + Assert.StartsWith("OK", outcome); + } + + [Fact] + public void Zy_I6_a_constructor_bound_entity_with_a_specification() + { + string outcome = Typed("I6 SelectMany(o => o.Members.AsQueryable().Where(spec))", _db.Owners.SelectMany(o => o.Members.AsQueryable().Where(ZyiSpecs.Named))); + + Assert.StartsWith("OK", outcome); + } + + [Fact] + public void Zy_I9_control_a_DDD_aggregate_with_a_private_constructor() + { + string outcome = Typed("I9 control SelectMany(o => o.Invoices)", _db.Owners.SelectMany(o => o.Invoices)); + + Assert.StartsWith("OK", outcome); + Assert.Contains("INV-1", outcome); + } + + [Fact] + public void Zy_I10_a_DDD_aggregate_with_a_private_constructor_and_a_specification() + { + string outcome = Typed("I10 SelectMany(o => o.Invoices.AsQueryable().Where(spec))", _db.Owners.SelectMany(o => o.Invoices.AsQueryable().Where(ZyiSpecs.Numbered))); + + Assert.StartsWith("OK", outcome); + Assert.Contains("INV-1", outcome); + } + + [Fact] + public void Zy_I11_a_DDD_aggregate_through_a_left_join_in_query_syntax() + { + IQueryable source = + from o in _db.Owners + join i in _db.Set() on o.Id equals i.ZyiOwnerId into invoices + from i in invoices.DefaultIfEmpty() + select i; + + string outcome = Typed("I11 left join to a DDD aggregate", source); + + Assert.StartsWith("OK", outcome); + Assert.Contains("INV-1", outcome); + } + + [Fact] + public void Zy_I7_a_queryable_function_on_the_context_is_read_as_building() + { + // Shape only: EF Core maps such a method to a table-valued function; SQLite cannot run one. + IQueryable source = _db.Owners.SelectMany(o => _db.DocsOf(o.Id)); + string shape = ZyLog.Shape(source.Expression); + + ZyLog.Write(_out, $"I I7 SelectMany(o => _db.DocsOf(o.Id)) (shape only): {shape}"); + + Assert.DoesNotContain("builds=True", shape); + } + + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewReshapePrecisionTests.cs b/DynamicWhere.Tests/Policies/ReviewReshapePrecisionTests.cs new file mode 100644 index 0000000..e3534a8 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewReshapePrecisionTests.cs @@ -0,0 +1,630 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// What an ordinary reshaped EF Core query gets when the only denial (ZyrCard.Pan) sits beneath a navigation nothing +// loads: no projection, the auto-included Tier kept, the converted Meta bag kept. Specifications, repositories, +// FromSql, a context's Set through an interface, composite keys and query-syntax joins, lets and left joins read no +// more than a plain chain does. QueryRoot's internals are reached by reflection. + +namespace DynamicWhere.Tests.Policies +{ + public class ZyrTier + { + public int Id { get; set; } + + public string Label { get; set; } = string.Empty; + } + + public class ZyrCustomer + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Region { get; set; } = string.Empty; + + public int ZyrTierId { get; set; } + + /// Automatically included; nothing beneath it is denied. + public ZyrTier? Tier { get; set; } + + /// A converted JSON bag, the pre-EF7 way to store one. + public Dictionary Meta { get; set; } = new(); + + /// Never loaded by any probe; the only denial is beneath it. + public List Cards { get; set; } = new(); + + public List Orders { get; set; } = new(); + } + + public class ZyrCard + { + public int Id { get; set; } + + public int ZyrCustomerId { get; set; } + + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pan { get; set; } + } + + public class ZyrChannel + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + public class ZyrOrder + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public string Region { get; set; } = string.Empty; + + public int ZyrCustomerId { get; set; } + + /// Never loaded; Customer.Cards.Pan is the denial beneath it. + public ZyrCustomer? Customer { get; set; } + + public int ZyrChannelId { get; set; } + + /// Automatically included; nothing beneath it is denied. + public ZyrChannel? Channel { get; set; } + + /// A converted JSON bag. + public Dictionary Meta { get; set; } = new(); + } + + /// A context exposed through an interface, as clean-architecture templates do. + public interface IZyrContext + { + DbSet Set() where TEntity : class; + } + + /// What a repository layer looks like: a method and a property handing out a plain set. + public sealed class ZyrRepository + { + private readonly ZyrContext _db; + + public ZyrRepository(ZyrContext db) => _db = db; + + public IQueryable Customers() => _db.Customers; + + public IQueryable CustomerSet => _db.Customers; + } + + /// Reusable predicates, the specification pattern. + public static class ZyrSpecs + { + public static readonly Expression> Open = o => o.Code != string.Empty; + + public static Expression> InRegion(string region) => o => o.Region == region; + + public static readonly Expression> Named = c => c.Name != string.Empty; + } + + public readonly record struct ZyrRegionId(int Value); + + /// A request object a controller captures into a query. + public sealed class ZyrFilter + { + public string Region { get; set; } = string.Empty; + + public List Codes { get; set; } = new(); + } + + public sealed class ZyrContext : DbContext, IZyrContext + { + private readonly SqliteConnection _connection; + + public ZyrContext(SqliteConnection connection) => _connection = connection; + + public DbSet Customers => Set(); + + public DbSet Orders => Set(); + + public DbSet Tiers => Set(); + + public DbSet Channels => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().ToTable("ZyrCustomers"); + model.Entity().ToTable("ZyrOrders"); + model.Entity().Navigation(c => c.Tier).AutoInclude(); + model.Entity().Navigation(o => o.Channel).AutoInclude(); + model.Entity().Property(c => c.Meta).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!); + model.Entity().Property(o => o.Meta).HasConversion( + value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null), + text => JsonSerializer.Deserialize>(text, (JsonSerializerOptions?)null)!); + } + } + + /// Writes a test's outcome to its output, and reads the query's shape. + internal static class ZyLog + { + internal static void Write(ITestOutputHelper output, string line) => output.WriteLine(line); + + /// QueryRoot.Reshapes / Builds, by reflection, where the tree has them. + internal static string Shape(Expression expression) + { + Type? root = typeof(Filter).Assembly.GetType("DynamicWhere.ex.Source.QueryRoot"); + + string Call(string name) + { + MethodInfo? method = root?.GetMethod(name, BindingFlags.NonPublic | BindingFlags.Static, null, new[] { typeof(Expression) }, null); + + if (method is null) + { + return "n/a"; + } + + try + { + return method.Invoke(null, new object[] { expression })!.ToString()!; + } + catch (TargetInvocationException e) + { + return "threw " + e.InnerException?.GetType().Name; + } + } + + return $"reshapes={Call("Reshapes")} builds={Call("Builds")}"; + } + + internal static string Decisions(PolicyQueryable guarded) where T : class => + string.Join(" | ", guarded.LastTrace?.Decisions + .Where(d => d.Action != PolicyAction.Allowed) + .Select(d => $"{d.FieldPath} {d.Action}: {d.Reason}") ?? Array.Empty()); + + internal static bool AnyDropped(PolicyQueryable guarded) where T : class => + guarded.LastTrace?.Decisions.Any(d => d.Action == PolicyAction.Dropped) ?? false; + } + + public sealed class ReviewReshapePrecisionTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZyrContext _db; + private readonly ZyrRepository _repo; + + public ReviewReshapePrecisionTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZyrContext(_connection); + _db.Database.EnsureCreated(); + _repo = new ZyrRepository(_db); + + ZyrTier gold = new() { Label = "gold" }; + ZyrChannel web = new() { Name = "web" }; + + ZyrCustomer c1 = new() + { + Name = "C1", + Region = "north", + Tier = gold, + Meta = { ["k"] = "v1" }, + Cards = { new ZyrCard { Label = "visa", Pan = "zyr-pan-secret" } } + }; + ZyrCustomer c2 = new() { Name = "C2", Region = "south", Tier = gold, Meta = { ["k"] = "v2" } }; + + c1.Orders.Add(new ZyrOrder { Code = "O1", Region = "north", Channel = web, Meta = { ["k"] = "o1" } }); + c1.Orders.Add(new ZyrOrder { Code = "O2", Region = "south", Channel = web, Meta = { ["k"] = "o2" } }); + c2.Orders.Add(new ZyrOrder { Code = "O3", Region = "south", Channel = web, Meta = { ["k"] = "o3" } }); + + _db.Customers.AddRange(c1, c2); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + /// + /// Runs the unguarded query (to show EF Core translates it) and the guarded one, logs both, and returns the + /// guarded rows with the handle; null rows when the unguarded query itself does not translate. + /// + private (List? Rows, PolicyQueryable Guarded) Run(string label, IQueryable source) where T : class + { + string shape = ZyLog.Shape(source.Expression); + PolicyQueryable guarded = Guard(source); + + try + { + int count = source.AsNoTracking().ToList().Count; + + ZyLog.Write(_out, $"R {label}: unguarded OK rows={count} {shape}"); + } + catch (Exception e) + { + ZyLog.Write(_out, $"R {label}: unguarded THREW {e.GetType().Name} {e.Message.Split('\n')[0]} {shape}"); + + return (null, guarded); + } + + _db.ChangeTracker.Clear(); + + try + { + List rows = guarded.ToList(new Filter()).Data; + int tracked = _db.ChangeTracker.Entries().Count(); + + ZyLog.Write(_out, $"R {label}: guarded OK rows={rows.Count} tracked={tracked} dropped=[{ZyLog.Decisions(guarded)}]"); + + return (rows, guarded); + } + catch (Exception e) + { + string code = e is PolicyException refusal ? refusal.ErrorCode.ToString() : string.Empty; + + ZyLog.Write(_out, $"R {label}: guarded THREW {e.GetType().Name} {code} {e.Message.Split('\n')[0]} dropped=[{ZyLog.Decisions(guarded)}]"); + + throw; + } + } + + private void CustomersAsLoaded(string label, IQueryable source, int count) + { + (List? rows, PolicyQueryable guarded) = Run(label, source); + + if (rows is null) + { + return; + } + + Assert.Equal(count, rows.Count); + Assert.False(ZyLog.AnyDropped(guarded), ZyLog.Decisions(guarded)); + Assert.All(rows, row => Assert.Equal("gold", row.Tier?.Label)); + Assert.All(rows, row => Assert.True(row.Meta.ContainsKey("k"), "Meta left out")); + } + + private void OrdersAsLoaded(string label, IQueryable source, int count) + { + (List? rows, PolicyQueryable guarded) = Run(label, source); + + if (rows is null) + { + return; + } + + Assert.Equal(count, rows.Count); + Assert.False(ZyLog.AnyDropped(guarded), ZyLog.Decisions(guarded)); + Assert.All(rows, row => Assert.Equal("web", row.Channel?.Name)); + Assert.All(rows, row => Assert.True(row.Meta.ContainsKey("k"), "Meta left out")); + } + + // ------------------------------------------------------------------ controls: expected green everywhere + + [Fact] + public void Zy_R00_control_Select_to_a_navigation() + { + CustomersAsLoaded("R00 Select(o => o.Customer)", _db.Orders.Select(o => o.Customer!), 3); + } + + [Fact] + public void Zy_R01_control_SelectMany_over_a_collection() + { + OrdersAsLoaded("R01 SelectMany(c => c.Orders)", _db.Customers.SelectMany(c => c.Orders), 3); + } + + [Fact] + public void Zy_R02_control_correlated_subquery_over_a_captured_set_property() + { + CustomersAsLoaded( + "R02 SelectMany(o => _db.Customers.Where(..))", + _db.Orders.SelectMany(o => _db.Customers.Where(c => c.Id == o.ZyrCustomerId)), + 3); + } + + [Fact] + public void Zy_R03_control_correlated_subquery_over_DbContext_Set() + { + CustomersAsLoaded( + "R03 SelectMany(o => _db.Set().Where(..))", + _db.Orders.SelectMany(o => _db.Set().Where(c => c.Id == o.ZyrCustomerId)), + 3); + } + + [Fact] + public void Zy_R04_control_correlated_subquery_over_a_repository_property() + { + CustomersAsLoaded( + "R04 SelectMany(o => _repo.CustomerSet.Where(..))", + _db.Orders.SelectMany(o => _repo.CustomerSet.Where(c => c.Id == o.ZyrCustomerId)), + 3); + } + + [Fact] + public void Zy_R05_control_captured_plain_query_variable() + { + IQueryable named = _db.Customers.Where(c => c.Name != string.Empty).AsNoTracking(); + + CustomersAsLoaded( + "R05 SelectMany(o => named.Where(..))", + _db.Orders.SelectMany(o => named.Where(c => c.Id == o.ZyrCustomerId)), + 3); + } + + [Fact] + public void Zy_R06_control_captured_values_in_a_nested_predicate() + { + List regions = new() { "north", "south" }; + ZyrRegionId region = new(1); + DateTime cutoff = new(2000, 1, 1); + + OrdersAsLoaded( + "R06 nested predicate: list.Contains, struct id, DateTime, new DateTime", + _db.Customers.SelectMany(c => c.Orders.Where(o => regions.Contains(o.Region) + && region.Value > 0 + && new DateTime(2100, 1, 1) > cutoff)), + 3); + } + + [Fact] + public void Zy_R07_control_Join_on_a_single_key() + { + CustomersAsLoaded( + "R07 Join single key", + _db.Orders.Join(_db.Customers, o => o.ZyrCustomerId, c => c.Id, (o, c) => c), + 3); + } + + // ------------------------------------------------------------------ what a lambda reads without building + + [Fact] + public void Zy_R10_a_specification_variable_inside_a_correlated_subquery() + { + Expression> named = c => c.Name != string.Empty; + + CustomersAsLoaded( + "R10 SelectMany(o => _db.Customers.Where(specVariable).Where(..))", + _db.Orders.SelectMany(o => _db.Customers.Where(named).Where(c => c.Id == o.ZyrCustomerId)), + 3); + } + + [Fact] + public void Zy_R11_a_static_specification_on_a_navigation_collection() + { + OrdersAsLoaded( + "R11 SelectMany(c => c.Orders.AsQueryable().Where(ZyrSpecs.Open))", + _db.Customers.SelectMany(c => c.Orders.AsQueryable().Where(ZyrSpecs.Open)), + 3); + } + + [Fact] + public void Zy_R12_a_specification_factory_method_on_a_navigation_collection() + { + OrdersAsLoaded( + "R12 SelectMany(c => c.Orders.AsQueryable().Where(ZyrSpecs.InRegion(\"south\")))", + _db.Customers.SelectMany(c => c.Orders.AsQueryable().Where(ZyrSpecs.InRegion("south"))), + 2); + } + + [Fact] + public void Zy_R13_a_static_specification_inside_a_correlated_subquery_over_a_set() + { + CustomersAsLoaded( + "R13 SelectMany(o => _db.Customers.Where(ZyrSpecs.Named).Where(..))", + _db.Orders.SelectMany(o => _db.Customers.Where(ZyrSpecs.Named).Where(c => c.Id == o.ZyrCustomerId)), + 3); + } + + [Fact] + public void Zy_R14_a_repository_method_inside_a_correlated_subquery() + { + CustomersAsLoaded( + "R14 SelectMany(o => _repo.Customers().Where(..))", + _db.Orders.SelectMany(o => _repo.Customers().Where(c => c.Id == o.ZyrCustomerId)), + 3); + } + + [Fact] + public void Zy_R15_Set_through_a_context_interface_inside_a_correlated_subquery() + { + IZyrContext context = _db; + + CustomersAsLoaded( + "R15 SelectMany(o => iface.Set().Where(..))", + _db.Orders.SelectMany(o => context.Set().Where(c => c.Id == o.ZyrCustomerId)), + 3); + } + + [Fact] + public void Zy_R16_FromSqlRaw_inside_a_correlated_subquery() + { + CustomersAsLoaded( + "R16 SelectMany(o => _db.Customers.FromSqlRaw(..).Where(..))", + _db.Orders.SelectMany(o => _db.Customers.FromSqlRaw("SELECT * FROM ZyrCustomers").Where(c => c.Id == o.ZyrCustomerId)), + 3); + } + + [Fact] + public void Zy_R17_a_specification_in_query_syntax() + { + Expression> open = o => o.Code != string.Empty; + + IQueryable source = + from c in _db.Customers + from o in c.Orders.AsQueryable().Where(open) + select o; + + OrdersAsLoaded("R17 from c .. from o in c.Orders.AsQueryable().Where(spec) select o", source, 3); + } + + // ------------------------------------------------------------------ 3.2.0-wide: anonymous keys and carriers + + [Fact] + public void Zy_R20_Join_on_a_composite_key() + { + CustomersAsLoaded( + "R20 Join composite key", + _db.Orders.Join(_db.Customers, o => new { Id = o.ZyrCustomerId, o.Region }, c => new { c.Id, c.Region }, (o, c) => c), + 2); + } + + [Fact] + public void Zy_R21_left_join_in_query_syntax() + { + IQueryable source = + from o in _db.Orders + join c in _db.Customers on o.ZyrCustomerId equals c.Id into cs + from c in cs.DefaultIfEmpty() + select c; + + CustomersAsLoaded("R21 left join (GroupJoin + DefaultIfEmpty)", source, 3); + } + + [Fact] + public void Zy_R22_two_from_clauses_with_a_where() + { + IQueryable source = + from c in _db.Customers + from o in c.Orders + where o.Code != string.Empty + select o; + + OrdersAsLoaded("R22 from c from o in c.Orders where .. select o", source, 3); + } + + [Fact] + public void Zy_R23_GroupBy_on_a_composite_key_then_First() + { + if (typeof(DbContext).Assembly.GetName().Version!.Major < 7) + { + return; + } + + OrdersAsLoaded( + "R23 GroupBy(new { .. }).Select(g => g.OrderBy(..).First())", + _db.Orders.GroupBy(o => new { o.ZyrCustomerId, o.Region }).Select(g => g.OrderBy(o => o.Id).First()), + 3); + } + + [Fact] + public void Zy_R08_control_a_conditional_with_null() + { + CustomersAsLoaded( + "R08 Select(o => o.ZyrCustomerId > 0 ? o.Customer : null)", + _db.Orders.Select(o => o.ZyrCustomerId > 0 ? o.Customer! : null!), + 3); + } + + [Fact] + public void Zy_R09_control_captured_filter_object_in_a_nested_predicate() + { + ZyrFilter filter = new() { Region = "south", Codes = new List { "O2", "O3" } }; + + OrdersAsLoaded( + "R09 nested predicate over a captured filter object's members", + _db.Customers.SelectMany(c => c.Orders.Where(o => o.Region == filter.Region && filter.Codes.Contains(o.Code))), + 2); + } + + [Fact] + public void Zy_R18_control_two_from_clauses_without_a_where() + { + IQueryable source = + from c in _db.Customers + from o in c.Orders + select o; + + OrdersAsLoaded("R18 from c from o in c.Orders select o", source, 3); + } + + [Fact] + public void Zy_R19_control_join_in_query_syntax() + { + IQueryable source = + from o in _db.Orders + join c in _db.Customers on o.ZyrCustomerId equals c.Id + select c; + + CustomersAsLoaded("R19 from o join c .. select c", source, 3); + } + + [Fact] + public void Zy_R25_join_then_where_in_query_syntax() + { + IQueryable source = + from o in _db.Orders + join c in _db.Customers on o.ZyrCustomerId equals c.Id + where o.Code != string.Empty + select c; + + CustomersAsLoaded("R25 from o join c .. where o.Code .. select c", source, 3); + } + + /// Shape only: value-typed calls in predicates and keys (string.Format, interpolation, Convert, ToUpper, Nullable). + [Fact] + public void Zy_R30_control_value_calls_in_predicates_and_keys_are_not_read_as_building() + { + (string Label, System.Linq.Expressions.Expression Expression)[] shapes = + { + ("string.Format in a nested predicate", _db.Customers.SelectMany(c => c.Orders.Where(o => string.Format("{0}", o.Code) == "O1")).Expression), + ("interpolation in a nested predicate", _db.Customers.SelectMany(c => c.Orders.Where(o => $"{o.Code}-{o.Region}" != string.Empty)).Expression), + ("Convert in a nested predicate", _db.Customers.SelectMany(c => c.Orders.Where(o => Convert.ToString(o.ZyrCustomerId) != string.Empty)).Expression), + ("ToUpper key", _db.Orders.GroupBy(o => o.Region.ToUpper()).Select(g => g.OrderBy(o => o.Id).First()).Expression), + ("Nullable key", _db.Orders.GroupBy(o => (int?)o.ZyrCustomerId).Select(g => g.OrderBy(o => o.Id).First()).Expression), + ("EF.Functions in a nested predicate", _db.Customers.SelectMany(c => c.Orders.Where(o => EF.Functions.Like(o.Code, "O%"))).Expression) + }; + + List building = new(); + + foreach ((string label, System.Linq.Expressions.Expression expression) in shapes) + { + string shape = ZyLog.Shape(expression); + + ZyLog.Write(_out, $"R R30 {label}: {shape}"); + + if (shape.Contains("builds=True")) + { + building.Add(label); + } + } + + Assert.Empty(building); + } + + [Fact] + public void Zy_R24_let_clause() + { + IQueryable source = + from o in _db.Orders + let c = o.Customer + where c!.Name != string.Empty + select c; + + CustomersAsLoaded("R24 let c = o.Customer", source, 3); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewReshapeTests.cs b/DynamicWhere.Tests/Policies/ReviewReshapeTests.cs new file mode 100644 index 0000000..efed892 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewReshapeTests.cs @@ -0,0 +1,298 @@ +using System.Collections; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using DynamicWhere.ex.Classes.Complex; +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; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================ reshaped chains: models + + public class ZvShopper + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Cards { get; set; } = new(); + } + + public class ZvPayCard + { + public int Id { get; set; } + + public int ZvShopperId { get; set; } + + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pan { get; set; } + } + + public class ZvPurchase + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public int ZvShopperId { get; set; } + + public ZvShopper? Shopper { get; set; } + } + + /// What an application's mapper does: builds the row type from what the query hands it. + public static class ZvMapper + { + public static ZvShopper Shopper(ZvShopper shopper, List cards) => + new() { Id = shopper.Id, Name = shopper.Name, Cards = cards }; + } + + public sealed class ZvReshapeContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZvReshapeContext(SqliteConnection connection) => _connection = connection; + + public DbSet Shoppers => Set(); + + public DbSet Purchases => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + // ============================================================================ reshaped chains: tests + + /// + /// A reshaped chain with no include and no object built in its lambdas is read from the model. These are + /// chains that load a navigation anyway: an application's method builds the row, or a lambda captures a query + /// with its own projection or include. An EF Core translation failure of the unguarded query is reported, + /// not asserted. + /// + public sealed class ReviewReshapeTests : IDisposable + { + private static readonly JsonSerializerOptions Json = new() { ReferenceHandler = ReferenceHandler.IgnoreCycles }; + + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZvReshapeContext _db; + + public ReviewReshapeTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZvReshapeContext(_connection); + _db.Database.EnsureCreated(); + + ZvShopper shopper = new() { Name = "S1", Cards = { new ZvPayCard { Label = "visa", Pan = "reshape-pan-secret" } } }; + + _db.Shoppers.Add(shopper); + _db.Purchases.Add(new ZvPurchase { Code = "P1", Shopper = shopper }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) + where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static string Sent(object? rows) => JsonSerializer.Serialize(rows, Json); + + /// Runs the unguarded query and the guarded one, and reports what each returned. + private (string Unguarded, string Guarded) Run(string label, IQueryable source, DwTier tier = DwTier.Strict) where T : class + { + string unguarded; + string guarded; + + try + { + unguarded = Sent(source.AsNoTracking().ToList()); + } + catch (Exception e) + { + unguarded = $"THREW {e.GetType().Name}: {e.Message.Split('\n')[0]}"; + } + + try + { + PolicyQueryable handle = Guard(source, tier); + + guarded = Sent(handle.ToList(new Filter()).Data) + " decisions=[" + + string.Join(" | ", handle.LastTrace?.Decisions.Where(d => d.Action != PolicyAction.Allowed) + .Select(d => $"{d.FieldPath} {d.Action}") ?? Array.Empty()) + "]"; + } + catch (Exception e) + { + guarded = $"THREW {e.GetType().Name}: {e.Message.Split('\n')[0]}"; + } + + RowShape shape = RowShape.Of(source); + + _out.WriteLine($"{label}: shape={shape.Kind} materializes(Cards.Pan)={shape.Materializes("Cards.Pan")} " + + $"reshapes={QueryRoot.Reshapes(source.Expression)} builds={QueryRoot.Builds(source.Expression)}"); + _out.WriteLine($"{label}: unguarded={unguarded}"); + _out.WriteLine($"{label}: guarded={guarded}"); + + return (unguarded, guarded); + } + + // ------------------------------------------------------------------------ D1: a mapper builds the row + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zv_D1_a_reshaped_chain_whose_row_a_method_builds_from_a_loaded_collection(DwTier tier) + { + IQueryable source = _db.Purchases.Select(p => ZvMapper.Shopper(p.Shopper!, p.Shopper!.Cards.ToList())); + + (string unguarded, string guarded) = Run("D1", source, tier); + + Assert.Contains("reshape-pan-secret", unguarded); + Assert.DoesNotContain("reshape-pan-secret", guarded); + } + + // ------------------------------------------------------------------------ D2: the projection is behind a closure + + [Fact] + public void Zv_D2_a_projection_captured_in_a_closure_inside_a_SelectMany() + { + IQueryable built = _db.Shoppers.Select(s => new ZvShopper { Id = s.Id, Name = s.Name, Cards = s.Cards }); + IQueryable source = _db.Purchases.SelectMany(p => built.Where(s => s.Id == p.ZvShopperId)); + + (string unguarded, string guarded) = Run("D2 SelectMany", source); + + if (unguarded.StartsWith("THREW", StringComparison.Ordinal)) + { + return; + } + + Assert.DoesNotContain("reshape-pan-secret", guarded); + } + + [Fact] + public void Zv_D2_a_projection_captured_in_a_closure_inside_a_Select() + { + IQueryable built = _db.Shoppers.Select(s => new ZvShopper { Id = s.Id, Name = s.Name, Cards = s.Cards }); + IQueryable source = _db.Purchases.Select(p => built.First(s => s.Id == p.ZvShopperId)); + + (string unguarded, string guarded) = Run("D2 Select", source); + + if (unguarded.StartsWith("THREW", StringComparison.Ordinal)) + { + return; + } + + Assert.DoesNotContain("reshape-pan-secret", guarded); + } + + /// An object captured from memory holds whatever it holds, which no include accounts for. + [Fact] + public void Zv_D2_an_object_captured_from_memory_inside_a_Select() + { + ZvShopper keeper = new() { Id = 99, Name = "keeper", Cards = { new ZvPayCard { Label = "mem", Pan = "memory-pan-secret" } } }; + IQueryable source = _db.Purchases.Select(p => keeper); + + (string unguarded, string guarded) = Run("D2 memory", source); + + if (unguarded.StartsWith("THREW", StringComparison.Ordinal)) + { + return; + } + + Assert.Contains("memory-pan-secret", unguarded); + Assert.DoesNotContain("memory-pan-secret", guarded); + } + + /// The same, with an include in place of the projection: the include is behind the closure too. + [Fact] + public void Zv_D2_an_include_captured_in_a_closure_inside_a_Select() + { + IQueryable withCards = _db.Shoppers.Include(s => s.Cards); + IQueryable source = _db.Purchases.Select(p => withCards.First(s => s.Id == p.ZvShopperId)); + + (string unguarded, string guarded) = Run("D2 include", source); + + if (unguarded.StartsWith("THREW", StringComparison.Ordinal)) + { + return; + } + + Assert.DoesNotContain("reshape-pan-secret", guarded); + } + + // ------------------------------------------------------------------------ ordinary EF Core queries and the epoch + + // ------------------------------------------------------------------------ D3/D4: ruled-out shapes + + /// An object built only inside a Where subquery loads nothing into the rows. + [Fact] + public void Zv_D3_an_object_built_only_inside_a_Where_subquery() + { + IQueryable source = _db.Purchases + .Where(p => _db.Shoppers.Select(s => new { s.Id, s.Name }).Any(x => x.Id == p.ZvShopperId)) + .Select(p => p.Shopper!); + + (string unguarded, string guarded) = Run("D3", source); + + Assert.DoesNotContain("reshape-pan-secret", unguarded); + Assert.DoesNotContain("reshape-pan-secret", guarded); + } + + /// A Join whose inner source projects: the construction is an argument of the chain, so it is seen. + [Fact] + public void Zv_D4_a_Join_whose_inner_source_projects() + { + IQueryable source = _db.Purchases.Join( + _db.Shoppers.Select(s => new ZvShopper { Id = s.Id, Name = s.Name, Cards = s.Cards }), + p => p.ZvShopperId, + s => s.Id, + (p, s) => s); + + (string unguarded, string guarded) = Run("D4", source); + + if (unguarded.StartsWith("THREW", StringComparison.Ordinal)) + { + return; + } + + Assert.DoesNotContain("reshape-pan-secret", guarded); + } + + /// A Join whose inner source is a projection held in a variable: still an argument, still seen. + [Fact] + public void Zv_D4_a_Join_whose_inner_source_is_a_projection_in_a_variable() + { + IQueryable built = _db.Shoppers.Select(s => new ZvShopper { Id = s.Id, Name = s.Name, Cards = s.Cards }); + IQueryable source = _db.Purchases.Join(built, p => p.ZvShopperId, s => s.Id, (p, s) => s); + + (string unguarded, string guarded) = Run("D4 variable", source); + + if (unguarded.StartsWith("THREW", StringComparison.Ordinal)) + { + return; + } + + Assert.DoesNotContain("reshape-pan-secret", guarded); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewSharedNameEfTests.cs b/DynamicWhere.Tests/Policies/ReviewSharedNameEfTests.cs new file mode 100644 index 0000000..c97c79c --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewSharedNameEfTests.cs @@ -0,0 +1,300 @@ +using System.Linq.Expressions; +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +// Members sharing a name on an EF Core entity, read from what the query loads; a denied value a derived type hides with +// new, read as its base; and a join on a filtered set, which holds its query inline. + +namespace DynamicWhere.Tests.Policies +{ + public class ZwAuthorCard + { + public int Id { get; set; } + + public int ZwAuthorId { get; set; } + + [DwDenied] + public string? Pan { get; set; } + } + + public class ZwAuthor + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// Never loaded by any probe; the only denial is beneath it. + public List Cards { get; set; } = new(); + } + + public class ZwDocBase + { + public int Id { get; set; } + + public string Title { get; set; } = string.Empty; + + public int? ZwAuthorId { get; set; } + + public virtual ZwAuthor? Author { get; set; } + } + + /// Re-declares the navigation with new, the same type (to change an annotation, say). + public class ZwDoc : ZwDocBase + { + public new ZwAuthor? Author + { + get => base.Author; + set => base.Author = value; + } + } + + public sealed class ZwDocContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZwDocContext(SqliteConnection connection) => _connection = connection; + + public DbSet Docs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + public class ZwInfoBase + { + public int Id { get; set; } + + [DwDenied] + public string? Info { get; set; } + } + + public class ZwPlainThing + { + public string Label { get; set; } = string.Empty; + } + + /// Hides the denied value with an object of a clean type. + public class ZwInfoDerived : ZwInfoBase + { + public new ZwPlainThing? Info { get; set; } + } + + /// The same beside a top-level denial, so a projection is built. + public class ZwInfoDerivedDenied : ZwInfoBase + { + [DwDenied] + public string? Ssn { get; set; } + + public new ZwPlainThing? Info { get; set; } + } + + public class ZwVipAuthor : ZwAuthor + { + public string Level { get; set; } = string.Empty; + } + + public class ZwDocBase2 + { + public int Id { get; set; } + + public string Title { get; set; } = string.Empty; + + public int? ZwAuthorId { get; set; } + + public ZwAuthor? Author { get; set; } + } + + /// Re-declares the navigation with new, under a derived type. + public class ZwDoc2 : ZwDocBase2 + { + public new ZwVipAuthor? Author { get; set; } + } + + public sealed class ZwDoc2Context : DbContext + { + private readonly SqliteConnection _connection; + + public ZwDoc2Context(SqliteConnection connection) => _connection = connection; + + public DbSet Docs => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + public sealed class ReviewSharedNameEfTests + { + private readonly ITestOutputHelper _out; + + public ReviewSharedNameEfTests(ITestOutputHelper output) => _out = output; + + [Fact] + public void Zw_N1_an_entity_redeclaring_an_unloaded_navigation_with_new_is_not_projected() + { + using SqliteConnection connection = new("DataSource=:memory:"); + connection.Open(); + + using ZwDocContext db = new(connection); + string model; + + try + { + db.Database.EnsureCreated(); + model = "built"; + } + catch (Exception e) + { + model = $"THREW {e.GetType().Name}: {e.Message.Split('\n')[0]}"; + } + + _out.WriteLine($"N1 model {model}; Author members: " + string.Join(", ", typeof(ZwDoc).GetProperties().Where(p => p.Name == "Author").Select(p => p.DeclaringType!.Name))); + + if (!model.StartsWith("built", StringComparison.Ordinal)) + { + return; + } + + db.Docs.Add(new ZwDoc { Title = "d1", Author = new ZwAuthor { Name = "a1", Cards = { new ZwAuthorCard { Pan = "zw-author-pan" } } } }); + db.SaveChanges(); + db.ChangeTracker.Clear(); + + PolicyQueryable guarded = ZwKit.Guard(db.Docs); + object? data = null; + string code = ZwKit.Code(() => data = guarded.ToList(new Filter()).Data); + + _out.WriteLine($"N1 code={code} sent={ZwKit.Json(data)} trace=[{ZwKit.Trace(guarded)}]"); + + Assert.Equal("ran", code); + Assert.False(ZwKit.AnyDropped(guarded), ZwKit.Trace(guarded)); + } + + [Fact] + public void Zw_N1b_an_entity_redeclaring_a_navigation_with_new_under_a_derived_type() + { + using SqliteConnection connection = new("DataSource=:memory:"); + connection.Open(); + + using ZwDoc2Context db = new(connection); + string model; + + try + { + db.Database.EnsureCreated(); + model = "built: " + string.Join(", ", db.Model.FindEntityType(typeof(ZwDoc2))!.GetNavigations().Select(n => $"{n.Name}->{n.TargetEntityType.ClrType.Name}")); + } + catch (Exception e) + { + model = $"THREW {e.GetType().Name}: {e.Message.Split('\n')[0]}"; + } + + _out.WriteLine($"N1b model {model}"); + + if (!model.StartsWith("built", StringComparison.Ordinal)) + { + return; + } + + db.Docs.Add(new ZwDoc2 { Title = "d1", Author = new ZwVipAuthor { Name = "a1", Level = "gold", Cards = { new ZwAuthorCard { Pan = "zw-author-pan" } } } }); + db.SaveChanges(); + db.ChangeTracker.Clear(); + + PolicyQueryable guarded = ZwKit.Guard(db.Docs); + object? data = null; + string code = ZwKit.Code(() => data = guarded.ToList(new Filter()).Data); + + _out.WriteLine($"N1b code={code} sent={ZwKit.Json(data)} trace=[{ZwKit.Trace(guarded)}]"); + + // The caller includes the author (not its cards, beneath which the only denial sits). + PolicyQueryable included = ZwKit.Guard(db.Docs.Include(d => d.Author)); + List? rows = null; + string code2 = ZwKit.Code(() => rows = included.ToList(new Filter()).Data); + + _out.WriteLine($"N1b Include(d => d.Author): code={code2} sent={ZwKit.Json(rows)} trace=[{ZwKit.Trace(included)}]"); + + Assert.Equal("ran", code); + Assert.False(ZwKit.AnyDropped(guarded), ZwKit.Trace(guarded)); + Assert.Equal("ran", code2); + Assert.Equal("a1", rows![0].Author?.Name); + } + + [Fact] + public void Zw_N2_a_hidden_denied_value_serialized_through_the_base_type() + { + ZwHideValueDerived row = new() { Id = 1, Code = "open" }; + ((ZwHideValueBase)row).Code = "zw-hidden-base-secret"; + + PolicyQueryable guarded = ZwKit.Guard(new[] { row }.AsQueryable()); + List sent = guarded.ToList(new Filter()).Data; + + // An API whose response type is the base class, as a controller returning List does. + string asBase = JsonSerializer.Serialize(sent.Cast().ToList()); + + _out.WriteLine($"N2 as derived={JsonSerializer.Serialize(sent)} as base={asBase} trace=[{ZwKit.Trace(guarded)}] denials(Code)=" + + string.Join(",", new AttributePolicyProvider() + .GetFragments(typeof(ZwHideValueDerived), new DwPolicyContext()) + .Where(f => f.FieldPath == "Code").Select(f => f.Effect))); + + Assert.DoesNotContain("zw-hidden-base-secret", asBase); + } + + [Fact] + public void Zw_N3_why_a_join_on_a_filtered_set_reads_as_building() + { + using SqliteConnection connection = new("DataSource=:memory:"); + connection.Open(); + + using ZwContext db = new(connection); + IQueryable source = db.Orders.Join(db.Customers.Where(c => c.Region != string.Empty), o => o.ZwCustomerId, c => c.Id, (o, c) => c); + MethodCallExpression join = (MethodCallExpression)source.Expression; + Expression inner = join.Arguments[1]; + string evaluated; + + try + { + object? value = Expression.Lambda>(Expression.Convert(inner, typeof(object))).Compile(preferInterpretation: true)(); + + evaluated = "ok " + value?.GetType().Name; + } + catch (Exception e) + { + evaluated = $"THREW {e.GetType().Name}: {e.Message.Split('\n')[0]}"; + } + + _out.WriteLine($"N3 inner={inner.NodeType} {inner.GetType().Name} arg0={((MethodCallExpression)inner).Arguments[0].GetType().Name} evaluated: {evaluated} {ZwKit.Shape(source.Expression)}"); + + // Diagnostic: the inner sequence cannot be compiled on its own (an inline EF Core root is not reducible), and + // the guard must not read that as building. + Assert.Contains("builds=False", ZwKit.Shape(source.Expression)); + } + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zw_N4_a_denied_value_hidden_by_an_object_of_a_clean_type(DwTier tier) + { + ZwInfoDerived row = new() { Id = 1, Info = new ZwPlainThing { Label = "x" } }; + ((ZwInfoBase)row).Info = "zw-info-base-secret"; + ZwInfoDerivedDenied denied = new() { Id = 2, Ssn = "ssn", Info = new ZwPlainThing { Label = "y" } }; + ((ZwInfoBase)denied).Info = "zw-info-base-secret-2"; + + PolicyQueryable guarded = ZwKit.Guard(new[] { row }.AsQueryable(), tier); + object? data = ZwKit.SafeRead(() => guarded.ToList(new Filter()).Data); + PolicyQueryable guarded2 = ZwKit.Guard(new[] { denied }.AsQueryable(), tier); + object? data2 = ZwKit.SafeRead(() => guarded2.ToList(new Filter()).Data); + + string asBase = data is List a ? JsonSerializer.Serialize(a.Cast().ToList()) : "null"; + string asBase2 = data2 is List b ? JsonSerializer.Serialize(b.Cast().ToList()) : "null"; + + _out.WriteLine($"N4 {tier}: as base={asBase} trace=[{ZwKit.Trace(guarded)}]"); + _out.WriteLine($"N4 {tier} projected: as base={asBase2} trace=[{ZwKit.Trace(guarded2)}]"); + + Assert.False(ZwKit.Holds(data, "zw-info-base-secret")); + Assert.False(ZwKit.Holds(data2, "zw-info-base-secret-2")); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewSharedNameTests.cs b/DynamicWhere.Tests/Policies/ReviewSharedNameTests.cs new file mode 100644 index 0000000..cb06b3e --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewSharedNameTests.cs @@ -0,0 +1,183 @@ +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================ other declarations: models + + /// A base type whose denied member a subtype overrides with a covariant return type (C# 9). + public class ZxCovBase + { + protected ZxBaseCard? CardValue; + + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + [DwDenied] + public virtual ZxBaseCard? Card => CardValue; + + public void Put(ZxBaseCard card) => CardValue = card; + } + + public class ZxCovDerived : ZxCovBase + { + public override ZxDerivedCard? Card => CardValue as ZxDerivedCard; + } + + /// The same with a scalar beneath, the denial on the abstract base declaration. + public abstract class ZxCovAbstract + { + public int Id { get; set; } + + [DwDenied] + public abstract ZxBaseCard? Card { get; } + } + + public class ZxCovConcrete : ZxCovAbstract + { + public ZxDerivedCard? Held { private get; set; } + + public override ZxDerivedCard? Card => Held; + } + + // ---- a default interface member that a derived interface overrides explicitly, with the denial there ---- + + public interface IZxCoded + { + string? Code { get; } + } + + public interface IZxSecretCoded : IZxCoded + { + string? Raw { get; } + + [DwDenied] + string? IZxCoded.Code => Raw; + } + + public class ZxCodedImpl : IZxSecretCoded + { + public string? Raw { get; set; } + } + + public class ZxCodedRow + { + public int Id { get; set; } + + public IZxCoded? Coded { get; set; } + } + + // ---- T itself hides a base member with new, typed as an object holding a denied field ---- + + public class ZxHideBase + { + public int Id { get; set; } + + public string? Info { get; set; } + } + + public class ZxHideDerived : ZxHideBase + { + public new ZxPayCard? Info { get; set; } + } + + /// The same beside a top-level denial, so a projection is built. + public class ZxHideDerivedDenied : ZxHideBase + { + [DwDenied] + public string? Ssn { get; set; } + + public new ZxPayCard? Info { get; set; } + } + + // ============================================================================ other declarations: tests + + public sealed class ReviewSharedNameTests + { + private readonly ITestOutputHelper _out; + + public ReviewSharedNameTests(ITestOutputHelper output) => _out = output; + + [Fact] + public void Zx_D1_a_covariant_override_keeps_the_base_declarations_denial() + { + Assert.NotEmpty(ZxKit.Denials(typeof(ZxCovDerived), "Card")); + Assert.NotEmpty(ZxKit.Denials(typeof(ZxCovConcrete), "Card")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_D1_rows_of_the_covariant_subtype_do_not_return_the_denied_member(DwTier tier) + { + ZxCovDerived row = new() { Id = 1, Name = "r1" }; + row.Put(new ZxDerivedCard { Label = "covariant-card-secret" }); + ZxCovConcrete concrete = new() { Id = 2, Held = new ZxDerivedCard { Label = "abstract-covariant-secret" } }; + + PolicyQueryable guarded = ZxKit.Guard(new[] { row }.AsQueryable(), tier); + object? data = ZxKit.SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine($"{tier}: sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + PolicyQueryable guarded2 = ZxKit.Guard(new[] { concrete }.AsQueryable(), tier); + object? data2 = ZxKit.SafeRead(() => guarded2.ToList(new Filter()).Data); + + _out.WriteLine($"{tier}: sent={ZxKit.Json(data2)} trace=[{ZxKit.Trace(guarded2)}]"); + + Assert.False(ZxKit.Holds(data, "covariant-card-secret")); + Assert.False(ZxKit.Holds(data2, "abstract-covariant-secret")); + } + + [Fact] + public void Zx_D4_a_denial_on_a_derived_interfaces_explicit_default_implementation() + { + ZxCodedRow[] rows = { new() { Id = 1, Coded = new ZxCodedImpl { Raw = "dim-code-secret" } } }; + + _out.WriteLine("runs: " + ((IZxCoded)rows[0].Coded!).Code); + _out.WriteLine("Coded.Code denials: " + ZxKit.Denials(typeof(ZxCodedRow), "Coded.Code").Count()); + + string where = ZxKit.Code(() => ZxKit.Guard(rows.AsQueryable()).ToList(ZxKit.Where("Coded.Code", "dim-code-secret"))); + object? data = ZxKit.SafeRead(() => ZxKit.Guard(rows.AsQueryable()).ToList(new Filter()).Data); + + _out.WriteLine($"where={where} sent={ZxKit.Json(data)}"); + + Assert.NotEqual("ran", where); + Assert.False(ZxKit.Holds(data, "dim-code-secret") && ZxKit.Json(data).Contains("dim-code-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_D5_a_row_type_that_hides_a_value_member_with_an_object_holding_a_denied_field(DwTier tier) + { + PropertyInfo[] infos = typeof(ZxHideDerived).GetProperties().Where(p => p.Name == "Info").ToArray(); + + _out.WriteLine("GetProperties order: " + string.Join(", ", infos.Select(p => $"{p.DeclaringType!.Name}.{p.Name}:{p.PropertyType.Name}"))); + _out.WriteLine("Info.Pan denials: " + ZxKit.Denials(typeof(ZxHideDerived), "Info.Pan").Count()); + + ZxHideDerived[] rows = { new() { Id = 1, Info = new ZxPayCard { Label = "visa", Pan = "hide-pan-secret" } } }; + ZxHideDerivedDenied[] denied = { new() { Id = 2, Ssn = "hide-ssn", Info = new ZxPayCard { Label = "visa", Pan = "hide-pan-secret-2" } } }; + + PolicyQueryable guarded = ZxKit.Guard(rows.AsQueryable(), tier); + object? data = ZxKit.SafeRead(() => guarded.ToList(new Filter()).Data); + object? named = ZxKit.SafeRead(() => ZxKit.Guard(rows.AsQueryable(), tier).ToList(ZxKit.Selecting("Id", "Info")).Data); + PolicyQueryable guarded2 = ZxKit.Guard(denied.AsQueryable(), tier); + object? data2 = ZxKit.SafeRead(() => guarded2.ToList(new Filter()).Data); + + _out.WriteLine($"{tier} none: sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + _out.WriteLine($"{tier} named: sent={ZxKit.Json(named)}"); + _out.WriteLine($"{tier} projected: sent={ZxKit.Json(data2)} trace=[{ZxKit.Trace(guarded2)}]"); + + Assert.False(ZxKit.Holds(data, "hide-pan-secret")); + Assert.False(ZxKit.Holds(named, "hide-pan-secret")); + Assert.False(ZxKit.Holds(data2, "hide-pan-secret-2")); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewSubtypeIndexTests.cs b/DynamicWhere.Tests/Policies/ReviewSubtypeIndexTests.cs new file mode 100644 index 0000000..f894168 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewSubtypeIndexTests.cs @@ -0,0 +1,136 @@ +using System.Collections; +using System.Diagnostics; +using System.Reflection; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Context; +using DynamicWhere.ex.Policies.DTOs; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Resolution; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================ the subtype index: models + + public class ZvIntBox : ZvBox + { + } + + public class ZvStringBox : ZvBox + { + } + + public class ZvMidBox : ZvBox + { + } + + public class ZvLeafBox : ZvMidBox + { + } + + /// An open generic type whose ancestor is closed: it can never be a ZvBox<int>. + public class ZvFixedBox : ZvBox + { + [DwNoGroup] + public override string? Code + { + get => base.Code; + set => base.Code = value; + } + } + + public class ZvDerivedHolder : ZvHolderImpl + { + } + + /// The base type a late-loaded assembly derives from; nothing else in this assembly does. + public class ZvLateBase + { + public int Id { get; set; } + + public virtual string? Code { get; set; } + } + + public class ZvJobRow + { + public int Id { get; set; } + + public string Status { get; set; } = string.Empty; + + public Exception? Error { get; set; } + } + + // ============================================================================ the subtype index: tests + + public sealed class ReviewSubtypeIndexTests + { + private readonly ITestOutputHelper _out; + + public ReviewSubtypeIndexTests(ITestOutputHelper output) => _out = output; + + private static PolicyQueryable Guard(IQueryable source) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = DwTier.Strict, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + private static int Denials(Type type, string path, PolicyFeature feature) => + new AttributePolicyProvider().GetFragments(type, new DwPolicyContext()) + .Count(f => f.Effect == PolicyEffect.Deny && f.FieldPath == path && (f.Features & feature) != 0); + + // ------------------------------------------------------------------------------------------------ C1 + + [Fact] + public void Zv_C1_generic_subtypes_are_indexed_under_the_instantiations_they_can_be() + { + IReadOnlyList ofIntBox = KnownSubtypes.Of(typeof(ZvBox)); + IReadOnlyList ofHolder = KnownSubtypes.Of(typeof(IZvHolder)); + + _out.WriteLine("Of(ZvBox): " + string.Join(", ", ofIntBox.Select(t => t.Name))); + _out.WriteLine("Of(IZvHolder): " + string.Join(", ", ofHolder.Select(t => t.Name))); + + Assert.Contains(typeof(ZvIntBox), ofIntBox); + Assert.Contains(typeof(ZvLeafBox), ofIntBox); + Assert.Contains(typeof(ZvSecretBox<>), ofIntBox); + Assert.Contains(typeof(ZvMidBox<>), ofIntBox); + Assert.DoesNotContain(typeof(ZvStringBox), ofIntBox); + + Assert.Contains(typeof(ZvHolderImpl<>), ofHolder); + Assert.Contains(typeof(ZvDerivedHolder<>), ofHolder); + Assert.Contains(typeof(ZvGenPlainHolder<>), KnownSubtypes.Of(typeof(IZvPlainHolder))); + } + + /// What the interface map says for an open generic implementation and a closed interface. + [Fact] + public void Zv_C1_the_interface_map_of_an_open_generic_implementation_for_a_closed_interface() + { + Exception? closed = Record.Exception(() => typeof(ZvHolderImpl<>).GetInterfaceMap(typeof(IZvHolder))); + Exception? open = Record.Exception(() => typeof(ZvHolderImpl<>).GetInterfaceMap(typeof(ZvHolderImpl<>).GetInterfaces()[0])); + + _out.WriteLine($"GetInterfaceMap(ZvHolderImpl<>, IZvHolder): {closed?.GetType().Name ?? "ok"} {closed?.Message}"); + _out.WriteLine($"GetInterfaceMap(ZvHolderImpl<>, IZvHolder): {open?.GetType().Name ?? "ok"}"); + + Assert.NotNull(closed); + Assert.Null(open); + } + + /// + /// An open generic type whose ancestor is a different closed instantiation is not a subtype of this one, + /// so its override's denial does not reach a path no row of it can be on. + /// + [Fact] + public void Zv_C1_an_open_generic_type_over_another_closed_instantiation_is_not_its_subtype() + { + _out.WriteLine("Of(ZvBox) holds ZvFixedBox<>: " + KnownSubtypes.Of(typeof(ZvBox)).Contains(typeof(ZvFixedBox<>))); + _out.WriteLine("ZvBox.Code group denials: " + Denials(typeof(ZvBox), "Code", PolicyFeature.Group)); + _out.WriteLine("ZvBox.Code group denials: " + Denials(typeof(ZvBox), "Code", PolicyFeature.Group)); + + // ZvFixedBox derives from ZvBox only; a ZvBox is never one, and a ZvBox can be. + Assert.DoesNotContain(typeof(ZvFixedBox<>), KnownSubtypes.Of(typeof(ZvBox))); + Assert.Contains(typeof(ZvFixedBox<>), KnownSubtypes.Of(typeof(ZvBox))); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewTrackingKit.cs b/DynamicWhere.Tests/Policies/ReviewTrackingKit.cs new file mode 100644 index 0000000..bc40cd8 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewTrackingKit.cs @@ -0,0 +1,201 @@ +using System.Collections; +using System.Reflection; +using System.Text.Json; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Exceptions; +using DynamicWhere.ex.Policies.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; + +namespace DynamicWhere.Tests.Policies +{ + /// Shared helpers for the tracking, variance, converter, shared-name and collection tests. + internal static class ZxKit + { + internal static PolicyQueryable Guard(IQueryable source, DwTier tier = DwTier.Strict) where T : class => + source.ApplyPolicy( + new DwPolicyContext().WithSubject(DwSubjectKind.User, "u1"), + new DwPolicyOptions { Tier = tier, Caps = { MinGroupSize = 1 } }, + new PolicyResolver(new IDwPolicyProvider[] { new AttributePolicyProvider() })); + + internal static Filter Where(string field, string value) => new() + { + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Sort = 1, Field = field, DataType = DataType.Text, Operator = Operator.Equal, Values = { value } } } + } + }; + + internal static Filter Selecting(params string[] fields) => new() { Selects = fields.ToList() }; + + /// The refusal code, "ran" when it ran, or the other exception. + internal static string Code(Action run) + { + try + { + run(); + + return "ran"; + } + catch (PolicyException refusal) + { + return refusal.ErrorCode.ToString(); + } + catch (Exception other) + { + return $"{other.GetType().Name}: {other.Message.Split('\n')[0]}"; + } + } + + /// Runs a read; a policy refusal or a core refusal returns null (nothing reached the caller). + internal static object? SafeRead(Func read) + { + try + { + return read(); + } + catch (PolicyException) + { + return null; + } + catch (LogicException) + { + return null; + } + } + + internal static IEnumerable Denials(Type type, string path) => + new AttributePolicyProvider().GetFragments(type, new DwPolicyContext()) + .Where(f => f.Effect == PolicyEffect.Deny && string.Equals(f.FieldPath, path, StringComparison.OrdinalIgnoreCase)); + + internal static string Trace(PolicyQueryable guarded) where T : class => + string.Join(" | ", guarded.LastTrace?.Decisions + .Where(d => d.Action != PolicyAction.Allowed) + .Select(d => $"{d.FieldPath} {d.Action}: {d.Reason}") ?? Array.Empty()); + + internal static string Json(object? value) + { + try + { + return JsonSerializer.Serialize(value, new JsonSerializerOptions + { + ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles + }); + } + catch (Exception e) + { + return $""; + } + } + + /// Everything reachable from a value, read by runtime type (public and non-public properties and fields). + internal static bool Holds(object? value, string text) + { + HashSet seen = new(ReferenceEqualityComparer.Instance); + Stack pending = new(); + + pending.Push(value); + + while (pending.Count > 0) + { + object? current = pending.Pop(); + + if (current is null || current is MemberInfo || current is Delegate + || (current.GetType().Namespace ?? string.Empty).StartsWith("Microsoft.", StringComparison.Ordinal)) + { + continue; + } + + if (current is string held) + { + if (held.Contains(text, StringComparison.Ordinal)) + { + return true; + } + + continue; + } + + if (current.GetType().IsPrimitive || current is decimal || current is DateTime || current is Guid) + { + continue; + } + + if (!current.GetType().IsValueType && !seen.Add(current)) + { + continue; + } + + if (current is IDictionary map) + { + foreach (DictionaryEntry item in map) + { + pending.Push(item.Key); + pending.Push(item.Value); + } + } + else if (current is IEnumerable items) + { + try + { + foreach (object? item in items) + { + pending.Push(item); + } + } + catch + { + // An enumerator that throws holds nothing readable. + } + } + + const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + + foreach (PropertyInfo property in current.GetType().GetProperties(all)) + { + if (property.GetIndexParameters().Length != 0 || !property.CanRead) + { + continue; + } + + try + { + pending.Push(property.GetValue(current)); + } + catch + { + // A getter that throws holds nothing readable. + } + } + + for (Type? type = current.GetType(); type is not null && type != typeof(object); type = type.BaseType) + { + if (type.Namespace is { } ns && (ns == "System" || ns.StartsWith("System.", StringComparison.Ordinal) + || ns.StartsWith("Microsoft.", StringComparison.Ordinal))) + { + continue; + } + + foreach (FieldInfo field in type.GetFields(all | BindingFlags.DeclaredOnly)) + { + try + { + pending.Push(field.GetValue(current)); + } + catch + { + // Unreadable. + } + } + } + } + + return false; + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewTrackingTests.cs b/DynamicWhere.Tests/Policies/ReviewTrackingTests.cs new file mode 100644 index 0000000..675f963 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewTrackingTests.cs @@ -0,0 +1,325 @@ +using System.Collections; +using System.Linq.Expressions; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.ex.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================ reshaped chains and tracking: models + + public class ZxShopper + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Cards { get; set; } = new(); + } + + public class ZxPayCard + { + public int Id { get; set; } + + public int ZxShopperId { get; set; } + + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pan { get; set; } + } + + public class ZxPurchase + { + public int Id { get; set; } + + public string Code { get; set; } = string.Empty; + + public int ZxShopperId { get; set; } + + public ZxShopper? Shopper { get; set; } + } + + /// An application helper whose result is only a flag, and which hands the row its cards on the way. + public static class ZxHydrator + { + public static bool Attach(ZxShopper shopper, List cards) + { + shopper.Cards = cards; + + return true; + } + } + + /// The same, done by a constructor whose object is only read for a flag. + public sealed class ZxAttachment + { + public ZxAttachment(ZxShopper shopper, List cards) => shopper.Cards = cards; + + public bool Done => true; + } + + public class ZxMaskedNote + { + public int Id { get; set; } + + [DwMask(MaskStrategy.Full)] + public string Body { get; set; } = string.Empty; + } + + public sealed class ZxShopContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZxShopContext(SqliteConnection connection) => _connection = connection; + + public DbSet Shoppers => Set(); + + public DbSet Cards => Set(); + + public DbSet Purchases => Set(); + + public DbSet Notes => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + /// + /// A queryable that forwards to another provider, as LinqKit's AsExpandable, DelegateDecompiler's Decompile and + /// similar wrappers do over an EF Core query. Its provider is not EF Core's, so EF Core's AsNoTracking leaves it + /// unchanged. + /// + public sealed class ZxForwardingQuery : IOrderedQueryable + { + private readonly IQueryable _inner; + + public ZxForwardingQuery(IQueryable inner) + { + _inner = inner; + Provider = new ZxForwardingProvider(inner.Provider); + } + + public Type ElementType => typeof(T); + + public Expression Expression => _inner.Expression; + + public IQueryProvider Provider { get; } + + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + public sealed class ZxForwardingProvider : IQueryProvider + { + private readonly IQueryProvider _inner; + + public ZxForwardingProvider(IQueryProvider inner) => _inner = inner; + + public IQueryable CreateQuery(Expression expression) + { + IQueryable created = _inner.CreateQuery(expression); + + return (IQueryable)Activator.CreateInstance(typeof(ZxForwardingQuery<>).MakeGenericType(created.ElementType), created)!; + } + + public IQueryable CreateQuery(Expression expression) => + new ZxForwardingQuery(_inner.CreateQuery(expression)); + + public object? Execute(Expression expression) => _inner.Execute(expression); + + public TResult Execute(Expression expression) => _inner.Execute(expression); + } + + // ============================================================================ reshaped chains and tracking: tests + + public sealed class ReviewTrackingTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZxShopContext _db; + + public ReviewTrackingTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZxShopContext(_connection); + _db.Database.EnsureCreated(); + + ZxShopper shopper = new() { Name = "S1", Cards = { new ZxPayCard { Label = "visa", Pan = "zx-pan-secret" } } }; + + _db.Shoppers.Add(shopper); + _db.Purchases.Add(new ZxPurchase { Code = "P1", Shopper = shopper }); + _db.Notes.Add(new ZxMaskedNote { Body = "original-note-body" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private (string Unguarded, object? Guarded) Run(string label, IQueryable source, DwTier tier, bool dynamic = false) where T : class + { + string unguarded; + + try + { + unguarded = ZxKit.Holds(source.AsNoTracking().ToList(), "zx-pan-secret") ? "HOLDS PAN" : "no pan"; + } + catch (Exception e) + { + unguarded = $"THREW {e.GetType().Name}: {e.Message.Split('\n')[0]}"; + } + + PolicyQueryable guarded = ZxKit.Guard(source, tier); + object? data; + + try + { + data = dynamic ? guarded.ToListDynamic(new Filter()).Data : guarded.ToList(new Filter()).Data; + } + catch (Exception e) + { + data = null; + _out.WriteLine($"{label}: guarded threw {e.GetType().Name}: {e.Message.Split('\n')[0]}"); + } + + RowShape shape = RowShape.Of(source); + + _out.WriteLine($"{label} {tier}: shape={shape.Kind} materializes(Cards.Pan)={shape.Materializes("Cards.Pan")} " + + $"reshapes={QueryRoot.Reshapes(source.Expression)} builds={QueryRoot.Builds(source.Expression)}"); + _out.WriteLine($"{label} {tier}: unguarded={unguarded}"); + _out.WriteLine($"{label} {tier}: guarded={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + return (unguarded, data); + } + + // ------------------------------------------------------------------------ B1: a flag-typed subtree that hands the row its cards + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_B1_an_application_method_read_only_for_a_flag_in_a_conditional(DwTier tier) + { + IQueryable source = _db.Purchases + .Select(p => ZxHydrator.Attach(p.Shopper!, p.Shopper!.Cards.ToList()) ? p.Shopper! : p.Shopper!); + + (string unguarded, object? guarded) = Run("B1", source, tier); + + if (!unguarded.StartsWith("HOLDS", StringComparison.Ordinal)) + { + return; + } + + Assert.False(ZxKit.Holds(guarded, "zx-pan-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_B2_a_constructor_read_only_for_a_flag_in_a_conditional(DwTier tier) + { + IQueryable source = _db.Purchases + .Select(p => new ZxAttachment(p.Shopper!, p.Shopper!.Cards.ToList()).Done ? p.Shopper! : p.Shopper!); + + (string unguarded, object? guarded) = Run("B2", source, tier); + + if (!unguarded.StartsWith("HOLDS", StringComparison.Ordinal)) + { + return; + } + + Assert.False(ZxKit.Holds(guarded, "zx-pan-secret")); + } + + // ------------------------------------------------------------------------ T: a provider that wraps EF Core runs a tracking query + + [Theory] + [InlineData(DwTier.Strict, false)] + [InlineData(DwTier.Convenience, false)] + [InlineData(DwTier.Strict, true)] + public void Zx_T1_a_wrapped_query_over_a_context_that_already_tracks_the_cards(DwTier tier, bool dynamic) + { + // Earlier in the same unit of work, the application read the cards with tracking. + _ = _db.Cards.ToList(); + + IQueryable source = new ZxForwardingQuery(_db.Shoppers); + + (_, object? guarded) = Run("T1", source, tier, dynamic); + + Assert.False(ZxKit.Holds(guarded, "zx-pan-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_T2_a_wrapped_query_over_a_context_that_already_tracks_the_shopper_with_its_cards(DwTier tier) + { + _ = _db.Shoppers.Include(s => s.Cards).ToList(); + + IQueryable source = new ZxForwardingQuery(_db.Shoppers); + + (_, object? guarded) = Run("T2", source, tier); + + Assert.False(ZxKit.Holds(guarded, "zx-pan-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_T3_control_the_same_query_unwrapped(DwTier tier) + { + _ = _db.Cards.ToList(); + + (_, object? guarded) = Run("T3", _db.Shoppers, tier); + + Assert.False(ZxKit.Holds(guarded, "zx-pan-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_T4_a_wrapped_reshaped_query_over_tracked_cards(DwTier tier) + { + _ = _db.Cards.ToList(); + + IQueryable source = new ZxForwardingQuery(_db.Purchases).Select(p => p.Shopper!); + + (_, object? guarded) = Run("T4", source, tier); + + Assert.False(ZxKit.Holds(guarded, "zx-pan-secret")); + } + + /// + /// Guarded() detaches the query so that a transform applied to a materialized entity is never written back. + /// Through a wrapping provider the query tracks, and the masked value is the entity's pending state. + /// + [Fact] + public void Zx_T5_a_wrapped_query_leaves_a_masked_value_as_a_pending_change() + { + IQueryable source = new ZxForwardingQuery(_db.Notes); + + object? data = ZxKit.Guard(source).ToList(new Filter()).Data; + + _out.WriteLine($"sent={ZxKit.Json(data)} tracked={_db.ChangeTracker.Entries().Count()} hasChanges={_db.ChangeTracker.HasChanges()}"); + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + + string stored = _db.Notes.AsNoTracking().Single().Body; + + _out.WriteLine($"stored after SaveChanges: {stored}"); + + Assert.Equal("original-note-body", stored); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs b/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs new file mode 100644 index 0000000..301ac80 --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewTrackingWrapperTests.cs @@ -0,0 +1,332 @@ +using System.Collections; +using System.Linq.Expressions; +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Source; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; +using Xunit.Abstractions; + +// A guarded query through a provider wrapping EF Core's runs untracked: AsNoTracking goes into the query itself. + +namespace DynamicWhere.Tests.Policies +{ + public class ZwShopper + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public List Cards { get; set; } = new(); + } + + public class ZwPayCard + { + public int Id { get; set; } + + public int ZwShopperId { get; set; } + + public string Label { get; set; } = string.Empty; + + [DwDenied] + public string? Pan { get; set; } + } + + public class ZwNote + { + public int Id { get; set; } + + public string Title { get; set; } = string.Empty; + + [DwMask(MaskStrategy.Full)] + public string Body { get; set; } = string.Empty; + } + + public sealed class ZwShopContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZwShopContext(SqliteConnection connection) => _connection = connection; + + public DbSet Shoppers => Set(); + + public DbSet Cards => Set(); + + public DbSet Notes => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + } + + /// A wrapper that forwards to EF Core's provider, async included, as LinqKit's EF Core build does. + public sealed class ZwAsyncQuery : IOrderedQueryable, IAsyncEnumerable + { + private readonly IQueryable _inner; + + public ZwAsyncQuery(IQueryable inner) + { + _inner = inner; + Provider = new ZwAsyncProvider(inner.Provider); + } + + public Type ElementType => typeof(T); + + public Expression Expression => _inner.Expression; + + public IQueryProvider Provider { get; } + + public IEnumerator GetEnumerator() => _inner.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + ((IAsyncEnumerable)_inner).GetAsyncEnumerator(cancellationToken); + } + + public sealed class ZwAsyncProvider : IAsyncQueryProvider + { + private readonly IQueryProvider _inner; + + public ZwAsyncProvider(IQueryProvider inner) => _inner = inner; + + public IQueryable CreateQuery(Expression expression) + { + IQueryable created = _inner.CreateQuery(expression); + + return (IQueryable)Activator.CreateInstance(typeof(ZwAsyncQuery<>).MakeGenericType(created.ElementType), created)!; + } + + public IQueryable CreateQuery(Expression expression) => + new ZwAsyncQuery(_inner.CreateQuery(expression)); + + public object? Execute(Expression expression) => _inner.Execute(expression); + + public TResult Execute(Expression expression) => _inner.Execute(expression); + + public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) => + ((IAsyncQueryProvider)_inner).ExecuteAsync(expression, cancellationToken); + } + + /// + /// A wrapper whose expression is a constant of itself, which its provider swaps for the inner query's before + /// handing it on: the EF Core root is not in the tree the guard reads. + /// + public sealed class ZwConstantRootQuery : IOrderedQueryable + { + public ZwConstantRootQuery(IQueryable inner, Expression? expression = null) + { + Inner = inner; + Expression = expression ?? Expression.Constant(this); + Provider = new ZwConstantRootProvider(this); + } + + internal IQueryable Inner { get; } + + public Type ElementType => typeof(T); + + public Expression Expression { get; } + + public IQueryProvider Provider { get; } + + public IEnumerator GetEnumerator() => + Inner.Provider.CreateQuery(ZwConstantRootProvider.Unwrap(Expression)).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + public sealed class ZwConstantRootProvider : IQueryProvider + { + private readonly ZwConstantRootQuery _root; + + public ZwConstantRootProvider(ZwConstantRootQuery root) => _root = root; + + internal static Expression Unwrap(Expression expression) => new Swap().Visit(expression); + + private sealed class Swap : ExpressionVisitor + { + protected override Expression VisitConstant(ConstantExpression node) => + node.Value is ZwConstantRootQuery query ? query.Inner.Expression : node; + } + + public IQueryable CreateQuery(Expression expression) => + (IQueryable)Activator.CreateInstance( + typeof(ZwConstantRootQuery<>).MakeGenericType(expression.Type.GetGenericArguments()[0]), + _root.Inner.Provider.CreateQuery(Unwrap(expression)), expression)!; + + public IQueryable CreateQuery(Expression expression) => + (IQueryable)CreateQuery(expression); + + public object? Execute(Expression expression) => _root.Inner.Provider.Execute(Unwrap(expression)); + + public TResult Execute(Expression expression) => _root.Inner.Provider.Execute(Unwrap(expression)); + } + + public sealed class ReviewTrackingWrapperTests : IDisposable + { + private const string Secret = "zw-shop-pan-secret"; + + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZwShopContext _db; + + public ReviewTrackingWrapperTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZwShopContext(_connection); + _db.Database.EnsureCreated(); + + _db.Shoppers.Add(new ZwShopper { Name = "S1", Cards = { new ZwPayCard { Label = "visa", Pan = Secret } } }); + _db.Shoppers.Add(new ZwShopper { Name = "S2" }); + _db.Notes.Add(new ZwNote { Title = "t1", Body = "original-note-body" }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + private static Segment Union() => new() + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Sort = 1, Field = "Name", DataType = DataType.Text, Operator = Operator.Equal, Values = { "S1" } } } + } + }, + new ConditionSet + { + Sort = 2, + Intersection = Intersection.Union, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Sort = 1, Field = "Name", DataType = DataType.Text, Operator = Operator.Equal, Values = { "S2" } } } + } + } + } + }; + + private static Summary CountByName() => new() + { + GroupBy = new GroupBy + { + Fields = new List { "Name" }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + } + }; + + // ------------------------------------------------------------------ W: a wrapper with async support + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public async Task Zw_W1_async_reads_through_a_wrapper_over_a_context_tracking_the_cards(DwTier tier) + { + _ = _db.Cards.ToList(); + int before = _db.ChangeTracker.Entries().Count(); + + IQueryable source = new ZwAsyncQuery(_db.Shoppers); + + object? data = null; + string code = ZwKit.Code(() => data = ZwKit.Guard(source, tier).ToListAsync(new Filter()).GetAwaiter().GetResult().Data); + string async = ZwKit.Code(() => ZwKit.Guard(source, tier).ToListAsyncDynamic(new Filter()).GetAwaiter().GetResult()); + string segment = ZwKit.Code(() => ZwKit.Guard(source, tier).ToListAsync(Union()).GetAwaiter().GetResult()); + string summary = ZwKit.Code(() => ZwKit.Guard(source, tier).ToListAsync(CountByName()).GetAwaiter().GetResult()); + int after = _db.ChangeTracker.Entries().Count(); + + _out.WriteLine($"W1 {tier}: list={code} dynamic={async} segment={segment} summary={summary} tracked {before}->{after} sent={ZwKit.Json(data)}"); + + Assert.Equal("ran", code); + Assert.Equal("ran", async); + Assert.Equal("ran", segment); + Assert.Equal("ran", summary); + Assert.False(ZwKit.Holds(data, Secret)); + } + + [Fact] + public async Task Zw_W2_a_masked_value_read_async_through_a_wrapper_is_never_a_pending_change() + { + IQueryable source = new ZwAsyncQuery(_db.Notes); + + object? data = (await ZwKit.Guard(source).ToListAsync(new Filter())).Data; + + _out.WriteLine($"W2 sent={ZwKit.Json(data)} tracked={_db.ChangeTracker.Entries().Count()} changes={_db.ChangeTracker.HasChanges()}"); + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + + Assert.Equal("original-note-body", _db.Notes.AsNoTracking().Single().Body); + } + + [Fact] + public async Task Zw_W3_a_masked_value_read_through_a_wrapped_segment_is_never_a_pending_change() + { + IQueryable source = new ZwAsyncQuery(_db.Notes); + + Segment segment = new() + { + ConditionSets = + { + new ConditionSet + { + Sort = 1, + ConditionGroup = new ConditionGroup + { + Conditions = { new Condition { Sort = 1, Field = "Title", DataType = DataType.Text, Operator = Operator.Equal, Values = { "t1" } } } + } + } + } + }; + + object? data = (await ZwKit.Guard(source).ToListAsync(segment)).Data; + + _out.WriteLine($"W3 sent={ZwKit.Json(data)} tracked={_db.ChangeTracker.Entries().Count()} changes={_db.ChangeTracker.HasChanges()}"); + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + + Assert.Equal("original-note-body", _db.Notes.AsNoTracking().Single().Body); + } + + [Fact] + public void Zw_W4_the_callers_AsTracking_inside_a_wrapper_does_not_outlive_the_guard() + { + _ = _db.Cards.ToList(); + + IQueryable source = new ZwAsyncQuery(_db.Shoppers.AsTracking()); + object? data = ZwKit.SafeRead(() => ZwKit.Guard(source).ToList(new Filter()).Data); + + _out.WriteLine($"W4 sent={ZwKit.Json(data)} tracked={_db.ChangeTracker.Entries().Count()}"); + + Assert.False(ZwKit.Holds(data, Secret)); + } + + // ------------------------------------------------------------------ C: a wrapper whose root the guard cannot see + + [Fact] + public void Zw_C2_shoppers_through_a_wrapper_whose_expression_is_a_constant_of_itself() + { + _ = _db.Cards.ToList(); + + IQueryable source = new ZwConstantRootQuery(_db.Shoppers); + + object? data = null; + string code = ZwKit.Code(() => data = ZwKit.Guard(source).ToList(new Filter()).Data); + + _out.WriteLine($"C2 code={code} sent={ZwKit.Json(data)} tracked={_db.ChangeTracker.Entries().Count()}"); + + Assert.False(ZwKit.Holds(data, Secret)); + } + } +} diff --git a/DynamicWhere.Tests/Policies/ReviewVarianceTests.cs b/DynamicWhere.Tests/Policies/ReviewVarianceTests.cs new file mode 100644 index 0000000..3ca4eca --- /dev/null +++ b/DynamicWhere.Tests/Policies/ReviewVarianceTests.cs @@ -0,0 +1,356 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Classes.Core; +using DynamicWhere.ex.Enums; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Config; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Source; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests.Policies +{ + // ============================================================================ variant generic interfaces: models + + public class ZxBaseCard + { + public string Label { get; set; } = string.Empty; + } + + public class ZxDerivedCard : ZxBaseCard + { + public string Tier { get; set; } = string.Empty; + } + + // ---- covariant, implemented only by an open generic class over a closed subtype argument ------------------- + + /// A covariant interface: an IZxOpenVar<ZxDerivedCard> is an IZxOpenVar<ZxBaseCard>. + public interface IZxOpenVar + { + T Value { get; } + + string? Code { get; } + } + + /// Every instantiation of this class is an IZxOpenVar<ZxBaseCard> through covariance. + public class ZxOpenVarImpl : IZxOpenVar + { + public ZxDerivedCard Value { get; set; } = new(); + + [DwDenied] + public string? Code { get; set; } + + [DwDenied] + public string? Secret { get; set; } + } + + public class ZxOpenVarRow + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public IZxOpenVar? Tagged { get; set; } + } + + // ---- covariant, implemented only by a CLOSED class over a subtype argument ---- + + public interface IZxClosedVar + { + T Value { get; } + + string? Code { get; } + } + + public class ZxClosedVarImpl : IZxClosedVar + { + public ZxDerivedCard Value { get; set; } = new(); + + [DwDenied] + public string? Code { get; set; } + + [DwDenied] + public string? Secret { get; set; } + } + + public class ZxClosedVarRow + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public IZxClosedVar? Tagged { get; set; } + } + + // ---- contravariant, implemented by a closed class over a BASE argument ---- + + public interface IZxSink + { + string? Code { get; } + + void Accept(T item); + } + + public class ZxBaseSink : IZxSink + { + [DwDenied] + public string? Code { get; set; } + + public void Accept(ZxBaseCard item) + { + } + } + + public class ZxSinkRow + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public IZxSink? Sink { get; set; } + } + + // ---- controls: the member typed as the exact instantiation implemented ---- + + public class ZxExactOpenRow + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public IZxOpenVar? Tagged { get; set; } + } + + // ============================================================================ variant generic interfaces: tests + + /// + /// A member typed as a variant interface holds implementations of other instantiations, which the subtype index + /// and the walker read as the runtime assigns them, covariant and contravariant alike. + /// + public sealed class ReviewVarianceTests + { + private readonly ITestOutputHelper _out; + + public ReviewVarianceTests(ITestOutputHelper output) => _out = output; + + private static Summary GroupedBy(string field) => new() + { + GroupBy = new GroupBy + { + Fields = new List { field }, + AggregateBy = new List { new() { Alias = "Total", Aggregator = Aggregator.Count } } + } + }; + + private void Report(string label, Type type) + { + _out.WriteLine($"{label}: Of({type.Name}<{type.GetGenericArguments()[0].Name}>) = " + + string.Join(", ", KnownSubtypes.Of(type).Select(t => t.Name))); + } + + [Fact] + public void Zx_V0_the_types_are_assignable_through_variance() + { + Assert.True(typeof(IZxOpenVar).IsAssignableFrom(typeof(ZxOpenVarImpl))); + Assert.True(typeof(IZxClosedVar).IsAssignableFrom(typeof(ZxClosedVarImpl))); + Assert.True(typeof(IZxSink).IsAssignableFrom(typeof(ZxBaseSink))); + + Exception? map = Record.Exception(() => typeof(ZxOpenVarImpl<>).GetInterfaceMap(typeof(IZxOpenVar))); + Exception? closedMap = Record.Exception(() => typeof(ZxClosedVarImpl).GetInterfaceMap(typeof(IZxClosedVar))); + + _out.WriteLine($"GetInterfaceMap(ZxOpenVarImpl<>, IZxOpenVar): {map?.GetType().Name ?? "ok"}"); + _out.WriteLine($"GetInterfaceMap(ZxClosedVarImpl, IZxClosedVar): {closedMap?.GetType().Name ?? "ok"}"); + } + + // ------------------------------------------------------------------------ the subtype index + + [Fact] + public void Zx_V1_open_the_index_lists_an_open_generic_implementation_over_a_subtype_argument() + { + Report("open", typeof(IZxOpenVar)); + + Assert.Contains(typeof(ZxOpenVarImpl<>), KnownSubtypes.Of(typeof(IZxOpenVar))); + } + + [Fact] + public void Zx_V1_closed_the_index_lists_a_closed_implementation_over_a_subtype_argument() + { + Report("closed", typeof(IZxClosedVar)); + + Assert.Contains(typeof(ZxClosedVarImpl), KnownSubtypes.Of(typeof(IZxClosedVar))); + } + + [Fact] + public void Zx_V1_contra_the_index_lists_a_contravariant_implementation() + { + Report("contra", typeof(IZxSink)); + + Assert.Contains(typeof(ZxBaseSink), KnownSubtypes.Of(typeof(IZxSink))); + } + + // ------------------------------------------------------------------------ the walker's fragment on the path + + [Fact] + public void Zx_V2_control_the_exact_instantiation_carries_the_implementations_denial() + { + Assert.NotEmpty(ZxKit.Denials(typeof(ZxExactOpenRow), "Tagged.Code")); + } + + [Fact] + public void Zx_V2_open_the_covariant_path_carries_the_implementations_denial() + { + _out.WriteLine("open Tagged.Code denials: " + ZxKit.Denials(typeof(ZxOpenVarRow), "Tagged.Code").Count()); + + Assert.NotEmpty(ZxKit.Denials(typeof(ZxOpenVarRow), "Tagged.Code")); + } + + [Fact] + public void Zx_V2_closed_the_covariant_path_carries_the_implementations_denial() + { + _out.WriteLine("closed Tagged.Code denials: " + ZxKit.Denials(typeof(ZxClosedVarRow), "Tagged.Code").Count()); + + Assert.NotEmpty(ZxKit.Denials(typeof(ZxClosedVarRow), "Tagged.Code")); + } + + [Fact] + public void Zx_V2_contra_the_contravariant_path_carries_the_implementations_denial() + { + _out.WriteLine("contra Sink.Code denials: " + ZxKit.Denials(typeof(ZxSinkRow), "Sink.Code").Count()); + + Assert.NotEmpty(ZxKit.Denials(typeof(ZxSinkRow), "Sink.Code")); + } + + // ------------------------------------------------------------------------ where, group, order on the denied path + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_V3_open_where_group_and_order_on_the_denied_code_are_refused(DwTier tier) + { + ZxOpenVarRow[] rows = { new() { Id = 1, Name = "r1", Tagged = new ZxOpenVarImpl { Code = "open-code-secret" } } }; + + string where = ZxKit.Code(() => ZxKit.Guard(rows.AsQueryable(), tier).ToList(ZxKit.Where("Tagged.Code", "open-code-secret"))); + object? grouped = null; + string group = ZxKit.Code(() => grouped = ZxKit.Guard(rows.AsQueryable(), tier).ToList(GroupedBy("Tagged.Code")).Data); + string order = ZxKit.Code(() => ZxKit.Guard(rows.AsQueryable(), tier).ToList(new Filter + { + Orders = new List { new() { Field = "Tagged.Code" } }, Selects = new List { "Id" } + })); + + _out.WriteLine($"open {tier}: where={where} group={group} ({ZxKit.Json(grouped)}) order={order}"); + + Assert.NotEqual("ran", where); + Assert.NotEqual("ran", group); + + if (tier == DwTier.Strict) + { + Assert.NotEqual("ran", order); + } + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_V3_closed_where_group_and_order_on_the_denied_code_are_refused(DwTier tier) + { + ZxClosedVarRow[] rows = { new() { Id = 1, Name = "r1", Tagged = new ZxClosedVarImpl { Code = "closed-code-secret" } } }; + + string where = ZxKit.Code(() => ZxKit.Guard(rows.AsQueryable(), tier).ToList(ZxKit.Where("Tagged.Code", "closed-code-secret"))); + object? grouped = null; + string group = ZxKit.Code(() => grouped = ZxKit.Guard(rows.AsQueryable(), tier).ToList(GroupedBy("Tagged.Code")).Data); + + _out.WriteLine($"closed {tier}: where={where} group={group} ({ZxKit.Json(grouped)})"); + + Assert.NotEqual("ran", where); + Assert.NotEqual("ran", group); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_V3_contra_where_on_the_denied_code_is_refused(DwTier tier) + { + ZxSinkRow[] rows = { new() { Id = 1, Name = "r1", Sink = new ZxBaseSink { Code = "contra-code-secret" } } }; + + string where = ZxKit.Code(() => ZxKit.Guard(rows.AsQueryable(), tier).ToList(ZxKit.Where("Sink.Code", "contra-code-secret"))); + + _out.WriteLine($"contra {tier}: where={where}"); + + Assert.NotEqual("ran", where); + } + + // ------------------------------------------------------------------------ rows returned with no projection asked for + + [Theory] + [InlineData(DwTier.Strict, false)] + [InlineData(DwTier.Convenience, false)] + [InlineData(DwTier.Strict, true)] + public void Zx_V4_open_rows_do_not_return_the_implementations_denied_members(DwTier tier, bool dynamic) + { + ZxOpenVarRow[] rows = + { + new() { Id = 1, Name = "r1", Tagged = new ZxOpenVarImpl { Code = "open-code-secret", Secret = "open-own-secret" } } + }; + + PolicyQueryable guarded = ZxKit.Guard(rows.AsQueryable(), tier); + object? data = ZxKit.SafeRead(() => dynamic ? guarded.ToListDynamic(new Filter()).Data : guarded.ToList(new Filter()).Data); + + _out.WriteLine($"open {tier} dynamic={dynamic}: sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + Assert.False(ZxKit.Holds(data, "open-code-secret")); + Assert.False(ZxKit.Holds(data, "open-own-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_V4_closed_rows_do_not_return_the_implementations_denied_members(DwTier tier) + { + ZxClosedVarRow[] rows = + { + new() { Id = 1, Name = "r1", Tagged = new ZxClosedVarImpl { Code = "closed-code-secret", Secret = "closed-own-secret" } } + }; + + PolicyQueryable guarded = ZxKit.Guard(rows.AsQueryable(), tier); + object? data = ZxKit.SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine($"closed {tier}: sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + Assert.False(ZxKit.Holds(data, "closed-code-secret")); + Assert.False(ZxKit.Holds(data, "closed-own-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_V4_contra_rows_do_not_return_the_implementations_denied_members(DwTier tier) + { + ZxSinkRow[] rows = { new() { Id = 1, Name = "r1", Sink = new ZxBaseSink { Code = "contra-code-secret" } } }; + + PolicyQueryable guarded = ZxKit.Guard(rows.AsQueryable(), tier); + object? data = ZxKit.SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine($"contra {tier}: sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + Assert.False(ZxKit.Holds(data, "contra-code-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_V4_control_rows_typed_as_the_exact_instantiation(DwTier tier) + { + ZxExactOpenRow[] rows = + { + new() { Id = 1, Name = "r1", Tagged = new ZxOpenVarImpl { Code = "exact-code-secret", Secret = "exact-own-secret" } } + }; + + PolicyQueryable guarded = ZxKit.Guard(rows.AsQueryable(), tier); + object? data = ZxKit.SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine($"exact {tier}: sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + Assert.False(ZxKit.Holds(data, "exact-code-secret")); + Assert.False(ZxKit.Holds(data, "exact-own-secret")); + } + } +} diff --git a/DynamicWhere.Tests/ReviewComplexPropertyTests.cs b/DynamicWhere.Tests/ReviewComplexPropertyTests.cs new file mode 100644 index 0000000..7f6f521 --- /dev/null +++ b/DynamicWhere.Tests/ReviewComplexPropertyTests.cs @@ -0,0 +1,199 @@ +using DynamicWhere.ex.Classes.Complex; +using DynamicWhere.ex.Policies.Attributes; +using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Source; +using DynamicWhere.Tests.Policies; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit.Abstractions; + +namespace DynamicWhere.Tests +{ + // EF Core 8 only (complex types, JSON columns): kept at the test project's root so the EF Core 6 floor leg, + // which compiles Policies/** only, leaves it out. + + // ============================================================================ a converted member inside a complex or JSON type: models + + public class ZxComplexMeta + { + public string Tag { get; set; } = string.Empty; + + public object? Payload { get; set; } + } + + public class ZxComplexEvent + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + + [DwDenied] + public string? Actor { get; set; } + + public ZxComplexMeta Meta { get; set; } = new(); + } + + public class ZxJsonMeta + { + public string Tag { get; set; } = string.Empty; + + public object? Payload { get; set; } + } + + public class ZxJsonEvent + { + public int Id { get; set; } + + public string Kind { get; set; } = string.Empty; + + [DwDenied] + public string? Actor { get; set; } + + public ZxJsonMeta? Meta { get; set; } + } + + public sealed class ZxComplexContext : DbContext + { + private readonly SqliteConnection _connection; + + public ZxComplexContext(SqliteConnection connection) => _connection = connection; + + public DbSet ComplexEvents => Set(); + + public DbSet JsonEvents => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite(_connection); + + protected override void OnModelCreating(ModelBuilder model) + { + model.Entity().ComplexProperty(e => e.Meta, meta => meta.Property(m => m.Payload).HasConversion(new ZxPayloadConverter())); + model.Entity().OwnsOne(e => e.Meta, meta => + { + meta.ToJson(); + meta.Property(m => m.Payload).HasConversion(new ZxPayloadConverter()); + }); + } + } + + // ============================================================================ a converted member inside a complex or JSON type: tests + + /// + /// RowShape.Converted asks the entity type for the column. A complex property is read whole as its CLR type, and + /// the converter on the member inside it is never asked about. + /// + public sealed class ReviewComplexPropertyTests : IDisposable + { + private readonly ITestOutputHelper _out; + private readonly SqliteConnection _connection; + private readonly ZxComplexContext _db; + + public ReviewComplexPropertyTests(ITestOutputHelper output) + { + _out = output; + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + _db = new ZxComplexContext(_connection); + _db.Database.EnsureCreated(); + _db.ComplexEvents.Add(new ZxComplexEvent + { + Kind = "CardIssued", Actor = "complex-actor-secret", + Meta = new ZxComplexMeta { Tag = "t", Payload = new ZxIssued { Label = "visa", Pan = "complex-pan-secret" } } + }); + _db.JsonEvents.Add(new ZxJsonEvent + { + Kind = "CardIssued", Actor = "json-actor-secret", + Meta = new ZxJsonMeta { Tag = "t", Payload = new ZxIssued { Label = "visa", Pan = "json-pan-secret" } } + }); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + } + + public void Dispose() + { + _db.Dispose(); + _connection.Dispose(); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_C2_a_converted_object_member_inside_a_complex_property_beside_a_top_level_denial(DwTier tier) + { + Assert.True(ZxKit.Holds(_db.ComplexEvents.AsNoTracking().ToList(), "complex-pan-secret")); + + PolicyQueryable guarded = ZxKit.Guard(_db.ComplexEvents, tier); + object? data = ZxKit.SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine($"{tier}: sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + Assert.False(ZxKit.Holds(data, "complex-actor-secret")); + Assert.False(ZxKit.Holds(data, "complex-pan-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_C2_dynamic_a_converted_object_member_inside_a_complex_property(DwTier tier) + { + PolicyQueryable guarded = ZxKit.Guard(_db.ComplexEvents, tier); + object? data = ZxKit.SafeRead(() => guarded.ToListDynamic(new Filter()).Data); + + _out.WriteLine($"{tier}: sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + Assert.False(ZxKit.Holds(data, "complex-pan-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public void Zx_C3_a_converted_object_member_inside_a_json_owned_type_beside_a_top_level_denial(DwTier tier) + { + List raw; + + try + { + raw = _db.JsonEvents.AsNoTracking().ToList(); + } + catch (Exception e) + { + _out.WriteLine($"unguarded threw: {e.GetType().Name}: {e.Message.Split('\n')[0]}"); + + return; + } + + _out.WriteLine("unguarded holds pan: " + ZxKit.Holds(raw, "json-pan-secret")); + + PolicyQueryable guarded = ZxKit.Guard(_db.JsonEvents, tier); + object? data = ZxKit.SafeRead(() => guarded.ToList(new Filter()).Data); + + _out.WriteLine($"{tier}: sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + Assert.False(ZxKit.Holds(data, "json-actor-secret")); + Assert.False(ZxKit.Holds(data, "json-pan-secret")); + } + + [Theory] + [InlineData(DwTier.Strict)] + [InlineData(DwTier.Convenience)] + public async Task Zx_C2_segment_a_converted_object_member_inside_a_complex_property(DwTier tier) + { + PolicyQueryable guarded = ZxKit.Guard(_db.ComplexEvents, tier); + object? data; + + try + { + data = (await guarded.ToListAsync(new Segment())).Data; + } + catch (Exception e) + { + _out.WriteLine($"{tier}: segment threw {e.GetType().Name}: {e.Message.Split('\n')[0]}"); + + return; + } + + _out.WriteLine($"{tier}: segment sent={ZxKit.Json(data)} trace=[{ZxKit.Trace(guarded)}]"); + + Assert.False(ZxKit.Holds(data, "complex-pan-secret")); + } + } +} diff --git a/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj b/DynamicWhere.ex.Policies.AspNetCore/DynamicWhere.ex.Policies.AspNetCore.csproj index b97432c..78ce335 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.1.0 - v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now requires. No API change in this package. The core release changes what its endpoints report, as it changes any guarded call: /simulate answers with 3.1.0's pipeline, the new caps, the strict tier's refusals and a type's DefaultOrder included, and /health's loadedAt and ageSeconds advance on a poll that confirms the version served, so an idle healthy store no longer reads as stale. One change needs action: DwClaimsAdapter.FromClaims returns an unprepared context, and ApplyPolicy(context) now refuses one with PolicyContextNotPrepared even when no store is configured. Use CreateContextAsync or ToPolicyContextAsync, or pass the context through DwPolicy.PrepareAsync before querying. In this package, the audit middleware's warning for events recorded with no IDwAuditSink registered now names both ways to stop recording them: remove [DwAudit] from the fields that produced them, or turn off DwPolicyOptions.AuditRefusals, the core's new switch that records refused guarded queries as audit events, which the middleware drains like any other. + 3.2.0 + v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API change in this package. The core release changes what /simulate reports for a request that sends no Selects. A simulation has no source, so it reads the type as one it cannot see into: every denial beneath a member counts, and the projection it shows keeps only members holding a value, where a guarded query over a projected row, an entity or rows in memory keeps what that source carries. + +v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now requires. No API change in this package. The core release changes what its endpoints report, as it changes any guarded call: /simulate answers with 3.1.0's pipeline, the new caps, the strict tier's refusals and a type's DefaultOrder included, and /health's loadedAt and ageSeconds advance on a poll that confirms the version served, so an idle healthy store no longer reads as stale. One change needs action: DwClaimsAdapter.FromClaims returns an unprepared context, and ApplyPolicy(context) now refuses one with PolicyContextNotPrepared even when no store is configured. Use CreateContextAsync or ToPolicyContextAsync, or pass the context through DwPolicy.PrepareAsync before querying. In this package, the audit middleware's warning for events recorded with no IDwAuditSink registered now names both ways to stop recording them: remove [DwAudit] from the fields that produced them, or turn off DwPolicyOptions.AuditRefusals, the core's new switch that records refused guarded queries as audit events, which the middleware drains like any other. v3.0.0 — First release. The administrative surface for the DynamicWhere.ex field-level policy layer, introduced in DynamicWhere.ex 3.0.0. MapDwPolicyAdmin mounts schema discovery, rule management, explain, simulate and health, and refuses to map at all without a named authorization policy — there is deliberately no default, and it fails at startup rather than on the first request. diff --git a/DynamicWhere.ex.Policies.EntityFrameworkCore/DynamicWhere.ex.Policies.EntityFrameworkCore.csproj b/DynamicWhere.ex.Policies.EntityFrameworkCore/DynamicWhere.ex.Policies.EntityFrameworkCore.csproj index 9367e98..6837aa4 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.1.0 - v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now requires. No API or behaviour change in this package. A stored rule's forced predicate can now carry allowNull, which lets rows with no value through: the core's PolicyRuleDocument writes it into the Detail column's forced object as "allowNull": true, only when it is true, so no migration is needed and a rule without it is stored exactly as before. A row whose forced object pairs IsNull or IsNotNull with "allowNull": true, value or no value, or with a contextValue, is refused when it is read, like any row the core cannot read: a Global, Tenant, Role or Custom rule fails the load, and a User rule fails DwPolicy.PrepareAsync for that user. + 3.2.0 + v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. + +v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now requires. No API or behaviour change in this package. A stored rule's forced predicate can now carry allowNull, which lets rows with no value through: the core's PolicyRuleDocument writes it into the Detail column's forced object as "allowNull": true, only when it is true, so no migration is needed and a rule without it is stored exactly as before. A row whose forced object pairs IsNull or IsNotNull with "allowNull": true, value or no value, or with a contextValue, is refused when it is read, like any row the core cannot read: a Global, Tenant, Role or Custom rule fails the load, and a User rule fails DwPolicy.PrepareAsync for that user. v3.0.0 — First release. A database-backed policy store for the DynamicWhere.ex field-level policy layer, introduced in DynamicWhere.ex 3.0.0. Works with any EF Core provider; ships DwPolicyDbContext, the rule and version records, and their EF configurations. Passes the same store conformance suite as the in-memory and Redis stores, verified against PostgreSQL. diff --git a/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj b/DynamicWhere.ex.Policies.Redis/DynamicWhere.ex.Policies.Redis.csproj index 931b439..9b1ea88 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.1.0 - v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now requires. No API or behaviour change in this package. A stored rule's forced predicate can now carry allowNull, which lets rows with no value through: the core's PolicyRuleDocument writes it into the rule document's forced object as "allowNull": true, only when it is true, so a rule without it is stored exactly as before. A document whose forced object pairs IsNull or IsNotNull with "allowNull": true, value or no value, or with a contextValue, is refused when it is read, like any document the core cannot read: a Global, Tenant, Role or Custom rule fails the load, and a User rule fails DwPolicy.PrepareAsync for that user. + 3.2.0 + v3.2.0 — Released in lockstep with DynamicWhere.ex 3.2.0, which it now requires. No API or behaviour change in this package. + +v3.1.0 — Released in lockstep with DynamicWhere.ex 3.1.0, which it now requires. No API or behaviour change in this package. A stored rule's forced predicate can now carry allowNull, which lets rows with no value through: the core's PolicyRuleDocument writes it into the rule document's forced object as "allowNull": true, only when it is true, so a rule without it is stored exactly as before. A document whose forced object pairs IsNull or IsNotNull with "allowNull": true, value or no value, or with a contextValue, is refused when it is read, like any document the core cannot read: a Global, Tenant, Role or Custom rule fails the load, and a User rule fails DwPolicy.PrepareAsync for that user. v3.0.0 — First release. A Redis-backed policy store for the DynamicWhere.ex field-level policy layer, introduced in DynamicWhere.ex 3.0.0. Holds runtime rules in Redis and invalidates through pub/sub with a poll behind it, because pub/sub is fire-and-forget and the poll is what bounds a dropped message. Passes the same store conformance suite as the in-memory and Entity Framework Core stores. diff --git a/DynamicWhere.ex/DOC.md b/DynamicWhere.ex/DOC.md index c12a4e5..b09bbbd 100644 --- a/DynamicWhere.ex/DOC.md +++ b/DynamicWhere.ex/DOC.md @@ -1,6 +1,6 @@ # DynamicWhere.ex -**Version:** 3.1.0  |  **Target Framework:** .NET 6+  |  **License:** MIT (Free Forever) +**Version:** 3.2.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.1.0 +dotnet add package DynamicWhere.ex --version 3.2.0 ``` **Dependencies:** @@ -445,7 +445,7 @@ Inherits all properties from `FilterResult`. Returned by segment operations. ## Extension Methods Reference -All extension methods live in `DynamicWhere.ex.Source.Extension` and operate on `IQueryable` (or `IEnumerable` for in-memory variants). +All extension methods live in `DynamicWhere.ex.Source.Extension` and operate on `IQueryable` (or `IEnumerable` for in-memory variants). There are 28 of them: the eighteen on `IQueryable` that 3.1 had, three in-memory variants on `IEnumerable`, and, since 3.2.0, seven more on `IQueryable` — overloads of the asynchronous terminals that take a `CancellationToken` (see [Cancellation](#cancellation)). ### `.Select(List fields)` @@ -632,6 +632,8 @@ In-memory variant — wraps the collection with `AsQueryable()` then delegates. Async version of `ToList(Filter)`. Uses `CountAsync()` and `ToListAsync()` for EF Core. +Since 3.2.0 two overloads take a `CancellationToken`, which reaches the count and the read: `.ToListAsync(Filter filter, CancellationToken cancellationToken)` and `.ToListAsync(Filter filter, bool getQueryString, CancellationToken cancellationToken)`. See [Cancellation](#cancellation). + **Returns:** `Task>` --- @@ -655,7 +657,9 @@ In-memory variant — wraps the collection with `AsQueryable()` then delegates t ### `.ToListAsyncDynamic(Filter filter, bool getQueryString = false)` -Async version of `ToListDynamic(Filter)`. Uses `CountAsync()` and `ToDynamicListAsync()` for EF Core. +Async version of `ToListDynamic(Filter)`. Counts with EF Core's `CountAsync()` and, since 3.2.0, reads with EF Core's own `ToListAsync()`, called for the query's element type: the class the projection generates, or `T` when `Selects` is null. It used to read with Dynamic LINQ's `ToDynamicListAsync()`, asynchronous as well but with no token to pass on; on an EF Core query a canceled token now reaches the database. The rows are the same. Only the count needs an EF Core async provider; on any other provider the read falls back to Dynamic LINQ's. + +Since 3.2.0 two overloads take a `CancellationToken`, which reaches the count and the read: `.ToListAsyncDynamic(Filter filter, CancellationToken cancellationToken)` and `.ToListAsyncDynamic(Filter filter, bool getQueryString, CancellationToken cancellationToken)`. See [Cancellation](#cancellation). **Returns:** `Task>` @@ -687,7 +691,9 @@ In-memory variant for summary operations. ### `.ToListAsync(Summary summary, bool getQueryString = false)` -Async version of `ToList(Summary)`. +Async version of `ToList(Summary)`. On an EF Core query it counts the groups with EF Core's `CountAsync()` and reads them with EF Core's `ToListAsync()`. Until 3.2.0 the count ran synchronously and the read went through Dynamic LINQ's `ToDynamicListAsync()`, which had no token to pass on; on an EF Core query a canceled token now reaches the database. The count and the rows are the same. A source whose provider is not EF Core's, such as rows in memory through `AsQueryable()`, keeps the synchronous count and Dynamic LINQ's read, on the calling thread. + +Since 3.2.0 two overloads take a `CancellationToken`, which reaches the count and the read: `.ToListAsync(Summary summary, CancellationToken cancellationToken)` and `.ToListAsync(Summary summary, bool getQueryString, CancellationToken cancellationToken)`. See [Cancellation](#cancellation). **Returns:** `Task` @@ -697,10 +703,41 @@ Async version of `ToList(Summary)`. Async-only segment operation. Combines every `ConditionSet` with set operations (`Union` / `Intersect` / `Except`) into one query, then orders, pages, projects and counts it in the database exactly as `ToListAsync(Filter)` does. Only the requested page is read, and `Orders` apply before `Selects`. `Union` and `Intersect` combine the sets' conditions and `Except` matches rows by primary key, never by object reference, so on a type with a primary key, tracking, `AsNoTracking()` and `Selects` return the same rows. Ordering follows the database: text sorts by its collation. +Since 3.2.0 an overload takes a `CancellationToken`, `.ToListAsync(Segment segment, CancellationToken cancellationToken)`, and passes it to the count and the read. See [Cancellation](#cancellation). + **Returns:** `Task>` --- +### Cancellation + +*New in 3.2.0.* Every asynchronous terminal has overloads that take a `CancellationToken`, guarded and unguarded: + +| Terminal | Overloads with a token | +|---|---| +| `ToListAsync` with a `Filter` | `(Filter filter, CancellationToken cancellationToken)` · `(Filter filter, bool getQueryString, CancellationToken cancellationToken)` | +| `ToListAsyncDynamic` with a `Filter` | `(Filter filter, CancellationToken cancellationToken)` · `(Filter filter, bool getQueryString, CancellationToken cancellationToken)` | +| `ToListAsync` with a `Summary` | `(Summary summary, CancellationToken cancellationToken)` · `(Summary summary, bool getQueryString, CancellationToken cancellationToken)` | +| `ToListAsync` with a `Segment` | `(Segment segment, CancellationToken cancellationToken)` | + +- The token reaches the count and the read. On an EF Core query a canceled token stops whichever of the two is running, and the call throws `OperationCanceledException`. EF Core's `TaskCanceledException` derives from it. `ToListAsync(Summary)` on a provider that is not EF Core's, such as rows in memory, checks the token before its synchronous count and read. +- The overloads without a token pass `CancellationToken.None`. +- They are overloads, not an optional parameter added to the old signatures. The 3.1 signatures are unchanged, so code compiled against 3.1 still binds. A reflection lookup of `ToListAsyncDynamic` by name alone now finds three methods where it found one, on the extension class and on the guarded handle, so `Type.GetMethod` given only the name throws `AmbiguousMatchException`; pass the parameter types. +- `ToListAsync(filter, default)` does not compile: `default` fits both `bool getQueryString` and `CancellationToken`, so the call is ambiguous (CS0121). So are `ToListAsyncDynamic(filter, default)` and `ToListAsync(summary, default)`, on a query and on the guarded handle alike. Write `false`, a token, or a named argument. A `Segment` takes no `getQueryString`, so `ToListAsync(segment, default)` binds the token overload. +- The guarded handle, `PolicyQueryable`, has the same seven overloads (see [The shape](#the-shape)). + +```csharp +// A minimal API binds a CancellationToken parameter to HttpContext.RequestAborted, +// so a client that disconnects cancels the count or the read. +app.MapPost("/customers/search", async (Filter filter, AppDbContext db, CancellationToken cancellationToken) => +{ + var result = await db.Customers.ToListAsync(filter, cancellationToken); + return Results.Ok(result); +}); +``` + +--- + ## Validation Rules ### Condition Validation Rules @@ -1502,6 +1539,12 @@ var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter); A context carries the snapshot it was served, and the staleness ceiling measures how old that snapshot is — which is why it is built once per request rather than reused. Since 3.1.0 an unprepared context is **refused by `ApplyPolicy` itself**, with `PolicyContextNotPrepared`, whether or not a store is configured: before that only a store provider refused one, so an attributes-only deployment accepted the missing call and would have started refusing the day it gained a store. `DwPolicyContext.IsPrepared` reports it. The overload taking explicit options and a resolver does not check — that host composes its own configuration and owns preparation. +`ApplyPolicy` returns a `PolicyQueryable`, whose terminals mirror the core's: `ToList`, `ToListAsync`, `ToListDynamic` and `ToListAsyncDynamic` with a `Filter`, `ToList` and `ToListAsync` with a `Summary`, and `ToListAsync` with a `Segment`. Since 3.2.0 every asynchronous one also has the overloads that take a `CancellationToken` — `ToListAsync(Filter, CancellationToken)`, `ToListAsync(Filter, bool, CancellationToken)`, the same two for `ToListAsyncDynamic` and for `ToListAsync` with a `Summary`, and `ToListAsync(Segment, CancellationToken)`. The policy is applied first, so a refusal is thrown whatever the token says; the token then reaches the count and the read. See [Cancellation](#cancellation). + +```csharp +var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter, cancellationToken); +``` + ### Attribute reference | Attribute | Applies to | Effect | @@ -1580,14 +1623,15 @@ It applies only under `ApplyPolicy`, when the caller sends no orders (`Orders` n - `ToList`, `ToListAsync`, `ToListDynamic` and `ToListAsyncDynamic` with a `Filter`; - `ToListAsync` with a `Segment`; - the composable `Filter` and `FilterDynamic`; -- the composable `Page`, on a source nothing has ordered or projected. +- the composable `Page`, on a source nothing has ordered and whose projection hides no field the default names. It is never applied: - by an unguarded call. The core extension methods on a plain `IQueryable` or `IEnumerable`, and a query taken out through `AsUnguardedQueryable()`, ignore the attribute and behave exactly as in 3.0; - when the caller sends orders — the default is not appended to them as a tiebreak; -- to an `IQueryable` that is already ordered, whether before it was guarded (`db.Tickets.OrderBy(t => t.Title).ApplyPolicy(caller)`) or by a composed `Order` earlier in the chain — even one whose every order the policy dropped, because the caller still sent orders. An in-memory sequence sorted with LINQ to Objects before `ApplyPolicy` is not seen as ordered, because `AsQueryable()` hides the sort, so the default replaces that order; -- to a projected query. A `Select` anywhere in the chain — the guarded composable `Select`, or a projection made before `ApplyPolicy` — leaves the query in its own order, because a default applied after a projection can name a field the projection left out, which EF Core cannot translate, so `guarded.Select(["Id", "Title"]).Page(page)` on a type ordered by `Priority` pages unordered, as in 3.0.0; +- to an `IQueryable` that is already ordered, whether before it was guarded (`db.Tickets.OrderBy(t => t.Title).ApplyPolicy(caller)`) or by a composed `Order` earlier in the chain — even one whose every order the policy dropped, because the caller still sent orders. Since 3.2.0 a `Filter` composed on the handle that sent orders counts the same way. An in-memory sequence sorted with LINQ to Objects before `ApplyPolicy` is not seen as ordered, because `AsQueryable()` hides the sort, so the default replaces that order; +- to a source whose projection could hide a field the default names. Only the outermost `Select` of the chain counts, because it makes the rows the default orders. Since 3.2.0 it hides nothing when it builds `T` itself in an object initializer, `Select(t => new TicketRow { CreatedAt = t.CreatedAt, Id = t.Id, … })`, and assigns every field the default names a column, at every level of a nested path (`"Owner.Name"` needs `Owner = new OwnerRow { Name = … }`). On EF Core a column is a member the model maps on the entity the `Select` reads, read directly, through reference navigations (`t.Owner.Name`) or through `EF.Property` (a shadow property included); in memory any assigned field is one. The default then applies, because EF Core translates an order by a column. A constructor with arguments, a default field the initializer does not assign, a nested path through anything but an initializer, a member the model does not map, or a default field the projection computes, by the application's own method (`Label = Decorate(r.Code)`), a framework one such as `Regex.Replace` or `ToUpper`, or an operator, leaves the query in its own order, as every projection did in 3.1.0: which of these EF Core can order by depends on the provider, and a default must never be the reason a query that ran unguarded fails; +- after a projection composed on the handle. The guarded `Select`, or a guarded `Filter` whose `Selects` is set, leaves the rest of the chain unordered even when it keeps every default field, so `guarded.Select(["Id", "Title"]).Page(page)` on a type ordered by `Priority` pages unordered, as in 3.0.0. The default is for the rows the caller's source makes; - to a `Summary`, or by the composable `Where`, `Select`, `Order` or `Group`. Nothing is ordered that the type's own `[DwEntity]` did not declare, and a default is never a reason for the library to refuse a query. An entry naming a field the type does not have, one that is not a field optionally followed by a direction, one the core refuses to order by — a path ending on a collection of entities, such as `Tags` — or one whose name the expression parser keeps for itself, such as `Null`, is skipped. A path through a collection to a value, such as `Tags.Value`, is kept and sorted by its smallest value ascending or its largest descending. A field this caller may not order by is left out, in either tier, and never refused: the caller did not send it, and ordering by it would rank rows by a value they may not see. In a `Segment`, a field this caller may not use in a segment is left out as well, since a segment refuses it in any clause; a filter still orders by it. The trace records a `Dropped` decision for `Order` whose reason starts `left out of the default order`; a dry run keeps the field and still records the decision. A caller whose own orders were all dropped under `Convenience` sent orders, and gets no default in their place. The startup check reports every entry a query would skip or leave out. @@ -1601,7 +1645,7 @@ A field the default keeps is a use of that field. One audited for `Order`, by `[ `DwPolicy.ValidateModel(options, types)` inspects the policy attributes on the given types and throws `InvalidOperationException` listing every error; `PolicyModelValidator.Inspect(types, options)` returns the same `PolicyModelReport` — `Errors`, `Warnings`, `IsValid` — without throwing. Called at startup, either one lets a misconfiguration fail the deployment rather than a caller's request. Since 3.1.0 the scan also reports: - every `[DwForceWhere]` resolution would refuse: `Value` and `ContextValue` both set or both missing on a comparison, either one set on a null check, a member whose type has no `DataType`, and `AllowNull` with `IsNull` / `IsNotNull` or on a member that can never be null. Before, these surfaced on the first query that resolved them; -- every `[DwEntity(DefaultOrder = ...)]` entry a guarded query would skip or leave out. An entry that is not a field optionally followed by `asc` or `desc` is an error; a field whose name starts with one of the words the expression parser keeps is an error, judged before the type is asked whether it has the member, because it may well have it — `"{Type}: DefaultOrder names '{field}', which starts with a name the expression parser keeps for itself, so no query can use it. Rename the member."`; a field the type does not have is a warning; a field no query can order by, such as a collection of entities, is an error; a field the type's own attributes seal against ordering is an error, because every guarded query would leave it out. A field denied for ordering only by attributes marked `Overridable = true` is a warning, because a rule can lift the denial for some callers — `"{Type}: DefaultOrder names '{field}', which its attributes deny for ordering unless a rule allows it, so guarded queries leave it out until one does."` — and so is a field the attributes deny for segments: `"{Type}: DefaultOrder names '{field}', which its attributes deny for segments, so guarded segments leave it out."` +- every `[DwEntity(DefaultOrder = ...)]` entry a guarded query would skip or leave out. An entry that is not a field optionally followed by `asc` or `desc` is an error; a field whose name starts with one of the words the expression parser keeps is an error, judged before the type is asked whether it has the member, because it may well have it — `"{Type}: DefaultOrder names '{field}', which starts with a name the expression parser keeps for itself, so no query can use it. Rename the member."`; a field the type does not have is a warning; a field no query can order by, such as a collection of entities, is an error; a field the type's own attributes seal against ordering is an error, because every guarded query would leave it out; the attributes include those of the member's other declarations, an interface member it implements, a subtype's override and a public member a subtype hides with `new`. A field denied for ordering only by attributes marked `Overridable = true` is a warning, because a rule can lift the denial for some callers — `"{Type}: DefaultOrder names '{field}', which its attributes deny for ordering unless a rule allows it, so guarded queries leave it out until one does."` — and so is a field the attributes deny for segments: `"{Type}: DefaultOrder names '{field}', which its attributes deny for segments, so guarded segments leave it out."` ### Blocked-action semantics @@ -1624,6 +1668,96 @@ A dropped field leaves nothing behind in the data, so the trace is the only way The convenience tier is unchanged: an unknown name fails validation with `LogicException` `ConditionMustHasValidFieldName`, a refusal names the field as the caller wrote it, with `RuleId` and `SourceOrigin` where a single source decided, and `MaxQueryCost` is checked before any field is gated. A dry run refuses no field, so an unknown name fails validation there in either tier. +### A navigation named in Selects + +A `Selects` entry can name a navigation, such as `"Lines"`, rather than the fields beneath it. With nothing denied beneath it, the entry is kept as written. With a denied field beneath it, the `Convenience` tier replaces the entry with the allowed fields beneath it, and the `Strict` tier refuses it with `FieldDeniedForSelect`. + +| `Selects` names a navigation | `Convenience` | `Strict` | +|---|---|---| +| with nothing denied beneath it | Kept whole, or narrowed around a transform that lands on a property with no setter (3.2.0) | Kept whole, or narrowed the same way | +| with a denied field beneath it | Replaced by the allowed fields beneath it | `FieldDeniedForSelect` | +| whose key, `Lines.Id`, is denied (3.2.0) | `FieldDeniedForSelect` | `FieldDeniedForSelect` | +| with a denied field beneath it, where the narrowing cannot be built (3.2.0) | `FieldDeniedForSelect` | `FieldDeniedForSelect` | +| 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 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. +- 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 + +A request that sends no `Selects` returns whole rows, denied fields included, because the core projects only when `Selects` is set. So when a field denied for `Select` could reach the result, a guarded query synthesizes the projection itself. It does so in both tiers, typed and dynamic, for a whole `Filter` and for a `Segment`. A clause composed on its own, such as `Where`, `Order` or `Page`, synthesizes nothing. + +The projection keeps the **allowed members**: what an unguarded call would return, less what the policy withholds. Before 3.2.0 it kept the allowed scalars only, and only a simple field denied at the top of the type asked for it (breaking point 20). + +**When a projection is needed.** + +- A field denied at the top of `T` always asks for one, whatever it holds: a scalar, a blob, a list, an owned object or a JSON column. +- A field denied beneath a member asks for one when its value can reach the result. On an entity, that is beneath a column, an owned or complex member, or a navigation something loads: an `Include` or `ThenInclude` on the query, an automatic include, or a lazy loader — EF Core's proxies, an injected `ILazyLoader`, a loader delegate or `ILazyLoader` the constructor takes and keeps in a field or any property, the asynchronous loader delegate of EF Core 7, or an injected `DbContext` — which fills a navigation after the query. On a row a projection builds, it is beneath a member the initializer assigns; a constructor with arguments counts every member as assigned, and an initializer after it still says what its own bindings hold. On a row in memory, it is beneath any member. A rule may spell the path in any letter case. +- Every navigation counts as loaded where the library cannot read which the query loads: an `Include` in a form it cannot read, one off the query's own chain, and a chain that reaches its rows through anything but the root's own rows — `Select(o => o.Customer)`, a `SelectMany`, a `Join`, a `GroupBy` — when it also has an include, which EF Core applies from the root to the entities it reaches, or when one of its lambdas hands its rows an object: one it builds, as a projection behind an identity `Select`, or an object built inside an anonymous row or a conditional, does; one an application's method returns from what the lambda gives it; or one it captured, another query with its own include or projection, or an object in memory. A call that reads nothing of the lambda's and returns a query or an expression — a specification, a repository's query, `FromSql`, a context's `Set` through an interface — is evaluated as EF Core evaluates it, and what it returns is read; a context's own query function is a query root; an anonymous object that only carries what the rows hold, query-syntax range variables or a composite key, builds nothing; and what only feeds a predicate or a key is a value and hands a row nothing. Such a chain with none of these is read from the model. +- A denial beneath a navigation nothing loads never leaves the database, so it asks for no projection. An entity whose only denials sit beneath such navigations is read as it was in 3.1.0. +- A member whose value can hold a field denied for `Select` that no path names — deeper than the walker's four segments, inside a framework generic such as `Dictionary`, or declared by a subtype of its type — asks for one too, read as for a named member, so on an entity only what loads counts. Under a `"*"` deny, so does a member whose value can hold a path the walk never asks about and the policy does not name. +- A member that can hold an object of any type — one typed `object`, a framework interface such as `IComparable`, or a collection that is not generic, such as `IEnumerable`, `ArrayList` or an application's own — asks for nothing on its own: the policy cannot see into it whether or not a projection is built. +- A row can be a subtype of `T`. On an entity, a member a type the model derives from `T` declares, and what loads beneath it, counts as one of `T`'s own would; on a row in memory, a member any loaded subtype declares does; on a row a projection builds, a member the type its initializer constructs declares below `T`. The projection builds `T` and leaves them out, recorded with a reason starting `left out: a type derived`. A subtype is any type loaded outside the framework's assemblies that derives from the type or implements it, an open generic one and an application's subclass of `Exception` included; a rule on a path through a subtype's member counts as a rule on the declared type's own path does. +- A member EF Core does not map counts as loaded: its getter can hand out a mapped field or a private navigation, so its type is read whole. +- The denials beneath a member come from the providers' rules as well as from walking the type, so a denied property with no setter, a rule on a path reached through a cycle, and a rule deeper than the walk all count. +- A forced scope beneath a member asks for no projection on its own. It filters the rows that hold the member, as it always has. When a projection is needed anyway, the member is left out whole. + +**What it keeps.** A member holding a value — a simple type, or a collection of one such as `byte[]`, `string[]` or `List` — is kept when it is allowed and the source carries it. A member holding an object, or a list of them, is kept whole, narrowed or left out whole, as below, and only where the source carries it: + +| Source | Values kept | Objects kept | +|---|---|---| +| A projection that builds its rows before `ApplyPolicy`: the outermost `Select` constructs the row, in an object initializer or with a constructor, as in `db.Roles.Select(r => new RoleRow { … })` | Every member the initializer assigns; every member when a constructor with arguments builds the row, with or without an initializer after it | The same | +| An entity query, or a `Select` that hands back an entity, as in `db.Orders.Select(o => o.Customer)` | Every member EF Core maps | Its columns, converted and JSON ones included, its owned members and, on EF Core 8 or later, its complex properties, read from the EF Core model; a converted value that can hold an object of any type is left out | +| Rows in memory, as in `roles.ApplyPolicy(caller)` | Every member | None | + +A value EF Core does not map is left out: computing it would make EF Core read the whole entity, the denied columns included, and it holds only its initial value anyway. A source the library cannot read — no EF Core model, and neither a projection it can see into nor rows in memory — keeps values only, as in 3.1.0, and every denial beneath a member counts. + +**Whole, narrowed or left out whole.** A member holding an object that the source carries is: + +- **kept whole** when nothing beneath it is denied, nothing its value can hold is denied (its subtypes included), it cannot hold an object of any type (asked of a projected row, a row in memory, and an entity's column a value converter hands back, directly or inside a complex property: what EF Core materializes itself never holds one), under a `"*"` deny every path beneath it the walk skips is one the policy names, no forced scope is beneath it, and no transform beneath it lands on a property with no setter; +- **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`. + +```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 +left out whole: it is a complex property, which EF Core cannot narrow +left out whole: it is stored as JSON, which EF Core cannot narrow +left out whole: the projection builds it in a way the core cannot narrow +left out whole: the projection would add its key 'Contents.Id', which is denied +left out whole: the core cannot project 'Contents.Code' +left out whole: the core cannot build 'IContact', which it narrows into +left out whole: nothing beneath it may be selected +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. + +**Never kept.** + +- An entity's navigation, included or not: projecting it would load it. So once a denial needs a projection, an included or automatically included navigation is not returned, and the trace records it as `Dropped` with a reason starting `left out:`. Under `Convenience`, name it in `Selects` to get it narrowed; under `Strict`, name its allowed fields. +- An object held by a row in memory: a kept object is the caller's own, and a transform beneath it would change it in place. The projection's rows are new and hold no member of an object type, so the source objects are left as they were. +- A member with no setter, and a member named with one of the expression parser's own words. + +**Other rules.** + +- A narrowed reference that is null in the source comes back as an empty object, as it does for a caller's own dotted `Selects`. +- A narrowed member carries every allowed field beneath it. An entity reached beneath it therefore has its own navigations projected, and so loaded, whether or not the source included them, exactly as when `Selects` names the member. +- Each denied field whose value can reach the result, at the top or beneath, is recorded as `Dropped` on `Select`. +- It never throws for a denied field, in either tier. It throws `AllSelectsDenied` only when no field is left. When nothing asks for a projection, `Selects` stays null and the query is the one an unguarded call runs. +- A typed query projects into `T`, so `T` needs a public parameterless constructor, or the query fails with `SelectTypeMustHaveParameterlessConstructor` (breaking point 1). The dynamic terminals do not need one. +- A dry run synthesizes nothing. It records the denials and returns the rows whole. +- A simulation has no source, so it reads `T` as a source it cannot see into: every denial beneath a member counts, and the projection it shows keeps only members holding a value (see [Administration](#administration)). + +**What the policy cannot see into.** A member typed `object`, a framework interface or a collection that is not generic, such as `IEnumerable`, `ArrayList` or an application's own, is opaque to the policy. It never asks for a projection; when one is needed anyway, a projected row, a row in memory, and an entity's converted column leave it out, and an entity's other columns keep it; and naming it returns whatever it holds. A converter returning an application type through a column typed `object` is opaque the same way, so type the member as what it holds. `BitArray` and the framework's string collections hold values. An application's own collection class, generic or not, still has its own members read, and a collection of values stays a value unless one of them is denied. Two members sharing a name, one hidden with `new` under another type or spelled in another case, are left out when either holds a denial: the core reads one of them, and a row carries both. Rows in memory are projected when a member a base type declares, and the row type hides with `new`, is denied. A method in a reshaping lambda that builds a query from captured values runs once more per guarded read, and one that answers differently on each call is enforced as it answered the guard. A framework generic holding a policed type, such as `Dictionary`, has no paths beneath it: naming it is refused in both tiers where the core cannot narrow it, at the top of `T` or on a row in memory, narrowed away under `Convenience` beneath a navigation, and a synthesized projection leaves it out. Hold such values in a list of the policed type instead. A member EF Core does not map is read as its type, since its getter can hand out what EF Core loaded; a getter that copies a denied column into a type with no denial is the application's to withhold. + +**A projection builds the declared type.** A query over the root of a hierarchy whose derived type declares a denied field comes back as root-type rows, the derived types' allowed fields dropped too. Over an abstract root the typed terminals fail with `SelectTypeMustHaveParameterlessConstructor`, and the dynamic ones return the root's members. Query the derived type, `OfType()`, to keep its fields. Rows in memory can be any loaded subtype, and the policy does not look at the rows: when a subtype declares a denied field, they are projected and their objects left out, even if no row is that subtype. Under a `"*"` deny, a member is kept whole only when every path beneath it the walk skips is one the policy names. A `[DwDenied]` on an override, on a public member a subtype hides with `new`, or on a class's implementation of an interface member, through a variant instantiation too, applies to the path through the base type or the interface, on every row and in every clause. + +**A forced scope on a list's element type filters rows, not elements.** A forced scope declared on a list's element type filters the rows that hold the list, never its elements. `Selects` naming the list returns every element, those the scope excludes included, as in every release; a synthesized projection leaves such a list out. Scope the elements where the row is built. + ### Results and the trace Every guarded query records a `PolicyTrace`: its tier, whether it ran dry, and a `PolicyDecision` — `FieldPath`, `Feature`, `Action`, `Reason` — for each thing the policy decided. `PolicyQueryable.LastTrace` holds it for the most recent call on the handle, the composable methods included. @@ -1802,6 +1936,8 @@ Options are frozen at startup. Every cap refuses a value below one, except two t | `POST` | `/simulate` | The sanitized clause, without executing or auditing | | `GET` | `/health` | Snapshot version, age, degraded state, last error | +A simulation, through `/simulate` or `PolicySimulator`, has no source, so it reads the type as a source it cannot see into. That shows in a clause that sends no `Selects`: every denial beneath a member counts, and the projection it shows keeps only the members that hold a value, a collection of values included. A guarded query keeps what its own source carries — over a projected row, the objects its initializer assigns; over an entity, its columns, owned and complex members, asking only about the denials whose value it loads; over rows in memory, values only. So the simulated clause can list fewer members than the query returns, and can show a projection an entity query does not need. See [A request that sends no Selects](#a-request-that-sends-no-selects). + ### Schema discovery `POST /dw-policies/schema` describes what one caller may do with one entity. It is a POST rather @@ -2103,7 +2239,7 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of ### ⚠️ Breaking Points 1. **Parameterless Constructor Required for Select Projection** - `Select(fields)` requires `T` to have a parameterless (default) constructor. If `T` does not have one — a positional record, most often — a `LogicException` is thrown whose `Message` is the stable code `SelectTypeMustHaveParameterlessConstructor` and whose `Subject` carries `typeof(T).Name`. Before 3.1.0 that message was an English sentence with the type name inside it. Most EF Core entity classes have parameterless constructors by default. A guarded query reaches the same refusal when a member carries `[DwNoSelect]`, because deny-select projects. + `Select(fields)` requires `T` to have a parameterless (default) constructor. If `T` does not have one — a positional record, most often — a `LogicException` is thrown whose `Message` is the stable code `SelectTypeMustHaveParameterlessConstructor` and whose `Subject` carries `typeof(T).Name`. Before 3.1.0 that message was an English sentence with the type name inside it. Most EF Core entity classes have parameterless constructors by default. A guarded query reaches the same refusal when a member carries `[DwNoSelect]`, because deny-select projects — since 3.2.0 whatever the member holds, and beneath another member when its value can reach the result (point 20). 2. **Segment Operations are Async-Only** `ToListAsync(Segment)` is the only entry point for segment queries. There is no synchronous `ToList(Segment)` variant. The condition sets are combined into one query that the database orders and pages. `Union` and `Intersect` combine the sets' conditions; only `Except` on a type with a primary key needs a provider that translates a correlated `EXISTS`. Under `ApplyPolicy`, `DwCaps.MaxConditionSets` (default 10) bounds how many sets one request may carry. @@ -2177,6 +2313,37 @@ All validation errors throw `LogicException` (inherits `Exception`) with one of 19. **`MaxConditionValues` and `MaxAggregates` Refuse Guarded Requests 3.0 Ran** Two more caps new in 3.1.0. `DwCaps.MaxConditionValues` (default 1000) bounds the values one condition carries — the largest condition of the where clause, a summary's `Having` and every segment set is the one compared — because an `In` is one comparison per value and so could build a predicate of any size for the price of one condition and one field. `DwCaps.MaxAggregates` (default 50) bounds the `AggregateBy` entries of one summary, through the summary terminals and the composable `Group` and `Summary`; the group-size floor's own count is not counted. A guarded request over either is refused in both tiers with `PolicyException` `CapExceeded`, `FieldPath` `"*"` and `SourceOrigin` `"MaxConditionValues cap (1000), request had 1001"` or `"MaxAggregates cap (50), request had 51"`, unless the deployment raises the cap. Both refuse a value below 1, freeze with the posture, and bind from `Caps:MaxConditionValues` and `Caps:MaxAggregates`. An aggregate with no field, such as a `Count`, is now charged `DefaultFieldCost` toward `MaxQueryCost`, where it cost nothing, so a summary that sat just under its budget can be refused with `QueryCostExceeded`. Every count cap is now checked before any field name is resolved, so an oversized request that also names a field that does not exist is refused with `CapExceeded`, where 3.0.0 resolved names first and answered `ConditionMustHasValidFieldName`. Only `ApplyPolicy` enforces them: an unguarded query is not affected. +20. **A Guarded Query That Sends No `Selects` Keeps What the Source Carries** + A request with no `Selects` returns whole rows, denied fields included, so a guarded query synthesizes a projection when a denied field could reach the result. 3.2.0 changed when it does so and what it keeps; the rules are under [A request that sends no Selects](#a-request-that-sends-no-selects). + + Fixed (security): a field denied only beneath a member, none at the top of `T`, synthesized nothing, so the whole row came back with the denied value in it — in a list or nested object of a row projected before `ApplyPolicy`, in a row held in memory, and in an entity's included, automatically included, lazily loaded or owned member — typed and dynamic, in both tiers, for a `Filter` and a `Segment`. Such a denial now synthesizes the projection whenever its value can reach the result: on an entity, beneath a column, an owned or complex member, or a navigation the query loads through `Include`, an automatic include or a lazy loader; on a projected row, beneath a member the initializer assigns; in memory, beneath any member. A denial beneath a navigation nothing loads never leaves the database, and the entity is read exactly as in 3.1.0. + + Fixed (security): what a query loads was read too narrowly. An include named from the root and reached through `Select(o => o.Customer)`, `SelectMany` or `Join`, a projection behind another `Select`, an initializer after a constructor with arguments, and a lazy loader the constructor takes and keeps in a field or a property of any name each loaded a denied value the gate read as unloaded. An injected `DbContext` or EF Core 7's asynchronous loader delegate did too, and so did a reshaping lambda that got its row from an application's method or from a captured query or object. An application's own collection class hid its own denied members, and a guarded query through a provider wrapping EF Core's, such as LinqKit's `AsExpandable`, ran tracking, so the context filled in navigations it already held and a masked value became a pending change. A field a subtype declares, a derived entity's or a subclass's held by a base-typed member, was not read at all, nor was a `[DwDenied]` on an override, on a member hidden with `new` or on an interface member's implementation, and under a `"*"` deny a path the walk never asked about was allowed. Each came back. Now every navigation counts as loaded on such a chain, the subtypes are read, and such a path is denied. When a type the model derives from `T`, or a loaded subclass of a row in memory, declares a denied field, the rows are projected to `T`, dropping a derived type's allowed fields too; over an abstract `T` the typed terminals fail with `SelectTypeMustHaveParameterlessConstructor` and the dynamic ones return its members. + + Fixed (security): a field denied at the top of `T` whose own type is not a simple value — a byte array, a list, an owned object, a JSON column — synthesized no projection either, so with nothing else denied the whole row came back with it. + + Changed: the projection kept simple fields only, so as soon as any field was denied, every nested object and list of a row projected before `ApplyPolicy` came back null or empty, and so did an entity's columns holding an object, its owned and complex members and its collections of simple values. A row a projection builds now keeps the members its initializer assigns; an entity keeps its mapped columns, converted and JSON ones included, except a converted one that can hold an object of any type, its owned and complex members, and every collection of simple values; rows in memory keep their values. A member holding an object is kept whole when nothing it can hold is denied, narrowed to the allowed fields where the core's narrowing translates, and otherwise left out whole, with a `Dropped` decision whose reason starts `left out whole`. An entity's navigations, included ones too, the objects of a row in memory, and a value EF Core does not map are left out; under `Convenience` name a navigation in `Selects` to get it narrowed, and under `Strict` name its allowed fields. A forced scope beneath a member asks for no projection on its own, and a projection needed for another reason leaves such a member out whole. A typed query needs `T` to have a public parameterless constructor for the projection, as it already did (point 1). + +21. **`Selects` Naming a Member Is Gated Against Every Denial Beneath It** + When `Selects` names a navigation with a denied field beneath it, the `Convenience` tier replaces the entry with the allowed fields beneath it, and the `Strict` tier refuses it. Since 3.2.0 the gate finds every denial beneath the member, and refuses, with `FieldDeniedForSelect`, a narrowing it cannot build as gated. See [A navigation named in Selects](#a-navigation-named-in-selects). + + Fixed (security): under the convenience tier, a navigation whose key (`Id`) is denied was narrowed to the allowed fields beneath it, and the core's typed projection, which adds the key of every nested node it builds, put the key back. Such a narrowing is refused in both tiers, as naming a sibling of the key already was. A navigation named through another, such as `Main.Lead`, now gates the key of `Main`, which the projection adds; it did not. + + Fixed (security): a member typed as a collection the core does not unwrap — `IReadOnlyList`, `IReadOnlyCollection`, `Collection` or an application's own — returned every field beneath it, denied ones included, in both tiers. The projection gate read collections through a narrower list than the attribute walker and found nothing beneath the member. It now reads them as the walker does, and a narrowing the core cannot project is refused. + + Fixed (security): a member of a projected or in-memory row whose type holds a field denied for `Select` that no path names — deeper than four segments, or inside a framework generic such as `Dictionary` — was returned whole. The strict tier refuses it now, and the convenience tier narrows it away; where it cannot be narrowed, both tiers refuse it. Denials beneath a named member are also read from the policy's own rules, so a denied property with no setter and a rule on a path reached through a cycle are found. A member that cannot be narrowed at all — a column, a complex property or a JSON-stored member, a member of a row in memory, or one a projection builds some way the core cannot narrow — is refused in both tiers when something beneath it is denied. A request that sends no `Selects` is not refused for such a member: its synthesized projection narrows it or leaves it out whole. + +22. **`DefaultOrder` Reaches a Projection That Builds `T`** + In 3.1.0 a `Select` anywhere in the chain kept a guarded query in its own order. Since 3.2.0 only the outermost `Select` counts, and when it builds `T` in an object initializer that assigns every field the default names a column, at every level of a nested path, the default applies: `db.Tickets.Select(t => new TicketRow { Id = t.Id, CreatedAt = t.CreatedAt, Title = t.Title }).ApplyPolicy(caller)` on a `TicketRow` declaring `"CreatedAt desc, Id"` was unordered and is now ordered. A column is a member the EF Core model maps on the entity the `Select` reads, read directly, through reference navigations or through `EF.Property`. A value the projection computes, by any method or operator, a member the model does not map, a constructor with arguments, a default field the initializer does not assign, or a nested path through anything but an initializer still leaves the query in its own order. A `Select`, or a `Filter` with `Selects`, composed on the guarded handle keeps the rest of the chain unordered, and a composed `Filter` that sent orders gets no default later in the chain, even when the policy dropped every one of them, as a composed `Order` already did not. See [Default order](#default-order). + +23. **Every Async Terminal Takes a `CancellationToken`** + Since 3.2.0 `ToListAsync` and `ToListAsyncDynamic` with a `Filter`, `ToListAsync` with a `Summary`, and `ToListAsync` with a `Segment` have overloads that take a `CancellationToken`, guarded and unguarded, and the token reaches the count and the read. The 3.1 signatures are unchanged, so code compiled against 3.1 still binds, but `ToListAsync(filter, default)`, `ToListAsyncDynamic(filter, default)` and `ToListAsync(summary, default)` no longer compile: `default` fits both `getQueryString` and the token (CS0121). Write `false`, a token, or a named argument. A reflection lookup of `ToListAsyncDynamic` by name alone now finds three methods where it found one, and one of `ToListAsync` finds more than it did. See [Cancellation](#cancellation). + + Changed: `ToListAsyncDynamic` and the async `Summary` read through EF Core's `ToListAsync` instead of Dynamic LINQ's `ToDynamicListAsync`, which had no token to pass on, and the async `Summary` counts through `CountAsync` where it counted synchronously. So on an EF Core query a canceled token now reaches the database. The rows and the counts are the same. A provider that is not EF Core's keeps Dynamic LINQ's read, on the calling thread. + +24. **A Type in a Namespace That Starts with `System` Is Policed** + The attribute walker does not descend into the framework's own types, which carry no policy attributes. Until 3.2.0 it took any namespace whose name started with `System` for the framework's, so an application namespace such as `SystemsCorp.Payroll` or `SystemX.Domain` got no policy beneath its types, and a `[DwDenied]` field on such a type, reached through a member, was returned, filterable and sortable. Fixed (security): only `System` and the namespaces beneath it are the framework's now, so a guarded request that filtered on, sorted by or selected such a field is refused or dropped, as for any denied field. + --- ## License diff --git a/DynamicWhere.ex/DynamicWhere.ex.csproj b/DynamicWhere.ex/DynamicWhere.ex.csproj index 370045a..80debba 100644 --- a/DynamicWhere.ex/DynamicWhere.ex.csproj +++ b/DynamicWhere.ex/DynamicWhere.ex.csproj @@ -34,21 +34,43 @@ DynamicWhere.ex DynamicWhere.ex — JSON-driven queries for EF Core - JSON-driven queries for Entity Framework Core. A powerful, versatile library for dynamically composing complex filter, sort, paginate, group, aggregate, and set-operation (Union / Intersect / Except) expressions — all driven by simple JSON objects from any front-end or API consumer. Three composable shapes (Filter / Segment / Summary), twenty-one extension methods, nested navigation through references and collections with auto-wrapped .Any() lambdas, heterogeneous Condition.Values with type-safe coercion, and a thread-safe reflection cache with five tuned presets (FIFO / LRU / LFU). Since 3.0 it also carries an opt-in field-level policy layer: attributes and runtime rules decide what each caller may filter, sort, select, group, aggregate and see, with masking, tokenization, forced predicates and a k-anonymity floor. Targets .NET 6+. Free Forever. Full reference, JSON cookbook, and tuning guide at https://doc.dynamicwhere.com. + JSON-driven queries for Entity Framework Core. A powerful, versatile library for dynamically composing complex filter, sort, paginate, group, aggregate, and set-operation (Union / Intersect / Except) expressions — all driven by simple JSON objects from any front-end or API consumer. Three composable shapes (Filter / Segment / Summary), twenty-eight extension methods, nested navigation through references and collections with auto-wrapped .Any() lambdas, heterogeneous Condition.Values with type-safe coercion, and a thread-safe reflection cache with five tuned presets (FIFO / LRU / LFU). Since 3.0 it also carries an opt-in field-level policy layer: attributes and runtime rules decide what each caller may filter, sort, select, group, aggregate and see, with masking, tokenization, forced predicates and a k-anonymity floor. Targets .NET 6+. Free Forever. Full reference, JSON cookbook, and tuning guide at https://doc.dynamicwhere.com. Dynamic filters, sort, paginate, group, aggregate, and set operations for EF Core — driven by JSON. DynamicWhere DynamicWhere.ex EFCore EntityFrameworkCore EF-Core LINQ DynamicLinq Filter DynamicFilter JsonFilter Where OrderBy Paging Pagination GroupBy Aggregation Summary Segment Union Intersect Except SetOperations QueryBuilder Query Expression ExpressionTree Reflection Cache JSON Dynamic FieldLevelSecurity DataMasking Tokenization Anonymization KAnonymity Authorization RBAC ABAC Multitenancy DotNet NET6 NET7 NET8 NET9 NET10 CSharp Sajjad H. Al-Khafaji Sajjad H. Al-Khafaji Free Forever — Copyright © Sajjad H. Al-Khafaji MIT - 3.1.0 + 3.2.0 https://doc.dynamicwhere.com git master https://github.com/Sajadh92/DynamicWhere.ex icon.png README.md - v3.1.0 — Date comparisons that work on every date member, segments combined in the database, five new caps, a stable code where a sentence used to be, preparation enforced whether or not a store is configured, a policy bypass through members named Root, It or Parent closed, a long In list that ended the process fixed, the names the expression parser keeps refused by name, a strict tier that no longer discloses its trace or which fields exist, forced predicates that can let rows with no value through, a declared default order for guarded queries, and refused queries written to the audit. + v3.2.0 — A row projected before ApplyPolicy keeps its nested objects and lists, denials the policy gate could not see are enforced, a declared default order reaches projected rows, and every asynchronous terminal takes a CancellationToken. + +Security fix: a field denied for Select only beneath a member was not enforced when the caller sent no Selects. A guarded query synthesizes a projection for a denied field, and it did so only when a simple field at the top of the type was denied. With every denial beneath a member, the whole row came back with the denied value in it: in a list or nested object of a row projected before ApplyPolicy, in a row held in memory, and in an entity's included, automatically included, lazily loaded or owned member, typed and dynamic, in both tiers, for a Filter and a Segment. Such a denial now synthesizes the projection whenever its value can reach the result. On an entity that means beneath a column, an owned or complex member, or a navigation the query loads through Include, an automatic include or a lazy loader; a denial beneath a navigation nothing loads never leaves the database, and the entity is read exactly as before. + +Security fix: what a query loads, and what a member holds, was read too narrowly. An include named from the root and reached through Select(o => o.Customer), SelectMany or Join, a projection behind another Select, an initializer after a constructor with arguments, and a lazy loader the constructor takes and keeps in a field or any property each loaded a denied value the gate read as unloaded. An injected DbContext and EF Core 7's asynchronous loader delegate loaded one too, and so did a reshaping lambda that got its row from an application's method or from a captured query or object. An application's own collection class hid its own denied members, and a guarded query through a provider wrapping EF Core's, such as LinqKit's AsExpandable, ran tracking, so the context filled in navigations it already held and a masked value became a pending change. A field a subtype declares, a derived entity's, a subclass's held by a base-typed member or an open generic one's, was not read at all, nor was a [DwDenied] on an override, on a member hidden with new or on an interface member's implementation, and under a "*" deny with exact allows a path the walk never asked about (past four segments, around a cycle, with no setter) resolved as allowed. Selects naming an entity navigation returned a denial in its owned chain past four segments or in a converted Dictionary<string, T> column. All of these are read now: from the EF Core model for an entity, so only what loads counts, and from every loaded subtype for a projected or in-memory row. Rows whose derived type declares a denied field come back as the queried type. A denial on an override, a public member hidden with new or an implementation, through a variant instantiation too, applies to the base type's or the interface's path, on every row and in every clause. + +Security fix: a field denied at the top of a type whose own type is not a simple value, such as a byte array, a list, an owned object or a JSON column, synthesized no projection either, so with nothing else denied it came back. + +Security fix: the attribute walker read any namespace starting with "System" as the framework's, so an application namespace such as SystemsCorp.Payroll got no policy beneath its types, and a [DwDenied] field there was returned, filterable and sortable. Only System and the namespaces beneath it are treated as the framework's now. + +Security fix: under the convenience tier, Selects naming a navigation whose element key (Id) is denied was narrowed to the allowed fields beneath it, and the core's typed projection added the key back. Such a narrowing is refused with FieldDeniedForSelect in both tiers, as naming a sibling of the key already was. A navigation named through another, such as Main.Lead, now gates the key of Main, which the projection adds; it did not. + +Security fix: Selects naming a member typed as a collection the core does not unwrap, such as IReadOnlyList<T>, returned every field beneath it, denied ones included, in both tiers. The projection gate read collections through a narrower list than the attribute walker that puts policy on the fields beneath; it now reads them the same way. Denials beneath a named member are also read from the policy's own rules, so a denied property with no setter and a rule on a path reached through a cycle are found, and a member carrying a field denied where no path reaches it, deeper than the walker, inside a framework collection such as Dictionary<string, T> or on a subtype, is refused under the strict tier, and under the convenience tier narrowed where the core can narrow it and refused where it cannot. + +Behaviour change: the projection synthesized for a guarded query that sends no Selects keeps what the source carries. It used to keep simple fields only, so every nested object and list of a row projected before ApplyPolicy came back null or empty as soon as any field was denied. A row a projection builds keeps the members its initializer assigns. An entity keeps its mapped columns, converted and JSON ones included except a converted one that can hold an object of any type, its owned and complex members, and every collection of simple values such as byte[] or List<string>. A member holding an object is kept whole when nothing it can hold is denied, narrowed to the allowed fields where the core's narrowing translates, and otherwise left out whole, recorded as Dropped with a reason starting "left out whole". An entity's navigations, the objects of a row in memory, and a value EF Core does not map are left out, and the type needs a public parameterless constructor for the typed projection, as it already did. A member that can hold an object of any type, a geometry or a JSON bag say, asks for no projection on its own; a member EF Core does not map is read as its type, since its getter can hand out what EF Core loaded; and a chain reaching its rows through a navigation, SelectMany, Join or GroupBy counts every navigation as loaded only when it has an include, or a lambda that builds an object, gets one from an application's method, or captures a query with its own include or projection. + +Behaviour change: [DwEntity(DefaultOrder)] applies to a projected source whose outermost Select builds the type in an object initializer and assigns every field the default names a column, at every level of a nested path: a mapped member read directly, through reference navigations or through EF.Property. A computed value or any other projection still leaves the query in its own order. A Select, or a Filter with Selects, composed on the guarded handle keeps the rest of the chain unordered, and a composed Filter that sent orders gets no default later in the chain, as a composed Order already did not. + +New: every asynchronous terminal has overloads taking a CancellationToken, guarded and unguarded: ToListAsync and ToListAsyncDynamic with a Filter, ToListAsync with a Summary, and ToListAsync with a Segment. The token reaches both the count and the read. The overloads sit beside the 3.1 signatures, which are unchanged, so code compiled against 3.1 still binds. ToListAsync(filter, default) no longer compiles, because default fits both overloads, and a reflection lookup of one of these methods by name alone finds more overloads than it did. + +Change: ToListAsyncDynamic and the asynchronous Summary read through EF Core's ToListAsync instead of Dynamic LINQ's ToDynamicListAsync, which had no token to pass on, and the Summary counts through CountAsync where it counted synchronously. So on an EF Core query a canceled token now reaches the database. A provider that is not EF Core's keeps Dynamic LINQ's read, on the calling thread. + +v3.1.0 — Date comparisons that work on every date member, segments combined in the database, five new caps, a stable code where a sentence used to be, preparation enforced whether or not a store is configured, a policy bypass through members named Root, It or Parent closed, a long In list that ended the process fixed, the names the expression parser keeps refused by name, a strict tier that no longer discloses its trace or which fields exist, forced predicates that can let rows with no value through, a declared default order for guarded queries, and refused queries written to the audit. Security fix: a member named Root, It or Parent was read as a System.Linq.Dynamic.Core keyword. Root.Name and It.Name filtered, sorted, grouped, aggregated and projected the row's own Name, Parent threw, and an alias named root, it or parent failed in Having and Summary orders. Under ApplyPolicy the gate decided on the path the caller named while the query read the row's own column: a dynamic projection of Root.Name returned a [DwDenied] Name, a filter on it tested the denied column, and a [DwForceWhere] scope reached through such a navigation filtered the row's own column. Every expression is now parsed with a library-owned ParsingConfig with the context keywords off. ParsingConfig.Default is no longer read, so a host's changes to it no longer reach DynamicWhere queries. diff --git a/DynamicWhere.ex/Policies/DTOs/PolicyTrace.cs b/DynamicWhere.ex/Policies/DTOs/PolicyTrace.cs index ae88f09..4f5dd44 100644 --- a/DynamicWhere.ex/Policies/DTOs/PolicyTrace.cs +++ b/DynamicWhere.ex/Policies/DTOs/PolicyTrace.cs @@ -64,6 +64,15 @@ public void Add(PolicyDecision decision) _decisions.Add(decision); } + /// How many decisions have been recorded. + internal int Count => _decisions.Count; + + /// + /// Withdraws every decision recorded after the first : the steps of an action + /// that was then not taken, such as members left out of a projection that is not built. + /// + internal void Withdraw(int count) => _decisions.RemoveRange(count, _decisions.Count - count); + /// Remembers that the caller reached a canonical path by writing another name. internal void RecordSpelling(string canonicalPath, string spoken) => _spoken[canonicalPath] = spoken; diff --git a/DynamicWhere.ex/Policies/Resolution/AttributePolicyProvider.cs b/DynamicWhere.ex/Policies/Resolution/AttributePolicyProvider.cs index 77a409d..f1c0730 100644 --- a/DynamicWhere.ex/Policies/Resolution/AttributePolicyProvider.cs +++ b/DynamicWhere.ex/Policies/Resolution/AttributePolicyProvider.cs @@ -6,6 +6,7 @@ using DynamicWhere.ex.Policies.Context; using DynamicWhere.ex.Policies.DTOs; using DynamicWhere.ex.Policies.Enums; +using DynamicWhere.ex.Policies.Source; namespace DynamicWhere.ex.Policies.Resolution; @@ -13,7 +14,8 @@ namespace DynamicWhere.ex.Policies.Resolution; /// Produces policy fragments by reflecting over the attributes on a type. /// /// -/// The result is identical for every caller, so it is computed once per type and cached. An +/// The result is identical for every caller, so it is computed once per type and cached, until an +/// assembly that could declare a subtype loads (see the denials below). An /// attribute with Overridable = false lands at and /// nothing at runtime can replace it; one with Overridable = true lands at /// , the least authoritative level, and acts only as @@ -21,7 +23,7 @@ namespace DynamicWhere.ex.Policies.Resolution; /// public sealed class AttributePolicyProvider : IDwPolicyProvider { - private static readonly ConcurrentDictionary> Cache = new(); + private static readonly ConcurrentDictionary Fragments)> Cache = new(); /// /// How many navigation segments a generated field path may contain. Matches the default @@ -38,7 +40,19 @@ public IReadOnlyList GetFragments(Type entityType, DwPolicyConte throw new ArgumentNullException(nameof(entityType)); } - return Cache.GetOrAdd(entityType, Build); + int epoch = KnownSubtypes.Epoch; + + if (Cache.TryGetValue(entityType, out (int Epoch, IReadOnlyList Fragments) known) + && known.Epoch == epoch) + { + return known.Fragments; + } + + IReadOnlyList fragments = Build(entityType); + + Cache[entityType] = (epoch, fragments); + + return fragments; } /// @@ -107,6 +121,11 @@ private static void Walk( fragments.Add(ToFragment(path, attribute)); } + foreach (DwDenyAttribute attribute in DenialsElsewhere(type, property)) + { + fragments.Add(ToFragment(path, attribute)); + } + foreach (DwOperatorsAttribute attribute in property.GetCustomAttributes(inherit: true)) { fragments.Add(ToFragment(path, attribute)); @@ -180,6 +199,224 @@ private static void Walk( } } + /// + /// The denials a member carries from another declaration of it: an interface member it implements, + /// and, in a type loaded below the one walked, the override, the member hidden with new, or the + /// implementation that a row of that type runs. + /// + /// + /// Attributes are read from the declaration walked, and inheritance only reaches up the chain. A row + /// read through a base type or an interface is still the subtype it is, though, and its member returns + /// what the subtype's declaration returns: a [DwDenied] on override Code, or on the + /// class's implementation of IAccount.Iban, was never seen through the base path, which filtered, + /// sorted, grouped and returned it. Each such denial applies to the path for every row, since a + /// projection cannot withhold a field from some rows only. + /// + /// Every declaration a row can run counts: an override of either accessor, an implementation declared + /// explicitly, inherited from a base class or by an open generic class, and an interface a subtype adds + /// over a member it inherits. So does a member a subtype hides with new: whether it reads the + /// member it hides cannot be told from outside, and a row serialized as its own type writes it under the + /// same name. + /// + /// + internal static IReadOnlyList DenialsElsewhere(Type type, PropertyInfo property) + { + int epoch = KnownSubtypes.Epoch; + + if (Elsewhere.TryGetValue((type, property), out (int Epoch, DwDenyAttribute[] Denials) known) && known.Epoch == epoch) + { + return known.Denials; + } + + DwDenyAttribute[] denials = ReadDenialsElsewhere(type, property).ToArray(); + + Elsewhere[(type, property)] = (epoch, denials); + + return denials; + } + + /// The denials of each member's other declarations, read once for each type and member until another assembly loads. + private static readonly ConcurrentDictionary<(Type Type, PropertyInfo Property), (int Epoch, DwDenyAttribute[] Denials)> Elsewhere = new(); + + private static IEnumerable ReadDenialsElsewhere(Type type, PropertyInfo property) + { + MethodInfo[] accessors = property.GetAccessors(nonPublic: true); + + if (accessors.Length == 0) + { + yield break; + } + + // Many rows reach one declaration, an interface every subtype implements, and it is read once. + HashSet read = new() { property }; + + foreach (Type row in KnownSubtypes.Of(type).Prepend(type)) + { + if (row.IsInterface) + { + continue; + } + + // What a row of this type runs for the member: the member, or what the row declares in its + // place; read through an interface, the row's implementation of it. + List runs = new(); + + if (type.IsInterface) + { + foreach (PropertyInfo implementation in Implementations(row, type, accessors)) + { + runs.AddRange(implementation.GetAccessors(nonPublic: true)); + + if (read.Add(implementation)) + { + foreach (DwDenyAttribute attribute in implementation.GetCustomAttributes(inherit: true)) + { + yield return attribute; + } + } + } + } + else + { + runs.AddRange(accessors); + + if (row != type) + { + foreach (PropertyInfo below in Redeclarations(row, property)) + { + runs.AddRange(below.GetAccessors(nonPublic: true)); + + if (read.Add(below)) + { + foreach (DwDenyAttribute attribute in below.GetCustomAttributes(inherit: false)) + { + yield return attribute; + } + } + } + } + } + + foreach (PropertyInfo declared in Declarations(row, runs, accessors)) + { + if (read.Add(declared)) + { + foreach (DwDenyAttribute attribute in declared.GetCustomAttributes(inherit: false)) + { + yield return attribute; + } + } + } + } + } + + /// + /// What a subtype declares in a member's place: an override of either accessor, or a public member of the + /// same name that hides it. Both carry the member's name. One the subtype keeps to itself hides nothing a + /// caller reads, and no serializer writes it. + /// + private static IEnumerable Redeclarations(Type subtype, PropertyInfo property) => + subtype + .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(candidate => candidate.Name == property.Name); + + /// + /// The properties a class implements an interface's member with: through the interface itself, through an + /// instantiation variance lets stand for it, IFeed<VisaCard> for IFeed<Card> with out T, and, for + /// a class that is itself open generic, through one over its own type parameters, which may be any. + /// + private static IEnumerable Implementations(Type row, Type contract, MethodInfo[] accessors) + { + List found = new(); + + foreach (Type implemented in row.GetInterfaces()) + { + bool same = implemented == contract + || (implemented.IsGenericType && contract.IsGenericType + && implemented.GetGenericTypeDefinition() == contract.GetGenericTypeDefinition() + && (implemented.ContainsGenericParameters || contract.ContainsGenericParameters + || contract.IsAssignableFrom(implemented))); + + if (!same || Map(row, implemented) is not { } map) + { + continue; + } + + for (int i = 0; i < map.InterfaceMethods.Length; i++) + { + if (accessors.Any(accessor => Same(map.InterfaceMethods[i], accessor)) + && Declaring(map.TargetMethods[i]) is { } implementation + && !found.Contains(implementation)) + { + found.Add(implementation); + } + } + } + + return found; + } + + /// + /// The interface properties a class implements with any of the accessors it runs, other than the member + /// walked itself. + /// + private static IEnumerable Declarations(Type row, List runs, MethodInfo[] own) + { + MethodInfo[] roots = runs.Select(accessor => accessor.GetBaseDefinition()).ToArray(); + List found = new(); + + foreach (Type contract in row.GetInterfaces()) + { + if (Map(row, contract) is not { } map) + { + continue; + } + + for (int i = 0; i < map.TargetMethods.Length; i++) + { + MethodInfo declared = map.InterfaceMethods[i]; + + if (roots.Any(root => Same(map.TargetMethods[i].GetBaseDefinition(), root)) + && !own.Any(accessor => Same(accessor, declared)) + && Declaring(declared) is { } property + && !found.Contains(property)) + { + found.Add(property); + } + } + } + + return found; + } + + /// + /// A class's interface map, or null when the runtime cannot map it, as for some open generic types. + /// Read once for each class and interface. + /// + private static InterfaceMapping? Map(Type type, Type contract) => + Maps.GetOrAdd((type, contract), static key => + { + try + { + return key.Type.GetInterfaceMap(key.Contract); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or NotSupportedException) + { + return null; + } + }); + + private static readonly ConcurrentDictionary<(Type Type, Type Contract), InterfaceMapping?> Maps = new(); + + /// The property an accessor belongs to, found on the type that declares the accessor. + private static PropertyInfo? Declaring(MethodInfo accessor) => + accessor.DeclaringType? + .GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .FirstOrDefault(candidate => candidate.GetAccessors(nonPublic: true).Any(method => Same(method, accessor))); + + private static bool Same(MethodInfo left, MethodInfo right) => + left.MetadataToken == right.MetadataToken && left.Module == right.Module; + /// /// How many collection layers peels off a single property type /// before it stops and walks whatever it has reached. @@ -217,7 +454,20 @@ private static void Walk( /// followed as well as classes, because these attributes are supported on DTOs, where an /// IContact reference or a record struct is ordinary. /// - internal static Type? NavigationTypeOf(Type propertyType) + internal static Type? NavigationTypeOf(Type propertyType) => AsNavigation(Peeled(propertyType)); + + /// + /// The type a property holds once every collection layer and is peeled + /// off: LineDto for IReadOnlyList<LineDto>, for + /// List<string>, and the property's own type when it is not a collection. + /// + /// + /// The one reading of a property's shape that the walker and the projection gate share. The gate + /// once read collections through a narrower list of its own, and a member typed + /// IReadOnlyList<T> hid every denial beneath it from the projection while the walker + /// had put a fragment on each of them. + /// + internal static Type Peeled(Type propertyType) { Type current = Nullable.GetUnderlyingType(propertyType) ?? propertyType; @@ -233,7 +483,32 @@ private static void Walk( current = Nullable.GetUnderlyingType(element) ?? element; } - return AsNavigation(current); + return current; + } + + /// + /// The type a property holds, then each collection layer beneath it, down to the element + /// reaches: List<Tags>, then Tags, then . + /// + internal static IEnumerable Layers(Type propertyType) + { + Type current = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + + yield return current; + + for (int layer = 0; layer < MaxCollectionLayers; layer++) + { + Type? element = ElementTypeOf(current); + + if (element is null) + { + yield break; + } + + current = Nullable.GetUnderlyingType(element) ?? element; + + yield return current; + } } /// @@ -296,9 +571,22 @@ private static void Walk( return null; } - return type.Namespace?.StartsWith("System", StringComparison.Ordinal) == true ? null : type; + return IsFramework(type) ? null : type; } + /// + /// True for a type the framework declares: one in the System namespace or beneath it. + /// + /// + /// The namespace is compared as a whole segment. A prefix match took an application's own + /// SystemsCorp.Payroll or SystemX.Domain for the framework, so nothing beneath one of + /// its types got a fragment, and a [DwDenied] field there was returned and filtered on as + /// though it carried no policy. + /// + internal static bool IsFramework(Type type) => + type.Namespace is { } name + && (name == "System" || name.StartsWith("System.", StringComparison.Ordinal)); + /// /// Converts one attribute into a fragment at the level its Overridable flag implies. /// diff --git a/DynamicWhere.ex/Policies/Resolution/PolicyResolver.cs b/DynamicWhere.ex/Policies/Resolution/PolicyResolver.cs index 53fd442..c5be3b3 100644 --- a/DynamicWhere.ex/Policies/Resolution/PolicyResolver.cs +++ b/DynamicWhere.ex/Policies/Resolution/PolicyResolver.cs @@ -373,6 +373,19 @@ public TypePolicy ResolveType(Type entityType, DwPolicyContext context) return new TypePolicy(aliases, forced, required, transforms); } + /// + /// Every fragment every provider has for a type and caller, in one list. + /// + /// + /// For the projection gate, which asks whether anything beneath a member is denied. A fragment + /// matches a path exactly or matches every path, so the paths these fragments name, and the + /// wildcard, are every place a denial can land. Walking the type instead missed the paths a walk + /// does not produce: a property with no setter, a type reached again through a cycle, a rule on a + /// path deeper than the walk goes. + /// + internal IReadOnlyList Fragments(Type entityType, DwPolicyContext context) => + Sweep(entityType, context).ToList(); + /// /// Reads every fragment every provider has for a type, refusing a provider that misbehaves. /// diff --git a/DynamicWhere.ex/Policies/Source/DefaultOrder.cs b/DynamicWhere.ex/Policies/Source/DefaultOrder.cs index f2061e6..8884656 100644 --- a/DynamicWhere.ex/Policies/Source/DefaultOrder.cs +++ b/DynamicWhere.ex/Policies/Source/DefaultOrder.cs @@ -7,6 +7,8 @@ using DynamicWhere.ex.Optimization.Cache.Source; using DynamicWhere.ex.Policies.Attributes; using DynamicWhere.ex.Source; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; namespace DynamicWhere.ex.Policies.Source; @@ -86,21 +88,86 @@ or nameof(Queryable.ThenBy) or nameof(Queryable.ThenByDescending)) } /// - /// True when a query's rows are a projection made somewhere along the chain that produced it. + /// True when a query's rows are built by a projection: the outermost Select of the chain + /// constructs each row, in an initializer or with a constructor, so every member holds what the + /// projection gave it. /// /// - /// A default names fields of the type, and a projection keeps only the members it assigns, so a - /// default ordering it could reach for a member the projection left out and fail to translate: - /// Select(["Id", "Title"]).Page(...) on a type ordered by Priority worked before the - /// default existed. Such a query is left in whatever order it had. + /// A Select that hands back an entity, Select(o => o.Customer), builds nothing: its + /// rows are entities as EF Core loads them, whose navigations hold a value only when something + /// includes them. This answers what the rows are, not whether a default can be applied to them, + /// which answers. /// - internal static bool IsProjected(Expression expression) + internal static bool BuildsRows(Expression expression) => + RowSelect(expression) is { } select + && StripQuotes(select.Arguments[1]) is LambdaExpression selector + && StripConversions(selector.Body) is MemberInitExpression or NewExpression; + + /// + /// The Select that makes a query's rows: the outermost one, when every call after it hands back + /// the rows it was given. Null when nothing projects the rows, or when a call such as a + /// SelectMany or a Join reaches the rows through it, so that its members are not theirs. + /// + internal static MethodCallExpression? RowSelect(Expression expression) { for (Expression? node = expression; node is MethodCallExpression call; node = call.Arguments.Count > 0 ? call.Arguments[0] : null) { if ((call.Method.DeclaringType == typeof(Queryable) || call.Method.DeclaringType == typeof(Enumerable)) && call.Method.Name == nameof(Queryable.Select)) + { + return call; + } + + if (!QueryRoot.KeepsRows(call)) + { + return null; + } + } + + return null; + } + + /// + /// True when a projection along the chain could have left out a field the type's default names. + /// + /// + /// A default names fields of the type, and a projection keeps only the members it assigns, so a + /// default ordering one it could reach for a member the projection left out and fail to translate: + /// Select(["Id", "Title"]).Page(...) on a type ordered by Priority worked before the + /// default existed. + /// + /// Only the outermost projection is read, because it makes the rows the default orders. It hides + /// nothing when it builds the type itself in an initializer — new Row { Code = t.Code } — + /// and assigns every field the default names, at every level of a nested path, a column of the + /// entity it reads on EF Core. EF Core can then translate the order. Anything else, a constructor + /// with arguments, a member it does not assign, a value it computes or a nested path through + /// something other than an initializer, leaves the query in whatever order it had. + /// + /// + internal static bool HidesDefault(Expression expression, Type type) + { + if (OutermostSelect(expression) is not { } select) + { + return false; + } + + if (StripQuotes(select.Arguments[1]) is not LambdaExpression selector + || StripConversions(selector.Body) is not MemberInitExpression initializer + || !type.IsAssignableFrom(initializer.Type)) + { + return true; + } + + // On EF Core a default field must be a column the database can order by. In memory anything can + // be ordered. + ColumnReader? columns = QueryRoot.Model(expression) is { } model + ? new ColumnReader(selector.Parameters[0], model.FindEntityType(selector.Parameters[0].Type)) + : null; + + foreach (Entry entry in For(type)) + { + if (!Assigns(initializer.Bindings, entry.Field.Split('.'), 0, columns)) { return true; } @@ -109,11 +176,131 @@ internal static bool IsProjected(Expression expression) return false; } + /// The Select nearest the end of the chain, or null when nothing projects it. + internal static MethodCallExpression? OutermostSelect(Expression expression) + { + for (Expression? node = expression; node is MethodCallExpression call; + node = call.Arguments.Count > 0 ? call.Arguments[0] : null) + { + if ((call.Method.DeclaringType == typeof(Queryable) || call.Method.DeclaringType == typeof(Enumerable)) + && call.Method.Name == nameof(Queryable.Select)) + { + return call; + } + } + + return null; + } + + /// True when the bindings assign the path, following nested initializers down it. + private static bool Assigns(IEnumerable bindings, string[] path, int depth, ColumnReader? columns) + { + MemberBinding? binding = bindings.FirstOrDefault( + candidate => string.Equals(candidate.Member.Name, path[depth], StringComparison.Ordinal)); + + if (binding is null) + { + return false; + } + + if (depth == path.Length - 1) + { + return binding is MemberAssignment { Expression: var assigned } + && (columns is null || columns.Column(assigned)); + } + + return binding switch + { + MemberAssignment { Expression: var assigned } + when StripConversions(assigned) is MemberInitExpression nested => + Assigns(nested.Bindings, path, depth + 1, columns), + MemberMemberBinding nested => Assigns(nested.Bindings, path, depth + 1, columns), + _ => false + }; + } + + /// + /// Reads whether EF Core can order by what a projection assigns: a column of the entity the + /// projection reads, directly, through reference navigations, or through EF.Property. + /// + /// + /// An assigned field is not enough on its own. Label = Decorate(r.Code) assigns the field the + /// default names, and EF Core evaluates the application's own method on the client, where it can + /// project the value but cannot order by it; so it does Regex.Replace, a member the model does + /// not map, and any number of framework methods a given provider cannot translate. Anything but a + /// column is therefore left out of the default, as the projection left the query unordered before: + /// a default must never be the reason a query that ran unguarded fails. + /// + private sealed class ColumnReader + { + private readonly ParameterExpression _row; + private readonly IEntityType? _source; + + internal ColumnReader(ParameterExpression row, IEntityType? source) + { + _row = row; + _source = source; + } + + /// True when the expression reads one column the model maps. + internal bool Column(Expression expression) + { + expression = StripConversions(expression); + + return Read(expression) is ({ } owner, { } name) && owner.FindProperty(name) is not null; + } + + /// The entity a navigation expression reaches, through reference navigations only. + private IEntityType? EntityOf(Expression expression) + { + expression = StripConversions(expression); + + if (expression == _row) + { + return _source; + } + + // A reference, read the way EF Core 6 reads IsCollection: the property that moved in EF Core 8 + // is not bound, so the check holds on either. + return Read(expression) is ({ } owner, { } name) + && owner.FindNavigation(name) is { } navigation + && (navigation.IsOnDependent || navigation.ForeignKey.IsUnique) + ? navigation.TargetEntityType + : null; + } + + /// The entity a member read is made on, and the member's name. + private (IEntityType? Owner, string? Name) Read(Expression expression) => expression switch + { + MemberExpression { Member: PropertyInfo property, Expression: { } owner } => + (EntityOf(owner), property.Name), + MethodCallExpression { Method.Name: nameof(EF.Property) } call + when call.Method.DeclaringType == typeof(EF) + && call.Arguments[1] is ConstantExpression { Value: string name } => + (EntityOf(call.Arguments[0]), name), + _ => (null, null) + }; + } + + internal static Expression StripQuotes(Expression expression) => + expression is UnaryExpression { NodeType: ExpressionType.Quote } quote ? quote.Operand : expression; + + internal static Expression StripConversions(Expression expression) + { + while (expression is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.TypeAs } conversion) + { + expression = conversion.Operand; + } + + return expression; + } + /// /// True when a guarded query over this source takes the type's default: nothing has ordered it and - /// nothing has projected it. + /// no projection hides a field the default names. /// - internal static bool Applies(Expression expression) => !IsOrdered(expression) && !IsProjected(expression); + internal static bool Applies(Expression expression, Type type) => + !IsOrdered(expression) && !HidesDefault(expression, type); /// /// Everything wrong with a type's declared default: entries that cannot be read, fields no query can diff --git a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs index 189e7d0..ede7cac 100644 --- a/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs +++ b/DynamicWhere.ex/Policies/Source/FilterSanitizer.cs @@ -1,9 +1,12 @@ -using System.Reflection; +using System.Collections; +using System.Collections.Concurrent; +using System.Reflection; using DynamicWhere.ex.Classes.Complex; using DynamicWhere.ex.Classes.Core; using DynamicWhere.ex.Enums; using DynamicWhere.ex.Exceptions; using DynamicWhere.ex.Optimization.Cache.Source; +using DynamicWhere.ex.Policies.Attributes; using DynamicWhere.ex.Policies.Audit; using DynamicWhere.ex.Policies.Config; using DynamicWhere.ex.Policies.Context; @@ -58,6 +61,10 @@ internal static class FilterSanitizer /// field this caller may not order by. False for a clause composed on its own, which orders /// nothing the caller did not ask it to. /// + /// + /// What the query's source puts in the members of its rows, which decides what a synthesized + /// projection keeps. Null for an entity query whose model is unknown. + /// /// A sanitized copy, safe to hand to the existing pipeline. /// Thrown when any argument is null. /// @@ -75,7 +82,8 @@ internal static Filter Sanitize( DwPolicyOptions options, PolicyTrace trace, bool synthesizeProjection = true, - bool applyDefaultOrder = true) + bool applyDefaultOrder = true, + RowShape? rows = null) where T : class { if (filter is null) @@ -141,7 +149,7 @@ internal static Filter Sanitize( working.Orders = defaults; } - GateSelects(working, gate, synthesizeProjection); + GateSelects(working, gate, synthesizeProjection, rows ?? RowShape.Unknown); // The strict tier checks the bill once every field has passed its gate. It drops nothing, so a // request still here names no field the caller may not use, and a weighted field the caller may @@ -610,6 +618,10 @@ private static void GateSummaryOrders( /// When true and the caller sent no orders, the type's declared default order is added, less every /// field this caller may not order by. /// + /// + /// What the query's source puts in the members of its rows, which decides what a synthesized + /// projection keeps. Null for an entity query whose model is unknown. + /// /// /// Every condition set is gated independently, because each one becomes its own subquery and a /// field refused in one must not be reachable through another. @@ -626,7 +638,8 @@ internal static Segment Sanitize( DwPolicyContext context, DwPolicyOptions options, PolicyTrace trace, - bool applyDefaultOrder = true) + bool applyDefaultOrder = true, + RowShape? rows = null) where T : class { if (segment is null) @@ -720,7 +733,7 @@ internal static Segment Sanitize( working.Orders = defaults; } - GateSegmentSelects(working, gate); + GateSegmentSelects(working, gate, rows ?? RowShape.Unknown); // The strict tier checks the bill once every field has passed its gate. It drops nothing, so a // request still here names no field the caller may not use, and a weighted field the caller may @@ -964,11 +977,11 @@ private static void GateSegmentOrders(Segment segment, Gate gate) /// materializes. /// /// - private static void GateSegmentSelects(Segment segment, Gate gate) + private static void GateSegmentSelects(Segment segment, Gate gate, RowShape rows) { if (segment.Selects is null || segment.Selects.Count == 0) { - if (SynthesizedProjection(gate) is List synthesized) + if (SynthesizedProjection(gate, rows) is List synthesized) { segment.Selects = synthesized; } @@ -976,7 +989,7 @@ private static void GateSegmentSelects(Segment segment, Gate gate) return; } - List kept = GateProjection(segment.Selects, gate); + List kept = GateProjection(segment.Selects, gate, rows); if (kept.Count == 0) { @@ -1604,7 +1617,7 @@ private static List DefaultOrders(Gate gate, bool segment = false) w /// entity — turning the strictest possible policy into the widest possible result. /// /// - private static void GateSelects(Filter filter, Gate gate, bool synthesize) + private static void GateSelects(Filter filter, Gate gate, bool synthesize, RowShape rows) { if (filter.Selects is null || filter.Selects.Count == 0) { @@ -1613,13 +1626,13 @@ private static void GateSelects(Filter filter, Gate gate, bool synthesize) // refuse the call outright on a type whose every field is denied for select. if (synthesize) { - SynthesizeSelects(filter, gate); + SynthesizeSelects(filter, gate, rows); } return; } - List kept = GateProjection(filter.Selects, gate); + List kept = GateProjection(filter.Selects, gate, rows); if (kept.Count == 0) { @@ -1644,8 +1657,14 @@ private static void GateSelects(Filter filter, Gate gate, bool synthesize) /// expansion happens only where there is something to narrow, so a type the policy has no /// opinion about still generates the SQL the unguarded path would. /// + /// + /// A narrowing the core cannot build as written is refused rather than handed over. The builder + /// adds the key of every node it narrows, so dropping a denied key from the list put it straight + /// back; and a path through a collection the core does not unwrap, such as an + /// IReadOnlyList<T>, failed validation with an error about the caller's field names. + /// /// - private static List GateProjection(IEnumerable selects, Gate gate) + private static List GateProjection(IEnumerable selects, Gate gate, RowShape rows) { List kept = new(); @@ -1657,19 +1676,14 @@ void Keep(string path) } } - bool Allowed(string path) - { - FieldPolicy policy = gate.PolicyFor(path, PolicyFeature.Select); - - return policy.Allows(PolicyFeature.Select) - || !gate.Refuse(path, PolicyFeature.Select, PolicyErrorCode.FieldDeniedForSelect, policy); - } - foreach (string field in selects) { // The field's own decision first. A navigation that is itself denied is refused as it // stands; what it carries is only asked about once it has survived on its own account. - if (!Allowed(field)) + FieldPolicy own = gate.PolicyFor(field, PolicyFeature.Select); + + if (!own.Allows(PolicyFeature.Select) + && gate.Refuse(field, PolicyFeature.Select, PolicyErrorCode.FieldDeniedForSelect, own)) { continue; } @@ -1682,30 +1696,92 @@ bool Allowed(string path) continue; } - IReadOnlyList carried = gate.ProjectionUnder(field); - List survivors = new(carried.Count); - bool narrowed = false; + (List survivors, List<(string Path, FieldPolicy Policy)> denied) = + gate.Beneath(field, PolicyFeature.Select); + + // Each denied path, as a denial beneath a named navigation always was: refused under the + // strict tier, dropped from the narrowing otherwise, left in place in a dry run. + (string Path, FieldPolicy Policy)? cause = null; - foreach (string path in carried) + foreach ((string path, FieldPolicy policy) in denied) { - if (Allowed(path)) - { - survivors.Add(path); - } - else + if (gate.Refuse(path, PolicyFeature.Select, PolicyErrorCode.FieldDeniedForSelect, policy)) { - narrowed = true; + cause ??= (path, policy); } } - if (!narrowed) + // A member can carry a denied field no path names: beyond the walker's depth, inside a + // framework collection, declared by a subtype, or, under a policy that denies whatever it + // does not name, anywhere the walk does not reach. On an entity, only what loads counts. + bool hidden = cause is null + && gate.Unnamed(field, rows).DeniesSelect + && gate.RefuseHidden(field); + + if (cause is null && !hidden && !gate.ReadOnlyTransformBeneath(field)) { Keep(field); + // Kept whole, a navigation reached through others still passes through their nodes, and + // the builder adds each one's key. + KeepCarriedKeys(field, gate, Keep); + + continue; + } + + (string Path, FieldPolicy? Policy) blame = cause is { } found ? (found.Path, found.Policy) : (field, null); + + if (!rows.Narrows(field.Split(SegmentSeparator)[0])) + { + // Narrowed only to keep a transform off a property it could not write: nothing is withheld + // by keeping it whole, as it always was kept. + if (cause is null && !hidden) + { + Keep(field); + KeepCarriedKeys(field, gate, Keep); + + continue; + } + + gate.RefuseNarrowing(blame.Path, blame.Policy, $"'{field}' cannot be narrowed: {rows.WhyNotNarrowed(field)}"); + continue; } + List narrowed = new(survivors.Count); + foreach (string path in survivors) + { + // Projected whole by the core, a field holding a framework collection of a policed type + // carries the denied fields the policy cannot name inside it. + if (gate.DeclaredType(path) is { } leaf && !Gate.HoldsValue(leaf) && gate.Unnamed(path, rows).DeniesSelect + && gate.RefuseHidden(path)) + { + continue; + } + + narrowed.Add(path); + } + + if (gate.DeniedKey(narrowed) is { } key) + { + gate.RefuseNarrowing( + key.Path, key.Policy, + $"'{field}' was narrowed, and the projection builder adds this key to every node it narrows"); + + continue; + } + + if (gate.Unprojectable(narrowed) is { } unbuilt) + { + gate.RefuseNarrowing( + blame.Path, blame.Policy, + $"'{field}' cannot be narrowed around it: the core cannot project '{unbuilt}'"); + + continue; + } + + foreach (string path in narrowed) { Keep(path); } @@ -1760,7 +1836,7 @@ private static void KeepCarriedKeys(string path, Gate gate, Action keep) /// denied. /// /// - /// A caller who sends no projection gets the whole entity, denied columns included, because + /// A caller who sends no projection gets the whole row, denied columns included, because /// the pipeline only projects when Selects is non-null. Gating the list alone would /// therefore enforce deny-select against precisely the callers who volunteered one, and leave /// it bypassable by asking for less. @@ -1771,19 +1847,14 @@ private static void KeepCarriedKeys(string path, Gate gate, Action keep) /// one that rewrites every query in the application. /// /// - /// Scalars only. A navigation is not loaded by an unguarded call in the first place, and - /// projecting one whole would carry every field beneath it — reopening the same hole one level - /// down. Where a caller had eagerly loaded one, narrowing it away fails closed. - /// - /// /// This never throws in the strict tier for a denied field. Strict refuses what a caller asks /// for, and here the caller named nothing; throwing would fail every strict query against a /// type carrying any denied field at all. /// /// - private static void SynthesizeSelects(Filter filter, Gate gate) + private static void SynthesizeSelects(Filter filter, Gate gate, RowShape rows) { - if (SynthesizedProjection(gate) is List allowed) + if (SynthesizedProjection(gate, rows) is List allowed) { filter.Selects = allowed; } @@ -1795,33 +1866,181 @@ private static void SynthesizeSelects(Filter filter, Gate gate) /// /// Shared by the filter and the segment paths, because they are closing the same hole and a /// second copy of this is a second place to forget. + /// + /// What an unguarded call would return, less what the policy withholds. A member that holds a + /// value is kept when it is allowed. A member that holds an object, or a list of them, is kept + /// whole when nothing beneath it is denied, and narrowed the way a caller naming it would have it + /// narrowed when something is — but only where the source carries it: every member of a + /// projected row or a row in memory, and the owned and complex members of an entity. An entity's + /// other navigations are left out, since an unguarded call loads them only when something includes + /// them, and projecting one would load it. + /// + /// + /// Everything beneath every object member is asked about, whether or not the source carries it. + /// A denial only beneath a member used to synthesize nothing, so the row came back whole, and an + /// included navigation, an owned member, a projected list or an object in memory carried the + /// denied value out with it. An entity query does not say what it loads: an include, an automatic + /// include and a lazy loader all fill a navigation the query text never names. + /// + /// + /// A member that cannot be narrowed as the projection would build it is left out whole: in + /// memory, where the core's narrowing of a reference reads it through EF Core; an EF Core complex + /// property, which the narrowing compares to null; where the builder would add a denied key back; + /// and where the core cannot project a path beneath it. + /// /// - private static List? SynthesizedProjection(Gate gate) + private static List? SynthesizedProjection(Gate gate, RowShape rows) { List allowed = new(); + List<(string Member, string Reason)> uncarried = new(); bool anyDenied = false; + int recorded = gate.TraceCount; - foreach (string field in gate.ProjectableFields()) + foreach (PropertyInfo member in gate.Members()) { - FieldPolicy policy = gate.PolicyFor(field, PolicyFeature.None); + string name = member.Name; + FieldPolicy policy = gate.PolicyFor(name, PolicyFeature.None); + + if (!policy.Allows(PolicyFeature.Select)) + { + anyDenied = true; + + gate.Record(name, PolicyFeature.Select, PolicyAction.Dropped, policy); + + continue; + } + + // Two members share the name, one hidden with new under another type or spelled in another case: + // the core reads one of them, and a row carries both. What either can hold is asked about, and a + // projection, which cannot tell them apart, leaves the name out. + if (gate.SharedName(name, rows) is { Shared: true } shared + && (shared.Denies || gate.Beneath(name, PolicyFeature.None).Denied.Any(d => rows.Materializes(d.Path)))) + { + anyDenied = true; + + gate.LeaveOut(name, "left out: the type declares more than one member of this name, which a projection cannot tell apart"); + + continue; + } + + // A projection assigns only a member with a setter, and no path can name a member the + // expression parser keeps a word for; such a member is still asked about below. + bool assignable = member.CanWrite && !ReservedNames.Starts(name); + + if (Gate.HoldsValue(member.PropertyType)) + { + if (assignable && rows.CarriesValue(name)) + { + allowed.Add(name); + } + + continue; + } + + (List survivors, List<(string Path, FieldPolicy Policy)> denied) = + gate.Beneath(name, PolicyFeature.None); + + // Only a denial whose value can reach the result asks for a projection: one beneath a + // navigation nothing loads never leaves the database. What no path names is asked about + // only where the source carries the member at all, and on an entity only where it loads. + List<(string Path, FieldPolicy Policy)> reached = denied.Where(d => rows.Materializes(d.Path)).ToList(); + Gate.TypeFacts unnamed = rows.Materializes(name + SegmentSeparator + name) + ? gate.Unnamed(name, rows) + : default; + bool forced = gate.ForcedBeneath(name); + + foreach ((string path, FieldPolicy beneath) in reached) + { + gate.Record(path, PolicyFeature.Select, PolicyAction.Dropped, beneath); + } + + // A forced scope beneath a member does not ask for a projection on its own. It filters the + // rows that hold the member, which is what it has always done for a list returned whole, + // and asking would leave out every included list of a scoped child type. Nor does a member + // that can hold an object of any type: the policy cannot see into it whether or not a + // projection is built, and asking would drop every such column of every query. + if (reached.Count > 0 || unnamed.DeniesSelect) + { + anyDenied = true; + } + + if (!assignable || !rows.Carries(name)) + { + // Left out because a projection cannot keep it. Worth a line in the trace only where the + // unguarded call would have returned it: an included navigation, an object in memory. + if (rows.Materializes(name + SegmentSeparator + name)) + { + uncarried.Add((name, !assignable + ? "left out: a projection cannot assign it" + : rows.Kind == RowKind.InMemory + ? "left out: a projection cannot keep an object a row in memory holds" + : "left out: it is a navigation, which projecting would load")); + } + + continue; + } - if (policy.Allows(PolicyFeature.Select)) + if (reached.Count == 0 && !unnamed.Opaque && !forced && !gate.ReadOnlyTransformBeneath(name)) { - allowed.Add(field); + allowed.Add(name); continue; } + string? reason = forced + ? "left out whole: a scope forced beneath it cannot be applied to what it holds" + : rows.Narrows(name) + ? Narrowed(name, survivors, gate, rows, allowed) + : reached.Count == 0 && !unnamed.DeniesSelect && unnamed.HoldsObject + ? "left out whole: it can hold what the policy cannot name" + : "left out whole: " + rows.WhyNotNarrowed(name); + + if (reason is not null) + { + gate.LeaveOut(name, reason); + } + } + + // A row can be a subtype of T, whose own members T's never name. The projection builds T, so it + // leaves them out; one this caller may not have is what asks for it. + foreach (string path in gate.DerivedDenials(rows)) + { anyDenied = true; - gate.Record(field, PolicyFeature.Select, PolicyAction.Dropped, policy); + gate.LeaveOut(path, "left out: a type derived from the row's type declares it, and the projection builds the row's type"); + } + + // A member a base type declares and T hides with new is still held by a row in memory, and read by + // anyone reading the row as the base type. A projection builds T and leaves it out. + if (rows.Kind == RowKind.InMemory) + { + foreach (string name in gate.HiddenDenials()) + { + anyDenied = true; + + gate.LeaveOut(name, "left out: a base type's member of this name, which the row's own member hides"); + } + } + + // A member that asks for nothing itself is still left out, or narrowed, as the projection would build + // it. With no projection built nothing was, and the trace says what the query did. + if (!anyDenied) + { + gate.WithdrawTrace(recorded); + + return null; } - if (!anyDenied || gate.IsDryRun) + if (gate.IsDryRun) { return null; } + foreach ((string name, string reason) in uncarried) + { + gate.LeaveOut(name, reason); + } + if (allowed.Count == 0) { throw gate.Exception(WholeClause, PolicyFeature.Select, PolicyErrorCode.AllSelectsDenied, null); @@ -1830,6 +2049,57 @@ private static void SynthesizeSelects(Filter filter, Gate gate) return allowed; } + /// + /// Narrows a member for a synthesized projection, adding what survives to , + /// or returns why the member is left out whole instead. + /// + /// + /// 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, + /// and so does a path the core cannot project. + /// + private static string? Narrowed(string member, List survivors, Gate gate, RowShape rows, List allowed) + { + List kept = new(survivors.Count); + + foreach (string path in survivors) + { + if (gate.DeclaredType(path) is { } type && !Gate.HoldsValue(type) && gate.Unnamed(path, rows).Opaque) + { + gate.LeaveOut(path, "left out whole: it can hold what the policy cannot name"); + + continue; + } + + kept.Add(path); + } + + if (gate.DeniedKey(kept) is { } key) + { + return $"left out whole: the projection would add its key '{key.Path}', which is denied"; + } + + if (gate.Unprojectable(kept) is { } unbuilt) + { + return $"left out whole: the core cannot project '{unbuilt}'"; + } + + if (gate.Unbuildable(member, kept) is { } node) + { + return $"left out whole: the core cannot build '{node.Name}', which it narrows into"; + } + + if (kept.Count == 0) + { + return "left out whole: nothing beneath it may be selected"; + } + + allowed.AddRange(kept); + + return null; + } + /// /// Refuses a request that fails to supply a filter the policy requires. /// @@ -2347,6 +2617,10 @@ private sealed class Gate // where a denied field is, as a denied field is. private readonly HashSet _unknown = new(StringComparer.Ordinal); + // What a projection of each navigation carries, per root type and path. Reflection only, so it + // is the same for every caller and every query. + private static readonly ConcurrentDictionary<(Type Root, string Path), IReadOnlyList> CarriedPaths = new(); + internal Gate( Type entityType, PolicyResolver resolver, @@ -2447,27 +2721,141 @@ internal string Unknown(string name) internal bool IsDryRun => _options.DryRun || _context.DryRun; /// - /// The scalar properties of the entity that a typed projection can actually assign. + /// The members of the entity: every readable property that is not an indexer. /// /// /// Read through the same reflection cache the validator uses, so a synthesized projection - /// cannot name a field the pipeline would then reject. Write-only and indexed members are - /// excluded because a typed projection assigns into them. + /// cannot name a field the pipeline would then reject. A projection assigns only the writable + /// ones, but a read-only one is still asked about: a denial on it, or beneath it, still needs a + /// projection, or the row comes back holding it. /// - internal IEnumerable ProjectableFields() + internal IEnumerable Members() { foreach (KeyValuePair entry in CacheReflection.GetTypeProperties(_entityType)) { PropertyInfo property = entry.Value; - if (property.CanRead - && property.CanWrite - && property.GetIndexParameters().Length == 0 - && CacheReflection.IsSimpleType(property.PropertyType)) + if (property.CanRead && property.GetIndexParameters().Length == 0) + { + yield return property; + } + } + } + + /// + /// True when a member of this type holds a value rather than an object: a scalar, or a + /// collection of scalars such as List<string> or byte[]. + /// + /// + /// A collection of scalars is a value the projection assigns whole. Nothing beneath it can + /// carry a policy, and on an entity it is a column the unguarded call loads. + /// + internal static bool HoldsValue(Type type) => + CacheReflection.IsSimpleType(AttributePolicyProvider.Peeled(type)) + && !AttributePolicyProvider.Layers(type).Any(layer => OwnsMembers(layer) && Facts(layer).DeniesSelect); + + /// + /// True for an application's own collection class, one deriving from List<string> or a + /// Dictionary say, that declares members beside the elements it holds. A collection of values + /// stays a value unless a member of its own is denied. + /// + private static bool OwnsMembers(Type type) => + type.IsClass + && !type.IsArray + && !AttributePolicyProvider.IsFramework(type) + && AttributePolicyProvider.Peeled(type) != type + && type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Any(property => property.GetIndexParameters().Length == 0 + && property.DeclaringType is { } declaring + && !AttributePolicyProvider.IsFramework(declaring)); + + /// + /// The names of members a base type of T declares, T hides with new, and this caller may not have, + /// or that hold a denied field no path names. + /// + internal List HiddenDenials() + { + List found = new(); + PropertyInfo[] shown = _entityType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + for (Type? declaring = _entityType.BaseType; declaring is not null && declaring != typeof(object); declaring = declaring.BaseType) + { + foreach (PropertyInfo hidden in declaring.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) { - yield return property.Name; + if (!hidden.CanRead + || hidden.GetIndexParameters().Length != 0 + || found.Contains(hidden.Name, StringComparer.Ordinal) + || shown.Any(member => member.Name == hidden.Name && SameSlot(member, hidden))) + { + continue; + } + + if (DeclaresDenial(hidden) + || (!HoldsValue(hidden.PropertyType) && Carried(hidden.PropertyType, hidden.Name, exact: false).DeniesSelect)) + { + found.Add(hidden.Name); + } } } + + return found; + } + + /// True when two properties' getters are one method, or one overrides the other. + private static bool SameSlot(PropertyInfo left, PropertyInfo right) => + left.GetGetMethod() is { } first + && right.GetGetMethod() is { } second + && first.GetBaseDefinition() is { } a + && second.GetBaseDefinition() is { } b + && a.MetadataToken == b.MetadataToken + && a.Module == b.Module; + + /// The names more than one readable member of a type shares, compared as the core compares names. + private static readonly ConcurrentDictionary> SharedNames = new(); + + private static HashSet ReadSharedNames(Type type) => + new(type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.CanRead && property.GetIndexParameters().Length == 0) + .GroupBy(property => property.Name, StringComparer.OrdinalIgnoreCase) + .Where(group => group.Count() > 1) + .Select(group => group.Key), + StringComparer.OrdinalIgnoreCase); + + /// + /// Whether T declares more than one member of a name, as the core compares names, one of them holding + /// more than a value; and if so, whether what one of them holds is denied. + /// + /// + /// A member hidden with new under another type, or two names differing only in case. The core + /// reads one of them by name, and a row carries every one, which a serializer writes. + /// + internal (bool Shared, bool Denies) SharedName(string name, RowShape rows) + { + if (!SharedNames.GetOrAdd(_entityType, ReadSharedNames).Contains(name)) + { + return (false, false); + } + + List variants = _entityType + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.CanRead + && property.GetIndexParameters().Length == 0 + && string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (variants.TrueForAll(variant => HoldsValue(variant.PropertyType))) + { + return (false, false); + } + + // An entity's model says which of them EF Core maps and what loads beneath it; any other row can + // hold what either type can. + bool beneath = rows.LoadedBeneath(name) is not null + ? Unnamed(name, rows).DeniesSelect + : variants.Exists(variant => !HoldsValue(variant.PropertyType) + && Carried(variant.PropertyType, name, exact: false).DeniesSelect); + + return (true, beneath || variants.Exists(variant => Denies(name, variant))); } /// @@ -2499,30 +2887,38 @@ internal bool HasKey(string path) /// deny-select against whoever spelled the child out in full and leaves it bypassable by /// asking for its parent instead — the same shape as sending no projection at all. /// - /// The walk stops where 's does, and carries the same - /// cycle guard. Nothing below that depth holds a fragment, so there is no decision down - /// there to enforce and descending further would only enumerate paths the resolver cannot - /// speak about. + /// A type is read the way reads it, through + /// , and no path is longer than the + /// longest the walker puts a fragment on. It once read collections through a narrower list of + /// its own, so a member typed IReadOnlyList<T> hid every denial beneath it, and it + /// went a level deeper than the walker, so a narrowing projected fields no policy could speak + /// about. It does not follow a type back into itself along one path: a narrowing leaves such a + /// region out. /// /// - internal IReadOnlyList ProjectionUnder(string path) + internal IReadOnlyList ProjectionUnder(string path) => + CarriedPaths.GetOrAdd((_entityType, path), key => Enumerate(key.Root, key.Path)); + + private static IReadOnlyList Enumerate(Type root, string path) { List paths = new(); - Type? type = TypeAt(path); - if (type is not null) + if (DeclaredType(root, path) is { } declared + && AttributePolicyProvider.NavigationTypeOf(declared) is { } type) { - Descend(path, type, 1, paths, new HashSet()); + Descend(path, type, path.Split(SegmentSeparator).Length, paths, new HashSet()); } return paths; } + // depth is how many segments prefix has; a child has one more. private static void Descend( string prefix, Type type, int depth, List paths, HashSet seen) { - // A type reachable from itself would otherwise enumerate until the stack ran out. - if (!seen.Add(type)) + // A child would be longer than any path the walker gives a fragment, or the type is one this + // path already passed through and would otherwise enumerate until the stack ran out. + if (depth >= AttributePolicyProvider.MaxDepth || !seen.Add(type)) { return; } @@ -2540,57 +2936,763 @@ private static void Descend( string child = $"{prefix}.{property.Name}"; - if (CacheReflection.IsSimpleType(property.PropertyType)) + // A value, a collection of values, or a type the walker does not enter: projected + // whole, and nothing beneath it has a path of its own. + if (AttributePolicyProvider.NavigationTypeOf(property.PropertyType) is not { } nested) { paths.Add(child); continue; } - Type nested = CacheReflection.GetCollectionElementType(property.PropertyType) - ?? property.PropertyType; + Descend(child, nested, depth + 1, paths, seen); + } + + seen.Remove(type); + } + + /// + /// What lies beneath a member, as the policy sees it: the fields a narrowing of it could keep, + /// and every path beneath it this caller may not select. + /// + /// The member's path. + /// + /// when the caller named the member, so an audited field a + /// narrowing would carry is recorded as used; otherwise. + /// + /// + /// The denials come from two places. The walk above finds every field a projection of the + /// member could hold. The fragments the providers hold find the rest: a fragment matches a path + /// exactly or matches every path, so a denied path the walk never produced, beneath a property + /// with no setter, around a cycle, or deeper than the walk goes, is still one of them. + /// + internal (List Survivors, List<(string Path, FieldPolicy Policy)> Denied) Beneath( + string member, PolicyFeature use) + { + List survivors = new(); + List<(string Path, FieldPolicy Policy)> denied = new(); + HashSet asked = new(StringComparer.OrdinalIgnoreCase); + + foreach (string path in ProjectionUnder(member)) + { + asked.Add(path); + + FieldPolicy policy = PolicyFor(path, use); - // A collection of a primitive is a value the projection assigns whole, not a - // navigation into its element type. Descending into byte would enumerate nothing - // and drop the member from an expansion that is supposed to preserve it. - if (CacheReflection.IsSimpleType(nested)) + if (policy.Allows(PolicyFeature.Select)) { - paths.Add(child); + survivors.Add(path); + } + else + { + denied.Add((path, policy)); + } + } + + string prefix = member + SegmentSeparator; + foreach (PolicyFragment fragment in Fragments) + { + // A rule on a path the type does not have, one written for a member since removed, holds + // nothing a projection could carry. + if (fragment.IsWildcard + || !fragment.FieldPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + || !asked.Add(fragment.FieldPath) + || !Resolves(_entityType, fragment.FieldPath)) + { continue; } - if (depth >= AttributePolicyProvider.MaxDepth) + FieldPolicy policy = PolicyFor(fragment.FieldPath, PolicyFeature.None); + + if (!policy.Allows(PolicyFeature.Select)) { - continue; + denied.Add((fragment.FieldPath, policy)); } + } - Descend(child, nested, depth + 1, paths, seen); + return (survivors, denied); + } + + /// + /// True when a path names a member, in any letter case, of the type or of any subtype a value along + /// it can be: a rule on Org.Swift names a field of the bank a member declared as an + /// organisation holds, and a type with both Info and INFO has two members the path + /// could name. + /// + private static bool Resolves(Type root, string path) + { + List current = new() { root }; + string[] segments = path.Split(SegmentSeparator); + + for (int i = 0; i < segments.Length; i++) + { + List next = new(); + bool named = false; + + foreach (Type type in current) + { + foreach (Type candidate in KnownSubtypes.Of(type).Prepend(type)) + { + foreach (PropertyInfo property in candidate.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!string.Equals(property.Name, segments[i], StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + named = true; + + if (AttributePolicyProvider.NavigationTypeOf(property.PropertyType) is { } nested + && !next.Contains(nested)) + { + next.Add(nested); + } + } + } + } + + if (!named || (i < segments.Length - 1 && next.Count == 0)) + { + return false; + } + + current = next; } - seen.Remove(type); + return true; } - /// The type a validated path names, or null when it names nothing. - private Type? TypeAt(string path) + /// + /// The first type a narrowing would build a node as that the core's typed projection cannot + /// construct, an interface, an abstract class or one without a public parameterless constructor, + /// which it skips; null when it can build them all. + /// + internal Type? Unbuildable(string member, IEnumerable paths) { - try + HashSet nodes = new(StringComparer.OrdinalIgnoreCase) { member }; + + foreach (string path in paths) { - return CacheReflection.GetFieldType(_entityType, path); + for (int cut = path.IndexOf(SegmentSeparator); cut >= 0; cut = path.IndexOf(SegmentSeparator, cut + 1)) + { + nodes.Add(path[..cut]); + } } - catch (LogicException) + + foreach (string node in nodes) { - return null; + if (DeclaredType(node) is { } declared + && AttributePolicyProvider.NavigationTypeOf(declared) is { } type + && (type.IsAbstract || type.GetConstructor(Type.EmptyTypes) is null)) + { + return type; + } } + + return null; } + /// Every fragment the providers hold for this type and caller, read once per query. + private IReadOnlyList Fragments => _fragments ??= _resolver.Fragments(_entityType, _context); + + private IReadOnlyList? _fragments; + /// - /// What one reference to a field spends. + /// True when a forced predicate reaches beneath the member. A projection cannot apply it to the + /// elements the member holds: the scope filters the rows of the query, and a list kept whole or + /// narrowed carries every element, those the scope excludes included. /// - /// - /// The field's own weight when something weighs it, and the standing default otherwise. - /// Null and zero are different answers here: null is "nobody weighed this field", which the - /// host's default decides, and zero is "this field is free", which the host's default must + internal bool ForcedBeneath(string member) + { + string prefix = member + SegmentSeparator; + + return TypePolicy.Forced.Any( + forced => forced.FieldPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// True when a transform beneath the member lands on a property with no setter, which the + /// outbound walk could not write back: a narrowing leaves such a property out. + /// + internal bool ReadOnlyTransformBeneath(string member) + { + string prefix = member + SegmentSeparator; + + foreach (string path in TypePolicy.Transforms.Keys) + { + if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && Property(_entityType, path) is { CanWrite: false }) + { + return true; + } + } + + return false; + } + + /// + /// What the value of a member can carry that the paths the walk asks about do not name: a field + /// denied for Select, and a member that can hold an object of any type. + /// + /// + /// On an entity query the model says what loads beneath the member + /// (). Each loaded member is asked about by its path, and by + /// its own attribute where no fragment reaches it: deeper than the walker goes, or declared by a + /// type the model derives. A value EF Core reads whole is read through its type. What EF Core + /// materializes from the database holds no object of the application's; what a value converter + /// hands back is the application's code, and holds what its type allows. A + /// projection's member is read as the type the projection constructs it as, when it says. Any + /// other value can carry whatever the member's type can hold, its subtypes included. + /// Under a policy that denies every field it does not name, a path the walk never asks about is a + /// denied one unless the policy names it. + /// + internal TypeFacts Unnamed(string member, RowShape rows) + { + if (rows.LoadedBeneath(member) is { } loaded) + { + bool denies = false; + bool holdsObject = false; + + foreach (Loaded entry in loaded) + { + TypeFacts carried = entry.Whole && !HoldsValue(entry.Property.PropertyType) + ? Carried(entry.Property.PropertyType, entry.Path, exact: false) + : default; + + denies |= Denies(entry.Path, entry.Property) || carried.DeniesSelect; + holdsObject |= entry.Converted && carried.HoldsObject; + } + + return new TypeFacts(denies, holdsObject, loaded.Count > 0); + } + + if (!member.Contains(SegmentSeparator) && rows.BuiltType(member) is { } built) + { + return Carried(built, member, exact: true); + } + + // A path that names nothing is refused long before this; were it not, nothing is shown clean. + return DeclaredType(member) is { } type + ? Carried(type, member, exact: false) + : new TypeFacts(true, true, true); + } + + /// + /// What a value of a type, held at a path, can carry that no path names. An exact type is the one + /// the value was constructed as, so its own subtypes cannot be it. + /// + private TypeFacts Carried(Type type, string path, bool exact) + { + TypeFacts facts = Facts(type, exact); + + return DeniesByDefault && !Granted(type, path, exact) + ? facts with { DeniesSelect = true } + : facts; + } + + /// + /// True when this caller may not select a member reached along a path: its policy says so, or, + /// where no fragment reaches the path, an attribute on the member does. + /// + /// + /// The walker puts a fragment on every path of the declared types up to its depth, a member + /// with no setter, a path around a cycle, and a denial an override or an implementation declares + /// included. Past its depth, or on a member only a derived type declares, the attributes are all + /// there is, read from every declaration a row can run. + /// + private bool Denies(string path, PropertyInfo property) + { + if (!PolicyFor(path, PolicyFeature.None).Allows(PolicyFeature.Select)) + { + return true; + } + + return (path.Split(SegmentSeparator).Length > AttributePolicyProvider.MaxDepth + || Property(_entityType, path) is null) + && DeclaresDenial(property); + } + + /// + /// True when a member's attributes deny Select: its own, inherited ones, and those of another + /// declaration a row of the type it was read from can run, an interface member or a subtype's + /// override among them. Read once for each member, and again once another assembly has loaded. + /// + private static bool DeclaresDenial(PropertyInfo property) + { + int epoch = KnownSubtypes.Epoch; + + if (DenialsByMember.TryGetValue(property, out (int Epoch, bool Denies) known) && known.Epoch == epoch) + { + return known.Denies; + } + + bool denies = property.GetCustomAttributes(inherit: true) + .Concat(AttributePolicyProvider.DenialsElsewhere(property.ReflectedType ?? property.DeclaringType!, property)) + .Any(attribute => (attribute.Features & PolicyFeature.Select) != 0); + + DenialsByMember[property] = (epoch, denies); + + return denies; + } + + private static readonly ConcurrentDictionary DenialsByMember = new(); + + /// + /// Every member a type derived from T declares that a row can carry and this caller may not + /// have, or that carries a denied field no path names. A projection builds T itself and leaves + /// them all out, so each is a reason to project. + /// + /// + /// A projection builds the type its initializer constructs, which may be a subtype of T. An entity + /// query materializes each row as the type the database says it is, which the model knows. Any + /// other source can hold any subtype loaded. + /// + internal List DerivedDenials(RowShape rows) + { + List found = new(); + + if (rows.Kind == RowKind.Projected) + { + if (rows.Built is { } built && built != _entityType && _entityType.IsAssignableFrom(built)) + { + Declared(built, found); + } + + return found; + } + + if (rows.LoadedByDerived() is { } loaded) + { + foreach (Loaded entry in loaded) + { + if (Denies(entry.Path, entry.Property) + || (entry.Whole && !HoldsValue(entry.Property.PropertyType) + && Carried(entry.Property.PropertyType, entry.Path, exact: false).DeniesSelect)) + { + found.Add(entry.Path); + } + } + + return found; + } + + foreach (Type subtype in KnownSubtypes.Of(_entityType)) + { + Declared(subtype, found); + } + + return found; + } + + /// Adds each member a subtype declares below T that this caller may not have. + private void Declared(Type subtype, List found) + { + foreach (PropertyInfo property in subtype.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!property.CanRead + || property.GetIndexParameters().Length != 0 + || property.DeclaringType!.IsAssignableFrom(_entityType) + || found.Contains(property.Name, StringComparer.Ordinal)) + { + continue; + } + + if (Denies(property.Name, property) + || (!HoldsValue(property.PropertyType) + && Carried(property.PropertyType, property.Name, exact: false).DeniesSelect)) + { + found.Add(property.Name); + } + } + } + + /// + /// True when a "*" rule denies Select on every field this caller is not granted by name, + /// so that a path the walk never asks about is a denied one. + /// + internal bool DeniesByDefault => + _deniesByDefault ??= Fragments.Any(fragment => fragment.IsWildcard) + && !PolicyFor(UnnamedPath, PolicyFeature.None).Allows(PolicyFeature.Select); + + private bool? _deniesByDefault; + + /// A path no member can have, so that only the "*" rules match it. + private const string UnnamedPath = ""; + + /// + /// Under a policy denying every field it does not name, true when every path beneath a member of + /// this type that the walk never asks about is one the policy grants by name. + /// + /// + /// The walk skips a property with no setter, stops four segments deep and at a type it already + /// passed through, reads declared types only and takes a framework type whole. Each path it skips + /// is asked about here, a subtype's members included; one the policy does not name is denied. + /// Around a cycle the paths never end, and inside a framework type holding an application type's + /// members no path can name them, so neither is ever granted. + /// + private bool Granted(Type type, string path, bool exact) + { + if (!_granted.TryGetValue((type, path, exact), out bool granted)) + { + granted = Grant(type, path, exact, new HashSet()); + _granted[(type, path, exact)] = granted; + } + + return granted; + } + + private readonly Dictionary<(Type, string, bool), bool> _granted = new(); + + private bool Grant(Type type, string path, bool exact, HashSet onPath) + { + // A value, a collection of them among values, holds no path to name. + if (HoldsValue(type)) + { + return true; + } + + if (AttributePolicyProvider.NavigationTypeOf(type) is not { } node) + { + TypeFacts facts = Facts(type, exact: false); + + return !facts.HoldsObject && !facts.HoldsMembers; + } + + if (!onPath.Add(node)) + { + return false; + } + + int depth = path.Split(SegmentSeparator).Length; + IEnumerable<(PropertyInfo Property, bool Declared)> members = node + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(property => (property, true)); + + if (!exact) + { + members = members.Concat(KnownSubtypes.Of(node) + .SelectMany(subtype => subtype.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + .Where(property => !property.DeclaringType!.IsAssignableFrom(node)) + .Select(property => (property, false))); + } + + foreach ((PropertyInfo property, bool declared) in members) + { + if (!property.CanRead || property.GetIndexParameters().Length != 0) + { + continue; + } + + string child = $"{path}{SegmentSeparator}{property.Name}"; + bool walked = declared && property.CanWrite && depth < AttributePolicyProvider.MaxDepth; + + if ((!walked && Denies(child, property)) + || (!HoldsValue(property.PropertyType) && !Grant(property.PropertyType, child, false, onPath))) + { + return false; + } + } + + onPath.Remove(node); + + return true; + } + + /// + /// What a type can hold that no path of the policy's reaches, read once per type and again once + /// another assembly has loaded, since that can add subtypes. An exact type is the one a value was + /// constructed as, so its own subtypes are not read. + /// + internal static TypeFacts Facts(Type type, bool exact = false) + { + int epoch = KnownSubtypes.Epoch; + + if (FactsByType.TryGetValue((type, exact), out (int Epoch, TypeFacts Facts) known) && known.Epoch == epoch) + { + return known.Facts; + } + + TypeFacts facts = Scan(type, exact); + + FactsByType[(type, exact)] = (epoch, facts); + + return facts; + } + + private static readonly ConcurrentDictionary<(Type, bool), (int Epoch, TypeFacts Facts)> FactsByType = new(); + + /// + /// Reads every type a value of this type can hold, however deep, for a field denied for Select + /// and for a member that can hold an object of any type. + /// + /// + /// Wider than the walker on purpose. The walker stops four segments deep, does not enter a + /// framework type and reads declared types only, so a field denied beneath any of those has no + /// path, and resolves as allowed. This follows every property, a read-only one included, every + /// collection, the type arguments of a framework generic such as Dictionary<string, T>, + /// and every loaded subtype of a type it reads, a framework class's included, and remembers the + /// types it has read rather than counting levels. A member that can hold anything is reported + /// rather than read. + /// + private static TypeFacts Scan(Type type, bool exact) + { + bool denies = false; + bool holdsObject = false; + bool holdsMembers = false; + HashSet seen = new(); + Stack pending = new(); + + void Read(Type candidate) + { + if (seen.Add(candidate)) + { + pending.Push(candidate); + } + } + + void Subtypes(Type of) + { + foreach (Type subtype in KnownSubtypes.Of(of)) + { + holdsMembers = true; + + Read(subtype); + } + } + + void Enqueue(Type candidate, bool root) + { + Type peeled = AttributePolicyProvider.Peeled(candidate); + + // An application's own collection class declares members beside the elements it holds, at any + // layer: the member's own type, or the element of a list or an array of it. + foreach (Type layer in AttributePolicyProvider.Layers(candidate)) + { + if (OwnsMembers(layer)) + { + holdsMembers = true; + + Read(layer); + + if (!(root && exact)) + { + Subtypes(layer); + } + } + } + + if (HoldsAnything(peeled)) + { + holdsObject = true; + + // An application's own collection that is not generic can hold anything, and it still + // declares members of its own, which are read as any other type's are. + if (peeled.IsGenericParameter || AttributePolicyProvider.IsFramework(peeled)) + { + return; + } + } + + if (AttributePolicyProvider.NavigationTypeOf(candidate) is { } navigation) + { + holdsMembers = true; + + Read(navigation); + + if (!(root && exact)) + { + Subtypes(navigation); + } + } + else if (peeled.IsClass && !peeled.IsSealed && !peeled.IsGenericType && !(root && exact)) + { + // A framework class an application can derive from, an Exception or a Stream: the + // subtype a value holds is read, since the class itself carries no policy. + Subtypes(peeled); + } + + if (peeled.IsGenericType && AttributePolicyProvider.IsFramework(peeled)) + { + foreach (Type argument in peeled.GetGenericArguments()) + { + Enqueue(argument, false); + } + } + } + + Enqueue(type, true); + + while (pending.Count > 0 && !(denies && holdsObject)) + { + foreach (PropertyInfo property in pending.Pop().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (property.GetIndexParameters().Length != 0) + { + continue; + } + + if (!denies && DeclaresDenial(property)) + { + denies = true; + } + + Enqueue(property.PropertyType, false); + } + } + + return new TypeFacts(denies, holdsObject, holdsMembers); + } + + /// + /// True for a type whose value can be an object of any type: itself, a type + /// parameter an open generic subtype leaves unbound, a framework interface such as + /// IComparable, and a collection that is not generic, such as ArrayList, + /// IEnumerable, or an application's own. + /// + private static bool HoldsAnything(Type peeled) => + peeled == typeof(object) + || peeled == typeof(ValueType) + || peeled.IsGenericParameter + || (!peeled.IsGenericType + && ((AttributePolicyProvider.IsFramework(peeled) && peeled.IsInterface) + || (peeled != typeof(string) && typeof(IEnumerable).IsAssignableFrom(peeled) + && !ValueCollections.Contains(peeled)))); + + /// The framework's collections that are not generic yet hold values only: bits and strings. + private static readonly HashSet ValueCollections = new() + { + typeof(BitArray), + typeof(System.Collections.Specialized.StringCollection), + typeof(System.Collections.Specialized.StringDictionary), + typeof(System.Collections.Specialized.NameValueCollection) + }; + + /// + /// What a type can hold that the policy cannot name a path to. + /// + /// A field denied for Select somewhere in what it can hold. + /// A member that can hold an object of any type somewhere in what it can hold. + /// An application type's members somewhere in what it can hold. + internal readonly record struct TypeFacts(bool DeniesSelect, bool HoldsObject, bool HoldsMembers) + { + /// True when either fact holds, so the type cannot be shown to be free of policy. + internal bool Opaque => DeniesSelect || HoldsObject; + } + + /// + /// The declared type at a path, read the way the walker reads it: through any collection, one + /// segment at a time. Null when a segment names nothing. + /// + internal Type? DeclaredType(string path) => DeclaredType(_entityType, path); + + private static Type? DeclaredType(Type root, string path) => Property(root, path)?.PropertyType; + + private static PropertyInfo? Property(Type root, string path) + { + Type? current = root; + PropertyInfo? property = null; + + foreach (string segment in path.Split(SegmentSeparator)) + { + if (current is null || CacheReflection.FindProperty(current, segment) is not { } next) + { + return null; + } + + property = next; + current = AttributePolicyProvider.NavigationTypeOf(next.PropertyType); + } + + return property; + } + + /// + /// The key the projection builder would add to a node that a list of narrowed paths passes + /// through, when this caller may not select it; null when every such key is allowed. + /// + /// + /// The builder adds the key of each nested node it builds whether the list names it or not, + /// so a narrowing that dropped a denied key would carry it anyway. + /// + internal (string Path, FieldPolicy Policy)? DeniedKey(IEnumerable paths) + { + HashSet nodes = new(StringComparer.Ordinal); + + foreach (string path in paths) + { + for (int cut = path.IndexOf(SegmentSeparator); cut >= 0; cut = path.IndexOf(SegmentSeparator, cut + 1)) + { + string node = path[..cut]; + + if (!nodes.Add(node) + || TypeAt(_entityType, node) is not { } type + || CacheReflection.FindProperty(type, "Id") is not { } key) + { + continue; + } + + string keyPath = $"{node}.{key.Name}"; + FieldPolicy policy = PolicyFor(keyPath, PolicyFeature.None); + + if (!policy.Allows(PolicyFeature.Select)) + { + return (keyPath, policy); + } + } + } + + return null; + } + + /// + /// The first of a list of narrowed paths that the core's own validation refuses, or null when + /// it accepts them all. + /// + /// + /// The walk above reads collections the way the policy does, and the core reads a narrower + /// set of them: a path through an IReadOnlyList<T> is one the policy can deny and + /// the core cannot project. + /// + internal string? Unprojectable(IEnumerable paths) + { + foreach (string path in paths) + { + try + { + CacheReflection.ValidatePropertyPath(_entityType, path); + } + catch (LogicException) + { + return path; + } + } + + return null; + } + + /// The type a validated path names, or null when it names nothing. + private Type? TypeAt(string path) => TypeAt(_entityType, path); + + private static Type? TypeAt(Type root, string path) + { + try + { + return CacheReflection.GetFieldType(root, path); + } + catch (LogicException) + { + return null; + } + } + + /// + /// What one reference to a field spends. + /// + /// + /// The field's own weight when something weighs it, and the standing default otherwise. + /// Null and zero are different answers here: null is "nobody weighed this field", which the + /// host's default decides, and zero is "this field is free", which the host's default must /// not override. /// internal int CostOf(string fieldPath) => @@ -2907,6 +4009,66 @@ internal void DenySegmentInference(string fieldPath, FieldPolicy policy) throw Exception(fieldPath, PolicyFeature.Segment, PolicyErrorCode.FieldDeniedForSegment, policy); } + /// How many decisions the query's trace holds. + internal int TraceCount => _trace.Count; + + /// Withdraws the decisions recorded after the first . + internal void WithdrawTrace(int count) => _trace.Withdraw(count); + + /// + /// Records that a synthesized projection left a member out whole, and why: something beneath it + /// is denied and the member cannot be narrowed. + /// + internal void LeaveOut(string fieldPath, string reason) => + _trace.Add(new PolicyDecision(fieldPath, PolicyFeature.Select, PolicyAction.Dropped, reason)); + + /// + /// Refuses, in either tier, a projection that would have to be narrowed around a denied field + /// and cannot be built that way. + /// + /// + /// Dropping is not an option here, for the reason it is not for a carried key: the projection + /// that would be built is not the one that was gated, so the only way to honour the denial is + /// to decline to build it. + /// + internal void RefuseNarrowing(string fieldPath, FieldPolicy? policy, string reason) + { + _trace.Add(new PolicyDecision(fieldPath, PolicyFeature.Select, PolicyAction.Denied, reason)); + + if (IsDryRun) + { + return; + } + + throw Exception(fieldPath, PolicyFeature.Select, PolicyErrorCode.FieldDeniedForSelect, policy); + } + + /// + /// Applies a denial the policy cannot name a path to: a field denied for Select that a named + /// member holds beyond the walker's depth or inside a framework collection. Throws in the strict + /// tier, as a denied field beneath a named navigation does, and otherwise records the drop. + /// + /// True when the caller must narrow it away. False in a dry run. + internal bool RefuseHidden(string fieldPath) + { + const string origin = "it holds a field denied for Select where no path can name it"; + + _trace.Add(new PolicyDecision( + fieldPath, PolicyFeature.Select, IsStrict ? PolicyAction.Denied : PolicyAction.Dropped, origin)); + + if (IsDryRun) + { + return false; + } + + if (IsStrict) + { + throw Exception(fieldPath, PolicyFeature.Select, PolicyErrorCode.FieldDeniedForSelect, null); + } + + return true; + } + /// Records that a field of the type's default order was left out for this caller. internal void SkipDefaultOrder(string fieldPath, FieldPolicy policy) { diff --git a/DynamicWhere.ex/Policies/Source/KnownSubtypes.cs b/DynamicWhere.ex/Policies/Source/KnownSubtypes.cs new file mode 100644 index 0000000..4b111e1 --- /dev/null +++ b/DynamicWhere.ex/Policies/Source/KnownSubtypes.cs @@ -0,0 +1,251 @@ +using System.Collections.Concurrent; +using System.Reflection; + +namespace DynamicWhere.ex.Policies.Source; + +/// +/// The types loaded into the process that derive from a type or implement it: what a member declared as +/// that type can hold at run time. +/// +/// +/// A policy is read from declared types, and a member declared as a base class or an interface holds +/// whichever subtype its value is. A [DwDenied] field a subtype declares is therefore carried by a +/// member the policy reads as clean, unless the subtypes are read too. +/// +/// Every object's type is loaded before the object exists, so the loaded assemblies are the whole answer +/// for any value a query returns. The framework's own assemblies are left out: none of them declares a +/// subtype of an application's type, and a framework type carries no policy. Every other assembly is +/// indexed once, each type under every base class and interface it has, so a search is a lookup. A +/// generic type is indexed under its definition as well as its own instantiation, and a subtype that is +/// itself generic is returned open, since the instantiation a row holds is not known. +/// +/// +/// Loading another such assembly may add subtypes, so the index is rebuilt after one loads. An assembly +/// emitted at run time, or one holding only resources, cannot declare one. +/// +/// +internal static class KnownSubtypes +{ + private static readonly object Building = new(); + + /// Every assembly seen loading, in case one raises its event before the domain lists it. + private static readonly ConcurrentDictionary SeenLoading = new(); + + private static int _epoch; + + private static Index? _index; + + static KnownSubtypes() => + AppDomain.CurrentDomain.AssemblyLoad += (_, loaded) => + { + if (Searched(loaded.LoadedAssembly)) + { + SeenLoading.TryAdd(loaded.LoadedAssembly, 0); + Interlocked.Increment(ref _epoch); + } + }; + + /// Changes whenever an assembly that could declare a subtype loads, and with it every answer read from one. + internal static int Epoch => Volatile.Read(ref _epoch); + + /// Every loaded type, other than the type itself, whose values the type's members can hold. + internal static IReadOnlyList Of(Type type) + { + if (type.IsSealed || type.IsValueType || type.IsGenericParameter || type == typeof(object)) + { + return Array.Empty(); + } + + Index index = Current(); + List? found = null; + + if (index.Descendants.TryGetValue(type, out Type[]? direct)) + { + found = new List(direct); + } + + // A subtype declared over the definition, class Tagged : Base, may be any instantiation of it; + // and a type still open itself may be any instantiation, so every subtype of the definition counts. + if (type.IsGenericType + && index.Descendants.TryGetValue(type.GetGenericTypeDefinition(), out Type[]? byDefinition)) + { + found ??= new List(); + + foreach (Type candidate in byDefinition) + { + if ((type.ContainsGenericParameters || CanBe(candidate, type)) && !found.Contains(candidate)) + { + found.Add(candidate); + } + } + } + + return found is null ? Array.Empty() : found; + } + + /// + /// True when a type indexed under a generic type's definition can be a value of one instantiation of it. + /// A closed type can when the runtime says so, which reads variance: a member typed + /// IFeed<Card>, with out T, holds an IFeed<VisaCard>. An open generic one can when its own + /// base or interface of that definition is still open, or is one of those. One over another + /// instantiation, class Fixed<T> : Base<string>, never holds a Base<int>. + /// + private static bool CanBe(Type candidate, Type type) + { + if (!candidate.ContainsGenericParameters) + { + return type.IsAssignableFrom(candidate); + } + + Type definition = type.GetGenericTypeDefinition(); + IEnumerable ancestors = type.IsInterface ? Interfaces(candidate) : BaseTypes(candidate); + + return ancestors.Any(ancestor => ancestor.IsGenericType + && ancestor.GetGenericTypeDefinition() == definition + && (ancestor.ContainsGenericParameters || type.IsAssignableFrom(ancestor))); + } + + private static IEnumerable BaseTypes(Type type) + { + for (Type? ancestor = type.BaseType; ancestor is not null; ancestor = ancestor.BaseType) + { + yield return ancestor; + } + } + + /// The index for the assemblies loaded now, built once for each epoch. + private static Index Current() + { + if (Volatile.Read(ref _index) is { } index && index.Epoch == Epoch) + { + return index; + } + + lock (Building) + { + if (_index is { } built && built.Epoch == Epoch) + { + return built; + } + + // The epoch is read before the assemblies, so one loading meanwhile rebuilds the index again. + int epoch = Epoch; + Index fresh = Build(epoch); + + Volatile.Write(ref _index, fresh); + + return fresh; + } + } + + private static Index Build(int epoch) + { + HashSet assemblies = new(AppDomain.CurrentDomain.GetAssemblies().Where(Searched)); + + // One the domain lists now needs no holding on to: this index reads it, and every later one will. + foreach (Assembly seen in SeenLoading.Keys) + { + if (!assemblies.Add(seen)) + { + SeenLoading.TryRemove(seen, out _); + } + } + + Dictionary> descendants = new(); + + void Add(Type ancestor, Type type) + { + Type key = ancestor.IsGenericType && ancestor.ContainsGenericParameters + ? ancestor.GetGenericTypeDefinition() + : ancestor; + + if (!descendants.TryGetValue(key, out List? list)) + { + descendants[key] = list = new List(); + } + + list.Add(type); + + if (key != ancestor || !ancestor.IsGenericType) + { + return; + } + + // A closed ancestor, Base, is also one instantiation of its definition. + Type definition = ancestor.GetGenericTypeDefinition(); + + if (!descendants.TryGetValue(definition, out List? byDefinition)) + { + descendants[definition] = byDefinition = new List(); + } + + byDefinition.Add(type); + } + + foreach (Assembly assembly in assemblies) + { + foreach (Type type in TypesOf(assembly)) + { + for (Type? ancestor = type.BaseType; ancestor is not null && ancestor != typeof(object); ancestor = ancestor.BaseType) + { + Add(ancestor, type); + } + + foreach (Type contract in Interfaces(type)) + { + Add(contract, type); + } + } + } + + return new Index(epoch, descendants.ToDictionary(entry => entry.Key, entry => entry.Value.Distinct().ToArray())); + } + + /// An assembly that can declare a subtype of an application's type. + private static bool Searched(Assembly assembly) + { + if (assembly.IsDynamic) + { + return false; + } + + AssemblyName name = assembly.GetName(); + + if (!string.IsNullOrEmpty(name.CultureName)) + { + return false; + } + + string simple = name.Name ?? string.Empty; + + return !(simple is "mscorlib" or "netstandard" or "System" or "WindowsBase" + || simple.StartsWith("System.", StringComparison.Ordinal) + || simple.StartsWith("Microsoft.", StringComparison.Ordinal)); + } + + private static Type[] TypesOf(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException partial) + { + return partial.Types.OfType().ToArray(); + } + } + + private static Type[] Interfaces(Type type) + { + try + { + return type.GetInterfaces(); + } + catch (TypeLoadException) + { + return Array.Empty(); + } + } + + private sealed record Index(int Epoch, Dictionary Descendants); +} diff --git a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs index 9501871..fb617ea 100644 --- a/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs +++ b/DynamicWhere.ex/Policies/Source/PolicyQueryable.cs @@ -11,6 +11,8 @@ using DynamicWhere.ex.Source; using Microsoft.EntityFrameworkCore; using System.Linq.Dynamic.Core; +using System.Linq.Expressions; +using System.Reflection; namespace DynamicWhere.ex.Policies.Source; @@ -42,6 +44,11 @@ public sealed class PolicyQueryable where T : class // gate. A caller whose orders were all dropped still sent orders, and gets no default in their place. private readonly bool _ordered; + // True once a composed Select, or a composed Filter carrying a projection, has run on this chain. + // Such a chain stays in whatever order it had: the default is for the rows the caller's source + // makes, and a projection the caller composes afterwards is theirs to order. + private readonly bool _projected; + private TypePolicy? _typePolicy; /// @@ -53,7 +60,8 @@ internal PolicyQueryable( PolicyResolver resolver, DwPolicyOptions options, PolicyTrace? carried = null, - bool ordered = false) + bool ordered = false, + bool projected = false) { _source = source; _context = context; @@ -61,6 +69,7 @@ internal PolicyQueryable( _options = options; _carried = carried; _ordered = ordered; + _projected = projected; } /// @@ -123,7 +132,27 @@ public FilterResult ToList(Filter filter, bool getQueryString = false) /// When true, includes the generated SQL in the result. /// Thrown when is null. /// Thrown when the policy refuses part of the filter. - public async Task> ToListAsync(Filter filter, bool getQueryString = false) + public Task> ToListAsync(Filter filter, bool getQueryString = false) => + ToListAsync(filter, getQueryString, CancellationToken.None); + + /// Applies a filter asynchronously and returns the matching page. + /// The caller's filter. Never modified. + /// Cancels the count and the read. + /// Thrown when is null. + /// Thrown when the policy refuses part of the filter. + /// Thrown when is canceled. + public Task> ToListAsync(Filter filter, CancellationToken cancellationToken) => + ToListAsync(filter, false, cancellationToken); + + /// Applies a filter asynchronously and returns the matching page. + /// The caller's filter. Never modified. + /// When true, includes the generated SQL in the result. + /// Cancels the count and the read. + /// Thrown when is null. + /// Thrown when the policy refuses part of the filter. + /// Thrown when is canceled. + public async Task> ToListAsync( + Filter filter, bool getQueryString, CancellationToken cancellationToken) { try { @@ -134,7 +163,7 @@ public async Task> ToListAsync(Filter filter, bool getQueryStrin using (PolicyScope.Enter(_context, LastTrace)) { - FilterResult result = await Guarded().ToListAsync(sanitized, getQueryString); + FilterResult result = await Guarded().ToListAsync(sanitized, getQueryString, cancellationToken); ResultTransformer.Rows( result.Data, TypePolicy, sanitized.Selects, _context, _options, trace); @@ -192,7 +221,27 @@ public FilterResult ToListDynamic(Filter filter, bool getQueryString = /// When true, includes the generated SQL in the result. /// Thrown when is null. /// Thrown when the policy refuses part of the filter. - public async Task> ToListAsyncDynamic(Filter filter, bool getQueryString = false) + public Task> ToListAsyncDynamic(Filter filter, bool getQueryString = false) => + ToListAsyncDynamic(filter, getQueryString, CancellationToken.None); + + /// Applies a filter asynchronously and returns the matching page as dynamic objects. + /// The caller's filter. Never modified. + /// Cancels the count and the read. + /// Thrown when is null. + /// Thrown when the policy refuses part of the filter. + /// Thrown when is canceled. + public Task> ToListAsyncDynamic(Filter filter, CancellationToken cancellationToken) => + ToListAsyncDynamic(filter, false, cancellationToken); + + /// Applies a filter asynchronously and returns the matching page as dynamic objects. + /// The caller's filter. Never modified. + /// When true, includes the generated SQL in the result. + /// Cancels the count and the read. + /// Thrown when is null. + /// Thrown when the policy refuses part of the filter. + /// Thrown when is canceled. + public async Task> ToListAsyncDynamic( + Filter filter, bool getQueryString, CancellationToken cancellationToken) { try { @@ -203,7 +252,7 @@ public async Task> ToListAsyncDynamic(Filter filter, bool using (PolicyScope.Enter(_context, LastTrace)) { - FilterResult result = await Guarded().ToListAsyncDynamic(sanitized, getQueryString); + FilterResult result = await Guarded().ToListAsyncDynamic(sanitized, getQueryString, cancellationToken); ResultTransformer.Rows( result.Data, TypePolicy, sanitized.Selects, _context, _options, trace); @@ -280,7 +329,27 @@ public SummaryResult ToList(Summary summary, bool getQueryString = false) /// When true, includes the generated SQL in the result. /// Thrown when is null. /// Thrown when the policy refuses part of the summary. - public async Task ToListAsync(Summary summary, bool getQueryString = false) + public Task ToListAsync(Summary summary, bool getQueryString = false) => + ToListAsync(summary, getQueryString, CancellationToken.None); + + /// Applies a summary asynchronously and returns the grouped page. + /// The caller's summary. Never modified. + /// Cancels the count and the read. + /// Thrown when is null. + /// Thrown when the policy refuses part of the summary. + /// Thrown when is canceled. + public Task ToListAsync(Summary summary, CancellationToken cancellationToken) => + ToListAsync(summary, false, cancellationToken); + + /// Applies a summary asynchronously and returns the grouped page. + /// The caller's summary. Never modified. + /// When true, includes the generated SQL in the result. + /// Cancels the count and the read. + /// Thrown when is null. + /// Thrown when the policy refuses part of the summary. + /// Thrown when is canceled. + public async Task ToListAsync( + Summary summary, bool getQueryString, CancellationToken cancellationToken) { try { @@ -291,7 +360,7 @@ public async Task ToListAsync(Summary summary, bool getQueryStrin using (PolicyScope.Enter(_context, LastTrace)) { - SummaryResult result = await Guarded().ToListAsync(sanitized, getQueryString); + SummaryResult result = await Guarded().ToListAsync(sanitized, getQueryString, cancellationToken); // First of the three, and the order matters. A group below the floor is one the caller // may not see at all, so nothing downstream should form an opinion about it: transformed @@ -330,7 +399,16 @@ public async Task ToListAsync(Summary summary, bool getQueryStrin /// The caller's segment. Never modified. /// Thrown when is null. /// Thrown when the policy refuses part of the segment. - public async Task> ToListAsync(Segment segment) + public Task> ToListAsync(Segment segment) => + ToListAsync(segment, CancellationToken.None); + + /// Applies a set operation asynchronously and returns the combined page. + /// The caller's segment. Never modified. + /// Cancels the count and the read. + /// Thrown when is null. + /// Thrown when the policy refuses part of the segment. + /// Thrown when is canceled. + public async Task> ToListAsync(Segment segment, CancellationToken cancellationToken) { try { @@ -338,13 +416,14 @@ public async Task> ToListAsync(Segment segment) Segment sanitized = FilterSanitizer.Sanitize( segment, _resolver, _context, _options, trace, - applyDefaultOrder: TakesDefaultOrder); + applyDefaultOrder: TakesDefaultOrder, + rows: RowShape.Of(_source)); LastTrace = trace; using (PolicyScope.Enter(_context, LastTrace)) { - SegmentResult result = await Guarded().ToListAsync(sanitized); + SegmentResult result = await Guarded().ToListAsync(sanitized, cancellationToken); ResultTransformer.Rows( result.Data, TypePolicy, sanitized.Selects, _context, _options, trace); @@ -373,7 +452,7 @@ public PolicyQueryable Select(List fields) using (PolicyScope.Enter(_context, LastTrace)) { - return Chain(Scoped(sanitized).Select(sanitized.Selects!)); + return Chain(Scoped(sanitized).Select(sanitized.Selects!), projected: true); } } catch (PolicyException refusal) when (Refused(refusal)) @@ -551,7 +630,12 @@ public PolicyQueryable Filter(Filter filter) using (PolicyScope.Enter(_context, LastTrace)) { - return Chain(Guarded().Filter(sanitized)); + // A caller who sent orders gets no default later in the chain, whether or not any of + // them survived, exactly as a composed Order does. + return Chain( + Guarded().Filter(sanitized), + ordered: filter.Orders is { Count: > 0 }, + projected: sanitized.Selects is not null); } } catch (PolicyException refusal) when (Refused(refusal)) @@ -651,10 +735,30 @@ static bool IsGroupSize(AggregateBy aggregate) => /// point every guarded query passes through, so no individual method can forget it. /// /// Harmless on a non-EF source: the EF extension returns the query unchanged when the provider - /// is not one of its own. + /// is not one of its own. A provider that wraps EF Core's, as LinqKit's AsExpandable and + /// DelegateDecompiler's Decompile do, is not one of its own either, and passes the query on to + /// EF Core with tracking still on: the tracked entities' navigations were then filled in on the rows, + /// and a masked value became a pending change. On such a query the call goes into the query itself, + /// where EF Core reads it. /// /// - private IQueryable Guarded() => _source.AsNoTracking(); + private IQueryable Guarded() + { + IQueryable untracked = _source.AsNoTracking(); + + if (!ReferenceEquals(untracked, _source) || QueryRoot.Model(_source.Expression) is null) + { + return untracked; + } + + return _source.Provider.CreateQuery( + Expression.Call(null, AsNoTrackingMethod.MakeGenericMethod(typeof(T)), _source.Expression)); + } + + private static readonly MethodInfo AsNoTrackingMethod = typeof(EntityFrameworkQueryableExtensions) + .GetMethods() + .Single(method => method.Name == nameof(EntityFrameworkQueryableExtensions.AsNoTracking) + && method.GetParameters().Length == 1); /// /// Returns the query as a plain , outside the guard. @@ -674,15 +778,18 @@ static bool IsGroupSize(AggregateBy aggregate) => /// Wraps a composed query back into a handle, carrying the trace so far. /// The composed query. - /// True when the composing call was an Order. - private PolicyQueryable Chain(IQueryable composed, bool ordered = false) => - new(composed, _context, _resolver, _options, LastTrace, _ordered || ordered); + /// True when the composing call sent orders. + /// True when the composing call projected the rows. + private PolicyQueryable Chain(IQueryable composed, bool ordered = false, bool projected = false) => + new(composed, _context, _resolver, _options, LastTrace, _ordered || ordered, _projected || projected); /// /// True when a query over this handle takes the type's default order: the caller composed no - /// Order, and nothing ordered or projected the source. + /// Order and no projection, nothing ordered the source, and no projection in the source hides + /// a field the default names. /// - private bool TakesDefaultOrder => !_ordered && DefaultOrder.Applies(_source.Expression); + private bool TakesDefaultOrder => + !_ordered && !_projected && DefaultOrder.Applies(_source.Expression, typeof(T)); /// /// Refuses a method that hands back a query the caller materializes, when this type's values @@ -789,7 +896,9 @@ private Filter Sanitize(Filter filter, PolicyTrace trace) { // A source the caller ordered before guarding it keeps that order: a default would replace it. Filter sanitized = FilterSanitizer.Sanitize( - filter, _resolver, _context, _options, trace, applyDefaultOrder: TakesDefaultOrder); + filter, _resolver, _context, _options, trace, + applyDefaultOrder: TakesDefaultOrder, + rows: RowShape.Of(_source)); LastTrace = trace; diff --git a/DynamicWhere.ex/Policies/Source/RowShape.cs b/DynamicWhere.ex/Policies/Source/RowShape.cs new file mode 100644 index 0000000..131f939 --- /dev/null +++ b/DynamicWhere.ex/Policies/Source/RowShape.cs @@ -0,0 +1,862 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using DynamicWhere.ex.Optimization.Cache.Source; +using DynamicWhere.ex.Source; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Query; + +namespace DynamicWhere.ex.Policies.Source; + +/// +/// What a guarded query's source puts in the members of its rows, as far as a projection the library +/// synthesizes is concerned. +/// +/// +/// A caller who sends no projection is owed what an unguarded call would return, less what the policy +/// withholds. Which members hold what depends on the source, and so does which denied value could +/// reach the result at all: +/// +/// +/// An entity query loads the entity's columns, its owned and complex members, and a navigation +/// only when something loads it: an Include, an automatic include or a lazy loader. A value +/// beneath a navigation nothing loads never reaches the result, so a denial there needs no +/// projection; and projecting the navigation would load it. A member the model does not map is +/// computed by the class, from whatever EF Core loaded into it, so it counts as holding its type. +/// +/// +/// A projection holds exactly the members its initializer assigns. The others hold defaults, and +/// narrowing one reads it through EF.Property, which EF Core cannot translate for a member +/// the initializer never bound. +/// +/// +/// Rows in memory hold everything, and a member kept from them is the caller's own object, which +/// a transform would then change in place. +/// +/// +/// Read from the query itself, never from the rows, so the sanitizer stays pure: this is decided +/// once, before it runs. Where the query does not say, every value counts as reaching the result. +/// +internal sealed class RowShape +{ + /// The key EF Core's relational layer gives an owned type stored in a JSON column. + private const string JsonContainer = "Relational:ContainerColumnName"; + + /// + /// A source this library cannot read: no model, not a projection it can see into, not rows in + /// memory. Every value beneath a member is taken to reach the result. + /// + internal static readonly RowShape Unknown = new(RowKind.Entity); + + /// Rows in memory. + internal static readonly RowShape InMemory = new(RowKind.InMemory); + + /// Whether a class can be handed a lazy loader, read once per class. + private static readonly ConcurrentDictionary TakesLoader = new(); + + /// The complex properties of each entity type, read once per entity type. + private static readonly ConditionalWeakTable> ComplexByType = new(); + + private readonly HashSet? _assigned; + private readonly Dictionary _built = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _narrowable = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _kept = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _includes = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _loadedWhole = new(StringComparer.OrdinalIgnoreCase); + private readonly IEntityType? _entity; + private readonly bool _includeUnknown; + + private RowShape(RowKind kind) => Kind = kind; + + private RowShape( + RowKind kind, HashSet? assigned, IEnumerable narrowable, Type? built, IDictionary members) + : this(kind) + { + _assigned = assigned; + _narrowable.UnionWith(narrowable); + Built = built; + + foreach (KeyValuePair member in members) + { + _built[member.Key] = member.Value; + } + } + + private RowShape(IEntityType entity, IEnumerable includes, bool includeUnknown) + : this(RowKind.Entity) + { + _entity = entity; + _includes.UnionWith(includes); + _includeUnknown = includeUnknown; + + foreach (IProperty property in entity.GetProperties()) + { + _kept.Add(property.Name); + _loadedWhole[property.Name] = "it is a column, which EF Core reads whole"; + } + + foreach (INavigation navigation in entity.GetNavigations()) + { + if (IsJson(navigation.TargetEntityType)) + { + _loadedWhole[navigation.Name] = "it is stored as JSON, which EF Core cannot narrow"; + } + else + { + // An owned member is kept and narrowed; any other navigation is only ever narrowed for a + // caller who names it, since a synthesized projection leaves it out. + _narrowable.Add(navigation.Name); + } + + if (IsOwned(navigation)) + { + _kept.Add(navigation.Name); + } + } + + foreach (ISkipNavigation navigation in entity.GetSkipNavigations()) + { + _narrowable.Add(navigation.Name); + } + + foreach (string complex in ComplexProperties(entity)) + { + _kept.Add(complex); + _loadedWhole[complex] = "it is a complex property, which EF Core cannot narrow"; + } + } + + /// Where the rows come from. + internal RowKind Kind { get; } + + /// + /// The type a projection constructs its rows as, which may be a subtype of the query's element type; + /// null for any other source. + /// + internal Type? Built { get; } + + /// + /// The type a projection constructs a member's value as, or each element of a list it builds, when + /// the initializer says: Contact = new ContactRow { … } holds a ContactRow whatever the + /// member is declared as. Null when the value is read from somewhere else. + /// + internal Type? BuiltType(string member) => + Kind == RowKind.Projected && _built.TryGetValue(member, out Type? type) ? type : null; + + /// + /// True when a synthesized projection may keep a member holding an object: one a projection + /// assigned, or an entity's column, owned or complex member. Never an entity's navigation, which + /// projecting would load, and never an object held by a row in memory. + /// + internal bool Carries(string member) => Kind switch + { + RowKind.Projected => _assigned is null || _assigned.Contains(member), + RowKind.Entity => _entity is not null && _kept.Contains(member), + _ => false + }; + + /// + /// True when a synthesized projection may keep a member that holds a value. An entity keeps only + /// its mapped ones: a value EF Core does not map makes it read the whole entity to compute it, the + /// columns the policy denies included, and holds only its initial value anyway. + /// + internal bool CarriesValue(string member) => Kind switch + { + RowKind.Projected => _assigned is null || _assigned.Contains(member), + RowKind.Entity => _entity is null || _kept.Contains(member), + _ => true + }; + + /// + /// True when the core's projection can narrow the member to some of the fields beneath it. Only on + /// EF Core: its narrowing of a reference reads it through EF.Property. Not a column or a + /// complex property, which EF Core loads whole, not an owned type stored as JSON, and not a member + /// a projection built in a way the narrowing cannot reach. A source this cannot read narrows as it + /// always did, and leaves the core to refuse what it cannot build. + /// + internal bool Narrows(string member) => + (Kind == RowKind.Entity && _entity is null) || _narrowable.Contains(member); + + /// Why the core cannot narrow a member this source carries, for the trace. + internal string WhyNotNarrowed(string member) => Kind switch + { + RowKind.InMemory => "the rows are in memory, and narrowing it needs EF Core", + RowKind.Projected => "the projection builds it in a way the core cannot narrow", + _ => _loadedWhole.TryGetValue(member, out string? reason) ? reason : "narrowing it needs EF Core" + }; + + /// + /// True when the value at a path beneath the root can reach the result: every one in memory or in + /// a projection's assigned member, and, on an entity, one every navigation on the way to is loaded. + /// + /// + /// A navigation is loaded when it is owned, included, automatically included, or reachable by a + /// lazy loader. A member the model does not map is computed by the class, and a getter over a mapped + /// field or a private navigation hands out what EF Core loaded, so it counts as loaded too. Anything + /// the model cannot answer for counts as loaded, which only ever asks for a projection that turns out + /// not to be needed. A rule may spell a path in any letter case, so each segment is read through the + /// member it names. + /// + internal bool Materializes(string path) + { + string[] segments = path.Split('.'); + + if (Kind == RowKind.InMemory) + { + return true; + } + + if (Kind == RowKind.Projected) + { + return _assigned is null || _assigned.Contains(segments[0]); + } + + IEntityType? current = _entity; + string prefix = string.Empty; + + for (int i = 0; i < segments.Length - 1; i++) + { + if (current is null || CacheReflection.FindProperty(current.ClrType, segments[i]) is not { } member) + { + return true; + } + + string name = member.Name; + + prefix = i == 0 ? name : $"{prefix}.{name}"; + + // A column holding an object, or a complex property, is read whole with its row. + if (current.FindProperty(name) is not null || ComplexProperties(current).Contains(name)) + { + return true; + } + + INavigationBase? navigation = + (INavigationBase?)current.FindNavigation(name) ?? current.FindSkipNavigation(name); + + if (navigation is null) + { + return true; + } + + if (!Loads(current, navigation, prefix)) + { + return false; + } + + current = navigation.TargetEntityType; + } + + return true; + } + + /// + /// Every member the value at a path loads beneath it, each with its path from the root, when an + /// entity query's model can say; null when the value can carry anything its type holds. + /// + /// + /// Naming a member in a projection loads it, so the path's own segments are not asked about. Beneath + /// it the model decides: an entity's columns, owned and complex members, and the navigations + /// something loads, and the same for every type the model derives from it, whose rows EF Core + /// materializes as that type. A member the model does not map is read whole, as a column is: its + /// getter can hand out anything the entity holds. A lazy loader in reach can fill any navigation, + /// which bounds nothing, and so does a query whose includes cannot be read. + /// + internal List? LoadedBeneath(string path) + { + if (Kind != RowKind.Entity || _entity is null || _includeUnknown) + { + return null; + } + + IEntityType entity = _entity; + string prefix = string.Empty; + string[] segments = path.Split('.'); + + for (int i = 0; i < segments.Length; i++) + { + if (CacheReflection.FindProperty(entity.ClrType, segments[i]) is not { } member) + { + return null; + } + + prefix = i == 0 ? member.Name : $"{prefix}.{member.Name}"; + + // A value read whole: what it holds is its type's to say, and a path inside it names part of it. + if (entity.FindProperty(member.Name) is not null || ComplexProperties(entity).Contains(member.Name)) + { + return i == segments.Length - 1 ? new List { new(prefix, member, true, Converted(entity, member.Name)) } : null; + } + + INavigationBase? navigation = + (INavigationBase?)entity.FindNavigation(member.Name) ?? entity.FindSkipNavigation(member.Name); + + // A member the model does not map holds what its getter computes: read it whole, as its type. + if (navigation is null) + { + return i == segments.Length - 1 ? new List { new(prefix, member, true) } : null; + } + + entity = navigation.TargetEntityType; + } + + List loaded = new(); + + return Collect(entity, prefix, loaded, new HashSet<(IEntityType, string?)>(), derivedOnly: false) + ? loaded + : null; + } + + /// + /// Every member an entity query's rows load that a type the model derives from the root declares, + /// each with what it loads beneath it; null when a lazy loader or an unreadable include bounds + /// nothing, and for any other source. + /// + /// + /// EF Core materializes each row as the type the database says it is, so a query over the root of a + /// hierarchy returns the derived types' columns too, which the root's own members never name. + /// + internal List? LoadedByDerived() + { + if (Kind != RowKind.Entity || _entity is null || _includeUnknown) + { + return null; + } + + List loaded = new(); + + return Collect(_entity, string.Empty, loaded, new HashSet<(IEntityType, string?)>(), derivedOnly: true) + ? loaded + : null; + } + + /// + /// Collects what an entity loads beneath a path, its derived types' members included. False when a + /// lazy loader can fill any of its navigations. + /// + private bool Collect( + IEntityType entity, string prefix, List loaded, HashSet<(IEntityType, string?)> seen, bool derivedOnly) + { + // Off an include's path, what loads depends only on the type, so a type is read once there. + if (!seen.Add((entity, _includes.Contains(prefix) ? prefix : null))) + { + return true; + } + + foreach (IEntityType type in entity.GetDerivedTypesInclusive()) + { + if (derivedOnly && type == entity) + { + continue; + } + + if (LoadsLazily(type)) + { + return false; + } + + foreach (PropertyInfo member in type.ClrType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + // A derived type's inherited members are the base type's, read once from there. + if (!member.CanRead + || member.GetIndexParameters().Length != 0 + || (type != entity && member.DeclaringType!.IsAssignableFrom(entity.ClrType))) + { + continue; + } + + string path = prefix.Length == 0 ? member.Name : $"{prefix}.{member.Name}"; + + if (type.FindProperty(member.Name) is not null || ComplexProperties(type).Contains(member.Name)) + { + loaded.Add(new Loaded(path, member, true, Converted(type, member.Name))); + + continue; + } + + INavigationBase? navigation = + (INavigationBase?)type.FindNavigation(member.Name) ?? type.FindSkipNavigation(member.Name); + + // Not mapped: whatever its getter computes from what EF Core loaded, read as its type. + if (navigation is null) + { + loaded.Add(new Loaded(path, member, true)); + + continue; + } + + if (!Loads(type, navigation, path)) + { + continue; + } + + loaded.Add(new Loaded(path, member, false)); + + if (!Collect(navigation.TargetEntityType, path, loaded, seen, derivedOnly: false)) + { + return false; + } + } + } + + return true; + } + + /// + /// True when a column's value comes from a value converter, which is the application's code and can hand + /// back an object of any type its member's type allows; for a complex property, when a member of it does. + /// + private static bool Converted(IEntityType type, string name) => + type.FindProperty(name)?.GetValueConverter() is not null || ConvertedComplex(type).Contains(name); + + /// The complex properties of an entity type with a converted member at any depth. + private static HashSet ConvertedComplex(IEntityType entityType) => + ConvertedComplexByType.GetValue(entityType, ReadConvertedComplex); + + private static readonly ConditionalWeakTable> ConvertedComplexByType = new(); + + /// + /// Reads, through reflection since EF Core 6 has none, the complex properties whose type holds a converted + /// property. One that cannot be read counts as converted. + /// + private static HashSet ReadConvertedComplex(IEntityType entityType) + { + HashSet names = new(StringComparer.OrdinalIgnoreCase); + + foreach (object complex in Items(entityType, "GetComplexProperties")) + { + if (complex.GetType().GetProperty("Name")?.GetValue(complex) is string name && HoldsConverted(complex, depth: 0)) + { + names.Add(name); + } + } + + return names; + } + + private static bool HoldsConverted(object complex, int depth) + { + if (depth > MaxComplexDepth || complex.GetType().GetProperty("ComplexType")?.GetValue(complex) is not { } type) + { + return true; + } + + return Items(type, "GetProperties").Any(property => property is not IReadOnlyProperty column || column.GetValueConverter() is not null) + || Items(type, "GetComplexProperties").Any(nested => HoldsConverted(nested, depth + 1)); + } + + /// How deep complex properties are read inside one another before the rest counts as converted. + private const int MaxComplexDepth = 8; + + /// What a model object's parameterless method of the name returns, for a method some interface of it declares. + private static IEnumerable Items(object model, string method) + { + foreach (Type contract in model.GetType().GetInterfaces()) + { + if (contract.GetMethod(method, Type.EmptyTypes) is { } found && found.Invoke(model, null) is IEnumerable items) + { + return items.Cast().ToList(); + } + } + + return Array.Empty(); + } + + /// True when something loads a navigation of an entity reached along a path. + private bool Loads(IEntityType owner, INavigationBase navigation, string path) => + (navigation is INavigation owned && IsOwned(owned)) + || navigation.IsEagerLoaded + || _includeUnknown + || _includes.Contains(path) + || LoadsLazily(owner); + + /// Reads the shape of a guarded query's source. + /// + /// Rows in memory first, because a projection over them is still in memory. Then a projection that + /// builds its rows, which is how a caller makes its own row type out of entities. What is left is an + /// entity query, read from the EF Core model; without one, the source is . A + /// chain that reaches its rows through anything but the root's own, Select(o => o.Customer), + /// a SelectMany, a Join or a GroupBy, holds entities EF Core loaded along + /// another path. Its includes name paths from another root, which EF Core still applies, and a + /// projection inside it, such as one behind an identity Select, loads whatever it assigns: with + /// either, every navigation counts as loaded. With neither, only an automatic include or a lazy + /// loader fills one, which the model says. + /// + internal static RowShape Of(IQueryable source) where T : class + { + if (source.Provider is EnumerableQuery) + { + return InMemory; + } + + if (DefaultOrder.BuildsRows(source.Expression)) + { + return Projected(source); + } + + if (QueryRoot.EntityType(source.Expression, typeof(T)) is not { } entity) + { + return Unknown; + } + + HashSet includes = new(StringComparer.OrdinalIgnoreCase); + bool unknown = !ReadIncludes(source.Expression, includes, out int included) + || (QueryRoot.Reshapes(source.Expression) && (included > 0 || QueryRoot.Builds(source.Expression))); + + return new RowShape(entity, includes, unknown); + } + + /// + /// A projection that builds its rows: which members its outermost initializer assigns, and which of + /// those the core can narrow. + /// + private static RowShape Projected(IQueryable source) where T : class + { + MethodCallExpression select = DefaultOrder.RowSelect(source.Expression)!; + LambdaExpression selector = (LambdaExpression)DefaultOrder.StripQuotes(select.Arguments[1]); + Expression body = DefaultOrder.StripConversions(selector.Body); + Dictionary built = 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. + return new RowShape(RowKind.Projected, null, Array.Empty(), body.Type, built); + } + + 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); + + foreach (MemberBinding binding in initializer.Bindings) + { + // After a constructor with arguments every member counts as assigned, since the constructor + // may set any of them; the initializer's own bindings still say what they hold. + assigned?.Add(binding.Member.Name); + + if (binding is not MemberAssignment assignment) + { + continue; + } + + if (BuiltBy(assignment.Expression) is { } type) + { + built[binding.Member.Name] = type; + } + + // The narrowing reads the member through EF.Property, so only EF Core can run it. + if (efCore && CanNarrow(assignment, row, rowSource)) + { + narrowable.Add(binding.Member.Name); + } + } + + return new RowShape(RowKind.Projected, assigned, narrowable, initializer.Type, built); + } + + /// + /// The type an assigned value is constructed as, new ContactRow { … }, or each element of a + /// list a subquery builds, o.Lines.Select(l => new LineRow { … }).ToList(); null otherwise. + /// + private static Type? BuiltBy(Expression expression) + { + Expression value = DefaultOrder.StripConversions(expression); + + if (value is MemberInitExpression or NewExpression) + { + return value.Type; + } + + if (value is MethodCallExpression { Method.Name: "ToList" or "ToArray" or "ToHashSet" } read + && (read.Method.DeclaringType == typeof(Enumerable) || read.Method.DeclaringType == typeof(Queryable)) + && DefaultOrder.StripConversions(read.Arguments[0]) is MethodCallExpression { Method.Name: "Select" } select + && select.Arguments.Count == 2 + && DefaultOrder.StripQuotes(select.Arguments[1]) is LambdaExpression { Body: var element } + && DefaultOrder.StripConversions(element) is MemberInitExpression or NewExpression) + { + return DefaultOrder.StripConversions(element).Type; + } + + return null; + } + + /// + /// True when the core's narrowing of an assigned member translates: an object the initializer + /// builds, a list a subquery reads into one the core can bind, or a navigation of the entity the + /// projection reads that is neither complex nor stored as JSON. + /// + private static bool CanNarrow(MemberAssignment assignment, ParameterExpression row, IEntityType? rowSource) + { + Type memberType = assignment.Member is PropertyInfo property ? property.PropertyType : typeof(object); + + // The core builds a List for a narrowed collection and skips a member that cannot hold one. + if (CacheReflection.GetCollectionElementType(memberType) is { } element + && !memberType.IsAssignableFrom(typeof(List<>).MakeGenericType(element))) + { + return false; + } + + Expression value = DefaultOrder.StripConversions(assignment.Expression); + + if (value is MemberInitExpression) + { + return true; + } + + if (value is MethodCallExpression { Method.Name: "ToList" or "ToArray" or "ToHashSet" } read + && (read.Method.DeclaringType == typeof(Enumerable) || read.Method.DeclaringType == typeof(Queryable))) + { + return true; + } + + if (value is MemberExpression { Member: PropertyInfo member, Expression: { } owner } + && DefaultOrder.StripConversions(owner) == row + && rowSource?.FindNavigation(member.Name) is { } navigation) + { + return !IsJson(navigation.TargetEntityType); + } + + return false; + } + + /// + /// Collects every navigation path an Include or ThenInclude on the chain loads, each + /// with its prefixes. False when one names its path in a form this cannot read, so every navigation + /// counts as loaded. + /// + private static bool ReadIncludes(Expression expression, HashSet includes, out int included) + { + List calls = new(); + + for (Expression? node = expression; node is MethodCallExpression call; + node = call.Arguments.Count > 0 ? call.Arguments[0] : null) + { + if (IsInclude(call)) + { + calls.Add(call); + } + } + + // An include somewhere else in the tree, in the other branch of a Concat or a Union, loads a + // navigation of rows this chain returns, and nothing here can say which. + IncludeCounter counter = new(); + + counter.Visit(expression); + + included = counter.Count; + + bool readable = counter.Count == calls.Count; + string? current = null; + + for (int i = calls.Count - 1; i >= 0; i--) + { + MethodCallExpression call = calls[i]; + bool then = call.Method.Name == nameof(EntityFrameworkQueryableExtensions.ThenInclude); + string? path = null; + bool named = false; + + if (!then && call.Arguments[1] is ConstantExpression { Value: string text }) + { + path = string.Join('.', text.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + named = true; + } + else if (DefaultOrder.StripQuotes(call.Arguments[1]) is LambdaExpression lambda + && MemberChain(lambda.Body, lambda.Parameters[0]) is { } chain) + { + path = then ? (current is null ? null : $"{current}.{chain}") : chain; + } + + if (string.IsNullOrEmpty(path)) + { + readable = false; + current = null; + + continue; + } + + string[] segments = path.Split('.'); + + for (int j = 1; j <= segments.Length; j++) + { + includes.Add(string.Join('.', segments, 0, j)); + } + + // A ThenInclude continues a lambda Include; one named by a string starts nothing to continue. + current = named ? null : path; + } + + return readable; + } + + private static bool IsInclude(MethodCallExpression call) => + call.Method.DeclaringType == typeof(EntityFrameworkQueryableExtensions) + && call.Method.Name is nameof(EntityFrameworkQueryableExtensions.Include) + or nameof(EntityFrameworkQueryableExtensions.ThenInclude); + + /// Counts every Include and ThenInclude anywhere in a query. + private sealed class IncludeCounter : ExpressionVisitor + { + internal int Count { get; private set; } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (IsInclude(node)) + { + Count++; + } + + return base.VisitMethodCall(node); + } + } + + /// + /// The member path a navigation lambda reads, o => o.Lines or o => o.Customer.Address, + /// through casts and a filtered include's operators; null for any other shape. + /// + private static string? MemberChain(Expression body, ParameterExpression parameter) + { + Expression node = DefaultOrder.StripConversions(body); + + // A filtered include reads the navigation through Where, OrderBy, Skip, Take and their kin. + while (node is MethodCallExpression call + && (call.Method.DeclaringType == typeof(Enumerable) || call.Method.DeclaringType == typeof(Queryable)) + && call.Arguments.Count > 0) + { + node = DefaultOrder.StripConversions(call.Arguments[0]); + } + + List names = new(); + + while (node is MemberExpression { Member: PropertyInfo or FieldInfo, Expression: { } owner } member) + { + names.Add(member.Member.Name); + node = DefaultOrder.StripConversions(owner); + } + + if (node != parameter || names.Count == 0) + { + return null; + } + + names.Reverse(); + + return string.Join('.', names); + } + + /// True for a navigation to a type the entity owns. + private static bool IsOwned(INavigation navigation) => + navigation.ForeignKey.IsOwnership && !navigation.IsOnDependent; + + /// True for an owned type EF Core stores in a JSON column, which it cannot project into. + private static bool IsJson(IEntityType entity) => entity.FindAnnotation(JsonContainer)?.Value is not null; + + /// + /// True when a lazy loader can fill this entity's navigations after the query: EF Core's proxies and + /// an injected ILazyLoader give it a service property, and a loader the class takes in its + /// constructor, either delegate form above all, may be kept in a field or a property of any name, + /// where the model has no record of it. An injected DbContext can load anything. + /// + private static bool LoadsLazily(IEntityType entity) => + entity.GetServiceProperties().Any(service => IsLoader(service.ClrType)) + || TakesLoader.GetOrAdd(entity.ClrType, HoldsLoader); + + private static bool HoldsLoader(Type type) + { + const BindingFlags any = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; + + if (type.GetConstructors(any).Any(constructor => constructor.GetParameters().Any(p => IsLoader(p.ParameterType)))) + { + return true; + } + + for (Type? current = type; current is not null && current != typeof(object); current = current.BaseType) + { + if (current.GetFields(any | BindingFlags.DeclaredOnly).Any(field => IsLoader(field.FieldType)) + || current.GetProperties(any | BindingFlags.DeclaredOnly).Any(property => IsLoader(property.PropertyType))) + { + return true; + } + } + + return false; + } + + private static bool IsLoader(Type type) => + type == typeof(ILazyLoader) + || type == typeof(Action) + || type == typeof(Func) + || typeof(DbContext).IsAssignableFrom(type); + + /// + /// The names of an entity type's complex properties, which EF Core 8 introduced and loads with the + /// entity. None on an earlier version, where the method does not exist. + /// + /// + /// Read by reflection because the library compiles against EF Core 6. The method is declared on an + /// interface the entity type implements, so the interfaces are searched rather than the class. + /// + private static HashSet ComplexProperties(IEntityType entityType) => + ComplexByType.GetValue(entityType, ReadComplexProperties); + + private static HashSet ReadComplexProperties(IEntityType entityType) + { + HashSet names = new(StringComparer.OrdinalIgnoreCase); + + foreach (Type contract in entityType.GetType().GetInterfaces()) + { + MethodInfo? method = contract.GetMethod("GetComplexProperties", Type.EmptyTypes); + + if (method is null) + { + continue; + } + + if (method.Invoke(entityType, null) is IEnumerable properties) + { + foreach (object? property in properties) + { + if (property?.GetType().GetProperty("Name")?.GetValue(property) is string name) + { + names.Add(name); + } + } + } + + break; + } + + return names; + } +} + +/// A member a value loads, with its path from the root. +/// The member's path from the root. +/// The member. +/// True when EF Core reads it whole: a column or a complex property. +/// +/// True when a value converter hands back its value: the application's code, which can return an object of any +/// type the member's type allows. +/// +internal readonly record struct Loaded(string Path, PropertyInfo Property, bool Whole, bool Converted = false); + +/// Where a guarded query's rows come from. +internal enum RowKind +{ + /// An entity query: a navigation holds a value only when something loads it. + Entity, + + /// A projection that builds its rows: every member holds what the projection gave it. + Projected, + + /// A sequence in memory: every member holds whatever the object holds. + InMemory +} diff --git a/DynamicWhere.ex/Policies/Validation/PolicyModelValidator.cs b/DynamicWhere.ex/Policies/Validation/PolicyModelValidator.cs index 8779dc7..5b9b16f 100644 --- a/DynamicWhere.ex/Policies/Validation/PolicyModelValidator.cs +++ b/DynamicWhere.ex/Policies/Validation/PolicyModelValidator.cs @@ -195,15 +195,18 @@ private static void CheckDefaultOrder(Type type, List errors, List - /// True when the member a path ends on is denied a feature by its own attributes, and every one of - /// those attributes is overridable. + /// True when the member a path ends on is denied a feature by its attributes, and every one of those + /// attributes is overridable. They include those of the member's other declarations: an interface + /// member it implements, and a subtype's override. /// private static bool DeniedOnlyOverridably(Type type, string path, PolicyFeature feature) { PropertyInfo? member = null; + Type owner = type; foreach (string segment in path.Split('.')) { + owner = type; member = CacheReflection.FindProperty(type, segment); if (member is null) @@ -216,6 +219,7 @@ private static bool DeniedOnlyOverridably(Type type, string path, PolicyFeature List denials = member! .GetCustomAttributes(inherit: true) + .Concat(Resolution.AttributePolicyProvider.DenialsElsewhere(owner, member!)) .Where(attribute => (attribute.Features & feature) == feature) .ToList(); diff --git a/DynamicWhere.ex/Source/AsyncReads.cs b/DynamicWhere.ex/Source/AsyncReads.cs new file mode 100644 index 0000000..47692f2 --- /dev/null +++ b/DynamicWhere.ex/Source/AsyncReads.cs @@ -0,0 +1,82 @@ +using System.Collections; +using System.Linq.Dynamic.Core; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; + +namespace DynamicWhere.ex.Source; + +/// +/// Asynchronous reads of a query whose element type is only known at run time: a dynamic projection or +/// a grouping. +/// +/// +/// EF Core's own asynchronous operators are generic in the element type, and a dynamic query's element +/// type is only known at run time, so they are reached by reflection. Through them a cancellation reaches +/// the database. They replace System.Linq.Dynamic.Core's ToDynamicListAsync on an EF Core query, and +/// Count(), which counted a grouping synchronously. +/// +/// Any other provider, rows in memory among them, keeps ToDynamicListAsync, which reads on the +/// calling thread. +/// +/// +internal static class AsyncReads +{ + /// EntityFrameworkQueryableExtensions.ToListAsync<TSource>(source, cancellationToken). + private static readonly MethodInfo ToListAsyncMethod = typeof(EntityFrameworkQueryableExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(method => method.Name == nameof(EntityFrameworkQueryableExtensions.ToListAsync) + && method.GetParameters().Length == 2); + + /// EntityFrameworkQueryableExtensions.CountAsync<TSource>(source, cancellationToken). + private static readonly MethodInfo CountAsyncMethod = typeof(EntityFrameworkQueryableExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(method => method.Name == nameof(EntityFrameworkQueryableExtensions.CountAsync) + && method.GetParameters().Length == 2 + && method.GetParameters()[1].ParameterType == typeof(CancellationToken)); + + /// Reads every row of a query into a list of dynamic objects. + internal static async Task> ToDynamicListAsync(IQueryable query, CancellationToken cancellationToken) + { + if (query.Provider is not IAsyncQueryProvider) + { + return await query.ToDynamicListAsync(cancellationToken); + } + + Task read = (Task)Call(ToListAsyncMethod, query, cancellationToken); + + await read; + + IList rows = (IList)read.GetType().GetProperty(nameof(Task.Result))!.GetValue(read)!; + List list = new(rows.Count); + + foreach (object? row in rows) + { + list.Add(row!); + } + + return list; + } + + /// Counts the rows of a query. + internal static async Task CountAsync(IQueryable query, CancellationToken cancellationToken) + { + if (query.Provider is not IAsyncQueryProvider) + { + cancellationToken.ThrowIfCancellationRequested(); + + return query.Count(); + } + + return await (Task)Call(CountAsyncMethod, query, cancellationToken); + } + + /// + /// Calls one of EF Core's operators for the query's element type, letting whatever it throws leave as + /// itself: a query EF Core cannot translate fails with its own exception, not one wrapped by reflection. + /// + private static object Call(MethodInfo operatorMethod, IQueryable query, CancellationToken cancellationToken) => + operatorMethod + .MakeGenericMethod(query.ElementType) + .Invoke(null, BindingFlags.DoNotWrapExceptions, null, new object[] { query, cancellationToken }, null)!; +} diff --git a/DynamicWhere.ex/Source/Extention.cs b/DynamicWhere.ex/Source/Extention.cs index 05f3d45..9149c40 100644 --- a/DynamicWhere.ex/Source/Extention.cs +++ b/DynamicWhere.ex/Source/Extention.cs @@ -681,7 +681,36 @@ public static FilterResult ToListDynamic(this IEnumerable query, /// A containing entities that match the filter conditions in the with pagination information. /// Thrown if either or is null. /// Thrown when contains invalid data. - public static async Task> ToListAsync(this IQueryable query, Filter filter, bool getQueryString = false) where T : class + public static Task> ToListAsync(this IQueryable query, Filter filter, bool getQueryString = false) where T : class => + query.ToListAsync(filter, getQueryString, CancellationToken.None); + + /// + /// Asynchronously retrieves a list of entities from the with optional filtering based on a . + /// + /// The entity type. + /// The to retrieve entities from. + /// The containing filter conditions and optional pagination settings. + /// Cancels the count and the read. + /// A containing entities that match the filter conditions in the with pagination information. + /// Thrown if either or is null. + /// Thrown when contains invalid data. + /// Thrown when is canceled. + public static Task> ToListAsync(this IQueryable query, Filter filter, CancellationToken cancellationToken) where T : class => + query.ToListAsync(filter, false, cancellationToken); + + /// + /// Asynchronously retrieves a list of entities from the with optional filtering based on a . + /// + /// The entity type. + /// The to retrieve entities from. + /// The containing filter conditions and optional pagination settings. + /// If true, includes the generated query string in the result. + /// Cancels the count and the read. + /// A containing entities that match the filter conditions in the with pagination information. + /// Thrown if either or is null. + /// Thrown when contains invalid data. + /// Thrown when is canceled. + public static async Task> ToListAsync(this IQueryable query, Filter filter, bool getQueryString, CancellationToken cancellationToken) where T : class { // Refuse a type that requires a policy context when the call is not inside one. PolicyScope.Require(); @@ -730,7 +759,7 @@ public static async Task> ToListAsync(this IQueryable quer } // Calculate the total count of entities before pagination. - int totalCount = await query.CountAsync(); + int totalCount = await query.CountAsync(cancellationToken); // Calculate the total page count based on the page size. An unpaged query is one page of // everything: dividing by one reported as many pages as there were rows, which read as a @@ -748,7 +777,7 @@ public static async Task> ToListAsync(this IQueryable quer TotalCount = totalCount, // Execute the query to retrieve the data. - Data = await newQuery.ToListAsync(), + Data = await newQuery.ToListAsync(cancellationToken), QueryString = getQueryString ? newQuery.ToQueryString() : null }; } @@ -764,7 +793,38 @@ public static async Task> ToListAsync(this IQueryable quer /// A of dynamic containing dynamic objects that match the filter conditions with pagination information. /// Thrown if either or is null. /// Thrown when contains invalid data. - public static async Task> ToListAsyncDynamic(this IQueryable query, Filter filter, bool getQueryString = false) where T : class + public static Task> ToListAsyncDynamic(this IQueryable query, Filter filter, bool getQueryString = false) where T : class => + query.ToListAsyncDynamic(filter, getQueryString, CancellationToken.None); + + /// + /// Asynchronously retrieves a list of dynamic objects from the with optional filtering based on a , + /// using for the field projection. + /// + /// The entity type. + /// The to retrieve entities from. + /// The containing filter conditions and optional pagination settings. + /// Cancels the count and the read. + /// A of dynamic containing dynamic objects that match the filter conditions with pagination information. + /// Thrown if either or is null. + /// Thrown when contains invalid data. + /// Thrown when is canceled. + public static Task> ToListAsyncDynamic(this IQueryable query, Filter filter, CancellationToken cancellationToken) where T : class => + query.ToListAsyncDynamic(filter, false, cancellationToken); + + /// + /// Asynchronously retrieves a list of dynamic objects from the with optional filtering based on a , + /// using for the field projection. + /// + /// The entity type. + /// The to retrieve entities from. + /// The containing filter conditions and optional pagination settings. + /// If true, includes the generated query string in the result. + /// Cancels the count and the read. + /// A of dynamic containing dynamic objects that match the filter conditions with pagination information. + /// Thrown if either or is null. + /// Thrown when contains invalid data. + /// Thrown when is canceled. + public static async Task> ToListAsyncDynamic(this IQueryable query, Filter filter, bool getQueryString, CancellationToken cancellationToken) where T : class { // Refuse a type that requires a policy context when the call is not inside one. PolicyScope.Require(); @@ -786,7 +846,7 @@ public static async Task> ToListAsyncDynamic(this IQuer } // Calculate the total count of entities before pagination. - int totalCount = await query.CountAsync(); + int totalCount = await query.CountAsync(cancellationToken); // Create a new query to apply ordering and pagination. IQueryable newQuery = query; @@ -830,7 +890,7 @@ public static async Task> ToListAsyncDynamic(this IQuer TotalCount = totalCount, // Execute the query to retrieve the dynamic data asynchronously. - Data = await result.ToDynamicListAsync(), + Data = await AsyncReads.ToDynamicListAsync(result, cancellationToken), QueryString = getQueryString ? result.ToQueryString() : null }; } @@ -1038,7 +1098,36 @@ public static SummaryResult ToList(this IEnumerable query, Summary summary /// A containing grouped entities that match the summary criteria with pagination information. /// Thrown if either or is null. /// Thrown when contains invalid data. - public static async Task ToListAsync(this IQueryable query, Summary summary, bool getQueryString = false) where T : class + public static Task ToListAsync(this IQueryable query, Summary summary, bool getQueryString = false) where T : class => + query.ToListAsync(summary, getQueryString, CancellationToken.None); + + /// + /// Asynchronously retrieves a list of dynamic grouped entities from the with optional filtering based on a . + /// + /// The entity type. + /// The to retrieve grouped entities from. + /// The containing filter conditions, group-by criteria, and optional pagination settings. + /// Cancels the count and the read. + /// A containing grouped entities that match the summary criteria with pagination information. + /// Thrown if either or is null. + /// Thrown when contains invalid data. + /// Thrown when is canceled. + public static Task ToListAsync(this IQueryable query, Summary summary, CancellationToken cancellationToken) where T : class => + query.ToListAsync(summary, false, cancellationToken); + + /// + /// Asynchronously retrieves a list of dynamic grouped entities from the with optional filtering based on a . + /// + /// The entity type. + /// The to retrieve grouped entities from. + /// The containing filter conditions, group-by criteria, and optional pagination settings. + /// If true, includes the generated query string in the result. + /// Cancels the count and the read. + /// A containing grouped entities that match the summary criteria with pagination information. + /// Thrown if either or is null. + /// Thrown when contains invalid data. + /// Thrown when is canceled. + public static async Task ToListAsync(this IQueryable query, Summary summary, bool getQueryString, CancellationToken cancellationToken) where T : class { // Refuse a type that requires a policy context when the call is not inside one. PolicyScope.Require(); @@ -1077,7 +1166,7 @@ public static async Task ToListAsync(this IQueryable query, } // Calculate the total count of grouped entities before pagination. - int totalCount = result.Count(); + int totalCount = await AsyncReads.CountAsync(result, cancellationToken); // Create a new query to apply ordering and pagination. IQueryable newResult = result; @@ -1126,7 +1215,7 @@ public static async Task ToListAsync(this IQueryable query, TotalCount = totalCount, // Execute the query to retrieve the data asynchronously. - Data = await newResult.ToDynamicListAsync(), + Data = await AsyncReads.ToDynamicListAsync(newResult, cancellationToken), QueryString = getQueryString ? newResult.ToQueryString() : null }; } @@ -1147,7 +1236,25 @@ public static async Task ToListAsync(this IQueryable query, /// the total count then run exactly as they do for a , so only the requested page /// is read. /// - public static async Task> ToListAsync(this IQueryable query, Segment segment) where T : class + public static Task> ToListAsync(this IQueryable query, Segment segment) where T : class => + query.ToListAsync(segment, CancellationToken.None); + + /// + /// Asynchronously retrieves a list of entities from the with optional filtering based on a . + /// + /// The entity type. + /// The to retrieve entities from. + /// The containing filter conditions and optional pagination settings. + /// Cancels the count and the read. + /// A containing entities that match the filter conditions in the with pagination information. + /// Thrown if either or is null. + /// Thrown when contains invalid data. + /// Thrown when is canceled. + /// + /// The condition sets become one query, which the database answers, exactly as they do for the + /// overload without a token. + /// + public static async Task> ToListAsync(this IQueryable query, Segment segment, CancellationToken cancellationToken) where T : class { // Refuse a type that requires a policy context when the call is not inside one. PolicyScope.Require(); @@ -1178,7 +1285,7 @@ public static async Task> ToListAsync(this IQueryable que Page = segment.Page }; - FilterResult fresult = await combined.ToListAsync(filter); + FilterResult fresult = await combined.ToListAsync(filter, false, cancellationToken); // Return the results as a SegmentResult. return new() diff --git a/DynamicWhere.ex/Source/QueryRoot.cs b/DynamicWhere.ex/Source/QueryRoot.cs new file mode 100644 index 0000000..850c3c0 --- /dev/null +++ b/DynamicWhere.ex/Source/QueryRoot.cs @@ -0,0 +1,497 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; +using DynamicWhere.ex.Optimization.Cache.Source; +using DynamicWhere.ex.Policies.Resolution; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace DynamicWhere.ex.Source; + +/// +/// The EF Core model behind a query, read from the query's root. +/// +/// +/// The root expression's type differs between EF Core versions — QueryRootExpression in 6, +/// EntityQueryRootExpression from 7 — and both carry the entity type in a property named +/// EntityType, so the property is found by name. Any root reaches the model, and the model is +/// asked for the type the caller names, which also answers for a derived type queried through +/// OfType. +/// +internal static class QueryRoot +{ + /// The EntityType property of each query-root expression type, or null for a type with none. + private static readonly ConcurrentDictionary RootEntityTypeProperties = new(); + + /// + /// The entity type the model maps for , or null when the query is not an + /// EF Core query or the model does not map that type. + /// + internal static IEntityType? EntityType(Expression expression, Type type) => Model(expression)?.FindEntityType(type); + + /// The EF Core model behind a query, or null when the query is not an EF Core query. + internal static IModel? Model(Expression expression) + { + RootFinder finder = new(); + + finder.Visit(expression); + + return finder.EntityType?.Model; + } + + /// + /// True when a call along a query's chain hands back the rows it was given, fewer of them or in + /// another order, rather than rows it builds or reaches through them. + /// + /// + /// A method this does not know counts as building its rows, which only ever reads a query more + /// warily than it needs. + /// + internal static bool KeepsRows(MethodCallExpression call) + { + Type? declaring = call.Method.DeclaringType; + + if (declaring == typeof(Queryable) || declaring == typeof(Enumerable)) + { + return call.Method.Name switch + { + nameof(Queryable.Where) or nameof(Queryable.OrderBy) or nameof(Queryable.OrderByDescending) + or nameof(Queryable.ThenBy) or nameof(Queryable.ThenByDescending) or "Order" or "OrderDescending" + or nameof(Queryable.Skip) or nameof(Queryable.Take) or nameof(Queryable.SkipWhile) + or nameof(Queryable.TakeWhile) or "SkipLast" or "TakeLast" or nameof(Queryable.Distinct) + or "DistinctBy" or nameof(Queryable.Reverse) or nameof(Queryable.AsQueryable) + or nameof(Queryable.OfType) or nameof(Queryable.Cast) => true, + + // Each of these returns the rows of both sources, and the other one is read the same way. + nameof(Queryable.Concat) or nameof(Queryable.Union) or "UnionBy" or nameof(Queryable.Intersect) + or "IntersectBy" or nameof(Queryable.Except) or "ExceptBy" => true, + + // With no argument of its own, only a missing row is added. + nameof(Queryable.DefaultIfEmpty) => call.Arguments.Count == 1, + + _ => false + }; + } + + // EF Core's own operators that load, track or tag the rows without changing which they are. + return declaring?.Namespace == "Microsoft.EntityFrameworkCore" + && call.Method.Name is "Include" or "ThenInclude" or "AsNoTracking" + or "AsNoTrackingWithIdentityResolution" or "AsTracking" or "IgnoreQueryFilters" + or "IgnoreAutoIncludes" or "TagWith" or "TagWithCallSite" or "AsSplitQuery" or "AsSingleQuery"; + } + + /// + /// True when some call along the chain, or along the other source of a set operation, builds its + /// rows or reaches them through the rows it was given: a Select, a SelectMany, a + /// Join, a GroupBy, or anything does not know. + /// + internal static bool Reshapes(Expression expression) + { + for (Expression? node = expression; node is MethodCallExpression call; + node = call.Arguments.Count > 0 ? call.Arguments[0] : null) + { + if (!KeepsRows(call)) + { + return true; + } + + if (call.Arguments.Count > 1 + && call.Method.Name is nameof(Queryable.Concat) or nameof(Queryable.Union) or "UnionBy" + or nameof(Queryable.Intersect) or "IntersectBy" or nameof(Queryable.Except) or "ExceptBy" + && Reshapes(call.Arguments[1])) + { + return true; + } + } + + return false; + } + + /// + /// True when a call along the chain that does not know can hand its rows an + /// object the query's own includes do not account for: one a lambda constructs, one an application's + /// method returns, and one a lambda captured, another query's rows among them. + /// + /// + /// A projection, such as the one behind Select(x => x), an anonymous row or a conditional, + /// assigns what it builds, and loads whatever navigation it assigns. A method can return anything. A + /// query captured in a variable becomes part of the query EF Core runs, with its own includes and + /// projections, and an object captured from memory holds whatever it holds. A value, such as a + /// predicate's result, a key or a date, carries none of them, so what only feeds one is not read. + /// + internal static bool Builds(Expression expression) => Builds(expression, depth: 0); + + private static bool Builds(Expression expression, int depth) + { + // A captured query that captures itself would never end; one this deep is counted as building. + if (depth > MaxCapturedDepth) + { + return true; + } + + ForeignFinder finder = new(depth); + + for (Expression? node = expression; node is MethodCallExpression call; + node = call.Arguments.Count > 0 ? call.Arguments[0] : null) + { + if (call.Arguments.Count > 1 + && call.Method.Name is nameof(Queryable.Concat) or nameof(Queryable.Union) or "UnionBy" + or nameof(Queryable.Intersect) or "IntersectBy" or nameof(Queryable.Except) or "ExceptBy" + && Builds(call.Arguments[1], depth)) + { + return true; + } + + if (KeepsRows(call)) + { + continue; + } + + foreach (Expression argument in call.Arguments.Skip(1)) + { + finder.Visit(argument); + + if (finder.Found) + { + return true; + } + } + } + + return false; + } + + /// How many captured queries deep reads before it stops. + private const int MaxCapturedDepth = 8; + + /// + /// Finds, anywhere in an expression, what can hand a row an object its query does not load: an object + /// constructed, an application's method's result, and an object or a query captured from outside. + /// + private sealed class ForeignFinder : ExpressionVisitor + { + private readonly int _depth; + + internal ForeignFinder(int depth) => _depth = depth; + + internal bool Found { get; private set; } + + public override Expression? Visit(Expression? node) => + Found || node is null || IsValue(node.Type) ? node : base.Visit(node); + + protected override Expression VisitNew(NewExpression node) + { + // An anonymous object carries what it is given, the range variables of a query-syntax join or + // let, or the parts of a composite key, and builds nothing of its own: what it carries is read. + if (IsAnonymous(node.Type)) + { + return base.VisitNew(node); + } + + Found = true; + + return node; + } + + protected override Expression VisitMemberInit(MemberInitExpression node) + { + Found = true; + + return node; + } + + protected override Expression VisitListInit(ListInitExpression node) + { + Found = true; + + return node; + } + + protected override Expression VisitNewArray(NewArrayExpression node) + { + Found = true; + + return node; + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + // A call that reads nothing of the lambda's and hands back a query or an expression, a + // repository's query, a specification or FromSql, is evaluated before the query runs, as EF + // Core evaluates it, and what it returns is read. + if (Evaluable(node)) + { + Found = !TryEvaluate(node, out object? value) || Holds(value, _depth); + + return node; + } + + if (Reads(node.Method)) + { + return base.VisitMethodCall(node); + } + + // Any other method's result is its own to say. + Found = true; + + return node; + } + + protected override Expression VisitMember(MemberExpression node) + { + if (!IsCaptured(node)) + { + return base.VisitMember(node); + } + + Found = !TryEvaluate(node, out object? value) || Holds(value, _depth); + + return node; + } + + protected override Expression VisitConstant(ConstantExpression node) + { + Found = Holds(node.Value, _depth); + + return node; + } + } + + /// + /// True for a method that hands back what it was given, or reads it: LINQ's operators, EF Core's + /// EF.Property and query operators, a context's Set, which names a query root, and a + /// context's own method returning a query, which EF Core translates only as a function the model maps. + /// + private static bool Reads(MethodInfo method) + { + Type? declaring = method.DeclaringType; + + return declaring == typeof(Queryable) + || declaring == typeof(Enumerable) + || declaring == typeof(EF) + || declaring == typeof(EntityFrameworkQueryableExtensions) + || (declaring == typeof(DbContext) && method.Name == nameof(DbContext.Set)) + || (declaring is not null && typeof(DbContext).IsAssignableFrom(declaring) + && typeof(IQueryable).IsAssignableFrom(method.ReturnType)); + } + + /// True for a type the compiler generates for an anonymous object. + private static bool IsAnonymous(Type type) => + type.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), inherit: false) + && type.Name.Contains("AnonymousType", StringComparison.Ordinal); + + /// + /// True for a call EF Core evaluates before it translates the query: one that reads no parameter of the + /// lambdas around it and hands back a query or an expression, which the query then holds. A query the + /// tree already holds inline, over a root EF Core put there, is read where it stands. + /// + private static bool Evaluable(MethodCallExpression call) + { + if (!typeof(IQueryable).IsAssignableFrom(call.Type) && !typeof(Expression).IsAssignableFrom(call.Type)) + { + return false; + } + + ParameterFinder parameters = new(); + + parameters.Visit(call); + + return !parameters.Found; + } + + /// Finds a parameter of a lambda outside the expression it is given, or a query root. + private sealed class ParameterFinder : ExpressionVisitor + { + private readonly HashSet _declared = new(); + + internal bool Found { get; private set; } + + public override Expression? Visit(Expression? node) => Found ? node : base.Visit(node); + + protected override Expression VisitLambda(Expression node) + { + _declared.UnionWith(node.Parameters); + + return base.VisitLambda(node); + } + + protected override Expression VisitParameter(ParameterExpression node) + { + Found |= !_declared.Contains(node); + + return node; + } + + protected override Expression VisitExtension(Expression node) + { + Found = true; + + return node; + } + } + + /// Runs a call EF Core would evaluate itself, and reads what it returns. + private static bool TryEvaluate(MethodCallExpression call, out object? value) + { + try + { + value = Expression.Lambda>(Expression.Convert(call, typeof(object))) + .Compile(preferInterpretation: true)(); + + return true; + } + catch (Exception) + { + // Whatever it throws, EF Core would throw when it ran the query; read here, it only means the + // call counts as handing the rows anything. + value = null; + + return false; + } + } + + /// True when a value of the type holds only values: a string, a number, a date, or a collection of them. + private static bool IsValue(Type type) => CacheReflection.IsSimpleType(AttributePolicyProvider.Peeled(type)); + + /// True for a member read off something the lambda captured, or off nothing, rather than off its parameters. + private static bool IsCaptured(MemberExpression node) + { + Expression? target = node.Expression; + + while (target is MemberExpression member) + { + target = member.Expression; + } + + return target is null or ConstantExpression; + } + + /// Reads a captured member's value as EF Core does before it runs the query. + private static bool TryEvaluate(MemberExpression node, out object? value) + { + value = null; + + object? target = null; + + if (node.Expression is ConstantExpression constant) + { + target = constant.Value; + } + else if (node.Expression is MemberExpression member && !TryEvaluate(member, out target)) + { + return false; + } + + try + { + value = node.Member switch + { + FieldInfo field => field.GetValue(target), + PropertyInfo property => property.GetValue(target), + _ => null + }; + + return node.Member is FieldInfo or PropertyInfo; + } + catch (Exception exception) when (exception is TargetException or TargetInvocationException + or ArgumentException or InvalidOperationException + or NotSupportedException or MemberAccessException) + { + return false; + } + } + + /// + /// True when a captured value can hand a row an object its query does not load: an object in memory, + /// or a query with its own includes or projections, or one over rows in memory. + /// + private static bool Holds(object? value, int depth) + { + if (value is null || value is DbContext || IsValue(value.GetType())) + { + return false; + } + + if (depth > MaxCapturedDepth) + { + return true; + } + + // An expression, a specification say, becomes part of the query, and is read as though written there. + if (value is Expression expression) + { + ForeignFinder finder = new(depth + 1); + + finder.Visit(expression); + + return finder.Found; + } + + if (value is not IQueryable query) + { + return true; + } + + if (query.Provider is EnumerableQuery) + { + return !IsValue(query.ElementType); + } + + IncludeFinder includes = new(); + + includes.Visit(query.Expression); + + return includes.Found || Builds(query.Expression, depth + 1); + } + + /// Finds an Include or a ThenInclude anywhere in a query. + private sealed class IncludeFinder : ExpressionVisitor + { + internal bool Found { get; private set; } + + public override Expression? Visit(Expression? node) => Found ? node : base.Visit(node); + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (node.Method.DeclaringType == typeof(EntityFrameworkQueryableExtensions) + && node.Method.Name is nameof(EntityFrameworkQueryableExtensions.Include) + or nameof(EntityFrameworkQueryableExtensions.ThenInclude)) + { + Found = true; + + return node; + } + + return base.VisitMethodCall(node); + } + } + + /// Finds the first query root that names an entity type. + private sealed class RootFinder : ExpressionVisitor + { + public IEntityType? EntityType { get; private set; } + + public override Expression? Visit(Expression? node) => EntityType is null ? base.Visit(node) : node; + + protected override Expression VisitExtension(Expression node) + { + // Enumerated rather than looked up by name, which throws when a derived root hides the + // property with a new one. + PropertyInfo? property = RootEntityTypeProperties.GetOrAdd( + node.GetType(), + type => type + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(candidate => candidate.Name == "EntityType" + && candidate.GetIndexParameters().Length == 0)); + + if (property?.GetValue(node) is IEntityType entityType) + { + EntityType = entityType; + + return node; + } + + return base.VisitExtension(node); + } + } +} diff --git a/DynamicWhere.ex/Source/SegmentComposer.cs b/DynamicWhere.ex/Source/SegmentComposer.cs index f40b015..f3b69a4 100644 --- a/DynamicWhere.ex/Source/SegmentComposer.cs +++ b/DynamicWhere.ex/Source/SegmentComposer.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Linq.Expressions; using System.Reflection; using DynamicWhere.ex.Classes.Core; @@ -40,9 +39,6 @@ namespace DynamicWhere.ex.Source; /// internal static class SegmentComposer { - /// The EntityType property of each query-root expression type, or null for a type with none. - private static readonly ConcurrentDictionary RootEntityTypeProperties = new(); - /// /// Combines validated condition sets, already in Sort order, into one query. /// @@ -205,50 +201,8 @@ private static Expression SameValue(Expression left, Expression right) /// The primary key EF Core maps for , or null when the query is not an EF Core /// query or the type has none. /// - /// - /// Read from the model behind the query's root. The root expression's type differs between EF Core - /// versions — QueryRootExpression in 6, EntityQueryRootExpression from 7 — and both - /// carry the entity type in a property named EntityType, so the property is found by name. - /// Any root reaches the model, and the model is asked for itself, which - /// also answers for a derived type queried through OfType. - /// - private static IReadOnlyList? PrimaryKey(IQueryable query) - { - RootFinder finder = new(); - - finder.Visit(query.Expression); - - return finder.EntityType?.Model.FindEntityType(typeof(T))?.FindPrimaryKey()?.Properties; - } - - /// Finds the first query root that names an entity type. - private sealed class RootFinder : ExpressionVisitor - { - public IEntityType? EntityType { get; private set; } - - public override Expression? Visit(Expression? node) => EntityType is null ? base.Visit(node) : node; - - protected override Expression VisitExtension(Expression node) - { - // Enumerated rather than looked up by name, which throws when a derived root hides the - // property with a new one. - PropertyInfo? property = RootEntityTypeProperties.GetOrAdd( - node.GetType(), - type => type - .GetProperties(BindingFlags.Public | BindingFlags.Instance) - .FirstOrDefault(candidate => candidate.Name == "EntityType" - && candidate.GetIndexParameters().Length == 0)); - - if (property?.GetValue(node) is IEntityType entityType) - { - EntityType = entityType; - - return node; - } - - return base.VisitExtension(node); - } - } + private static IReadOnlyList? PrimaryKey(IQueryable query) => + QueryRoot.EntityType(query.Expression, typeof(T))?.FindPrimaryKey()?.Properties; /// Replaces one lambda parameter with another expression. private sealed class ParameterSwap : ExpressionVisitor diff --git a/OfficialWebsite/app/docs/ai/page.tsx b/OfficialWebsite/app/docs/ai/page.tsx index ccb5646..fbc40d9 100644 --- a/OfficialWebsite/app/docs/ai/page.tsx +++ b/OfficialWebsite/app/docs/ai/page.tsx @@ -84,7 +84,7 @@ DynamicWhere.ex code. It is the complete API surface.`} m spelling of Sumation.
  • - All twenty-one extension methods with their real + All twenty-eight extension methods with their real signatures, what each one validates, and which have no synchronous or in-memory form. Plus the generated predicate for every operator, value coercion per DataType, and how field paths resolve. @@ -136,8 +136,8 @@ DynamicWhere.ex code. It is the complete API surface.`} - The reference is generated against version 3.1.0 from the source, and - checked by running the library, so it states behaviour — including the + The reference is written against version 3.2.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, the file is the one to trust. For the reasoning behind a rule, the human diff --git a/OfficialWebsite/app/docs/breaking-changes/page.tsx b/OfficialWebsite/app/docs/breaking-changes/page.tsx index 743efb8..c01874a 100644 --- a/OfficialWebsite/app/docs/breaking-changes/page.tsx +++ b/OfficialWebsite/app/docs/breaking-changes/page.tsx @@ -17,10 +17,29 @@ export default function Page() {

    Breaking Changes & Known Limitations

    DynamicWhere.ex is intentionally opinionated about how queries are shaped. - The twenty-four points below cover constraints, surprises, and corner cases — + The twenty-nine 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 25 to 29 changed in 3.2.0, and each is + visible to code written for 3.1.0. A guarded query that sends no{" "} + Selects synthesizes its projection whenever a denied value + can reach the result, and the projection keeps what the source carries: + the assigned members of a projected row, and an entity's columns, + owned and complex members, while an entity's navigations and the + objects of a row in memory are left out (point 25).{" "} + Selects naming a member is gated against every denial + beneath it, and a narrowing that cannot be built is refused + (point 26). A declared default order reaches a projection that + builds T and assigns every field the default names + (point 27). Every async terminal gains overloads that take a{" "} + CancellationToken, so ToListAsync(filter, default){" "} + no longer compiles, and the async dynamic Filter and the + async Summary read through EF Core's asynchronous + operators (point 28). And a type in an application namespace that + starts with System is policed (point 29). + Eleven behaviours changed in 3.1.0. Each one fixes a defect, and each one is visible to a caller that depended on the old shape. Date @@ -594,10 +613,13 @@ export default function Page() { A member carrying [DwNoSelect] makes the policy layer - synthesize a projection for a query that sent none, so a typed guarded - query on a type with no parameterless constructor raises the same code — - even though the caller never asked for a Select. The dynamic - terminals project through SelectDynamic and are not affected. + synthesize a projection for a query that sent none — since 3.2.0 + whatever the member holds, and beneath another member when its value + can reach the result (point 25) — so a typed guarded query on a + type with no parameterless constructor raises + the same code, even though the caller never asked for a{" "} + Select. The dynamic terminals project through{" "} + SelectDynamic and are not affected.

    18. A Guarded Query Requires a Prepared Context

    @@ -1077,6 +1099,353 @@ PolicyTrace? recorded = guarded.LastTrace; // recorded whatever the setting say }`}
    +

    25. A Guarded Query That Sends No Selects Keeps What the Source Carries

    +

    + A request with no Selects returns whole rows, denied fields + included, so a guarded query synthesizes a projection when a denied + field could reach the result. 3.2.0 changed when it + does so and what the projection keeps. The rules are on{" "} + Policy configuration. +

    +
      +
    • + When. A field denied at the top of T{" "} + asks for it whatever the field holds. A field denied beneath a member + asks for it when its value can reach the result: on an entity, beneath + a column, an owned or complex member, or a navigation the query loads + through an Include, an automatic include or a lazy + loader; on a projected row, beneath a member the initializer assigns; + in memory, beneath any member. A chain that reaches its rows through + a navigation, a SelectMany, a Join or a{" "} + GroupBy counts every navigation as loaded when it also has + an include, or a lambda that builds an object, gets one from an + application's method, or captures a query with its own include or + projection. A field a subtype of{" "} + T declares, one a subtype of a member's type + declares, and one beneath a member EF Core does not map, count too. A + member that can hold an object of any type asks for nothing on its + own. It does so in both tiers, typed and dynamic, for a{" "} + Filter and a Segment. Until 3.2.0 only a + simple field denied at the top of T asked for one. A + denial beneath a navigation nothing loads never leaves the database, so + an entity whose only denials sit there is read exactly as in 3.1.0. +
    • +
    • + What. The allowed members, which replace the allowed + scalars. A row a projection builds keeps the members its initializer + assigns. An entity keeps its mapped columns, converted and JSON ones + included except a converted one that can hold an object of any type, + its owned and complex members, and every collection of + simple values such as byte[] or{" "} + List<string>. Rows in memory keep their values. A + member holding an object is kept whole when nothing it can hold is + denied, narrowed to the allowed fields where the core's narrowing + translates, and otherwise left out whole. +
    • +
    + + With every denied field beneath a member and none at the top of{" "} + T, nothing was synthesized, and the whole row came back + with the denied value in it: in a list or nested object of a row + projected before ApplyPolicy, in a row held in memory, and + in an entity's included, automatically included, lazily loaded or + owned member — typed and dynamic, in both tiers, for a{" "} + Filter and a Segment. + + + An include named from the root and reached through{" "} + {`Select(o => o.Customer)`}, SelectMany or{" "} + Join, a projection behind another Select, an + initializer after a constructor with arguments, and a lazy loader the + constructor takes and keeps in a field or a property of any name each + loaded a denied value the gate read as unloaded, and so did an injected{" "} + DbContext or EF Core 7's asynchronous loader delegate, + and a reshaping lambda that got its row from an application's + method or from a captured query or object. An application's own + collection class hid its own denied members, and a guarded query + through a provider wrapping EF Core's, such as LinqKit's{" "} + AsExpandable, ran tracking, so the context filled in + navigations it already held and a masked value became a pending change. + A field a subtype declares — a derived entity's, or a + subclass's held by a base-typed member — was not read at all, nor + was a [DwDenied] on an override, on a public member hidden + with new or on an interface member's implementation, + and + under a{" "} + "*" deny a path the walk never asked about was + allowed. Each came back. + + + When a type the model derives from T, or a loaded subclass + of a row in memory, declares a denied field, the rows are projected to{" "} + T, so a derived type's allowed fields are dropped too, + and a member declared as a base type is narrowed to it. Over an abstract{" "} + T the typed terminals fail with{" "} + SelectTypeMustHaveParameterlessConstructor; the dynamic ones + return its members. Query the derived type,{" "} + {`OfType()`}, to keep its fields. Rows in memory + can be any loaded subtype, so there the rows are projected whenever one + declares a denied field. A [DwDenied] on an override, on a + public member a subtype hides with new, or on an interface + member's implementation, through a variant instantiation too, + denies the base path for every row, in every clause. + + + A field denied at the top of T whose own type is not a + simple value — a byte array, a list, an owned object, a JSON column — + synthesized no projection either, so with nothing else denied the whole + row came back with it. + + + In 3.1.0, as soon as any field was denied, every nested object and list + of a row projected before ApplyPolicy came back null or + empty, and so did an entity's columns holding an object, its owned + and complex members and its collections of simple values. They are + returned now, whole or narrowed, except a converted value that can hold + an object of any type, which the policy cannot see into. A member that cannot be narrowed is + left out whole, and the trace records a Dropped decision + whose reason starts left out whole. + + + Once a projection is needed it leaves out an entity's navigations, + included ones too, since projecting one would load it: under{" "} + Convenience name the navigation in Selects to + get it narrowed, and under Strict name its allowed fields. + It leaves out + the objects a row in memory holds, since a kept object is the + caller's own and a transform would change it in place, and a value + EF Core does not map, which EF Core could compute only by reading the + whole entity, the denied columns included. A member with no setter and + a member named with one of the parser's words are left out too. A + typed query projects into T, so T needs a + public parameterless constructor for it, as it already did + (point 1). + + + A forced scope declared on a list's element type asks for no + projection on its own. It filters the rows that hold the list, never its + elements, so Selects naming the list returns every element, + those the scope excludes included, as in every release. A projection + needed for another reason leaves such a list out whole. Scope the + elements where the row is built. + + +

    26. Selects Naming a Member Is Gated Against Every Denial Beneath It

    +

    + When Selects names a navigation with a denied field beneath + it, the Convenience tier replaces the entry with the + allowed fields beneath it, and the Strict tier refuses it. + Since 3.2.0 the gate finds every denial beneath the + member, and refuses, with FieldDeniedForSelect, a narrowing + it cannot build as gated. See{" "} + A navigation named in Selects. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Selects namesUntil 3.1.0Since 3.2.0
    A navigation whose key, Id, is denied + The convenience tier narrowed the key away, and the core's + typed projection, which adds the key of every nested node it + builds, put it back. + + Refused in both tiers, as naming a sibling of the key already + was. +
    A navigation named through another, Main.Lead, when Main.Id is deniedKept, and the projection added Main's key.Refused: the key of every node the path passes through is gated.
    + A member typed as a collection the core does not unwrap —{" "} + IReadOnlyList<T>,{" "} + IReadOnlyCollection<T>,{" "} + Collection<T> or an application's own — + with a denied field beneath it + + Every field beneath it came back, the denied ones included, in both + tiers: the projection gate read collections through a narrower list + than the attribute walker, and found nothing beneath the member. + + The gate reads collections the way the walker does. The strict + tier refuses the denied field, and the convenience tier's + narrowing, which the core cannot project, is refused too. +
    + A member that carries a field denied where no path reaches it: + deeper than four segments, inside a framework generic such as{" "} + Dictionary<string, T>, declared by a subtype of + its type, in an entity navigation's owned chain or converted + column, or, under a "*" deny, on a path the + walk never asks about + Returned, the denied field included. + Refused under Strict. Under Convenience{" "} + narrowed where the core can narrow it, which builds the declared + type, and refused where it cannot. +
    A member with a denied property that has no setter beneath it, or a rule on a path reached through a cycleNot found, so the member came back with it.Found: the gate reads the providers' rules as well as the walk.
    +

    + A member that cannot be narrowed at all — a column, a complex property + or a member stored as JSON, a member of a row in memory, or one a + projection builds some way the core cannot narrow — is refused in both + tiers when something beneath it is denied. +

    + + A request that ran on 3.1.0 can now be refused. Under the{" "} + Convenience tier 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 "*". A request that sends no{" "} + Selects is not refused for such a member: its synthesized + projection narrows the member or leaves it out whole (point 25). + + + A member typed object, a framework interface or a + collection that is not generic, such as IEnumerable,{" "} + ArrayList or an application's own, is opaque to the + policy: it never asks for a projection, a synthesized projection over a + projected row or rows in memory leaves it out, and naming it returns + whatever it holds. A framework generic holding a policed type, such as{" "} + Dictionary<string, LineDto>, has no paths beneath it: + naming it is refused in both tiers where the core cannot narrow it, + narrowed away under Convenience beneath a navigation, and a + synthesized projection leaves it out. Hold such values in a list of the + policed type instead. + + +

    27. DefaultOrder Reaches a Projection That Builds T

    +

    + In 3.1.0 a Select anywhere in the chain kept a guarded query + in its own order, because a default applied after a projection could + name a field the projection left out, which EF Core cannot translate. + Since 3.2.0 only the outermost Select{" "} + counts, because it makes the rows the default orders. When it builds{" "} + T in an object initializer and assigns every field the{" "} + [DwEntity(DefaultOrder)]{" "} + names a column, at every level of a nested path, the default applies. A + column is a member the EF Core model maps on the entity the{" "} + Select reads, read directly, through reference navigations + or through EF.Property; in memory any assigned field is + one. A value the projection computes, by any method or operator, even + one EF Core could translate, a member the model does not map, a + constructor with arguments, a default field the initializer does not + assign, or a nested path through anything but an initializer still + leaves the query in its own order: ordering by it could fail where the + unguarded query ran. +

    + {`[DwEntity(DefaultOrder = "CreatedAt desc, Id")] +public class TicketRow +{ + public int Id { get; set; } + public DateTime CreatedAt { get; set; } + public string Title { get; set; } = string.Empty; +} + +// 3.1.0: unordered. 3.2.0: ordered by CreatedAt desc, Id. +var rows = db.Tickets + .Select(t => new TicketRow { Id = t.Id, CreatedAt = t.CreatedAt, Title = t.Title }) + .ApplyPolicy(caller) + .ToList(new Filter());`} +
      +
    • + A projection composed on the guarded handle — the guarded{" "} + Select, or a guarded Filter whose{" "} + Selects is set — keeps the rest of the chain unordered, + even when it keeps every default field. So{" "} + {`guarded.Select(["Id", "Title"]).Page(page)`} pages as it + did in 3.0.0, unordered. +
    • +
    • + A Filter composed on the handle that sent orders gets no + default later in the chain, even when the policy dropped every one of + them, as a composed Order already did not. +
    • +
    + + A guarded query over such a projection that sends no orders now comes + back in the declared order, where it used to come back in the + database's. Paging through it is stable if the default ends with a + unique field. + + +

    28. Every Async Terminal Takes a CancellationToken

    +

    + Since 3.2.0 every asynchronous terminal, guarded and + unguarded, has overloads that take a CancellationToken:{" "} + ToListAsync{" "} + and{" "} + ToListAsyncDynamic{" "} + with a Filter,{" "} + ToListAsync{" "} + with a Summary, and{" "} + ToListAsync{" "} + with a Segment. The token reaches the count and the read. The + overloads sit beside the 3.1 signatures, which are unchanged, so code + compiled against 3.1 still binds. That brings the extension methods to + 28. A reflection lookup by name alone finds more overloads than it did, + and where it found one — ToListAsyncDynamic, on the + extension class and on the guarded handle — it now finds several, so{" "} + Type.GetMethod given only the name throws{" "} + AmbiguousMatchException; pass the parameter types. +

    + + default fits both bool getQueryString and the + new CancellationToken overload, so the call is ambiguous + (CS0121). So are ToListAsyncDynamic(filter, default) and{" "} + ToListAsync(summary, default), on a query and on the guarded + handle alike. Write false, a token, or a named argument. + {`await query.ToListAsync(filter, default); // CS0121 since 3.2.0 +await query.ToListAsync(filter, false); // as 3.1 read it +await query.ToListAsync(filter, cancellationToken); // the new overload`} + + + ToListAsyncDynamic and the async Summary read + through EF Core's ToListAsync instead of Dynamic + LINQ's ToDynamicListAsync, which had no token to pass + on, and the async Summary counts through{" "} + CountAsync where it counted synchronously. So on an EF Core + query a canceled token now reaches the database. The rows and the counts + are the same. A provider that is not EF Core's keeps Dynamic + LINQ's read, on the calling thread. + + +

    29. A Type in a Namespace That Starts with System Is Policed

    +

    + The attribute walker does not descend into the framework's own + types, which carry no policy attributes. Until 3.2.0{" "} + it took any namespace whose name started with System for + the framework's, so an application namespace such as{" "} + SystemsCorp.Payroll or SystemX.Domain got no + policy beneath its types. A [DwDenied] field on such a type, + reached through a member, was returned, filterable and sortable. Only{" "} + System and the namespaces beneath it are the + framework's now. +

    + + A guarded request that filtered on, sorted by or selected such a field + ran on 3.1.0. It is now refused or dropped, as for any denied field. + +

    See also

    • @@ -1110,12 +1479,22 @@ PolicyTrace? recorded = guarded.LastTrace; // recorded whatever the setting say
    • Policy configuration →{" "} - context for points 21, 22, 23 and 24: the caps, the trace on a result, - and what a strict refusal carries. + context for points 21 to 26: the caps, the trace on a result, what a + strict refusal carries, and what a denied field does to a projection.
    • Security & k-anonymity →{" "} - why the strict tier hides which fields exist (point 23). + why the strict tier hides which fields exist (point 23), and{" "} + the denials the gate could not see{" "} + (points 25, 26 and 29). +
    • +
    • + Default order →{" "} + context for point 27. +
    • +
    • + Materialization →{" "} + context for point 28: every async terminal and its overloads.
    diff --git a/OfficialWebsite/app/docs/errors/page.tsx b/OfficialWebsite/app/docs/errors/page.tsx index 11d0cd6..7ac4933 100644 --- a/OfficialWebsite/app/docs/errors/page.tsx +++ b/OfficialWebsite/app/docs/errors/page.tsx @@ -340,8 +340,10 @@ export default function Page() { T the projection cannot construct — a positional record, most often. The type's name is on{" "} Subject, not in the message. Also reached by a typed - guarded query whose policy denies a field for Select, - since the deny synthesizes a projection + guarded query whose policy denies a field for Select{" "} + — since 3.2.0 whatever the field holds, and beneath a member where + its value can reach the result — because the deny synthesizes a + projection diff --git a/OfficialWebsite/app/docs/extensions/page.tsx b/OfficialWebsite/app/docs/extensions/page.tsx index 813f08d..253a81f 100644 --- a/OfficialWebsite/app/docs/extensions/page.tsx +++ b/OfficialWebsite/app/docs/extensions/page.tsx @@ -165,13 +165,16 @@ export default function Page() {

    Materialization

    Execute the composed query and return a paginated result — typed, - dynamic, summary, or segment. The two Filter async - terminals count with EF Core's CountAsync and read - with ToListAsync / ToDynamicListAsync;{" "} - ToListAsync(Summary) counts synchronously and only awaits - the read, and ToListAsync(Segment) combines its sets into - one query and then counts and reads it exactly as a{" "} - Filter does. + dynamic, summary, or segment. The async terminals count with EF + Core's CountAsync and read with EF Core's{" "} + ToListAsync. Since 3.2.0 that includes the dynamic{" "} + Filter's read and the Summary's count + and read: the count used to run synchronously, and the reads through + Dynamic LINQ, with no token to pass on.{" "} + ToListAsync(Summary) on a provider that is not EF + Core's keeps its synchronous count and Dynamic LINQ's read, and{" "} + ToListAsync(Segment) combines its sets into one query and + then counts and reads it exactly as a Filter does.

    @@ -198,6 +201,16 @@ export default function Page() { + + + + + + + @@ -212,6 +225,16 @@ export default function Page() { + + + + + + + @@ -226,6 +249,16 @@ export default function Page() { + + + + + + + @@ -233,9 +266,34 @@ export default function Page() { + + + + + + +
    Yes Docs
    ToListAsync (Filter, token) + .ToListAsync<T>(Filter, CancellationToken) /{" "} + .ToListAsync<T>(Filter, bool getQueryString, CancellationToken) + Task<FilterResult<T>>YesDocs
    ToListDynamic (Filter) .ToListDynamic<T>(Filter, bool getQueryString = false)Yes Docs
    ToListAsyncDynamic (Filter, token) + .ToListAsyncDynamic<T>(Filter, CancellationToken) /{" "} + .ToListAsyncDynamic<T>(Filter, bool getQueryString, CancellationToken) + Task<FilterResult<dynamic>>YesDocs
    ToList (Summary) .ToList<T>(Summary, bool getQueryString = false)Yes Docs
    ToListAsync (Summary, token) + .ToListAsync<T>(Summary, CancellationToken) /{" "} + .ToListAsync<T>(Summary, bool getQueryString, CancellationToken) + Task<SummaryResult>YesDocs
    ToListAsync (Segment) .ToListAsync<T>(Segment segment)Yes (only) Docs
    ToListAsync (Segment, token).ToListAsync<T>(Segment, CancellationToken)Task<SegmentResult<T>>Yes (only)Docs
    + + ToListAsync and ToListAsyncDynamic with a{" "} + Filter, ToListAsync with a{" "} + Summary and ToListAsync with a{" "} + Segment each have overloads that take a{" "} + CancellationToken. The token reaches the count and the + read. The overloads sit beside the 3.1 signatures, which are unchanged, + so code compiled against 3.1 still binds.{" "} + ToListAsync(filter, default) no longer compiles, because{" "} + default fits both getQueryString and the + token; neither do ToListAsyncDynamic(filter, default) and{" "} + ToListAsync(summary, default). Write false, a + token, or a named argument. And a reflection lookup of{" "} + ToListAsyncDynamic by name alone now finds three methods + where it found one: pass the parameter types to{" "} + Type.GetMethod. + + Segment operations are async-only. There is no synchronous ToList<T>(Segment) variant. The set @@ -254,7 +312,9 @@ export default function Page() { for unit tests and in-process pipelines. Nothing else has one: there is no ToListDynamic(Summary), and no async or composable method works on an IEnumerable<T> source. Counting - those three, the library ships 21 extension methods. + those three and the seven overloads that take a{" "} + CancellationToken, the library ships 28{" "} + extension methods.

    @@ -268,7 +328,7 @@ export default function Page() { - Every one of the 21 begins by asking the policy layer whether this type + Every one of the 28 begins by asking the policy layer whether this type may be queried at all. A type marked{" "} [DwEntity(RequirePolicy = true)] throws{" "} PolicyException with PolicyRequired when it is diff --git a/OfficialWebsite/app/docs/extensions/to-list-async-dynamic-filter/page.tsx b/OfficialWebsite/app/docs/extensions/to-list-async-dynamic-filter/page.tsx index 6596940..f8e595c 100644 --- a/OfficialWebsite/app/docs/extensions/to-list-async-dynamic-filter/page.tsx +++ b/OfficialWebsite/app/docs/extensions/to-list-async-dynamic-filter/page.tsx @@ -7,7 +7,7 @@ import Callout from "@/components/Callout"; export const metadata: Metadata = { title: ".ToListAsyncDynamic(Filter)", description: - "Async EF Core entry — materialize a Filter using SelectDynamic and ToDynamicListAsync, returning Task>.", + "Async EF Core entry — materialize a Filter using SelectDynamic, EF Core's CountAsync and ToListAsync, returning Task>, with overloads that take a CancellationToken.", alternates: { canonical: "https://doc.dynamicwhere.com/docs/extensions/to-list-async-dynamic-filter/" }, }; @@ -20,8 +20,9 @@ export default function Page() { .ToListDynamic<T>(Filter) - . Uses EF Core's CountAsync() and{" "} - ToDynamicListAsync() under the hood. + . Counts with EF Core's CountAsync() and reads with EF + Core's own ToListAsync(). Since 3.2.0 two more overloads + take a CancellationToken, which reaches both.

    Signature

    @@ -29,6 +30,20 @@ export default function Page() { this IQueryable query, Filter filter, bool getQueryString = false) + where T : class + +// 3.2.0 +public static Task> ToListAsyncDynamic( + this IQueryable query, + Filter filter, + CancellationToken cancellationToken) + where T : class + +public static Task> ToListAsyncDynamic( + this IQueryable query, + Filter filter, + bool getQueryString, + CancellationToken cancellationToken) where T : class`} @@ -58,6 +73,15 @@ export default function Page() { QueryString + + + + + +
    cancellationTokenCancellationToken– + Cancels the count and the read. The overload without it passes{" "} + CancellationToken.None +
    @@ -65,14 +89,29 @@ export default function Page() {
    • Where applied on the typed query.
    • - CountAsync() on the typed query → TotalCount. + CountAsync(cancellationToken) on the typed query →{" "} + TotalCount.
    • Order applied on the typed query.
    • Page applied on the typed query.
    • SelectDynamic projection applied last.
    • -
    • ToDynamicListAsync() materializes the result.
    • +
    • + EF Core's ToListAsync(cancellationToken) materializes + the result, called for the query's element type: the class the + projection generates, or T when Selects is + null. +
    + + The read used to run Dynamic LINQ's{" "} + ToDynamicListAsync(), asynchronous as well but with no token + to pass on. On an EF Core query it now runs through EF Core's{" "} + ToListAsync(), so a canceled token reaches the database. The + rows are the same. Only the count needs an EF Core async provider; on + any other provider the read falls back to Dynamic LINQ's. + + Ordering and pagination are applied on the strongly-typed{" "} IQueryable<T> before the dynamic @@ -89,6 +128,32 @@ export default function Page() { nested dynamic objects and collections. +

    Cancellation

    +

    + The two overloads that take a CancellationToken are new in + 3.2.0. The token reaches the count and the read, so a canceled token + stops whichever of the two is running, and the call throws{" "} + OperationCanceledException. EF Core's{" "} + TaskCanceledException derives from it. The overload without + a token passes CancellationToken.None. +

    +

    + They are overloads, not an optional parameter added to the old + signature. The 3.1 signature is unchanged, so code compiled against 3.1 + still binds. The guarded handle that{" "} + ApplyPolicy{" "} + returns has the same overloads. +

    + + default fits both bool getQueryString and{" "} + CancellationToken, so the compiler reports the call as + ambiguous (CS0121). Write false, a token, or a named + argument. + + {`await db.Products.ToListAsyncDynamic(filter, default); // CS0121: ambiguous +await db.Products.ToListAsyncDynamic(filter, cancellationToken); // the token overload +await db.Products.ToListAsyncDynamic(filter, true, cancellationToken); // the SQL and a token`} +

    Returns

    Task<FilterResult<dynamic>>. diff --git a/OfficialWebsite/app/docs/extensions/to-list-async-filter/page.tsx b/OfficialWebsite/app/docs/extensions/to-list-async-filter/page.tsx index ac8eb3c..fd218cf 100644 --- a/OfficialWebsite/app/docs/extensions/to-list-async-filter/page.tsx +++ b/OfficialWebsite/app/docs/extensions/to-list-async-filter/page.tsx @@ -7,7 +7,7 @@ import Callout from "@/components/Callout"; export const metadata: Metadata = { title: ".ToListAsync(Filter)", description: - "Async EF Core entry point — materialize a Filter against an IQueryable using CountAsync and ToListAsync.", + "Async EF Core entry point — materialize a Filter against an IQueryable using CountAsync and ToListAsync, with overloads that take a CancellationToken.", alternates: { canonical: "https://doc.dynamicwhere.com/docs/extensions/to-list-async-filter/" }, }; @@ -21,7 +21,8 @@ export default function Page() { .ToList<T>(Filter) . Uses EF Core's CountAsync() and{" "} - ToListAsync() under the hood. + ToListAsync() under the hood. Since 3.2.0 two more + overloads take a CancellationToken, which reaches both.

    Signature

    @@ -29,6 +30,20 @@ export default function Page() { this IQueryable query, Filter filter, bool getQueryString = false) + where T : class + +// 3.2.0 +public static Task> ToListAsync( + this IQueryable query, + Filter filter, + CancellationToken cancellationToken) + where T : class + +public static Task> ToListAsync( + this IQueryable query, + Filter filter, + bool getQueryString, + CancellationToken cancellationToken) where T : class`} @@ -58,6 +73,15 @@ export default function Page() { FilterResult.QueryString + + + + + +
    cancellationTokenCancellationToken– + Cancels the count and the read. The overload without it passes{" "} + CancellationToken.None +
    @@ -65,12 +89,13 @@ export default function Page() {
    • Where applied on the typed query.
    • - CountAsync() on the typed query → TotalCount. + CountAsync(cancellationToken) on the typed query →{" "} + TotalCount.
    • Order applied on the typed query.
    • Page applied on the typed query.
    • Select projection applied last.
    • -
    • ToListAsync() materializes the result.
    • +
    • ToListAsync(cancellationToken) materializes the result.
    @@ -86,6 +111,33 @@ export default function Page() { database provider. +

    Cancellation

    +

    + The two overloads that take a CancellationToken are new in + 3.2.0. The token reaches the count and the read, so a canceled token + stops whichever of the two is running, and the call throws{" "} + OperationCanceledException. EF Core's{" "} + TaskCanceledException derives from it. The overload without + a token passes CancellationToken.None. +

    +

    + They are overloads, not an optional parameter added to the old + signature. The 3.1 signature is unchanged, so code compiled against 3.1 + still binds. The guarded handle that{" "} + ApplyPolicy{" "} + returns has the same overloads. +

    + + default fits both bool getQueryString and{" "} + CancellationToken, so the compiler reports the call as + ambiguous (CS0121). Write false, a token, or a named + argument. + + {`await db.Customers.ToListAsync(filter, default); // CS0121: ambiguous +await db.Customers.ToListAsync(filter, cancellationToken); // the token overload +await db.Customers.ToListAsync(filter, getQueryString: false); // no token +await db.Customers.ToListAsync(filter, true, cancellationToken); // the SQL and a token`} +

    Returns

    Task< @@ -115,6 +167,18 @@ Console.WriteLine(result.QueryString);`} return Results.Ok(result); });`} +

    + Cancel with the request. A minimal API binds a{" "} + CancellationToken parameter to{" "} + HttpContext.RequestAborted, so a client that disconnects + cancels the count or the read. +

    + {`app.MapPost("/customers/search", async (Filter filter, AppDbContext db, CancellationToken cancellationToken) => +{ + var result = await db.Customers.ToListAsync(filter, cancellationToken); + return Results.Ok(result); +});`} +

    See also

    • diff --git a/OfficialWebsite/app/docs/extensions/to-list-async-segment/page.tsx b/OfficialWebsite/app/docs/extensions/to-list-async-segment/page.tsx index 9c50828..9429240 100644 --- a/OfficialWebsite/app/docs/extensions/to-list-async-segment/page.tsx +++ b/OfficialWebsite/app/docs/extensions/to-list-async-segment/page.tsx @@ -7,7 +7,7 @@ import Callout from "@/components/Callout"; export const metadata: Metadata = { title: ".ToListAsync(Segment)", description: - "Async-only segment entry — combine the ConditionSets with Union / Intersect / Except into one query, then order, page and project it in the database like a filter.", + "Async-only segment entry — combine the ConditionSets with Union / Intersect / Except into one query, then order, page and project it in the database like a filter. An overload takes a CancellationToken.", alternates: { canonical: "https://doc.dynamicwhere.com/docs/extensions/to-list-async-segment/" }, }; @@ -38,6 +38,13 @@ export default function Page() { {`public static Task> ToListAsync( this IQueryable query, Segment segment) + where T : class + +// 3.2.0 +public static Task> ToListAsync( + this IQueryable query, + Segment segment, + CancellationToken cancellationToken) where T : class`} @@ -59,6 +66,14 @@ export default function Page() { Selects, Orders, Page + + + + +
      cancellationTokenCancellationToken + Cancels the count and the read. New in 3.2.0; the overload + without it passes CancellationToken.None +
      @@ -85,7 +100,18 @@ export default function Page() { Only the requested page is read, and an order field need not be selected.
    • +
    • + The overload that takes a CancellationToken passes it to + that count and that read. A canceled token stops whichever of the two + is running, and the call throws OperationCanceledException. +
    +

    + A segment takes no getQueryString, so{" "} + ToListAsync(segment, default) is not ambiguous: it binds + the token overload and passes CancellationToken.None. The + 3.1 signature is unchanged, so code compiled against 3.1 still binds. +

    Which rows belong is decided in the database, not by object reference, so a tracking query, an AsNoTracking() query and a query with{" "} diff --git a/OfficialWebsite/app/docs/extensions/to-list-async-summary/page.tsx b/OfficialWebsite/app/docs/extensions/to-list-async-summary/page.tsx index cf97876..3a309d3 100644 --- a/OfficialWebsite/app/docs/extensions/to-list-async-summary/page.tsx +++ b/OfficialWebsite/app/docs/extensions/to-list-async-summary/page.tsx @@ -7,7 +7,7 @@ import Callout from "@/components/Callout"; export const metadata: Metadata = { title: ".ToListAsync(Summary)", description: - "Async EF Core entry — materialize a Summary against an IQueryable and return Task.", + "Async EF Core entry — materialize a Summary against an IQueryable and return Task, with overloads that take a CancellationToken.", alternates: { canonical: "https://doc.dynamicwhere.com/docs/extensions/to-list-async-summary/" }, }; @@ -20,8 +20,10 @@ export default function Page() { .ToList<T>(Summary) - . The group count runs synchronously; only the data read is async, via - Dynamic LINQ's ToDynamicListAsync(). + . On an EF Core query it counts the groups with EF Core's{" "} + CountAsync() and reads them with EF Core's{" "} + ToListAsync(). Since 3.2.0 two more overloads take a{" "} + CancellationToken, which reaches both.

    Signature

    @@ -29,6 +31,20 @@ export default function Page() { this IQueryable query, Summary summary, bool getQueryString = false) + where T : class + +// 3.2.0 +public static Task ToListAsync( + this IQueryable query, + Summary summary, + CancellationToken cancellationToken) + where T : class + +public static Task ToListAsync( + this IQueryable query, + Summary summary, + bool getQueryString, + CancellationToken cancellationToken) where T : class`} @@ -58,6 +74,15 @@ export default function Page() { SummaryResult.QueryString + + + + + +
    cancellationTokenCancellationToken– + Cancels the count and the read. The overload without it passes{" "} + CancellationToken.None +
    @@ -67,13 +92,32 @@ export default function Page() {
  • Group applied — produces grouped dynamic intermediate.
  • Having applied — fields must reference aggregate aliases.
  • - Count() on the grouped query → TotalCount. - This count is synchronous, not awaited. + The grouped query is counted → TotalCount. On an EF Core + query this is EF Core's CountAsync(cancellationToken).
  • Order applied on the grouped query.
  • Page applied on the grouped query.
  • -
  • Async materialization as List<dynamic>.
  • +
  • + Async materialization as List<dynamic>. On an EF + Core query this is EF Core's{" "} + ToListAsync(cancellationToken). +
  • +

    + A source whose provider is not EF Core's — rows in memory through{" "} + AsQueryable(), for one — keeps the reads it had in 3.1: a + synchronous Count(), then Dynamic LINQ's{" "} + ToDynamicListAsync(), which reads on the calling thread. A + token that is already canceled still stops it before the count. +

    + + + Until 3.2.0 the group count ran synchronously, and the read went through + Dynamic LINQ's ToDynamicListAsync(), which had no token + to pass on, on every provider. On an EF Core query both now run through + EF Core's asynchronous operators, so a canceled token reaches the + database. The count and the rows are the same. + Dotted GroupBy fields like Category.Name{" "} @@ -82,6 +126,32 @@ export default function Page() { use the dotted form — the library handles alias mapping internally. +

    Cancellation

    +

    + The two overloads that take a CancellationToken are new in + 3.2.0. The token reaches the count and the read. On an EF Core query a + canceled token stops whichever of the two is running, and the call + throws OperationCanceledException. EF Core's{" "} + TaskCanceledException derives from it. The overload without + a token passes CancellationToken.None. +

    +

    + They are overloads, not an optional parameter added to the old + signature. The 3.1 signature is unchanged, so code compiled against 3.1 + still binds. The guarded handle that{" "} + ApplyPolicy{" "} + returns has the same overloads. +

    + + default fits both bool getQueryString and{" "} + CancellationToken, so the compiler reports the call as + ambiguous (CS0121). Write false, a token, or a named + argument. + + {`await db.Products.ToListAsync(summary, default); // CS0121: ambiguous +await db.Products.ToListAsync(summary, cancellationToken); // the token overload +await db.Products.ToListAsync(summary, true, cancellationToken); // the SQL and a token`} +

    Returns

    Task< diff --git a/OfficialWebsite/app/docs/installation/page.tsx b/OfficialWebsite/app/docs/installation/page.tsx index aafdb54..88a27ce 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.1.0`} + {`dotnet add package DynamicWhere.ex --version 3.2.0`}

    Package Manager (Visual Studio)

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

    PackageReference (csproj)

    {` - + `}

    Dependencies

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

    - Version 3.1.0. Target framework .NET 6+. + Version 3.2.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.1.0`} + {`dotnet add package DynamicWhere.ex --version 3.2.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 e087a85..bb4f0f5 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.1.0`} + {`dotnet add package DynamicWhere.ex.Policies.AspNetCore --version 3.2.0`} {`app.MapDwPolicyAdmin(options => { options.RoutePrefix = "/dw-policies"; // the default; mount it anywhere @@ -169,6 +169,20 @@ export default function Page() { nothing is audited, so an operator checking a rule does not fill the audit trail with reads that never happened.

    +

    + A simulation has no source, so it reads the type as a source it cannot + see into. That shows in a clause that sends no Selects: + every denial beneath a member counts, and the projection it shows keeps + only the members that hold a value, a collection of values included. A + guarded query keeps what its own source carries — over a projected row, + the objects its initializer assigns; over an entity, its columns, owned + and complex members, asking only about the denials whose value it + loads; over rows in memory, values only. So the simulated clause can + list fewer members than the query returns, and can show a projection + an entity query does not need. PolicySimulator reads a + type the same way. See{" "} + A request that sends no Selects. +

    From a ClaimsPrincipal

    {`var caller = await httpContext.GetPolicyContextAsync(claimsOptions); diff --git a/OfficialWebsite/app/docs/policies/attributes/page.tsx b/OfficialWebsite/app/docs/policies/attributes/page.tsx index 15e1b2c..0365614 100644 --- a/OfficialWebsite/app/docs/policies/attributes/page.tsx +++ b/OfficialWebsite/app/docs/policies/attributes/page.tsx @@ -72,7 +72,8 @@ public class Ticket and ToListAsyncDynamic with a Filter, to{" "} ToListAsync with a Segment, to the composable{" "} Filter and FilterDynamic, and to the composable{" "} - Page on a source nothing has ordered or projected. + Page on a source nothing has ordered and whose projection + hides no field the default names.
  • It never applies outside the guarded handle. A core method on a plain{" "} @@ -89,7 +90,8 @@ public class Ticket {`db.Tickets.OrderBy(t => t.Title).ApplyPolicy(caller)`} — or an Order was composed on the guarded handle first, as in{" "} {`guarded.Order(order).Page(page)`} — even when the policy - dropped every order that call sent. An in-memory + dropped every order that call sent. Since 3.2.0 a Filter{" "} + composed on the handle with orders counts the same way. An in-memory sequence sorted before ApplyPolicy is not recognised as ordered, because it reaches the policy as a query with no{" "} OrderBy in it, so it takes the default; send that order with @@ -98,14 +100,62 @@ public class Ticket Order.
  • - A projected query takes no default. A Select anywhere in the - chain — the guarded Select, as in{" "} - {`guarded.Select(fields).Page(page)`}, or a projection made - before ApplyPolicy — leaves the query in its own order: a - default applied after a projection can name a field the projection left - out, which EF Core cannot translate. + A projection made before ApplyPolicy takes the default only + when it cannot hide a field the default names. Only the outermost{" "} + Select of the chain counts, because it makes the rows the + default orders. Since 3.2.0 it hides nothing when it builds{" "} + T itself in an object initializer and assigns every field + the default names a column, at every level of a nested path:{" "} + "Owner.Name" needs{" "} + {`Owner = new OwnerRow { Name = … }`}. On EF Core a column + is a member the model maps on the entity the Select reads, + read directly (t.Code), through reference navigations + (t.Owner.Name) or through EF.Property, a + shadow property included; in memory any assigned field is one. EF Core + then translates the order. A projection over an anonymous or other + intermediate row reads no entity, so it takes no default. +
  • +
  • + Any other projection leaves the query in its own order, as every + projection did in 3.1.0: a constructor with arguments, a default field + the initializer does not assign, a nested path through anything but an + initializer, a member the model does not map, or a default field the + projection computes, by the application's own method{" "} + ({`Label = Decorate(r.Code)`}), a framework one such as{" "} + Regex.Replace or ToUpper, or an operator. EF + Core evaluates some of these on the client, where it can project the + value but cannot order by it, and which ones it translates depends on + the provider, so none is ordered by: a default must never be the reason + a query that ran unguarded fails. +
  • +
  • + A projection composed on the guarded handle takes no default, even when + it keeps every field the default names. The guarded{" "} + Select, as in{" "} + {`guarded.Select(fields).Page(page)`}, and a guarded{" "} + Filter whose Selects is set leave the rest of + the chain unordered. The default is for the rows the caller's + source makes.
  • + {`[DwEntity(RequirePolicy = true, DefaultOrder = "CreatedAt desc, Id")] +public class TicketRow +{ + public int Id { get; set; } + public DateTime CreatedAt { get; set; } + public string Title { get; set; } = string.Empty; +} + +// Takes the default: the initializer builds TicketRow and assigns CreatedAt and Id. +var ordered = db.Tickets + .Select(t => new TicketRow { Id = t.Id, CreatedAt = t.CreatedAt, Title = t.Title }) + .ApplyPolicy(caller); + +// Keeps its own order: CreatedAt is not assigned, so the default could name +// a member these rows do not carry. +var unordered = db.Tickets + .Select(t => new TicketRow { Id = t.Id, Title = t.Title }) + .ApplyPolicy(caller);`}

    The default is gated like any order. A field in it that this caller may not order by is left out — never refused, because the caller did not send it — @@ -176,6 +226,26 @@ public class Ticket // and cannot sweep for one it does not. [DwOperators(Allow = new[] { Operator.Equal, Operator.In })] public string EmployeeCode { get; set; }`} + + A request that sends no Selects would return the whole row. + So a field denied for Select — at the top of the type, or + beneath a member where its value can reach the result — makes a guarded + query project the allowed members instead. See{" "} + A request that sends no Selects. + + + An access-control attribute on another declaration of a member applies + to its path too: on the interface member a class or its subtype + implements with it, on an override of either accessor a subtype + declares, on a public member a subtype hides with new, and + on the implementation a type gives an interface member, explicit, + inherited or declared by an open generic class, through a variant + instantiation too. A row read through the base type or + the interface is still that subtype, so the denial holds for the path on + every row, in every clause. Until 3.2.0 only the declaration walked, and + the attributes above it, were read. The other attributes are still read + from the declaration walked only. +

    Injection

    diff --git a/OfficialWebsite/app/docs/policies/configuration/page.tsx b/OfficialWebsite/app/docs/policies/configuration/page.tsx index 4377193..8867c36 100644 --- a/OfficialWebsite/app/docs/policies/configuration/page.tsx +++ b/OfficialWebsite/app/docs/policies/configuration/page.tsx @@ -6,7 +6,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, 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.", + 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"], alternates: { canonical: "https://doc.dynamicwhere.com/docs/policies/configuration/" }, }; @@ -64,6 +64,400 @@ export default function Page() { IncludeTraceInResult is false.

    + +

    + A Selects entry can name a navigation, such as{" "} + "Lines", rather than the fields beneath it. With + nothing denied beneath it, the entry is kept as written. With a denied + field beneath it, the Convenience tier replaces the entry + with the allowed fields beneath it, and the Strict tier + refuses it. +

    +
    + + + + + + + + +
    Selects names a navigationConvenienceStrict
    with nothing denied beneath itKept whole, or narrowed around a transform that lands on a property with no setter (3.2.0)Kept whole, or narrowed the same way
    with a denied field beneath itReplaced by the allowed fields beneath itFieldDeniedForSelect
    whose key, Lines.Id, is denied (3.2.0)FieldDeniedForSelectFieldDeniedForSelect
    with a denied field beneath it, where the narrowing cannot be built (3.2.0)FieldDeniedForSelectFieldDeniedForSelect
    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 cannotFieldDeniedForSelect
    +
      +
    • + 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<T>,{" "} + IReadOnlyCollection<T>,{" "} + Collection<T> 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<T>,{" "} + IList<T>, ICollection<T>,{" "} + IEnumerable<T>, HashSet<T> or{" "} + ISet<T>, 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<string, T>, 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. +
    • +
    • + 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

    +

    + A request that sends no Selects returns whole rows, denied + fields included, because the core projects only when{" "} + Selects is set. So when a field denied for{" "} + Select could reach the result, a guarded query synthesizes + the projection itself. It does so in both tiers, typed and dynamic, for + a whole Filter and for a Segment. A clause + composed on its own, such as Where, Order or{" "} + Page, synthesizes nothing. +

    +

    + The projection keeps the allowed members: what an + unguarded call would return, less what the policy withholds. Before + 3.2.0 it kept the allowed scalars only, and only a simple field denied + at the top of the type asked for it — see{" "} + breaking changes. +

    + +

    When a projection is needed

    +
      +
    • + A field denied at the top of T always asks for one, + whatever it holds: a scalar, a blob, a list, an owned object or a JSON + column. +
    • +
    • + A field denied beneath a member asks for one when its value can reach + the result. On an entity, that is beneath a column, an owned or + complex member, or a navigation something loads: an{" "} + Include or ThenInclude on the query, an + automatic include, or a lazy loader — EF Core's proxies, an + injected ILazyLoader, a loader delegate or{" "} + ILazyLoader the constructor takes and keeps in a field or + any property, the asynchronous loader delegate of EF Core 7, or an + injected DbContext — which fills a navigation after the + query. On a row a projection builds, it is beneath a member the + initializer assigns; a constructor with arguments counts every member + as assigned, and an initializer after it still says what its own + bindings hold. On a row in memory, it is beneath any member. A rule may + spell the path in any letter case. +
    • +
    • + Every navigation counts as loaded where the library cannot read which + the query loads: an Include in a form it cannot read, one + off the query's own chain, and a chain that reaches its rows + through anything but the root's own rows —{" "} + {`Select(o => o.Customer)`}, a SelectMany, a{" "} + Join, a GroupBy — when it also has an + include, which EF Core applies from the root to the entities it + reaches, or when one of its lambdas hands its rows an object: one it + builds, as a projection behind an identity Select, or an + object built inside an anonymous row or a conditional, does; one an + application's method returns from what the lambda gives it; or + one it captured, another query with its own include or projection, or + an object in memory. A call that reads nothing of the lambda's and + returns a query or an expression (a specification, a repository's + query, FromSql) is evaluated as EF Core evaluates it, and + what it returns is read; a context's own query function is a query + root; an anonymous object that only carries what the rows hold, range + variables or a composite key, builds nothing; and what only feeds a + predicate or a key is a value. Such a chain with none of these is read + from the model. +
    • +
    • + A denial beneath a navigation nothing loads never leaves the database, + so it asks for no projection. An entity whose only denials sit beneath + such navigations is read as it was in 3.1.0. +
    • +
    • + A member whose value can hold a field denied for Select{" "} + that no path names — deeper than the walker's four segments, + inside a framework generic such as{" "} + Dictionary<string, T>, or declared by a subtype of + its type — asks for one too, read as for a named member, so on an + entity only what loads counts. Under a{" "} + "*" deny, so does a member whose value can hold a + path the walk never asks about and the policy does not name. +
    • +
    • + A member that can hold an object of any type — one typed{" "} + object, a framework interface such as{" "} + IComparable, or a collection that is not generic, such + as IEnumerable, ArrayList or an + application's own — asks for nothing on its own: the policy cannot + see into it whether or not a projection is built. +
    • +
    • + A row can be a subtype of T. On an entity, a member a type + the model derives from T declares, and what loads beneath + it, counts as one of T's own would; on a row in memory, + a member any loaded subtype declares does; on a row a projection + builds, a member the type its initializer constructs declares below{" "} + T. The projection builds T and leaves them + out, recorded with a reason starting{" "} + left out: a type derived. A subtype is any type loaded + outside the framework's assemblies that derives from the type or + implements it, an open generic one and an application's subclass + of Exception included; a rule on a path through a + subtype's member counts as a rule on the declared type's own + path does. +
    • +
    • + A member EF Core does not map counts as loaded: its getter can hand out + a mapped field or a private navigation, so its type is read whole. +
    • +
    • + The denials beneath a member come from the providers' rules as + well as from walking the type, so a denied property with no setter, a + rule on a path reached through a cycle, and a rule deeper than the + walk all count. +
    • +
    • + A forced scope beneath a member asks for no projection on its own. It + filters the rows that hold the member, as it always has. When a + projection is needed anyway, the member is left out whole. +
    • +
    + +

    What it keeps

    +

    + A member holding a value — a simple type, or a collection of one such as{" "} + byte[], string[] or{" "} + List<string> — is kept when it is allowed and the + source carries it. A member holding an object, or a list of them, is + kept whole, narrowed or left out whole, as below, and only where the + source carries it: +

    + + + + + + + + + + + + + + + + + + + +
    SourceValues keptObjects kept
    A projection that builds its rows before ApplyPolicy: the outermost Select constructs the row, in an object initializer or with a constructor, as in {`db.Roles.Select(r => new RoleRow { … })`}Every member the initializer assigns; every member when a constructor with arguments builds the row, with or without an initializer after itThe same
    An entity query, or a Select that hands back an entity, as in {`db.Orders.Select(o => o.Customer)`}Every member EF Core mapsIts columns, converted and JSON ones included, its owned members and, on EF Core 8 or later, its complex properties, read from the EF Core model; a converted value that can hold an object of any type is left out
    Rows in memory, as in roles.ApplyPolicy(caller)Every memberNone
    +

    + A value EF Core does not map is left out: computing it would make EF + Core read the whole entity, the denied columns included, and it holds + only its initial value anyway. A source the library cannot read — no EF + Core model, and neither a projection it can see into nor rows in memory + — keeps values only, as in 3.1.0, and every denial beneath a member + counts. +

    + +

    Whole, narrowed or left out whole

    +

    A member holding an object that the source carries is:

    +
      +
    • + kept whole when nothing beneath it is denied, nothing + its value can hold is denied (its subtypes included), it cannot hold an + object of any type (asked of a projected row, a row in memory, and an + entity's column a value converter hands back, directly or inside a + complex property: what EF Core materializes itself never holds one), + under a{" "} + "*" deny every path beneath it the walk skips is + one the policy names, no forced scope is beneath it, and no transform + beneath it lands on a property with no setter; +
    • +
    • + 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. +
    • +
    + {`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 +left out whole: it is a complex property, which EF Core cannot narrow +left out whole: it is stored as JSON, which EF Core cannot narrow +left out whole: the projection builds it in a way the core cannot narrow +left out whole: the projection would add its key 'Contents.Id', which is denied +left out whole: the core cannot project 'Contents.Code' +left out whole: the core cannot build 'IContact', which it narrows into +left out whole: nothing beneath it may be selected +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. +

    + +

    Never kept

    +
      +
    • + An entity's navigation, included or not: projecting it would load + it. So once a denial needs a projection, an included or automatically + included navigation is not returned, and the trace records it as{" "} + Dropped with a reason starting left out:. + Under Convenience, name it in Selects to get + it narrowed; under Strict, name its allowed fields. +
    • +
    • + An object held by a row in memory: a kept object is the caller's + own, and a transform beneath it would change it in place. The + projection's rows are new and hold no member of an object type, + so the source objects are left as they were. +
    • +
    • + A member with no setter, and a member named with one of the + expression parser's own words. +
    • +
    + +

    Other rules

    +
      +
    • + A narrowed reference that is null in the source comes back as an empty + object, as it does for a caller's own dotted{" "} + Selects. +
    • +
    • + A narrowed member carries every allowed field beneath it. An entity + reached beneath it therefore has its own navigations projected, and so + loaded, whether or not the source included them, exactly as when{" "} + Selects names the member. +
    • +
    • + Each denied field whose value can reach the result, at the top or + beneath, is recorded as Dropped on Select. +
    • +
    • + It never throws for a denied field, in either tier. It throws{" "} + AllSelectsDenied only when no field is left. When nothing + asks for a projection, Selects stays null and the query is + the one an unguarded call runs. +
    • +
    • + A typed query projects into T, so T needs a + public parameterless constructor, or the query fails with{" "} + SelectTypeMustHaveParameterlessConstructor. The dynamic + terminals do not need one. +
    • +
    • + A dry run synthesizes nothing. It records the denials and returns the + rows whole. +
    • +
    • + A simulation has no source, so it reads T as a source it + cannot see into: every denial beneath a member counts, and the + projection it shows keeps only members holding a value — see{" "} + Simulate. +
    • +
    + + A member typed object, a framework interface or a + collection that is not generic, such as IEnumerable,{" "} + ArrayList or an application's own, is opaque to the + policy. It never asks for a projection; when one is needed anyway, a + projected row, a row in memory, and an entity's converted column + leave it out, and an entity's other columns keep it; + and naming it returns whatever it holds. A converter returning an + application type through a column typed object is opaque + the same way, so type the member as what it holds.{" "} + BitArray and the framework's string collections hold + values. An application's own collection class, generic or not, + still has its own members read, and a collection of values stays a + value unless one of them is denied. Two members sharing a name are left + out when either holds a denial. A framework generic holding a + policed type, such as Dictionary<string, LineDto>, has + no paths beneath it: naming it is refused in both tiers where the core + cannot narrow it, narrowed away under Convenience beneath a + navigation, and a synthesized projection leaves it out. Hold such values + in a list of the policed type instead. + + + A query over the root of a hierarchy whose derived type declares a + denied field comes back as root-type rows, the derived types' + allowed fields dropped too. Over an abstract root the typed terminals + fail with SelectTypeMustHaveParameterlessConstructor, and + the dynamic ones return the root's members. Query the derived type,{" "} + {`OfType()`}, to keep its fields. Rows in memory + can be any loaded subtype, and the policy does not look at the rows: + when a subtype declares a denied field, they are projected and their + objects left out, even if no row is that subtype. Under a{" "} + "*" deny, a member is kept whole only when every + path beneath it the walk skips is one the policy names. + + + A forced scope declared on a list's element type filters the rows + that hold the list, never its elements. Selects naming the + list returns every element, those the scope excludes included, as in + every release; a synthesized projection leaves such a list out. Scope + the elements where the row is built. + +

    The trace on a result

    Every guarded query records a PolicyTrace: what the policy diff --git a/OfficialWebsite/app/docs/policies/page.tsx b/OfficialWebsite/app/docs/policies/page.tsx index ef2ef98..eb6b86e 100644 --- a/OfficialWebsite/app/docs/policies/page.tsx +++ b/OfficialWebsite/app/docs/policies/page.tsx @@ -95,6 +95,45 @@ public class Employee public JsonDocument? WorkSchedule { get; set; } }`} +

    The guarded handle

    +

    + ApplyPolicy returns a PolicyQueryable<T>. + Its terminals mirror the core's: ToList,{" "} + ToListAsync, ToListDynamic and{" "} + ToListAsyncDynamic with a Filter,{" "} + ToList and ToListAsync with a{" "} + Summary, and ToListAsync with a{" "} + Segment. Each one sanitizes the request, runs it, and + transforms the rows. +

    +

    + Since 3.2.0 every asynchronous terminal on the handle also has + overloads that take a CancellationToken, as the{" "} + core's do. The + policy is applied first, so a refusal is thrown whatever the token + says. The token then reaches the count and the read. The overloads sit + beside the 3.1 signatures, which are unchanged. +

    + {`// PolicyQueryable: new in 3.2.0, beside the overloads without a token +Task> ToListAsync(Filter filter, CancellationToken cancellationToken) +Task> ToListAsync(Filter filter, bool getQueryString, CancellationToken cancellationToken) +Task> ToListAsyncDynamic(Filter filter, CancellationToken cancellationToken) +Task> ToListAsyncDynamic(Filter filter, bool getQueryString, CancellationToken cancellationToken) +Task ToListAsync(Summary summary, CancellationToken cancellationToken) +Task ToListAsync(Summary summary, bool getQueryString, CancellationToken cancellationToken) +Task> ToListAsync(Segment segment, CancellationToken cancellationToken)`} + {`// In a minimal API, a CancellationToken parameter is the request's own: +// a client that disconnects cancels the count or the read. +var result = await db.Employees.ApplyPolicy(caller).ToListAsync(filter, cancellationToken);`} + + default fits both bool getQueryString and{" "} + CancellationToken, on the handle as on the core, so the + call is ambiguous. The same goes for{" "} + ToListAsyncDynamic(filter, default) and{" "} + ToListAsync(summary, default). Write false, a + token, or a named argument. + +

    Six features, per field

    Every decision is made for one field and one feature:{" "} diff --git a/OfficialWebsite/app/docs/policies/providers/page.tsx b/OfficialWebsite/app/docs/policies/providers/page.tsx index ba2f1d9..1cdead2 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.1.0`} + {`dotnet add package DynamicWhere.ex.Policies.Redis --version 3.2.0`} {`var redis = await ConnectionMultiplexer.ConnectAsync(connectionString); var store = new RedisPolicyStore(redis); @@ -37,7 +37,7 @@ DwPolicy.Configure(options, provider);`}

    Entity Framework Core

    - {`dotnet add package DynamicWhere.ex.Policies.EntityFrameworkCore --version 3.1.0`} + {`dotnet add package DynamicWhere.ex.Policies.EntityFrameworkCore --version 3.2.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 ef0580a..40fbf3d 100644 --- a/OfficialWebsite/app/docs/policies/security/page.tsx +++ b/OfficialWebsite/app/docs/policies/security/page.tsx @@ -6,7 +6,7 @@ import Callout from "@/components/Callout"; export const metadata: Metadata = { title: "Security & k-anonymity — the eight inference channels", - description: "How DynamicWhere.ex closes the disclosure channels no per-field rule closes on its own: set-operation reconstruction, singleton-group aggregates, cardinality probes, sort-and-page binary search, SQL leakage, and schema probing through refusals.", + description: "How DynamicWhere.ex closes the disclosure channels no per-field rule closes on its own — set-operation reconstruction, singleton-group aggregates, cardinality probes, sort-and-page binary search, SQL leakage, and schema probing through refusals — and the denials the gate could not see until 3.2.0.", keywords: ["k-anonymity", "MinGroupSize", "inference attack", "data disclosure", "aggregate disclosure", "EF Core security"], alternates: { canonical: "https://doc.dynamicwhere.com/docs/policies/security/" }, }; @@ -18,8 +18,9 @@ export default function Page() {

    Denying a field is easy. The hard part is the set of ways a caller can learn a value without reading it. Six such channels follow, then - two bypasses that are not channels; each has a test that reproduces the - attack and goes red if the control is removed. + two bypasses that are not channels, then the requests that, until + 3.2.0, carried out a denied value the gate could not see; each has a + test that reproduces the attack and goes red if the control is removed.

    @@ -198,6 +199,172 @@ true order. Add [DwNoOrder] unless that is intended.`}
    +

    Denials the gate could not see

    +

    + A denied field often sits on a type the query reaches through a member: + a secret on each line of an order, a code inside a nested object. The + denial holds on every path that reaches it, and it has to hold whether + or not the caller names the member. Until 3.2.0 each request below + carried a denied value out. All are closed. +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    AttackControl
    Send no Selects, on a type whose only denied fields sit beneath a member + A guarded query synthesizes a projection whenever the denied value + can reach the result, and narrows the member around it or leaves + the member out. Only a simple field denied at the top of{" "} + T used to synthesize one, so the whole row came back + with the denied value in it: in a list or nested object of a row + projected before ApplyPolicy, in a row held in memory, + and in an entity's included, automatically included, lazily + loaded or owned member — in both tiers. A denial beneath a + navigation nothing loads never leaves the database, so it asks for + nothing. See{" "} + A request that sends no Selects. +
    Send no Selects, on a type whose denied field holds no simple value: a blob, a list, an owned object, a JSON column + A field denied at the top of T asks for the projection + whatever it holds. Such a field used to be passed over, so with + nothing else denied the whole row came back with it. +
    Name a navigation whose key, Id, is denied + Refused with FieldDeniedForSelect in both tiers. The + core's typed projection adds the key of every nested node it + builds, so the convenience tier used to narrow the key away and + get it back. A navigation named through another,{" "} + Main.Lead, now gates the key of Main as + well, which the projection adds. +
    Name a member typed IReadOnlyList<T>, or another collection the core does not unwrap, with a denied field beneath it + Refused with FieldDeniedForSelect in both tiers. The + projection gate now reads collections the way the attribute walker + does. It used to read them through a narrower list, found nothing + beneath such a member, and returned every field, the denied ones + included, in both tiers. +
    Name a member that carries a denied field no path reaches: deeper than four segments, inside a framework generic such as Dictionary<string, T>, or, on an entity's navigation, in its owned chain or a converted column + Refused under Strict. Under Convenience it + is narrowed where the core can narrow it and refused where it + cannot. What the member carries is read from the source — from the + EF Core model for an entity, so only what loads counts. The gate + also reads the rules themselves, so a denied property with no + setter and a rule on a path reached through a cycle are found + beneath a named member too. +
    Include a navigation from the root, then reach the rows through it — {`Select(o => o.Customer)`}, SelectMany, Join — or hide a projection behind another Select + Every navigation counts as loaded on such a chain, since EF Core + still applies includes named from the root to the entities it + reaches, and the library cannot read which. The includes used to be + read against the wrong root, so the denied value beneath them was + returned. +
    Let a lazy loader fill a navigation after the query: a loader delegate or ILazyLoader the constructor takes, kept in a field or a property of any name + Counts as loading every navigation, as EF Core's proxies and an + injected ILazyLoader property already did. The model + keeps no record of such a loader, so the navigation it filled came + back with the denied value. +
    Declare the denied field on a subtype — a derived entity, a subclass, an interface's implementation — and read it through the base type: a query over the hierarchy's root, or a member declared as the base type + The subtypes are read too: the types the EF Core model derives for + an entity, and for a projected or in-memory row every loaded + subtype, an open generic one and an application's subclass of a + framework class such as Exception included. Such rows + are projected to T and such members narrowed to the + declared type; a named one is refused under Strict. A + projection constructing a subtype of T is read as it, + and a rule on a subtype's field through a base-typed member is + enforced. The policy used to read the declared type only. +
    Put the [DwDenied] on an override, on a public member a subtype hides with new, or on a class's implementation of an interface member, and read the member through the base type or the interface, a variant instantiation of it included + The denial applies to the path for every row, in every clause. The + attribute walker read the declaration it walked and the attributes + above it, never an override, a hiding member or an implementation + below, so the base path filtered, sorted, grouped and returned the + value. +
    Guard a query through a provider that wraps EF Core's, as LinqKit's AsExpandable or DelegateDecompiler's Decompile do + The query runs untracked. EF Core's AsNoTracking{" "} + hands such a query back unchanged, so it tracked: the context filled + in navigations it already held, the denied ones included, and a + masked value became a pending change the next{" "} + SaveChanges would write. The call now goes into the + query itself. +
    Under a "*" deny with exact allows, reach a path the walk never asks about: past four segments, around a cycle, a property with no setter + Such a path is denied, so a member holding one is narrowed, left out + or refused. It used to resolve as allowed, so the member was + returned whole, named or not. +
    Put the policed type in an application namespace that starts with System, such as SystemsCorp.Payroll + Policed. The walker read any namespace starting with{" "} + System as the framework's and put no policy + beneath its types, so a [DwDenied] field there was + returned, filterable and sortable. Only System and the + namespaces beneath it are the framework's now. +
    + + A member typed object, a framework interface or a + collection that is not generic, such as IEnumerable,{" "} + ArrayList or an application's own, is opaque to the + policy: it never asks for a projection, a synthesized projection over a + projected row or rows in memory leaves it out, and naming it returns + whatever it holds. A framework generic holding a policed type, such as{" "} + Dictionary<string, LineDto>, has no paths beneath it: + naming it is refused in both tiers where the core cannot narrow it, + narrowed away under Convenience beneath a navigation, and a + synthesized projection leaves it out. Hold such values in a list of the + policed type instead. A member EF Core does not map is read as its type, + since its getter can hand out what EF Core loaded; a getter that copies a + denied column into a type with no denial is the application's to + withhold. + + + A forced scope declared on a list's element type filters the rows + that hold the list, never its elements. Selects naming the + list returns every element, those the scope excludes included, as in + every release; a synthesized projection leaves such a list out. Scope + the elements where the row is built. + +

    Getting the posture right

    • Use DwTier.Strict unless you need getQueryString.
    • @@ -208,6 +375,8 @@ true order. Add [DwNoOrder] unless that is intended.`}
    • Run DwPolicy.ValidateModel(...) at startup and treat its warnings as a checklist.
    • Put [DwEntity(RequirePolicy = true)] on anything sensitive, so a DynamicWhere call that forgets ApplyPolicy fails loudly.
    • Prefer [DwOperators] over allowing free filtering on a protected field.
    • +
    • 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.
    • 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/page.tsx b/OfficialWebsite/app/page.tsx index 1395e6a..f508894 100644 --- a/OfficialWebsite/app/page.tsx +++ b/OfficialWebsite/app/page.tsx @@ -219,7 +219,7 @@ export default function HomePage() { Everything you need to query dynamically

    - Four packages. Seventeen extension methods. Three composable shapes (Filter, Segment, Summary), and a policy layer deciding who sees what. + Four packages. Twenty-eight extension methods. Three composable shapes (Filter, Segment, Summary), and a policy layer deciding who sees what.

    @@ -309,7 +309,7 @@ export default function HomePage() { {[ { title: "Installation", desc: "Add to your project in seconds.", href: "/docs/installation" }, { title: "Quick Start", desc: "A working filter in 30 lines.", href: "/docs/quick-start" }, - { title: "Extension Methods", desc: "All 17 methods, one place.", href: "/docs/extensions" }, + { title: "Extension Methods", desc: "All 28 methods, one place.", href: "/docs/extensions" }, { title: "JSON Cookbook", desc: "13 copy-pasteable examples.", href: "/docs/examples" }, { title: "Enums Reference", desc: "Every DataType & Operator.", href: "/docs/enums" }, { title: "Classes Reference", desc: "Condition → Filter → Result.", href: "/docs/classes" }, diff --git a/OfficialWebsite/lib/nav.ts b/OfficialWebsite/lib/nav.ts index f1c8223..077ead3 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.1.0", + version: "3.2.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 9843f75..f17a1de 100644 --- a/OfficialWebsite/package.json +++ b/OfficialWebsite/package.json @@ -1,6 +1,6 @@ { "name": "dynamicwhere-docs", - "version": "3.1.0", + "version": "3.2.0", "private": true, "scripts": { "dev": "next dev", diff --git a/OfficialWebsite/public/llms.txt b/OfficialWebsite/public/llms.txt index a7f3129..4149939 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.1.0 · targets net6.0 · runs on .NET 6, 7, 8, 9, 10 · EF Core 6+ · MIT +> Version 3.2.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.1.0 +dotnet add package DynamicWhere.ex --version 3.2.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 @@ -619,7 +619,8 @@ Path Where predicate generated ## 6. Extension methods `public static class Extension`, namespace `DynamicWhere.ex.Source` (the source file is spelled `Extention.cs`; -the class is `Extension`). 21 public methods, all generic with `where T : class`. None takes a `CancellationToken`. +the class is `Extension`). 28 public methods, all generic with `where T : class`. Every asynchronous one also has +overloads taking a `CancellationToken` (3.2.0). ``` On IQueryable query — composable (validates and builds, executes nothing) @@ -644,6 +645,15 @@ On IQueryable query — terminal ToListAsync(Summary summary, bool getQueryString = false) -> Task ToListAsync(Segment segment) -> Task> +On IQueryable query — terminal, cancellable (3.2.0) + ToListAsync(Filter filter, CancellationToken cancellationToken) -> Task> + ToListAsync(Filter filter, bool getQueryString, CancellationToken cancellationToken) -> Task> + ToListAsyncDynamic(Filter filter, CancellationToken cancellationToken) -> Task> + ToListAsyncDynamic(Filter filter, bool getQueryString, CancellationToken cancellationToken) -> Task> + ToListAsync(Summary summary, CancellationToken cancellationToken) -> Task + ToListAsync(Summary summary, bool getQueryString, CancellationToken cancellationToken) -> Task + ToListAsync(Segment segment, CancellationToken cancellationToken) -> Task> + On IEnumerable query — terminal, in memory ToList(Filter filter, bool getQueryString = false) -> FilterResult ToListDynamic(Filter filter, bool getQueryString = false) -> FilterResult @@ -651,7 +661,9 @@ On IEnumerable query — terminal, in memory ``` These do not exist: a synchronous `ToList(Segment)`, `getQueryString` on Segment, any async or composable -method on `IEnumerable`, names such as `ToListFilter` / `ToListAsyncSegment`, and a `new()` constraint. +method on `IEnumerable`, names such as `ToListFilter` / `ToListAsyncSegment`, a `new()` constraint, and a +`CancellationToken` parameter on the 3.1 signatures: the token overloads are separate methods, so code compiled +against 3.1 still binds. ### Rules for every method @@ -791,10 +803,17 @@ ToListDynamic, ToListAsyncDynamic Where -> COUNT(where-only query) -> build Or - Every call runs two queries: a count of the filtered set (ignoring Page) and the data query. - In the dynamic pair an invalid `Orders`, `Page` or `Selects` throws after the count has already run. -- `ToListAsync` uses EF Core `CountAsync` / `ToListAsync`; `ToListAsyncDynamic` uses EF Core `CountAsync` and - Dynamic LINQ `ToDynamicListAsync`. Both need an EF Core async provider: on a plain `list.AsQueryable()` they throw - `InvalidOperationException` ("The provider for the source 'IQueryable' doesn't implement 'IAsyncQueryProvider'…"). - Use the synchronous methods in memory. +- `ToListAsync` uses EF Core `CountAsync` / `ToListAsync`. `ToListAsyncDynamic` uses EF Core `CountAsync`, then + EF Core's `ToListAsync` over the query's element type: `T` when `Selects` is null, the projection's generated + class otherwise (3.2.0). It used to read through Dynamic LINQ's `ToDynamicListAsync`, asynchronous as well but + with no token to pass on. Both need an EF Core async provider for the count: on a plain + `list.AsQueryable()` they throw `InvalidOperationException` ("The provider for the source 'IQueryable' doesn't + implement 'IAsyncQueryProvider'…"). Use the synchronous methods in memory. +- The overloads taking a `CancellationToken` (3.2.0) pass it to the count and to the read, so a canceled token stops + whichever is running and the call throws `OperationCanceledException` (EF Core's `TaskCanceledException` derives + from it). The overloads without a token pass `CancellationToken.None`. + - `ToListAsync(filter, default)` does not compile: `default` fits both `bool getQueryString` and + `CancellationToken`. Write `false`, `CancellationToken.None` or a named argument. - `ToListDynamic` rows are `DynamicClass` objects when `Selects` is set, and T instances when it is null. ### ToListAsync(Segment) @@ -828,7 +847,8 @@ then exactly as ToListAsync(Filter): Order(Orders) -> Page(Page) -> Select(Selec a set with a null `ConditionGroup` → `ArgumentNullException`. Every clause is validated before the database is queried. - Empty or null `ConditionSets` runs `ToListAsync(new Filter { Selects, Orders, Page })`: there is nothing to combine. -- No synchronous version, no `getQueryString`; needs an EF Core async provider. Only `Except` on a type with a +- No synchronous version, no `getQueryString`; needs an EF Core async provider. An overload takes a + `CancellationToken` (3.2.0) and passes it to the count and the read. Only `Except` on a type with a primary key needs the provider to translate a correlated `EXISTS`; `Union` and `Intersect` there are plain `OR` and `AND`. @@ -865,7 +885,8 @@ GroupBy.Fields emitted key's own type (an enum key stays an enum), then one property per `AggregateBy.Alias`, in list order. Rows are `DynamicClass` objects; `SummaryResult.Data` is `List`. - A null group key is its own group (`{ "categoryName": null, … }`). -- `ToListAsync(Summary)` counts synchronously; only the data read is async. +- `ToListAsync(Summary)` counts and reads through EF Core's `CountAsync` and `ToListAsync` (3.2.0; it used to count + synchronously). On a provider that is not EF Core's, rows in memory among them, it counts and reads synchronously. ``` Aggregator Emitted per group Field accepted Result type @@ -1316,10 +1337,11 @@ Each of these compiles, passes validation, and returns something other than what 10. **Summary column names collide silently.** Two group fields ending in the same segment (`Name`, `Category.Name`) throw `InvalidOperationException` at run time, and an alias equal to a dot-stripped group field (`CategoryName` beside `Category.Name`) silently drops a column. -11. **In-memory sources behave differently from EF Core.** The async terminals throw; typed `Select` through a +11. **In-memory sources behave differently from EF Core.** The async Filter and Segment terminals throw; typed `Select` through a reference navigation throws; a null navigation inside a path throws `NullReferenceException`; ordering by a navigation throws; `getQueryString` returns a placeholder sentence instead of SQL. -12. **No method takes a `CancellationToken`.** `ToListAsync(filter, ct)` does not compile, and nothing can cancel a query. +12. **`ToListAsync(filter, default)` is ambiguous (3.2.0).** `default` fits both `getQueryString` and the new + `CancellationToken` overload, and the call does not compile. Write `false`, a token, or a named argument. 13. **`Selects: []` throws `MustHasFields`.** Omit the property (null) to return whole entities. 14. **A null inside `Values` is the empty string, not NULL.** Test for NULL with `IsNull` / `IsNotNull` and no values. 15. **`Page` does not order.** Paging without `Orders` returns whatever order the database chooses; always send an @@ -1728,6 +1750,15 @@ Terminal: sanitize, run, transform, set result.Policy (null under Strict unless ToListAsync(Summary summary, bool getQueryString = false) -> Task ToListAsync(Segment segment) -> Task> +Terminal, cancellable (3.2.0): the same, with the token passed to the count and the read + ToListAsync(Filter filter, CancellationToken cancellationToken) -> Task> + ToListAsync(Filter filter, bool getQueryString, CancellationToken cancellationToken) -> Task> + ToListAsyncDynamic(Filter filter, CancellationToken cancellationToken) -> Task> + ToListAsyncDynamic(Filter filter, bool getQueryString, CancellationToken cancellationToken) -> Task> + ToListAsync(Summary summary, CancellationToken cancellationToken) -> Task + ToListAsync(Summary summary, bool getQueryString, CancellationToken cancellationToken) -> Task + ToListAsync(Segment segment, CancellationToken cancellationToken) -> Task> + Composable: sanitize one clause, apply the type's forced predicates, return a new handle Select(List fields) -> PolicyQueryable Where(Condition condition) -> PolicyQueryable @@ -1756,8 +1787,11 @@ How this differs from the unguarded surface: Rules: - Every guarded query runs over `AsNoTracking()`. Returned EF Core entities are detached, so a masked value is never - saved back. An in-memory source has no such copy: without `Selects`, the rows returned are the source objects - themselves, transformed in place. + saved back. Through a provider that wraps EF Core's, as LinqKit's `AsExpandable` and DelegateDecompiler's + `Decompile` do, EF Core's extension would hand the query back still tracking, so the call is put into the query + itself (3.2.0). An in-memory source has no such copy: without `Selects` and with nothing denied, the rows returned are + the source objects themselves, transformed in place. When a projection is synthesized the rows are new, and they + hold no member of an object type, so the source objects are left as they were. - Chaining keeps decisions: each composable returns a new handle, and the terminal's trace includes the earlier links' decisions. - The terminal's trace is on `result.Policy` only when `DwPolicyOptions.IncludeTraceInResult` allows it: null follows the tier (off under `Strict`, on under `Convenience`), and `true` or `false` overrides it (3.1.0). `LastTrace` holds it whatever the setting. - `getQueryString: true` under `Strict` → `QueryStringDenied` (14). This is checked before sanitizing; dry run records it instead. @@ -1793,7 +1827,7 @@ public class Ticket { … } entry, such as the one a trailing comma leaves, is ignored, and a field named twice is used once. - Applied only under `ApplyPolicy`, when `Orders` is null or empty: `ToList`, `ToListAsync`, `ToListDynamic` and `ToListAsyncDynamic` with a `Filter`; `ToListAsync(Segment)`; the composable `Filter` and `FilterDynamic`; and the - composable `Page` on a source nothing has ordered or projected. + composable `Page` on a source nothing has ordered and whose projection hides no default field. - Never applied: - by an unguarded call. The core methods of section 6 on a plain `IQueryable` or `IEnumerable`, `Page` included, never read the attribute and order only as their caller asks, exactly as in 3.0; so does a DynamicWhere @@ -1801,12 +1835,24 @@ public class Ticket { … } - when the caller sends orders: the default is not appended as a tiebreak; - to a source already ordered, before it was guarded (`db.Tickets.OrderBy(t => t.Title).ApplyPolicy(ctx)`) or by a composed `Order` earlier in the chain — even one whose every order the policy dropped, because the caller - still sent orders. Only the query's expression is read, so a sequence sorted in memory before - `ApplyPolicy(IEnumerable)` does not count as ordered: send `Orders` for it; - - to a projected source: a `Select` anywhere in the query's chain, whether the guarded composable `Select` - or a projection before `ApplyPolicy`, leaves the query in its own order. A default applied after a projection could - name a field the projection left out, which EF Core cannot translate, so `guarded.Select(["Id", "Title"]).Page(page)` - pages as it did in 3.0.0, unordered; + still sent orders. A composed `Filter` that sent orders counts the same way (3.2.0). Only the query's expression + is read, so a sequence sorted in memory before `ApplyPolicy(IEnumerable)` does not count as ordered: send + `Orders` for it; + - to a source whose projection could hide a default field. Only the outermost `Select` of the chain counts, because + it makes the rows the default orders. Since 3.2.0 it hides nothing when it builds T itself in an object + initializer, `Select(t => new Row { Code = t.Code, … })`, and assigns every field the default names, at every + level of a nested path (`"Owner.Name"` needs `Owner = new OwnerRow { Name = … }`), a column. On EF Core a column + is a member the model maps on the entity the `Select` reads, read directly, through reference navigations + (`t.Owner.Name`) or through `EF.Property` (a shadow property included); the default then applies, because EF + Core translates an order by a column the projection assigned. In memory any assigned field is ordered by. A + value the projection computes, by a method (`Regex.Replace`, `ToUpper`, the application's own) or an operator + (`t.First + " " + t.Last`), a member the model does not map, a constructor with arguments, a default field the + initializer does not assign, or a nested path through anything but an initializer leaves the query in its own + order, as every projection did in 3.1.0: ordering by it could fail where the unguarded query ran; + - after a projection composed on the handle: the guarded `Select`, or a guarded `Filter` whose `Selects` is set, + leaves the rest of the chain unordered even when it keeps every default field, so + `guarded.Select(["Id", "Title"]).Page(page)` pages as it did in 3.0.0, unordered. The default is for the rows the + caller's source makes; - to a `Summary`, or by the composable `Where`, `Select` and `Order`. - A type that declares no `DefaultOrder` is never ordered by the library, guarded or not; only a caller's own orders apply. End a default with a unique field, such as the key, or rows sharing the leading values can still @@ -1836,7 +1882,7 @@ public class Ticket { … } ### [DwEntity(RequirePolicy = true)] enforcement -- All 21 public methods of `DynamicWhere.ex.Source.Extension` (the `IQueryable` and `IEnumerable` overloads) run the guard first. +- All 28 public methods of `DynamicWhere.ex.Source.Extension` (the `IQueryable` and `IEnumerable` overloads) run the guard first. - They throw `PolicyException` with `ErrorCode = PolicyRequired` (10) when `T` requires a policy and the call is not running inside a `PolicyQueryable` method. - Composables such as `Select` and `Where` throw when called, not when enumerated. - The exception carries: @@ -1932,6 +1978,16 @@ No named attribute refuses `Segment`: write `[DwDeny(PolicyFeature.Segment)]`. - `[DwDeny(PolicyFeature.None)]` refuses nothing, and nothing reports it. - `[DwNoSelect]` leaves the field filterable, sortable, groupable and aggregatable, so it can still be counted. +- One on another declaration of the member applies to the path too (3.2.0): on the interface member a class, or a + loaded subtype of it, implements with the member; on an override of either accessor a loaded subtype declares; on + a public member a loaded subtype hides with `new`; and on the implementation a loaded type gives an interface + member, explicit, inherited from a base class or declared by an open generic class, through that interface or an + instantiation variance lets stand for it (`IFeed` for a member typed `IFeed`, with `out T`). A row read through the base type or + the interface is still that subtype, and its member returns what the subtype's declaration returns, so the denial + holds for the path on every row, Where, Order, Group and Select alike. A member hidden with `new` counts because + whether it reads the member it hides cannot be told from outside, and a row serialized as its own type writes it + under the same name. Only the deny family is read this way; aliases, transforms and the other attributes are read + from the declaration walked. ### DwOperators @@ -2238,9 +2294,17 @@ HAVING through a key or alias of such a field or operator throw t ORDER BY a field denied for Order (also a summary key/alias) drop throw FieldDeniedForOrder SELECT a field denied for Select drop throw FieldDeniedForSelect SELECT a navigation with a denied field beneath it allowed leaves throw FieldDeniedForSelect +SELECT a navigation that cannot be narrowed around a denied + field: its key a.Id is denied, or a path through it is one + the core cannot project (3.2.0) throw throw FieldDeniedForSelect SELECT a.b when the key a.Id is denied throw throw FieldDeniedForSelect +SELECT a member that can carry a denied field no path names: + past four segments, in a framework generic, on a subtype, + or unasked under a "*" deny (3.2.0) allowed leaves, throw FieldDeniedForSelect + or throw where it + cannot be narrowed every requested SELECT dropped throw - AllSelectsDenied -no Selects while some field is denied for Select allowed scalars allowed scalars +no Selects while a denied field can reach the result (3.2.0) allowed members allowed members GROUP BY a denied field throw throw FieldDeniedForGroup AGGREGATE a denied field, or a transformed field lacking AllowAggregate on a stage throw throw FieldDeniedForAggregate @@ -2263,14 +2327,128 @@ DefaultOrder field denied for Segment, in a Segment (3.1.0) left out l - "drop" removes the entry and records `Dropped`. A Strict throw records `Denied`. - The guarded composable `Order(...)` and `Select(...)` drop and throw the same way. -- "allowed leaves": when `Selects` names a navigation, it is replaced by the allowed leaf paths beneath it. -- "allowed scalars" applies when `Selects` is null or empty and some field is denied for Select. It runs - for a whole-`Filter` terminal and for a Segment, not for a single-clause composable call. - - The projection becomes every allowed property that is readable, writable and not an indexer, and whose - type is simple: primitive, enum, `string`, `decimal`, `DateTime`, `DateOnly`, `TimeOnly`, - `DateTimeOffset`, `TimeSpan` or `Guid`. Navigations are left out. - - It never throws for the denied field. It throws `AllSelectsDenied` only when no field is left. - - With nothing denied, `Selects` stays null. +- "allowed leaves": when `Selects` names a navigation with a denied field beneath it, it is replaced by the allowed + leaf paths beneath it. A navigation with nothing denied beneath it is kept as written, unless a transform beneath it + lands on a property with no setter, which the outbound walk could not write back: it is then narrowed around that + property (3.2.0). + - The fields beneath are read the way the attribute walker reads them (3.2.0): through any collection type, so a + member typed `IReadOnlyList` or an application's own collection no longer hides its denied fields, and no + deeper than the walker's four segments. The providers' fragments are asked too, so a denied property with no + setter and a rule on a path reached through a cycle count (3.2.0). + - A named member can also carry a field denied for Select that no path names (3.2.0): deeper than four segments, + inside a framework generic such as `Dictionary`, or declared by a subtype of the member's type (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 that 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 (`Contact = new ContactRow { … }`), 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, where the paths never end, it always is. + - Such a member is refused with `FieldDeniedForSelect` under Strict. Under Convenience it is narrowed to the + allowed leaves where the core can narrow the path's first member, which builds the declared type and so drops a + subtype's fields; a path that names a framework generic itself narrows to nothing and is dropped. Where the core + 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 + 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. +- "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. + - A denial at the top of T always counts, whatever the member holds: a scalar, a blob, a list, an owned object, a + JSON column (3.2.0; 3.1.0 asked only about simple members, so a denied `byte[]` or owned member came back). + - A denial beneath a member counts when its value can reach the result. A rule may spell its path in any letter + case. + - On an entity: beneath a column, an owned or complex member, or a navigation something loads. That is an + `Include` or `ThenInclude` on the query, an automatic include, or a lazy loader: proxies, an injected + `ILazyLoader`, a loader delegate or `ILazyLoader` the constructor takes, kept in a field or any property, the + asynchronous loader delegate EF Core 7 added, or an injected `DbContext`, any of which fills a navigation + after the query. Every navigation counts as loaded when the library cannot read which the query loads: an + include in a form it cannot read, an include off the query's own chain (on a join's inner source, say), and a + chain that reaches its rows through anything but the root's own rows (`Select(o => o.Customer)`, a + `SelectMany`, a `Join`, a `GroupBy`) when it also has an include, which EF Core applies from the root to the + entities it reaches, or when one of its lambdas hands its rows an object: one it builds (a projection behind an + identity `Select`, or an object built inside an anonymous row or a conditional), which loads whatever it + assigns; one an application's method returns from what the lambda gives it; or one it captured, another query + with its own include or projection, or an object in memory. A call that reads nothing of the lambda's and returns + a query or an expression (a specification, a repository's query, `FromSql`, a context's `Set` through an + interface) is evaluated as EF Core evaluates it, and what it returns is read; a context's own query function is a + query root; an anonymous object that only carries what the rows hold (query-syntax range variables, a + composite key) builds nothing; and what only feeds a predicate or a key is a value and hands a row nothing. Such + a chain with none of these is read from the model. A denial beneath a navigation nothing loads never leaves the database + and needs no projection, + so a connected model is read as it was in 3.1.0. A member EF Core does not map counts as loaded: its getter can + hand out a mapped field or a private navigation, so its type is read whole. + - On a row a projection builds: beneath a member its initializer assigns. A constructor with arguments counts + every member as assigned; an initializer after it still says what its own bindings hold. + - On a row in memory: beneath any member. + - So does a member whose value can hold a field denied for Select that no path names (deeper than the walker's + four segments, inside a framework generic such as `Dictionary`, or declared by a subtype of its + type), read as for a named member above, so on an entity only what loads counts. Under a policy with a `"*"` + deny, so does a member whose value can hold a path the walk never asks about and the policy does not name. + - A member that can hold an object of any type, one typed `object`, a framework interface such as `IComparable`, + an unbound type parameter, or a collection that is not generic (`IEnumerable`, `ArrayList`, `Array`, an + application's own), asks for nothing on its own: the policy cannot see into it whether or not a projection is + built. An application's own such collection still has its own members read, as any type's are. + - A row can be a subtype of T. On an entity, each member a type the model derives from T declares, and what loads + beneath it, counts as one of T's own would; on a row in memory, each member any loaded subtype declares; on a + row a projection builds, each member the type its initializer constructs declares below T. The projection builds + T, so it leaves them all out, recorded as `Dropped` with a reason starting `left out: a type derived`. + - A subtype is any type loaded outside the framework's own assemblies that derives from the type or implements it: + an open generic one, `Tagged : Creature`, and an application's subclass of a framework class, an `Exception` + or a `Stream`, included. A rule on a path through a subtype's member (`Org.Swift`, where `Swift` is the bank's), + or through one of two members whose names differ only in letter case, counts beneath a member as a rule on the + declared type's own path does. + - The denials beneath a member come from the providers' fragments as well as from walking the type, so a denied + property with no setter, a rule on a path reached through a cycle, and a rule deeper than the walk all count. + - A forced scope beneath a member asks for no projection on its own: it filters the rows that hold the member, as it + always has. When a projection is needed anyway, the member is left out whole (below). + - The projection is what an unguarded call would return, less what the policy withholds: + - every allowed member holding a value, a simple type (primitive, enum, `string`, `decimal`, `DateTime`, + `DateOnly`, `TimeOnly`, `DateTimeOffset`, `TimeSpan`, `Guid`) or a collection of one (`byte[]`, `string[]`, + `List`), that the source carries: every one a projection assigns, every one of a row in memory, and + every one EF Core maps on an entity. A value EF Core does not map is left out: computing it would make EF Core + read the whole entity, the denied columns included, and it holds only its initial value anyway; + - every allowed member holding an object or a list of them that the source carries: a member a projection's + initializer assigns, and an entity's columns (converted or JSON), owned and complex members. It is kept whole + when nothing beneath it is denied, nothing its value can hold is denied, it cannot hold an object of any type + (asked of a projected row, a row in memory, and an entity's column a value converter hands back, directly or + inside a complex property: what EF Core materializes itself never holds one), under a + `"*"` deny every path beneath it the walk skips is one the policy names, no forced scope is beneath it, and no + transform beneath it lands on a property with no setter; + - otherwise it is narrowed to the allowed leaves beneath it, to four segments, as a caller naming it would get, + when 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 member's declared type, so a + subtype's fields are dropped. A leaf that can hold what the policy cannot name is left out; + - otherwise it is left out whole, recorded as `Dropped` on `Select` with a reason starting `left out whole`: a + scope forced beneath it; a column, complex property or JSON-stored member, which EF Core reads whole; one the + projection builds some other way, by a constructor with arguments, a conditional or an unassigned member; a + 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; + - 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 + member named with one of the parser's words. Each one the unguarded call would have returned, an included + navigation or an object in memory, is recorded as `Dropped` with a reason starting `left out:` (3.2.0). + - A `Select` handing back an entity, `Select(o => o.Customer)`, builds no row and is read as an entity query, with + every navigation counted as loaded when the query has an include (above). + - A narrowed reference that is null in the source comes back as an empty object, as it does for a caller's own + dotted `Selects`. + - A narrowed member carries every allowed leaf beneath it. An entity reached beneath it has its own navigations + projected and so loaded, which the source may not have included, exactly as `Selects` naming the member does. + - Each denied field whose value can reach the result, at the top or beneath, is recorded as `Dropped` on `Select`. + - It never throws for the denied field. It throws `AllSelectsDenied` only when no field is left. A typed terminal + projects into T, so a T with no public parameterless constructor, an abstract one among them, fails there with + `SelectTypeMustHaveParameterlessConstructor`; the dynamic terminals build their own class. + - With nothing counted, `Selects` stays null and the query is the one an unguarded call runs. - A path 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. @@ -2619,7 +2797,8 @@ Errors: - a field no query can order by, a path ending on a collection of entities: `"{Type}: DefaultOrder names '{field}', which no query can order by, so guarded queries skip it."` - a field the type's own attributes deny for ordering, unless every one of those denials is `Overridable` (then a - warning, below): + warning, below). The attributes include those of the member's other declarations, an interface member it + implements, a subtype's override and a public member a subtype hides with `new` (3.2.0): `"{Type}: DefaultOrder names '{field}', which its attributes deny for ordering, so every guarded query leaves it out."` - a stage that cannot be built: - `Round` or `Bucket` with `Step <= 0`, or `Truncate` with `Decimals < 0` @@ -2695,6 +2874,15 @@ What is recorded Dropped a field removed from a Convenience request (Select, Order); one per field Dropped a DefaultOrder field left out for this caller (Order), both tiers, and in a Segment a field denied for Segment too; Reason starts "left out of the default order" (3.1.0) + Dropped a denied field a synthesized projection leaves out (Select), at the top or beneath a member; + Reason names the policy's sources, such as "DwDeniedAttribute (sealed)" (3.2.0) + Dropped a member a synthesized projection leaves out whole (Select); Reason starts "left out whole" (3.2.0) + Dropped a member a type derived from T declares, which a synthesized projection building T leaves out + (Select); Reason starts "left out: a type derived" (3.2.0) + Dropped a member a synthesized projection cannot keep that the unguarded call would have returned, an + included navigation or an object of a row in memory (Select); Reason starts "left out:" (3.2.0) + Dropped a member a Convenience caller named that can hold a denied field no path names (Select); Reason + "it holds a field denied for Select where no path can name it" (3.2.0) Denied a refusal: Strict denial, cap, cost, query string ("*"), required filter, segment inference, MaxAuditEvents; the throw follows unless dry run. Under Strict a name that matches nothing is Denied under that name, Reason "names nothing on " (3.1.0) @@ -3081,6 +3269,10 @@ if (!sim.WouldRun) logger.LogInformation("{Code}", sim.Refusal!.ErrorCode); - A `PolicyException` becomes `Refusal`. Any other exception propagates, unwrapped even from the runtime overload. - Under `Strict`, outside dry run, a path that matches nothing is therefore a `Refusal` with the clause's `FieldDeniedFor*` code and `FieldPath` `"*"`, not a `LogicException` (3.1.0). The `Trace` names it. - A simulated `Filter` or `Segment` that sends no orders gets the type's `DefaultOrder` in `Clause.Orders`, less the fields the caller may not order by (3.1.0). +- A simulation has no source, so it reads T as a source it cannot see into (3.2.0): every denial beneath a member + counts, and with no `Selects` a synthesized `Clause.Selects` keeps only members holding a value. The guarded query + over a projected row keeps its assigned objects, one over an entity keeps its columns, owned and complex members + and asks only about denials whose value it loads, and one in memory keeps values only. - Runtime overload errors: - a value-type `entityType`, or a `TClause` other than `Filter`, `Summary` or `Segment` → `ArgumentException`; - null entity, context or options → `ArgumentNullException`. @@ -4131,8 +4323,10 @@ Read before generating policy attributes. ignore it. A caller who sends any `Orders` gets exactly those, so rows tied on them can still move between pages, and an `IQueryable` ordered before `ApplyPolicy`, or by a composed `Order` before `Page`, keeps its own order; a list sorted in memory before `ApplyPolicy` is not seen as ordered and gets the default, so send the - order with the filter. A projected query — a `Select` before `ApplyPolicy` or the guarded `Select` — takes no - default at all. A default field the caller may not order by is left out without an error. End every + order with the filter. A projected query takes the default only when its outermost `Select` builds T in an + object initializer assigning every default field a column (3.2.0); a computed value, even one EF Core could + translate, leaves it unordered, and the guarded `Select` composed afterwards never takes it. + A default field the caller may not order by is left out without an error. End every order meant for paging with a unique field, such as the key. 38. **A `[DwEntity]` on a derived type replaces its base type's.** The attribute allows one per type, and .NET inheritance hands a derived type its own when it declares one, so the base type's `RequirePolicy` and @@ -4580,6 +4774,104 @@ MemoryCalculationInput ### History ``` +3.2.0 A projected row keeps its members, denials the gate could not see are enforced, a default order that + reaches projected rows, and a CancellationToken on every async terminal. The security fixes refuse or + withhold what 3.1.0 returned; the bullets marked "Behaviour change" also change what a correct query + returns; and one call form stops compiling (the token bullet). + - Security fix and behaviour change. With no Selects, a field denied for Select only beneath a member, none at + the top of T, synthesized no projection, so the whole row came back with the denied value in it: in a list or + nested object of a row projected before ApplyPolicy, in a row in memory, and in an entity's included, + automatically included, lazily loaded or owned member. Such a denial now synthesizes the projection when its + value can reach the result, in both tiers, typed and dynamic, for a Filter and a Segment. On an entity that + means beneath a column, an owned or complex member, or a navigation the query loads; a denial beneath a + navigation nothing loads never leaves the database, and the entity is read as in 3.1.0. + - Security fix. Where the query hides what loads, every navigation now counts as loaded: an include named from + the root and re-rooted by Select(o => o.Customer), SelectMany or Join, which EF Core still applies, and a + projection behind another Select (an identity Select, a member of an anonymous row, a conditional). So does + a lazy loader the constructor takes, delegate or ILazyLoader, kept in a field or a property of any name, and + an initializer after a constructor with arguments counts every member as assigned. So do an injected + DbContext and EF Core 7's asynchronous loader delegate. So does a reshaped chain whose lambda hands its rows + an object an application's method returns from the row, or one it captured: another query with its own + include or projection, or an object in memory. Each returned the denied value. A reshaped chain with none of + these is still read from the model, so it is not projected for a denial beneath a navigation it does not + load. A specification, a repository's query, FromSql and a context's Set through an interface are evaluated + as EF Core evaluates them, a context's query function is a query root, and an anonymous object carrying + range variables or a composite key, or a value that only feeds a predicate or a key, builds nothing. + - Security fix. A guarded query through a provider that wraps EF Core's, LinqKit's AsExpandable or + DelegateDecompiler's Decompile, ran tracking: EF Core's AsNoTracking hands such a query back unchanged. The + rows' navigations were then filled from entities the context already tracked, the denied ones included, + and a masked value became a pending change the next SaveChanges would write. AsNoTracking now goes into the + query itself. + - Security fix and behaviour change. A member declared as a base type or an interface holds its subtypes, + whose denied fields the declared type never names. They are read now: the types the EF Core model derives, + for an entity, and every loaded subtype for a projected or in-memory row, an open generic one and an + application's subclass of a framework class included. A query over the root of a hierarchy whose derived + type declares a denied field, an included or named base-typed navigation, a base-typed member of a row, and + a projection constructing a subtype of T, all returned it. Such rows are projected to T and such members + narrowed to the declared type, which drops the subtype's fields, allowed ones too. Over an abstract T the + typed terminals then fail with SelectTypeMustHaveParameterlessConstructor, as for any T they cannot build; + the dynamic terminals return the root's allowed members. A rule on a subtype's field through a base-typed + member was dropped as naming nothing; it is enforced. + - Security fix. A deny-family attribute on an override, on a public member a subtype hides with new, on the + implementation of an interface member (through a variant instantiation too), or on the interface member a + class implements, was read only from its own declaration, so the base type's or the interface's path + filtered, sorted, grouped and returned the value. It applies to the path now. + - Security fix. Under a "*" deny with exact allows, a path the walk never asked about (past four segments, + around a cycle, with no setter, on a subtype) resolved as allowed, so a member holding one was returned + whole, named or not. Each such path is asked of the policy now; one it does not name is denied, and a + member holding one is refused, narrowed or projected as one with a denial beneath it is. + - Security fix. A field denied at the top of T whose type is not a simple value (a blob, a list, an owned + object or a JSON column, say) synthesized no projection either, so with nothing else denied it came back. + - Security fix. The attribute walker read any namespace starting with "System" as the framework's, so an + application's SystemsCorp.Payroll got no fragment beneath its types, and a [DwDenied] field there was + returned, filterable and sortable. Only System and the namespaces beneath it are the framework's now. + - Security fix. Under Convenience, Selects naming a navigation whose element key (Id) is denied narrowed the + key away, and the core's typed projection added it back. Such a narrowing is refused with + FieldDeniedForSelect in both tiers, as naming a sibling of the key already was. A navigation named through + another, Main.Lead, now gates Main's key, which the builder adds; it did not. + - Security fix. Selects naming a member typed as a collection the core does not unwrap (IReadOnlyList, + IReadOnlyCollection, Collection or an application's own) returned every field beneath it, denied ones + included, in both tiers: the projection gate read collections through a narrower list than the attribute + walker. It reads them as the walker does, and a narrowing the core cannot project is refused. + - Security fix. Selects naming a member that carries a field denied for Select no path names returned it: + deeper than four segments, inside a framework generic such as Dictionary, or, on a named entity + navigation, in its owned chain or a converted column. What a member carries is read from the source, from + the EF Core model for an entity. Strict refuses it; Convenience narrows it where the core can, and refuses it + where it cannot. A denied property with no setter, and a rule on a path reached through a cycle, are found + beneath a named member too. + - Behaviour change. The synthesized projection keeps what the source carries. A row a projection builds keeps + its assigned nested objects and lists; an entity keeps its columns, converted and JSON ones included, and its + owned and complex members, and every member holding a collection of simple values (byte[], List). In + 3.1.0 all of these came back null or empty whenever a field was denied. A member is kept whole when nothing + it can hold is denied, narrowed around a denial where the core's narrowing translates, and otherwise left out + whole with a "left out whole" Dropped decision. An entity's navigations and the objects of a row in memory + are left out, as before, and each one the unguarded call would have returned is recorded as Dropped; a + value EF Core does not map is left out too. A member that can hold an object of any type, a geometry or a + JSON bag say, asks for no projection on its own, and an entity keeps it whole, unless a value converter + hands back its value, directly or inside a complex property: a converter is the application's code, so + such a column is left out. BitArray and the framework's string collections hold values. An application's + own collection class, generic or not, has its own members read; a collection of values stays a value unless + one of them is denied. Two members sharing a name, one hidden with new under another type or spelled in + another case, are left out when either holds a denial, since the core reads one and a row carries both. + Rows in memory are projected when a member a base type declares, and the row type hides with new, is + denied. A projected member is read as the type its initializer constructs, and an + initializer after a constructor with arguments narrows its own bindings. The trace records a member left + out only when a projection is built, or, in a dry run, would be. + - Behaviour change. [DwEntity(DefaultOrder)] applies to a projected source whose outermost Select builds T + in an object initializer assigning every field the default names a column: a mapped member, read directly, + through reference navigations or through EF.Property. A computed value or any other projection still leaves + the query in its own order. A Select, or a Filter with Selects, composed on the guarded handle keeps the rest + of the chain unordered. A composed Filter that sent orders gets no default later in the chain, as a composed + Order already did not. + - Every async terminal has overloads taking a CancellationToken, guarded and unguarded: ToListAsync and + ToListAsyncDynamic with a Filter, ToListAsync with a Summary, and ToListAsync with a Segment. The 3.1 + signatures are unchanged, so code compiled against 3.1 still binds. The token reaches the count and the + read. ToListAsync(filter, default) no longer compiles, since default fits both bool and CancellationToken, + and a reflection lookup of one of these methods by name alone finds more overloads than it did. + - Behaviour change. The async Summary counts through EF Core's CountAsync, where it counted synchronously, and + it and ToListAsyncDynamic read through EF Core's ToListAsync instead of Dynamic LINQ's ToDynamicListAsync, + so on an EF Core query a canceled token reaches the database. A provider that is not EF Core's keeps Dynamic + LINQ's read, on the calling thread. 3.1.0 Dates rebuilt, segments combined in the database, five new caps, two new error codes, preparation enforced, a strict tier that discloses less, declared default orders, forced predicates that admit null, audited refusals, and long In lists that no longer end the process. The eleven bullets marked "Behaviour change" @@ -4733,7 +5025,59 @@ MemoryCalculationInput - Enum filtering works whether the column stores names or numbers: the parser converts the name to the enum value before EF Core translates it. `Contains` / `StartsWith` / `EndsWith` work only on string members. - Cache configuration changes are eventually consistent: calls already running finish with the options they read. -- Nothing accepts a `CancellationToken`. +- With no `Selects`, a guarded query over an entity leaves out every navigation EF Core does not own once a denial + needs a projection (section 17), an included one too. Under Convenience, name the navigation in `Selects` to get + it narrowed; under Strict, name its allowed fields. +- A forced scope declared on a list's element type filters the rows that hold the list, never its elements. Selects + naming the list returns every element, those the scope excludes included, as in every release; a synthesized + projection leaves such a list out. Scope the elements where the row is built. +- A member typed `object`, a framework interface or a collection that is not generic (`IEnumerable`, `ArrayList`, + `Array`, an application's own) is opaque to the policy. It never asks for a projection; when one is needed anyway, + a projected row, a row in memory, and an entity's converted column leave it out, and an entity's other columns + keep it (what EF Core materializes itself holds no application object); and naming it + returns whatever it holds. A converted column counts as able to hold anything when its type can: `object`, a + `Dictionary`, or a type with such a member. `BitArray`, `StringCollection`, `StringDictionary` and + `NameValueCollection` hold values. A value converter that returns an application type through a column typed + `object`, and an unmapped getter typed `object` over a private navigation, are opaque the same way: with nothing + else denied the row comes back as loaded. Type the member as what it holds. + A framework generic holding a policed type (`Dictionary`) has no paths beneath it: naming it is + refused in both tiers where the core cannot narrow it (at the top of T, or on a row in memory), narrowed away + under Convenience beneath a navigation, and a synthesized projection leaves it out. +- A projection builds the declared type. A query over the root of a hierarchy whose derived type declares a denied + field comes back as root-type rows, the derived types' allowed fields dropped too; over an abstract root the typed + terminals fail with `SelectTypeMustHaveParameterlessConstructor` and the dynamic ones return the root's members. + Query the derived type (`OfType()`) to keep its fields. Subtypes are read from the assemblies loaded + when the query runs, which hold every type a row can have. +- Under a `"*"` deny, a member is kept whole only when every path beneath it the walk skips, one with no setter, + on a subtype or past four segments, is one the policy names; around a cycle it never is. Otherwise it is + narrowed, left out or refused. +- A member EF Core does not map counts as loaded and is read as its type, since its getter can hand out what EF + Core loaded: a denied field in its type asks for a projection, which leaves the member out, since the projection + cannot assign it. A getter that copies a denied column into a type with no denial is the application's to + withhold. +- Rows in memory can be any loaded subtype, and the policy does not look at the rows. When a subtype of T declares + a denied field, or a subtype or implementation of a member's type does, the rows are projected and their objects + left out, even if no row is that subtype. +- A deny-family attribute on an override, or on a member a subtype hides with `new`, denies the base path for every + subtype loaded, not only those the EF Core model maps: a view model deriving from an entity and overriding one of + its members decides the entity's own path. Declare such a class apart from the entity to keep the path open. +- Types from an unloadable `AssemblyLoadContext` stay referenced by the policy's caches, so the context is not + collected while the process runs. +- A method or property in a reshaping lambda that builds a query from captured values (a repository's query, a + specification) runs once more per guarded read, when the guard reads what it returns; one that returns a different + query on each call is enforced as it answered the guard. +- A provider that wraps EF Core's gets `AsNoTracking` only when its query's expression shows the EF Core root, as + LinqKit's and DelegateDecompiler's do; one that hides it behind its own expression runs tracking. +- A member declared as a framework collection (`IEnumerable`, `List`) that holds an application's own + collection class at run time is read as the framework type, so that class's own members are not read. Serializers + write only the elements. +- On EF Core 6, a reshaped query whose projection EF Core 6 cannot translate (a count over `GroupBy`/`First`, a + `SelectMany` over a captured query that builds its rows) fails guarded, where it ran unguarded; EF Core 7 and later + translate it. +- A default order reaches a projected row only through columns of the entity its `Select` reads: a projection over + an anonymous or other intermediate row takes no default. +- A simulation reads T as a source it cannot see into (section 23): every denial beneath a member counts, and a + synthesized `Clause.Selects` keeps only members holding a value. --- diff --git a/README.md b/README.md index 664106e..663e9fe 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Stop concatenating LINQ predicates by hand. Your front-end sends one JSON shape; - **JSON in → `IQueryable` out.** No string LINQ. No manual expression trees. - **Three composable shapes** — `Filter`, `Segment`, `Summary` — cover where, set operations, and group-by reporting. -- **Twenty-one extension methods** on `IQueryable` and `IEnumerable`. +- **Twenty-eight extension methods** on `IQueryable` and `IEnumerable`, every async one with overloads that take a `CancellationToken`. - **Nested navigation** through references and collections, with auto-wrapped `.Any()` lambdas where needed. - **Heterogeneous `Condition.Values`** — pass raw numbers, booleans, strings; normalized per `DataType`. - **Thread-safe reflection cache** with FIFO / LRU / LFU eviction and five tuned presets. @@ -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.1.0 +dotnet add package DynamicWhere.ex --version 3.2.0 ``` Or via Package Manager: ```powershell -Install-Package DynamicWhere.ex -Version 3.1.0 +Install-Package DynamicWhere.ex -Version 3.2.0 ``` Dependencies (restored automatically): @@ -140,7 +140,7 @@ That's the whole loop. Full walk-through in **[Quick Start](https://doc.dynamicw | **[`Segment`](https://doc.dynamicwhere.com/docs/classes/segment)** | set1 ∪/∩/∖ set2 ∪/∩/∖ set3 → order → page | UNION / INTERSECT / EXCEPT across multiple condition sets | | **[`Summary`](https://doc.dynamicwhere.com/docs/classes/summary)** | where → group → having → order → page | Aggregate reporting (`GROUP BY` + `SUM` / `AVG` / `COUNT` …) | -### Twenty-one extension methods +### Twenty-eight extension methods Projection, filtering, composition, and materialization on `IQueryable` and `IEnumerable`: @@ -151,6 +151,8 @@ Projection, filtering, composition, and materialization on `IQueryable` and ` | **Composition** | `.Order` · `.Page` · `.Group` · `.Filter` · `.FilterDynamic` · `.Summary` | | **Materialization** | `.ToList(Filter)` · `.ToListAsync(Filter)` · `.ToListDynamic(Filter)` · `.ToListAsyncDynamic(Filter)` · `.ToList(Summary)` · `.ToListAsync(Summary)` · `.ToListAsync(Segment)` | +Every async terminal also has overloads that take a `CancellationToken`, which reaches the count and the read. `ToList(Filter)`, `ToListDynamic(Filter)` and `ToList(Summary)` also run on an `IEnumerable`. + Full signatures, validations, and return types → **[Extension Methods Reference](https://doc.dynamicwhere.com/docs/extensions)**. ### Operators & data types @@ -363,7 +365,7 @@ The complete reference — every enum, class, extension method, validation rule, | [Getting Started](https://doc.dynamicwhere.com/docs) | Introduction, installation, quick start | | [Enums](https://doc.dynamicwhere.com/docs/enums) | Every DataType, Operator, Connector, Direction, Intersection, Aggregator, Cache enum | | [Classes](https://doc.dynamicwhere.com/docs/classes) | Condition, ConditionGroup, ConditionSet, OrderBy, GroupBy, AggregateBy, PageBy, Filter, Segment, Summary, Result types | -| [Extension Methods](https://doc.dynamicwhere.com/docs/extensions) | All 17 methods with signatures, validations, examples | +| [Extension Methods](https://doc.dynamicwhere.com/docs/extensions) | All 28 methods with signatures, validations, examples | | [Validation Rules](https://doc.dynamicwhere.com/docs/validation) | What's checked and what throws | | [JSON Cookbook](https://doc.dynamicwhere.com/docs/examples) | 13 copy-pasteable end-to-end examples | | [Field-Level Policies](https://doc.dynamicwhere.com/docs/policies) | Attributes, precedence, masking, dynamic rules, admin API, k-anonymity | @@ -373,6 +375,22 @@ The complete reference — every enum, class, extension method, validation rule, --- +## Version 3.2.0 highlights + +**Upgrade note — the security fixes and the three changes alter what code written for 3.1.0 does, and the new overloads can stop a call from compiling. Read these before bumping.** + +- **Fixed (security): a field denied beneath a member reached a caller who sent no `Selects`.** A guarded query synthesizes a projection for a denied field, and it did so only when a simple field at the top of the type was denied. With every denial beneath a member, the whole row came back with the denied value in it: in a list or nested object of a row projected before `ApplyPolicy`, in a row held in memory, and in an entity's included, automatically included, lazily loaded or owned member — typed and dynamic, in both tiers, for a `Filter` and a `Segment`. Such a denial now synthesizes the projection whenever its value can reach the result. On an entity that means beneath a column, an owned or complex member, or a navigation the query loads through `Include`, an automatic include or a lazy loader. A denial beneath a navigation nothing loads never leaves the database, and the entity is read exactly as before. +- **Fixed (security): what a query loads, and what a member holds, was read too narrowly.** An include named from the root and reached through `Select(o => o.Customer)`, `SelectMany` or `Join`, a projection behind another `Select`, an initializer after a constructor with arguments, and a lazy loader the constructor takes and keeps in a field or any property each loaded a denied value the gate read as unloaded. An injected `DbContext` and EF Core 7's asynchronous loader delegate loaded one too, and so did a reshaping lambda that got its row from an application's method or from a captured query or object. An application's own collection class hid its own denied members, and a guarded query through a provider wrapping EF Core's, such as LinqKit's `AsExpandable`, ran tracking, so the context filled in navigations it already held and a masked value became a pending change. A field a subtype declares — a derived entity's, a subclass's held by a base-typed member, an open generic one's — was not read at all, nor was a `[DwDenied]` on an override, on a member hidden with `new` or on an interface member's implementation, and under a `"*"` deny with exact allows a path the walk never asked about (past four segments, around a cycle, with no setter) resolved as allowed. `Selects` naming an entity navigation returned a denial in its owned chain past four segments or in a converted `Dictionary` column. All of these are read now: from the EF Core model for an entity, so only what loads counts, and from every loaded subtype for a projected or in-memory row. A denial on an override, a public member hidden with `new` or an implementation, through a variant instantiation too, applies to the base type's or the interface's path, on every row and in every clause. +- **Fixed (security): a denied member that holds no simple value came back.** A field denied at the top of the type whose own type is not a simple value — a byte array, a list, an owned object, a JSON column — synthesized no projection either, so with nothing else denied it came back. +- **Fixed (security): an application namespace starting with `System` got no policy.** The attribute walker read any namespace starting with "System" as the framework's, so an application namespace such as `SystemsCorp.Payroll` got no policy beneath its types, and a `[DwDenied]` field there was returned, filterable and sortable. Only `System` and the namespaces beneath it are the framework's now. +- **Fixed (security): a navigation narrowed around its own denied key got the key back.** Under the convenience tier, `Selects` naming a navigation whose key (`Id`) is denied was narrowed to the allowed fields beneath it, and the core's typed projection added the key back. Such a narrowing is refused with `FieldDeniedForSelect` in both tiers, as naming a sibling of the key already was. A navigation named through another, such as `Main.Lead`, now gates the key of `Main`, which the projection adds; it did not. +- **Fixed (security): `Selects` could name a member whose denials the gate did not see.** A member typed as a collection the core does not unwrap — `IReadOnlyList`, `IReadOnlyCollection`, `Collection` or an application's own — returned every field beneath it, denied ones included, in both tiers, because the projection gate read collections through a narrower list than the attribute walker. It reads them the same way now, and a narrowing the core cannot project is refused with `FieldDeniedForSelect`. Denials beneath a named member are also read from the policy's own rules, so a denied property with no setter and a rule on a path reached through a cycle are found. A member carrying a field denied where no path reaches it — deeper than the walker, inside a framework collection such as `Dictionary`, or on a subtype — is refused under the strict tier, and under the convenience tier narrowed where the core can narrow it and refused where it cannot. +- **Changed: the synthesized projection keeps what the source carries.** It kept simple fields only, so every nested object and list of a row projected before `ApplyPolicy` came back null or empty as soon as any field was denied. A row a projection builds — the outermost `Select` constructs it, in an object initializer or with a constructor, as in `db.Roles.Select(r => new RoleRow { … })` — keeps the members its initializer assigns. An entity, or a `Select` that hands back an entity such as `db.Orders.Select(o => o.Customer)`, keeps its mapped columns, converted and JSON ones included except a converted one that can hold an object of any type, its owned and complex members, and every collection of simple values such as `byte[]` or `List`. A member holding an object is kept whole when nothing it can hold is denied, narrowed to the allowed fields where the core's narrowing translates, and otherwise left out whole, recorded as `Dropped` with a reason starting `left out whole`. An entity's navigations, the objects of a row in memory, and a value EF Core does not map are left out, and the type needs a public parameterless constructor for the typed projection, as it already did. When a derived type in the model, or a loaded subclass of a row in memory, declares a denied field, the rows come back as the queried type, so a derived type's allowed fields are dropped too; query the derived type with `OfType()` to keep them. A member that can hold an object of any type, a geometry or a JSON bag say, asks for no projection on its own, and a chain that reaches its rows through a navigation, `SelectMany`, `Join` or `GroupBy` counts every navigation as loaded only when it has an include, or a lambda that builds an object, gets one from an application's method, or captures a query with its own include or projection. +- **Changed: `[DwEntity(DefaultOrder)]` reaches a projection that builds the row.** A guarded query over a projected source takes the default when the outermost `Select` builds the type in an object initializer and assigns every field the default names a column, at every level of a nested path — a mapped member read directly, through reference navigations or through `EF.Property`: `Select(t => new TicketRow { Id = t.Id, CreatedAt = t.CreatedAt })` for `"CreatedAt desc, Id"`. A computed value or any other projection still leaves the query in its own order. A `Select`, or a `Filter` with `Selects`, composed on the guarded handle keeps the rest of the chain unordered, and a composed `Filter` that sent orders gets no default later in the chain, as a composed `Order` already did not. +- **Changed: the async dynamic `Filter` and the async `Summary` read through EF Core.** `ToListAsyncDynamic` and `ToListAsync(Summary)` read with EF Core's `ToListAsync` instead of Dynamic LINQ's `ToDynamicListAsync`, which had no token to pass on, and the summary counts with `CountAsync` where it counted synchronously. So on an EF Core query a canceled token now reaches the database. A provider that is not EF Core's keeps Dynamic LINQ's read, on the calling thread. +- **New: a `CancellationToken` on every async terminal**, guarded and unguarded: `ToListAsync` and `ToListAsyncDynamic` with a `Filter`, `ToListAsync` with a `Summary`, and `ToListAsync` with a `Segment`. The token reaches the count and the read. The overloads sit beside the 3.1 signatures, which are unchanged, so code compiled against 3.1 still binds. `ToListAsync(filter, default)`, `ToListAsyncDynamic(filter, default)` and `ToListAsync(summary, default)` no longer compile, because `default` fits both `getQueryString` and the token: write `false`, a token, or a named argument. A reflection lookup of `ToListAsyncDynamic` by name alone now finds three methods where it found one, and one of `ToListAsync` finds more than it did. +- **Known limits.** Once a projection is needed, an entity's navigations are left out, included ones too; under the convenience tier name one in `Selects` to get it narrowed, and under the strict tier name its allowed fields. A forced scope declared on a list's element type filters the rows that hold the list, never its elements, so `Selects` naming the list returns every element and a synthesized projection leaves the list out; scope the elements where the row is built. A member typed `object`, a framework interface or a collection that is not generic (`IEnumerable`, `ArrayList`) is opaque to the policy, and a framework generic holding a policed type, such as `Dictionary`, has no paths beneath it: hold such values in a list of the policed type. A member EF Core does not map is read as its type, since its getter can hand out what EF Core loaded. Rows in memory can be any loaded subtype, so they are projected whenever one declares a denied field. A denial on an override or a `new` member counts for every loaded subtype, a class EF Core does not map included. A repository or specification method in a reshaping lambda runs once more per guarded read, and on EF Core 6 a reshaped query whose projection EF Core 6 cannot translate fails guarded where it ran unguarded. `/simulate` 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. + ## Version 3.1.0 highlights **Upgrade note — eleven behaviour changes, listed first. Read these before bumping.** diff --git a/build/check-version.ps1 b/build/check-version.ps1 index 311f68f..7d5e900 100644 --- a/build/check-version.ps1 +++ b/build/check-version.ps1 @@ -103,10 +103,10 @@ $targets = [ordered]@{ "Version ($semver) . targets net6\.0", "DynamicWhere\.ex --version ($semver)" ) - # The page that serves that reference says which version it was generated against, in prose + # The page that serves that reference says which version it was written against, in prose # none of the patterns above reach. 'OfficialWebsite/app/docs/ai/page.tsx' = @( - "generated against version ($semver)" + "written against version ($semver)" ) }