You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@nifi.apache.org by tu...@apache.org on 2022/12/15 23:18:18 UTC

[nifi] branch main updated: NIFI-10618: Add Asana connector

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

turcsanyi 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 1c40738997 NIFI-10618: Add Asana connector
1c40738997 is described below

commit 1c4073899722c202b192330c543174edcdb9f13e
Author: Rajmund Takacs <ta...@gmail.com>
AuthorDate: Fri Sep 23 15:33:29 2022 +0200

    NIFI-10618: Add Asana connector
    
    This closes #6504.
    
    Signed-off-by: Peter Turcsanyi <tu...@apache.org>
---
 nifi-assembly/LICENSE                              |  25 ++
 nifi-assembly/NOTICE                               |  60 +++
 nifi-assembly/pom.xml                              |  18 +
 .../nifi-asana-processors-nar/pom.xml              |  44 ++
 .../src/main/resources/META-INF/LICENSE            | 202 +++++++++
 .../src/main/resources/META-INF/NOTICE             |  20 +
 .../nifi-asana-processors/pom.xml                  |  65 +++
 .../nifi/processors/asana/AsanaObjectType.java     | 115 +++++
 .../nifi/processors/asana/GenericObjectSerDe.java  |  57 +++
 .../nifi/processors/asana/GetAsanaObject.java      | 409 ++++++++++++++++++
 .../asana/utils/AbstractAsanaObjectFetcher.java    |  49 +++
 .../nifi/processors/asana/utils/AsanaObject.java   |  72 ++++
 .../processors/asana/utils/AsanaObjectFetcher.java |  53 +++
 .../asana/utils/AsanaObjectFetcherException.java   |  32 ++
 .../processors/asana/utils/AsanaObjectState.java   |  23 +
 .../asana/utils/AsanaProjectEventFetcher.java      |  80 ++++
 .../asana/utils/AsanaProjectFetcher.java           |  40 ++
 .../asana/utils/AsanaProjectMembershipFetcher.java |  58 +++
 .../utils/AsanaProjectStatusAttachmentFetcher.java |  66 +++
 .../asana/utils/AsanaProjectStatusFetcher.java     |  58 +++
 .../processors/asana/utils/AsanaStoryFetcher.java  |  57 +++
 .../processors/asana/utils/AsanaTagFetcher.java    |  35 ++
 .../asana/utils/AsanaTaskAttachmentFetcher.java    |  62 +++
 .../processors/asana/utils/AsanaTaskFetcher.java   | 105 +++++
 .../processors/asana/utils/AsanaTeamFetcher.java   |  35 ++
 .../asana/utils/AsanaTeamMemberFetcher.java        |  58 +++
 .../processors/asana/utils/AsanaUserFetcher.java   |  35 ++
 .../asana/utils/GenericAsanaObjectFetcher.java     | 176 ++++++++
 .../services/org.apache.nifi.processor.Processor   |  15 +
 .../additionalDetails.html                         | 100 +++++
 .../asana/AbstractAsanaObjectFetcherTest.java      |  89 ++++
 .../nifi/processors/asana/AsanaObjectTest.java     | 114 +++++
 .../asana/AsanaProjectEventFetcherTest.java        | 162 +++++++
 .../processors/asana/AsanaProjectFetcherTest.java  | 150 +++++++
 .../asana/AsanaProjectMembershipFetcherTest.java   | 190 +++++++++
 .../AsanaProjectStatusAttachmentFetcherTest.java   | 247 +++++++++++
 .../asana/AsanaProjectStatusFetcherTest.java       | 181 ++++++++
 .../processors/asana/AsanaStoryFetcherTest.java    | 441 +++++++++++++++++++
 .../nifi/processors/asana/AsanaTagFetcherTest.java | 140 ++++++
 .../asana/AsanaTaskAttachmentFetcherTest.java      | 470 +++++++++++++++++++++
 .../processors/asana/AsanaTaskFetcherTest.java     | 437 +++++++++++++++++++
 .../processors/asana/AsanaTeamFetcherTest.java     | 142 +++++++
 .../asana/AsanaTeamMemberFetcherTest.java          | 184 ++++++++
 .../processors/asana/AsanaUserFetcherTest.java     | 142 +++++++
 .../asana/GenericAsanaObjectFetcherTest.java       | 213 ++++++++++
 .../processors/asana/GenericObjectSerDeTest.java   |  82 ++++
 .../asana/GetAsanaObjectConfigurationTest.java     | 447 ++++++++++++++++++++
 .../asana/GetAsanaObjectLifecycleTest.java         | 297 +++++++++++++
 .../mocks/MockAbstractAsanaObjectFetcher.java      |  55 +++
 .../mocks/MockAsanaClientProviderService.java      |  33 ++
 .../asana/mocks/MockDistributedMapCacheClient.java |  78 ++++
 .../asana/mocks/MockGenericAsanaObjectFetcher.java |  36 ++
 .../processors/asana/mocks/MockGetAsanaObject.java |  34 ++
 .../nifi-asana-services-api-nar/pom.xml            |  45 ++
 .../src/main/resources/META-INF/LICENSE            | 228 ++++++++++
 .../src/main/resources/META-INF/NOTICE             | 103 +++++
 .../nifi-asana-services-api/pom.xml                |  38 ++
 .../apache/nifi/controller/asana/AsanaClient.java  | 194 +++++++++
 .../controller/asana/AsanaClientException.java     |  25 ++
 .../asana/AsanaClientProviderService.java          |  32 ++
 .../controller/asana/AsanaEventsCollection.java    |  41 ++
 .../nifi-asana-services-nar/pom.xml                |  44 ++
 .../src/main/resources/META-INF/LICENSE            | 202 +++++++++
 .../src/main/resources/META-INF/NOTICE             |   5 +
 .../nifi-asana-bundle/nifi-asana-services/pom.xml  |  58 +++
 .../nifi/controller/asana/StandardAsanaClient.java | 295 +++++++++++++
 .../asana/StandardAsanaClientProviderService.java  | 101 +++++
 .../org.apache.nifi.controller.ControllerService   |  15 +
 .../additionalDetails.html                         |  91 ++++
 .../StandardAsanaClientProviderServiceTest.java    | 177 ++++++++
 nifi-nar-bundles/nifi-asana-bundle/pom.xml         |  77 ++++
 nifi-nar-bundles/pom.xml                           |   1 +
 72 files changed, 8360 insertions(+)

diff --git a/nifi-assembly/LICENSE b/nifi-assembly/LICENSE
index e6842290a4..3160381c1f 100644
--- a/nifi-assembly/LICENSE
+++ b/nifi-assembly/LICENSE
@@ -2936,6 +2936,31 @@ The binary distribution of this product bundles 'Azure SDK for Java' which is av
 	WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
 	SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 
+The binary distribution of this product bundles 'Java client library for the Asana API'
+which is available under MIT license. For details see https://github.com/Asana/java-asana.
+
+    The MIT License (MIT)
+
+    Copyright (c) 2015 Asana
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE.
+
 The binary distribution of this product bundles 'Zstd-jni: JNI bindings to Zstd Library' under a 2-Clause BSD style license.
 
   Copyright (c) 2015-present, Luben Karavelov/ All rights reserved.
diff --git a/nifi-assembly/NOTICE b/nifi-assembly/NOTICE
index 6c47d36ab6..f3529d5320 100644
--- a/nifi-assembly/NOTICE
+++ b/nifi-assembly/NOTICE
@@ -973,6 +973,36 @@ The following binary components are provided under the Apache Software License v
       Swagger Core 1.5.3-M1
       Copyright 2015 Reverb Technologies, Inc.
 
+  (ASLv2) Error Prone Annotations
+    The following NOTICE information applies:
+      Error Prone Annotations
+      Copyright 2015 The Error Prone Authors
+
+  (ASLv2) Guava InternalFutureFailureAccess and InternalFutures
+    The following NOTICE information applies:
+      Guava InternalFutureFailureAccess and InternalFutures
+      Copyright (C) 2018 The Guava Authors
+
+  (ASLv2) Google HTTP Client Library For Java
+    The following NOTICE information applies:
+      Google HTTP Client Library For Java
+      Copyright (c) 2011 Google Inc.
+
+  (ASLv2) GSON Extensions to The Google HTTP Client Library For Java
+    The following NOTICE information applies:
+      GSON Extensions to The Google HTTP Client Library For Java
+      Copyright (c) 2011 Google Inc.
+
+  (ASLv2) Google OAuth Client Library For Java
+    The following NOTICE information applies:
+      Google OAuth Client Library For Java
+      Copyright 2021 Google LLC
+
+  (ASLv2) GRPC Context
+    The following NOTICE information applies:
+      GRPC Context
+      Copyright 2015 The gRPC Authors
+
   (ASLv2) Google GSON
     The following NOTICE information applies:
       Copyright 2008 Google Inc.
@@ -1010,6 +1040,26 @@ The following binary components are provided under the Apache Software License v
       Apache Commons FileUpload
       Copyright 2002-2014 The Apache Software Foundation
 
+  (ASLv2) J2ObjC Annotations
+    The following NOTICE information applies:
+      J2ObjC Annotations
+      Copyright 2022 The J2ObjC Annotations Authors
+
+  (ASLv2) FindBugs JSR305
+    The following NOTICE information applies:
+      FindBugs JSR305
+      Copyright 2017 The FindBugs JSR305 Authors
+
+  (ASLv2) Guava ListenableFuture Only
+    The following NOTICE information applies:
+      Guava ListenableFuture Only
+      Copyright (C) 2018 The Guava Authors
+
+  (ASLv2) OpenCensus
+    The following NOTICE information applies:
+      OpenCensus
+      Copyright 2016-17, OpenCensus Authors
+
   (ASLv2) JSON-SMART
     The following NOTICE information applies:
       Copyright 2011 JSON-SMART authors
