@@ -492,6 +492,185 @@ func TestPermissionResolver_Users(t *testing.T) {
492492 })
493493}
494494
495+ // TestPermissionResolver_Filtering verifies that traversing relationships
496+ // (tenant→groups, group→tenant→groups, permissions→children, group→feeds)
497+ // never widens the result set beyond what the querying user is authorized to see.
498+ func TestPermissionResolver_Filtering (t * testing.T ) {
499+ // Hierarchy:
500+ // tl-tenant
501+ // ├── CT-group → feed CT (public)
502+ // ├── BA-group → feed BA (public)
503+ // └── HA-group → feed HA (public), feed EG (non-public)
504+ // restricted-tenant
505+ // └── EX-group (no feeds)
506+ //
507+ // Users:
508+ // "full-user" : admin of tl-tenant → sees all groups/feeds under tl-tenant
509+ // "partial-user" : viewer of CT-group only → sees only CT-group
510+ // "multi-user" : viewer of CT-group + editor of EX-group → spans two tenants
511+ // "nobody" : no tuples → sees nothing
512+ filterTuples := []authz.TupleKey {
513+ // tl-tenant groups
514+ {Subject : authz .NewEntityKey (authz .TenantType , "tl-tenant" ), Object : authz .NewEntityKey (authz .GroupType , "CT-group" ), Relation : authz .ParentRelation },
515+ {Subject : authz .NewEntityKey (authz .TenantType , "tl-tenant" ), Object : authz .NewEntityKey (authz .GroupType , "BA-group" ), Relation : authz .ParentRelation },
516+ {Subject : authz .NewEntityKey (authz .TenantType , "tl-tenant" ), Object : authz .NewEntityKey (authz .GroupType , "HA-group" ), Relation : authz .ParentRelation },
517+ // restricted-tenant groups
518+ {Subject : authz .NewEntityKey (authz .TenantType , "restricted-tenant" ), Object : authz .NewEntityKey (authz .GroupType , "EX-group" ), Relation : authz .ParentRelation },
519+ // Feed assignments
520+ {Subject : authz .NewEntityKey (authz .GroupType , "CT-group" ), Object : authz .NewEntityKey (authz .FeedType , "CT" ), Relation : authz .ParentRelation },
521+ {Subject : authz .NewEntityKey (authz .GroupType , "BA-group" ), Object : authz .NewEntityKey (authz .FeedType , "BA" ), Relation : authz .ParentRelation },
522+ {Subject : authz .NewEntityKey (authz .GroupType , "HA-group" ), Object : authz .NewEntityKey (authz .FeedType , "HA" ), Relation : authz .ParentRelation },
523+ {Subject : authz .NewEntityKey (authz .GroupType , "HA-group" ), Object : authz .NewEntityKey (authz .FeedType , "EG" ), Relation : authz .ParentRelation },
524+ // full-user: admin of tl-tenant (inherits access to all children)
525+ {Subject : authz .NewEntityKey (authz .UserType , "full-user" ), Object : authz .NewEntityKey (authz .TenantType , "tl-tenant" ), Relation : authz .AdminRelation },
526+ // partial-user: viewer of CT-group only, member of tl-tenant
527+ {Subject : authz .NewEntityKey (authz .UserType , "partial-user" ), Object : authz .NewEntityKey (authz .TenantType , "tl-tenant" ), Relation : authz .MemberRelation },
528+ {Subject : authz .NewEntityKey (authz .UserType , "partial-user" ), Object : authz .NewEntityKey (authz .GroupType , "CT-group" ), Relation : authz .ViewerRelation },
529+ // multi-user: viewer of CT-group + editor of EX-group (spans two tenants)
530+ {Subject : authz .NewEntityKey (authz .UserType , "multi-user" ), Object : authz .NewEntityKey (authz .TenantType , "tl-tenant" ), Relation : authz .MemberRelation },
531+ {Subject : authz .NewEntityKey (authz .UserType , "multi-user" ), Object : authz .NewEntityKey (authz .GroupType , "CT-group" ), Relation : authz .ViewerRelation },
532+ {Subject : authz .NewEntityKey (authz .UserType , "multi-user" ), Object : authz .NewEntityKey (authz .TenantType , "restricted-tenant" ), Relation : authz .MemberRelation },
533+ {Subject : authz .NewEntityKey (authz .UserType , "multi-user" ), Object : authz .NewEntityKey (authz .GroupType , "EX-group" ), Relation : authz .EditorRelation },
534+ }
535+ opts := testconfig.Options {
536+ FGAEndpoint : testutil .FGAServer (t ),
537+ FGAModelFile : testdata .Path ("server/authz/tls.json" ),
538+ FGAModelTuples : filterTuples ,
539+ }
540+ cfg := testconfig .Config (t , opts )
541+
542+ // Helper to collect names from a gjson array
543+ names := func (arr []gjson.Result , key string ) []string {
544+ var out []string
545+ for _ , r := range arr {
546+ out = append (out , r .Get (key ).Str )
547+ }
548+ return out
549+ }
550+
551+ t .Run ("partial-user tenant groups filtered" , func (t * testing.T ) {
552+ c := newPermTestClientFromConfig (cfg , "partial-user" )
553+ jj := postQuery (t , c , `{ tenants { name groups { name } } }` , nil )
554+ for _ , tenant := range gjson .Get (jj , "tenants" ).Array () {
555+ if tenant .Get ("name" ).Str != "tl-tenant" {
556+ continue
557+ }
558+ groupNames := names (tenant .Get ("groups" ).Array (), "name" )
559+ assert .Contains (t , groupNames , "CT-group" )
560+ assert .NotContains (t , groupNames , "BA-group" , "partial-user should not see BA-group" )
561+ assert .NotContains (t , groupNames , "HA-group" , "partial-user should not see HA-group" )
562+ return
563+ }
564+ t .Fatal ("tl-tenant not found" )
565+ })
566+
567+ t .Run ("partial-user group tenant groups no leak" , func (t * testing.T ) {
568+ // Traversing group → tenant → groups must not widen results
569+ c := newPermTestClientFromConfig (cfg , "partial-user" )
570+ jj := postQuery (t , c , `{ groups { name tenant { name groups { name } } } }` , nil )
571+ groups := gjson .Get (jj , "groups" ).Array ()
572+ assert .Equal (t , 1 , len (groups ), "partial-user should see exactly 1 group" )
573+ assert .Equal (t , "CT-group" , groups [0 ].Get ("name" ).Str )
574+ tenantGroups := names (groups [0 ].Get ("tenant.groups" ).Array (), "name" )
575+ assert .Contains (t , tenantGroups , "CT-group" )
576+ assert .NotContains (t , tenantGroups , "BA-group" , "traversal should not widen to BA-group" )
577+ assert .NotContains (t , tenantGroups , "HA-group" , "traversal should not widen to HA-group" )
578+ })
579+
580+ t .Run ("partial-user permissions children filtered" , func (t * testing.T ) {
581+ c := newPermTestClientFromConfig (cfg , "partial-user" )
582+ jj := postQuery (t , c , `{ tenants { name permissions { children { type name } } } }` , nil )
583+ for _ , tenant := range gjson .Get (jj , "tenants" ).Array () {
584+ if tenant .Get ("name" ).Str != "tl-tenant" {
585+ continue
586+ }
587+ childNames := names (tenant .Get ("permissions.children" ).Array (), "name" )
588+ assert .Contains (t , childNames , "CT-group" )
589+ assert .NotContains (t , childNames , "BA-group" , "permissions children should not include BA-group" )
590+ assert .NotContains (t , childNames , "HA-group" , "permissions children should not include HA-group" )
591+ return
592+ }
593+ t .Fatal ("tl-tenant not found" )
594+ })
595+
596+ t .Run ("partial-user group feeds" , func (t * testing.T ) {
597+ c := newPermTestClientFromConfig (cfg , "partial-user" )
598+ jj := postQuery (t , c , `{ groups { name feeds { onestop_id } } }` , nil )
599+ groups := gjson .Get (jj , "groups" ).Array ()
600+ assert .Equal (t , 1 , len (groups ))
601+ feedIDs := names (groups [0 ].Get ("feeds" ).Array (), "onestop_id" )
602+ assert .Contains (t , feedIDs , "CT" )
603+ assert .NotContains (t , feedIDs , "BA" )
604+ })
605+
606+ t .Run ("full-user sees all tenant groups" , func (t * testing.T ) {
607+ c := newPermTestClientFromConfig (cfg , "full-user" )
608+ jj := postQuery (t , c , `{ tenants { name groups { name } } }` , nil )
609+ for _ , tenant := range gjson .Get (jj , "tenants" ).Array () {
610+ if tenant .Get ("name" ).Str != "tl-tenant" {
611+ continue
612+ }
613+ groupNames := names (tenant .Get ("groups" ).Array (), "name" )
614+ assert .Contains (t , groupNames , "CT-group" )
615+ assert .Contains (t , groupNames , "BA-group" )
616+ assert .Contains (t , groupNames , "HA-group" )
617+ return
618+ }
619+ t .Fatal ("tl-tenant not found" )
620+ })
621+
622+ t .Run ("full-user sees non-public feed via group" , func (t * testing.T ) {
623+ c := newPermTestClientFromConfig (cfg , "full-user" )
624+ jj := postQuery (t , c , `{ groups { name feeds { onestop_id } } }` , nil )
625+ for _ , group := range gjson .Get (jj , "groups" ).Array () {
626+ if group .Get ("name" ).Str != "HA-group" {
627+ continue
628+ }
629+ feedIDs := names (group .Get ("feeds" ).Array (), "onestop_id" )
630+ assert .Contains (t , feedIDs , "HA" )
631+ assert .Contains (t , feedIDs , "EG" , "admin should see non-public feed EG via HA-group" )
632+ return
633+ }
634+ t .Fatal ("HA-group not found" )
635+ })
636+
637+ t .Run ("multi-user cross-tenant isolation" , func (t * testing.T ) {
638+ c := newPermTestClientFromConfig (cfg , "multi-user" )
639+ jj := postQuery (t , c , `{ tenants { name groups { name } } }` , nil )
640+ tenants := gjson .Get (jj , "tenants" ).Array ()
641+ assert .GreaterOrEqual (t , len (tenants ), 2 , "multi-user should see two tenants" )
642+ for _ , tenant := range tenants {
643+ groupNames := names (tenant .Get ("groups" ).Array (), "name" )
644+ switch tenant .Get ("name" ).Str {
645+ case "tl-tenant" :
646+ assert .Contains (t , groupNames , "CT-group" )
647+ assert .NotContains (t , groupNames , "BA-group" , "multi-user should not see BA-group" )
648+ assert .NotContains (t , groupNames , "HA-group" , "multi-user should not see HA-group" )
649+ case "restricted-tenant" :
650+ assert .Contains (t , groupNames , "EX-group" )
651+ }
652+ }
653+ })
654+
655+ t .Run ("multi-user top-level groups" , func (t * testing.T ) {
656+ c := newPermTestClientFromConfig (cfg , "multi-user" )
657+ jj := postQuery (t , c , `{ groups { name } }` , nil )
658+ groupNames := names (gjson .Get (jj , "groups" ).Array (), "name" )
659+ assert .Contains (t , groupNames , "CT-group" )
660+ assert .Contains (t , groupNames , "EX-group" )
661+ assert .NotContains (t , groupNames , "BA-group" )
662+ assert .NotContains (t , groupNames , "HA-group" )
663+ })
664+
665+ t .Run ("nobody sees nothing" , func (t * testing.T ) {
666+ c := newPermTestClientFromConfig (cfg , "nobody" )
667+ jj := postQuery (t , c , `{ tenants { id } }` , nil )
668+ assert .Equal (t , 0 , len (gjson .Get (jj , "tenants" ).Array ()))
669+ jj = postQuery (t , c , `{ groups { id } }` , nil )
670+ assert .Equal (t , 0 , len (gjson .Get (jj , "groups" ).Array ()))
671+ })
672+ }
673+
495674func TestPermissionResolver_NilPermissionManager (t * testing.T ) {
496675 srv , _ := NewServer ()
497676 cfg := testconfig .Config (t , testconfig.Options {})
0 commit comments