Browse Source

fix(page-grant): permit GRANT_RESTRICTED child under any parent grant

GRANT_RESTRICTED ("anyone with the link") is a link-only sharing mode
and is orthogonal to the page tree hierarchy. Putting a RESTRICTED page
under an OWNER parent was previously rejected by isGrantNormalized,
which surfaced the "Fix Page Grant" alert even though the user
deliberately chose this combination.

Add an early return at the top of validateGrant so RESTRICTED targets
are always considered normalized, regardless of the ancestor grant.

Refs #9315

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Yuki Takei 3 days ago
parent
commit
7b57489064

+ 6 - 0
apps/app/src/server/service/page-grant.ts

@@ -199,6 +199,12 @@ class PageGrantService implements IPageGrantService {
 
     const Page = mongoose.model<IPage, PageModel>('Page');
 
+    // GRANT_RESTRICTED pages are link-only and orthogonal to the page tree hierarchy.
+    // They are intentionally permitted under any parent grant.
+    if (target.grant === Page.GRANT_RESTRICTED) {
+      return true;
+    }
+
     /*
      * ancestor side
      */

+ 96 - 0
apps/app/src/server/service/page/page-grant.integ.ts

@@ -722,6 +722,102 @@ describe('PageGrantService', () => {
     });
   });
 
+  /*
+   * GRANT_RESTRICTED ("anyone with the link") is a link-only sharing mode
+   * and is intentionally orthogonal to the page tree hierarchy.
+   * A RESTRICTED child must be permitted under any parent grant.
+   * See: https://github.com/growilabs/growi/issues/9315
+   */
+  describe('Test isGrantNormalized method for GRANT_RESTRICTED target', () => {
+    it('Should return true when Ancestor: owned by User1, Target: anyone with the link', async () => {
+      const targetPath = `${pageE2User1Path}/NEW`;
+      const grant = Page.GRANT_RESTRICTED;
+      const grantedUserIds = undefined;
+      const grantedGroupIds: {
+        item: mongoose.Types.ObjectId;
+        type: GroupType;
+      }[] = [];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user1,
+        targetPath,
+        grant,
+        grantedUserIds,
+        grantedGroupIds,
+        shouldCheckDescendants,
+      );
+
+      expect(result).toBe(true);
+    });
+
+    it('Should return true when Ancestor: owned by GroupParent, Target: anyone with the link', async () => {
+      const targetPath = `${pageE3GroupParentPath}/NEW`;
+      const grant = Page.GRANT_RESTRICTED;
+      const grantedUserIds = undefined;
+      const grantedGroupIds: {
+        item: mongoose.Types.ObjectId;
+        type: GroupType;
+      }[] = [];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user1,
+        targetPath,
+        grant,
+        grantedUserIds,
+        grantedGroupIds,
+        shouldCheckDescendants,
+      );
+
+      expect(result).toBe(true);
+    });
+
+    it('Should return true when Ancestor: public, Target: anyone with the link', async () => {
+      const targetPath = `${pageE1PublicPath}/NEW`;
+      const grant = Page.GRANT_RESTRICTED;
+      const grantedUserIds = undefined;
+      const grantedGroupIds: {
+        item: mongoose.Types.ObjectId;
+        type: GroupType;
+      }[] = [];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user1,
+        targetPath,
+        grant,
+        grantedUserIds,
+        grantedGroupIds,
+        shouldCheckDescendants,
+      );
+
+      expect(result).toBe(true);
+    });
+
+    it('Should still return false when Ancestor: owned by User1, Target: public (regression guard for non-RESTRICTED targets under OWNER)', async () => {
+      const targetPath = `${pageE2User1Path}/NEW`;
+      const grant = Page.GRANT_PUBLIC;
+      const grantedUserIds = undefined;
+      const grantedGroupIds: {
+        item: mongoose.Types.ObjectId;
+        type: GroupType;
+      }[] = [];
+      const shouldCheckDescendants = false;
+
+      const result = await pageGrantService.isGrantNormalized(
+        user1,
+        targetPath,
+        grant,
+        grantedUserIds,
+        grantedGroupIds,
+        shouldCheckDescendants,
+      );
+
+      expect(result).toBe(false);
+    });
+  });
+
   describe('Test isGrantNormalized method with shouldCheckDescendants true', () => {
     it('Should return true when Target: public, Descendant: public', async () => {
       const targetPath = emptyPagePath1;