@@ -905,6 +905,163 @@ public void WildcardColumnInclusionWithExplicitExclusion()
905905 excludedColumns ) ) ;
906906 }
907907
908+ /// <summary>
909+ /// Reproduces the real-world Book entity scenario: a single role with MULTIPLE actions
910+ /// (read with no field restriction, create/update excluding a column, delete with no
911+ /// field restriction) all defined in one EntityPermission.Actions array - unlike
912+ /// AuthorizationHelpers.InitRuntimeConfig which only ever builds a single action per role.
913+ /// </summary>
914+ [ TestMethod ( "Multiple actions per role - column exclusion on create/update only" ) ]
915+ public void MultipleActionsPerRole_ColumnExclusionOnCreateAndUpdate ( )
916+ {
917+ EntityActionFields createUpdateFields = new ( Exclude : new ( ) { "col3" } ) ;
918+ EntityAction readAction = new ( Action : EntityActionOperation . Read , Fields : null , Policy : new ( null , null ) ) ;
919+ EntityAction createAction = new ( Action : EntityActionOperation . Create , Fields : createUpdateFields , Policy : new ( null , null ) ) ;
920+ EntityAction updateAction = new ( Action : EntityActionOperation . Update , Fields : createUpdateFields , Policy : new ( null , null ) ) ;
921+ EntityAction deleteAction = new ( Action : EntityActionOperation . Delete , Fields : null , Policy : new ( null , null ) ) ;
922+
923+ Azure . DataApiBuilder . Config . ObjectModel . EntityPermission permissionForEntity = new (
924+ Role : AuthorizationHelpers . TEST_ROLE ,
925+ Actions : new EntityAction [ ] { readAction , createAction , updateAction , deleteAction } ) ;
926+
927+ Azure . DataApiBuilder . Config . ObjectModel . Entity sampleEntity = new (
928+ Source : new Azure . DataApiBuilder . Config . ObjectModel . EntitySource ( AuthorizationHelpers . TEST_ENTITY , Azure . DataApiBuilder . Config . ObjectModel . EntitySourceType . Table , null , null ) ,
929+ Fields : null ,
930+ Rest : new ( Array . Empty < SupportedHttpVerb > ( ) ) ,
931+ GraphQL : new ( AuthorizationHelpers . TEST_ENTITY , AuthorizationHelpers . TEST_ENTITY ) ,
932+ Permissions : new Azure . DataApiBuilder . Config . ObjectModel . EntityPermission [ ] { permissionForEntity } ,
933+ Relationships : null ,
934+ Mappings : null ) ;
935+
936+ Azure . DataApiBuilder . Config . ObjectModel . RuntimeConfig runtimeConfig = new (
937+ Schema : "UnitTestSchema" ,
938+ DataSource : new Azure . DataApiBuilder . Config . ObjectModel . DataSource ( Azure . DataApiBuilder . Config . ObjectModel . DatabaseType . MSSQL , "" , new ( ) ) ,
939+ Runtime : new (
940+ Rest : new ( ) ,
941+ GraphQL : new ( ) ,
942+ Mcp : new ( ) ,
943+ Host : new (
944+ Cors : null ,
945+ Authentication : new ( "AppService" , null ) ) ) ,
946+ Entities : new ( new Dictionary < string , Azure . DataApiBuilder . Config . ObjectModel . Entity > { { AuthorizationHelpers . TEST_ENTITY , sampleEntity } } ) ) ;
947+
948+ AuthorizationResolver authZResolver = AuthorizationHelpers . InitAuthorizationResolver ( runtimeConfig ) ;
949+
950+ // Read should allow all columns (no exclusion for read).
951+ Assert . IsTrue ( authZResolver . AreColumnsAllowedForOperation (
952+ AuthorizationHelpers . TEST_ENTITY ,
953+ AuthorizationHelpers . TEST_ROLE ,
954+ operation : EntityActionOperation . Read ,
955+ new List < string > { "col1" , "col3" } ) ) ;
956+
957+ // Create should DENY col3 since it is excluded for create.
958+ Assert . IsFalse ( authZResolver . AreColumnsAllowedForOperation (
959+ AuthorizationHelpers . TEST_ENTITY ,
960+ AuthorizationHelpers . TEST_ROLE ,
961+ operation : EntityActionOperation . Create ,
962+ new List < string > { "col1" , "col3" } ) ) ;
963+
964+ // Update should DENY col3 since it is excluded for update.
965+ Assert . IsFalse ( authZResolver . AreColumnsAllowedForOperation (
966+ AuthorizationHelpers . TEST_ENTITY ,
967+ AuthorizationHelpers . TEST_ROLE ,
968+ operation : EntityActionOperation . Update ,
969+ new List < string > { "col1" , "col3" } ) ) ;
970+
971+ // Round-trip the config through JSON serialization/deserialization (as happens when DAB
972+ // loads a real config file from disk) to rule out any bug specific to the JSON converters
973+ // (e.g. shared/aliased Fields.Exclude HashSet instances across sibling actions).
974+ string json = runtimeConfig . ToJson ( ) ;
975+ Assert . IsTrue ( Azure . DataApiBuilder . Config . RuntimeConfigLoader . TryParseConfig ( json , out Azure . DataApiBuilder . Config . ObjectModel . RuntimeConfig ? roundTrippedConfig ) ) ;
976+ AuthorizationResolver roundTrippedResolver = AuthorizationHelpers . InitAuthorizationResolver ( roundTrippedConfig ! ) ;
977+
978+ Assert . IsTrue ( roundTrippedResolver . AreColumnsAllowedForOperation (
979+ AuthorizationHelpers . TEST_ENTITY ,
980+ AuthorizationHelpers . TEST_ROLE ,
981+ operation : EntityActionOperation . Read ,
982+ new List < string > { "col1" , "col3" } ) ) ;
983+
984+ Assert . IsFalse ( roundTrippedResolver . AreColumnsAllowedForOperation (
985+ AuthorizationHelpers . TEST_ENTITY ,
986+ AuthorizationHelpers . TEST_ROLE ,
987+ operation : EntityActionOperation . Create ,
988+ new List < string > { "col1" , "col3" } ) ) ;
989+
990+ Assert . IsFalse ( roundTrippedResolver . AreColumnsAllowedForOperation (
991+ AuthorizationHelpers . TEST_ENTITY ,
992+ AuthorizationHelpers . TEST_ROLE ,
993+ operation : EntityActionOperation . Update ,
994+ new List < string > { "col1" , "col3" } ) ) ;
995+ }
996+
997+ /// <summary>
998+ /// Parses the EXACT real-world Book entity config structure from JSON (entity-level "fields"
999+ /// array listing only id/title, plus a multi-action role that excludes publisher_id for
1000+ /// create/update) and asserts that Fields.Exclude survives deserialization for each action.
1001+ /// This isolates whether the bug is in the JSON config converters (EntityActionConverter /
1002+ /// EntityActionFields) independent of any database/metadata provider.
1003+ /// </summary>
1004+ [ TestMethod ( "Real config JSON: Fields.Exclude survives parse for multi-action role" ) ]
1005+ public void RealConfigJson_FieldsExcludeSurvivesParse ( )
1006+ {
1007+ string configJson = @"{
1008+ ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/v0.10.23/dab.draft.schema.json"",
1009+ ""data-source"": {
1010+ ""database-type"": ""mssql"",
1011+ ""connection-string"": ""Server=localhost;Database=master;""
1012+ },
1013+ ""runtime"": {
1014+ ""rest"": { ""enabled"": true, ""path"": ""/api"", ""request-body-strict"": true },
1015+ ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" },
1016+ ""host"": { ""mode"": ""development"", ""authentication"": { ""provider"": ""StaticWebApps"" } }
1017+ },
1018+ ""entities"": {
1019+ ""Book"": {
1020+ ""source"": { ""object"": ""books"", ""type"": ""table"" },
1021+ ""fields"": [
1022+ { ""name"": ""id"", ""alias"": ""id"", ""primary-key"": false },
1023+ { ""name"": ""title"", ""alias"": ""title"", ""primary-key"": false }
1024+ ],
1025+ ""permissions"": [
1026+ {
1027+ ""role"": ""test_role_with_excluded_fields_on_mutation"",
1028+ ""actions"": [
1029+ { ""action"": ""read"" },
1030+ { ""action"": ""create"", ""fields"": { ""exclude"": [ ""publisher_id"" ] } },
1031+ { ""action"": ""update"", ""fields"": { ""exclude"": [ ""publisher_id"" ] } },
1032+ { ""action"": ""delete"" }
1033+ ]
1034+ }
1035+ ]
1036+ }
1037+ }
1038+ }" ;
1039+
1040+ Assert . IsTrue (
1041+ Azure . DataApiBuilder . Config . RuntimeConfigLoader . TryParseConfig ( configJson , out Azure . DataApiBuilder . Config . ObjectModel . RuntimeConfig ? config ) ,
1042+ "Config should parse successfully." ) ;
1043+ Assert . IsNotNull ( config ) ;
1044+
1045+ Azure . DataApiBuilder . Config . ObjectModel . Entity bookEntity = config ! . Entities [ "Book" ] ;
1046+ Azure . DataApiBuilder . Config . ObjectModel . EntityPermission permission =
1047+ bookEntity . Permissions . Single ( p => p . Role == "test_role_with_excluded_fields_on_mutation" ) ;
1048+
1049+ Azure . DataApiBuilder . Config . ObjectModel . EntityAction createAction =
1050+ permission . Actions . Single ( a => a . Action == EntityActionOperation . Create ) ;
1051+ Azure . DataApiBuilder . Config . ObjectModel . EntityAction updateAction =
1052+ permission . Actions . Single ( a => a . Action == EntityActionOperation . Update ) ;
1053+
1054+ Assert . IsNotNull ( createAction . Fields , "Create action Fields should not be null after parsing." ) ;
1055+ Assert . IsNotNull ( updateAction . Fields , "Update action Fields should not be null after parsing." ) ;
1056+
1057+ Assert . IsTrue (
1058+ createAction . Fields ! . Exclude . Contains ( "publisher_id" ) ,
1059+ $ "Create action Exclude should contain publisher_id. Actual: [{ string . Join ( "," , createAction . Fields . Exclude ) } ]") ;
1060+ Assert . IsTrue (
1061+ updateAction . Fields ! . Exclude . Contains ( "publisher_id" ) ,
1062+ $ "Update action Exclude should contain publisher_id. Actual: [{ string . Join ( "," , updateAction . Fields . Exclude ) } ]") ;
1063+ }
1064+
9081065 /// <summary>
9091066 /// Test that all columns should be excluded if the exclusion contains wildcard character.
9101067 /// </summary>
0 commit comments