@@ -306,18 +306,76 @@ fn build_ilp_calvin_tasks(
306306
307307#[ cfg( test) ]
308308mod tests {
309- use super :: { IlpPreflightFailure , preflight_ilp_batch} ;
310- use crate :: control:: security:: audit:: NoopAuditEmitter ;
309+ use super :: { IlpPreflightFailure , flush_authenticated_ilp_batch, preflight_ilp_batch} ;
310+ use std:: sync:: { Arc , Mutex } ;
311+
312+ use crate :: bridge:: dispatch:: Dispatcher ;
313+ use crate :: control:: state:: SharedState ;
314+
311315 use crate :: control:: security:: audit:: emitter:: test_helpers:: CapturingEmitter ;
316+ use crate :: control:: security:: audit:: {
317+ AuditEmitContext , AuditEmitter , AuditEvent , NoopAuditEmitter ,
318+ } ;
312319 use crate :: control:: security:: identity:: {
313320 AuthMethod , AuthenticatedIdentity , DatabaseSet , Permission ,
314321 } ;
315322 use crate :: control:: security:: permission:: PermissionStore ;
316323 use crate :: control:: security:: role:: RoleStore ;
317324 use crate :: types:: { DatabaseId , TenantId , VShardId } ;
325+ use crate :: wal:: WalManager ;
318326 use nodedb_physical:: physical_plan:: { PhysicalPlan , TimeseriesOp } ;
319327 use nodedb_types:: Surrogate ;
320328
329+ #[ derive( Clone , Debug ) ]
330+ struct CapturedAuditEvent {
331+ event : AuditEvent ,
332+ source : String ,
333+ auth_user_id : String ,
334+ auth_user_name : String ,
335+ tenant_id : Option < TenantId > ,
336+ }
337+
338+ struct ContextCapturingEmitter {
339+ events : Mutex < Vec < CapturedAuditEvent > > ,
340+ }
341+
342+ impl ContextCapturingEmitter {
343+ fn new ( ) -> Self {
344+ Self {
345+ events : Mutex :: new ( Vec :: new ( ) ) ,
346+ }
347+ }
348+
349+ fn recorded ( & self ) -> Vec < CapturedAuditEvent > {
350+ self . events
351+ . lock ( )
352+ . unwrap_or_else ( |poisoned| poisoned. into_inner ( ) )
353+ . clone ( )
354+ }
355+ }
356+
357+ impl AuditEmitter for ContextCapturingEmitter {
358+ fn emit (
359+ & self ,
360+ event : AuditEvent ,
361+ source : & str ,
362+ _detail : & str ,
363+ context : AuditEmitContext < ' _ > ,
364+ ) {
365+ let mut events = self
366+ . events
367+ . lock ( )
368+ . unwrap_or_else ( |poisoned| poisoned. into_inner ( ) ) ;
369+ events. push ( CapturedAuditEvent {
370+ event,
371+ source : source. into ( ) ,
372+ auth_user_id : context. auth_user_id . into ( ) ,
373+ auth_user_name : context. auth_user_name . into ( ) ,
374+ tenant_id : context. tenant_id ,
375+ } ) ;
376+ }
377+ }
378+
321379 fn identity ( database_id : DatabaseId ) -> AuthenticatedIdentity {
322380 AuthenticatedIdentity :: new_regular (
323381 7 ,
@@ -351,6 +409,42 @@ mod tests {
351409 )
352410 }
353411
412+ #[ tokio:: test]
413+ async fn authenticated_flush_rejects_unwritable_measurement_before_dispatch_or_catalog_projection ( )
414+ {
415+ let directory = tempfile:: tempdir ( ) . expect ( "create ILP batch test directory" ) ;
416+ let wal = Arc :: new (
417+ WalManager :: open_for_testing ( & directory. path ( ) . join ( "ilp-batch.wal" ) )
418+ . expect ( "open ILP batch test WAL" ) ,
419+ ) ;
420+ let ( dispatcher, _data_sides) = Dispatcher :: new ( 1 , 64 ) ;
421+ let state = SharedState :: new ( dispatcher, wal) . expect ( "construct ILP batch state" ) ;
422+ let database_id = DatabaseId :: new ( 7 ) ;
423+
424+ let error = flush_authenticated_ilp_batch (
425+ & state,
426+ & identity ( database_id) ,
427+ database_id,
428+ "cpu value=1i\n " ,
429+ )
430+ . await
431+ . expect_err ( "regular identity without write permission is rejected during preflight" ) ;
432+
433+ assert ! ( matches!(
434+ error,
435+ crate :: Error :: BadRequest { detail } if detail == "ILP batch rejected"
436+ ) ) ;
437+ assert ! (
438+ state
439+ . credentials
440+ . catalog( )
441+ . get_collection( database_id, 9 , "cpu" )
442+ . expect( "read catalog collection projection" )
443+ . is_none( ) ,
444+ "early authorization denial must not create a collection/schema projection"
445+ ) ;
446+ }
447+
354448 #[ test]
355449 fn groups_two_measurements_in_canonical_order_and_preserves_source_order ( ) {
356450 let permissions = PermissionStore :: new ( ) ;
@@ -445,6 +539,7 @@ mod tests {
445539
446540 #[ test]
447541 fn read_only_collection_is_not_sufficient_for_ilp_ingest ( ) {
542+ // ILP is write-only; read access is not applicable to ingestion.
448543 let permissions = PermissionStore :: new ( ) ;
449544 permissions
450545 . grant (
@@ -462,13 +557,59 @@ mod tests {
462557 ) ;
463558 }
464559
560+ #[ test]
561+ fn system_audit_log_measurement_is_denied_to_regular_ingester ( ) {
562+ let permissions = PermissionStore :: new ( ) ;
563+ grant_write ( & permissions, "_system.audit_log" ) ;
564+ let audit = ContextCapturingEmitter :: new ( ) ;
565+
566+ assert_eq ! (
567+ preflight_ilp_batch(
568+ & identity( DatabaseId :: new( 7 ) ) ,
569+ DatabaseId :: new( 7 ) ,
570+ "_system.audit_log value=1i\n " ,
571+ & permissions,
572+ & RoleStore :: new( ) ,
573+ & audit,
574+ ) ,
575+ Err ( IlpPreflightFailure :: PermissionDenied )
576+ ) ;
577+ let events = audit. recorded ( ) ;
578+ assert_eq ! ( events. len( ) , 1 ) ;
579+ assert_eq ! ( events[ 0 ] . event, AuditEvent :: PermissionDenied ) ;
580+ assert_eq ! ( events[ 0 ] . source, "ingester" ) ;
581+ assert_eq ! ( events[ 0 ] . auth_user_id, "7" ) ;
582+ assert_eq ! ( events[ 0 ] . auth_user_name, "ingester" ) ;
583+ assert_eq ! ( events[ 0 ] . tenant_id, Some ( TenantId :: new( 9 ) ) ) ;
584+ }
585+
586+ #[ test]
587+ fn tenant_ten_write_grant_does_not_authorize_tenant_nine_ilp_ingest ( ) {
588+ let permissions = PermissionStore :: new ( ) ;
589+ permissions
590+ . grant (
591+ "collection:10:cpu" ,
592+ "user:ingester" ,
593+ Permission :: Write ,
594+ "admin" ,
595+ None ,
596+ )
597+ . expect ( "in-memory grant succeeds" ) ;
598+
599+ assert_eq ! (
600+ preflight( "cpu value=1i\n " , & permissions) ,
601+ Err ( IlpPreflightFailure :: PermissionDenied )
602+ ) ;
603+ }
604+
465605 #[ test]
466606 fn non_default_database_is_used_for_collection_authorization ( ) {
467607 let permissions = PermissionStore :: new ( ) ;
468608 grant_write ( & permissions, "cpu" ) ;
469609 let database_id = DatabaseId :: new ( 7 ) ;
470610 let roles = RoleStore :: new ( ) ;
471611
612+ // Database selection is handshake-bound; ILP payload does not select it.
472613 assert_eq ! (
473614 preflight_ilp_batch(
474615 & identity( DatabaseId :: DEFAULT ) ,
0 commit comments