Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
import org.broadleafcommerce.openadmin.dto.Entity;
import org.broadleafcommerce.openadmin.dto.PersistencePackage;
import org.broadleafcommerce.openadmin.dto.Property;
import org.broadleafcommerce.openadmin.dto.SectionCrumb;
import org.broadleafcommerce.openadmin.server.security.domain.AdminPermission;
import org.broadleafcommerce.openadmin.server.security.domain.AdminRole;
import org.broadleafcommerce.openadmin.server.security.domain.AdminUser;
Expand All @@ -40,17 +39,13 @@
import org.broadleafcommerce.openadmin.server.security.service.type.PermissionType;
import org.broadleafcommerce.openadmin.server.service.ValidationException;
import org.broadleafcommerce.openadmin.server.service.persistence.validation.GlobalValidationResult;
import org.springframework.cglib.core.CollectionUtils;
import org.springframework.cglib.core.Transformer;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

import jakarta.annotation.Resource;

Expand Down Expand Up @@ -133,17 +128,10 @@ public AdminUser getPersistentAdminUser() {

@Override
public void securityCheck(PersistencePackage persistencePackage, EntityOperationType operationType) throws ServiceException {
Set<String> ceilingNames = new HashSet<>();
ceilingNames.add(persistencePackage.getSecurityCeilingEntityFullyQualifiedClassname());
if (!ArrayUtils.isEmpty(persistencePackage.getSectionCrumbs())) {
ceilingNames.addAll(CollectionUtils.transform(Arrays.asList(persistencePackage.getSectionCrumbs()),
new Transformer() {
@Override
public Object transform(Object o) {
return ((SectionCrumb) o).getSectionIdentifier();
}
}));
}
//Authorization is performed exclusively against the security ceiling of the entity actually being operated
//on. Section crumbs are client supplied navigation state and must never widen the set of ceilings the user
//is authorized against.
String securityCeiling = persistencePackage.getSecurityCeilingEntityFullyQualifiedClassname();
Comment on lines +131 to +134

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Sub-collection requests now authorize against the collection entity, not the owning section

The PR description states "nested-collection requests continue to authorize against the owning section's ceiling as before", but that is only true for the LOOKUP_FOR_UPDATE path that now explicitly sets a security ceiling (admin/broadleaf-open-admin-platform/src/main/java/org/broadleafcommerce/openadmin/server/service/AdminEntityServiceImpl.java:657). For ordinary sub-collection fetch/add/update/remove, PersistencePackageRequest.fromMetadata sets only ceilingEntityClassname to the collection ceiling (see admin/broadleaf-open-admin-platform/src/main/java/org/broadleafcommerce/openadmin/server/domain/PersistencePackageRequest.java:143-176 and AdminEntityServiceImpl.java:395-437), and PersistencePackage.getSecurityCeilingEntityFullyQualifiedClassname() falls back to that ceiling. Previously the section crumb ceiling (the owning section entity) also satisfied the check via the OR loop; now it does not. OSS seed data in core/broadleaf-framework/src/main/resources/config/bc/sql/load_admin_permissions.sql does register most collection entities (CategoryXrefImpl, ProductOptionXref, SkuAttribute, ...), so out-of-the-box flows should keep working, but downstream projects with custom collections that were never registered in BLC_ADMIN_PERMISSION_ENTITY will start getting SecurityServiceException on collection screens. Worth calling out explicitly in release notes/upgrade docs.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and that tightening is inherent to the fix: any path that previously passed only because a crumb ceiling satisfied the OR now requires permission on the target ceiling. OSS seed data covers the standard collection entities, but downstream projects with unregistered custom collection entities will need BLC_ADMIN_PERMISSION_ENTITY records. I've called this out in the PR description as an upgrade note.


Entity entity = persistencePackage.getEntity();

Expand Down Expand Up @@ -186,14 +174,18 @@ public Object transform(Object o) {
}
}

securityCheck(ceilingNames.toArray(new String[ceilingNames.size()]), operationType);
securityCheck(securityCeiling, operationType);
}