@@ -2477,6 +2527,16 @@ The MIT License
        This product includes software developed by
        Microsoft Corporation (https://www.microsoft.com/).
 
+  (MIT) Java client library for the Asana API
+    The following NOTICE information applies:
+      Asana
+      Copyright (c) 2015
+
+  (MIT) Checker Framework qualifiers
+    The following NOTICE information applies:
+      Checker Framework qualifiers
+      Copyright 2004-present by the Checker Framework developers
+
 *****************
 Mozilla Public License v2.0
 *****************
diff --git a/nifi-assembly/pom.xml b/nifi-assembly/pom.xml
index 416fb2799a..38b42fe3a6 100644
--- a/nifi-assembly/pom.xml
+++ b/nifi-assembly/pom.xml
@@ -781,6 +781,24 @@ language governing permissions and limitations under the License. -->
             <version>1.20.0-SNAPSHOT</version>
             <type>nar</type>
         </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-asana-processors-nar</artifactId>
+            <version>1.20.0-SNAPSHOT</version>
+            <type>nar</type>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-asana-services-nar</artifactId>
+            <version>1.20.0-SNAPSHOT</version>
+            <type>nar</type>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-asana-services-api-nar</artifactId>
+            <version>1.20.0-SNAPSHOT</version>
+            <type>nar</type>
+        </dependency>
         <dependency>
             <groupId>org.apache.nifi</groupId>
             <artifactId>nifi-hazelcast-services-api-nar</artifactId>
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors-nar/pom.xml b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors-nar/pom.xml
new file mode 100644
index 0000000000..34813b93e7
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors-nar/pom.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements. See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License. You may obtain a copy of the License at
+  http://www.apache.org/licenses/LICENSE-2.0
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.nifi</groupId>
+        <artifactId>nifi-asana-bundle</artifactId>
+        <version>1.20.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>nifi-asana-processors-nar</artifactId>
+    <packaging>nar</packaging>
+    <properties>
+        <maven.javadoc.skip>true</maven.javadoc.skip>
+        <source.skip>true</source.skip>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-asana-services-api-nar</artifactId>
+            <type>nar</type>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-asana-processors</artifactId>
+        </dependency>
+    </dependencies>
+</project>
\ No newline at end of file
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors-nar/src/main/resources/META-INF/LICENSE b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors-nar/src/main/resources/META-INF/LICENSE
new file mode 100644
index 0000000000..d645695673
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors-nar/src/main/resources/META-INF/LICENSE
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors-nar/src/main/resources/META-INF/NOTICE b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors-nar/src/main/resources/META-INF/NOTICE
new file mode 100644
index 0000000000..ea899dbda1
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors-nar/src/main/resources/META-INF/NOTICE
@@ -0,0 +1,20 @@
+nifi-asana-processors-nar
+Copyright 2015-2022 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
+
+===========================================
+Apache Software License v2
+===========================================
+
+The following binary components are provided under the Apache Software License v2
+
+  (ASLv2) Apache Commons IO
+    The following NOTICE information applies:
+      Apache Commons IO
+      Copyright 2002-2022 The Apache Software Foundation
+
+  (ASLv2) Apache Commons Collections
+      Apache Commons Collections
+      Copyright 2002-2022 The Apache Software Foundation
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/pom.xml b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/pom.xml
new file mode 100644
index 0000000000..62d4d83fa5
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/pom.xml
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements. See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License. You may obtain a copy of the License at
+  http://www.apache.org/licenses/LICENSE-2.0
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.nifi</groupId>
+        <artifactId>nifi-asana-bundle</artifactId>
+        <version>1.20.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>nifi-asana-processors</artifactId>
+    <packaging>jar</packaging>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-utils</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-distributed-cache-client-service-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-asana-services-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>commons-io</groupId>
+            <artifactId>commons-io</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-collections4</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.asana</groupId>
+            <artifactId>asana</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-mock</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/AsanaObjectType.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/AsanaObjectType.java
new file mode 100644
index 0000000000..a6edfcf354
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/AsanaObjectType.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import java.util.Arrays;
+import org.apache.nifi.components.DescribedValue;
+
+public enum AsanaObjectType implements DescribedValue {
+    AV_COLLECT_TASKS(
+            "asana-collect-tasks",
+            "Tasks", ""
+            + "Collect tasks matching to the specified conditions."
+    ),
+    AV_COLLECT_TASK_ATTACHMENTS(
+            "asana-collect-task-attachments",
+            "Task Attachments",
+            "Collect attached files of tasks matching to the specified conditions."
+    ),
+    AV_COLLECT_PROJECTS(
+            "asana-collect-projects",
+            "Projects",
+            "Collect projects of the workspace."
+    ),
+    AV_COLLECT_TAGS(
+            "asana-collect-tags",
+            "Tags",
+            "Collect tags of the workspace."
+    ),
+    AV_COLLECT_USERS(
+            "asana-collect-users",
+            "Users",
+            "Collect users assigned to the workspace."
+    ),
+    AV_COLLECT_PROJECT_MEMBERS(
+            "asana-collect-project-members",
+            "Members of a Project",
+            "Collect users assigned to the specified project."
+    ),
+    AV_COLLECT_TEAMS(
+            "asana-collect-teams",
+            "Teams",
+            "Collect teams of the workspace."
+    ),
+    AV_COLLECT_TEAM_MEMBERS(
+            "asana-collect-team-members",
+            "Team Members",
+            "Collect users assigned to the specified team."
+    ),
+    AV_COLLECT_STORIES(
+            "asana-collect-stories",
+            "Stories of Tasks",
+            "Collect stories (comments) of of tasks matching to the specified conditions."
+    ),
+    AV_COLLECT_PROJECT_STATUS_UPDATES(
+            "asana-collect-project-status-updates",
+            "Status Updates of a Project",
+            "Collect status updates of the specified project."
+    ),
+    AV_COLLECT_PROJECT_STATUS_ATTACHMENTS(
+            "asana-collect-project-status-attachments",
+            "Attachments of Status Updates",
+            "Collect attached files of project status updates."
+    ),
+    AV_COLLECT_PROJECT_EVENTS(
+            "asana-collect-project-events",
+            "Events of a Project",
+            "Collect various events happening on the specified project and on its' tasks."
+    );
+
+    private final String value;
+    private final String displayName;
+    private final String description;
+
+    AsanaObjectType(String value, String displayName, String description) {
+        this.value = value;
+        this.displayName = displayName;
+        this.description = description;
+    }
+
+    @Override
+    public String getValue() {
+        return value;
+    }
+
+    @Override
+    public String getDisplayName() {
+        return displayName;
+    }
+
+    @Override
+    public String getDescription() {
+        return description;
+    }
+
+    public static AsanaObjectType fromValue(String value) {
+        return Arrays.stream(AsanaObjectType.values())
+                .filter(asanaObjectType -> asanaObjectType.getValue().equals(value))
+                .findFirst()
+                .orElse(null);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/GenericObjectSerDe.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/GenericObjectSerDe.java
new file mode 100644
index 0000000000..4664c4a9c2
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/GenericObjectSerDe.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.OutputStream;
+import org.apache.nifi.distributed.cache.client.Deserializer;
+import org.apache.nifi.distributed.cache.client.Serializer;
+import org.apache.nifi.distributed.cache.client.exception.DeserializationException;
+import org.apache.nifi.distributed.cache.client.exception.SerializationException;
+
+public class GenericObjectSerDe <V> implements Serializer<V>, Deserializer<V> {
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public V deserialize(byte[] value) throws DeserializationException, IOException {
+        if (value == null || value.length == 0) {
+            return null;
+        }
+
+        try (ByteArrayInputStream bis = new ByteArrayInputStream(value)) {
+            try (ObjectInputStream objectInputStream = new ObjectInputStream(bis)) {
+                return (V) objectInputStream.readObject();
+            } catch (ClassNotFoundException e) {
+                throw new DeserializationException(e);
+            }
+        }
+    }
+
+    @Override
+    public void serialize(V value, OutputStream output) throws SerializationException, IOException {
+        if (value == null) {
+            return;
+        }
+
+        try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(output)) {
+            objectOutputStream.writeObject(value);
+        }
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/GetAsanaObject.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/GetAsanaObject.java
new file mode 100644
index 0000000000..4a75722e3f
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/GetAsanaObject.java
@@ -0,0 +1,409 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static java.lang.String.format;
+import static java.lang.String.join;
+import static java.util.Collections.singletonMap;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_PROJECT_EVENTS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_PROJECT_MEMBERS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_PROJECT_STATUS_ATTACHMENTS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_PROJECT_STATUS_UPDATES;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_STORIES;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_TASKS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_TASK_ATTACHMENTS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_TEAM_MEMBERS;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.Set;
+import org.apache.http.entity.ContentType;
+import org.apache.nifi.annotation.behavior.InputRequirement;
+import org.apache.nifi.annotation.behavior.PrimaryNodeOnly;
+import org.apache.nifi.annotation.behavior.SystemResource;
+import org.apache.nifi.annotation.behavior.SystemResourceConsideration;
+import org.apache.nifi.annotation.behavior.TriggerSerially;
+import org.apache.nifi.annotation.behavior.WritesAttribute;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnScheduled;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.PropertyDescriptor.Builder;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.controller.asana.AsanaClientProviderService;
+import org.apache.nifi.distributed.cache.client.DistributedMapCacheClient;
+import org.apache.nifi.flowfile.FlowFile;
+import org.apache.nifi.flowfile.attributes.CoreAttributes;
+import org.apache.nifi.processor.AbstractProcessor;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.Relationship;
+import org.apache.nifi.processor.exception.ProcessException;
+import org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaProjectEventFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaProjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaProjectMembershipFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaProjectStatusAttachmentFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaProjectStatusFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaStoryFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaTagFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaTaskAttachmentFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaTaskFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaTeamFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaTeamMemberFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaUserFetcher;
+import org.apache.nifi.reporting.InitializationException;
+
+@TriggerSerially
+@PrimaryNodeOnly
+@InputRequirement(InputRequirement.Requirement.INPUT_FORBIDDEN)
+@WritesAttribute(attribute = GetAsanaObject.ASANA_GID, description = "Global ID of the object in Asana.")
+@Tags({"asana", "source", "ingest"})
+@CapabilityDescription("This processor collects data from Asana")
+@SystemResourceConsideration(resource = SystemResource.MEMORY)
+public class GetAsanaObject extends AbstractProcessor {
+
+    protected static final String ASANA_GID = "asana.gid";
+    protected static final String ASANA_CLIENT_SERVICE = "asana-controller-service";
+    protected static final String DISTRIBUTED_CACHE_SERVICE = "distributed-cache-service";
+    protected static final String ASANA_OBJECT_TYPE = "asana-object-type";
+    protected static final String ASANA_PROJECT_NAME = "asana-project-name";
+    protected static final String ASANA_SECTION_NAME = "asana-section-name";
+    protected static final String ASANA_TAG_NAME = "asana-tag-name";
+    protected static final String ASANA_TEAM_NAME = "asana-team-name";
+    protected static final String ASANA_OUTPUT_BATCH_SIZE = "asana-output-batch-size";
+    protected static final String REL_NAME_NEW = "new";
+    protected static final String REL_NAME_UPDATED = "updated";
+    protected static final String REL_NAME_REMOVED = "removed";
+
+    protected static final PropertyDescriptor PROP_ASANA_CLIENT_SERVICE = new PropertyDescriptor.Builder()
+            .name(ASANA_CLIENT_SERVICE)
+            .displayName("Asana Client Service")
+            .description("Specify which controller service to use for accessing Asana.")
+            .required(true)
+            .identifiesControllerService(AsanaClientProviderService.class)
+            .build();
+
+    protected static final PropertyDescriptor PROP_DISTRIBUTED_CACHE_SERVICE = new Builder()
+            .name(DISTRIBUTED_CACHE_SERVICE)
+            .displayName("Distributed Cache Service")
+            .description("Cache service to store fetched item fingerprints. These, from the last successful query"
+                    + " are stored, in order to enable incremental loading and change detection.")
+            .required(true)
+            .identifiesControllerService(DistributedMapCacheClient.class)
+            .build();
+
+    protected static final PropertyDescriptor PROP_ASANA_OBJECT_TYPE = new PropertyDescriptor.Builder()
+            .name(ASANA_OBJECT_TYPE)
+            .displayName("Object Type")
+            .description("Specify what kind of objects to be collected from Asana")
+            .required(true)
+            .allowableValues(AsanaObjectType.class)
+            .defaultValue(AV_COLLECT_TASKS.getValue())
+            .build();
+
+    protected static final PropertyDescriptor PROP_ASANA_PROJECT = new PropertyDescriptor.Builder()
+            .name(ASANA_PROJECT_NAME)
+            .displayName("Project Name")
+            .description("Fetch only objects in this project. Case sensitive.")
+            .required(true)
+            .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
+            .dependsOn(
+                PROP_ASANA_OBJECT_TYPE,
+                    AV_COLLECT_TASKS,
+                    AV_COLLECT_TASK_ATTACHMENTS,
+                    AV_COLLECT_PROJECT_MEMBERS,
+                    AV_COLLECT_STORIES,
+                    AV_COLLECT_PROJECT_STATUS_UPDATES,
+                    AV_COLLECT_PROJECT_STATUS_ATTACHMENTS,
+                    AV_COLLECT_PROJECT_EVENTS)
+            .build();
+
+    protected static final PropertyDescriptor PROP_ASANA_SECTION = new PropertyDescriptor.Builder()
+            .name(ASANA_SECTION_NAME)
+            .displayName("Section Name")
+            .description("Fetch only objects in this section. Case sensitive.")
+            .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
+            .dependsOn(PROP_ASANA_OBJECT_TYPE,
+                    AV_COLLECT_TASKS,
+                    AV_COLLECT_TASK_ATTACHMENTS,
+                    AV_COLLECT_STORIES)
+            .build();
+
+    protected static final PropertyDescriptor PROP_ASANA_TAG = new PropertyDescriptor.Builder()
+            .name(ASANA_TAG_NAME)
+            .displayName("Tag")
+            .description("Fetch only objects having this tag. Case sensitive.")
+            .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
+            .dependsOn(PROP_ASANA_OBJECT_TYPE,
+                    AV_COLLECT_TASKS,
+                    AV_COLLECT_TASK_ATTACHMENTS,
+                    AV_COLLECT_STORIES)
+            .build();
+
+    protected static final PropertyDescriptor PROP_ASANA_TEAM_NAME = new PropertyDescriptor.Builder()
+            .name(ASANA_TEAM_NAME)
+            .displayName("Team")
+            .description("Team name. Case sensitive.")
+            .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
+            .dependsOn(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_TEAM_MEMBERS)
+            .build();
+
+    protected static final PropertyDescriptor PROP_ASANA_OUTPUT_BATCH_SIZE = new PropertyDescriptor.Builder()
+            .name(ASANA_OUTPUT_BATCH_SIZE)
+            .displayName("Output Batch Size")
+            .description("The number of items batched together in a single Flow File. If set to 1 (default), then each item is"
+                    + " transferred in a separate Flow File and each will have an asana.gid attribute, to help identifying"
+                    + " the fetched item on the server side, if needed. If the batch size is greater than 1, then the"
+                    + " specified amount of items are batched together in a single Flow File as a Json array, and the"
+                    + " Flow Files won't have the asana.gid attribute.")
+            .defaultValue("1")
+            .required(true)
+            .addValidator(StandardValidators.POSITIVE_INTEGER_VALIDATOR)
+            .build();
+
+    protected static final List<PropertyDescriptor> DESCRIPTORS = Collections.unmodifiableList(Arrays.asList(
+            PROP_ASANA_CLIENT_SERVICE,
+            PROP_DISTRIBUTED_CACHE_SERVICE,
+            PROP_ASANA_OBJECT_TYPE,
+            PROP_ASANA_PROJECT,
+            PROP_ASANA_SECTION,
+            PROP_ASANA_TEAM_NAME,
+            PROP_ASANA_TAG,
+            PROP_ASANA_OUTPUT_BATCH_SIZE
+    ));
+
+    protected static final Relationship REL_NEW = new Relationship.Builder()
+            .name(REL_NAME_NEW)
+            .description("Newly collected objects are routed to this relationship.")
+            .build();
+
+    protected static final Relationship REL_UPDATED = new Relationship.Builder()
+            .name(REL_NAME_UPDATED)
+            .description("Objects that have already been collected earlier, but were updated since, are routed to this relationship.")
+            .build();
+
+    protected static final Relationship REL_REMOVED = new Relationship.Builder()
+            .name(REL_NAME_REMOVED)
+            .description("Notification about deleted objects are routed to this relationship. "
+                    + "Flow files will not have any payload. IDs of the resources no longer exist "
+                    + "are carried by the asana.gid attribute of the generated FlowFiles.")
+            .build();
+
+    protected static final Set<Relationship> RELATIONSHIPS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+            REL_NEW,
+            REL_UPDATED,
+            REL_REMOVED
+    )));
+    protected static final GenericObjectSerDe<String> STATE_MAP_KEY_SERIALIZER = new GenericObjectSerDe<>();
+    protected static final GenericObjectSerDe<Map<String, String>> STATE_MAP_VALUE_SERIALIZER = new GenericObjectSerDe<>();
+
+    private volatile AsanaObjectFetcher objectFetcher;
+    private volatile Integer batchSize;
+
+    @Override
+    public Set<Relationship> getRelationships() {
+        return RELATIONSHIPS;
+    }
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return DESCRIPTORS;
+    }
+
+    @OnScheduled
+    public void onScheduled(final ProcessContext context) throws InitializationException {
+        AsanaClientProviderService controllerService = context.getProperty(PROP_ASANA_CLIENT_SERVICE).asControllerService(AsanaClientProviderService.class);
+        AsanaClient client = controllerService.createClient();
+        batchSize = context.getProperty(PROP_ASANA_OUTPUT_BATCH_SIZE).asInteger();
+
+        try {
+            getLogger().debug("Initializing object fetcher...");
+            objectFetcher = createObjectFetcher(context, client);
+        } catch (Exception e) {
+            throw new InitializationException(e);
+        }
+    }
+
+    @Override
+    public void onTrigger(final ProcessContext context, final ProcessSession session) throws ProcessException {
+        Map<String, String> processorState = recoverState(context).orElse(Collections.emptyMap());
+        try {
+            getLogger().debug("Attempting to load state: {}", processorState);
+            objectFetcher.loadState(processorState);
+        } catch (Exception e) {
+            getLogger().info("Failed to recover state. Falling back to clean start.");
+            objectFetcher.clearState();
+        }
+        if (getLogger().isDebugEnabled()) {
+            getLogger().debug("Initial state: {}", objectFetcher.saveState());
+        }
+
+        int transferCount = 0;
+
+        if (batchSize == 1) {
+            AsanaObject asanaObject;
+            while ((asanaObject = objectFetcher.fetchNext()) != null) {
+                final Map<String, String> attributes = new HashMap<>(2);
+                attributes.put(CoreAttributes.MIME_TYPE.key(), ContentType.APPLICATION_JSON.getMimeType());
+                attributes.put(ASANA_GID, asanaObject.getGid());
+                FlowFile flowFile = createFlowFileWithStringPayload(session, asanaObject.getContent());
+                flowFile = session.putAllAttributes(flowFile, attributes);
+                transferFlowFileByAsanaObjectState(session, asanaObject.getState(), flowFile);
+                transferCount++;
+            }
+        } else {
+            final Map<AsanaObjectState, Collection<String>> flowFileContents = new HashMap<>();
+            flowFileContents.put(AsanaObjectState.NEW, new ArrayList<>());
+            flowFileContents.put(AsanaObjectState.UPDATED, new ArrayList<>());
+            flowFileContents.put(AsanaObjectState.REMOVED, new ArrayList<>());
+
+            AsanaObject asanaObject;
+            while ((asanaObject = objectFetcher.fetchNext()) != null) {
+                AsanaObjectState state = asanaObject.getState();
+                Collection<String> buffer = flowFileContents.get(state);
+                buffer.add(asanaObject.getContent());
+                if (buffer.size() == batchSize) {
+                    transferBatchedItemsFromBuffer(session, state, buffer);
+                    transferCount++;
+                    buffer.clear();
+                }
+            }
+            for (Entry<AsanaObjectState, Collection<String>> entry : flowFileContents.entrySet()) {
+                if (!entry.getValue().isEmpty()) {
+                    transferBatchedItemsFromBuffer(session, entry.getKey(), entry.getValue());
+                    transferCount++;
+                }
+            }
+        }
+
+        if (transferCount == 0) {
+            context.yield();
+            getLogger().debug("Yielding, as there are no new FlowFiles.");
+        }
+
+        session.commitAsync();
+        Map<String, String> state = objectFetcher.saveState();
+        persistState(state, context);
+        objectFetcher.clearState();
+
+        getLogger().debug("New state after transferring {} FlowFiles: {}", transferCount, state);
+    }
+
+    private void transferBatchedItemsFromBuffer(ProcessSession session, AsanaObjectState state, Collection<String> buffer) {
+        FlowFile flowFile = createFlowFileWithStringPayload(session, format("[%s]", join(",", buffer)));
+        flowFile = session.putAllAttributes(flowFile,
+                singletonMap(CoreAttributes.MIME_TYPE.key(),
+                        ContentType.APPLICATION_JSON.getMimeType()));
+        transferFlowFileByAsanaObjectState(session, state, flowFile);
+    }
+
+    private static void transferFlowFileByAsanaObjectState(ProcessSession session, AsanaObjectState state, FlowFile flowFile) {
+        switch (state) {
+            case NEW:
+                session.transfer(flowFile, REL_NEW);
+                break;
+            case UPDATED:
+                session.transfer(flowFile, REL_UPDATED);
+                break;
+            case REMOVED:
+                session.transfer(flowFile, REL_REMOVED);
+                break;
+        }
+    }
+
+    protected AsanaObjectFetcher createObjectFetcher(final ProcessContext context, AsanaClient client) {
+        final String objectType = context.getProperty(PROP_ASANA_OBJECT_TYPE).getValue();
+        final String projectName = context.getProperty(PROP_ASANA_PROJECT).getValue();
+        final String sectionName = context.getProperty(PROP_ASANA_SECTION).getValue();
+        final String teamName = context.getProperty(PROP_ASANA_TEAM_NAME).getValue();
+        final String tagName = context.getProperty(PROP_ASANA_TAG).getValue();
+
+        switch (AsanaObjectType.fromValue(objectType)) {
+            case AV_COLLECT_TASKS:
+                return new AsanaTaskFetcher(client, projectName, sectionName, tagName);
+            case AV_COLLECT_PROJECTS:
+                return new AsanaProjectFetcher(client);
+            case AV_COLLECT_PROJECT_EVENTS:
+                return new AsanaProjectEventFetcher(client, projectName);
+            case AV_COLLECT_PROJECT_MEMBERS:
+                return new AsanaProjectMembershipFetcher(client, projectName);
+            case AV_COLLECT_PROJECT_STATUS_ATTACHMENTS:
+                return new AsanaProjectStatusAttachmentFetcher(client, projectName);
+            case AV_COLLECT_PROJECT_STATUS_UPDATES:
+                return new AsanaProjectStatusFetcher(client, projectName);
+            case AV_COLLECT_STORIES:
+                return new AsanaStoryFetcher(client, projectName, sectionName, tagName);
+            case AV_COLLECT_TAGS:
+                return new AsanaTagFetcher(client);
+            case AV_COLLECT_TASK_ATTACHMENTS:
+                return new AsanaTaskAttachmentFetcher(client, projectName, sectionName, tagName);
+            case AV_COLLECT_TEAMS:
+                return new AsanaTeamFetcher(client);
+            case AV_COLLECT_TEAM_MEMBERS:
+                return new AsanaTeamMemberFetcher(client, teamName);
+            case AV_COLLECT_USERS:
+                return new AsanaUserFetcher(client);
+        }
+
+        throw new ProcessException("Cannot fetch objects of type: " + objectType);
+    }
+
+    private Optional<Map<String, String>> recoverState(final ProcessContext context) {
+        final DistributedMapCacheClient client = getDistributedMapCacheClient(context);
+        try {
+            final Map<String, String> result = client.get(getIdentifier(), STATE_MAP_KEY_SERIALIZER,
+                    STATE_MAP_VALUE_SERIALIZER);
+            return Optional.ofNullable(result);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    private FlowFile createFlowFileWithStringPayload(ProcessSession session, String payload) {
+        byte[] data = payload.getBytes(StandardCharsets.UTF_8);
+        return session.importFrom(new ByteArrayInputStream(data), session.create());
+    }
+
+    private void persistState(final Map<String, String> state, final ProcessContext context) {
+        final DistributedMapCacheClient client = getDistributedMapCacheClient(context);
+        try {
+            client.put(getIdentifier(), state, STATE_MAP_KEY_SERIALIZER, STATE_MAP_VALUE_SERIALIZER);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    private static DistributedMapCacheClient getDistributedMapCacheClient(ProcessContext context) {
+        return context.getProperty(DISTRIBUTED_CACHE_SERVICE).asControllerService(DistributedMapCacheClient.class);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AbstractAsanaObjectFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AbstractAsanaObjectFetcher.java
new file mode 100644
index 0000000000..2ac9986250
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AbstractAsanaObjectFetcher.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import java.util.Iterator;
+
+public abstract class AbstractAsanaObjectFetcher implements AsanaObjectFetcher {
+
+    private Iterator<AsanaObject> pending;
+
+    public AbstractAsanaObjectFetcher() {
+        pending = null;
+    }
+
+    @Override
+    public AsanaObject fetchNext() {
+        if (pending == null) {
+            pending = fetch();
+        }
+
+        if (!pending.hasNext()) {
+            pending = null;
+            return null;
+        }
+
+        return pending.next();
+    }
+
+    @Override
+    public void clearState() {
+        pending = null;
+    }
+
+    protected abstract Iterator<AsanaObject> fetch();
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaObject.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaObject.java
new file mode 100644
index 0000000000..7cd203f550
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaObject.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import org.apache.nifi.util.StringUtils;
+
+import java.util.Optional;
+
+public class AsanaObject {
+    private final AsanaObjectState state;
+    private final String gid;
+    private final String content;
+    private final String fingerprint;
+
+    public AsanaObject(AsanaObjectState state, String gid) {
+        this(state, gid, StringUtils.EMPTY);
+    }
+
+    public AsanaObject(AsanaObjectState state, String gid, String content) {
+        this(state, gid, content, null);
+    }
+
+    public AsanaObject(AsanaObjectState state, String gid, String content, String fingerprint) {
+        this.state = state;
+        this.gid = gid;
+        this.content = content;
+        this.fingerprint = fingerprint;
+    }
+
+    public AsanaObjectState getState() {
+        return state;
+    }
+
+    public String getGid() {
+        return gid;
+    }
+
+    public String getContent() {
+        return content;
+    }
+
+    public String getFingerprint() {
+        return Optional.ofNullable(fingerprint).orElse(content);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (o instanceof AsanaObject) {
+            AsanaObject other = (AsanaObject) o;
+            return state.equals(other.state)
+                && Optional.ofNullable(gid).equals(Optional.ofNullable(other.gid))
+                && Optional.ofNullable(content).equals(Optional.ofNullable(other.content))
+                && Optional.ofNullable(fingerprint).equals(Optional.ofNullable(other.fingerprint));
+        } else {
+            return false;
+        }
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaObjectFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaObjectFetcher.java
new file mode 100644
index 0000000000..927c26a641
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaObjectFetcher.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import java.util.Map;
+
+/**
+ * This interface defines a common API to fetch objects from Asana without knowing what kind of objects are these,
+ * and how to process them. Implementors responsibility to detect whether an object is new, updated, or removed.
+ */
+public interface AsanaObjectFetcher {
+    /**
+     * Call this method to retrieve the next unprocessed object from Asana. The returned {@link AsanaObject} contains
+     * the object in Json formatted string, and information about its ID and whether it is new, updated, or removed.
+     *
+     * @return null if there are no more unprocessed objects at the moment, {@link AsanaObject} otherwise.
+     */
+    AsanaObject fetchNext();
+
+    /**
+     * Call this method to serialize this object fetcher's state.
+     *
+     * @return A {@link Map} containing the object state in key-value pairs. Optimized for using it with
+     *         {@link org.apache.nifi.components.state.StateManager}
+     */
+    Map<String, String> saveState();
+
+    /**
+     * Call this method to deserialize & restore a state that was saved/exported earlier.
+     *
+     * @param state A {@link Map} containing all the key-value pairs returned by a prior call to {@code saveState()}.
+     */
+    void loadState(Map<String, String> state);
+
+    /**
+     * Call this method to wipe out all the state information of this object fetcher. As if it was brand-new.
+     */
+    void clearState();
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaObjectFetcherException.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaObjectFetcherException.java
new file mode 100644
index 0000000000..e4fcb194ea
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaObjectFetcherException.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+public class AsanaObjectFetcherException extends RuntimeException {
+
+    public AsanaObjectFetcherException() {
+        super();
+    }
+
+    public AsanaObjectFetcherException(String message) {
+        super(message);
+    }
+
+    public AsanaObjectFetcherException(Throwable cause){
+        super(cause);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaObjectState.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaObjectState.java
new file mode 100644
index 0000000000..3a00840e1f
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaObjectState.java
@@ -0,0 +1,23 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+public enum AsanaObjectState {
+    NEW,
+    UPDATED,
+    REMOVED
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectEventFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectEventFetcher.java
new file mode 100644
index 0000000000..106d8f589a
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectEventFetcher.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import static java.lang.String.format;
+
+import com.asana.Json;
+import com.asana.models.Project;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.stream.StreamSupport;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.controller.asana.AsanaEventsCollection;
+import org.apache.nifi.util.StringUtils;
+
+public class AsanaProjectEventFetcher extends AbstractAsanaObjectFetcher {
+
+    private static final String PROJECT_GID = ".project.gid";
+    private static final String NEXT_SYNC_TOKEN = ".nextSyncToken";
+
+    private final AsanaClient client;
+    private final Project project;
+    private String nextSyncToken;
+
+    public AsanaProjectEventFetcher(AsanaClient client, String projectName) {
+        super();
+        this.client = client;
+        this.project = client.getProjectByName(projectName);
+        this.nextSyncToken = StringUtils.EMPTY;
+    }
+
+    @Override
+    public Map<String, String> saveState() {
+        Map<String, String> state = new HashMap<>();
+        state.put(this.getClass().getName() + PROJECT_GID, project.gid);
+        state.put(this.getClass().getName() + NEXT_SYNC_TOKEN, nextSyncToken);
+        return state;
+    }
+
+    @Override
+    public void loadState(Map<String, String> state) {
+        if (!project.gid.equals(state.get(this.getClass().getName() + PROJECT_GID))) {
+            throw new AsanaObjectFetcherException("Project gid does not match.");
+        }
+        nextSyncToken = state.getOrDefault(this.getClass().getName() + NEXT_SYNC_TOKEN, StringUtils.EMPTY);
+    }
+
+    @Override
+    public void clearState() {
+        nextSyncToken = StringUtils.EMPTY;
+    }
+
+    @Override
+    protected Iterator<AsanaObject> fetch() {
+        AsanaEventsCollection events = client.getEvents(project, nextSyncToken);
+        Iterator<AsanaObject> result = StreamSupport.stream(events.spliterator(), false)
+            .map(e -> new AsanaObject(
+                AsanaObjectState.NEW,
+                format("%s.%s.%s.%d", e.type, e.resource.resourceType, e.resource.gid, e.createdAt.getValue()),
+                Json.getInstance().toJson(e)))
+            .iterator();
+        nextSyncToken = events.getNextSyncToken();
+        return result;
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectFetcher.java
new file mode 100644
index 0000000000..7b7c797ff5
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectFetcher.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import com.asana.models.Project;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+
+public class AsanaProjectFetcher extends GenericAsanaObjectFetcher<Project> {
+    private final AsanaClient client;
+
+    public AsanaProjectFetcher(AsanaClient client) {
+        super();
+        this.client = client;
+    }
+
+    @Override
+    protected Stream<Project> fetchObjects() {
+        return client.getProjects();
+    }
+
+    @Override
+    protected String createObjectFingerprint(Project object) {
+        return Long.toString(object.modifiedAt.getValue());
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectMembershipFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectMembershipFetcher.java
new file mode 100644
index 0000000000..d037bf78db
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectMembershipFetcher.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import com.asana.models.Project;
+import com.asana.models.ProjectMembership;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class AsanaProjectMembershipFetcher extends GenericAsanaObjectFetcher<ProjectMembership> {
+    private static final String PROJECT_GID = ".project.gid";
+
+    private final AsanaClient client;
+    private final Project project;
+
+    public AsanaProjectMembershipFetcher(AsanaClient client, String projectName) {
+        super();
+        this.client = client;
+        this.project = client.getProjectByName(projectName);
+    }
+
+    @Override
+    public Map<String, String> saveState() {
+        Map<String, String> state = new HashMap<>(super.saveState());
+        state.put(this.getClass().getName() + PROJECT_GID, project.gid);
+        return state;
+    }
+
+    @Override
+    public void loadState(Map<String, String> state) {
+        if (!project.gid.equals(state.get(this.getClass().getName() + PROJECT_GID))) {
+            throw new AsanaObjectFetcherException("Project gid does not match.");
+        }
+        super.loadState(state);
+    }
+
+    @Override
+    protected Stream<ProjectMembership> fetchObjects() {
+        return client.getProjectMemberships(project);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectStatusAttachmentFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectStatusAttachmentFetcher.java
new file mode 100644
index 0000000000..9416f84520
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectStatusAttachmentFetcher.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import com.asana.models.Attachment;
+import com.asana.models.Project;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+
+public class AsanaProjectStatusAttachmentFetcher extends GenericAsanaObjectFetcher<Attachment> {
+    private static final String PROJECT_GID = ".project.gid";
+
+    private final AsanaClient client;
+    private final Project project;
+
+    public AsanaProjectStatusAttachmentFetcher(AsanaClient client, String projectName) {
+        super();
+        this.client = client;
+        this.project = client.getProjectByName(projectName);
+    }
+
+    @Override
+    public Map<String, String> saveState() {
+        Map<String, String> state = new HashMap<>(super.saveState());
+        state.put(this.getClass().getName() + PROJECT_GID, project.gid);
+        return state;
+    }
+
+    @Override
+    public void loadState(Map<String, String> state) {
+        if (!project.gid.equals(state.get(this.getClass().getName() + PROJECT_GID))) {
+            throw new AsanaObjectFetcherException("Project gid does not match.");
+        }
+        super.loadState(state);
+    }
+
+    @Override
+    protected Stream<Attachment> fetchObjects() {
+        return fetchProjectStatusAttachments();
+    }
+
+    @Override
+    protected String createObjectFingerprint(Attachment object) {
+        return Long.toString(object.createdAt.getValue());
+    }
+
+    private Stream<Attachment> fetchProjectStatusAttachments() {
+        return client.getProjectStatusUpdates(project).flatMap(client::getAttachments);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectStatusFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectStatusFetcher.java
new file mode 100644
index 0000000000..9c320bbb00
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaProjectStatusFetcher.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import com.asana.models.Project;
+import com.asana.models.ProjectStatus;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class AsanaProjectStatusFetcher extends GenericAsanaObjectFetcher<ProjectStatus> {
+    private static final String PROJECT_GID = ".project.gid";
+
+    private final AsanaClient client;
+    private final Project project;
+
+    public AsanaProjectStatusFetcher(AsanaClient client, String projectName) {
+        super();
+        this.client = client;
+        this.project = client.getProjectByName(projectName);
+    }
+
+    @Override
+    public Map<String, String> saveState() {
+        Map<String, String> state = new HashMap<>(super.saveState());
+        state.put(this.getClass().getName() + PROJECT_GID, project.gid);
+        return state;
+    }
+
+    @Override
+    public void loadState(Map<String, String> state) {
+        if (!project.gid.equals(state.get(this.getClass().getName() + PROJECT_GID))) {
+            throw new AsanaObjectFetcherException("Project gid does not match.");
+        }
+        super.loadState(state);
+    }
+
+    @Override
+    protected Stream<ProjectStatus> fetchObjects() {
+        return client.getProjectStatusUpdates(project);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaStoryFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaStoryFetcher.java
new file mode 100644
index 0000000000..a5475fdb22
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaStoryFetcher.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import com.asana.models.Story;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+
+public class AsanaStoryFetcher extends GenericAsanaObjectFetcher<Story> {
+    private final AsanaClient client;
+    private final AsanaTaskFetcher taskFetcher;
+
+    public AsanaStoryFetcher(AsanaClient client, String projectName, String sectionName, String tagName) {
+        super();
+        this.client = client;
+        this.taskFetcher = new AsanaTaskFetcher(client, projectName, sectionName, tagName);
+    }
+
+    @Override
+    public Map<String, String> saveState() {
+        Map<String, String> state = new HashMap<>();
+        state.putAll(taskFetcher.saveState());
+        state.putAll(super.saveState());
+        return state;
+    }
+
+    @Override
+    public void loadState(Map<String, String> state) {
+        taskFetcher.loadState(state);
+        super.loadState(state);
+    }
+
+    @Override
+    protected Stream<Story> fetchObjects() {
+        return fetchStories();
+    }
+
+    private Stream<Story> fetchStories() {
+        return taskFetcher.fetchTasks().flatMap(client::getStories);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTagFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTagFetcher.java
new file mode 100644
index 0000000000..6e4df8c283
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTagFetcher.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import com.asana.models.Tag;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+
+public class AsanaTagFetcher extends GenericAsanaObjectFetcher<Tag> {
+    private final AsanaClient client;
+
+    public AsanaTagFetcher(AsanaClient client) {
+        super();
+        this.client = client;
+    }
+
+    @Override
+    protected Stream<Tag> fetchObjects() {
+        return client.getTags();
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTaskAttachmentFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTaskAttachmentFetcher.java
new file mode 100644
index 0000000000..9f0ea016a1
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTaskAttachmentFetcher.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import com.asana.models.Attachment;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+
+public class AsanaTaskAttachmentFetcher extends GenericAsanaObjectFetcher<Attachment> {
+    private final AsanaClient client;
+    private final AsanaTaskFetcher taskFetcher;
+
+    public AsanaTaskAttachmentFetcher(AsanaClient client, String projectName, String sectionName, String tagName) {
+        super();
+        this.client = client;
+        this.taskFetcher = new AsanaTaskFetcher(client, projectName, sectionName, tagName);
+    }
+
+    @Override
+    public Map<String, String> saveState() {
+        Map<String, String> state = new HashMap<>();
+        state.putAll(taskFetcher.saveState());
+        state.putAll(super.saveState());
+        return state;
+    }
+
+    @Override
+    public void loadState(Map<String, String> state) {
+        taskFetcher.loadState(state);
+        super.loadState(state);
+    }
+
+    @Override
+    protected Stream<Attachment> fetchObjects() {
+        return fetchTaskAttachments();
+    }
+
+    @Override
+    protected String createObjectFingerprint(Attachment object) {
+        return Long.toString(object.createdAt.getValue());
+    }
+
+    private Stream<Attachment> fetchTaskAttachments() {
+        return taskFetcher.fetchTasks().flatMap(client::getAttachments);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTaskFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTaskFetcher.java
new file mode 100644
index 0000000000..ba707c77ca
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTaskFetcher.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import com.asana.models.Project;
+import com.asana.models.Section;
+import com.asana.models.Task;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public class AsanaTaskFetcher extends GenericAsanaObjectFetcher<Task> {
+    private static final String SETTINGS_FINGERPRINT = ".settings.fingerprint";
+
+    private final AsanaClient client;
+    private final Project project;
+    private final Section section;
+    private final String tagName;
+
+    public AsanaTaskFetcher(AsanaClient client, String projectName, String sectionName, String tagName) {
+        super();
+        this.client = client;
+        this.project = client.getProjectByName(projectName);
+
+        this.section = Optional.ofNullable(sectionName)
+            .map(name -> client.getSectionByName(project, name))
+            .orElse(null);
+
+        this.tagName = tagName;
+    }
+
+    @Override
+    public Map<String, String> saveState() {
+        Map<String, String> state = new HashMap<>(super.saveState());
+        state.put(this.getClass().getName() + SETTINGS_FINGERPRINT, createSettingsFingerprint());
+        return state;
+    }
+
+    @Override
+    public void loadState(Map<String, String> state) {
+        if (!createSettingsFingerprint().equals(state.get(this.getClass().getName() + SETTINGS_FINGERPRINT))) {
+            throw new AsanaObjectFetcherException("Settings mismatch.");
+        }
+        super.loadState(state);
+    }
+
+    public Stream<Task> fetchTasks() {
+        Stream<Task> result;
+        if (section != null) {
+            result = client.getTasks(section);
+        } else {
+            result = client.getTasks(project);
+        }
+
+        if (tagName != null) {
+            Set<String> taskIdsWithTag = client.getTags()
+                .filter(tag -> tag.name.equals(tagName))
+                .flatMap(client::getTasks)
+                .map(t -> t.gid)
+                .collect(Collectors.toSet());
+
+            result = result.filter(task -> taskIdsWithTag.contains(task.gid));
+        }
+
+        return result;
+    }
+
+    @Override
+    protected Stream<Task> fetchObjects() {
+        return fetchTasks();
+    }
+
+    @Override
+    protected String createObjectFingerprint(Task object) {
+        return Long.toString(object.modifiedAt.getValue());
+    }
+
+    private String createSettingsFingerprint() {
+        return String.join(":", Arrays.asList(
+            Optional.ofNullable(project).map(p -> p.gid).orElse(""),
+            Optional.ofNullable(section).map(s -> s.gid).orElse(""),
+            Optional.ofNullable(tagName).orElse("")
+        ));
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTeamFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTeamFetcher.java
new file mode 100644
index 0000000000..133325c2d8
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTeamFetcher.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import com.asana.models.Team;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+
+public class AsanaTeamFetcher extends GenericAsanaObjectFetcher<Team> {
+    private final AsanaClient client;
+
+    public AsanaTeamFetcher(AsanaClient client) {
+        super();
+        this.client = client;
+    }
+
+    @Override
+    protected Stream<Team> fetchObjects() {
+        return client.getTeams();
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTeamMemberFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTeamMemberFetcher.java
new file mode 100644
index 0000000000..80e74204a9
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaTeamMemberFetcher.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import com.asana.models.Team;
+import com.asana.models.User;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class AsanaTeamMemberFetcher extends GenericAsanaObjectFetcher<User> {
+    private static final String TEAM_GID = ".team.gid";
+
+    private final AsanaClient client;
+    private final Team team;
+
+    public AsanaTeamMemberFetcher(AsanaClient client, String teamName) {
+        super();
+        this.client = client;
+        this.team = client.getTeamByName(teamName);
+    }
+
+    @Override
+    public Map<String, String> saveState() {
+        Map<String, String> state = new HashMap<>(super.saveState());
+        state.put(this.getClass().getName() + TEAM_GID, team.gid);
+        return state;
+    }
+
+    @Override
+    public void loadState(Map<String, String> state) {
+        if (!team.gid.equals(state.get(this.getClass().getName() + TEAM_GID))) {
+            throw new AsanaObjectFetcherException("Team gid does not match.");
+        }
+        super.loadState(state);
+    }
+
+    @Override
+    protected Stream<User> fetchObjects() {
+        return client.getTeamMembers(team);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaUserFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaUserFetcher.java
new file mode 100644
index 0000000000..d0ce701105
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/AsanaUserFetcher.java
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import com.asana.models.User;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+
+public class AsanaUserFetcher extends GenericAsanaObjectFetcher<User> {
+    private final AsanaClient client;
+
+    public AsanaUserFetcher(AsanaClient client) {
+        super();
+        this.client = client;
+    }
+
+    @Override
+    protected Stream<User> fetchObjects() {
+        return client.getUsers();
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/GenericAsanaObjectFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/GenericAsanaObjectFetcher.java
new file mode 100644
index 0000000000..433e0e12b9
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/java/org/apache/nifi/processors/asana/utils/GenericAsanaObjectFetcher.java
@@ -0,0 +1,176 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.utils;
+
+import static java.util.Collections.emptySet;
+
+import com.asana.Json;
+import com.asana.models.Resource;
+import com.google.gson.reflect.TypeToken;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.lang.reflect.Type;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Stream;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.GZIPOutputStream;
+import org.apache.commons.collections4.iterators.FilterIterator;
+
+public abstract class GenericAsanaObjectFetcher<T extends Resource> extends AbstractAsanaObjectFetcher {
+    private static final String LAST_FINGERPRINTS = ".lastFingerprints";
+
+    private Map<String, String> lastFingerprints;
+
+    public GenericAsanaObjectFetcher() {
+        super();
+        this.lastFingerprints = new HashMap<>();
+    }
+
+    @Override
+    public AsanaObject fetchNext() {
+        AsanaObject result = super.fetchNext();
+        if (result != null) {
+            if (result.getState().equals(AsanaObjectState.REMOVED)) {
+                lastFingerprints.remove(result.getGid());
+            } else {
+                lastFingerprints.put(result.getGid(), result.getFingerprint());
+            }
+        }
+        return result;
+    }
+
+    @Override
+    public Map<String, String> saveState() {
+        Map<String, String> state = new HashMap<>();
+        try {
+            state.put(this.getClass().getName() + LAST_FINGERPRINTS, compress(Json.getInstance().toJson(lastFingerprints)));
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+        return state;
+    }
+
+    @Override
+    public void loadState(Map<String, String> state) {
+        if (state.containsKey(this.getClass().getName() + LAST_FINGERPRINTS)) {
+            Type type = new TypeToken<HashMap<String, String>>() {}.getType();
+            try {
+                lastFingerprints = Json.getInstance().fromJson(decompress(state.get(this.getClass().getName() + LAST_FINGERPRINTS)), type);
+            } catch (IOException e) {
+                throw new UncheckedIOException(e);
+            }
+        }
+    }
+
+    @Override
+    public void clearState() {
+        super.clearState();
+        lastFingerprints.clear();
+    }
+
+    @Override
+    protected Iterator<AsanaObject> fetch() {
+        Stream<AsanaObject> currentObjects = fetchObjects()
+                .map(item -> {
+                    String payload = transformObjectToPayload(item);
+                    return new AsanaObject(
+                            lastFingerprints.containsKey(item.gid) ? AsanaObjectState.UPDATED : AsanaObjectState.NEW,
+                            item.gid,
+                            payload,
+                            Optional.ofNullable(createObjectFingerprint(item)).orElseGet(() -> calculateSecureHash(payload)));
+                });
+
+        return new FilterIterator<>(
+                new Iterator<AsanaObject>() {
+                    Iterator<AsanaObject> it = currentObjects.iterator();
+                    Set<String> unseenIds = new HashSet<>(lastFingerprints.keySet()); // copy all previously seen ids.
+
+                    @Override
+                    public boolean hasNext() {
+                        return it.hasNext() || !unseenIds.isEmpty();
+                    }
+
+                    @Override
+                    public AsanaObject next() {
+                        if (it.hasNext()) {
+                            AsanaObject next = it.next();
+                            unseenIds.remove(next.getGid());
+                            return next;
+                        }
+                        it = unseenIds.stream()
+                                .map(gid -> new AsanaObject(AsanaObjectState.REMOVED, gid,
+                                        Json.getInstance().toJson(gid)))
+                                .iterator();
+                        unseenIds = emptySet();
+                        return it.next();
+                    }
+                },
+                item -> !item.getState().equals(AsanaObjectState.UPDATED) || !lastFingerprints.get(item.getGid())
+                        .equals(item.getFingerprint())
+        );
+    }
+
+    protected String transformObjectToPayload(T object) {
+        return Json.getInstance().toJson(object);
+    }
+
+    protected String createObjectFingerprint(T object) {
+        return null;
+    }
+
+    protected abstract Stream<T> fetchObjects();
+
+    private static String compress(String str) throws IOException {
+        ByteArrayOutputStream compressedBytes = new ByteArrayOutputStream(str.length());
+        try (GZIPOutputStream gzip = new GZIPOutputStream(compressedBytes)) {
+            gzip.write(str.getBytes(StandardCharsets.UTF_8));
+        }
+        return Base64.getEncoder().encodeToString(compressedBytes.toByteArray());
+    }
+
+    private static String decompress(String str) throws IOException {
+        ByteArrayOutputStream uncompressedBytes = new ByteArrayOutputStream();
+        try (InputStream gzip = new GZIPInputStream(new ByteArrayInputStream(Base64.getDecoder().decode(str)))) {
+            int n;
+            byte[] buffer = new byte[1024];
+            while((n = gzip.read(buffer)) > -1) {
+                uncompressedBytes.write(buffer, 0, n);
+            }
+        }
+        return new String(uncompressedBytes.toByteArray(), StandardCharsets.UTF_8);
+    }
+
+    private String calculateSecureHash(String input) {
+        try {
+            return Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-512").digest(input.getBytes(StandardCharsets.UTF_8)));
+        } catch (NoSuchAlgorithmException e) {
+            throw new AsanaObjectFetcherException(e);
+        }
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
new file mode 100644
index 0000000000..16a71b04b8
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
@@ -0,0 +1,15 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+org.apache.nifi.processors.asana.GetAsanaObject
\ No newline at end of file
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/resources/docs/org.apache.nifi.processors.asana.GetAsanaObject/additionalDetails.html b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/resources/docs/org.apache.nifi.processors.asana.GetAsanaObject/additionalDetails.html
new file mode 100644
index 0000000000..e4886d2b0f
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/main/resources/docs/org.apache.nifi.processors.asana.GetAsanaObject/additionalDetails.html
@@ -0,0 +1,100 @@
+<!DOCTYPE html>
+<html lang="en" xmlns="http://www.w3.org/1999/html">
+<!--
+      Licensed to the Apache Software Foundation (ASF) under one or more
+      contributor license agreements.  See the NOTICE file distributed with
+      this work for additional information regarding copyright ownership.
+      The ASF licenses this file to You under the Apache License, Version 2.0
+      (the "License"); you may not use this file except in compliance with
+      the License.  You may obtain a copy of the License at
+          http://www.apache.org/licenses/LICENSE-2.0
+      Unless required by applicable law or agreed to in writing, software
+      distributed under the License is distributed on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+      See the License for the specific language governing permissions and
+      limitations under the License.
+    -->
+
+<head>
+    <meta charset="utf-8"/>
+    <title>GetAsanaObject</title>
+    <link rel="stylesheet" href="../../../../../css/component-usage.css" type="text/css"/>
+    <style>
+        h2 {margin-top: 4em}
+        h3 {margin-top: 3em}
+        td {text-align: left}
+    </style>
+</head>
+
+<body>
+
+<h1>GetAsanaObject</h1>
+
+<h3>Description</h3>
+<p>
+    This processor collects various objects (eg. tasks, comments, etc...) from Asana via the specified
+    <code>AsanaClientService</code>. When the processor started for the first time with a given configuration
+    it collects each of the objects matching the user specified criteria, and emits <code>FlowFile</code>s
+    of each on the <code>NEW</code> relationship. Then, it polls Asana in the frequency of the configured <em>Run Schedule</em>
+    and detects changes by comparing the object fingerprints. When there are updates, it emits them through
+    the <code>UPDATED</code> and <code>REMOVED</code> relationships, respectively.
+</p>
+
+<h3>FlowFile contents & attributes</h3>
+<p>
+    Each emitted <code>FlowFile</code> contains the Json representation of the fetched Asana object. These can be
+    processed further via the respective processors, that accept text data in this format. The <code>FlowFile</code>s
+    emitted from the <code>REMOVED</code> relationship have no content, because the actual data is not stored in the
+    processor, and so there is no way to retrieve the deleted content.<br />
+    <br />
+    Each <code>FlowFile</code>, regardless to which relationship they were emitted from, have an <code>asana.gid</code>
+    attribute set, which contain the ID of the object in Asana. These IDs are globally unique within the Asana instance,
+    regardless of what type of object they were assigned to. In case of <em>Events</em>, these IDs are generated by the
+    client, because Asana does not keep track of these objects.
+</p>
+
+<h3>Object fingerprints</h3>
+<p>
+    These are used only for content change detection.<br />
+    <br />
+    Fingerprints are generally calculated by applying an <code>SHA-512</code> algorithm on the retrieved object. In case
+    of immutable objects, like <em>Attachments</em>, these fingerprints are static, so <em>update</em>s (which is impossible
+    anyway) are not detected. In case of <em>Projects</em> and <em>Tasks</em>, where the last modification time is available,
+    these timestamps are stored as fingerprints.
+</p>
+
+<h3>Batch size</h3>
+<p>
+    By default, this processor emits each fetched object from Asana in a separate <code>FlowFile</code>. This is usually OK
+    for a workspace having low traffic, and thus generating data in low rate. For workspaces with high volume of traffic,
+    it is advisable to set the batch size to a reasonably high value, to have better performance. With this value set to
+    something other than the default (1), the processor will emit <code>FlowFile</code>s that have multiple items batched
+    together in a Json array, but in exchange, without having the <code>asana.gid</code> attribute set.
+</p>
+
+<h3>Configuring filters, filtering by name</h3>
+<p>
+    In case of collecting some objects, like <em>Project Events</em>, <em>Tasks</em>, and <em>Team Members</em>, the processor
+    requires/allows defining filters. In example: if you would like to collect <em>Tasks</em>, then you need to define the project
+    from where the tasks you would like to collect.<br />
+    <br />
+    In these cases, when the filters refer to some parent object, you need to provide its name in the configuration, in
+    case-sensitive manner. Another important note to keep in mind, Asana lets the users create multiple objects with the
+    same name. In example: you can create two projects with name 'My project'. But when you need to refer to this project
+    by its name, it is impossible to figure out which 'My project' you intended to refer to, therefore these situations
+    should be avoided. In such cases, this processor picks the first one returned by Asana when listing them. This is not
+    random, but the ordering is not guaranteed.
+</p>
+
+<h3>Further reading about Asana</h3>
+<p>
+<ul>
+    <li><a href="https://academy.asana.com">Asana Academy</a></li>
+    <li><a href="https://asana.com/guide">Asana Guide</a></li>
+    <li><a href="https://developers.asana.com/docs">Asana Developer Documentation</a></li>
+    <li><a href="https://github.com/Asana/java-asana/">Java client library for the Asana API</a></li>
+</ul>
+</p>
+
+</body>
+</html>
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AbstractAsanaObjectFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AbstractAsanaObjectFetcherTest.java
new file mode 100644
index 0000000000..23c135931b
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AbstractAsanaObjectFetcherTest.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import org.apache.nifi.processors.asana.mocks.MockAbstractAsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+public class AbstractAsanaObjectFetcherTest {
+
+    @Test
+    public void testFetchNext() {
+        final MockAbstractAsanaObjectFetcher fetcher = new MockAbstractAsanaObjectFetcher();
+
+        fetcher.items = emptyList();
+        assertNull(fetcher.fetchNext());
+        assertEquals(1, fetcher.pollCount);
+        assertNull(fetcher.fetchNext());
+        assertEquals(2, fetcher.pollCount);
+
+        final AsanaObject oneObject = new AsanaObject(AsanaObjectState.NEW, "1234");
+        final AsanaObject otherObject = new AsanaObject(AsanaObjectState.REMOVED, "1234");
+
+        fetcher.items = singletonList(oneObject);
+        assertEquals(oneObject, fetcher.fetchNext());
+        assertEquals(3, fetcher.pollCount);
+        assertNull(fetcher.fetchNext());
+        assertEquals(3, fetcher.pollCount);
+        assertNull(fetcher.fetchNext());
+        assertEquals(4, fetcher.pollCount);
+        assertNull(fetcher.fetchNext());
+        assertEquals(5, fetcher.pollCount);
+
+        fetcher.items = Arrays.asList(oneObject, otherObject);
+        assertEquals(oneObject, fetcher.fetchNext());
+        assertEquals(6, fetcher.pollCount);
+        assertEquals(otherObject, fetcher.fetchNext());
+        assertEquals(6, fetcher.pollCount);
+        assertNull(fetcher.fetchNext());
+        assertEquals(6, fetcher.pollCount);
+        assertNull(fetcher.fetchNext());
+        assertEquals(7, fetcher.pollCount);
+        assertNull(fetcher.fetchNext());
+        assertEquals(8, fetcher.pollCount);
+
+        fetcher.items = Arrays.asList(oneObject, otherObject);
+        assertEquals(oneObject, fetcher.fetchNext());
+        assertEquals(9, fetcher.pollCount);
+        fetcher.items = Arrays.asList(oneObject, otherObject);
+        assertEquals(otherObject, fetcher.fetchNext());
+        assertEquals(9, fetcher.pollCount);
+        assertNull(fetcher.fetchNext());
+        assertEquals(9, fetcher.pollCount);
+        assertEquals(oneObject, fetcher.fetchNext());
+        assertEquals(10, fetcher.pollCount);
+        assertEquals(otherObject, fetcher.fetchNext());
+        assertEquals(10, fetcher.pollCount);
+        assertNull(fetcher.fetchNext());
+        fetcher.items = singletonList(oneObject);
+        assertEquals(oneObject, fetcher.fetchNext());
+        assertEquals(11, fetcher.pollCount);
+        assertNull(fetcher.fetchNext());
+        assertEquals(11, fetcher.pollCount);
+        assertNull(fetcher.fetchNext());
+        assertEquals(12, fetcher.pollCount);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaObjectTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaObjectTest.java
new file mode 100644
index 0000000000..5c525efbbd
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaObjectTest.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.util.StringUtils;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+public class AsanaObjectTest {
+
+    private static final String GID = "1234";
+    private static final String CONTENT = "Lorem Ipsum";
+    private static final String FINGERPRINT = "Foo Bar";
+
+    @Test
+    public void testWithoutContent() {
+        final AsanaObject asanaObject = new AsanaObject(AsanaObjectState.NEW, GID);
+
+        assertEquals(AsanaObjectState.NEW, asanaObject.getState());
+        assertEquals(GID, asanaObject.getGid());
+        assertEquals(StringUtils.EMPTY, asanaObject.getContent());
+        assertEquals(StringUtils.EMPTY, asanaObject.getFingerprint());
+    }
+
+    @Test
+    public void testWithoutFingerprint() {
+        final AsanaObject asanaObject = new AsanaObject(AsanaObjectState.NEW, GID, CONTENT);
+
+        assertEquals(AsanaObjectState.NEW, asanaObject.getState());
+        assertEquals(GID, asanaObject.getGid());
+        assertEquals(CONTENT, asanaObject.getContent());
+        assertEquals(CONTENT, asanaObject.getFingerprint());
+    }
+
+    @Test
+    public void testWithFingerprint() {
+        final AsanaObject asanaObject = new AsanaObject(AsanaObjectState.NEW, GID, CONTENT, FINGERPRINT);
+
+        assertEquals(AsanaObjectState.NEW, asanaObject.getState());
+        assertEquals(GID, asanaObject.getGid());
+        assertEquals(CONTENT, asanaObject.getContent());
+        assertEquals(FINGERPRINT, asanaObject.getFingerprint());
+    }
+
+    @Test
+    public void testWithNullFingerprint() {
+        final AsanaObject asanaObject = new AsanaObject(AsanaObjectState.NEW, GID, CONTENT, null);
+
+        assertEquals(AsanaObjectState.NEW, asanaObject.getState());
+        assertEquals(GID, asanaObject.getGid());
+        assertEquals(CONTENT, asanaObject.getContent());
+        assertEquals(CONTENT, asanaObject.getFingerprint());
+    }
+
+    @Test
+    public void testWithNullContentAndNonNullFingerprint() {
+        final AsanaObject asanaObject = new AsanaObject(AsanaObjectState.NEW, GID, null, FINGERPRINT);
+
+        assertEquals(AsanaObjectState.NEW, asanaObject.getState());
+        assertEquals(GID, asanaObject.getGid());
+        assertNull(asanaObject.getContent());
+        assertEquals(FINGERPRINT, asanaObject.getFingerprint());
+    }
+
+    @Test
+    public void testWithOtherStates() {
+        assertEquals(AsanaObjectState.UPDATED, new AsanaObject(AsanaObjectState.UPDATED, GID).getState());
+        assertEquals(AsanaObjectState.REMOVED, new AsanaObject(AsanaObjectState.REMOVED, GID).getState());
+
+        assertEquals(AsanaObjectState.UPDATED, new AsanaObject(AsanaObjectState.UPDATED, GID, CONTENT).getState());
+        assertEquals(AsanaObjectState.REMOVED, new AsanaObject(AsanaObjectState.REMOVED, GID, CONTENT).getState());
+
+        assertEquals(AsanaObjectState.UPDATED, new AsanaObject(AsanaObjectState.UPDATED, GID, CONTENT, FINGERPRINT).getState());
+        assertEquals(AsanaObjectState.REMOVED, new AsanaObject(AsanaObjectState.REMOVED, GID, CONTENT, FINGERPRINT).getState());
+    }
+
+    @Test
+    public void testEquality() {
+        final AsanaObject one = new AsanaObject(AsanaObjectState.UPDATED, GID);
+        assertEquals(one, one);
+
+        final AsanaObject other = new AsanaObject(AsanaObjectState.NEW, GID);
+        assertNotEquals(one, other);
+
+        assertEquals(one, new AsanaObject(AsanaObjectState.UPDATED, GID));
+        assertNotEquals(one, new AsanaObject(AsanaObjectState.UPDATED, GID, CONTENT));
+        assertNotEquals(one, new AsanaObject(AsanaObjectState.UPDATED, GID, CONTENT, FINGERPRINT));
+
+        final AsanaObject another = new AsanaObject(AsanaObjectState.REMOVED, GID, CONTENT, FINGERPRINT);
+        assertEquals(another, another);
+        assertEquals(another, new AsanaObject(AsanaObjectState.REMOVED, GID, CONTENT, FINGERPRINT));
+        assertNotEquals(another, new AsanaObject(AsanaObjectState.REMOVED, GID, CONTENT + "1", FINGERPRINT));
+        assertNotEquals(another, new AsanaObject(AsanaObjectState.REMOVED, GID, CONTENT, FINGERPRINT + "1"));
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectEventFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectEventFetcherTest.java
new file mode 100644
index 0000000000..ade3c24031
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectEventFetcherTest.java
@@ -0,0 +1,162 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import com.asana.models.Event;
+import com.asana.models.Project;
+import com.google.api.client.util.DateTime;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.controller.asana.AsanaEventsCollection;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcherException;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaProjectEventFetcher;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class AsanaProjectEventFetcherTest {
+
+    @Mock
+    private AsanaClient client;
+    private Project project;
+
+    @BeforeEach
+    public void init() {
+        project = new Project();
+        project.gid = "123";
+        project.modifiedAt = new DateTime(123456789);
+        project.name = "My Project";
+
+        when(client.getProjectByName(project.name)).thenReturn(project);
+    }
+
+    @Test
+    public void testSyncTokenIsUpdated() {
+        final AsanaObjectFetcher fetcher = new AsanaProjectEventFetcher(client, project.name);
+
+        when(client.getEvents(any(), any()))
+            .thenReturn(new AsanaEventsCollection("foo", emptyList()))
+            .thenReturn(new AsanaEventsCollection("bar", emptyList()));
+
+        fetcher.fetchNext();
+        fetcher.fetchNext();
+        fetcher.fetchNext();
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getEvents(project, "");
+        verify(client, times(1)).getEvents(project, "foo");
+        verify(client, times(1)).getEvents(project, "bar");
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleEventIsFetched() {
+        final AsanaObjectFetcher fetcher = new AsanaProjectEventFetcher(client, project.name);
+
+        final Event event = new Event();
+        event.createdAt = new DateTime(123456789);
+        event.type = "SomeEvent";
+        event.resource = event.new Entity();
+        event.resource.name = "Foo Bar";
+        event.resource.resourceType = "Something";
+        event.resource.gid = "123";
+
+        when(client.getEvents(any(), any()))
+            .thenReturn(new AsanaEventsCollection("foo", singletonList(event)))
+            .thenReturn(new AsanaEventsCollection("bar", emptyList()));
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertFalse(object.getGid().isEmpty());
+        assertFalse(object.getContent().isEmpty());
+        assertFalse(object.getFingerprint().isEmpty());
+    }
+
+    @Test
+    public void testRestoreStateAndContinue() {
+        final AsanaObjectFetcher fetcher1 = new AsanaProjectEventFetcher(client, project.name);
+
+        when(client.getEvents(any(), any()))
+                .thenReturn(new AsanaEventsCollection("foo", emptyList()))
+                .thenReturn(new AsanaEventsCollection("bar", emptyList()));
+
+        fetcher1.fetchNext();
+        fetcher1.fetchNext();
+
+        final AsanaObjectFetcher fetcher2 = new AsanaProjectEventFetcher(client, project.name);
+        fetcher2.loadState(fetcher1.saveState());
+
+        fetcher2.fetchNext();
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getEvents(project, "");
+        verify(client, times(1)).getEvents(project, "foo");
+        verify(client, times(1)).getEvents(project, "bar");
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testClearState() {
+        final AsanaObjectFetcher fetcher = new AsanaProjectEventFetcher(client, project.name);
+
+        when(client.getEvents(any(), any()))
+                .thenReturn(new AsanaEventsCollection("foo", emptyList()))
+                .thenReturn(new AsanaEventsCollection("bar", emptyList()));
+
+        fetcher.fetchNext();
+        fetcher.fetchNext();
+        fetcher.clearState();
+        fetcher.fetchNext();
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getEvents(project, "");
+        verify(client, times(1)).getEvents(project, "foo");
+        verify(client, times(0)).getEvents(project, "bar");
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testWrongStateForConfigurationThrows() {
+        final AsanaObjectFetcher fetcher1 = new AsanaProjectEventFetcher(client, project.name);
+
+        Project otherProject = new Project();
+        otherProject.name = "Other Project";
+        otherProject.gid = "999";
+
+        when(client.getProjectByName(otherProject.name)).thenReturn(otherProject);
+
+        final AsanaObjectFetcher fetcher2 = new AsanaProjectEventFetcher(client, otherProject.name);
+        assertThrows(AsanaObjectFetcherException.class, () -> fetcher2.loadState(fetcher1.saveState()));
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectFetcherTest.java
new file mode 100644
index 0000000000..4c3b0dff8f
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectFetcherTest.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.asana.models.Project;
+import com.google.api.client.util.DateTime;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaProjectFetcher;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class AsanaProjectFetcherTest {
+
+    @Mock
+    private AsanaClient client;
+
+    @Test
+    public void testNoProjectsFetchedWhenNoProjectsReturned() {
+        when(client.getProjects()).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectFetcher(client);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, times(1)).getProjects();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleProjectFetched() {
+        final Project project = new Project();
+        project.gid = "123";
+        project.name = "My Project";
+        project.modifiedAt = new DateTime(123456789);
+
+        when(client.getProjects()).then(invocation -> Stream.of(project));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectFetcher(client);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(project.gid, object.getGid());
+        verify(client, times(1)).getProjects();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleProjectUpdatedOnlyWhenModificationDateChanges() {
+        final Project project = new Project();
+        project.gid = "123";
+        project.name = "My Project";
+        project.modifiedAt = new DateTime(123456789);
+
+        when(client.getProjects()).then(invocation -> Stream.of(project));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectFetcher(client);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        project.name = "My Project Is Renamed";
+        assertNull(fetcher.fetchNext());
+
+        project.modifiedAt = new DateTime(234567891);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(project.gid, object.getGid());
+        verify(client, times(3)).getProjects();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testClearState() {
+        final Project project = new Project();
+        project.gid = "123";
+        project.name = "My Project";
+        project.modifiedAt = new DateTime(123456789);
+
+        when(client.getProjects()).then(invocation -> Stream.of(project));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectFetcher(client);
+        assertNotNull(fetcher.fetchNext());
+
+        project.modifiedAt = new DateTime(234567891);
+        fetcher.clearState();
+
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(project.gid, object.getGid());
+        verify(client, times(2)).getProjects();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testRestoreStateAndContinue() {
+        final Project project = new Project();
+        project.gid = "123";
+        project.name = "My Project";
+        project.modifiedAt = new DateTime(123456789);
+
+        when(client.getProjects()).then(invocation -> Stream.of(project));
+
+        final AsanaObjectFetcher fetcher1 = new AsanaProjectFetcher(client);
+        assertNotNull(fetcher1.fetchNext());
+        assertNull(fetcher1.fetchNext());
+
+        project.name = "My Project Is Renamed";
+        assertNull(fetcher1.fetchNext());
+
+        project.modifiedAt = new DateTime(234567891);
+
+        final AsanaObjectFetcher fetcher2 = new AsanaProjectFetcher(client);
+        fetcher2.loadState(fetcher1.saveState());
+        AsanaObject object = fetcher2.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(project.gid, object.getGid());
+        verify(client, times(3)).getProjects();
+        verifyNoMoreInteractions(client);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectMembershipFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectMembershipFetcherTest.java
new file mode 100644
index 0000000000..472cedd645
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectMembershipFetcherTest.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.asana.models.Project;
+import com.asana.models.ProjectMembership;
+import com.asana.models.User;
+import com.google.api.client.util.DateTime;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcherException;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaProjectMembershipFetcher;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class AsanaProjectMembershipFetcherTest {
+
+    @Mock
+    private AsanaClient client;
+    private Project project;
+
+    @BeforeEach
+    public void init() {
+        project = new Project();
+        project.gid = "123";
+        project.modifiedAt = new DateTime(123456789);
+        project.name = "My Project";
+
+        when(client.getProjectByName(project.name)).thenReturn(project);
+    }
+
+    @Test
+    public void testNoObjectsFetchedWhenNoProjectMembershipsReturned() {
+        when(client.getProjectMemberships(any())).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectMembershipFetcher(client, project.name);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getProjectMemberships(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleMembershipFetched() {
+        final ProjectMembership membership = new ProjectMembership();
+        membership.gid = "123";
+        membership.project = project;
+        membership.user = new User();
+        membership.user.gid = "456";
+        membership.user.name = "Foo Bar";
+
+        when(client.getProjectMemberships(any())).then(invocation -> Stream.of(membership));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectMembershipFetcher(client, project.name);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(membership.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getProjectMemberships(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleMembershipUpdatedWhenAnyPartChanges() {
+        final ProjectMembership membership = new ProjectMembership();
+        membership.gid = "123";
+        membership.project = project;
+        membership.user = new User();
+        membership.user.gid = "456";
+        membership.user.name = "Foo Bar";
+
+        when(client.getProjectMemberships(any())).then(invocation -> Stream.of(membership));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectMembershipFetcher(client, project.name);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        membership.user.name = "Bar Foo";
+        AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(membership.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getProjectMemberships(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testRestoreStateAndContinue() {
+        final ProjectMembership membership = new ProjectMembership();
+        membership.gid = "123";
+        membership.project = project;
+        membership.user = new User();
+        membership.user.gid = "456";
+        membership.user.name = "Foo Bar";
+
+        when(client.getProjectMemberships(any())).then(invocation -> Stream.of(membership));
+
+        final AsanaObjectFetcher fetcher1 = new AsanaProjectMembershipFetcher(client, project.name);
+        assertNotNull(fetcher1.fetchNext());
+
+        final AsanaObjectFetcher fetcher2 = new AsanaProjectMembershipFetcher(client, project.name);
+        fetcher2.loadState(fetcher1.saveState());
+
+        membership.user.name = "Bar Foo";
+        AsanaObject object = fetcher2.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(membership.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getProjectMemberships(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testClearState() {
+        final ProjectMembership membership = new ProjectMembership();
+        membership.gid = "123";
+        membership.project = project;
+        membership.user = new User();
+        membership.user.gid = "456";
+        membership.user.name = "Foo Bar";
+
+        when(client.getProjectMemberships(any())).then(invocation -> Stream.of(membership));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectMembershipFetcher(client, project.name);
+        assertNotNull(fetcher.fetchNext());
+
+        membership.user.name = "Bar Foo";
+        fetcher.clearState();
+        AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(membership.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getProjectMemberships(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testWrongStateForConfigurationThrows() {
+        final Project otherProject = new Project();
+        otherProject.gid = "999";
+        otherProject.name = "Other Project";
+
+        when(client.getProjectByName(otherProject.name)).thenReturn(otherProject);
+
+        final AsanaObjectFetcher fetcher1 = new AsanaProjectMembershipFetcher(client, project.name);
+        final AsanaObjectFetcher fetcher2 = new AsanaProjectMembershipFetcher(client, otherProject.name);
+        assertThrows(AsanaObjectFetcherException.class, () -> fetcher2.loadState(fetcher1.saveState()));
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectStatusAttachmentFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectStatusAttachmentFetcherTest.java
new file mode 100644
index 0000000000..ee38754db8
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectStatusAttachmentFetcherTest.java
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.asana.models.Attachment;
+import com.asana.models.Project;
+import com.asana.models.ProjectStatus;
+import com.google.api.client.util.DateTime;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcherException;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaProjectStatusAttachmentFetcher;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class AsanaProjectStatusAttachmentFetcherTest {
+
+    @Mock
+    private AsanaClient client;
+    private Project project;
+
+    @BeforeEach
+    public void init() {
+        project = new Project();
+        project.gid = "123";
+        project.modifiedAt = new DateTime(123456789);
+        project.name = "My Project";
+
+        when(client.getProjectByName(project.name)).thenReturn(project);
+    }
+
+    @Test
+    public void testNoAttachmentsFetchedWhenNoStatusUpdatesReturned() {
+        when(client.getProjectStatusUpdates(any())).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectStatusAttachmentFetcher(client, project.name);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getProjectStatusUpdates(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testNoAttachmentsFetchedWhenNoAttachmentsReturned() {
+        final ProjectStatus status = new ProjectStatus();
+        status.gid = "123";
+
+        when(client.getProjectStatusUpdates(any())).then(invocation -> Stream.of(status));
+        when(client.getAttachments(any(ProjectStatus.class))).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectStatusAttachmentFetcher(client, project.name);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getProjectStatusUpdates(project);
+        verify(client, times(1)).getAttachments(status);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleAttachmentFetched() {
+        final ProjectStatus status = new ProjectStatus();
+        status.gid = "123";
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "456";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getProjectStatusUpdates(any())).then(invocation -> Stream.of(status));
+        when(client.getAttachments(any(ProjectStatus.class))).then(invocation -> Stream.of(attachment));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectStatusAttachmentFetcher(client, project.name);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(attachment.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getProjectStatusUpdates(project);
+        verify(client, times(1)).getAttachments(status);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testAttachmentUpdateIsNotDetected() {
+        final ProjectStatus status = new ProjectStatus();
+        status.gid = "123";
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "456";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getProjectStatusUpdates(any())).then(invocation -> Stream.of(status));
+        when(client.getAttachments(any(ProjectStatus.class))).then(invocation -> Stream.of(attachment));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectStatusAttachmentFetcher(client, project.name);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        attachment.name = "bar.txt";
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getProjectStatusUpdates(project);
+        verify(client, times(2)).getAttachments(status);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testAttachmentRemovalIsDetected() {
+        final ProjectStatus status = new ProjectStatus();
+        status.gid = "123";
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "456";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getProjectStatusUpdates(any())).then(invocation -> Stream.of(status));
+        when(client.getAttachments(any(ProjectStatus.class))).then(invocation -> Stream.of(attachment));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectStatusAttachmentFetcher(client, project.name);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        when(client.getAttachments(any(ProjectStatus.class))).then(invocation -> Stream.empty());
+        AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.REMOVED, object.getState());
+        assertEquals(attachment.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getProjectStatusUpdates(project);
+        verify(client, times(2)).getAttachments(status);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testRestoreStateAndContinue() {
+        final ProjectStatus status = new ProjectStatus();
+        status.gid = "123";
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "456";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getProjectStatusUpdates(any())).then(invocation -> Stream.of(status));
+        when(client.getAttachments(any(ProjectStatus.class))).then(invocation -> Stream.of(attachment));
+
+        final AsanaObjectFetcher fetcher1 = new AsanaProjectStatusAttachmentFetcher(client, project.name);
+        assertNotNull(fetcher1.fetchNext());
+        assertNull(fetcher1.fetchNext());
+
+        final AsanaObjectFetcher fetcher2 = new AsanaProjectStatusAttachmentFetcher(client, project.name);
+        fetcher2.loadState(fetcher1.saveState());
+
+        when(client.getAttachments(any(ProjectStatus.class))).then(invocation -> Stream.empty());
+        AsanaObject object = fetcher2.fetchNext();
+
+        assertEquals(AsanaObjectState.REMOVED, object.getState());
+        assertEquals(attachment.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getProjectStatusUpdates(project);
+        verify(client, times(2)).getAttachments(status);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testClearState() {
+        final ProjectStatus status = new ProjectStatus();
+        status.gid = "123";
+
+        Attachment attachment = new Attachment();
+        attachment.gid = "456";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getProjectStatusUpdates(any())).then(invocation -> Stream.of(status));
+        when(client.getAttachments(any(ProjectStatus.class))).then(invocation -> Stream.of(attachment));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectStatusAttachmentFetcher(client, project.name);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        attachment.name = "bar.txt";
+        fetcher.clearState();
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(attachment.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getProjectStatusUpdates(project);
+        verify(client, times(2)).getAttachments(status);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testWrongStateForConfigurationThrows() {
+        final Project otherProject = new Project();
+        otherProject.gid = "999";
+        otherProject.name = "Other Project";
+
+        when(client.getProjectByName(otherProject.name)).thenReturn(otherProject);
+
+        final AsanaObjectFetcher fetcher1 = new AsanaProjectStatusAttachmentFetcher(client, project.name);
+        final AsanaObjectFetcher fetcher2 = new AsanaProjectStatusAttachmentFetcher(client, otherProject.name);
+        assertThrows(AsanaObjectFetcherException.class, () -> fetcher2.loadState(fetcher1.saveState()));
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectStatusFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectStatusFetcherTest.java
new file mode 100644
index 0000000000..f8ecc05d72
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaProjectStatusFetcherTest.java
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.asana.models.Project;
+import com.asana.models.ProjectStatus;
+import com.google.api.client.util.DateTime;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcherException;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaProjectStatusFetcher;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class AsanaProjectStatusFetcherTest {
+
+    @Mock
+    private AsanaClient client;
+    private Project project;
+
+    @BeforeEach
+    public void init() {
+        project = new Project();
+        project.gid = "123";
+        project.modifiedAt = new DateTime(123456789);
+        project.name = "My Project";
+
+        when(client.getProjectByName(project.name)).thenReturn(project);
+    }
+
+    @Test
+    public void testNoObjectsFetchedWhenNoStatusUpdatesReturned() {
+        when(client.getProjectStatusUpdates(any())).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectStatusFetcher(client, project.name);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getProjectStatusUpdates(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleObjectFetched() {
+        final ProjectStatus status = new ProjectStatus();
+        status.gid = "123";
+        status.createdAt = new DateTime(123456789);
+        status.title = "Some title";
+
+        when(client.getProjectStatusUpdates(any())).then(invocation -> Stream.of(status));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectStatusFetcher(client, project.name);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(status.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getProjectStatusUpdates(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleObjectUpdatedWhenAnyPartChanges() {
+        final ProjectStatus status = new ProjectStatus();
+        status.gid = "123";
+        status.createdAt = new DateTime(123456789);
+        status.title = "Some title";
+
+        when(client.getProjectStatusUpdates(any())).then(invocation -> Stream.of(status));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectStatusFetcher(client, project.name);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        status.text = "Lorem Ipsum Blablabla";
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(status.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getProjectStatusUpdates(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testRestoreStateAndContinue() {
+        final ProjectStatus status = new ProjectStatus();
+        status.gid = "123";
+        status.createdAt = new DateTime(123456789);
+        status.title = "Some title";
+
+        when(client.getProjectStatusUpdates(any())).then(invocation -> Stream.of(status));
+
+        final AsanaObjectFetcher fetcher1 = new AsanaProjectStatusFetcher(client, project.name);
+        assertNotNull(fetcher1.fetchNext());
+
+        final AsanaObjectFetcher fetcher2 = new AsanaProjectStatusFetcher(client, project.name);
+        fetcher2.loadState(fetcher1.saveState());
+
+        status.text = "Lorem Ipsum Blablabla";
+        final AsanaObject object = fetcher2.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(status.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getProjectStatusUpdates(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testClearState() {
+        final ProjectStatus status = new ProjectStatus();
+        status.gid = "123";
+        status.createdAt = new DateTime(123456789);
+        status.title = "Some title";
+
+        when(client.getProjectStatusUpdates(any())).then(invocation -> Stream.of(status));
+
+        final AsanaObjectFetcher fetcher = new AsanaProjectStatusFetcher(client, project.name);
+        assertNotNull(fetcher.fetchNext());
+
+        status.text = "Lorem Ipsum Blablabla";
+        fetcher.clearState();
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(status.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getProjectStatusUpdates(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testWrongStateForConfigurationThrows() {
+        final Project otherProject = new Project();
+        otherProject.gid = "999";
+        otherProject.name = "Other Project";
+
+        when(client.getProjectByName(otherProject.name)).thenReturn(otherProject);
+
+        final AsanaObjectFetcher fetcher1 = new AsanaProjectStatusFetcher(client, project.name);
+        final AsanaObjectFetcher fetcher2 = new AsanaProjectStatusFetcher(client, otherProject.name);
+        assertThrows(AsanaObjectFetcherException.class, () -> fetcher2.loadState(fetcher1.saveState()));
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaStoryFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaStoryFetcherTest.java
new file mode 100644
index 0000000000..06e6da1594
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaStoryFetcherTest.java
@@ -0,0 +1,441 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.asana.models.Project;
+import com.asana.models.Section;
+import com.asana.models.Story;
+import com.asana.models.Tag;
+import com.asana.models.Task;
+import com.google.api.client.util.DateTime;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcherException;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaStoryFetcher;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class AsanaStoryFetcherTest {
+
+    @Mock
+    private AsanaClient client;
+    private Project project;
+    private Section section;
+    private Tag tag;
+
+    @BeforeEach
+    public void init() {
+        project = new Project();
+        project.gid = "123";
+        project.modifiedAt = new DateTime(123456789);
+        project.name = "My Project";
+
+        when(client.getProjectByName(project.name)).thenReturn(project);
+
+        section = new Section();
+        section.gid = "456";
+        section.project = project;
+        section.name = "Some section";
+        section.createdAt = new DateTime(123456789);
+
+        when(client.getSections(project)).then(invocation -> Stream.of(section));
+        when(client.getSectionByName(project, section.name)).thenReturn(section);
+
+        tag = new Tag();
+        tag.gid = "9876";
+        tag.name = "Foo";
+        tag.createdAt = new DateTime(123456789);
+
+        when(client.getTags()).then(invocation -> Stream.of(tag));
+    }
+
+    @Test
+    public void testNoObjectsFetchedWhenNoStoriesReturned() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getStories(any(Task.class))).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaStoryFetcher(client, project.name, null, null);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getStories(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testNoObjectsFetchedWhenNoStoriesReturnedBySection() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Section.class))).then(invocation -> Stream.of(task));
+        when(client.getStories(any(Task.class))).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaStoryFetcher(client, project.name, section.name, null);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getSectionByName(project, section.name);
+        verify(client, times(1)).getTasks(section);
+        verify(client, times(1)).getStories(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testNoObjectsFetchedWhenNoStoriesReturnedByTag() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task));
+        when(client.getStories(any(Task.class))).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaStoryFetcher(client, project.name, null, tag.name);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getTasks(tag);
+        verify(client, times(1)).getStories(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleStoryFetched() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Story story = new Story();
+        story.gid = "99";
+        story.createdAt = new DateTime(123456789);
+        story.text = "Lorem Ipsum";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getStories(any(Task.class))).then(invocation -> Stream.of(story));
+
+        final AsanaObjectFetcher fetcher = new AsanaStoryFetcher(client, project.name, null, null);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(story.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getStories(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleStoryFetchedBySection() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Story story = new Story();
+        story.gid = "99";
+        story.createdAt = new DateTime(123456789);
+        story.text = "Lorem Ipsum";
+
+        when(client.getTasks(any(Section.class))).then(invocation -> Stream.of(task));
+        when(client.getStories(any(Task.class))).then(invocation -> Stream.of(story));
+
+        final AsanaObjectFetcher fetcher = new AsanaStoryFetcher(client, project.name, section.name, null);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(story.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getSectionByName(project, section.name);
+        verify(client, times(1)).getTasks(section);
+        verify(client, times(1)).getStories(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleStoryFetchedByTag() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Story story = new Story();
+        story.gid = "99";
+        story.createdAt = new DateTime(123456789);
+        story.text = "Lorem Ipsum";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task));
+        when(client.getStories(any(Task.class))).then(invocation -> Stream.of(story));
+
+        final AsanaObjectFetcher fetcher = new AsanaStoryFetcher(client, project.name, null, tag.name);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(story.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getTasks(tag);
+        verify(client, times(1)).getStories(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testNoStoryFetchedByNonMatchingTag() {
+        final Task task1 = new Task();
+        task1.gid = "1234";
+        task1.name = "My first task";
+        task1.modifiedAt = new DateTime(123456789);
+
+        final Task task2 = new Task();
+        task2.gid = "5678";
+        task2.name = "My other task";
+        task2.modifiedAt = new DateTime(123456789);
+
+        final Story story1 = new Story();
+        story1.gid = "99";
+        story1.createdAt = new DateTime(123456789);
+        story1.text = "Lorem Ipsum";
+
+        final Story story2 = new Story();
+        story2.gid = "88";
+        story2.createdAt = new DateTime(123456789);
+        story2.text = "My other Lorem Ipsum";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task1));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task2));
+        when(client.getStories(task1)).then(invocation -> Stream.of(story1));
+        when(client.getStories(task2)).then(invocation -> Stream.of(story2));
+
+        final AsanaObjectFetcher fetcher = new AsanaStoryFetcher(client, project.name, null, tag.name);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getTasks(tag);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testTaskOfStoryRemovedFromSection() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Story story = new Story();
+        story.gid = "99";
+        story.createdAt = new DateTime(123456789);
+        story.text = "Lorem Ipsum";
+
+        when(client.getTasks(any(Section.class))).then(invocation -> Stream.of(task));
+        when(client.getStories(any(Task.class))).then(invocation -> Stream.of(story));
+
+        final AsanaObjectFetcher fetcher = new AsanaStoryFetcher(client, project.name, section.name, null);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        when(client.getTasks(any(Section.class))).then(invocation -> Stream.empty());
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.REMOVED, object.getState());
+        assertEquals(story.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getSectionByName(project, section.name);
+        verify(client, times(2)).getTasks(section);
+        verify(client, times(1)).getStories(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testTaskOfStoryUntagged() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Story story = new Story();
+        story.gid = "99";
+        story.createdAt = new DateTime(123456789);
+        story.text = "Lorem Ipsum";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task));
+        when(client.getStories(any(Task.class))).then(invocation -> Stream.of(story));
+
+        final AsanaObjectFetcher fetcher = new AsanaStoryFetcher(client, project.name, null, tag.name);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.empty());
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.REMOVED, object.getState());
+        assertEquals(story.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(2)).getTasks(project);
+        verify(client, times(2)).getTasks(tag);
+        verify(client, times(1)).getStories(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testStoryUpdatedWhenAnyPartChanges() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Story story = new Story();
+        story.gid = "99";
+        story.createdAt = new DateTime(123456789);
+        story.text = "Lorem Ipsum";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getStories(any(Task.class))).then(invocation -> Stream.of(story));
+        final AsanaObjectFetcher fetcher = new AsanaStoryFetcher(client, project.name, null, null);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        story.text = "Bla bla";
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(story.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getTasks(project);
+        verify(client, times(2)).getStories(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testRestoreStateAndContinue() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Story story = new Story();
+        story.gid = "99";
+        story.createdAt = new DateTime(123456789);
+        story.text = "Lorem Ipsum";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getStories(any(Task.class))).then(invocation -> Stream.of(story));
+        final AsanaObjectFetcher fetcher1 = new AsanaStoryFetcher(client, project.name, null, null);
+        assertNotNull(fetcher1.fetchNext());
+
+        final AsanaObjectFetcher fetcher2 = new AsanaStoryFetcher(client, project.name, null, null);
+        fetcher2.loadState(fetcher1.saveState());
+
+        story.text = "Bla bla";
+
+        final AsanaObject object = fetcher2.fetchNext();
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(story.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getTasks(project);
+        verify(client, times(2)).getStories(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testClearState() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Story story = new Story();
+        story.gid = "99";
+        story.createdAt = new DateTime(123456789);
+        story.text = "Lorem Ipsum";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getStories(any(Task.class))).then(invocation -> Stream.of(story));
+        final AsanaObjectFetcher fetcher = new AsanaStoryFetcher(client, project.name, null, null);
+        assertNotNull(fetcher.fetchNext());
+
+        fetcher.clearState();
+
+        story.text = "Bla bla";
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(story.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getTasks(project);
+        verify(client, times(2)).getStories(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testWrongStateForConfigurationThrows() {
+        final Project otherProject = new Project();
+        otherProject.gid = "999";
+        otherProject.name = "Other Project";
+
+        when(client.getProjectByName(otherProject.name)).thenReturn(otherProject);
+
+        final AsanaObjectFetcher fetcher1 = new AsanaStoryFetcher(client, project.name, null, null);
+        final AsanaObjectFetcher fetcher2 = new AsanaStoryFetcher(client, otherProject.name, null, null);
+        assertThrows(AsanaObjectFetcherException.class, () -> fetcher2.loadState(fetcher1.saveState()));
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTagFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTagFetcherTest.java
new file mode 100644
index 0000000000..ee95cc832c
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTagFetcherTest.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.asana.models.Tag;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaTagFetcher;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class AsanaTagFetcherTest {
+
+    @Mock
+    private AsanaClient client;
+
+    @Test
+    public void testNoTagsFetchedWhenNoTagsReturned() {
+        when(client.getTags()).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaTagFetcher(client);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, times(1)).getTags();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleTagFetched() {
+        final Tag tag = new Tag();
+        tag.gid = "123";
+        tag.name = "My Tag";
+
+        when(client.getTags()).then(invocation -> Stream.of(tag));
+
+        final AsanaObjectFetcher fetcher = new AsanaTagFetcher(client);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(tag.gid, object.getGid());
+        verify(client, times(1)).getTags();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleTagUpdatedWhenAnyPartChanges() {
+        final Tag tag = new Tag();
+        tag.gid = "123";
+        tag.name = "My Tag";
+
+        when(client.getTags()).then(invocation -> Stream.of(tag));
+
+        final AsanaObjectFetcher fetcher = new AsanaTagFetcher(client);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        tag.color = "brown";
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(tag.gid, object.getGid());
+        verify(client, times(2)).getTags();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testClearState() {
+        final Tag tag = new Tag();
+        tag.gid = "123";
+        tag.name = "My Tag";
+
+        when(client.getTags()).then(invocation -> Stream.of(tag));
+
+        final AsanaObjectFetcher fetcher = new AsanaTagFetcher(client);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        fetcher.clearState();
+
+        tag.color = "brown";
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(tag.gid, object.getGid());
+        verify(client, times(2)).getTags();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testRestoreStateAndContinue() {
+        final Tag tag = new Tag();
+        tag.gid = "123";
+        tag.name = "My Tag";
+
+        when(client.getTags()).then(invocation -> Stream.of(tag));
+
+        final AsanaObjectFetcher fetcher1 = new AsanaTagFetcher(client);
+        assertNotNull(fetcher1.fetchNext());
+        assertNull(fetcher1.fetchNext());
+
+        final AsanaObjectFetcher fetcher2 = new AsanaTagFetcher(client);
+        fetcher2.loadState(fetcher1.saveState());
+
+        tag.color = "brown";
+        final AsanaObject object = fetcher2.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(tag.gid, object.getGid());
+        verify(client, times(2)).getTags();
+        verifyNoMoreInteractions(client);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTaskAttachmentFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTaskAttachmentFetcherTest.java
new file mode 100644
index 0000000000..a080395e81
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTaskAttachmentFetcherTest.java
@@ -0,0 +1,470 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.asana.models.Attachment;
+import com.asana.models.Project;
+import com.asana.models.Section;
+import com.asana.models.Tag;
+import com.asana.models.Task;
+import com.google.api.client.util.DateTime;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcherException;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaTaskAttachmentFetcher;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class AsanaTaskAttachmentFetcherTest {
+
+    @Mock
+    private AsanaClient client;
+    private Project project;
+    private Section section;
+    private Tag tag;
+
+    @BeforeEach
+    public void init() {
+        project = new Project();
+        project.gid = "123";
+        project.modifiedAt = new DateTime(123456789);
+        project.name = "My Project";
+
+        when(client.getProjectByName(project.name)).thenReturn(project);
+
+        section = new Section();
+        section.gid = "456";
+        section.project = project;
+        section.name = "Some section";
+        section.createdAt = new DateTime(123456789);
+
+        when(client.getSections(project)).then(invocation -> Stream.of(section));
+        when(client.getSectionByName(project, section.name)).thenReturn(section);
+
+        tag = new Tag();
+        tag.gid = "9876";
+        tag.name = "Foo";
+        tag.createdAt = new DateTime(123456789);
+
+        when(client.getTags()).then(invocation -> Stream.of(tag));
+    }
+
+    @Test
+    public void testNoObjectsFetchedWhenNoAttachmentsReturned() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskAttachmentFetcher(client, project.name, null, null);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getAttachments(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testNoObjectsFetchedWhenNoAttachmentsReturnedBySection() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Section.class))).then(invocation -> Stream.of(task));
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskAttachmentFetcher(client, project.name, section.name, null);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getSectionByName(project, section.name);
+        verify(client, times(1)).getTasks(section);
+        verify(client, times(1)).getAttachments(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testNoObjectsFetchedWhenNoAttachmentsReturnedByTag() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task));
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskAttachmentFetcher(client, project.name, null, tag.name);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getTasks(tag);
+        verify(client, times(1)).getAttachments(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleAttachmentFetched() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "99";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.of(attachment));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskAttachmentFetcher(client, project.name, null, null);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(attachment.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getAttachments(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleAttachmentFetchedBySection() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "99";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getTasks(any(Section.class))).then(invocation -> Stream.of(task));
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.of(attachment));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskAttachmentFetcher(client, project.name, section.name, null);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(attachment.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getSectionByName(project, section.name);
+        verify(client, times(1)).getTasks(section);
+        verify(client, times(1)).getAttachments(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleAttachmentFetchedByTag() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "99";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task));
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.of(attachment));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskAttachmentFetcher(client, project.name, null, tag.name);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(attachment.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getTasks(tag);
+        verify(client, times(1)).getAttachments(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testNoAttachmentFetchedByNonMatchingTag() {
+        final Task task1 = new Task();
+        task1.gid = "1234";
+        task1.name = "My first task";
+        task1.modifiedAt = new DateTime(123456789);
+
+        final Task task2 = new Task();
+        task2.gid = "5678";
+        task2.name = "My other task";
+        task2.modifiedAt = new DateTime(123456789);
+
+        final Attachment attachment1 = new Attachment();
+        attachment1.gid = "99";
+        attachment1.createdAt = new DateTime(123456789);
+        attachment1.name = "foo.txt";
+
+        final Attachment attachment2 = new Attachment();
+        attachment2.gid = "88";
+        attachment2.createdAt = new DateTime(123456789);
+        attachment2.name = "bar.txt";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task1));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task2));
+        when(client.getAttachments(task1)).then(invocation -> Stream.of(attachment1));
+        when(client.getAttachments(task2)).then(invocation -> Stream.of(attachment2));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskAttachmentFetcher(client, project.name, null, tag.name);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getTasks(tag);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testTaskOfAttachmentRemovedFromSection() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "99";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getTasks(any(Section.class))).then(invocation -> Stream.of(task));
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.of(attachment));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskAttachmentFetcher(client, project.name, section.name, null);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        when(client.getTasks(any(Section.class))).then(invocation -> Stream.empty());
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.REMOVED, object.getState());
+        assertEquals(attachment.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getSectionByName(project, section.name);
+        verify(client, times(2)).getTasks(section);
+        verify(client, times(1)).getAttachments(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testTaskOfAttachmentUntagged() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "99";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task));
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.of(attachment));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskAttachmentFetcher(client, project.name, null, tag.name);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.empty());
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.REMOVED, object.getState());
+        assertEquals(attachment.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(2)).getTasks(project);
+        verify(client, times(2)).getTasks(tag);
+        verify(client, times(1)).getAttachments(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testAttachmentUpdateIsNotDetected() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "99";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task));
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.of(attachment));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskAttachmentFetcher(client, project.name, null, null);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        attachment.name = "bar.txt";
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getTasks(project);
+        verify(client, times(2)).getAttachments(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testAttachmentRemovalIsDetected() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "99";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task));
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.of(attachment));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskAttachmentFetcher(client, project.name, null, null);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.empty());
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.REMOVED, object.getState());
+        assertEquals(attachment.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getTasks(project);
+        verify(client, times(2)).getAttachments(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testRestoreStateAndContinue() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "99";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.of(attachment));
+        final AsanaObjectFetcher fetcher1 = new AsanaTaskAttachmentFetcher(client, project.name, null, null);
+        assertNotNull(fetcher1.fetchNext());
+
+        final AsanaObjectFetcher fetcher2 = new AsanaTaskAttachmentFetcher(client, project.name, null, null);
+        fetcher2.loadState(fetcher1.saveState());
+
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.empty());
+
+        final AsanaObject object = fetcher2.fetchNext();
+        assertEquals(AsanaObjectState.REMOVED, object.getState());
+        assertEquals(attachment.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getTasks(project);
+        verify(client, times(2)).getAttachments(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testClearState() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        final Attachment attachment = new Attachment();
+        attachment.gid = "99";
+        attachment.createdAt = new DateTime(123456789);
+        attachment.name = "foo.txt";
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getAttachments(any(Task.class))).then(invocation -> Stream.of(attachment));
+        AsanaObjectFetcher fetcher = new AsanaTaskAttachmentFetcher(client, project.name, null, null);
+        assertNotNull(fetcher.fetchNext());
+
+        fetcher.clearState();
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(attachment.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getTasks(project);
+        verify(client, times(2)).getAttachments(task);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testWrongStateForConfigurationThrows() {
+        final Project otherProject = new Project();
+        otherProject.gid = "999";
+        otherProject.name = "Other Project";
+
+        when(client.getProjectByName(otherProject.name)).thenReturn(otherProject);
+
+        final AsanaObjectFetcher fetcher1 = new AsanaTaskAttachmentFetcher(client, project.name, null, null);
+        final AsanaObjectFetcher fetcher2 = new AsanaTaskAttachmentFetcher(client, otherProject.name, null, null);
+        assertThrows(AsanaObjectFetcherException.class, () -> fetcher2.loadState(fetcher1.saveState()));
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTaskFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTaskFetcherTest.java
new file mode 100644
index 0000000000..9b49ad3138
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTaskFetcherTest.java
@@ -0,0 +1,437 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.asana.models.Project;
+import com.asana.models.Section;
+import com.asana.models.Tag;
+import com.asana.models.Task;
+import com.google.api.client.util.DateTime;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcherException;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaTaskFetcher;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class AsanaTaskFetcherTest {
+
+    @Mock
+    private AsanaClient client;
+    private Project project;
+    private Section section;
+    private Tag tag;
+
+    @BeforeEach
+    public void init() {
+        project = new Project();
+        project.gid = "123";
+        project.modifiedAt = new DateTime(123456789);
+        project.name = "My Project";
+
+        when(client.getProjectByName(project.name)).thenReturn(project);
+
+        section = new Section();
+        section.gid = "456";
+        section.project = project;
+        section.name = "Some section";
+        section.createdAt = new DateTime(123456789);
+
+        when(client.getSections(project)).then(invocation -> Stream.of(section));
+        when(client.getSectionByName(project, section.name)).thenReturn(section);
+
+        tag = new Tag();
+        tag.gid = "9876";
+        tag.name = "Foo";
+        tag.createdAt = new DateTime(123456789);
+
+        when(client.getTags()).then(invocation -> Stream.of(tag));
+    }
+
+    @Test
+    public void testNoObjectsFetchedWhenNoTasksReturned() {
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskFetcher(client, project.name, null, null);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getTasks(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testNoObjectsFetchedWhenNoTasksReturnedBySection() {
+        when(client.getTasks(any(Section.class))).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskFetcher(client, project.name, section.name, null);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getSectionByName(project, section.name);
+        verify(client, times(1)).getTasks(section);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testNoObjectsFetchedWhenNoTasksReturnedByTag() {
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskFetcher(client, project.name, null, tag.name);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getTasks(tag);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleTaskFetched() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskFetcher(client, project.name, null, null);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(task.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(1)).getTasks(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleTaskFetchedBySection() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Section.class))).then(invocation -> Stream.of(task));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskFetcher(client, project.name, section.name, null);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(task.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getSectionByName(project, section.name);
+        verify(client, times(1)).getTasks(section);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleTaskFetchedByTag() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskFetcher(client, project.name, null, tag.name);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(task.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getTasks(tag);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testNoTaskFetchedByNonMatchingTag() {
+        final Task task1 = new Task();
+        task1.gid = "1234";
+        task1.name = "My first task";
+        task1.modifiedAt = new DateTime(123456789);
+
+        final Task task2 = new Task();
+        task2.gid = "5678";
+        task2.name = "My other task";
+        task2.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task1));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task2));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskFetcher(client, project.name, null, tag.name);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getTasks(tag);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testTaskRemovedFromSection() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Section.class))).then(invocation -> Stream.of(task));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskFetcher(client, project.name, section.name, null);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        when(client.getTasks(any(Section.class))).then(invocation -> Stream.empty());
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.REMOVED, object.getState());
+        assertEquals(task.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getSectionByName(project, section.name);
+        verify(client, times(2)).getTasks(section);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testTaskUntagged() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.of(task));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskFetcher(client, project.name, null, tag.name);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        when(client.getTasks(any(Tag.class))).then(invocation -> Stream.empty());
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.REMOVED, object.getState());
+        assertEquals(task.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(2)).getTasks(project);
+        verify(client, times(2)).getTasks(tag);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testCollectMultipleTasksWithSameTagAndFilterOutDuplicates() {
+        final Tag anotherTagWithSameName = new Tag();
+        anotherTagWithSameName.gid = "555";
+        anotherTagWithSameName.name = tag.name;
+
+        when(client.getTags()).then(invocation -> Stream.of(tag, anotherTagWithSameName));
+
+        final Task task1 = new Task();
+        task1.gid = "1234";
+        task1.name = "My first task";
+        task1.modifiedAt = new DateTime(123456789);
+
+        final Task task2 = new Task();
+        task2.gid = "1212";
+        task2.name = "My other task";
+        task2.modifiedAt = new DateTime(234567891);
+
+        final Task task3 = new Task();
+        task3.gid = "333";
+        task3.name = "My third task";
+        task3.modifiedAt = new DateTime(345678912);
+
+        final Task task4 = new Task();
+        task4.gid = "444";
+        task4.name = "A task without tag";
+        task4.modifiedAt = new DateTime(456789123);
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task1, task2, task3, task4));
+        when(client.getTasks(tag)).then(invocation -> Stream.of(task1));
+        when(client.getTasks(anotherTagWithSameName)).then(invocation -> Stream.of(task1, task2, task3));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskFetcher(client, project.name, null, tag.name);
+
+        final AsanaObject object1 = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, object1.getState());
+
+        final AsanaObject object2 = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, object2.getState());
+        assertNotEquals(object1, object2);
+
+        final AsanaObject object3 = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, object3.getState());
+        assertNotEquals(object1, object3);
+        assertNotEquals(object2, object3);
+
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, atLeastOnce()).getTags();
+        verify(client, times(1)).getTasks(project);
+        verify(client, times(1)).getTasks(tag);
+        verify(client, times(1)).getTasks(anotherTagWithSameName);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testTaskUpdatedOnlyWhenModificationDateChanges() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskFetcher(client, project.name, null, null);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        task.name = "Update my task";
+        assertNull(fetcher.fetchNext());
+
+        task.modifiedAt = new DateTime(234567891);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(task.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(3)).getTasks(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testRestoreStateAndContinue() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+
+        final AsanaObjectFetcher fetcher1 = new AsanaTaskFetcher(client, project.name, null, null);
+        assertNotNull(fetcher1.fetchNext());
+
+        final AsanaObjectFetcher fetcher2 = new AsanaTaskFetcher(client, project.name, null, null);
+        fetcher2.loadState(fetcher1.saveState());
+
+        task.modifiedAt = new DateTime(234567891);
+        final AsanaObject object = fetcher2.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(task.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getTasks(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testClearState() {
+        final Task task = new Task();
+        task.gid = "1234";
+        task.name = "My first task";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+
+        final AsanaObjectFetcher fetcher = new AsanaTaskFetcher(client, project.name, null, null);
+        assertNotNull(fetcher.fetchNext());
+
+        fetcher.clearState();
+
+        task.modifiedAt = new DateTime(234567891);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(task.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getProjectByName(project.name);
+        verify(client, times(2)).getTasks(project);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testWrongStateForConfigurationThrows() {
+        final Project otherProject = new Project();
+        otherProject.gid = "999";
+        otherProject.name = "Other Project";
+
+        final Section otherSection = new Section();
+        otherSection.gid = "888";
+        otherSection.name = "Other Section";
+
+        final Tag otherTag = new Tag();
+        otherTag.gid = "777";
+        otherTag.name = "Other Tag";
+
+        when(client.getProjectByName(otherProject.name)).thenReturn(otherProject);
+        when(client.getSectionByName(project, otherSection.name)).thenReturn(otherSection);
+
+        final AsanaObjectFetcher fetcher1 = new AsanaTaskFetcher(client, project.name, null, null);
+        final AsanaObjectFetcher fetcher2 = new AsanaTaskFetcher(client, otherProject.name, null, null);
+        assertThrows(AsanaObjectFetcherException.class, () -> fetcher2.loadState(fetcher1.saveState()));
+
+        final AsanaObjectFetcher fetcher3 = new AsanaTaskFetcher(client, project.name, section.name, null);
+        assertThrows(AsanaObjectFetcherException.class, () -> fetcher3.loadState(fetcher1.saveState()));
+
+        final AsanaObjectFetcher fetcher4 = new AsanaTaskFetcher(client, project.name, null, tag.name);
+        assertThrows(AsanaObjectFetcherException.class, () -> fetcher4.loadState(fetcher1.saveState()));
+
+        final AsanaObjectFetcher fetcher5 = new AsanaTaskFetcher(client, project.name, section.name, tag.name);
+        assertThrows(AsanaObjectFetcherException.class, () -> fetcher5.loadState(fetcher1.saveState()));
+
+        final AsanaObjectFetcher fetcher6 = new AsanaTaskFetcher(client, project.name, otherSection.name, null);
+        assertThrows(AsanaObjectFetcherException.class, () -> fetcher6.loadState(fetcher3.saveState()));
+
+        final AsanaObjectFetcher fetcher7 = new AsanaTaskFetcher(client, project.name, section.name, otherTag.name);
+        assertThrows(AsanaObjectFetcherException.class, () -> fetcher7.loadState(fetcher5.saveState()));
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTeamFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTeamFetcherTest.java
new file mode 100644
index 0000000000..4ba55835b7
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTeamFetcherTest.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.asana.models.Team;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaTeamFetcher;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class AsanaTeamFetcherTest {
+
+    @Mock
+    private AsanaClient client;
+
+    @Test
+    public void testNoObjectsFetchedWhenNoTeamsReturned() {
+        when(client.getTeams()).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaTeamFetcher(client);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, times(1)).getTeams();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleTeamFetched() {
+        final Team team = new Team();
+        team.gid = "123";
+        team.name = "My Team";
+        team.description = "Lorem Ipsum";
+
+        when(client.getTeams()).then(invocation -> Stream.of(team));
+
+        final AsanaObjectFetcher fetcher = new AsanaTeamFetcher(client);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(team.gid, object.getGid());
+        verify(client, times(1)).getTeams();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleTeamUpdatedWhenAnyPartChanges() {
+        final Team team = new Team();
+        team.gid = "123";
+        team.name = "My Team";
+        team.description = "Lorem Ipsum";
+
+        when(client.getTeams()).then(invocation -> Stream.of(team));
+
+        final AsanaObjectFetcher fetcher = new AsanaTeamFetcher(client);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        team.description = "Bla bla";
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(team.gid, object.getGid());
+        verify(client, times(2)).getTeams();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testClearState() {
+        final Team team = new Team();
+        team.gid = "123";
+        team.name = "My Team";
+        team.description = "Lorem Ipsum";
+
+        when(client.getTeams()).then(invocation -> Stream.of(team));
+
+        final AsanaObjectFetcher fetcher = new AsanaTeamFetcher(client);
+        assertNotNull(fetcher.fetchNext());
+
+        fetcher.clearState();
+
+        team.description = "Bla bla";
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(team.gid, object.getGid());
+        verify(client, times(2)).getTeams();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testRestoreStateAndContinue() {
+        final Team team = new Team();
+        team.gid = "123";
+        team.name = "My Team";
+        team.description = "Lorem Ipsum";
+
+        when(client.getTeams()).then(invocation -> Stream.of(team));
+
+        final AsanaObjectFetcher fetcher1 = new AsanaTeamFetcher(client);
+        assertNotNull(fetcher1.fetchNext());
+
+        final AsanaObjectFetcher fetcher2 = new AsanaTeamFetcher(client);
+        fetcher2.loadState(fetcher1.saveState());
+
+        team.description = "Bla bla";
+        final AsanaObject object = fetcher2.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(team.gid, object.getGid());
+        verify(client, times(2)).getTeams();
+        verifyNoMoreInteractions(client);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTeamMemberFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTeamMemberFetcherTest.java
new file mode 100644
index 0000000000..856fe7b6e4
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaTeamMemberFetcherTest.java
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.asana.models.Team;
+import com.asana.models.User;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaTeamMemberFetcher;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class AsanaTeamMemberFetcherTest {
+
+    @Mock
+    private AsanaClient client;
+    private Team team;
+
+    @BeforeEach
+    public void init() {
+        team = new Team();
+        team.gid = "123";
+        team.name = "My Team";
+        team.description = "Lorem Ipsum";
+
+        when(client.getTeamByName(team.name)).thenReturn(team);
+    }
+
+    @Test
+    public void testNoObjectsFetchedWhenNoTeamMembersReturned() {
+        when(client.getTeamMembers(any())).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaTeamMemberFetcher(client, team.name);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, atLeastOnce()).getTeamByName(team.name);
+        verify(client, times(1)).getTeamMembers(team);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleMemberFetched() {
+        final User user = new User();
+        user.gid = "123";
+        user.name = "My User";
+        user.email = "myuser@example.com";
+
+        when(client.getTeamMembers(any())).then(invocation -> Stream.of(user));
+
+        final AsanaObjectFetcher fetcher = new AsanaTeamMemberFetcher(client, team.name);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(user.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getTeamByName(team.name);
+        verify(client, times(1)).getTeamMembers(team);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleMemberUpdatedWhenAnyPartChanges() {
+        final User user = new User();
+        user.gid = "123";
+        user.name = "My User";
+        user.email = "myuser@example.com";
+
+        when(client.getTeamMembers(any())).then(invocation -> Stream.of(user));
+
+        final AsanaObjectFetcher fetcher = new AsanaTeamMemberFetcher(client, team.name);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        user.name = "Bar Foo";
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(user.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getTeamByName(team.name);
+        verify(client, times(2)).getTeamMembers(team);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testRestoreStateAndContinue() {
+        final User user = new User();
+        user.gid = "123";
+        user.name = "My User";
+        user.email = "myuser@example.com";
+
+        when(client.getTeamMembers(any())).then(invocation -> Stream.of(user));
+
+        final AsanaObjectFetcher fetcher1 = new AsanaTeamMemberFetcher(client, team.name);
+        assertNotNull(fetcher1.fetchNext());
+
+        final AsanaObjectFetcher fetcher2 = new AsanaTeamMemberFetcher(client, team.name);
+        fetcher2.loadState(fetcher1.saveState());
+
+        user.name = "Bar Foo";
+        final AsanaObject object = fetcher2.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(user.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getTeamByName(team.name);
+        verify(client, times(2)).getTeamMembers(team);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testClearState() {
+        final User user = new User();
+        user.gid = "123";
+        user.name = "My User";
+        user.email = "myuser@example.com";
+
+        when(client.getTeamMembers(any())).then(invocation -> Stream.of(user));
+
+        final AsanaObjectFetcher fetcher = new AsanaTeamMemberFetcher(client, team.name);
+        assertNotNull(fetcher.fetchNext());
+
+        fetcher.clearState();
+
+        user.name = "Bar Foo";
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(user.gid, object.getGid());
+
+        verify(client, atLeastOnce()).getTeamByName(team.name);
+        verify(client, times(2)).getTeamMembers(team);
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testWrongStateForConfigurationThrows() {
+        final Team otherTeam = new Team();
+        team.gid = "999";
+        team.name = "My Second Team";
+        team.description = "Bla bla";
+
+        when(client.getTeamByName(otherTeam.name)).thenReturn(otherTeam);
+
+        final AsanaObjectFetcher fetcher1 = new AsanaTeamMemberFetcher(client, team.name);
+        final AsanaObjectFetcher fetcher2 = new AsanaTeamMemberFetcher(client, otherTeam.name);
+        assertThrows(RuntimeException.class, () -> fetcher2.loadState(fetcher1.saveState()));
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaUserFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaUserFetcherTest.java
new file mode 100644
index 0000000000..f34f37ed07
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/AsanaUserFetcherTest.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.asana.models.User;
+import java.util.stream.Stream;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.processors.asana.utils.AsanaUserFetcher;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+public class AsanaUserFetcherTest {
+
+    @Mock
+    private AsanaClient client;
+
+    @Test
+    public void testNoObjectsFetchedWhenNoTeamsReturned() {
+        when(client.getUsers()).then(invocation -> Stream.empty());
+
+        final AsanaObjectFetcher fetcher = new AsanaUserFetcher(client);
+        assertNull(fetcher.fetchNext());
+
+        verify(client, times(1)).getUsers();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleUserFetched() {
+        final User user = new User();
+        user.gid = "123";
+        user.name = "My User";
+        user.email = "myuser@example.com";
+
+        when(client.getUsers()).then(invocation -> Stream.of(user));
+
+        final AsanaObjectFetcher fetcher = new AsanaUserFetcher(client);
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(user.gid, object.getGid());
+        verify(client, times(1)).getUsers();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testSingleTeamUpdatedWhenAnyPartChanges() {
+        final User user = new User();
+        user.gid = "123";
+        user.name = "My User";
+        user.email = "myuser@example.com";
+
+        when(client.getUsers()).then(invocation -> Stream.of(user));
+
+        final AsanaObjectFetcher fetcher = new AsanaUserFetcher(client);
+        assertNotNull(fetcher.fetchNext());
+        assertNull(fetcher.fetchNext());
+
+        user.email = "otheraddress@ecample.com";
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(user.gid, object.getGid());
+        verify(client, times(2)).getUsers();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testClearState() {
+        final User user = new User();
+        user.gid = "123";
+        user.name = "My User";
+        user.email = "myuser@example.com";
+
+        when(client.getUsers()).then(invocation -> Stream.of(user));
+
+        final AsanaObjectFetcher fetcher = new AsanaUserFetcher(client);
+        assertNotNull(fetcher.fetchNext());
+
+        fetcher.clearState();
+
+        user.email = "otheraddress@ecample.com";
+        final AsanaObject object = fetcher.fetchNext();
+
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals(user.gid, object.getGid());
+        verify(client, times(2)).getUsers();
+        verifyNoMoreInteractions(client);
+    }
+
+    @Test
+    public void testRestoreStateAndContinue() {
+        final User user = new User();
+        user.gid = "123";
+        user.name = "My User";
+        user.email = "myuser@example.com";
+
+        when(client.getUsers()).then(invocation -> Stream.of(user));
+
+        final AsanaObjectFetcher fetcher1 = new AsanaUserFetcher(client);
+        assertNotNull(fetcher1.fetchNext());
+
+        final AsanaObjectFetcher fetcher2 = new AsanaUserFetcher(client);
+        fetcher2.loadState(fetcher1.saveState());
+
+        user.email = "otheraddress@ecample.com";
+        final AsanaObject object = fetcher2.fetchNext();
+
+        assertEquals(AsanaObjectState.UPDATED, object.getState());
+        assertEquals(user.gid, object.getGid());
+        verify(client, times(2)).getUsers();
+        verifyNoMoreInteractions(client);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/GenericAsanaObjectFetcherTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/GenericAsanaObjectFetcherTest.java
new file mode 100644
index 0000000000..e46965d1e5
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/GenericAsanaObjectFetcherTest.java
@@ -0,0 +1,213 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import com.asana.models.Resource;
+import com.google.gson.Gson;
+import java.util.Map;
+import org.apache.nifi.processors.asana.mocks.MockGenericAsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.junit.jupiter.api.Test;
+
+public class GenericAsanaObjectFetcherTest {
+
+    private static final Gson GSON = new Gson();
+
+    @Test
+    public void testNoObjects() {
+        final MockGenericAsanaObjectFetcher fetcher = new MockGenericAsanaObjectFetcher();
+        fetcher.items = emptyList();
+
+        assertNull(fetcher.fetchNext());
+        assertEquals(1, fetcher.refreshCount);
+
+        assertNull(fetcher.fetchNext());
+        assertEquals(2, fetcher.refreshCount);
+    }
+
+    @Test
+    public void testSingleStaticObject() {
+        final MockGenericAsanaObjectFetcher fetcher = new MockGenericAsanaObjectFetcher();
+
+        final Resource resource = new Resource();
+        resource.gid = "123";
+        resource.resourceType = "Something";
+
+        fetcher.items = singletonList(resource);
+
+        final AsanaObject object = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, object.getState());
+        assertEquals("123", object.getGid());
+        assertFalse(object.getContent().isEmpty());
+        assertEquals(1, fetcher.refreshCount);
+        assertNull(fetcher.fetchNext());
+
+        assertNull(fetcher.fetchNext());
+        assertEquals(2, fetcher.refreshCount);
+    }
+
+    @Test
+    public void testSingleObjectAddedUpdatedRemoved() {
+        final MockGenericAsanaObjectFetcher fetcher = new MockGenericAsanaObjectFetcher();
+
+        final Resource resource = new Resource();
+        resource.gid = "123";
+        resource.resourceType = "Something";
+
+        fetcher.items = singletonList(resource);
+
+        final AsanaObject objectWhenNew = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, objectWhenNew.getState());
+        assertEquals("123", objectWhenNew.getGid());
+        assertFalse(objectWhenNew.getContent().isEmpty());
+        assertEquals(1, fetcher.refreshCount);
+        assertNull(fetcher.fetchNext());
+
+        resource.resourceType = "Etwas";
+        final AsanaObject objectAfterUpdate = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.UPDATED, objectAfterUpdate.getState());
+        assertEquals("123", objectAfterUpdate.getGid());
+        assertFalse(objectAfterUpdate.getContent().isEmpty());
+        assertNotEquals(objectWhenNew.getContent(), objectAfterUpdate.getContent());
+        assertNotEquals(objectWhenNew.getFingerprint(), objectAfterUpdate.getFingerprint());
+        assertEquals(2, fetcher.refreshCount);
+
+        assertNull(fetcher.fetchNext());
+        assertEquals(2, fetcher.refreshCount);
+
+        assertNull(fetcher.fetchNext());
+        assertEquals(3, fetcher.refreshCount);
+
+        resource.resourceType = "Something";
+        final AsanaObject objectAfterAnotherUpdate = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.UPDATED, objectAfterAnotherUpdate.getState());
+        assertEquals("123", objectAfterAnotherUpdate.getGid());
+        assertFalse(objectAfterAnotherUpdate.getContent().isEmpty());
+        assertNotEquals(objectAfterUpdate.getContent(), objectAfterAnotherUpdate.getContent());
+        assertNotEquals(objectAfterUpdate.getFingerprint(), objectAfterAnotherUpdate.getFingerprint());
+        assertEquals(4, fetcher.refreshCount);
+
+        assertNull(fetcher.fetchNext());
+        assertEquals(4, fetcher.refreshCount);
+
+        fetcher.items = emptyList();
+        final AsanaObject objectAfterRemove = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.REMOVED, objectAfterRemove.getState());
+        assertEquals("123", objectAfterRemove.getGid());
+        assertEquals(GSON.toJson(objectAfterRemove.getGid()), objectAfterRemove.getContent());
+        assertEquals(5, fetcher.refreshCount);
+
+        assertNull(fetcher.fetchNext());
+        assertEquals(5, fetcher.refreshCount);
+
+        assertNull(fetcher.fetchNext());
+        assertEquals(6, fetcher.refreshCount);
+    }
+
+    @Test
+    public void testSingleObjectIdChange() {
+        final MockGenericAsanaObjectFetcher fetcher = new MockGenericAsanaObjectFetcher();
+
+        final Resource resource = new Resource();
+        resource.gid = "123";
+        resource.resourceType = "Something";
+
+        fetcher.items = singletonList(resource);
+
+        final AsanaObject objectBeforeIdChange = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, objectBeforeIdChange.getState());
+        assertEquals("123", objectBeforeIdChange.getGid());
+        assertFalse(objectBeforeIdChange.getContent().isEmpty());
+        assertEquals(1, fetcher.refreshCount);
+        assertNull(fetcher.fetchNext());
+
+        resource.gid = "456";
+
+        final AsanaObject objectWithNewId = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, objectWithNewId.getState());
+        assertEquals("456", objectWithNewId.getGid());
+        assertFalse(objectWithNewId.getContent().isEmpty());
+        assertEquals(2, fetcher.refreshCount);
+
+        final AsanaObject objectAfterIdChange = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.REMOVED, objectAfterIdChange.getState());
+        assertEquals("123", objectAfterIdChange.getGid());
+        assertEquals(GSON.toJson(objectAfterIdChange.getGid()), objectAfterIdChange.getContent());
+        assertEquals(2, fetcher.refreshCount);
+
+        assertNull(fetcher.fetchNext());
+        assertEquals(2, fetcher.refreshCount);
+    }
+
+    @Test
+    public void testStateSavedAndThenRestored() {
+        final MockGenericAsanaObjectFetcher fetcher1 = new MockGenericAsanaObjectFetcher();
+
+        final Resource resource = new Resource();
+        resource.gid = "123";
+        resource.resourceType = "Something";
+
+        fetcher1.items = singletonList(resource);
+
+        final AsanaObject objectBeforeStateExport = fetcher1.fetchNext();
+        assertEquals(AsanaObjectState.NEW, objectBeforeStateExport.getState());
+        assertEquals("123", objectBeforeStateExport.getGid());
+
+        final Map<String, String> savedState = fetcher1.saveState();
+
+        final MockGenericAsanaObjectFetcher fetcher2 = new MockGenericAsanaObjectFetcher();
+
+        fetcher2.loadState(savedState);
+
+        fetcher2.items = singletonList(resource);
+        assertNull(fetcher2.fetchNext());
+
+        fetcher2.items = emptyList();
+        final AsanaObject objectAfterStateImport = fetcher2.fetchNext();
+        assertEquals(AsanaObjectState.REMOVED, objectAfterStateImport.getState());
+        assertEquals("123", objectAfterStateImport.getGid());
+    }
+
+    @Test
+    public void testClearState() {
+        final MockGenericAsanaObjectFetcher fetcher = new MockGenericAsanaObjectFetcher();
+
+        final Resource resource = new Resource();
+        resource.gid = "123";
+        resource.resourceType = "Something";
+
+        fetcher.items = singletonList(resource);
+
+        final AsanaObject objectBeforeStateCleared = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, objectBeforeStateCleared.getState());
+        assertEquals("123", objectBeforeStateCleared.getGid());
+
+        fetcher.clearState();
+
+        final AsanaObject objectAfterStateCleared = fetcher.fetchNext();
+        assertEquals(AsanaObjectState.NEW, objectAfterStateCleared.getState());
+        assertEquals("123", objectAfterStateCleared.getGid());
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/GenericObjectSerDeTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/GenericObjectSerDeTest.java
new file mode 100644
index 0000000000..d1f2503ada
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/GenericObjectSerDeTest.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static java.util.Collections.singletonMap;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Map;
+import org.apache.groovy.util.Maps;
+import org.junit.jupiter.api.Test;
+
+public class GenericObjectSerDeTest {
+
+    @Test
+    public void testString1() throws IOException {
+        String expected = "Lorem Ipsum";
+        String actual = serializeAndThenDeserialize(expected);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testString2() throws IOException {
+        String expected = "Foo Bar";
+        String actual = serializeAndThenDeserialize(expected);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testMap1() throws IOException {
+        Map<String, String> expected = singletonMap("Lorem", "Ipsum");
+        Map<String, String> actual = serializeAndThenDeserialize(expected);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testMap2() throws IOException {
+        Map<String, String> expected = Maps.of("Lorem", "Ipsum", "Foo", "Bar");
+        Map<String, String> actual = serializeAndThenDeserialize(expected);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testMap3() throws IOException {
+        Map<String, Map<String, Integer>> expected = Maps.of("Lorem", singletonMap("Ipsum", 1), "Foo", singletonMap("Bar", 2));
+        Map<String, Map<String, Integer>> actual = serializeAndThenDeserialize(expected);
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testDeserializingNullInput() throws IOException {
+        assertNull(new GenericObjectSerDe<>().deserialize(null));
+    }
+
+    @Test
+    public void testDeserializingEmptyByteArray() throws IOException {
+        assertNull(new GenericObjectSerDe<>().deserialize(new byte[0]));
+    }
+
+    private <V> V serializeAndThenDeserialize(V value) throws IOException {
+        try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+            new GenericObjectSerDe<V>().serialize(value, bos);
+            return new GenericObjectSerDe<V>().deserialize(bos.toByteArray());
+        }
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/GetAsanaObjectConfigurationTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/GetAsanaObjectConfigurationTest.java
new file mode 100644
index 0000000000..7156946990
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/GetAsanaObjectConfigurationTest.java
@@ -0,0 +1,447 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static java.util.Collections.emptyList;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_PROJECTS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_PROJECT_EVENTS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_PROJECT_MEMBERS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_PROJECT_STATUS_ATTACHMENTS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_PROJECT_STATUS_UPDATES;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_STORIES;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_TAGS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_TASKS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_TASK_ATTACHMENTS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_TEAMS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_TEAM_MEMBERS;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_USERS;
+import static org.apache.nifi.processors.asana.GetAsanaObject.PROP_ASANA_CLIENT_SERVICE;
+import static org.apache.nifi.processors.asana.GetAsanaObject.PROP_ASANA_OBJECT_TYPE;
+import static org.apache.nifi.processors.asana.GetAsanaObject.PROP_ASANA_OUTPUT_BATCH_SIZE;
+import static org.apache.nifi.processors.asana.GetAsanaObject.PROP_ASANA_PROJECT;
+import static org.apache.nifi.processors.asana.GetAsanaObject.PROP_ASANA_TEAM_NAME;
+import static org.apache.nifi.processors.asana.GetAsanaObject.PROP_DISTRIBUTED_CACHE_SERVICE;
+import static org.apache.nifi.processors.asana.GetAsanaObject.REL_NEW;
+import static org.apache.nifi.util.TestRunners.newTestRunner;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import com.asana.models.Project;
+import com.asana.models.ProjectStatus;
+import com.asana.models.Tag;
+import com.asana.models.Task;
+import com.asana.models.Team;
+import com.asana.models.User;
+import com.google.api.client.util.DateTime;
+import java.util.stream.Stream;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.controller.asana.AsanaClientProviderService;
+import org.apache.nifi.controller.asana.AsanaEventsCollection;
+import org.apache.nifi.distributed.cache.client.DistributedMapCacheClient;
+import org.apache.nifi.processors.asana.mocks.MockAsanaClientProviderService;
+import org.apache.nifi.processors.asana.mocks.MockDistributedMapCacheClient;
+import org.apache.nifi.reporting.InitializationException;
+import org.apache.nifi.util.TestRunner;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class GetAsanaObjectConfigurationTest {
+
+    private TestRunner runner;
+    private MockAsanaClientProviderService mockService;
+    private MockDistributedMapCacheClient mockDistributedMapCacheClient;
+
+    @BeforeEach
+    public void init() {
+        runner = newTestRunner(GetAsanaObject.class);
+        mockService = new MockAsanaClientProviderService();
+        mockDistributedMapCacheClient = new MockDistributedMapCacheClient();
+    }
+
+    @Test
+    public void testNotValidWithoutControllerService() {
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+        runner.assertNotValid();
+    }
+
+    @Test
+    public void testNotValidWithoutDistributedMapCacheClient() throws InitializationException {
+        withMockAsanaClientService();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+        runner.assertNotValid();
+    }
+
+    @Test
+    public void testBatchSizeMustBePositiveInteger() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+
+        runner.setProperty(PROP_ASANA_OUTPUT_BATCH_SIZE, StringUtils.EMPTY);
+        runner.assertNotValid();
+
+        runner.setProperty(PROP_ASANA_OUTPUT_BATCH_SIZE, "Lorem");
+        runner.assertNotValid();
+
+        runner.setProperty(PROP_ASANA_OUTPUT_BATCH_SIZE, "-1");
+        runner.assertNotValid();
+
+        runner.setProperty(PROP_ASANA_OUTPUT_BATCH_SIZE, "0");
+        runner.assertNotValid();
+
+        runner.setProperty(PROP_ASANA_OUTPUT_BATCH_SIZE, "100");
+        runner.assertValid();
+    }
+
+    @Test
+    public void testValidConfigurations() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+        runner.assertValid();
+
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_TASKS.getValue());
+        runner.setProperty(PROP_ASANA_PROJECT, "My Project");
+        runner.assertValid();
+
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_TEAM_MEMBERS.getValue());
+        runner.setProperty(PROP_ASANA_TEAM_NAME, "A team");
+        runner.assertValid();
+    }
+
+    @Test
+    public void testConfigurationInvalidWithoutProjectName() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_TASKS.getValue());
+        runner.assertNotValid();
+
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECT_MEMBERS.getValue());
+        runner.assertNotValid();
+
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_STORIES.getValue());
+        runner.assertNotValid();
+
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECT_EVENTS.getValue());
+        runner.assertNotValid();
+
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECT_STATUS_ATTACHMENTS.getValue());
+        runner.assertNotValid();
+
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECT_STATUS_UPDATES.getValue());
+        runner.assertNotValid();
+
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_TASK_ATTACHMENTS.getValue());
+        runner.assertNotValid();
+    }
+
+    @Test
+    public void testConfigurationInvalidWithoutTeamName() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_TEAM_MEMBERS.getValue());
+    }
+
+    @Test
+    public void testCollectProjects() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+
+        final Project project = new Project();
+        project.gid = "12345";
+        project.modifiedAt = new DateTime(123456789);
+
+        when(mockService.client.getProjects()).then(invocation -> Stream.of(project));
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 1);
+        verify(mockService.client, atLeastOnce()).getProjects();
+        verifyNoMoreInteractions(mockService.client);
+    }
+
+    @Test
+    public void testCollectTeams() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_TEAMS.getValue());
+
+        final Team team = new Team();
+        team.gid = "12345";
+
+        when(mockService.client.getTeams()).then(invocation -> Stream.of(team));
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 1);
+        verify(mockService.client, atLeastOnce()).getTeams();
+        verifyNoMoreInteractions(mockService.client);
+    }
+
+    @Test
+    public void testCollectTeamMembers() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_TEAM_MEMBERS.getValue());
+        runner.setProperty(PROP_ASANA_TEAM_NAME, "A team");
+
+        final Team team = new Team();
+        team.gid = "12345";
+
+        when(mockService.client.getTeamByName("A team")).thenReturn(team);
+        when(mockService.client.getTeamMembers(any())).then(invocation -> Stream.empty());
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 0);
+        verify(mockService.client, atLeastOnce()).getTeamByName("A team");
+        verify(mockService.client, atLeastOnce()).getTeamMembers(any());
+        verifyNoMoreInteractions(mockService.client);
+    }
+
+    @Test
+    public void testCollectUsers() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_USERS.getValue());
+
+        final User user = new User();
+        user.gid = "12345";
+
+        when(mockService.client.getUsers()).then(invocation -> Stream.of(user));
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 1);
+        verify(mockService.client, atLeastOnce()).getUsers();
+        verifyNoMoreInteractions(mockService.client);
+    }
+
+    @Test
+    public void testCollectTags() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_TAGS.getValue());
+
+        final Tag tag = new Tag();
+        tag.gid = "12345";
+
+        when(mockService.client.getTags()).then(invocation -> Stream.of(tag));
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 1);
+        verify(mockService.client, atLeastOnce()).getTags();
+        verifyNoMoreInteractions(mockService.client);
+    }
+
+    @Test
+    public void testCollectProjectEvents() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECT_EVENTS.getValue());
+        runner.setProperty(PROP_ASANA_PROJECT, "My Project");
+
+        final Project project = new Project();
+        project.gid = "12345";
+        project.modifiedAt = new DateTime(123456789);
+
+        final AsanaEventsCollection events = new AsanaEventsCollection("foo", emptyList());
+
+        when(mockService.client.getProjectByName("My Project")).thenReturn(project);
+        when(mockService.client.getEvents(any(), any())).thenReturn(events);
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 0);
+        verify(mockService.client, atLeastOnce()).getProjectByName("My Project");
+        verify(mockService.client, atLeastOnce()).getEvents(any(), any());
+        verifyNoMoreInteractions(mockService.client);
+    }
+
+    @Test
+    public void testCollectProjectMembers() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECT_MEMBERS.getValue());
+        runner.setProperty(PROP_ASANA_PROJECT, "My Project");
+
+        final Project project = new Project();
+        project.gid = "12345";
+        project.modifiedAt = new DateTime(123456789);
+
+        when(mockService.client.getProjectByName("My Project")).thenReturn(project);
+        when(mockService.client.getProjectMemberships(any())).then(invocation -> Stream.empty());
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 0);
+        verify(mockService.client, atLeastOnce()).getProjectByName("My Project");
+        verify(mockService.client, atLeastOnce()).getProjectMemberships(any());
+        verifyNoMoreInteractions(mockService.client);
+    }
+
+    @Test
+    public void testCollectProjectStatusUpdates() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECT_STATUS_UPDATES.getValue());
+        runner.setProperty(PROP_ASANA_PROJECT, "My Project");
+
+        final Project project = new Project();
+        project.gid = "12345";
+        project.modifiedAt = new DateTime(123456789);
+
+        when(mockService.client.getProjectByName("My Project")).thenReturn(project);
+        when(mockService.client.getProjectStatusUpdates(any())).then(invocation -> Stream.empty());
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 0);
+        verify(mockService.client, atLeastOnce()).getProjectByName("My Project");
+        verify(mockService.client, atLeastOnce()).getProjectStatusUpdates(any());
+        verifyNoMoreInteractions(mockService.client);
+    }
+
+    @Test
+    public void testCollectProjectStatusAttachments() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECT_STATUS_ATTACHMENTS.getValue());
+        runner.setProperty(PROP_ASANA_PROJECT, "My Project");
+
+        final Project project = new Project();
+        project.gid = "12345";
+        project.modifiedAt = new DateTime(123456789);
+
+        final ProjectStatus projectStatus = new ProjectStatus();
+        projectStatus.gid = "12345";
+
+        when(mockService.client.getProjectByName("My Project")).thenReturn(project);
+        when(mockService.client.getProjectStatusUpdates(any())).then(invocation -> Stream.of(projectStatus));
+        when(mockService.client.getAttachments(any(ProjectStatus.class))).then(invocation -> Stream.empty());
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 0);
+        verify(mockService.client, atLeastOnce()).getProjectByName("My Project");
+        verify(mockService.client, atLeastOnce()).getProjectStatusUpdates(any());
+        verify(mockService.client, atLeastOnce()).getAttachments(any(ProjectStatus.class));
+        verifyNoMoreInteractions(mockService.client);
+    }
+
+    @Test
+    public void testCollectTasks() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_TASKS.getValue());
+        runner.setProperty(PROP_ASANA_PROJECT, "My Project");
+
+        final Project project = new Project();
+        project.gid = "12345";
+        project.modifiedAt = new DateTime(123456789);
+
+        final Task task = new Task();
+        task.gid = "12345";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(mockService.client.getProjectByName("My Project")).thenReturn(project);
+        when(mockService.client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 1);
+        verify(mockService.client, atLeastOnce()).getProjectByName("My Project");
+        verify(mockService.client, atLeastOnce()).getTasks(any(Project.class));
+        verifyNoMoreInteractions(mockService.client);
+    }
+
+    @Test
+    public void testCollectTaskAttachments() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_TASK_ATTACHMENTS.getValue());
+        runner.setProperty(PROP_ASANA_PROJECT, "My Project");
+
+        final Project project = new Project();
+        project.gid = "12345";
+        project.modifiedAt = new DateTime(123456789);
+
+        final Task task = new Task();
+        task.gid = "12345";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(mockService.client.getProjectByName("My Project")).thenReturn(project);
+        when(mockService.client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(mockService.client.getAttachments(any(Task.class))).then(invocation -> Stream.empty());
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 0);
+        verify(mockService.client, atLeastOnce()).getProjectByName("My Project");
+        verify(mockService.client, atLeastOnce()).getTasks(any(Project.class));
+        verify(mockService.client, atLeastOnce()).getAttachments(any(Task.class));
+        verifyNoMoreInteractions(mockService.client);
+    }
+
+    @Test
+    public void testCollectStories() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_STORIES.getValue());
+        runner.setProperty(PROP_ASANA_PROJECT, "My Project");
+
+        final Project project = new Project();
+        project.gid = "12345";
+        project.modifiedAt = new DateTime(123456789);
+
+        final Task task = new Task();
+        task.gid = "12345";
+        task.modifiedAt = new DateTime(123456789);
+
+        when(mockService.client.getProjectByName("My Project")).thenReturn(project);
+        when(mockService.client.getTasks(any(Project.class))).then(invocation -> Stream.of(task));
+        when(mockService.client.getStories(any())).then(invocation -> Stream.empty());
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 0);
+        verify(mockService.client, atLeastOnce()).getProjectByName("My Project");
+        verify(mockService.client, atLeastOnce()).getTasks(any(Project.class));
+        verify(mockService.client, atLeastOnce()).getStories(any());
+        verifyNoMoreInteractions(mockService.client);
+    }
+
+    private void withMockAsanaClientService() throws InitializationException {
+        final String serviceIdentifier = AsanaClientProviderService.class.getName();
+        runner.addControllerService(serviceIdentifier, mockService);
+        runner.enableControllerService(mockService);
+        runner.setProperty(PROP_ASANA_CLIENT_SERVICE, serviceIdentifier);
+    }
+
+    private void withMockDistributedMapCacheClient() throws InitializationException {
+        final String serviceIdentifier = DistributedMapCacheClient.class.getName();
+        runner.addControllerService(serviceIdentifier, mockDistributedMapCacheClient);
+        runner.enableControllerService(mockDistributedMapCacheClient);
+        runner.setProperty(PROP_DISTRIBUTED_CACHE_SERVICE, serviceIdentifier);
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/GetAsanaObjectLifecycleTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/GetAsanaObjectLifecycleTest.java
new file mode 100644
index 0000000000..99f2b9c3b3
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/GetAsanaObjectLifecycleTest.java
@@ -0,0 +1,297 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana;
+
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptyMap;
+import static java.util.Collections.singletonList;
+import static java.util.Collections.singletonMap;
+import static org.apache.nifi.processors.asana.AsanaObjectType.AV_COLLECT_PROJECTS;
+import static org.apache.nifi.processors.asana.GetAsanaObject.ASANA_GID;
+import static org.apache.nifi.processors.asana.GetAsanaObject.PROP_ASANA_CLIENT_SERVICE;
+import static org.apache.nifi.processors.asana.GetAsanaObject.PROP_ASANA_OBJECT_TYPE;
+import static org.apache.nifi.processors.asana.GetAsanaObject.PROP_ASANA_OUTPUT_BATCH_SIZE;
+import static org.apache.nifi.processors.asana.GetAsanaObject.PROP_DISTRIBUTED_CACHE_SERVICE;
+import static org.apache.nifi.processors.asana.GetAsanaObject.REL_NEW;
+import static org.apache.nifi.processors.asana.GetAsanaObject.REL_REMOVED;
+import static org.apache.nifi.processors.asana.GetAsanaObject.REL_UPDATED;
+import static org.apache.nifi.util.TestRunners.newTestRunner;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.gson.Gson;
+import java.util.List;
+import java.util.Map;
+import org.apache.groovy.util.Maps;
+import org.apache.nifi.controller.asana.AsanaClientProviderService;
+import org.apache.nifi.distributed.cache.client.DistributedMapCacheClient;
+import org.apache.nifi.processors.asana.mocks.MockAsanaClientProviderService;
+import org.apache.nifi.processors.asana.mocks.MockDistributedMapCacheClient;
+import org.apache.nifi.processors.asana.mocks.MockGetAsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcherException;
+import org.apache.nifi.processors.asana.utils.AsanaObjectState;
+import org.apache.nifi.reporting.InitializationException;
+import org.apache.nifi.util.MockFlowFile;
+import org.apache.nifi.util.MockProcessContext;
+import org.apache.nifi.util.TestRunner;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class GetAsanaObjectLifecycleTest {
+
+    private static final Gson GSON = new Gson();
+    private TestRunner runner;
+    private MockAsanaClientProviderService mockService;
+    private MockDistributedMapCacheClient mockDistributedMapCacheClient;
+    private AsanaObjectFetcher mockObjectFetcher;
+
+    @BeforeEach
+    public void init() {
+        runner = newTestRunner(MockGetAsanaObject.class);
+        mockService = new MockAsanaClientProviderService();
+        mockDistributedMapCacheClient = new MockDistributedMapCacheClient();
+        mockObjectFetcher = ((MockGetAsanaObject)runner.getProcessor()).objectFetcher;
+    }
+
+    @Test
+    public void testYieldIsCalledWhenNoAsanaObjectsFetched() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+
+        when(mockObjectFetcher.fetchNext()).thenReturn(null);
+
+        runner.run(1);
+
+        verify(mockObjectFetcher, times(1)).fetchNext();
+
+        runner.assertTransferCount(REL_NEW, 0);
+        runner.assertTransferCount(REL_REMOVED, 0);
+        runner.assertTransferCount(REL_UPDATED, 0);
+
+        assertTrue(((MockProcessContext) runner.getProcessContext()).isYieldCalled());
+    }
+
+    @Test
+    public void testCollectObjectsFromAsanaThenYield() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+
+        when(mockObjectFetcher.fetchNext())
+            .thenReturn(new AsanaObject(AsanaObjectState.NEW, "1", "Lorem ipsum"))
+            .thenReturn(new AsanaObject(AsanaObjectState.NEW, "2", "dolor sit amet"))
+            .thenReturn(new AsanaObject(AsanaObjectState.NEW, "3", "consectetur adipiscing elit"))
+            .thenReturn(new AsanaObject(AsanaObjectState.UPDATED, "1", "Lorem Ipsum"))
+            .thenReturn(new AsanaObject(AsanaObjectState.REMOVED, "3"))
+            .thenReturn(null);
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 3);
+        runner.assertTransferCount(REL_REMOVED, 1);
+        runner.assertTransferCount(REL_UPDATED, 1);
+
+        assertFalse(((MockProcessContext) runner.getProcessContext()).isYieldCalled());
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 3);
+        runner.assertTransferCount(REL_REMOVED, 1);
+        runner.assertTransferCount(REL_UPDATED, 1);
+
+        assertTrue(((MockProcessContext) runner.getProcessContext()).isYieldCalled());
+
+        verify(mockObjectFetcher, times(7)).fetchNext();
+
+        final List<MockFlowFile> newFlowFiles = runner.getFlowFilesForRelationship(REL_NEW);
+
+        newFlowFiles.get(0).assertAttributeEquals(ASANA_GID, "1");
+        newFlowFiles.get(0).assertContentEquals("Lorem ipsum");
+
+        newFlowFiles.get(1).assertAttributeEquals(ASANA_GID, "2");
+        newFlowFiles.get(1).assertContentEquals("dolor sit amet");
+
+        newFlowFiles.get(2).assertAttributeEquals(ASANA_GID, "3");
+        newFlowFiles.get(2).assertContentEquals("consectetur adipiscing elit");
+
+        final List<MockFlowFile> updatedFlowFiles = runner.getFlowFilesForRelationship(REL_UPDATED);
+
+        updatedFlowFiles.get(0).assertAttributeEquals(ASANA_GID, "1");
+        updatedFlowFiles.get(0).assertContentEquals("Lorem Ipsum");
+
+        final List<MockFlowFile> removedFlowFiles = runner.getFlowFilesForRelationship(REL_REMOVED);
+
+        removedFlowFiles.get(0).assertAttributeEquals(ASANA_GID, "3");
+    }
+
+    @Test
+    public void testCollectObjectsFromAsanaWithBatchSizeConfigured() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+        runner.setProperty(PROP_ASANA_OUTPUT_BATCH_SIZE, "2");
+
+        when(mockObjectFetcher.fetchNext())
+            .thenReturn(new AsanaObject(AsanaObjectState.NEW, "1", GSON.toJson("Lorem ipsum")))
+            .thenReturn(new AsanaObject(AsanaObjectState.NEW, "2", GSON.toJson("dolor sit amet")))
+            .thenReturn(new AsanaObject(AsanaObjectState.NEW, "3", GSON.toJson("consectetur adipiscing elit")))
+            .thenReturn(new AsanaObject(AsanaObjectState.UPDATED, "1", GSON.toJson("Lorem Ipsum")))
+            .thenReturn(new AsanaObject(AsanaObjectState.REMOVED, "3", GSON.toJson("Some info about removal")))
+            .thenReturn(null);
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 2);
+        runner.assertTransferCount(REL_REMOVED, 1);
+        runner.assertTransferCount(REL_UPDATED, 1);
+
+        assertFalse(((MockProcessContext) runner.getProcessContext()).isYieldCalled());
+
+        runner.run(1);
+
+        runner.assertTransferCount(REL_NEW, 2);
+        runner.assertTransferCount(REL_REMOVED, 1);
+        runner.assertTransferCount(REL_UPDATED, 1);
+
+        assertTrue(((MockProcessContext) runner.getProcessContext()).isYieldCalled());
+
+        verify(mockObjectFetcher, times(7)).fetchNext();
+
+        final List<MockFlowFile> newFlowFiles = runner.getFlowFilesForRelationship(REL_NEW);
+
+        newFlowFiles.get(0).assertContentEquals(GSON.toJson(asList("Lorem ipsum", "dolor sit amet")));
+        newFlowFiles.get(1).assertContentEquals(GSON.toJson(singletonList("consectetur adipiscing elit")));
+
+        final List<MockFlowFile> updatedFlowFiles = runner.getFlowFilesForRelationship(REL_UPDATED);
+
+        updatedFlowFiles.get(0).assertContentEquals(GSON.toJson(singletonList("Lorem Ipsum")));
+
+        final List<MockFlowFile> removedFlowFiles = runner.getFlowFilesForRelationship(REL_REMOVED);
+
+        removedFlowFiles.get(0).assertContentEquals(GSON.toJson(singletonList("Some info about removal")));
+    }
+
+    @Test
+    public void testAttemptLoadStateButNoStatePresent() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+
+        when(mockObjectFetcher.fetchNext()).thenReturn(null);
+
+        runner.run(1);
+
+        verify(mockObjectFetcher, times(1)).loadState(emptyMap());
+        verify(mockObjectFetcher, times(1)).clearState();
+    }
+
+    @Test
+    public void testLoadValidState() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+
+        final Map<String, String> validState = Maps.of(
+            "Key1", "Value1",
+            "Key2", "Value2"
+        );
+
+        mockDistributedMapCacheClient.put(runner.getProcessor().getIdentifier(), validState);
+
+        when(mockObjectFetcher.fetchNext()).thenReturn(null);
+
+        runner.run(1);
+
+        verify(mockObjectFetcher, times(1)).loadState(validState);
+        verify(mockObjectFetcher, times(1)).clearState();
+    }
+
+    @Test
+    public void testAttemptLoadInvalidStateThenClear() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+
+        final Map<String, String> invalidState = singletonMap("Key", "Value");
+
+        mockDistributedMapCacheClient.put(runner.getProcessor().getIdentifier(), invalidState);
+
+        doThrow(new AsanaObjectFetcherException()).when(mockObjectFetcher).loadState(invalidState);
+        when(mockObjectFetcher.fetchNext()).thenReturn(null);
+
+        runner.run(1);
+
+        verify(mockObjectFetcher, times(1)).loadState(invalidState);
+        verify(mockObjectFetcher, times(2)).clearState();
+    }
+
+    @Test
+    public void testStateIsSavedIfProcessorYields() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+
+        final Map<String, String> state = singletonMap("Key", "Value");
+
+        when(mockObjectFetcher.saveState()).thenReturn(state);
+        when(mockObjectFetcher.fetchNext()).thenReturn(null);
+
+        runner.run(1);
+
+        assertEquals(state, mockDistributedMapCacheClient.get(runner.getProcessor().getIdentifier()));
+    }
+
+    @Test
+    public void testStateIsSavedIfThereAreObjectsFetched() throws InitializationException {
+        withMockAsanaClientService();
+        withMockDistributedMapCacheClient();
+        runner.setProperty(PROP_ASANA_OBJECT_TYPE, AV_COLLECT_PROJECTS.getValue());
+
+        final Map<String, String> state = singletonMap("Key", "Value");
+
+        when(mockObjectFetcher.saveState()).thenReturn(state);
+        when(mockObjectFetcher.fetchNext())
+            .thenReturn(new AsanaObject(AsanaObjectState.NEW, "1", "Lorem ipsum"))
+            .thenReturn(null);
+
+        runner.run(1);
+
+        assertEquals(state, mockDistributedMapCacheClient.get(runner.getProcessor().getIdentifier()));
+    }
+
+    private void withMockAsanaClientService() throws InitializationException {
+        final String serviceIdentifier = AsanaClientProviderService.class.getName();
+        runner.addControllerService(serviceIdentifier, mockService);
+        runner.enableControllerService(mockService);
+        runner.setProperty(PROP_ASANA_CLIENT_SERVICE, serviceIdentifier);
+    }
+
+    private void withMockDistributedMapCacheClient() throws InitializationException {
+        final String serviceIdentifier = DistributedMapCacheClient.class.getName();
+        runner.addControllerService(serviceIdentifier, mockDistributedMapCacheClient);
+        runner.enableControllerService(mockDistributedMapCacheClient);
+        runner.setProperty(PROP_DISTRIBUTED_CACHE_SERVICE, serviceIdentifier);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockAbstractAsanaObjectFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockAbstractAsanaObjectFetcher.java
new file mode 100644
index 0000000000..a51ef61a59
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockAbstractAsanaObjectFetcher.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.mocks;
+
+import static java.util.Collections.emptyList;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.Map;
+import org.apache.nifi.processors.asana.utils.AsanaObject;
+import org.apache.nifi.processors.asana.utils.AbstractAsanaObjectFetcher;
+
+public class MockAbstractAsanaObjectFetcher extends AbstractAsanaObjectFetcher {
+
+    public Collection<AsanaObject> items = emptyList();
+    public int pollCount = 0;
+
+    @Override
+    protected Iterator<AsanaObject> fetch() {
+        pollCount++;
+        Collection<AsanaObject> result = new ArrayList<>(items);
+        items = emptyList();
+        return result.iterator();
+    }
+
+    @Override
+    public Map<String, String> saveState() {
+        return null;
+    }
+
+    @Override
+    public void loadState(Map<String, String> state) {
+
+    }
+
+    @Override
+    public void clearState() {
+
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockAsanaClientProviderService.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockAsanaClientProviderService.java
new file mode 100644
index 0000000000..976c2c3c9e
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockAsanaClientProviderService.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.mocks;
+
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.controller.asana.AsanaClientProviderService;
+
+import static org.mockito.Mockito.mock;
+
+public class MockAsanaClientProviderService extends AbstractControllerService implements AsanaClientProviderService {
+
+    public final AsanaClient client = mock(AsanaClient.class);
+
+    @Override
+    public synchronized AsanaClient createClient() {
+        return client;
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockDistributedMapCacheClient.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockDistributedMapCacheClient.java
new file mode 100644
index 0000000000..7a07e2e22d
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockDistributedMapCacheClient.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.mocks;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.commons.lang3.NotImplementedException;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.distributed.cache.client.Deserializer;
+import org.apache.nifi.distributed.cache.client.DistributedMapCacheClient;
+import org.apache.nifi.distributed.cache.client.Serializer;
+
+public class MockDistributedMapCacheClient extends AbstractControllerService implements DistributedMapCacheClient {
+    private final Map<Object, Object> stored = new HashMap<>();
+
+    @Override
+    public <K, V> boolean putIfAbsent(K key, V value, Serializer<K> keySerializer, Serializer<V> valueSerializer) {
+        throw new NotImplementedException();
+    }
+
+    @Override
+    public <K, V> V getAndPutIfAbsent(K key, V value, Serializer<K> keySerializer, Serializer<V> valueSerializer, Deserializer<V> valueDeserializer) {
+        throw new NotImplementedException();
+    }
+
+    @Override
+    public <K> boolean containsKey(K key, Serializer<K> keySerializer) {
+        return stored.containsKey(key);
+    }
+
+    @Override
+    public <K, V> void put(K key, V value, Serializer<K> keySerializer, Serializer<V> valueSerializer) {
+        put(key, value);
+    }
+
+    public <K, V> void put(K key, V value) {
+        stored.put(key, value);
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public <K, V> V get(K key, Serializer<K> keySerializer, Deserializer<V> valueDeserializer) {
+        return (V) stored.get(key);
+    }
+
+    @SuppressWarnings("unchecked")
+    public <K, V> V get(K key) {
+        return (V) stored.get(key);
+    }
+
+    @Override
+    public void close() {
+    }
+
+    @Override
+    public <K> boolean remove(K key, Serializer<K> serializer) {
+        throw new NotImplementedException();
+    }
+
+    @Override
+    public long removeByPattern(String regex) {
+        throw new NotImplementedException();
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockGenericAsanaObjectFetcher.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockGenericAsanaObjectFetcher.java
new file mode 100644
index 0000000000..b35fed621f
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockGenericAsanaObjectFetcher.java
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.mocks;
+
+import static java.util.Collections.emptyList;
+
+import com.asana.models.Resource;
+import java.util.Collection;
+import java.util.stream.Stream;
+import org.apache.nifi.processors.asana.utils.GenericAsanaObjectFetcher;
+
+public class MockGenericAsanaObjectFetcher extends GenericAsanaObjectFetcher<Resource> {
+
+    public Collection<Resource> items = emptyList();
+    public int refreshCount = 0;
+
+    @Override
+    protected Stream<Resource> fetchObjects() {
+        refreshCount++;
+        return items.stream();
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockGetAsanaObject.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockGetAsanaObject.java
new file mode 100644
index 0000000000..a2bf71af4c
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-processors/src/test/java/org/apache/nifi/processors/asana/mocks/MockGetAsanaObject.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.asana.mocks;
+
+import org.apache.nifi.controller.asana.AsanaClient;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processors.asana.GetAsanaObject;
+import org.apache.nifi.processors.asana.utils.AsanaObjectFetcher;
+
+import static org.mockito.Mockito.mock;
+
+public class MockGetAsanaObject extends GetAsanaObject {
+
+    public final AsanaObjectFetcher objectFetcher = mock(AsanaObjectFetcher.class);
+
+    @Override
+    protected AsanaObjectFetcher createObjectFetcher(ProcessContext context, AsanaClient client) {
+        return objectFetcher;
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api-nar/pom.xml b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api-nar/pom.xml
new file mode 100644
index 0000000000..b97eb73d95
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api-nar/pom.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements. See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License. You may obtain a copy of the License at
+  http://www.apache.org/licenses/LICENSE-2.0
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.nifi</groupId>
+        <artifactId>nifi-asana-bundle</artifactId>
+        <version>1.20.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>nifi-asana-services-api-nar</artifactId>
+    <packaging>nar</packaging>
+    <properties>
+        <maven.javadoc.skip>true</maven.javadoc.skip>
+        <source.skip>true</source.skip>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-standard-services-api-nar</artifactId>
+            <type>nar</type>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-asana-services-api</artifactId>
+        </dependency>
+    </dependencies>
+
+</project>
\ No newline at end of file
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api-nar/src/main/resources/META-INF/LICENSE b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api-nar/src/main/resources/META-INF/LICENSE
new file mode 100644
index 0000000000..a5545c5ab0
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api-nar/src/main/resources/META-INF/LICENSE
@@ -0,0 +1,228 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+
+
+The binary distribution of this product bundles 'Java client library for the Asana API'
+which is available under MIT license. For details see https://github.com/Asana/java-asana.
+
+    The MIT License (MIT)
+
+    Copyright (c) 2015 Asana
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE.
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api-nar/src/main/resources/META-INF/NOTICE b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api-nar/src/main/resources/META-INF/NOTICE
new file mode 100644
index 0000000000..d71f82c956
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api-nar/src/main/resources/META-INF/NOTICE
@@ -0,0 +1,103 @@
+nifi-asana-services-api-nar
+Copyright 2015-2022 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
+
+===========================================
+Apache Software License v2
+===========================================
+
+The following binary components are provided under the Apache Software License v2
+
+  (ASLv2) Apache Commons Codec
+    The following NOTICE information applies:
+      Apache Commons Codec
+      Copyright 2002-2022 The Apache Software Foundation
+
+  (ASLv2) Error Prone Annotations
+    The following NOTICE information applies:
+      Error Prone Annotations
+      Copyright 2015 The Error Prone Authors
+
+  (ASLv2) Guava InternalFutureFailureAccess and InternalFutures
+    The following NOTICE information applies:
+      Guava InternalFutureFailureAccess and InternalFutures
+      Copyright (C) 2018 The Guava Authors
+
+  (ASLv2) Google HTTP Client Library For Java
+    The following NOTICE information applies:
+      Google HTTP Client Library For Java
+      Copyright (c) 2011 Google Inc.
+
+  (ASLv2) GSON Extensions to The Google HTTP Client Library For Java
+    The following NOTICE information applies:
+      GSON Extensions to The Google HTTP Client Library For Java
+      Copyright (c) 2011 Google Inc.
+
+  (ASLv2) Google OAuth Client Library For Java
+    The following NOTICE information applies:
+      Google OAuth Client Library For Java
+      Copyright 2021 Google LLC
+
+  (ASLv2) GRPC Context
+    The following NOTICE information applies:
+      GRPC Context
+      Copyright 2015 The gRPC Authors
+
+  (ASLv2) Gson
+    The following NOTICE information applies:
+      Gson
+      Copyright (C) 2008 Google Inc.
+
+  (ASLv2) Guava: Google Core Libraries For Java
+    The following NOTICE information applies:
+      Guava: Google Core Libraries For Java
+      Copyright (C) 2017 The Guava Authors
+
+  (ASLv2) Apache HttpClient
+    The following NOTICE information applies:
+      Apache HttpClient
+      Copyright 1999-2022 The Apache Software Foundation
+
+  (ASLv2) Apache HttpCore
+    The following NOTICE information applies:
+      Apache HttpCore
+      Copyright 2005-2022 The Apache Software Foundation
+
+  (ASLv2) J2ObjC Annotations
+    The following NOTICE information applies:
+      J2ObjC Annotations
+      Copyright 2022 The J2ObjC Annotations Authors
+
+  (ASLv2) FindBugs JSR305
+    The following NOTICE information applies:
+      FindBugs JSR305
+      Copyright 2017 The FindBugs JSR305 Authors
+
+  (ASLv2) Guava ListenableFuture Only
+    The following NOTICE information applies:
+      Guava ListenableFuture Only
+      Copyright (C) 2018 The Guava Authors
+
+  (ASLv2) OpenCensus
+    The following NOTICE information applies:
+      OpenCensus
+      Copyright 2016-17, OpenCensus Authors
+
+
+===========================================
+The MIT License
+===========================================
+
+The following binary components are provided under the MIT License
+
+  (MIT License) Java client library for the Asana API
+    The following NOTICE information applies:
+      Asana
+      Copyright (c) 2015
+
+  (MIT License) Checker Framework qualifiers
+    The following NOTICE information applies:
+      Checker Framework qualifiers
+      Copyright 2004-present by the Checker Framework developers
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/pom.xml b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/pom.xml
new file mode 100644
index 0000000000..dcb0f775ab
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/pom.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements. See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License. You may obtain a copy of the License at
+  http://www.apache.org/licenses/LICENSE-2.0
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.nifi</groupId>
+        <artifactId>nifi-asana-bundle</artifactId>
+        <version>1.20.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>nifi-asana-services-api</artifactId>
+    <packaging>jar</packaging>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.asana</groupId>
+            <artifactId>asana</artifactId>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/src/main/java/org/apache/nifi/controller/asana/AsanaClient.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/src/main/java/org/apache/nifi/controller/asana/AsanaClient.java
new file mode 100644
index 0000000000..4586d6dae8
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/src/main/java/org/apache/nifi/controller/asana/AsanaClient.java
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.controller.asana;
+
+import com.asana.models.Attachment;
+import com.asana.models.Project;
+import com.asana.models.ProjectMembership;
+import com.asana.models.ProjectStatus;
+import com.asana.models.Section;
+import com.asana.models.Story;
+import com.asana.models.Tag;
+import com.asana.models.Task;
+import com.asana.models.Team;
+import com.asana.models.User;
+import java.util.stream.Stream;
+
+/**
+ * This interface represents a client to Asana REST server, with some basic filtering options built in.
+ */
+public interface AsanaClient {
+    /**
+     * Find & retrieve an Asana project by its name. If multiple projects match, returns the first.
+     * If there is no match, then {@link AsanaClientException} is thrown. Note that constant ordering
+     * is not guaranteed by Asana.
+     *
+     * @param projectName The name of the project you would like to retrieve. Case-sensitive.
+     * @return An object representing the project.
+     */
+    Project getProjectByName(String projectName);
+
+    /**
+     * Retrieve all the projects from Asana without filtering them any further.
+     *
+     * @return {@link Stream} of projects.
+     */
+    Stream<Project> getProjects();
+
+    /**
+     * Retrieve all the users from Asana without filtering them any further.
+     *
+     * @return {@link Stream} of users.
+     */
+    Stream<User> getUsers();
+
+    /**
+     * Retrieve the users (members) assigned to a given project.
+     *
+     * @param project The object representing the project. Returned earlier by {@code getProjects()}
+     *                or {@code getProjectByName()}.
+     *
+     * @return {@link Stream} of project members.
+     */
+    Stream<ProjectMembership> getProjectMemberships(Project project);
+
+    /**
+     * Find & retrieve an Asana team by its name. If multiple teams match, returns the first.
+     * If there is no match, then {@link AsanaClientException} is thrown. Note that constant ordering
+     * is not guaranteed by Asana.
+     *
+     * @param teamName The name of the team you would like to retrieve. Case-sensitive.
+     * @return An object representing the team.
+     */
+    Team getTeamByName(String teamName);
+
+    /**
+     * Retrieve all the teams from Asana without filtering them any further.
+     *
+     * @return {@link Stream} of teams.
+     */
+    Stream<Team> getTeams();
+
+    /**
+     * Retrieve the users (members) assigned to a given team.
+     *
+     * @param team The object representing the team. Returned earlier by {@code getTeams()}
+     *             or {@code getTeamByName()}.
+     *
+     * @return {@link Stream} of team members.
+     */
+    Stream<User> getTeamMembers(Team team);
+
+    /**
+     * Find & retrieve an Asana project's section (board column) by its name. If multiple sections
+     * match, returns the first. If there is no match, then {@link AsanaClientException} is thrown.
+     * Note that constant ordering is not guaranteed by Asana.
+     *
+     * @param project The object representing the project. Returned earlier by {@code getProjects()}
+     *                or {@code getProjectByName()}.
+     * @param sectionName The name of the section (board column) you would like to retrieve. Case-sensitive.
+     * @return An object representing the section.
+     */
+    Section getSectionByName(Project project, String sectionName);
+
+    /**
+     * Retrieve all the sections (board columns) of an Asana project without filtering them any further.
+     *
+     * @param project The object representing the project. Returned earlier by {@code getProjects()}
+     *                or {@code getProjectByName()}.
+     * @return {@link Stream} of project sections.
+     */
+    Stream<Section> getSections(Project project);
+
+    /**
+     * Retrieve all the tasks from an Asana project without filtering them any further.
+     *
+     * @param project The object representing the project. Returned earlier by {@code getProjects()}
+     *                or {@code getProjectByName()}.
+     * @return {@link Stream} of project tasks.
+     */
+    Stream<Task> getTasks(Project project);
+
+    /**
+     * Retrieve all the tasks tagged with a given tag.
+     *
+     * @param tag The object representing the tag. Returned earlier by {@code getTags()}.
+     * @return {@link Stream} of tasks.
+     */
+    Stream<Task> getTasks(Tag tag);
+
+    /**
+     * Retrieve all the tasks from a given section.
+     *
+     * @param section The object representing the section. Returned earlier by {@code getSections()}
+     *                or {@code getSectionByName()}.
+     * @return {@link Stream} of tasks.
+     */
+    Stream<Task> getTasks(Section section);
+
+    /**
+     * Retrieve all the tags from Asana without filtering them any further.
+     *
+     * @return {@link Stream} of tags.
+     */
+    Stream<Tag> getTags();
+
+    /**
+     * Retrieve all the status updates of an Asana project without filtering them any further.
+     *
+     * @param project The object representing the project. Returned earlier by {@code getProjects()}
+     *                or {@code getProjectByName()}.
+     * @return {@link Stream} of project status updates.
+     */
+    Stream<ProjectStatus> getProjectStatusUpdates(Project project);
+
+    /**
+     * Retrieve all the stories (comments) of an Asana task without filtering them any further.
+     *
+     * @param task The object representing the task. Returned earlier by {@code getTasks()}.
+     * @return {@link Stream} of stories (comments).
+     */
+    Stream<Story> getStories(Task task);
+
+    /**
+     * Retrieve all the attachments of an Asana task without filtering them any further.
+     *
+     * @param task The object representing the task. Returned earlier by {@code getTasks()}.
+     * @return {@link Stream} of attachments.
+     */
+    Stream<Attachment> getAttachments(Task task);
+
+    /**
+     * Retrieve all the attachments of an Asana project status update without filtering them any further.
+     *
+     * @param projectStatus The object representing the project's status. Returned earlier by
+     *                      {@code getProjectStatusUpdates()}.
+     * @return {@link Stream} of attachments.
+     */
+    Stream<Attachment> getAttachments(ProjectStatus projectStatus);
+
+    /**
+     * Subscribes the event stream of a project, and incrementally returns new events created since the last call.
+     *
+     * @param project The object representing the project. Returned earlier by {@code getProjects()}
+     *                or {@code getProjectByName()}.
+     * @param syncToken Token returned by the previous call. If the token is empty, null, or invalid, a new token will be generated,
+     *                  but events since the last call won't be fetched.
+     * @return An {@link AsanaEventsCollection} containing a token and the collection of events created since the last call.
+     */
+    AsanaEventsCollection getEvents(Project project, String syncToken);
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/src/main/java/org/apache/nifi/controller/asana/AsanaClientException.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/src/main/java/org/apache/nifi/controller/asana/AsanaClientException.java
new file mode 100644
index 0000000000..5b04519746
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/src/main/java/org/apache/nifi/controller/asana/AsanaClientException.java
@@ -0,0 +1,25 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.controller.asana;
+
+public class AsanaClientException extends RuntimeException {
+
+    public AsanaClientException(String message) {
+        super(message);
+    }
+
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/src/main/java/org/apache/nifi/controller/asana/AsanaClientProviderService.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/src/main/java/org/apache/nifi/controller/asana/AsanaClientProviderService.java
new file mode 100644
index 0000000000..c1f0ccb254
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/src/main/java/org/apache/nifi/controller/asana/AsanaClientProviderService.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.controller.asana;
+
+import org.apache.nifi.controller.ControllerService;
+
+/**
+ * This interface represents an API to the controller service of Asana processors.
+ */
+public interface AsanaClientProviderService extends ControllerService {
+
+    /**
+     * Creates an Asana client with authentication, workspace, and API endpoint related settings pre-configured.
+     *
+     * @return An {@link AsanaClient}, based on the configuration of the {@link ControllerService}.
+     */
+    AsanaClient createClient();
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/src/main/java/org/apache/nifi/controller/asana/AsanaEventsCollection.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/src/main/java/org/apache/nifi/controller/asana/AsanaEventsCollection.java
new file mode 100644
index 0000000000..fbb8269f50
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-api/src/main/java/org/apache/nifi/controller/asana/AsanaEventsCollection.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.controller.asana;
+
+import com.asana.models.Event;
+import java.util.Collection;
+import java.util.Iterator;
+
+public class AsanaEventsCollection implements Iterable<Event> {
+
+    private final String nextSyncToken;
+    private final Collection<Event> events;
+
+    public AsanaEventsCollection(String nextSyncToken, Collection<Event> events) {
+        this.nextSyncToken = nextSyncToken;
+        this.events = events;
+    }
+
+    @Override
+    public Iterator<Event> iterator() {
+        return events.iterator();
+    }
+
+    public String getNextSyncToken() {
+        return nextSyncToken;
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-nar/pom.xml b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-nar/pom.xml
new file mode 100644
index 0000000000..762a53aa38
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-nar/pom.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements. See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License. You may obtain a copy of the License at
+  http://www.apache.org/licenses/LICENSE-2.0
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.nifi</groupId>
+        <artifactId>nifi-asana-bundle</artifactId>
+        <version>1.20.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>nifi-asana-services-nar</artifactId>
+    <packaging>nar</packaging>
+    <properties>
+        <maven.javadoc.skip>true</maven.javadoc.skip>
+        <source.skip>true</source.skip>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-asana-services-api-nar</artifactId>
+            <type>nar</type>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-asana-services</artifactId>
+        </dependency>
+    </dependencies>
+</project>
\ No newline at end of file
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-nar/src/main/resources/META-INF/LICENSE b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-nar/src/main/resources/META-INF/LICENSE
new file mode 100644
index 0000000000..d645695673
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-nar/src/main/resources/META-INF/LICENSE
@@ -0,0 +1,202 @@
+
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-nar/src/main/resources/META-INF/NOTICE b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-nar/src/main/resources/META-INF/NOTICE
new file mode 100644
index 0000000000..e480a68bc2
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services-nar/src/main/resources/META-INF/NOTICE
@@ -0,0 +1,5 @@
+nifi-asana-services-nar
+Copyright 2015-2022 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (http://www.apache.org/).
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/pom.xml b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/pom.xml
new file mode 100644
index 0000000000..507c4fa008
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/pom.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements. See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License. You may obtain a copy of the License at
+  http://www.apache.org/licenses/LICENSE-2.0
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.nifi</groupId>
+        <artifactId>nifi-asana-bundle</artifactId>
+        <version>1.20.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>nifi-asana-services</artifactId>
+    <packaging>jar</packaging>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-utils</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-asana-services-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.asana</groupId>
+            <artifactId>asana</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.nifi</groupId>
+            <artifactId>nifi-mock</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>mockwebserver</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/main/java/org/apache/nifi/controller/asana/StandardAsanaClient.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/main/java/org/apache/nifi/controller/asana/StandardAsanaClient.java
new file mode 100644
index 0000000000..d21026dde8
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/main/java/org/apache/nifi/controller/asana/StandardAsanaClient.java
@@ -0,0 +1,295 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.controller.asana;
+
+import com.asana.Client;
+import com.asana.errors.InvalidTokenError;
+import com.asana.models.Attachment;
+import com.asana.models.Event;
+import com.asana.models.Project;
+import com.asana.models.ProjectMembership;
+import com.asana.models.ProjectStatus;
+import com.asana.models.Resource;
+import com.asana.models.ResultBodyCollection;
+import com.asana.models.Section;
+import com.asana.models.Story;
+import com.asana.models.Tag;
+import com.asana.models.Task;
+import com.asana.models.Team;
+import com.asana.models.User;
+import com.asana.models.Workspace;
+import com.asana.requests.CollectionRequest;
+import com.asana.requests.EventsRequest;
+import com.google.gson.annotations.SerializedName;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+public class StandardAsanaClient implements AsanaClient {
+
+    static final String ASANA_CLIENT_OPTION_BASE_URL = "base_url";
+
+    private final Client client;
+    private final Workspace workspace;
+
+    public StandardAsanaClient(String personalAccessToken, String workspaceName, String baseUrl) {
+        client = Client.accessToken(personalAccessToken);
+        if (baseUrl != null) {
+            client.options.put(ASANA_CLIENT_OPTION_BASE_URL, baseUrl);
+        }
+        workspace = getWorkspaceByName(workspaceName);
+    }
+
+    @Override
+    public Project getProjectByName(String projectName) {
+        return getProjects()
+                .filter(p -> p.name.equals(projectName))
+                .findFirst()
+                .orElseThrow(() -> new AsanaClientException("No such project: " + projectName));
+    }
+
+    @Override
+    public Stream<Project> getProjects() {
+        try {
+            return collectionRequestToStream(
+                    client.projects.getProjects(null, null, workspace.gid, null, null, getSerializedFieldNames(Project.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public Stream<User> getUsers() {
+        try {
+            return collectionRequestToStream(
+                    client.users.getUsersForWorkspace(workspace.gid, null, getSerializedFieldNames(User.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    public Stream<ProjectMembership> getProjectMemberships(Project project) {
+        try {
+            return collectionRequestToStream(
+                    client.projectMemberships.getProjectMembershipsForProject(project.gid, null, null, null, getSerializedFieldNames(ProjectMembership.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public Team getTeamByName(String teamName) {
+        return getTeams()
+                .filter(t -> t.name.equals(teamName))
+                .findFirst()
+                .orElseThrow(() -> new AsanaClientException("No such team: " + teamName));
+    }
+
+    @Override
+    public Stream<Team> getTeams() {
+        try {
+            return collectionRequestToStream(
+                    client.teams.getTeamsForWorkspace(workspace.gid, null, null, getSerializedFieldNames(Team.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public Stream<User> getTeamMembers(Team team) {
+        try {
+            return collectionRequestToStream(
+                    client.users.getUsersForTeam(team.gid, null, getSerializedFieldNames(User.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public Section getSectionByName(Project project, String sectionName) {
+        return getSections(project)
+                .filter(s -> s.name.equals(sectionName))
+                .findFirst()
+                .orElseThrow(() -> new AsanaClientException("No such section: " + sectionName + " in project: " + project.name));
+    }
+
+    @Override
+    public Stream<Section> getSections(Project project) {
+        try {
+            return collectionRequestToStream(
+                    client.sections.getSectionsForProject(project.gid, null, null, getSerializedFieldNames(Section.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public Stream<Task> getTasks(Project project) {
+        try {
+            return collectionRequestToStream(
+                    client.tasks.getTasksForProject(project.gid, null, null, null, getSerializedFieldNames(Task.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public Stream<Task> getTasks(Tag tag) {
+        try {
+            return collectionRequestToStream(
+                    client.tasks.getTasksForTag(tag.gid, null, null, getSerializedFieldNames(Task.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public Stream<Task> getTasks(Section section) {
+        try {
+            return collectionRequestToStream(
+                    client.tasks.getTasksForSection(section.gid, null, null, getSerializedFieldNames(Task.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public Stream<Tag> getTags() {
+        try {
+            return collectionRequestToStream(
+                    client.tags.getTags(workspace.gid, null, null, getSerializedFieldNames(Tag.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public Stream<ProjectStatus> getProjectStatusUpdates(Project project) {
+        try {
+            return collectionRequestToStream(
+                    client.projectStatuses.getProjectStatusesForProject(project.gid, null, null, getSerializedFieldNames(ProjectStatus.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public Stream<Story> getStories(Task task) {
+        try {
+            return collectionRequestToStream(
+                    client.stories.getStoriesForTask(task.gid, null, null, getSerializedFieldNames(Story.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public Stream<Attachment> getAttachments(Task task) {
+        try {
+            return collectionRequestToStream(
+                    client.attachments.getAttachmentsForObject(task.gid, null, null, getSerializedFieldNames(Attachment.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    @Override
+    public Stream<Attachment> getAttachments(ProjectStatus projectStatus) {
+        try {
+            return collectionRequestToStream(
+                    client.attachments.getAttachmentsForObject(projectStatus.gid, null, null, getSerializedFieldNames(Attachment.class), false)
+            );
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    private Workspace getWorkspaceByName(String workspaceName) {
+        List<Workspace> results;
+        try {
+            results = collectionRequestToStream(client.workspaces.getWorkspaces(null, null, getSerializedFieldNames(Workspace.class), false))
+                    .filter(w -> w.name.equals(workspaceName))
+                    .collect(Collectors.toList());
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+
+        if (results.isEmpty()) {
+            throw new AsanaClientException("No such workspace: " + workspaceName);
+        } else if (results.size() > 1) {
+            throw new AsanaClientException("Multiple workspaces match: " + workspaceName);
+        }
+        return results.get(0);
+    }
+
+    @Override
+    public AsanaEventsCollection getEvents(Project project, String syncToken) {
+        try {
+            String resultSyncToken;
+            List<Event> resultEvents = new ArrayList<>();
+            try {
+                EventsRequest<Event> request = client.events.get(project.gid, syncToken);
+                ResultBodyCollection<Event> result = request.executeRaw();
+
+                resultSyncToken = result.sync;
+                resultEvents = result.data;
+            } catch (InvalidTokenError e) {
+                resultSyncToken = e.sync;
+            }
+
+            return new AsanaEventsCollection(resultSyncToken, resultEvents);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    private <T> List<String> getSerializedFieldNames(Class<T> cls) {
+        List<String> result = new ArrayList<>();
+        for (Field field : cls.getFields()) {
+            SerializedName serializedName = field.getAnnotation(SerializedName.class);
+            if (serializedName != null) {
+                result.add(serializedName.value());
+            } else {
+                result.add(field.getName());
+            }
+        }
+        return result;
+    }
+
+    private static <T extends Resource> Stream<T> collectionRequestToStream(CollectionRequest<T> asanaCollectionRequest) {
+        return StreamSupport.stream(asanaCollectionRequest.spliterator(), false);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/main/java/org/apache/nifi/controller/asana/StandardAsanaClientProviderService.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/main/java/org/apache/nifi/controller/asana/StandardAsanaClientProviderService.java
new file mode 100644
index 0000000000..efe4b804fa
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/main/java/org/apache/nifi/controller/asana/StandardAsanaClientProviderService.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.controller.asana;
+
+import static org.apache.nifi.controller.asana.StandardAsanaClient.ASANA_CLIENT_OPTION_BASE_URL;
+
+import com.asana.Client;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.annotation.lifecycle.OnEnabled;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.controller.AbstractControllerService;
+import org.apache.nifi.controller.ConfigurationContext;
+import org.apache.nifi.processor.util.StandardValidators;
+
+@CapabilityDescription("Common service to authenticate with Asana, and to work on a specified workspace.")
+@Tags({"asana", "service", "authentication"})
+public class StandardAsanaClientProviderService extends AbstractControllerService implements AsanaClientProviderService {
+
+    protected static final String ASANA_API_URL = "asana-api-url";
+    protected static final String ASANA_PERSONAL_ACCESS_TOKEN = "asana-personal-access-token";
+    protected static final String ASANA_WORKSPACE_NAME = "asana-workspace-name";
+
+    protected static final PropertyDescriptor PROP_ASANA_API_BASE_URL = new PropertyDescriptor.Builder()
+            .name(ASANA_API_URL)
+            .displayName("API URL")
+            .description("Base URL of Asana API. Leave it as default, unless you have your own Asana instance "
+                    + "serving on a different URL. (typical for on-premise installations)")
+            .required(true)
+            .defaultValue(Client.DEFAULTS.get(ASANA_CLIENT_OPTION_BASE_URL).toString())
+            .addValidator(StandardValidators.URL_VALIDATOR)
+            .build();
+
+    protected static final PropertyDescriptor PROP_ASANA_PERSONAL_ACCESS_TOKEN = new PropertyDescriptor.Builder()
+            .name(ASANA_PERSONAL_ACCESS_TOKEN)
+            .displayName("Personal Access Token")
+            .description("Similarly to entering your username/password into a website, when you access "
+                    + "your Asana data via the API you need to authenticate. Personal Access Token (PAT) "
+                    + "is an authentication mechanism for accessing the API. You can generate a PAT from "
+                    + "the Asana developer console. Refer to Asana Authentication Quick Start for detailed "
+                    + "instructions on getting started.")
+            .required(true)
+            .sensitive(true)
+            .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
+            .build();
+
+    protected static final PropertyDescriptor PROP_ASANA_WORKSPACE_NAME = new PropertyDescriptor.Builder()
+            .name(ASANA_WORKSPACE_NAME)
+            .displayName("Workspace")
+            .description("Specify which Asana workspace to use. Case sensitive. "
+                    + "A workspace is the highest-level organizational unit in Asana. All projects and tasks "
+                    + "have an associated workspace. An organization is a special kind of workspace that "
+                    + "represents a company. In an organization, you can group your projects into teams.")
+            .required(true)
+            .addValidator(StandardValidators.NON_BLANK_VALIDATOR)
+            .build();
+
+    protected static final List<PropertyDescriptor> DESCRIPTORS = Collections.unmodifiableList(Arrays.asList(
+            PROP_ASANA_API_BASE_URL,
+            PROP_ASANA_PERSONAL_ACCESS_TOKEN,
+            PROP_ASANA_WORKSPACE_NAME
+    ));
+
+    private volatile String personalAccessToken;
+    private volatile String workspaceName;
+    private volatile String baseUrl;
+
+    @Override
+    public List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return DESCRIPTORS;
+    }
+
+    @OnEnabled
+    public synchronized void onEnabled(final ConfigurationContext context) {
+        personalAccessToken = context.getProperty(PROP_ASANA_PERSONAL_ACCESS_TOKEN).getValue();
+        workspaceName = context.getProperty(PROP_ASANA_WORKSPACE_NAME).getValue();
+        baseUrl = context.getProperty(PROP_ASANA_API_BASE_URL).getValue();
+    }
+
+    @Override
+    public synchronized AsanaClient createClient() {
+        return new StandardAsanaClient(personalAccessToken, workspaceName, baseUrl);
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
new file mode 100644
index 0000000000..386d4116f5
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/main/resources/META-INF/services/org.apache.nifi.controller.ControllerService
@@ -0,0 +1,15 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+org.apache.nifi.controller.asana.StandardAsanaClientProviderService
\ No newline at end of file
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/main/resources/docs/org.apache.nifi.controller.asana.AsanaClientService/additionalDetails.html b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/main/resources/docs/org.apache.nifi.controller.asana.AsanaClientService/additionalDetails.html
new file mode 100644
index 0000000000..bc82e9091b
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/main/resources/docs/org.apache.nifi.controller.asana.AsanaClientService/additionalDetails.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<html lang="en" xmlns="http://www.w3.org/1999/html">
+<!--
+      Licensed to the Apache Software Foundation (ASF) under one or more
+      contributor license agreements.  See the NOTICE file distributed with
+      this work for additional information regarding copyright ownership.
+      The ASF licenses this file to You under the Apache License, Version 2.0
+      (the "License"); you may not use this file except in compliance with
+      the License.  You may obtain a copy of the License at
+          http://www.apache.org/licenses/LICENSE-2.0
+      Unless required by applicable law or agreed to in writing, software
+      distributed under the License is distributed on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+      See the License for the specific language governing permissions and
+      limitations under the License.
+    -->
+
+<head>
+    <meta charset="utf-8"/>
+    <title>AsanaClientService</title>
+    <link rel="stylesheet" href="../../../../../css/component-usage.css" type="text/css"/>
+    <style>
+        h2 {margin-top: 4em}
+        h3 {margin-top: 3em}
+        td {text-align: left}
+    </style>
+</head>
+
+<body>
+
+<h1>AsanaClientService</h1>
+
+<h3>Description</h3>
+<p>
+    This service manages credentials to Asana, and provides a common API for the processors to work on
+    the user specified workspace (or organization).
+</p>
+
+<h3>Using a custom Asana instance</h3>
+<p>
+    If you have an Asana instance running on your custom domain, then you need to specify the <em>API URL</em>
+    of that instance. For example: <code>https://asana.example.com/api/1.0</code>
+</p>
+
+<h3>Authentication</h3>
+<p>
+    Asana supports a few methods of authenticating with the API. Simple cases are usually handled with a
+    personal access token, while multi-user apps utilize OAuth.<br />
+    <br />
+    Asana provides three ways to authenticate:
+    <ul>
+        <li><a href="https://developers.asana.com/docs/oauth">OAuth</a></li>
+        <li><a href="https://developers.asana.com/docs/personal-access-token">Personal access token (PAT)</a></li>
+        <li><a href="https://developers.asana.com/docs/openid-connect">OpenID Connect</a></li>
+    </ul>
+    <br />
+    <strong>Note:</strong> This service currently only supports <em>Personal access tokens</em> as authentication method.</br>
+    <br />
+    Personal access tokens (PATs) are a useful mechanism for accessing the API in scenarios where OAuth would
+    be considered overkill, such as access from the command line and personal scripts or applications. A user
+    can create many, but not unlimited, personal access tokens. When creating a token, you must give it a
+    description to help you remember what you created the token for.<br />
+    Remember to keep your tokens secret and treat them just like passwords. Your tokens act on your behalf when interacting with the API.<br />
+    <br />
+    You can generate a personal access token from the <a href="https://app.asana.com/0/developer-console">Asana developer console</a>.
+    See the <a href="https://developers.asana.com/docs/authentication-quick-start">Authentication quick start</a> for detailed
+    instructions on getting started with PATs.
+</p>
+
+<h3>Workspaces & Organizations</h3>
+<p>
+    A <em>workspace</em> is the highest-level organizational unit in Asana. All projects and tasks have an associated workspace.<br />
+    <br />
+    An <em>organization</em> is a special kind of <em>workspace</em> that represents a company. In an organization, you can group your<br />
+    projects into teams. You can read more about how organizations work on the <a href="https://asana.com/guide/help/workspaces/basics">Asana Guide</a>.<br />
+    <br />
+    You can read more about how objects are organized in Asana in the <a href="https://developers.asana.com/docs/object-hierarchy">developer guide</a>.
+</p>
+
+<h3>Further reading about Asana</h3>
+<p>
+<ul>
+    <li><a href="https://academy.asana.com">Asana Academy</a></li>
+    <li><a href="https://asana.com/guide">Asana Guide</a></li>
+    <li><a href="https://developers.asana.com/docs">Asana Developer Documentation</a></li>
+    <li><a href="https://github.com/Asana/java-asana/">Java client library for the Asana API</a></li>
+</ul>
+</p>
+
+</body>
+</html>
diff --git a/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/test/java/org/apache/nifi/controller/asana/StandardAsanaClientProviderServiceTest.java b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/test/java/org/apache/nifi/controller/asana/StandardAsanaClientProviderServiceTest.java
new file mode 100644
index 0000000000..6b0881196d
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/nifi-asana-services/src/test/java/org/apache/nifi/controller/asana/StandardAsanaClientProviderServiceTest.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.controller.asana;
+
+import static java.util.stream.Collectors.toMap;
+import static org.apache.nifi.controller.asana.StandardAsanaClientProviderService.PROP_ASANA_API_BASE_URL;
+import static org.apache.nifi.controller.asana.StandardAsanaClientProviderService.PROP_ASANA_PERSONAL_ACCESS_TOKEN;
+import static org.apache.nifi.controller.asana.StandardAsanaClientProviderService.PROP_ASANA_WORKSPACE_NAME;
+import static org.apache.nifi.util.TestRunners.newTestRunner;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.asana.models.Project;
+import java.io.IOException;
+import java.util.Map;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import org.apache.nifi.reporting.InitializationException;
+import org.apache.nifi.util.NoOpProcessor;
+import org.apache.nifi.util.StringUtils;
+import org.apache.nifi.util.TestRunner;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class StandardAsanaClientProviderServiceTest {
+
+    private static final String LOCALHOST = "localhost";
+
+    private static final String WORKSPACES = "{\n" +
+            "  \"data\": [\n" +
+            "    {\n" +
+            "      \"gid\": \"1202898619267352\",\n" +
+            "      \"name\": \"My Workspace\",\n" +
+            "      \"resource_type\": \"workspace\"\n" +
+            "    },\n" +
+            "    {\n" +
+            "      \"gid\": \"1202939205399549\",\n" +
+            "      \"name\": \"Company or Team Name\",\n" +
+            "      \"resource_type\": \"workspace\"\n" +
+            "    },\n" +
+            "    {\n" +
+            "      \"gid\": \"1202946450806837\",\n" +
+            "      \"name\": \"Company or Team Name\",\n" +
+            "      \"resource_type\": \"workspace\"\n" +
+            "    }\n" +
+            "  ],\n" +
+            "  \"next_page\": null\n" +
+            "}";
+
+    private static final String PROJECTS = "{\n" +
+            "  \"data\": [\n" +
+            "    {\n" +
+            "      \"gid\": \"1202898619637000\",\n" +
+            "      \"name\": \"Our First Project\",\n" +
+            "      \"resource_type\": \"project\"\n" +
+            "    },\n" +
+            "    {\n" +
+            "      \"gid\": \"1202986168388325\",\n" +
+            "      \"name\": \"Another Project Again\",\n" +
+            "      \"resource_type\": \"project\"\n" +
+            "    }\n" +
+            "  ],\n" +
+            "  \"next_page\": null\n" +
+            "}";
+
+    private TestRunner runner;
+    private StandardAsanaClientProviderService service;
+    private MockWebServer mockWebServer;
+
+    @BeforeEach
+    public void init() throws InitializationException {
+        runner = newTestRunner(NoOpProcessor.class);
+        service = new StandardAsanaClientProviderService();
+        runner.addControllerService(AsanaClientProviderService.class.getName(), service);
+        mockWebServer = new MockWebServer();
+    }
+
+    @AfterEach
+    public void shutdownServer() throws IOException {
+        mockWebServer.shutdown();
+    }
+
+    @Test
+    public void testInvalidIfNoAccessTokenProvided() {
+        runner.assertNotValid(service);
+    }
+
+    @Test
+    public void testInvalidWithAccessTokenButNoWorkspace() {
+        runner.setProperty(service, PROP_ASANA_PERSONAL_ACCESS_TOKEN, "12345");
+        runner.assertNotValid(service);
+    }
+
+    @Test
+    public void testInvalidWithWorkspaceButNoAccessToken() {
+        runner.setProperty(service, PROP_ASANA_WORKSPACE_NAME, "My Workspace");
+        runner.assertNotValid(service);
+    }
+
+    @Test
+    public void testValidWithAccessTokenAndWorkspaceProvided() {
+        runner.setProperty(service, PROP_ASANA_PERSONAL_ACCESS_TOKEN, "12345");
+        runner.setProperty(service, PROP_ASANA_WORKSPACE_NAME, "My Workspace");
+        runner.assertValid(service);
+    }
+
+    @Test
+    public void testInvalidWithIncorrectApiUrlFormat() {
+        runner.setProperty(service, PROP_ASANA_PERSONAL_ACCESS_TOKEN, "12345");
+        runner.setProperty(service, PROP_ASANA_WORKSPACE_NAME, "My Workspace");
+        runner.setProperty(service, PROP_ASANA_API_BASE_URL, "Foo::Bar::1234");
+        runner.assertNotValid(service);
+    }
+
+    @Test
+    public void testInvalidWithEmptyUrl() {
+        runner.setProperty(service, PROP_ASANA_PERSONAL_ACCESS_TOKEN, "12345");
+        runner.setProperty(service, PROP_ASANA_WORKSPACE_NAME, "My Workspace");
+        runner.setProperty(service, PROP_ASANA_API_BASE_URL, StringUtils.EMPTY);
+        runner.assertNotValid(service);
+    }
+
+    @Test
+    public void testClientCreatedWithConfiguredApiEndpoint() throws InterruptedException {
+        runner.setProperty(service, PROP_ASANA_PERSONAL_ACCESS_TOKEN, "12345");
+        runner.setProperty(service, PROP_ASANA_WORKSPACE_NAME, "My Workspace");
+        runner.setProperty(service, PROP_ASANA_API_BASE_URL, getMockWebServerUrl());
+
+        mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(WORKSPACES));
+        mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(PROJECTS));
+
+        runner.enableControllerService(service);
+        final Map<String, Project> projects = service.createClient().getProjects()
+                .collect(toMap(project -> project.gid, project -> project));
+
+        assertEquals(2, projects.size());
+        assertEquals("Our First Project", projects.get("1202898619637000").name);
+        assertEquals("Another Project Again", projects.get("1202986168388325").name);
+
+        assertEquals(2, mockWebServer.getRequestCount());
+        assertEquals("Bearer 12345", mockWebServer.takeRequest().getHeader("Authorization"));
+        assertTrue(mockWebServer.takeRequest().getRequestLine().contains("workspace=1202898619267352"));
+    }
+
+    @Test
+    public void testCreateClientThrowsIfMultipleWorkspacesExistWithSameName() {
+        runner.setProperty(service, PROP_ASANA_PERSONAL_ACCESS_TOKEN, "12345");
+        runner.setProperty(service, PROP_ASANA_WORKSPACE_NAME, "Company or Team Name");
+        runner.setProperty(service, PROP_ASANA_API_BASE_URL, getMockWebServerUrl());
+
+        mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(WORKSPACES));
+        mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(PROJECTS));
+
+        runner.enableControllerService(service);
+        assertThrows(RuntimeException.class, service::createClient);
+    }
+
+    private String getMockWebServerUrl() {
+        return mockWebServer.url("asana").newBuilder().host(LOCALHOST).build().toString();
+    }
+}
diff --git a/nifi-nar-bundles/nifi-asana-bundle/pom.xml b/nifi-nar-bundles/nifi-asana-bundle/pom.xml
new file mode 100644
index 0000000000..03bd423926
--- /dev/null
+++ b/nifi-nar-bundles/nifi-asana-bundle/pom.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements. See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License. You may obtain a copy of the License at
+  http://www.apache.org/licenses/LICENSE-2.0
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.apache.nifi</groupId>
+        <artifactId>nifi-nar-bundles</artifactId>
+        <version>1.20.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>nifi-asana-bundle</artifactId>
+    <packaging>pom</packaging>
+
+    <modules>
+        <module>nifi-asana-processors</module>
+        <module>nifi-asana-processors-nar</module>
+        <module>nifi-asana-services</module>
+        <module>nifi-asana-services-nar</module>
+        <module>nifi-asana-services-api</module>
+        <module>nifi-asana-services-api-nar</module>
+    </modules>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.apache.nifi</groupId>
+                <artifactId>nifi-utils</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.nifi</groupId>
+                <artifactId>nifi-asana-processors</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.nifi</groupId>
+                <artifactId>nifi-asana-services</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.nifi</groupId>
+                <artifactId>nifi-asana-services-api</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>org.apache.commons</groupId>
+                <artifactId>commons-collections4</artifactId>
+                <version>4.4</version>
+            </dependency>
+            <dependency>
+                <groupId>com.asana</groupId>
+                <artifactId>asana</artifactId>
+                <version>1.0.0</version>
+                <exclusions>
+                    <exclusion>
+                        <groupId>commons-logging</groupId>
+                        <artifactId>commons-logging</artifactId>
+                    </exclusion>
+                </exclusions>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+</project>
diff --git a/nifi-nar-bundles/pom.xml b/nifi-nar-bundles/pom.xml
index acd761d8f0..034c68ccab 100755
--- a/nifi-nar-bundles/pom.xml
+++ b/nifi-nar-bundles/pom.xml
@@ -48,6 +48,7 @@
         <module>nifi-flume-bundle</module>
         <module>nifi-hbase-bundle</module>
         <module>nifi-ambari-bundle</module>
+        <module>nifi-asana-bundle</module>
         <module>nifi-media-bundle</module>
         <module>nifi-avro-bundle</module>
         <module>nifi-couchbase-bundle</module>