fix(permissions): File Asset Containers inherit from their folder, not the Site - #37332
fix(permissions): File Asset Containers inherit from their folder, not the Site#37332jcastro-dotcms wants to merge 19 commits into
Conversation
…nce defect
A File Asset Container and its container.vtl file asset report the same
permission id (the file asset identifier, via WebAsset.getPermissionId)
and the same permission type (Contentlet, via the override in
FileAssetContainer), so both collapse onto the same permission_reference
row. They resolve different parents, though:
- Contentlet.getParentPermissionable() returns the container folder,
the only parent that honours the folder's individual permissions.
- Container.getParentPermissionable() returns the Site, falling back
to System Host when the parent Site cannot be resolved.
Whichever code path rebuilds the reference last wins and is persisted,
so a limited user granted View on the container folder can silently lose
the Container from the Template Builder picker, and the loss survives a
permission cache flush because Step 2 of loadPermissions() then reads a
non-empty list back from the poisoned row.
Adds an integration test covering the reference target, the resulting
picker visibility, and the editor/reviewer role split. The test is not
registered in MainSuite1a yet because it currently fails; it should be
added once the defect is fixed.
Refs support ticket 38795, related to #35680 and #33665.
Two failures in the local run were setup problems in the test, not the defect under test: - Users created by UserDataGen are not back-end users, and PermissionBitAPIImpl refuses READ on a non-live Contentlet for a non-back-end user. container.vtl only has a working version, so the Container was filtered out of the picker before permissions were even consulted. Both test users now hold the Back-end User role. - The reviewer role had an inheritable grant on the Site but no View on the Site itself, so findFolderAssetContainers() hit a DotSecurityException, swallowed it, and returned an empty list. The core reproduction is unaffected: loaded as a Contentlet the permission reference resolves to the container folder; loaded as a FileAssetContainer the same asset resolves to SYSTEM_HOST instead -- the same value observed on the affected customer environment. Refs support ticket 38795.
The first assertion-level test passed on one run while the picker-level test failed on the same build, which cannot both be true. Rather than guess, capture the permission_reference value after each reset and each load and report all of them in every assertion message. Also asserts that both resets actually cleared the row. If they did not, no parent walk-up happened and a passing result would be meaningless -- that is the most likely explanation for the inconsistency and this makes it visible instead of silent. Refs support ticket 38795.
…clean up Two problems, one of which explains the inconsistent result between runs. Resolving the asset was happening *after* the reference row was cleared. Both ContentletAPI.findContentletByIdentifier() and ContainerAPI.getWorkingContainerById() can trigger a permission load while resolving, which rebuilds the reference through whichever identity they use internally. The load under test then found a populated row, returned at step 2 of loadPermissions() and never walked up -- so the assertion passed without exercising the code path it claims to cover. Both objects are now resolved before the first reset, and the load under test is the only permission load after it. Also adds per-test teardown for the Site, Users and Roles. Every test builds its own Site and container folder so leftovers cannot change a result, but without cleanup they accumulated on each run. Refs support ticket 38795.
The reference-level and picker-level tests now reproduce the defect deterministically. The role-split case still fails on its sanity check, so give it the same observed-values report rather than guessing which parent the walk-up settled on. Also removes test users with failSilently, since UserAPIImpl.delete() can fail resolving a replacement role and the stack trace was noise on an otherwise clean run. Refs support ticket 38795.
The role-split case was failing before it reached the behaviour it tests. The Container picker resolves each container's folder as the requesting user and silently skips it when that throws (ContainerFactoryImpl.findContainersAssetsByHost, ~line 808). The reviewer role held View on the Site but nothing on the container folder, whose inheritance the scenario deliberately breaks -- so the folder was dropped and the picker returned nothing regardless of where the permission reference pointed. Grants the reviewer role View on the folder itself, and deliberately not an inheritable grant on the folder's child content, so its view of the Container still depends on the Site-level grant. That is the asymmetry under test, and it matches the affected customer, whose Reviewer roles do hold View on the container folder. Refs support ticket 38795.
…older A File Asset Container is the container.vtl file under /application/containers/<name>/, and it already reports Contentlet as its permission type so that both views of the file share one permission identity. Its parent Permissionable, however, was still the Container one -- the Site -- while the Contentlet view of the same file resolves the Container folder. Because permission_reference is keyed by asset_id alone, whichever view was loaded last overwrote the row written by the other one. Once the Container view won, every grant made on the Container folder was replaced by whatever the Site inherits, and roles that only hold View on the folder stopped seeing the Container in the page layout editor. FileAssetContainer now resolves its parent Permissionable to the Container folder, so both views agree and the reference row is stable. Instances that do not carry a Site or a path -- such as the stub built while container.vtl is being deleted -- keep the previous behavior. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CsKDCNudZcysg4sPKHijU
…ed role The role-split test asserted that a role holding only a Site-level inheritable grant keeps the Container. That was the broken behavior: it only held because the reference resolved to the Site and skipped the container folder entirely. Once the reference resolves to the folder, the folder is the nearest ancestor carrying an inheritable Contentlet set, so the walk-up stops there and the Site's grants no longer reach the asset -- the same rule container.vtl already obeys when it is read as a file. The test now asserts that, plus the reference resolving to the folder even when the Site has an inheritable grant of its own, which is the case that produced the Site id seen in the customer's database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CsKDCNudZcysg4sPKHijU
Now that a File Asset Container inherits its permissions from the Container folder, getParentPermissionable() no longer returns a Host for every Container, and two callers were casting the result to one. ContainerPaginator.hostname() blew up with a ClassCastException while sorting, which took down the whole GET /api/v1/containers response -- so the Template Builder listed no Containers at all. It now asks the Container for its own Site name, which both Container and FileAssetContainer already answer correctly, and falls back to an empty sort key instead of failing the request. The GraphQL "parentPermissionable" field on a Container is declared as a Site, so it now resolves the Site from the Container's own host id rather than from its permission parent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CsKDCNudZcysg4sPKHijU
|
Claude finished @jcastro-dotcms's task in 1m 44s —— View job Code Review: File Asset Container permission inheritance
I reviewed the diff ( New IssuesNo issues found. The change is sound and internally consistent:
Blast radius verified: the only other Resolved
Test coverage is thorough: three failing-before/passing-after methods cover the reference-parent mismatch, the user-visible picker regression, and the role-split case, and the new class is registered in LGTM. · branch |
…ookup findContainerFolder() wrapped the folder lookup in Try.of(...).getOrNull(), which caught everything. A transient DotDataException therefore looked exactly like "this instance has no folder": getParentPermissionable() answered with the Site, and because the permission reference upsert is unconditional, that wrong parent was persisted and stayed until something cleared the row -- the defect this override exists to prevent, reintroduced silently and with nothing logged. The two cases were already separable. FolderAPI.findFolderByPath() returns null or an inode-less Folder when the path does not resolve, and only raises when the lookup itself fails. So a missing folder still returns null and the caller falls back to the Site, while a failed lookup now propagates: nothing is written, and the next request resolves the reference again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CsKDCNudZcysg4sPKHijU
|
Valid finding — fixed in bf637b0. I verified all three factual claims:
On the assumption you flagged for verification — a transient failure being realistic rather than only "folder missing": yes. I went further than the suggested remedy. Logging alone would make the failure diagnosable but still persist the wrong parent. The two cases turn out to be already separable in the API — final Folder folder = folderFactory.findFolderByPath(path, host);
if (folder == null || UtilMethods.isEmpty(folder.getInode()) || permissionAPI.doesUserHavePermission(...)) {
return folder;
}A genuinely absent folder comes back as null or inode-less — it does not throw. It only throws on a real failure. The So the fix keeps the null / inode-less / Net effect: a failed lookup now fails loudly, nothing is written, and the next request resolves the reference again. That matches the principle already stated three lines above the upsert in the #35680 comment — "a failed walk-up must be retried, not persisted" — applied to the reference row rather than the cache. One trade-off worth naming for human reviewers: propagating turns a transient DB blip into a failed page render instead of a silent degradation to Site permissions. For a permission resolver I think loud-and-retryable beats quietly-wrong-and-durable, but it's a judgment call someone may want to take the other way. Generated by Claude Code |
… the class PERMISSION_REFERENCES_UPDATE_ASYNC was pinned to false in @BeforeClass and never put back. The property defaults to true and this class runs inside MainSuite3a, so every class scheduled after it inherited synchronous permission-reference upserts -- a global side effect outliving the test that set it. The old value is now captured and restored in @afterclass, matching what ContentDriveLinksTest and MenuLinkAPITest already do for this same flag. They scope it per method with try/finally; all three methods here need it, so it stays class-scoped, which is precisely why the restore has to exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CsKDCNudZcysg4sPKHijU
|
Valid finding — fixed in f1dfff3. Confirmed: the class had only Also confirmed both cited precedents do capture-and-restore: The fix captures the old value in The property name is also lifted to a constant now that it appears three times. Generated by Claude Code |
…-permissions-resolve-to-site' into issue-37331-file-asset-container-permissions-resolve-to-site
|
Closing this PR as several commits are not verified. New PR: #37402 |
Proposed Changes
This PR fixes: #37331
A File Asset Container is the
container.vtlfile under/application/containers/<name>/. It already reportsContentletas its permission type, so both views of the file share one permission identity — but its parent Permissionable was still theContainerone (the Site), while theContentletview of the same file resolves the container folder.Because
permission_referenceis keyed byasset_idalone (unique (asset_id)), whichever view was loaded last overwrote the row written by the other. Once the Container view won, every grant made on the Container folder was replaced by whatever the Site inherits, and roles holding View only on the folder stopped seeing the Container in the layout editor.FileAssetContainer.getParentPermissionable()— new override resolving the Container folder from the instance's own Site and path, so both identities agree and the reference row is stable. Instances carrying neither Site nor path (the stub built whilecontainer.vtlis being deleted) keep the previous behavior.ContainerPaginator.hostname()— wasHost.class.cast(container.getParentPermissionable())while sorting. That cast now receives aFolder, and the resultingClassCastExceptionescapes the wholeGET /api/v1/containersrequest, so the Template Builder lists no Containers at all. It now asks the Container for its own Site name viagetHostName(), which both Container types answer correctly, and falls back to an empty sort key rather than failing the request.PageAPIGraphQLTypesProvider— the Container'sparentPermissionableGraphQL field is declared as a Site, so it now resolves the Site from the Container's own host id instead of from its permission parent.FileAssetContainerPermissionInheritanceTest— new integration test, registered inMainSuite3a.The main review question for this change is "does any caller expect a
Hostback fromgetParentPermissionable()?". Every consumer indotCMS/src/mainwas audited; the two above were the only ones affected.Container.getHostId()/getHostName()also cast, butFileAssetContaineroverrides both from its own Site field.Behavior change worth calling out: once the reference points at the folder and that folder carries its own inheritable permissions, the folder is the nearest ancestor with an inheritable
Contentletset, so Site-level grants no longer reach the Container. A role that could see a Container purely through a Site-level inheritable content grant, with nothing granted on the folder, loses it. That is the rulecontainer.vtlalready obeys when read as a file — which is precisely why the two identities must agree — but it is user-visible for environments relying on the previous behavior.Checklist
This is a permission-resolution change, so it was treated as security-relevant throughout. It makes the effective permissions of a File Asset Container match the folder that administrators actually configured, rather than silently substituting the Site's. No permission is widened: the folder's own grants become authoritative, and the Site's inheritable grants stop leaking past a folder that has broken inheritance. The behavior change noted above is a narrowing, not a widening.
Additional Info
Three integration test methods, all failing before the fix and passing after it:
permissionReferenceForFileAssetContainerMustResolveToContainerFolderlimitedUserMustKeepSeeingContainerAfterReferenceIsRebuiltAsContainerfolderGrantedRoleMustNotLoseViewToSiteLevelGrantOfAnotherRoleSYSTEM_HOST.The test sets
PERMISSION_REFERENCES_UPDATE_ASYNCtofalse, since the reference write is otherwise performed on a background thread and the assertions would race it.Manual reproduction steps for QA, verified on an unfixed build, are in #37331.
Screenshots
N/A — the defect and the fix are both observable through
GET /api/v1/containers/and thepermission_referencetable; there is no visual change.🤖 Generated with Claude Code
https://claude.ai/code/session_012CsKDCNudZcysg4sPKHijU
Generated by Claude Code