This is an automated email from the ASF dual-hosted git repository.

pvillard31 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 6c8fed169ad NIFI-15939: Adding support to specify the Process Group 
used to locat… (#11247)
6c8fed169ad is described below

commit 6c8fed169adc598a3a02ce552435b9d4a03eef67
Author: Matt Gilman <[email protected]>
AuthorDate: Tue May 19 12:45:31 2026 -0400

    NIFI-15939: Adding support to specify the Process Group used to locat… 
(#11247)
    
    * NIFI-15939: Adding support to specify the Process Group used to locate 
descedent components when authorizing bulletins.
---
 .../apache/nifi/web/StandardNiFiServiceFacade.java | 109 +++++++++++--
 .../nifi/web/StandardNiFiServiceFacadeTest.java    | 171 +++++++++++++++++++++
 2 files changed, 263 insertions(+), 17 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
index dc82f31094a..9de055e4aa7 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
@@ -4674,36 +4674,105 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
     }
 
     private boolean authorizeBulletin(final Bulletin bulletin) {
+        return authorizeBulletin(bulletin, null);
+    }
+
+    /**
+     * Resolves the bulletin's source authorizable and checks READ for the 
current user.
+     *
+     * <p>When {@code group} is non-null the source is resolved within that 
group's
+     * component hierarchy via the {@code findX} APIs first. This is required 
for
+     * bulletins generated inside a connector's managed flow: the standard
+     * {@link AuthorizableLookup} is backed by {@link 
org.apache.nifi.web.dao.impl.StandardProcessorDAO}
+     * (and its peers) which only walks the {@link 
org.apache.nifi.controller.flow.FlowManager}'s
+     * root group, so any source that lives inside a connector's managed flow 
context
+     * would otherwise resolve to {@link ResourceNotFoundException} and report
+     * {@code canRead = false} for every bulletin -- preventing the canvas from
+     * rendering bulletin icons for the connector's components and child 
groups.
+     * Resolving via the live group preserves the source's authorizable parent 
chain
+     * (which terminates at the connector node via
+     * {@link 
org.apache.nifi.groups.ProcessGroup#setExplicitParentAuthorizable(Authorizable)}),
+     * so READ on the connector correctly grants READ on its inner 
bulletins.</p>
+     *
+     * <p>The fallback to {@code authorizableLookup} handles source types that 
are
+     * not addressable via a {@link ProcessGroup} (controller-scoped 
components such
+     * as reporting tasks and flow analysis rules) and the standard root-flow 
case
+     * when no owning group is supplied.</p>
+     */
+    private boolean authorizeBulletin(final Bulletin bulletin, final 
ProcessGroup group) {
         final String sourceId = bulletin.getSourceId();
         final ComponentType type = bulletin.getSourceType();
 
         final Authorizable authorizable;
         try {
-            authorizable = switch (type) {
-                case PROCESSOR -> 
authorizableLookup.getProcessor(sourceId).getAuthorizable();
-                case REPORTING_TASK -> 
authorizableLookup.getReportingTask(sourceId).getAuthorizable();
-                case FLOW_ANALYSIS_RULE -> 
authorizableLookup.getFlowAnalysisRule(sourceId).getAuthorizable();
-                case FLOW_REGISTRY_CLIENT -> 
authorizableLookup.getFlowRegistryClient(sourceId).getAuthorizable();
-                case PARAMETER_PROVIDER -> 
authorizableLookup.getParameterProvider(sourceId).getAuthorizable();
-                case CONTROLLER_SERVICE -> 
authorizableLookup.getControllerService(sourceId).getAuthorizable();
-                case FLOW_CONTROLLER -> controllerFacade;
-                case INPUT_PORT -> authorizableLookup.getInputPort(sourceId);
-                case OUTPUT_PORT -> authorizableLookup.getOutputPort(sourceId);
-                case REMOTE_PROCESS_GROUP -> 
authorizableLookup.getRemoteProcessGroup(sourceId);
-                case PROCESS_GROUP -> 
authorizableLookup.getProcessGroup(sourceId).getAuthorizable();
-                case CONNECTOR -> authorizableLookup.getConnector(sourceId);
-                default -> throw new IllegalArgumentException("Unexpected 
ComponentType: " + type);
-            };
+            authorizable = resolveBulletinAuthorizable(sourceId, type, group);
         } catch (final ResourceNotFoundException e) {
             // if the underlying component is gone, disallow
             return false;
         }
 
+        if (authorizable == null) {
+            return false;
+        }
+
         // perform the authorization
         final AuthorizationResult result = 
authorizable.checkAuthorization(authorizer, RequestAction.READ, 
NiFiUserUtils.getNiFiUser());
         return Result.Approved.equals(result.getResult());
     }
 
+    /**
+     * Locates the {@link ProcessGroup} that owns the bulletin, including 
connector-managed groups.
+     * When the group cannot be found, returns {@code null} so authorization 
falls back to the
+     * global {@link AuthorizableLookup}.
+     */
+    private ProcessGroup resolveOwningProcessGroupForBulletin(final Bulletin 
bulletin) {
+        final String groupId = bulletin.getGroupId();
+        if (groupId == null) {
+            return null;
+        }
+
+        try {
+            return processGroupDAO.getProcessGroup(groupId, true);
+        } catch (final ResourceNotFoundException e) {
+            // Owning group was removed; fall back to global authorizable 
lookup.
+            return null;
+        }
+    }
+
+    private Authorizable resolveBulletinAuthorizable(final String sourceId, 
final ComponentType type, final ProcessGroup group) {
+        if (group != null) {
+            final Authorizable found = switch (type) {
+                case PROCESSOR -> group.findProcessor(sourceId);
+                case INPUT_PORT -> group.findInputPort(sourceId);
+                case OUTPUT_PORT -> group.findOutputPort(sourceId);
+                case REMOTE_PROCESS_GROUP -> 
group.findRemoteProcessGroup(sourceId);
+                case CONTROLLER_SERVICE -> 
group.findControllerService(sourceId, true, true);
+                case PROCESS_GROUP -> sourceId.equals(group.getIdentifier()) ? 
group : group.findProcessGroup(sourceId);
+                default -> null;
+            };
+
+            if (found != null) {
+                return found;
+            }
+        }
+
+        return switch (type) {
+            case PROCESSOR -> 
authorizableLookup.getProcessor(sourceId).getAuthorizable();
+            case REPORTING_TASK -> 
authorizableLookup.getReportingTask(sourceId).getAuthorizable();
+            case FLOW_ANALYSIS_RULE -> 
authorizableLookup.getFlowAnalysisRule(sourceId).getAuthorizable();
+            case FLOW_REGISTRY_CLIENT -> 
authorizableLookup.getFlowRegistryClient(sourceId).getAuthorizable();
+            case PARAMETER_PROVIDER -> 
authorizableLookup.getParameterProvider(sourceId).getAuthorizable();
+            case CONTROLLER_SERVICE -> 
authorizableLookup.getControllerService(sourceId).getAuthorizable();
+            case FLOW_CONTROLLER -> controllerFacade;
+            case INPUT_PORT -> authorizableLookup.getInputPort(sourceId);
+            case OUTPUT_PORT -> authorizableLookup.getOutputPort(sourceId);
+            case REMOTE_PROCESS_GROUP -> 
authorizableLookup.getRemoteProcessGroup(sourceId);
+            case PROCESS_GROUP -> 
authorizableLookup.getProcessGroup(sourceId).getAuthorizable();
+            case CONNECTOR -> authorizableLookup.getConnector(sourceId);
+            default -> throw new IllegalArgumentException("Unexpected 
ComponentType: " + type);
+        };
+    }
+
     @Override
     public BulletinBoardDTO getBulletinBoard(final BulletinQueryDTO query) {
         // build the query
@@ -4724,7 +4793,8 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
         final List<BulletinEntity> bulletinEntities = new ArrayList<>();
         for (final ListIterator<Bulletin> bulletinIter = 
results.listIterator(results.size()); bulletinIter.hasPrevious();) {
             final Bulletin bulletin = bulletinIter.previous();
-            
bulletinEntities.add(entityFactory.createBulletinEntity(dtoFactory.createBulletinDto(bulletin,
 true), authorizeBulletin(bulletin)));
+            final ProcessGroup owningGroup = 
resolveOwningProcessGroupForBulletin(bulletin);
+            
bulletinEntities.add(entityFactory.createBulletinEntity(dtoFactory.createBulletinDto(bulletin,
 true), authorizeBulletin(bulletin, owningGroup)));
         }
 
         // create the bulletin board
@@ -5417,9 +5487,14 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
             
bulletins.addAll(bulletinRepository.findBulletinsForGroupBySource(descendantGroup.getIdentifier()));
         }
 
+        // Pass the owning group so authorizeBulletin can resolve sources via 
the live
+        // group hierarchy. The standard AuthorizableLookup only walks the 
FlowManager's
+        // root group and cannot resolve sources that live inside a 
connector's managed
+        // flow context, which would otherwise cause every bulletin in the 
connector
+        // canvas to report canRead=false.
         List<BulletinEntity> bulletinEntities = new ArrayList<>();
         for (final Bulletin bulletin : bulletins) {
-            
bulletinEntities.add(entityFactory.createBulletinEntity(dtoFactory.createBulletinDto(bulletin,
 false), authorizeBulletin(bulletin)));
+            
bulletinEntities.add(entityFactory.createBulletinEntity(dtoFactory.createBulletinDto(bulletin,
 false), authorizeBulletin(bulletin, group)));
         }
 
         return pruneAndSortBulletins(bulletinEntities, 
BulletinRepository.MAX_BULLETINS_PER_COMPONENT);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
index aba36685007..640894bcf24 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
@@ -87,6 +87,7 @@ import 
org.apache.nifi.registry.flow.mapping.InstantiatedVersionedProcessGroup;
 import org.apache.nifi.registry.flow.mapping.VersionedComponentFlowMapper;
 import org.apache.nifi.reporting.Bulletin;
 import org.apache.nifi.reporting.BulletinFactory;
+import org.apache.nifi.reporting.BulletinQuery;
 import org.apache.nifi.reporting.ComponentType;
 import org.apache.nifi.reporting.UserAwareEventAccess;
 import org.apache.nifi.services.FlowService;
@@ -94,6 +95,8 @@ import org.apache.nifi.util.MockBulletinRepository;
 import org.apache.nifi.util.NiFiProperties;
 import org.apache.nifi.validation.RuleViolation;
 import org.apache.nifi.validation.RuleViolationsManager;
+import org.apache.nifi.web.api.dto.BulletinBoardDTO;
+import org.apache.nifi.web.api.dto.BulletinQueryDTO;
 import org.apache.nifi.web.api.dto.ComponentStateDTO;
 import org.apache.nifi.web.api.dto.ConnectorDTO;
 import org.apache.nifi.web.api.dto.CounterDTO;
@@ -1009,6 +1012,169 @@ public class StandardNiFiServiceFacadeTest {
         assertEquals(groupId, result.getBulletins().get(0).getGroupId());
     }
 
+    /**
+     * Regression test for the connector canvas case: bulletins generated by a 
processor that
+     * lives inside a connector's managed flow cannot be resolved by the 
standard
+     * {@link AuthorizableLookup} (which only walks the FlowManager's root 
group). Without
+     * resolving the source through the owning {@link ProcessGroup}, every 
bulletin would
+     * report {@code canRead=false} and the canvas would never render the 
bulletin icon
+     * for the connector or its child groups.
+     */
+    @Test
+    public void 
testUpdateProcessGroup_BulletinAuthorizedViaGroupLookupWhenAuthorizableLookupMisses()
 {
+        final Authentication authentication = new NiFiAuthenticationToken(new 
NiFiUserDetails(new Builder().identity(USER_1).build()));
+        SecurityContextHolder.getContext().setAuthentication(authentication);
+
+        final String groupId = UUID.randomUUID().toString();
+        final ProcessGroup processGroup = mock(ProcessGroup.class);
+        when(processGroup.getIdentifier()).thenReturn(groupId);
+
+        // Simulate the source residing inside a connector-managed flow 
context: not findable
+        // through the global authorizable lookup (PROCESSOR_ID_2 throws 
ResourceNotFoundException
+        // in the test fixture) but resolvable through the owning ProcessGroup 
with an
+        // Authorizable that approves READ for the current user. The processor 
mock is built
+        // before the outer stubbing call so its own stubbing does not 
interleave with this
+        // when()/thenReturn() pair.
+        final ProcessorNode approvingProcessor = approvingProcessorNode();
+        
when(processGroup.findProcessor(PROCESSOR_ID_2)).thenReturn(approvingProcessor);
+
+        final ProcessGroupEntity result = 
invokeUpdateProcessGroupWithBulletin(groupId, processGroup, PROCESSOR_ID_2, 
PROCESSOR_NAME_2, BULLETIN_MESSAGE_2);
+
+        assertNotNull(result);
+        assertEquals(1, result.getBulletins().size());
+        assertTrue(result.getBulletins().get(0).getCanRead(),
+                "Bulletin canRead should be true when the source is resolved 
via the owning ProcessGroup");
+    }
+
+    /**
+     * When the bulletin source cannot be located inside the owning {@link 
ProcessGroup}
+     * (the standard root-flow case), authorization must fall back to the 
global
+     * {@link AuthorizableLookup} so existing non-connector behavior is 
preserved.
+     */
+    @Test
+    public void 
testUpdateProcessGroup_BulletinAuthorizationFallsBackToAuthorizableLookupWhenGroupLookupMisses()
 {
+        final Authentication authentication = new NiFiAuthenticationToken(new 
NiFiUserDetails(new Builder().identity(USER_1).build()));
+        SecurityContextHolder.getContext().setAuthentication(authentication);
+
+        final String groupId = UUID.randomUUID().toString();
+        final ProcessGroup processGroup = mock(ProcessGroup.class);
+        when(processGroup.getIdentifier()).thenReturn(groupId);
+        // findProcessor returns null by default; the fallback to 
authorizableLookup must succeed.
+
+        // PROCESSOR_ID_1 resolves through the global authorizable lookup and 
is approved for USER_1.
+        final ProcessGroupEntity result = 
invokeUpdateProcessGroupWithBulletin(groupId, processGroup, PROCESSOR_ID_1, 
PROCESSOR_NAME_1, BULLETIN_MESSAGE_1);
+
+        assertNotNull(result);
+        assertEquals(1, result.getBulletins().size());
+        assertTrue(result.getBulletins().get(0).getCanRead(),
+                "Bulletin canRead should be true when the global authorizable 
lookup approves the source");
+    }
+
+    /**
+     * When neither the owning {@link ProcessGroup} nor the global {@link 
AuthorizableLookup}
+     * can resolve the bulletin source (the source component has been 
deleted), authorization
+     * must deny so the bulletin is reported as unreadable rather than 
surfacing the
+     * underlying {@link ResourceNotFoundException} as a 500.
+     */
+    @Test
+    public void 
testUpdateProcessGroup_BulletinDeniedWhenNeitherLookupResolvesSource() {
+        final Authentication authentication = new NiFiAuthenticationToken(new 
NiFiUserDetails(new Builder().identity(USER_1).build()));
+        SecurityContextHolder.getContext().setAuthentication(authentication);
+
+        final String groupId = UUID.randomUUID().toString();
+        final ProcessGroup processGroup = mock(ProcessGroup.class);
+        when(processGroup.getIdentifier()).thenReturn(groupId);
+        // findProcessor returns null, and PROCESSOR_ID_2 throws 
ResourceNotFoundException in the global lookup.
+
+        final ProcessGroupEntity result = 
invokeUpdateProcessGroupWithBulletin(groupId, processGroup, PROCESSOR_ID_2, 
PROCESSOR_NAME_2, BULLETIN_MESSAGE_2);
+
+        assertNotNull(result);
+        assertEquals(1, result.getBulletins().size());
+        assertFalse(result.getBulletins().get(0).getCanRead(),
+                "Bulletin canRead should be false when the source cannot be 
resolved by either lookup");
+    }
+
+    /**
+     * The bulletin board authorizes each row independently of process group 
flow responses. Connector-managed
+     * sources must resolve through the bulletin's owning group (including 
connector-managed process groups)
+     * or the board reports {@code canRead=false} even when the user can read 
the connector.
+     */
+    @Test
+    public void 
testGetBulletinBoard_BulletinAuthorizedViaOwningProcessGroupWhenAuthorizableLookupMisses()
 {
+        final Authentication authentication = new NiFiAuthenticationToken(new 
NiFiUserDetails(new Builder().identity(USER_1).build()));
+        SecurityContextHolder.getContext().setAuthentication(authentication);
+
+        final String groupId = UUID.randomUUID().toString();
+        final ProcessGroup processGroup = mock(ProcessGroup.class);
+        when(processGroup.getIdentifier()).thenReturn(groupId);
+
+        final ProcessorNode approvingProcessor = approvingProcessorNode();
+        
when(processGroup.findProcessor(PROCESSOR_ID_2)).thenReturn(approvingProcessor);
+        when(processGroupDAO.getProcessGroup(groupId, 
true)).thenReturn(processGroup);
+
+        final StandardNiFiServiceFacade serviceFacadeSpy = spy(serviceFacade);
+        final MockTestBulletinRepository bulletinRepository = new 
MockTestBulletinRepository();
+        serviceFacadeSpy.setBulletinRepository(bulletinRepository);
+
+        bulletinRepository.addBulletin(
+                BulletinFactory.createBulletin(groupId, GROUP_NAME_1, 
PROCESSOR_ID_2,
+                        ComponentType.PROCESSOR, PROCESSOR_NAME_2,
+                        BULLETIN_CATEGORY, BULLETIN_SEVERITY, 
BULLETIN_MESSAGE_2, PATH_TO_GROUP_1));
+
+        final BulletinBoardDTO board = serviceFacadeSpy.getBulletinBoard(new 
BulletinQueryDTO());
+
+        assertNotNull(board);
+        assertEquals(1, board.getBulletins().size());
+        assertTrue(board.getBulletins().get(0).getCanRead(),
+                "Bulletin board canRead should be true when the source is 
resolved via the owning ProcessGroup");
+        verify(processGroupDAO).getProcessGroup(groupId, true);
+    }
+
+    private ProcessGroupEntity invokeUpdateProcessGroupWithBulletin(final 
String groupId, final ProcessGroup processGroup,
+                                                                    final 
String sourceId, final String sourceName, final String message) {
+        final ProcessGroupStatus processGroupStatus = new ProcessGroupStatus();
+        processGroupStatus.setId(groupId);
+        processGroupStatus.setName(GROUP_NAME_1);
+        processGroupStatus.setStatelessActiveThreadCount(0);
+
+        final ControllerFacade controllerFacade = mock(ControllerFacade.class);
+        
when(controllerFacade.getProcessGroupStatus(any())).thenReturn(processGroupStatus);
+
+        final StandardNiFiServiceFacade serviceFacadeSpy = spy(serviceFacade);
+        serviceFacadeSpy.setControllerFacade(controllerFacade);
+
+        final ProcessGroupDTO processGroupDTO = new ProcessGroupDTO();
+        processGroupDTO.setId(groupId);
+        
when(processGroupDAO.getProcessGroup(groupId)).thenReturn(processGroup);
+        
when(processGroupDAO.updateProcessGroup(processGroupDTO)).thenReturn(processGroup);
+
+        final RevisionManager revisionManager = mock(RevisionManager.class);
+        final Revision revision = new Revision(1L, "a", "b");
+        final FlowModification lastModification = new 
FlowModification(revision, "a");
+        final RevisionUpdate<Object> snapshot = new 
StandardRevisionUpdate<>(processGroupDTO, lastModification);
+        when(revisionManager.updateRevision(any(), any(), 
any())).thenReturn(snapshot);
+        serviceFacadeSpy.setRevisionManager(revisionManager);
+
+        final MockTestBulletinRepository bulletinRepository = new 
MockTestBulletinRepository();
+        serviceFacadeSpy.setBulletinRepository(bulletinRepository);
+
+        bulletinRepository.addBulletin(
+                BulletinFactory.createBulletin(groupId, GROUP_NAME_1, sourceId,
+                        ComponentType.PROCESSOR, sourceName,
+                        BULLETIN_CATEGORY, BULLETIN_SEVERITY, message, 
PATH_TO_GROUP_1));
+
+        return serviceFacadeSpy.updateProcessGroup(revision, processGroupDTO);
+    }
+
+    private ProcessorNode approvingProcessorNode() {
+        // The production code calls Authorizable#checkAuthorization on 
whatever findProcessor
+        // returns. Stubbing it directly keeps the test focused on the 
resolution path and
+        // avoids reproducing the entire AbstractComponentNode authorization 
machinery.
+        final ProcessorNode processorNode = mock(ProcessorNode.class);
+        when(processorNode.checkAuthorization(any(Authorizer.class), any(), 
any(NiFiUser.class))).thenReturn(AuthorizationResult.approved());
+        return processorNode;
+    }
+
     @Test
     public void testSearchTenantsNullQuery() {
         setupSearchTenants();
@@ -1116,6 +1282,11 @@ public class StandardNiFiServiceFacadeTest {
             bulletinList.add(bulletin);
         }
 
+        @Override
+        public List<Bulletin> findBulletins(BulletinQuery bulletinQuery) {
+            return new ArrayList<>(bulletinList);
+        }
+
         @Override
         public List<Bulletin> findBulletinsForSource(String sourceId) {
             List<Bulletin> ans = new ArrayList<>();

Reply via email to