@@ -236,13 +236,11 @@ public async Task The_policy_holds_the_customization_tables_without_the_filter()
236236 // announcement `TransactionBehavior` made — which is the half the schema
237237 // suite cannot reach, because it sets `app.tenant_id` itself.
238238 //
239- // <b>What it does not discriminate, stated rather than implied.</b> The
240- // filter and the policy scope on the same column with the same value, so no
241- // in-process read can tell "the filter answered" from "the policy answered":
242- // if `IgnoreQueryFilters()` ever stopped applying, this case would keep
243- // passing. The layer that proves the policy alone is the schema suite, which
244- // runs raw SQL as `learnstack_app` with no EF in the picture at all
245- // (`CustomizationSchemaTests`). This one adds the request path to it.
239+ // The read is raw SQL on the request's own connection, so there is no filter
240+ // to reacquire and nothing to trust: what answers is the policy plus the
241+ // announcement. `CustomizationSchemaTests` asks the policy the same question
242+ // in isolation; what only this case can say is that the announcement a real
243+ // request produced is the one the policy read.
246244 var english = await GetAsync ( SeedData . English . Host , "customizations-unfiltered" ) ;
247245 var yoga = await GetAsync ( SeedData . Yoga . Host , "customizations-unfiltered" ) ;
248246
@@ -544,13 +542,12 @@ public enum ProbeSubject
544542 Settings ,
545543 Customizations ,
546544
547- /// <summary>The same read with the EF query filter removed .</summary>
545+ /// <summary>The same read as raw SQL, with no EF in the path .</summary>
548546 /// <remarks>
549547 /// One of the four isolation layers taken away on purpose, so the one behind it
550- /// answers alone. <c>No_IgnoreQueryFilters_Outside_PlatformAdminScope</c> scans
551- /// <c>backend/src</c> and not this file — the rule exists so a production call
552- /// site is a deliberate edit, and a test that removes a layer to prove the next
553- /// one holds is the claim defence in depth makes.
548+ /// answers alone — and taken away by construction rather than by asking EF to
549+ /// drop it, which is the difference between a case that proves something and a
550+ /// case that trusts a call.
554551 /// </remarks>
555552 CustomizationsUnfiltered ,
556553}
@@ -611,7 +608,8 @@ public sealed record ForeignWriteCommand(Guid TenantId)
611608/// </remarks>
612609public sealed class ProbeQueryHandler (
613610 TenancyDbContext db ,
614- LearnStack . Modules . Customization . Infrastructure . Persistence . CustomizationDbContext customization )
611+ LearnStack . Modules . Customization . Infrastructure . Persistence . CustomizationDbContext customization ,
612+ SharedKernel . Persistence . IUnitOfWork unitOfWork )
615613 : MediatR . IRequestHandler < ProbeQuery , SharedKernel . Results . Result < List < string > > > ,
616614 MediatR . IRequestHandler < UnresolvedProbeQuery , SharedKernel . Results . Result < List < string > > > ,
617615 MediatR . IRequestHandler < UnresolvedCustomizationProbeQuery ,
@@ -640,7 +638,7 @@ await ReadSettingsAsync(db, cancellationToken)),
640638 await ReadCustomizationsAsync ( customization , cancellationToken ) ) ,
641639
642640 ProbeSubject . CustomizationsUnfiltered => SharedKernel . Results . Result . Ok (
643- await ReadCustomizationsAsync ( customization , cancellationToken , filtered : false ) ) ,
641+ await ReadCustomizationsWithoutEfAsync ( unitOfWork , cancellationToken ) ) ,
644642
645643 // Exhaustive by construction, fail-closed on a member added without deciding
646644 // what it reads — the house style, and the opposite of falling through to
@@ -680,29 +678,68 @@ await ReadCustomizationsAsync(customization, cancellationToken, filtered: false)
680678 /// </remarks>
681679 private static async Task < List < string > > ReadCustomizationsAsync (
682680 LearnStack . Modules . Customization . Infrastructure . Persistence . CustomizationDbContext db ,
683- CancellationToken cancellationToken ,
684- bool filtered = true )
681+ CancellationToken cancellationToken )
685682 {
686- var contentTypes = await Rows ( db . TenantContentTypes , filtered )
683+ var contentTypes = await db . TenantContentTypes
687684 . Select ( contentType => "content-type:" + contentType . Key + "@" + contentType . TenantId )
688685 . ToListAsync ( cancellationToken ) ;
689686
690- var taxonomies = await Rows ( db . TenantLevelTaxonomies , filtered )
687+ var taxonomies = await db . TenantLevelTaxonomies
691688 . Select ( taxonomy => "taxonomy:" + taxonomy . Key + "@" + taxonomy . TenantId )
692689 . ToListAsync ( cancellationToken ) ;
693690
694- var bands = await Rows (
695- db . Set < LearnStack . Modules . Customization . Domain . TenantLevelTaxonomyItem > ( ) , filtered )
691+ var bands = await db
692+ . Set < LearnStack . Modules . Customization . Domain . TenantLevelTaxonomyItem > ( )
696693 . Select ( item => "band:" + item . Key + "@" + item . TenantId )
697694 . ToListAsync ( cancellationToken ) ;
698695
699696 return [ .. contentTypes . Concat ( taxonomies ) . Concat ( bands ) . Order ( StringComparer . Ordinal ) ] ;
700697 }
701698
702- /// <summary>The same query, with the EF filter kept or taken away.</summary>
703- private static IQueryable < TEntity > Rows < TEntity > ( IQueryable < TEntity > rows , bool filtered )
704- where TEntity : class =>
705- filtered ? rows : rows . IgnoreQueryFilters ( ) ;
699+ /// <summary>
700+ /// The same three tables, read with no EF in the path at all.
701+ /// </summary>
702+ /// <remarks>
703+ /// <para>
704+ /// <b>Raw SQL on the request's own connection, not <c>IgnoreQueryFilters()</c>.</b>
705+ /// Both remove the query filter, and only one of them removes it by construction:
706+ /// asking EF to drop a filter leaves EF deciding, so a call that silently stopped
707+ /// applying would leave this case green while proving nothing. There is no filter
708+ /// to reacquire here, and what answers is the policy plus the <c>SET LOCAL
709+ /// app.tenant_id</c> that <c>TransactionBehavior</c> issued — which is the half
710+ /// the schema suite cannot reach, because it announces the tenant itself.
711+ /// </para>
712+ /// <para>
713+ /// It is also the pattern <c>No_IgnoreQueryFilters_Outside_PlatformAdminScope</c>
714+ /// asks for. That rule scans <c>backend/src</c> and not this file, so the call was
715+ /// legal — but a test is where the next person reads the idiom from.
716+ /// </para>
717+ /// </remarks>
718+ private static async Task < List < string > > ReadCustomizationsWithoutEfAsync (
719+ SharedKernel . Persistence . IUnitOfWork unitOfWork , CancellationToken cancellationToken )
720+ {
721+ await using var command = new NpgsqlCommand (
722+ """
723+ SELECT 'content-type:' || key || '@' || tenant_id FROM tenant_content_types
724+ UNION ALL
725+ SELECT 'taxonomy:' || key || '@' || tenant_id FROM tenant_level_taxonomies
726+ UNION ALL
727+ SELECT 'band:' || key || '@' || tenant_id FROM tenant_level_taxonomy_items
728+ ORDER BY 1
729+ """ ,
730+ ( NpgsqlConnection ) unitOfWork . Connection ,
731+ ( NpgsqlTransaction ? ) unitOfWork . Transaction ) ;
732+
733+ var rows = new List < string > ( ) ;
734+ await using var reader = await command . ExecuteReaderAsync ( cancellationToken ) ;
735+
736+ while ( await reader . ReadAsync ( cancellationToken ) )
737+ {
738+ rows . Add ( reader . GetString ( 0 ) ) ;
739+ }
740+
741+ return rows ;
742+ }
706743
707744 /// <summary>
708745 /// Every setting the request can see, as <c>key=scope</c>.
0 commit comments