@Override
public void securityCheck(String ceilingEntityFullyQualifiedName, EntityOperationType operationType) throws ServiceException {
securityCheck(new String[]{ceilingEntityFullyQualifiedName}, operationType);
}

/**
* Verifies the current admin user is qualified for the given operation on <b>every</b> supplied ceiling. A user
* must never gain access to one ceiling by virtue of holding a permission on another.
*/
protected void securityCheck(String[] ceilingNames, EntityOperationType operationType) throws ServiceException {
Comment on lines +185 to 189

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Multi-ceiling helper semantics flipped from OR to AND with no remaining multi-ceiling caller

After this change the only production caller reaches securityCheck(String[]) through the single-string overload, so the AND semantics are currently unobservable outside tests. The method remains protected, meaning subclasses in downstream modules that previously relied on the OR ("qualified for any ceiling") contract will silently get inverted behavior. Since no in-repo caller passes multiple ceilings, consider making the array overload private or renaming it to make the new contract explicit for downstream extenders.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentionally left protected — narrowing visibility would be a source-breaking change for downstream extenders, and the inverted contract is the point of the fix (a subclass relying on the OR contract is relying on the vulnerability). The javadoc states the new AND semantics explicitly.

if (ArrayUtils.isEmpty(ceilingNames)) {
throw new SecurityServiceException("Security Check Failed: ceilingNames not specified");
Expand Down Expand Up @@ -228,25 +220,23 @@ protected void securityCheck(String[] ceilingNames, EntityOperationType operatio
}

SecurityServiceException primaryException = null;
boolean isQualified = false;
String unqualifiedCeiling = null;
for (String ceilingEntityFullyQualifiedName : ceilingNames) {
isQualified = securityService.isUserQualifiedForOperationOnCeilingEntity(
boolean isQualified = securityService.isUserQualifiedForOperationOnCeilingEntity(
persistentAdminUser, permissionType, ceilingEntityFullyQualifiedName
);
if (!isQualified) {
if (primaryException == null) {
primaryException = new SecurityServiceException("Security Check Failed for entity operation: "
+ operationType.toString() + " (" + ceilingEntityFullyQualifiedName + ")");
}
} else {
unqualifiedCeiling = ceilingEntityFullyQualifiedName;
primaryException = new SecurityServiceException("Security Check Failed for entity operation: "
+ operationType.toString() + " (" + ceilingEntityFullyQualifiedName + ")");
break;
}
}
if (!isQualified) {
if (primaryException != null) {
//check if the requested entity is not configured and warn
if (!securityService.doesOperationExistForCeilingEntity(permissionType, ceilingNames[0])) {
if (!securityService.doesOperationExistForCeilingEntity(permissionType, unqualifiedCeiling)) {
if (LOG.isWarnEnabled()) {
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
LOG.warn("Detected security request for an unregistered ceiling entity (" + StringUtil.sanitize(ceilingNames[0]) + "). " +
LOG.warn("Detected security request for an unregistered ceiling entity (" + StringUtil.sanitize(unqualifiedCeiling) + "). " +
"As a result, the request failed. Please make sure to configure security for any ceiling entities " +
"referenced via the admin. This is usually accomplished by adding records in the " +
"BLC_ADMIN_PERMISSION_ENTITY table. Note, depending on how the entity in question is used, you " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,10 @@ public PersistenceResponse addSubCollectionEntity(

if (fmd.getAddMethodType().equals(AddMethodType.LOOKUP_FOR_UPDATE)) {
ppr.setUpdateLookupType(true);
//the operation updates the looked up member of a collection owned by the entity currently being
//managed, so authorize against that owning entity's ceiling, as derived from server side metadata
ppr.withSecurityCeilingEntityClassname(StringUtils.isNotBlank(mainMetadata.getSecurityCeilingType())
? mainMetadata.getSecurityCeilingType() : mainMetadata.getCeilingType());
Comment on lines +655 to +658

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: LOOKUP_FOR_UPDATE still authorizes only against the owning entity, not the entity being modified

For AddMethodType.LOOKUP_FOR_UPDATE the persistence operation is a full update of the looked-up target record, yet authorization is now performed against the owning entity's ceiling (mainMetadata), mirroring the old sectionCrumbs[0] behavior. The client-controlled input is removed (good), but a user holding only UPDATE on the owning section can still mutate the target entity without holding permission on it. There are no OSS usages of LOOKUP_FOR_UPDATE, so this is latent; consider whether the target ceiling should additionally be checked (the new AND-semantics helper would support passing both).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the other LOOKUP_FOR_UPDATE thread — noted as latent and left unchanged deliberately; see #97 (comment).

Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines +655 to +658

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Security ceiling for lookup-for-update now derives from the current section rather than the first crumb

mainMetadata in all three addSubCollectionEntity callers (AdminBasicEntityController selectize-add, add, addEmpty) is obtained via getSectionPersistencePackageRequest(mainClassName, ...), and AdminEntityServiceImpl.getClassMetadata (line 111) always populates securityCeilingType (it falls back to the ceiling class), so the isNotBlank(...) ? ... : getCeilingType() fallback here is effectively dead but harmless. Behaviorally, the ceiling used is now the current section entity, whereas the removed code in add(...) used sectionCrumbs[0], which after getSectionCrumbs appends the current section last is the outermost (client-supplied) crumb — i.e. this is a genuine tightening, not merely a source change. One residual difference: when no crumbs were present the old code left the security ceiling as the looked-up target class; now the owning entity's ceiling is always used, which is slightly more permissive in that (rare) case.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the analysis matches my reading. On the residual no-crumbs case: it only arises for LOOKUP_FOR_UPDATE, which has no OSS usages, and the alternatives (leaving the ceiling as the looked-up target, or requiring both ceilings via the AND helper) are behavior decisions that would deny flows working today, so I'm surfacing them to the maintainers rather than deciding here. The fallback to getCeilingType() is defensive only, consistent with other callers.

}

Property fp = new Property();
Expand Down Expand Up @@ -1003,12 +1007,6 @@ public PersistenceResponse add(PersistencePackageRequest request, boolean transa
PersistencePackage pkg = persistencePackageFactory.create(request);
try {
if (request.isUpdateLookupType()) {
if (pkg.getSectionCrumbs() != null && pkg.getSectionCrumbs().length > 0) {
SectionCrumb sc = pkg.getSectionCrumbs()[0];
if (StringUtils.isNotBlank(sc.getSectionIdentifier())) {
pkg.setSecurityCeilingEntityFullyQualifiedClassname(sc.getSectionIdentifier());
}
}
if (transactional) {
return service.update(pkg);
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*-
* #%L
* BroadleafCommerce Open Admin Platform
* %%
* Copyright (C) 2009 - 2026 Broadleaf Commerce
* %%
* Licensed under the Broadleaf Fair Use License Agreement, Version 1.0
* (the "Fair Use License" located at http://license.broadleafcommerce.org/fair_use_license-1.0.txt)
* unless the restrictions on use therein are violated and require payment to Broadleaf in which case
* the Broadleaf End User License Agreement (EULA), Version 1.1
* (the "Commercial License" located at http://license.broadleafcommerce.org/commercial_license-1.1.txt)
* shall apply.
*
* Alternatively, the Commercial License may be replaced with a mutually agreed upon license (the "Custom License")
* between you and Broadleaf Commerce. You may not use this file except in compliance with the applicable license.
* #L%
*/
package org.broadleafcommerce.openadmin.spec

import org.broadleafcommerce.common.exception.SecurityServiceException
import org.broadleafcommerce.openadmin.dto.PersistencePackage
import org.broadleafcommerce.openadmin.dto.SectionCrumb
import org.broadleafcommerce.openadmin.server.security.domain.AdminUser
import org.broadleafcommerce.openadmin.server.security.extension.AdminSecurityCheckExtensionManager
import org.broadleafcommerce.openadmin.server.security.remote.AdminSecurityServiceRemote
import org.broadleafcommerce.openadmin.server.security.remote.EntityOperationType
import org.broadleafcommerce.openadmin.server.security.service.AdminSecurityService
import org.broadleafcommerce.openadmin.server.security.service.RowLevelSecurityService
import org.broadleafcommerce.openadmin.server.security.service.type.PermissionType
import spock.lang.Specification

/**
* Verifies that admin entity authorization is performed against the security ceiling of the entity actually being
* operated on and cannot be satisfied by client supplied section crumbs.
*/
class AdminSecurityServiceRemoteSpec extends Specification {

static final String TARGET_CEILING = "org.broadleafcommerce.openadmin.server.security.domain.AdminUser"
static final String AUTHORIZED_CEILING = "org.broadleafcommerce.core.catalog.domain.Product"

AdminSecurityService securityService
AdminUser adminUser
AdminSecurityServiceRemote remoteService

def setup() {
securityService = Mock(AdminSecurityService)
adminUser = Mock(AdminUser)

//a real manager with no registered handlers always reports NOT_HANDLED
AdminSecurityCheckExtensionManager extensionManager = new AdminSecurityCheckExtensionManager()

AdminUser currentUser = adminUser
remoteService = new AdminSecurityServiceRemote() {
@Override
AdminUser getPersistentAdminUser() {
return currentUser
}
}
remoteService.securityService = securityService
remoteService.securityCheckExtensionManager = extensionManager
remoteService.rowLevelSecurityService = Mock(RowLevelSecurityService)
}

def "section crumbs cannot authorize an operation on an unrelated entity"() {
given:
PersistencePackage pkg = new PersistencePackage()
pkg.setCeilingEntityFullyQualifiedClassname(TARGET_CEILING)
pkg.setSectionCrumbs([crumb(AUTHORIZED_CEILING)] as SectionCrumb[])

when:
remoteService.securityCheck(pkg, EntityOperationType.FETCH)

then:
1 * securityService.isUserQualifiedForOperationOnCeilingEntity(adminUser, PermissionType.READ, TARGET_CEILING) >> false
0 * securityService.isUserQualifiedForOperationOnCeilingEntity(adminUser, _, AUTHORIZED_CEILING)
thrown(SecurityServiceException)
}

def "a permission on the target ceiling authorizes the operation"() {
given:
PersistencePackage pkg = new PersistencePackage()
pkg.setCeilingEntityFullyQualifiedClassname(TARGET_CEILING)
pkg.setSectionCrumbs([crumb(AUTHORIZED_CEILING)] as SectionCrumb[])

when:
remoteService.securityCheck(pkg, EntityOperationType.UPDATE)

then:
1 * securityService.isUserQualifiedForOperationOnCeilingEntity(adminUser, PermissionType.UPDATE, TARGET_CEILING) >> true
notThrown(SecurityServiceException)
}

def "the explicit security ceiling takes precedence over the ceiling entity"() {
given:
PersistencePackage pkg = new PersistencePackage()
pkg.setCeilingEntityFullyQualifiedClassname(AUTHORIZED_CEILING)
pkg.setSecurityCeilingEntityFullyQualifiedClassname(TARGET_CEILING)

when:
remoteService.securityCheck(pkg, EntityOperationType.ADD)

then:
1 * securityService.isUserQualifiedForOperationOnCeilingEntity(adminUser, PermissionType.CREATE, TARGET_CEILING) >> false
thrown(SecurityServiceException)
}

protected SectionCrumb crumb(String sectionIdentifier) {
SectionCrumb crumb = new SectionCrumb()
crumb.setSectionIdentifier(sectionIdentifier)
crumb.setSectionId("1")
return crumb
}

}