NIFIREG-211 Initial work for adding extenion bundles to NiFi Registry

- Setting up DB tables and entities for extensions
- Updated MetadataService and DatabaseMetadataService with new methods for extension entities
- Added ON DELETE CASCADE to existing tables and simplified delete logic for buckets and flows
- Created data model for extension bundles and mapping to/from DB entities
- Created ExtensionBundleExtractor with an implemenetation for NARs
- Setup LinkService and LinkBuilder for extension bundles
- Setup pluggable persistence provider for extension bundles and implemented a local file-system provider.
- Refactored LinkService and add links for all extension related items
- Changed extension service to write out bundles to a temp directory before extracting and passing to persistence provider
- Implemented multi-part form upload for extensions bundles
- Upgraded to spring-boot 2.1.0
- Added SHA-256 checksums for bundle versions
- Initial client methods for uploading and retrieving bundles
- Configuring NiFi Registry Jersey client to use chunked entity processing so we don't load the entire bundle content into memory during an upload
- Added event publishing for extension bundles
- Add an adapter for serializing ExtensionBundleType enum
- Remove capitalize class from droplet grid item
- Add ability for clients to optionally specify the SHA-256 when uploading a bundle

This closes #148.

Signed-off-by: Kevin Doran <kdoran@apache.org>
diff --git a/nifi-registry-assembly/pom.xml b/nifi-registry-assembly/pom.xml
index 91dad51..f1cc1ac 100644
--- a/nifi-registry-assembly/pom.xml
+++ b/nifi-registry-assembly/pom.xml
@@ -160,6 +160,9 @@
         <!-- nifi-registry.properties: provider properties -->
         <nifi.registry.providers.configuration.file>./conf/providers.xml</nifi.registry.providers.configuration.file>
 
+        <!-- nifi-registry.properties: extension properties -->
+        <nifi.registry.extensions.working.directory>./work/extensions</nifi.registry.extensions.working.directory>
+
         <!-- nifi-registry.properties: legacy database properties, used to migrate data from old DB to the new DB below -->
         <nifi.registry.db.directory />
         <nifi.registry.db.url.append />
diff --git a/nifi-registry-core/nifi-registry-bundle-utils/pom.xml b/nifi-registry-core/nifi-registry-bundle-utils/pom.xml
new file mode 100644
index 0000000..670331e
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-bundle-utils/pom.xml
@@ -0,0 +1,27 @@
+<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.apache.nifi.registry</groupId>
+        <artifactId>nifi-registry-core</artifactId>
+        <version>0.4.0-SNAPSHOT</version>
+    </parent>
+    
+    <artifactId>nifi-registry-bundle-utils</artifactId>
+    <packaging>jar</packaging>
+
+    <dependencies>
+
+    </dependencies>
+
+</project>
diff --git a/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/BundleCoordinate.java b/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/BundleCoordinate.java
new file mode 100644
index 0000000..96ce5ea
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/BundleCoordinate.java
@@ -0,0 +1,87 @@
+/*
+ * 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.registry.extension;
+
+/**
+ * The coordinate of an extension bundle (i.e group + artifact + version).
+ */
+public class BundleCoordinate {
+
+    private final String groupId;
+    private final String artifactId;
+    private final String version;
+
+    private final String coordinate;
+
+
+    public BundleCoordinate(final String groupId, final String artifactId, final String version) {
+        this.groupId = groupId;
+        this.artifactId = artifactId;
+        this.version = version;
+
+        if (isBlank(this.groupId) || isBlank(this.artifactId) || isBlank(this.version)) {
+            throw new IllegalStateException("Group, Id, and Version are required for BundleCoordinate");
+        }
+
+        this.coordinate = this.groupId + ":" + this.artifactId + ":" + this.version;
+    }
+
+    private boolean isBlank(String str) {
+        return str == null || str.trim().length() == 0;
+    }
+
+    public String getGroupId() {
+        return groupId;
+    }
+
+    public String getArtifactId() {
+        return artifactId;
+    }
+
+    public String getVersion() {
+        return version;
+    }
+
+    public final String getCoordinate() {
+        return coordinate;
+    }
+
+    @Override
+    public String toString() {
+        return coordinate;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+
+        if (!(obj instanceof BundleCoordinate)) {
+            return false;
+        }
+
+        final BundleCoordinate other = (BundleCoordinate) obj;
+        return getCoordinate().equals(other.getCoordinate());
+    }
+
+    @Override
+    public int hashCode() {
+        return 37 * this.coordinate.hashCode();
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/BundleDetails.java b/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/BundleDetails.java
new file mode 100644
index 0000000..88ec469
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/BundleDetails.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.registry.extension;
+
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+public class BundleDetails {
+
+    private final BundleCoordinate bundleCoordinate;
+
+    // Can be null when there is no dependent bundle
+    private final Set<BundleCoordinate> dependencyBundleCoordinates;
+
+    private BundleDetails(final Builder builder) {
+        this.bundleCoordinate = builder.bundleCoordinate;
+        this.dependencyBundleCoordinates = Collections.unmodifiableSet(new HashSet<>(builder.dependencyBundleCoordinates));
+        if (this.bundleCoordinate == null) {
+            throw new IllegalStateException("A bundle coordinate is required");
+        }
+    }
+
+    public BundleCoordinate getBundleCoordinate() {
+        return bundleCoordinate;
+    }
+
+    public Set<BundleCoordinate> getDependencyBundleCoordinates() {
+        return dependencyBundleCoordinates;
+    }
+
+    /**
+     * Builder for creating instances of BundleDetails.
+     */
+    public static class Builder {
+
+        private BundleCoordinate bundleCoordinate;
+        private Set<BundleCoordinate> dependencyBundleCoordinates = new HashSet<>();
+
+        public Builder coordinate(final BundleCoordinate bundleCoordinate) {
+            this.bundleCoordinate = bundleCoordinate;
+            return this;
+        }
+
+        public Builder dependencyCoordinate(final BundleCoordinate dependencyCoordinate) {
+            if (dependencyCoordinate != null) {
+                this.dependencyBundleCoordinates.add(dependencyCoordinate);
+            }
+            return this;
+        }
+
+        public BundleDetails build() {
+            return new BundleDetails(this);
+        }
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java b/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/BundleExtractor.java
similarity index 62%
copy from nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java
copy to nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/BundleExtractor.java
index ec356fd..771c632 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java
+++ b/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/BundleExtractor.java
@@ -14,17 +14,21 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.registry.web.link.builder;
+package org.apache.nifi.registry.extension;
 
-import javax.ws.rs.core.Link;
+import java.io.IOException;
+import java.io.InputStream;
 
 /**
- * Creates a Link for a given type.
- *
- * @param <T> the type to create a link for
+ * Extracts the bundle metadata from the given InputStream.
  */
-public interface LinkBuilder<T> {
+public interface BundleExtractor {
 
-    Link createLink(T t);
+    /**
+     * @param inputStream the input stream of the binary bundle
+     * @return the bundle metadata extracted from the input stream
+     * @throws IOException if an error occurs reading from the InputStream
+     */
+    BundleDetails extract(InputStream inputStream) throws IOException;
 
 }
diff --git a/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/minificpp/MiNiFiCppBundleExtractor.java b/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/minificpp/MiNiFiCppBundleExtractor.java
new file mode 100644
index 0000000..ba0eb68
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/minificpp/MiNiFiCppBundleExtractor.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.registry.extension.minificpp;
+
+import org.apache.nifi.registry.extension.BundleDetails;
+import org.apache.nifi.registry.extension.BundleExtractor;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * ExtensionBundleExtractor for MiNiFi CPP extensions.
+ */
+public class MiNiFiCppBundleExtractor implements BundleExtractor {
+
+    @Override
+    public BundleDetails extract(final InputStream inputStream) throws IOException {
+        // TODO implement
+        throw new UnsupportedOperationException("Minifi CPP extensions are not yet supported");
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/nar/NarBundleExtractor.java b/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/nar/NarBundleExtractor.java
new file mode 100644
index 0000000..d9cfe71
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/nar/NarBundleExtractor.java
@@ -0,0 +1,77 @@
+/*
+ * 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.registry.extension.nar;
+
+import org.apache.nifi.registry.extension.BundleCoordinate;
+import org.apache.nifi.registry.extension.BundleDetails;
+import org.apache.nifi.registry.extension.BundleExtractor;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.jar.Attributes;
+import java.util.jar.JarInputStream;
+import java.util.jar.Manifest;
+
+/**
+ * Implementation of ExtensionBundleExtractor for NAR bundles.
+ */
+public class NarBundleExtractor implements BundleExtractor {
+
+    @Override
+    public BundleDetails extract(final InputStream inputStream) throws IOException {
+        try (final JarInputStream jarInputStream = new JarInputStream(inputStream)) {
+            final Manifest manifest = jarInputStream.getManifest();
+            if (manifest == null) {
+                throw new IllegalArgumentException("NAR bundles must contain a valid MANIFEST");
+            }
+
+            final Attributes attributes = manifest.getMainAttributes();
+
+            final String groupId = attributes.getValue(NarManifestEntry.NAR_GROUP.getManifestName());
+            final String artifactId = attributes.getValue(NarManifestEntry.NAR_ID.getManifestName());
+            final String version = attributes.getValue(NarManifestEntry.NAR_VERSION.getManifestName());
+
+            final BundleCoordinate bundleCoordinate = new BundleCoordinate(groupId, artifactId, version);
+
+            final String dependencyGroupId = attributes.getValue(NarManifestEntry.NAR_DEPENDENCY_GROUP.getManifestName());
+            final String dependencyArtifactId = attributes.getValue(NarManifestEntry.NAR_DEPENDENCY_ID.getManifestName());
+            final String dependencyVersion = attributes.getValue(NarManifestEntry.NAR_DEPENDENCY_VERSION.getManifestName());
+
+            final BundleCoordinate dependencyCoordinate;
+            if (dependencyArtifactId != null) {
+                dependencyCoordinate = new BundleCoordinate(dependencyGroupId, dependencyArtifactId, dependencyVersion);
+            } else {
+                dependencyCoordinate = null;
+            }
+
+            // TODO figure out what to do with build info
+            final String buildBranch = attributes.getValue(NarManifestEntry.BUILD_BRANCH.getManifestName());
+            final String buildTag = attributes.getValue(NarManifestEntry.BUILD_TAG.getManifestName());
+            final String buildRevision = attributes.getValue(NarManifestEntry.BUILD_REVISION.getManifestName());
+            final String buildTimestamp = attributes.getValue(NarManifestEntry.BUILD_TIMESTAMP.getManifestName());
+            final String buildJdk = attributes.getValue(NarManifestEntry.BUILD_JDK.getManifestName());
+            final String builtBy = attributes.getValue(NarManifestEntry.BUILT_BY.getManifestName());
+
+            final BundleDetails.Builder builder = new BundleDetails.Builder()
+                    .coordinate(bundleCoordinate)
+                    .dependencyCoordinate(dependencyCoordinate);
+
+            return builder.build();
+        }
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/nar/NarManifestEntry.java b/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/nar/NarManifestEntry.java
new file mode 100644
index 0000000..0c75ed3
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-bundle-utils/src/main/java/org/apache/nifi/registry/extension/nar/NarManifestEntry.java
@@ -0,0 +1,48 @@
+/*
+ * 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.registry.extension.nar;
+
+/**
+ * Enumeration of entries that will be in a NAR MANIFEST file.
+ */
+public enum NarManifestEntry {
+
+    NAR_GROUP("Nar-Group"),
+    NAR_ID("Nar-Id"),
+    NAR_VERSION("Nar-Version"),
+    NAR_DEPENDENCY_GROUP("Nar-Dependency-Group"),
+    NAR_DEPENDENCY_ID("Nar-Dependency-Id"),
+    NAR_DEPENDENCY_VERSION("Nar-Dependency-Version"),
+    BUILD_TAG("Build-Tag"),
+    BUILD_REVISION("Build-Revision"),
+    BUILD_BRANCH("Build-Branch"),
+    BUILD_TIMESTAMP("Build-Timestamp"),
+    BUILD_JDK("Build-Jdk"),
+    BUILT_BY("Built-By"),
+    ;
+
+    final String manifestName;
+
+    NarManifestEntry(String manifestName) {
+        this.manifestName = manifestName;
+    }
+
+    public String getManifestName() {
+        return manifestName;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-bundle-utils/src/test/java/org/apache/nifi/registry/extension/TestNarBundleExtractor.java b/nifi-registry-core/nifi-registry-bundle-utils/src/test/java/org/apache/nifi/registry/extension/TestNarBundleExtractor.java
new file mode 100644
index 0000000..306f1d1
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-bundle-utils/src/test/java/org/apache/nifi/registry/extension/TestNarBundleExtractor.java
@@ -0,0 +1,93 @@
+/*
+ * 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.registry.extension;
+
+import org.apache.nifi.registry.extension.nar.NarBundleExtractor;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.fail;
+
+public class TestNarBundleExtractor {
+
+    private BundleExtractor extractor;
+
+    @Before
+    public void setup() {
+        this.extractor = new NarBundleExtractor();
+    }
+
+    @Test
+    public void testExtractFromGoodNarNoDependencies() throws IOException {
+        try (final InputStream in = new FileInputStream("src/test/resources/nars/nifi-framework-nar.nar")) {
+            final BundleDetails bundleDetails = extractor.extract(in);
+            assertNotNull(bundleDetails);
+            assertNotNull(bundleDetails.getBundleCoordinate());
+            assertNotNull(bundleDetails.getDependencyBundleCoordinates());
+            assertEquals(0, bundleDetails.getDependencyBundleCoordinates().size());
+
+            final BundleCoordinate bundleCoordinate = bundleDetails.getBundleCoordinate();
+            assertEquals("org.apache.nifi", bundleCoordinate.getGroupId());
+            assertEquals("nifi-framework-nar", bundleCoordinate.getArtifactId());
+            assertEquals("1.8.0", bundleCoordinate.getVersion());
+        }
+    }
+
+    @Test
+    public void testExtractFromGoodNarWithDependencies() throws IOException {
+        try (final InputStream in = new FileInputStream("src/test/resources/nars/nifi-foo-nar.nar")) {
+            final BundleDetails bundleDetails = extractor.extract(in);
+            assertNotNull(bundleDetails);
+            assertNotNull(bundleDetails.getBundleCoordinate());
+            assertNotNull(bundleDetails.getDependencyBundleCoordinates());
+            assertEquals(1, bundleDetails.getDependencyBundleCoordinates().size());
+
+            final BundleCoordinate bundleCoordinate = bundleDetails.getBundleCoordinate();
+            assertEquals("org.apache.nifi", bundleCoordinate.getGroupId());
+            assertEquals("nifi-foo-nar", bundleCoordinate.getArtifactId());
+            assertEquals("1.8.0", bundleCoordinate.getVersion());
+
+            final BundleCoordinate dependencyCoordinate = bundleDetails.getDependencyBundleCoordinates().stream().findFirst().get();
+            assertEquals("org.apache.nifi", dependencyCoordinate.getGroupId());
+            assertEquals("nifi-bar-nar", dependencyCoordinate.getArtifactId());
+            assertEquals("2.0.0", dependencyCoordinate.getVersion());
+        }
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testExtractFromNarMissingRequiredManifestEntries() throws IOException {
+        try (final InputStream in = new FileInputStream("src/test/resources/nars/nifi-missing-manifest-entries.nar")) {
+            extractor.extract(in);
+            fail("Should have thrown exception");
+        }
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testExtractFromNarMissingManifest() throws IOException {
+        try (final InputStream in = new FileInputStream("src/test/resources/nars/nifi-missing-manifest.nar")) {
+            extractor.extract(in);
+            fail("Should have thrown exception");
+        }
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-bundle-utils/src/test/resources/nars/nifi-foo-nar.nar b/nifi-registry-core/nifi-registry-bundle-utils/src/test/resources/nars/nifi-foo-nar.nar
new file mode 100644
index 0000000..e33ee88
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-bundle-utils/src/test/resources/nars/nifi-foo-nar.nar
Binary files differ
diff --git a/nifi-registry-core/nifi-registry-bundle-utils/src/test/resources/nars/nifi-framework-nar.nar b/nifi-registry-core/nifi-registry-bundle-utils/src/test/resources/nars/nifi-framework-nar.nar
new file mode 100644
index 0000000..0d0319b
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-bundle-utils/src/test/resources/nars/nifi-framework-nar.nar
Binary files differ
diff --git a/nifi-registry-core/nifi-registry-bundle-utils/src/test/resources/nars/nifi-missing-manifest-entries.nar b/nifi-registry-core/nifi-registry-bundle-utils/src/test/resources/nars/nifi-missing-manifest-entries.nar
new file mode 100644
index 0000000..22b8d12
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-bundle-utils/src/test/resources/nars/nifi-missing-manifest-entries.nar
Binary files differ
diff --git a/nifi-registry-core/nifi-registry-bundle-utils/src/test/resources/nars/nifi-missing-manifest.nar b/nifi-registry-core/nifi-registry-bundle-utils/src/test/resources/nars/nifi-missing-manifest.nar
new file mode 100644
index 0000000..bc930c8
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-bundle-utils/src/test/resources/nars/nifi-missing-manifest.nar
Binary files differ
diff --git a/nifi-registry-core/nifi-registry-client/pom.xml b/nifi-registry-core/nifi-registry-client/pom.xml
index 5eb894b..6021d11 100644
--- a/nifi-registry-core/nifi-registry-client/pom.xml
+++ b/nifi-registry-core/nifi-registry-client/pom.xml
@@ -52,6 +52,11 @@
             <version>${jersey.version}</version>
         </dependency>
         <dependency>
+            <groupId>org.glassfish.jersey.media</groupId>
+            <artifactId>jersey-media-multipart</artifactId>
+            <version>${jersey.version}</version>
+        </dependency>
+        <dependency>
             <groupId>org.slf4j</groupId>
             <artifactId>slf4j-simple</artifactId>
             <version>${org.slf4j.version}</version>
diff --git a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionBundleClient.java b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionBundleClient.java
new file mode 100644
index 0000000..32eb3c9
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionBundleClient.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.registry.client;
+
+import org.apache.nifi.registry.extension.ExtensionBundle;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Client for interacting with extension bundles.
+ */
+public interface ExtensionBundleClient {
+
+    /**
+     * Retrieves all extension bundles located in buckets the current user is authorized for.
+     *
+     * @return the list of extension bundles
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    List<ExtensionBundle> getAll() throws IOException, NiFiRegistryException;
+
+    /**
+     * Retrieves the extension bundles located in the given bucket.
+     *
+     * @param bucketId the bucket id
+     * @return the list of bundles in the bucket
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    List<ExtensionBundle> getByBucket(String bucketId) throws IOException, NiFiRegistryException;
+
+    /**
+     * Retrieves the extension bundle with the given id.
+     *
+     * @param bundleId the id of the bundle
+     * @return the bundle with the given id
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    ExtensionBundle get(String bundleId) throws IOException, NiFiRegistryException;
+
+    /**
+     * Deletes the extension bundle with the given id, and all of its versions.
+     *
+     * @param bundleId the bundle id
+     * @return the deleted bundle
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    ExtensionBundle delete(String bundleId) throws IOException, NiFiRegistryException;
+
+}
diff --git a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionBundleVersionClient.java b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionBundleVersionClient.java
new file mode 100644
index 0000000..8bad8c9
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionBundleVersionClient.java
@@ -0,0 +1,156 @@
+/*
+ * 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.registry.client;
+
+import org.apache.nifi.registry.extension.ExtensionBundleType;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionMetadata;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+/**
+ * Client for interacting with extension bundle versions.
+ */
+public interface ExtensionBundleVersionClient {
+
+    /**
+     * Uploads a version of an extension bundle to NiFi Registry where the bundle content comes from an InputStream.
+     *
+     * @param bucketId the bucket where the extension bundle will leave
+     * @param bundleType the type of bundle being uploaded
+     * @param bundleContentStream the input stream with the binary content of the bundle
+     * @return the ExtensionBundleVersion entity
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    ExtensionBundleVersion create(String bucketId, ExtensionBundleType bundleType, InputStream bundleContentStream)
+            throws IOException, NiFiRegistryException;
+
+    /**
+     * Uploads a version of an extension bundle to NiFi Registry where the bundle content comes from an InputStream.
+     *
+     * @param bucketId the bucket where the extension bundle will leave
+     * @param bundleType the type of bundle being uploaded
+     * @param bundleContentStream the input stream with the binary content of the bundle
+     * @param sha256 the optional SHA-256 in hex form
+     * @return the ExtensionBundleVersion entity
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    ExtensionBundleVersion create(String bucketId, ExtensionBundleType bundleType, InputStream bundleContentStream, String sha256)
+            throws IOException, NiFiRegistryException;
+
+    /**
+     * Uploads a version of an extension bundle to NiFi Registry where the bundle content comes from a File.
+     *
+     * @param bucketId the bucket where the extension bundle will leave
+     * @param bundleType the type of bundle being uploaded
+     * @param bundleFile the file with the binary content of the bundle
+     * @return the ExtensionBundleVersion entity
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    ExtensionBundleVersion create(String bucketId, ExtensionBundleType bundleType, File bundleFile)
+            throws IOException, NiFiRegistryException;
+
+    /**
+     * Uploads a version of an extension bundle to NiFi Registry where the bundle content comes from a File.
+     *
+     * @param bucketId the bucket where the extension bundle will leave
+     * @param bundleType the type of bundle being uploaded
+     * @param bundleFile the file with the binary content of the bundle
+     * @param sha256 the optional SHA-256 in hex form
+     * @return the ExtensionBundleVersion entity
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    ExtensionBundleVersion create(String bucketId, ExtensionBundleType bundleType, File bundleFile, String sha256)
+            throws IOException, NiFiRegistryException;
+
+
+    /**
+     * Retrieves the metadata about the versions of the given bundle.
+     *
+     * @param bundleId the bundle id
+     * @return the list of version metadata
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    List<ExtensionBundleVersionMetadata> getBundleVersions(String bundleId) throws IOException, NiFiRegistryException;
+
+    /**
+     * Retrieves bundle version entity for the given bundle id and version string.
+     *
+     * The entity contains all of the information about the version, such as the bucket, bundle, and version metadata.
+     *
+     * The binary content of the bundle can be obtained by calling {@method getBundleVersionContent}.
+     *
+     * @param bundleId the bundle id
+     * @param version the bundle version
+     * @return the ExtensionBundleVersion entity
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    ExtensionBundleVersion getBundleVersion(String bundleId, String version) throws IOException, NiFiRegistryException;
+
+    /**
+     * Obtains an InputStream for the binary content for the version of the given bundle.
+     *
+     * @param bundleId the bundle id
+     * @param version the version
+     * @return the InputStream for the bundle version content
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    InputStream getBundleVersionContent(String bundleId, String version) throws IOException, NiFiRegistryException;
+
+    /**
+     * Writes the binary content for the version of the given the bundle to the specified directory.
+     *
+     * @param bundleId the bundle id
+     * @param version the bundle version
+     * @param directory the directory to write to
+     * @return the File object for the bundle that was written
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    File writeBundleVersionContent(String bundleId, String version, File directory) throws IOException, NiFiRegistryException;
+
+    /**
+     * Deletes the given extension bundle version.
+     *
+     * @param bundleId the bundle id
+     * @param version the bundle version
+     * @return the deleted bundle versions
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    ExtensionBundleVersion delete(String bundleId, String version) throws IOException, NiFiRegistryException;
+
+}
diff --git a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionRepoClient.java b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionRepoClient.java
new file mode 100644
index 0000000..6682921
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/ExtensionRepoClient.java
@@ -0,0 +1,126 @@
+/*
+ * 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.registry.client;
+
+import org.apache.nifi.registry.extension.repo.ExtensionRepoArtifact;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoBucket;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoGroup;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersion;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersionSummary;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+/**
+ * Client for interacting with the extension repository.
+ */
+public interface ExtensionRepoClient {
+
+    /**
+     * Gets the buckets in the extension repo.
+     *
+     * @return the list of extension repo buckets.
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    List<ExtensionRepoBucket> getBuckets() throws IOException, NiFiRegistryException;
+
+    /**
+     * Gets the extension repo groups in the specified bucket.
+     *
+     * @param bucketName the bucket name
+     * @return the list of groups
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    List<ExtensionRepoGroup> getGroups(String bucketName) throws IOException, NiFiRegistryException;
+
+    /**
+     * Gets the extension repo artifacts in the given bucket and group.
+     *
+     * @param bucketName the bucket name
+     * @param groupId the group id
+     * @return the list of artifacts
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    List<ExtensionRepoArtifact> getArtifacts(String bucketName, String groupId) throws IOException, NiFiRegistryException;
+
+    /**
+     * Gets the extension repo versions for the given bucket, group, artifact.
+     *
+     * @param bucketName the bucket name
+     * @param groupId the group id
+     * @param artifactId the artifact id
+     * @return the list of version summaries
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    List<ExtensionRepoVersionSummary> getVersions(String bucketName, String groupId, String artifactId)
+            throws IOException, NiFiRegistryException;
+
+    /**
+     * Gets the extension repo version for the given bucket, group, artifact, and version.
+     *
+     * @param bucketName the bucket name
+     * @param groupId the group id
+     * @param artifactId the artifact id
+     * @param version the version
+     * @return the extension repo version
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    ExtensionRepoVersion getVersion(String bucketName, String groupId, String artifactId, String version)
+            throws IOException, NiFiRegistryException;
+
+    /**
+     * Gets an InputStream for the binary content of the specified version.
+     *
+     * @param bucketName the bucket name
+     * @param groupId the group id
+     * @param artifactId the artifact id
+     * @param version the version
+     * @return the input stream
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    InputStream getVersionContent(String bucketName, String groupId, String artifactId, String version)
+            throws IOException, NiFiRegistryException;
+
+    /**
+     * Gets the hex representation of the SHA-256 hash of the binary content for the given version.
+     *
+     * @param bucketName the bucket name
+     * @param groupId the group id
+     * @param artifactId the artifact id
+     * @param version the version
+     * @return the SHA-256 hex string
+     *
+     * @throws IOException if an I/O error occurs
+     * @throws NiFiRegistryException if an non I/O error occurs
+     */
+    String getVersionSha256(String bucketName, String groupId, String artifactId, String version)
+            throws IOException, NiFiRegistryException;
+
+}
diff --git a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/NiFiRegistryClient.java b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/NiFiRegistryClient.java
index 07fb817..c5c4df4 100644
--- a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/NiFiRegistryClient.java
+++ b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/NiFiRegistryClient.java
@@ -74,6 +74,36 @@
     UserClient getUserClient(String ... proxiedEntity);
 
     /**
+     * @return the client for interacting with extension bundles
+     */
+    ExtensionBundleClient getExtensionBundleClient();
+
+    /**
+     * @return the client for interacting with extension bundles on behalf of the given proxied entities
+     */
+    ExtensionBundleClient getExtensionBundleClient(String ... proxiedEntity);
+
+    /**
+     * @return the client for interacting with extension bundle versions
+     */
+    ExtensionBundleVersionClient getExtensionBundleVersionClient();
+
+    /**
+     * @return the client for interacting with extension bundle versions on behalf of the given proxied entities
+     */
+    ExtensionBundleVersionClient getExtensionBundleVersionClient(String ... proxiedEntity);
+
+    /**
+     * @return the client for interacting with the extension repository
+     */
+    ExtensionRepoClient getExtensionRepoClient();
+
+    /**
+     * @return the client for interacting with the extension repository on behalf of the given proxied entities
+     */
+    ExtensionRepoClient getExtensionRepoClient(String ... proxiedEntity);
+
+    /**
      * The builder interface that implementations should provide for obtaining the client.
      */
     interface Builder {
diff --git a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/BucketItemDeserializer.java b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/BucketItemDeserializer.java
index 5640d43..3bd7359 100644
--- a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/BucketItemDeserializer.java
+++ b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/BucketItemDeserializer.java
@@ -24,6 +24,7 @@
 import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.registry.bucket.BucketItem;
 import org.apache.nifi.registry.bucket.BucketItemType;
+import org.apache.nifi.registry.extension.ExtensionBundle;
 import org.apache.nifi.registry.flow.VersionedFlow;
 
 import java.io.IOException;
@@ -65,8 +66,12 @@
                     final VersionedFlow versionedFlow = jsonParser.getCodec().treeToValue(node, VersionedFlow.class);
                     bucketItems.add(versionedFlow);
                     break;
+                case Extension_Bundle:
+                    final ExtensionBundle extensionBundle = jsonParser.getCodec().treeToValue(node, ExtensionBundle.class);
+                    bucketItems.add(extensionBundle);
+                    break;
                 default:
-                    throw new IllegalStateException("Unknown type for BucketItem");
+                    throw new IllegalStateException("Unknown type for BucketItem: " + bucketItemType);
             }
         }
 
diff --git a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionBundleClient.java b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionBundleClient.java
new file mode 100644
index 0000000..b70a653
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionBundleClient.java
@@ -0,0 +1,103 @@
+/*
+ * 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.registry.client.impl;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.registry.client.ExtensionBundleClient;
+import org.apache.nifi.registry.client.NiFiRegistryException;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+
+import javax.ws.rs.client.WebTarget;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Jersey implementation of ExtensionBundleClient.
+ */
+public class JerseyExtensionBundleClient extends AbstractJerseyClient implements ExtensionBundleClient {
+
+    private final WebTarget bucketExtensionBundlesTarget;
+    private final WebTarget extensionBundlesTarget;
+
+    public JerseyExtensionBundleClient(final WebTarget baseTarget) {
+        this(baseTarget, Collections.emptyMap());
+    }
+
+    public JerseyExtensionBundleClient(final WebTarget baseTarget, final Map<String, String> headers) {
+        super(headers);
+        this.bucketExtensionBundlesTarget = baseTarget.path("buckets/{bucketId}/extensions/bundles");
+        this.extensionBundlesTarget = baseTarget.path("extensions/bundles");
+    }
+
+    @Override
+    public List<ExtensionBundle> getAll() throws IOException, NiFiRegistryException {
+        return executeAction("Error getting extension bundles", () -> {
+            WebTarget target = extensionBundlesTarget;
+
+            final ExtensionBundle[] bundles = getRequestBuilder(target).get(ExtensionBundle[].class);
+            return  bundles == null ? Collections.emptyList() : Arrays.asList(bundles);
+        });
+    }
+
+    @Override
+    public List<ExtensionBundle> getByBucket(final String bucketId) throws IOException, NiFiRegistryException {
+        if (StringUtils.isBlank(bucketId)) {
+            throw new IllegalArgumentException("Bucket id cannot be null or blank");
+        }
+
+        return executeAction("Error getting extension bundles for bucket", () -> {
+            WebTarget target = bucketExtensionBundlesTarget.resolveTemplate("bucketId", bucketId);
+
+            final ExtensionBundle[] bundles = getRequestBuilder(target).get(ExtensionBundle[].class);
+            return  bundles == null ? Collections.emptyList() : Arrays.asList(bundles);
+        });
+    }
+
+    @Override
+    public ExtensionBundle get(final String bundleId) throws IOException, NiFiRegistryException {
+        if (StringUtils.isBlank(bundleId)) {
+            throw new IllegalArgumentException("Bundle id cannot be null or blank");
+        }
+
+        return executeAction("Error getting extension bundle", () -> {
+            WebTarget target = extensionBundlesTarget
+                    .path("{bundleId}")
+                    .resolveTemplate("bundleId", bundleId);
+
+            return getRequestBuilder(target).get(ExtensionBundle.class);
+        });
+    }
+
+    @Override
+    public ExtensionBundle delete(final String bundleId) throws IOException, NiFiRegistryException {
+        if (StringUtils.isBlank(bundleId)) {
+            throw new IllegalArgumentException("Bundle id cannot be null or blank");
+        }
+
+        return executeAction("Error deleting extension bundle", () -> {
+            WebTarget target = extensionBundlesTarget
+                    .path("{bundleId}")
+                    .resolveTemplate("bundleId", bundleId);
+
+            return getRequestBuilder(target).delete(ExtensionBundle.class);
+        });
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionBundleVersionClient.java b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionBundleVersionClient.java
new file mode 100644
index 0000000..6969317
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionBundleVersionClient.java
@@ -0,0 +1,280 @@
+/*
+ * 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.registry.client.impl;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.registry.client.ExtensionBundleVersionClient;
+import org.apache.nifi.registry.client.NiFiRegistryException;
+import org.apache.nifi.registry.extension.ExtensionBundleType;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionMetadata;
+import org.glassfish.jersey.media.multipart.FormDataMultiPart;
+import org.glassfish.jersey.media.multipart.file.FileDataBodyPart;
+import org.glassfish.jersey.media.multipart.file.StreamDataBodyPart;
+
+import javax.ws.rs.client.Entity;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Jersey implementation of ExtensionBundleVersionClient.
+ */
+public class JerseyExtensionBundleVersionClient extends AbstractJerseyClient implements ExtensionBundleVersionClient {
+
+    private final WebTarget bucketExtensionBundlesTarget;
+    private final WebTarget extensionBundlesTarget;
+
+    public JerseyExtensionBundleVersionClient(final WebTarget baseTarget) {
+        this(baseTarget, Collections.emptyMap());
+    }
+
+    public JerseyExtensionBundleVersionClient(final WebTarget baseTarget, final Map<String, String> headers) {
+        super(headers);
+        this.bucketExtensionBundlesTarget = baseTarget.path("buckets/{bucketId}/extensions/bundles");
+        this.extensionBundlesTarget = baseTarget.path("extensions/bundles");
+    }
+
+    @Override
+    public ExtensionBundleVersion create(final String bucketId, final ExtensionBundleType bundleType, final InputStream bundleContentStream)
+            throws IOException, NiFiRegistryException {
+        return create(bucketId, bundleType, bundleContentStream, null);
+    }
+
+        @Override
+    public ExtensionBundleVersion create(final String bucketId, final ExtensionBundleType bundleType, final InputStream bundleContentStream, final String sha256)
+            throws IOException, NiFiRegistryException {
+
+        if (StringUtils.isBlank(bucketId)) {
+            throw new IllegalArgumentException("Bucket id cannot be null or blank");
+        }
+
+        if (bundleType == null) {
+            throw new IllegalArgumentException("Bundle type cannot be null");
+        }
+
+        if (bundleContentStream == null) {
+            throw new IllegalArgumentException("Bundle content cannot be null");
+        }
+
+        return executeAction("Error creating extension bundle version", () -> {
+            final WebTarget target = bucketExtensionBundlesTarget
+                    .path("{bundleType}")
+                    .resolveTemplate("bucketId", bucketId)
+                    .resolveTemplate("bundleType", bundleType.toString());
+
+            final StreamDataBodyPart streamBodyPart = new StreamDataBodyPart("file", bundleContentStream);
+
+            final FormDataMultiPart multipart = new FormDataMultiPart();
+            multipart.bodyPart(streamBodyPart);
+
+            if (!StringUtils.isBlank(sha256)) {
+                multipart.field("sha256", sha256);
+            }
+
+            return getRequestBuilder(target)
+                    .post(
+                            Entity.entity(multipart, multipart.getMediaType()),
+                            ExtensionBundleVersion.class
+                    );
+        });
+    }
+
+    @Override
+    public ExtensionBundleVersion create(final String bucketId, final ExtensionBundleType bundleType, final File bundleFile)
+            throws IOException, NiFiRegistryException {
+        return create(bucketId, bundleType, bundleFile, null);
+    }
+
+    @Override
+    public ExtensionBundleVersion create(final String bucketId, final ExtensionBundleType bundleType, final File bundleFile, final String sha256)
+            throws IOException, NiFiRegistryException {
+
+        if (StringUtils.isBlank(bucketId)) {
+            throw new IllegalArgumentException("Bucket id cannot be null or blank");
+        }
+
+        if (bundleType == null) {
+            throw new IllegalArgumentException("Bundle type cannot be null");
+        }
+
+        if (bundleFile == null) {
+            throw new IllegalArgumentException("Bundle file cannot be null");
+        }
+
+        return executeAction("Error creating extension bundle version", () -> {
+            final WebTarget target = bucketExtensionBundlesTarget
+                    .path("{bundleType}")
+                    .resolveTemplate("bucketId", bucketId)
+                    .resolveTemplate("bundleType", bundleType.toString());
+
+            final FileDataBodyPart fileBodyPart = new FileDataBodyPart("file", bundleFile, MediaType.APPLICATION_OCTET_STREAM_TYPE);
+
+            final FormDataMultiPart multipart = new FormDataMultiPart();
+            multipart.bodyPart(fileBodyPart);
+
+            if (!StringUtils.isBlank(sha256)) {
+                multipart.field("sha256", sha256);
+            }
+
+            return getRequestBuilder(target)
+                    .post(
+                            Entity.entity(multipart, multipart.getMediaType()),
+                            ExtensionBundleVersion.class
+                    );
+        });
+    }
+
+    @Override
+    public List<ExtensionBundleVersionMetadata> getBundleVersions(final String bundleId)
+            throws IOException, NiFiRegistryException {
+
+        if (StringUtils.isBlank(bundleId)) {
+            throw new IllegalArgumentException("Bundle id cannot be null or blank");
+        }
+
+        return executeAction("Error getting extension bundle versions", () -> {
+            final WebTarget target = extensionBundlesTarget
+                    .path("{bundleId}/versions")
+                    .resolveTemplate("bundleId", bundleId);
+
+            final ExtensionBundleVersionMetadata[] bundleVersions = getRequestBuilder(target).get(ExtensionBundleVersionMetadata[].class);
+            return  bundleVersions == null ? Collections.emptyList() : Arrays.asList(bundleVersions);
+        });
+    }
+
+    @Override
+    public ExtensionBundleVersion getBundleVersion(final String bundleId, final String version)
+            throws IOException, NiFiRegistryException {
+
+        if (StringUtils.isBlank(bundleId)) {
+            throw new IllegalArgumentException("Bundle id cannot be null or blank");
+        }
+
+        if (StringUtils.isBlank(version)) {
+            throw new IllegalArgumentException("Version cannot be null or blank");
+        }
+
+        return executeAction("Error getting extension bundle version", () -> {
+            final WebTarget target = extensionBundlesTarget
+                    .path("{bundleId}/versions/{version}")
+                    .resolveTemplate("bundleId", bundleId)
+                    .resolveTemplate("version", version);
+
+            return getRequestBuilder(target).get(ExtensionBundleVersion.class);
+         });
+    }
+
+    @Override
+    public InputStream getBundleVersionContent(final String bundleId, final String version)
+            throws IOException, NiFiRegistryException {
+
+        if (StringUtils.isBlank(bundleId)) {
+            throw new IllegalArgumentException("Bundle id cannot be null or blank");
+        }
+
+        if (StringUtils.isBlank(version)) {
+            throw new IllegalArgumentException("Version cannot be null or blank");
+        }
+
+        return executeAction("Error getting extension bundle version", () -> {
+            final WebTarget target = extensionBundlesTarget
+                    .path("{bundleId}/versions/{version}/content")
+                    .resolveTemplate("bundleId", bundleId)
+                    .resolveTemplate("version", version);
+
+            return getRequestBuilder(target)
+                    .accept(MediaType.APPLICATION_OCTET_STREAM_TYPE)
+                    .get()
+                    .readEntity(InputStream.class);
+        });
+    }
+
+    @Override
+    public File writeBundleVersionContent(final String bundleId, final String version, final File directory)
+            throws IOException, NiFiRegistryException {
+
+        if (StringUtils.isBlank(bundleId)) {
+            throw new IllegalArgumentException("Bundle id cannot be null or blank");
+        }
+
+        if (StringUtils.isBlank(version)) {
+            throw new IllegalArgumentException("Version cannot be null or blank");
+        }
+
+        if (directory == null || !directory.exists() || !directory.isDirectory()) {
+            throw new IllegalArgumentException("Directory must exist and be a valid directory");
+        }
+
+        return executeAction("Error getting extension bundle version", () -> {
+            final WebTarget target = extensionBundlesTarget
+                    .path("{bundleId}/versions/{version}/content")
+                    .resolveTemplate("bundleId", bundleId)
+                    .resolveTemplate("version", version);
+
+            final Response response = getRequestBuilder(target)
+                    .accept(MediaType.APPLICATION_OCTET_STREAM_TYPE)
+                    .get();
+
+            final String contentDispositionHeader = response.getHeaderString("Content-Disposition");
+            if (StringUtils.isBlank(contentDispositionHeader)) {
+                throw new IllegalStateException("Content-Disposition header was blank or missing");
+            }
+
+            final int equalsIndex = contentDispositionHeader.lastIndexOf("=");
+            final String filename = contentDispositionHeader.substring(equalsIndex + 1).trim();
+            final File bundleFile = new File(directory, filename);
+
+            try (final InputStream responseInputStream = response.readEntity(InputStream.class)) {
+                Files.copy(responseInputStream, bundleFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
+                return bundleFile;
+            } catch (Exception e) {
+                throw new IllegalStateException("Unable to write bundle content due to: " + e.getMessage(), e);
+            }
+        });
+    }
+
+    @Override
+    public ExtensionBundleVersion delete(final String bundleId, final String version) throws IOException, NiFiRegistryException {
+        if (StringUtils.isBlank(bundleId)) {
+            throw new IllegalArgumentException("Bundle id cannot be null or blank");
+        }
+
+        if (StringUtils.isBlank(version)) {
+            throw new IllegalArgumentException("Version cannot be null or blank");
+        }
+
+        return executeAction("Error deleting extension bundle version", () -> {
+            final WebTarget target = extensionBundlesTarget
+                    .path("{bundleId}/versions/{version}")
+                    .resolveTemplate("bundleId", bundleId)
+                    .resolveTemplate("version", version);
+
+            return getRequestBuilder(target).delete(ExtensionBundleVersion.class);
+        });
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionRepoClient.java b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionRepoClient.java
new file mode 100644
index 0000000..18a6979
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyExtensionRepoClient.java
@@ -0,0 +1,199 @@
+/*
+ * 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.registry.client.impl;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.registry.client.ExtensionRepoClient;
+import org.apache.nifi.registry.client.NiFiRegistryException;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoArtifact;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoBucket;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoGroup;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersion;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersionSummary;
+
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+public class JerseyExtensionRepoClient extends AbstractJerseyClient implements ExtensionRepoClient {
+
+    private WebTarget extensionRepoTarget;
+
+    public JerseyExtensionRepoClient(final WebTarget baseTarget) {
+        this(baseTarget, Collections.emptyMap());
+    }
+
+    public JerseyExtensionRepoClient(final WebTarget baseTarget, final Map<String, String> headers) {
+        super(headers);
+        this.extensionRepoTarget = baseTarget.path("extensions/repo");
+    }
+
+    @Override
+    public List<ExtensionRepoBucket> getBuckets() throws IOException, NiFiRegistryException {
+        return executeAction("Error retrieving buckets for extension repo", () -> {
+           final ExtensionRepoBucket[] repoBuckets = getRequestBuilder(extensionRepoTarget).get(ExtensionRepoBucket[].class);
+           return  repoBuckets == null ? Collections.emptyList() : Arrays.asList(repoBuckets);
+        });
+    }
+
+    @Override
+    public List<ExtensionRepoGroup> getGroups(final String bucketName) throws IOException, NiFiRegistryException {
+        if (StringUtils.isBlank(bucketName)) {
+            throw new IllegalArgumentException("Bucket name cannot be null or blank");
+        }
+
+        return executeAction("Error retrieving groups for extension repo", () -> {
+            final WebTarget target = extensionRepoTarget
+                    .path("{bucketName}")
+                    .resolveTemplate("bucketName", bucketName);
+
+            final ExtensionRepoGroup[] repoGroups = getRequestBuilder(target).get(ExtensionRepoGroup[].class);
+            return  repoGroups == null ? Collections.emptyList() : Arrays.asList(repoGroups);
+        });
+    }
+
+    @Override
+    public List<ExtensionRepoArtifact> getArtifacts(final String bucketName, final String groupId)
+            throws IOException, NiFiRegistryException {
+
+        if (StringUtils.isBlank(bucketName)) {
+            throw new IllegalArgumentException("Bucket name cannot be null or blank");
+        }
+
+        if (StringUtils.isBlank(groupId)) {
+            throw new IllegalArgumentException("Group id cannot be null or blank");
+        }
+
+        return executeAction("Error retrieving artifacts for extension repo", () -> {
+            final WebTarget target = extensionRepoTarget
+                    .path("{bucketName}/{groupId}")
+                    .resolveTemplate("bucketName", bucketName)
+                    .resolveTemplate("groupId", groupId);
+
+            final ExtensionRepoArtifact[] repoArtifacts = getRequestBuilder(target).get(ExtensionRepoArtifact[].class);
+            return  repoArtifacts == null ? Collections.emptyList() : Arrays.asList(repoArtifacts);
+        });
+    }
+
+    @Override
+    public List<ExtensionRepoVersionSummary> getVersions(final String bucketName, final String groupId, final String artifactId)
+            throws IOException, NiFiRegistryException {
+
+        if (StringUtils.isBlank(bucketName)) {
+            throw new IllegalArgumentException("Bucket name cannot be null or blank");
+        }
+
+        if (StringUtils.isBlank(groupId)) {
+            throw new IllegalArgumentException("Group id cannot be null or blank");
+        }
+
+        if (StringUtils.isBlank(artifactId)) {
+            throw new IllegalArgumentException("Artifact id cannot be null or blank");
+        }
+
+        return executeAction("Error retrieving versions for extension repo", () -> {
+            final WebTarget target = extensionRepoTarget
+                    .path("{bucketName}/{groupId}/{artifactId}")
+                    .resolveTemplate("bucketName", bucketName)
+                    .resolveTemplate("groupId", groupId)
+                    .resolveTemplate("artifactId", artifactId);
+
+            final ExtensionRepoVersionSummary[] repoVersions = getRequestBuilder(target).get(ExtensionRepoVersionSummary[].class);
+            return  repoVersions == null ? Collections.emptyList() : Arrays.asList(repoVersions);
+        });
+    }
+
+    @Override
+    public ExtensionRepoVersion getVersion(final String bucketName, final String groupId, final String artifactId, final String version)
+            throws IOException, NiFiRegistryException {
+
+        validate(bucketName, groupId, artifactId, version);
+
+        return executeAction("Error retrieving versions for extension repo", () -> {
+            final WebTarget target = extensionRepoTarget
+                    .path("{bucketName}/{groupId}/{artifactId}/{version}")
+                    .resolveTemplate("bucketName", bucketName)
+                    .resolveTemplate("groupId", groupId)
+                    .resolveTemplate("artifactId", artifactId)
+                    .resolveTemplate("version", version);
+
+            return getRequestBuilder(target).get(ExtensionRepoVersion.class);
+        });
+    }
+
+    @Override
+    public InputStream getVersionContent(final String bucketName, final String groupId, final String artifactId, final String version)
+            throws IOException, NiFiRegistryException {
+
+        validate(bucketName, groupId, artifactId, version);
+
+        return executeAction("Error retrieving version content for extension repo", () -> {
+            final WebTarget target = extensionRepoTarget
+                    .path("{bucketName}/{groupId}/{artifactId}/{version}/content")
+                    .resolveTemplate("bucketName", bucketName)
+                    .resolveTemplate("groupId", groupId)
+                    .resolveTemplate("artifactId", artifactId)
+                    .resolveTemplate("version", version);
+
+            return getRequestBuilder(target)
+                    .accept(MediaType.APPLICATION_OCTET_STREAM_TYPE)
+                    .get()
+                    .readEntity(InputStream.class);
+        });
+    }
+
+    @Override
+    public String getVersionSha256(final String bucketName, final String groupId, final String artifactId, final String version)
+            throws IOException, NiFiRegistryException {
+
+        validate(bucketName, groupId, artifactId, version);
+
+        return executeAction("Error retrieving version content for extension repo", () -> {
+            final WebTarget target = extensionRepoTarget
+                    .path("{bucketName}/{groupId}/{artifactId}/{version}/sha256")
+                    .resolveTemplate("bucketName", bucketName)
+                    .resolveTemplate("groupId", groupId)
+                    .resolveTemplate("artifactId", artifactId)
+                    .resolveTemplate("version", version);
+
+            return getRequestBuilder(target).accept(MediaType.TEXT_PLAIN_TYPE).get(String.class);
+        });
+    }
+
+    private void validate(String bucketName, String groupId, String artifactId, String version) {
+        if (StringUtils.isBlank(bucketName)) {
+            throw new IllegalArgumentException("Bucket name cannot be null or blank");
+        }
+
+        if (StringUtils.isBlank(groupId)) {
+            throw new IllegalArgumentException("Group id cannot be null or blank");
+        }
+
+        if (StringUtils.isBlank(artifactId)) {
+            throw new IllegalArgumentException("Artifact id cannot be null or blank");
+        }
+
+        if (StringUtils.isBlank(version)) {
+            throw new IllegalArgumentException("Version cannot be null or blank");
+        }
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyNiFiRegistryClient.java b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyNiFiRegistryClient.java
index 329a47a..972211b 100644
--- a/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyNiFiRegistryClient.java
+++ b/nifi-registry-core/nifi-registry-client/src/main/java/org/apache/nifi/registry/client/impl/JerseyNiFiRegistryClient.java
@@ -24,6 +24,9 @@
 import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.registry.bucket.BucketItem;
 import org.apache.nifi.registry.client.BucketClient;
+import org.apache.nifi.registry.client.ExtensionBundleClient;
+import org.apache.nifi.registry.client.ExtensionBundleVersionClient;
+import org.apache.nifi.registry.client.ExtensionRepoClient;
 import org.apache.nifi.registry.client.FlowClient;
 import org.apache.nifi.registry.client.FlowSnapshotClient;
 import org.apache.nifi.registry.client.ItemsClient;
@@ -33,7 +36,9 @@
 import org.apache.nifi.registry.security.util.ProxiedEntitiesUtils;
 import org.glassfish.jersey.client.ClientConfig;
 import org.glassfish.jersey.client.ClientProperties;
+import org.glassfish.jersey.client.RequestEntityProcessing;
 import org.glassfish.jersey.jackson.internal.jackson.jaxrs.json.JacksonJaxbJsonProvider;
+import org.glassfish.jersey.media.multipart.MultiPartFeature;
 
 import javax.net.ssl.HostnameVerifier;
 import javax.net.ssl.SSLContext;
@@ -107,9 +112,13 @@
         final ClientConfig clientConfig = new ClientConfig();
         clientConfig.property(ClientProperties.CONNECT_TIMEOUT, connectTimeout);
         clientConfig.property(ClientProperties.READ_TIMEOUT, readTimeout);
+        clientConfig.property(ClientProperties.REQUEST_ENTITY_PROCESSING, RequestEntityProcessing.CHUNKED);
         clientConfig.register(jacksonJaxbJsonProvider());
         clientBuilder.withConfig(clientConfig);
-        this.client = clientBuilder.build();
+
+        this.client = clientBuilder
+                .register(MultiPartFeature.class)
+                .build();
 
         this.baseTarget = client.target(baseUrl);
         this.bucketClient = new JerseyBucketClient(baseTarget);
@@ -173,6 +182,39 @@
         return new JerseyUserClient(baseTarget, headers);
     }
 
+    @Override
+    public ExtensionBundleClient getExtensionBundleClient() {
+        return new JerseyExtensionBundleClient(baseTarget);
+    }
+
+    @Override
+    public ExtensionBundleClient getExtensionBundleClient(String... proxiedEntity) {
+        final Map<String,String> headers = getHeaders(proxiedEntity);
+        return new JerseyExtensionBundleClient(baseTarget, headers);
+    }
+
+    @Override
+    public ExtensionBundleVersionClient getExtensionBundleVersionClient() {
+        return new JerseyExtensionBundleVersionClient(baseTarget);
+    }
+
+    @Override
+    public ExtensionBundleVersionClient getExtensionBundleVersionClient(String... proxiedEntity) {
+        final Map<String,String> headers = getHeaders(proxiedEntity);
+        return new JerseyExtensionBundleVersionClient(baseTarget, headers);
+    }
+
+    @Override
+    public ExtensionRepoClient getExtensionRepoClient() {
+        return new JerseyExtensionRepoClient(baseTarget);
+    }
+
+    @Override
+    public ExtensionRepoClient getExtensionRepoClient(String... proxiedEntity) {
+        final Map<String,String> headers = getHeaders(proxiedEntity);
+        return new JerseyExtensionRepoClient(baseTarget, headers);
+    }
+
     private Map<String,String> getHeaders(String[] proxiedEntities) {
         final String proxiedEntitiesValue = getProxiedEntitesValue(proxiedEntities);
 
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/bucket/BucketItemType.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/bucket/BucketItemType.java
index e119c02..b746491 100644
--- a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/bucket/BucketItemType.java
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/bucket/BucketItemType.java
@@ -23,5 +23,8 @@
 
     // The case of these enum names matches what we want to return in
     // the BucketItem.type field when serialized in an API response.
-    Flow;
+
+    Flow,
+
+    Extension_Bundle;
 }
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundle.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundle.java
new file mode 100644
index 0000000..c9f0e7f
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundle.java
@@ -0,0 +1,92 @@
+/*
+ * 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.registry.extension;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import org.apache.nifi.registry.bucket.BucketItem;
+import org.apache.nifi.registry.bucket.BucketItemType;
+
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * Represents an extension bundle identified by a group and artifact id with in a bucket.
+ *
+ * Each bundle may then have one or more versions associated with it by creating an {@link ExtensionBundleVersion}.
+ *
+ * The {@link ExtensionBundleVersion} represents the actually binary bundle which may contain one or more extensions.
+ */
+@ApiModel
+@XmlRootElement
+public class ExtensionBundle extends BucketItem {
+
+    @NotNull
+    private ExtensionBundleType bundleType;
+
+    @NotBlank
+    private String groupId;
+
+    @NotBlank
+    private String artifactId;
+
+    @Min(0)
+    private long versionCount;
+
+    public ExtensionBundle() {
+        super(BucketItemType.Extension_Bundle);
+    }
+
+    @ApiModelProperty(value = "The type of the extension bundle")
+    public ExtensionBundleType getBundleType() {
+        return bundleType;
+    }
+
+    public void setBundleType(ExtensionBundleType bundleType) {
+        this.bundleType = bundleType;
+    }
+
+    @ApiModelProperty(value = "The group id of the extension bundle")
+    public String getGroupId() {
+        return groupId;
+    }
+
+    public void setGroupId(String groupId) {
+        this.groupId = groupId;
+    }
+
+    @ApiModelProperty(value = "The artifact id of the extension bundle")
+    public String getArtifactId() {
+        return artifactId;
+    }
+
+    public void setArtifactId(String artifactId) {
+        this.artifactId = artifactId;
+    }
+
+    @ApiModelProperty(value = "The number of versions of this extension bundle.", readOnly = true)
+    public long getVersionCount() {
+        return versionCount;
+    }
+
+    public void setVersionCount(long versionCount) {
+        this.versionCount = versionCount;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleType.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleType.java
new file mode 100644
index 0000000..0eb0447
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleType.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.registry.extension;
+
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+
+/**
+ * The possible types of extension bundles.
+ */
+@XmlJavaTypeAdapter(ExtensionBundleTypeAdapter.class)
+public enum ExtensionBundleType {
+
+    NIFI_NAR("nifi-nar"),
+
+    MINIFI_CPP("minifi-cpp");
+
+    private final String displayName;
+
+    ExtensionBundleType(String displayName) {
+        this.displayName = displayName;
+    }
+
+    // Note: This method must be name fromString for JAX-RS/Jersey to use it on query and path params
+    public static ExtensionBundleType fromString(String value) {
+        if (value == null) {
+            throw new IllegalArgumentException("Value cannot be null");
+        }
+
+        for (final ExtensionBundleType type : values()) {
+            if (type.toString().equals(value)) {
+                return type;
+            }
+        }
+
+        throw new IllegalArgumentException("Unknown ExtensionBundleType: " + value);
+    }
+
+
+    @Override
+    public String toString() {
+        return displayName;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleTypeAdapter.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleTypeAdapter.java
new file mode 100644
index 0000000..1a993cf
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleTypeAdapter.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.registry.extension;
+
+import javax.xml.bind.annotation.adapters.XmlAdapter;
+
+public class ExtensionBundleTypeAdapter extends XmlAdapter<String,ExtensionBundleType> {
+
+    @Override
+    public ExtensionBundleType unmarshal(String v) throws Exception {
+        if (v == null) {
+            return null;
+        }
+
+        return ExtensionBundleType.fromString(v);
+    }
+
+    @Override
+    public String marshal(final ExtensionBundleType v) throws Exception {
+        if (v == null) {
+            return null;
+        }
+
+        return v.toString();
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleVersion.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleVersion.java
new file mode 100644
index 0000000..a8ef0c3
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleVersion.java
@@ -0,0 +1,98 @@
+/*
+ * 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.registry.extension;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import org.apache.nifi.registry.bucket.Bucket;
+import org.apache.nifi.registry.link.LinkableEntity;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlTransient;
+import java.util.Set;
+
+@ApiModel
+@XmlRootElement
+public class ExtensionBundleVersion extends LinkableEntity {
+
+    @Valid
+    @NotNull
+    private ExtensionBundleVersionMetadata versionMetadata;
+
+    // read-only, only populated from retrieval of an individual bundle version
+    private Set<ExtensionBundleVersionDependency> dependencies;
+
+    // read-only, only populated from retrieval of an individual bundle version
+    private ExtensionBundle extensionBundle;
+
+    // read-only, only populated from retrieval of an individual bundle version
+    private Bucket bucket;
+
+    @ApiModelProperty(value = "The metadata about this version of the extension bundle")
+    public ExtensionBundleVersionMetadata getVersionMetadata() {
+        return versionMetadata;
+    }
+
+    public void setVersionMetadata(ExtensionBundleVersionMetadata versionMetadata) {
+        this.versionMetadata = versionMetadata;
+    }
+
+    @ApiModelProperty(value = "The set of other bundle versions that this version is dependent on", readOnly = true)
+    public Set<ExtensionBundleVersionDependency> getDependencies() {
+        return dependencies;
+    }
+
+    public void setDependencies(Set<ExtensionBundleVersionDependency> dependencies) {
+        this.dependencies = dependencies;
+    }
+
+    @ApiModelProperty(value = "The bundle this version is for", readOnly = true)
+    public ExtensionBundle getExtensionBundle() {
+        return extensionBundle;
+    }
+
+    public void setExtensionBundle(ExtensionBundle extensionBundle) {
+        this.extensionBundle = extensionBundle;
+    }
+
+    @ApiModelProperty(value = "The bucket that the extension bundle belongs to")
+    public Bucket getBucket() {
+        return bucket;
+    }
+
+    public void setBucket(Bucket bucket) {
+        this.bucket = bucket;
+    }
+
+    @XmlTransient
+    public String getFilename() {
+        final String filename = extensionBundle.getArtifactId() + "-" + versionMetadata.getVersion();
+
+        switch (extensionBundle.getBundleType()) {
+            case NIFI_NAR:
+                return filename + ".nar";
+            case MINIFI_CPP:
+                // TODO should CPP get a special extension
+                return filename;
+            default:
+                throw new IllegalStateException("Unknown bundle type: " + extensionBundle.getBundleType());
+        }
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleVersionDependency.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleVersionDependency.java
new file mode 100644
index 0000000..f84649b
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleVersionDependency.java
@@ -0,0 +1,84 @@
+/*
+ * 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.registry.extension;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+
+import javax.validation.constraints.NotBlank;
+import java.util.Objects;
+
+@ApiModel
+public class ExtensionBundleVersionDependency {
+
+    @NotBlank
+    private String groupId;
+
+    @NotBlank
+    private String artifactId;
+
+    @NotBlank
+    private String version;
+
+    @ApiModelProperty(value = "The group id of the bundle dependency")
+    public String getGroupId() {
+        return groupId;
+    }
+
+    public void setGroupId(String groupId) {
+        this.groupId = groupId;
+    }
+
+    @ApiModelProperty(value = "The artifact id of the bundle dependency")
+    public String getArtifactId() {
+        return artifactId;
+    }
+
+    public void setArtifactId(String artifactId) {
+        this.artifactId = artifactId;
+    }
+
+    @ApiModelProperty(value = "The version of the bundle dependency")
+    public String getVersion() {
+        return version;
+    }
+
+    public void setVersion(String version) {
+        this.version = version;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(groupId, artifactId, version);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+
+        final ExtensionBundleVersionDependency other = (ExtensionBundleVersionDependency) obj;
+
+        return Objects.equals(groupId, other.groupId)
+                && Objects.equals(artifactId, other.artifactId)
+                && Objects.equals(version, other.version);
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleVersionMetadata.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleVersionMetadata.java
new file mode 100644
index 0000000..35756f9
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleVersionMetadata.java
@@ -0,0 +1,163 @@
+/*
+ * 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.registry.extension;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import org.apache.nifi.registry.link.LinkableEntity;
+
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.xml.bind.annotation.XmlRootElement;
+import java.util.Objects;
+
+@ApiModel
+@XmlRootElement
+public class ExtensionBundleVersionMetadata extends LinkableEntity implements Comparable<ExtensionBundleVersionMetadata> {
+
+    @NotBlank
+    private String id;
+
+    @NotBlank
+    private String extensionBundleId;
+
+    @NotBlank
+    private String bucketId;
+
+    @NotBlank
+    private String version;
+
+    @Min(1)
+    private long timestamp;
+
+    @NotBlank
+    private String author;
+
+    private String description;
+
+    @NotBlank
+    private String sha256;
+
+    @NotNull
+    private Boolean sha256Supplied;
+
+
+    @ApiModelProperty(value = "The id of this version of the extension bundle")
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    @ApiModelProperty(value = "The id of the extension bundle this version is for")
+    public String getExtensionBundleId() {
+        return extensionBundleId;
+    }
+
+    public void setExtensionBundleId(String extensionBundleId) {
+        this.extensionBundleId = extensionBundleId;
+    }
+
+    @ApiModelProperty(value = "The id of the bucket the extension bundle belongs to", required = true)
+    public String getBucketId() {
+        return bucketId;
+    }
+
+    public void setBucketId(String bucketId) {
+        this.bucketId = bucketId;
+    }
+
+    @ApiModelProperty(value = "The version of the extension bundle")
+    public String getVersion() {
+        return version;
+    }
+
+    public void setVersion(String version) {
+        this.version = version;
+    }
+
+    @ApiModelProperty(value = "The timestamp of the create date of this version")
+    public long getTimestamp() {
+        return timestamp;
+    }
+
+    public void setTimestamp(long timestamp) {
+        this.timestamp = timestamp;
+    }
+
+    @ApiModelProperty(value = "The identity that created this version")
+    public String getAuthor() {
+        return author;
+    }
+
+    public void setAuthor(String author) {
+        this.author = author;
+    }
+
+    @ApiModelProperty(value = "The description for this version")
+    public String getDescription() {
+        return description;
+    }
+
+    public void setDescription(String description) {
+        this.description = description;
+    }
+
+    @ApiModelProperty(value = "The hex representation of the SHA-256 digest of the binary content for this version")
+    public String getSha256() {
+        return sha256;
+    }
+
+    public void setSha256(String sha256) {
+        this.sha256 = sha256;
+    }
+
+    @ApiModelProperty(value = "Whether or not the client supplied a SHA-256 when uploading the bundle")
+    public Boolean getSha256Supplied() {
+        return sha256Supplied;
+    }
+
+    public void setSha256Supplied(Boolean sha256Supplied) {
+        this.sha256Supplied = sha256Supplied;
+    }
+
+    @Override
+    public int compareTo(final ExtensionBundleVersionMetadata o) {
+        return o == null ? -1 : version.compareTo(o.version);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(this.id);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+
+        final ExtensionBundleVersionMetadata other = (ExtensionBundleVersionMetadata) obj;
+        return Objects.equals(this.id, other.id);
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoArtifact.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoArtifact.java
new file mode 100644
index 0000000..6b42678
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoArtifact.java
@@ -0,0 +1,93 @@
+/*
+ * 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.registry.extension.repo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import org.apache.nifi.registry.link.LinkableEntity;
+
+import javax.xml.bind.annotation.XmlRootElement;
+import java.util.Comparator;
+import java.util.Objects;
+
+@ApiModel
+@XmlRootElement
+public class ExtensionRepoArtifact extends LinkableEntity implements Comparable<ExtensionRepoArtifact> {
+
+    private String bucketName;
+
+    private String groupId;
+
+    private String artifactId;
+
+    @ApiModelProperty(value = "The bucket name")
+    public String getBucketName() {
+        return bucketName;
+    }
+
+    public void setBucketName(String bucketName) {
+        this.bucketName = bucketName;
+    }
+
+    @ApiModelProperty("The group id")
+    public String getGroupId() {
+        return groupId;
+    }
+
+    public void setGroupId(String groupId) {
+        this.groupId = groupId;
+    }
+
+    @ApiModelProperty("The artifact id")
+    public String getArtifactId() {
+        return artifactId;
+    }
+
+    public void setArtifactId(String artifactId) {
+        this.artifactId = artifactId;
+    }
+
+    @Override
+    public int compareTo(final ExtensionRepoArtifact o) {
+        return Comparator.comparing(ExtensionRepoArtifact::getArtifactId)
+                .thenComparing(ExtensionRepoArtifact::getGroupId)
+                .thenComparing(ExtensionRepoArtifact::getBucketName)
+                .compare(this, o);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.bucketName, this.groupId, this.artifactId) ;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+
+        final ExtensionRepoArtifact other = (ExtensionRepoArtifact) obj;
+
+        return Objects.equals(this.getBucketName(), other.getBucketName())
+                && Objects.equals(this.getGroupId(), other.getGroupId())
+                && Objects.equals(this.getArtifactId(), other.getArtifactId());
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoBucket.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoBucket.java
new file mode 100644
index 0000000..1798df7
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoBucket.java
@@ -0,0 +1,64 @@
+/*
+ * 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.registry.extension.repo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import org.apache.nifi.registry.link.LinkableEntity;
+
+import javax.xml.bind.annotation.XmlRootElement;
+import java.util.Comparator;
+import java.util.Objects;
+
+@ApiModel
+@XmlRootElement
+public class ExtensionRepoBucket extends LinkableEntity implements Comparable<ExtensionRepoBucket> {
+
+    private String bucketName;
+
+    @ApiModelProperty(value = "The name of the bucket")
+    public String getBucketName() {
+        return bucketName;
+    }
+
+    public void setBucketName(String bucketName) {
+        this.bucketName = bucketName;
+    }
+
+    @Override
+    public int compareTo(final ExtensionRepoBucket o) {
+        return Comparator.comparing(ExtensionRepoBucket::getBucketName).compare(this, o);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(this.bucketName);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+
+        final ExtensionRepoBucket other = (ExtensionRepoBucket) obj;
+        return Objects.equals(this.getBucketName(), other.getBucketName());
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoGroup.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoGroup.java
new file mode 100644
index 0000000..86e25f2
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoGroup.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.registry.extension.repo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import org.apache.nifi.registry.link.LinkableEntity;
+
+import javax.xml.bind.annotation.XmlRootElement;
+import java.util.Comparator;
+import java.util.Objects;
+
+@ApiModel
+@XmlRootElement
+public class ExtensionRepoGroup extends LinkableEntity implements Comparable<ExtensionRepoGroup> {
+
+    private String bucketName;
+
+    private String groupId;
+
+    @ApiModelProperty(value = "The bucket name")
+    public String getBucketName() {
+        return bucketName;
+    }
+
+    public void setBucketName(String bucketName) {
+        this.bucketName = bucketName;
+    }
+
+    @ApiModelProperty(value = "The group id")
+    public String getGroupId() {
+        return groupId;
+    }
+
+    public void setGroupId(String groupId) {
+        this.groupId = groupId;
+    }
+
+    @Override
+    public int compareTo(final ExtensionRepoGroup o) {
+        return Comparator.comparing(ExtensionRepoGroup::getGroupId)
+                .thenComparing(ExtensionRepoGroup::getBucketName)
+                .compare(this, o);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.bucketName, this.groupId) ;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+
+        final ExtensionRepoGroup other = (ExtensionRepoGroup) obj;
+
+        return Objects.equals(this.getBucketName(), other.getBucketName())
+                && Objects.equals(this.getGroupId(), other.getGroupId());
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoVersion.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoVersion.java
new file mode 100644
index 0000000..4dff6e6
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoVersion.java
@@ -0,0 +1,68 @@
+/*
+ * 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.registry.extension.repo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import org.apache.nifi.registry.link.LinkAdapter;
+
+import javax.ws.rs.core.Link;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+
+@ApiModel
+@XmlRootElement
+public class ExtensionRepoVersion {
+
+    private Link downloadLink;
+
+    private Link sha256Link;
+
+    private Boolean sha256Supplied;
+
+    @XmlElement
+    @XmlJavaTypeAdapter(LinkAdapter.class)
+    @ApiModelProperty(value = "The WebLink to download this version of the extension bundle.", readOnly = true)
+    public Link getDownloadLink() {
+        return downloadLink;
+    }
+
+    public void setDownloadLink(Link downloadLink) {
+        this.downloadLink = downloadLink;
+    }
+
+    @XmlElement
+    @XmlJavaTypeAdapter(LinkAdapter.class)
+    @ApiModelProperty(value = "The WebLink to retrieve the SHA-256 digest for this version of the extension bundle.", readOnly = true)
+    public Link getSha256Link() {
+        return sha256Link;
+    }
+
+    public void setSha256Link(Link sha256Link) {
+        this.sha256Link = sha256Link;
+    }
+
+    @ApiModelProperty(value = "Indicates if the client supplied a SHA-256 when uploading this version of the extension bundle.", readOnly = true)
+    public Boolean getSha256Supplied() {
+        return sha256Supplied;
+    }
+
+    public void setSha256Supplied(Boolean sha256Supplied) {
+        this.sha256Supplied = sha256Supplied;
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoVersionSummary.java b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoVersionSummary.java
new file mode 100644
index 0000000..f73d32e
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-data-model/src/main/java/org/apache/nifi/registry/extension/repo/ExtensionRepoVersionSummary.java
@@ -0,0 +1,106 @@
+/*
+ * 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.registry.extension.repo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import org.apache.nifi.registry.link.LinkableEntity;
+
+import javax.xml.bind.annotation.XmlRootElement;
+import java.util.Comparator;
+import java.util.Objects;
+
+@ApiModel
+@XmlRootElement
+public class ExtensionRepoVersionSummary extends LinkableEntity implements Comparable<ExtensionRepoVersionSummary> {
+
+    private String bucketName;
+
+    private String groupId;
+
+    private String artifactId;
+
+    private String version;
+
+    @ApiModelProperty(value = "The bucket name")
+    public String getBucketName() {
+        return bucketName;
+    }
+
+    public void setBucketName(String bucketName) {
+        this.bucketName = bucketName;
+    }
+
+    @ApiModelProperty("The group id")
+    public String getGroupId() {
+        return groupId;
+    }
+
+    public void setGroupId(String groupId) {
+        this.groupId = groupId;
+    }
+
+    @ApiModelProperty("The artifact id")
+    public String getArtifactId() {
+        return artifactId;
+    }
+
+    public void setArtifactId(String artifactId) {
+        this.artifactId = artifactId;
+    }
+
+    @ApiModelProperty("The version")
+    public String getVersion() {
+        return version;
+    }
+
+    public void setVersion(String version) {
+        this.version = version;
+    }
+
+    @Override
+    public int compareTo(ExtensionRepoVersionSummary o) {
+        return Comparator.comparing(ExtensionRepoVersionSummary::getVersion)
+                .thenComparing(ExtensionRepoVersionSummary::getArtifactId)
+                .thenComparing(ExtensionRepoVersionSummary::getGroupId)
+                .thenComparing(ExtensionRepoVersionSummary::getBucketName)
+                .compare(this, o);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.bucketName, this.groupId, this.artifactId, this.version);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+
+        final ExtensionRepoVersionSummary other = (ExtensionRepoVersionSummary) obj;
+
+        return Objects.equals(this.getBucketName(), other.getBucketName())
+                && Objects.equals(this.getGroupId(), other.getGroupId())
+                && Objects.equals(this.getArtifactId(), other.getArtifactId())
+                && Objects.equals(this.getVersion(), other.getVersion());
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/pom.xml b/nifi-registry-core/nifi-registry-framework/pom.xml
index 8ce4a13..c226e42 100644
--- a/nifi-registry-core/nifi-registry-framework/pom.xml
+++ b/nifi-registry-core/nifi-registry-framework/pom.xml
@@ -182,6 +182,11 @@
             <version>0.4.0-SNAPSHOT</version>
         </dependency>
         <dependency>
+            <groupId>org.apache.nifi.registry</groupId>
+            <artifactId>nifi-registry-bundle-utils</artifactId>
+            <version>0.4.0-SNAPSHOT</version>
+        </dependency>
+        <dependency>
             <groupId>javax.servlet</groupId>
             <artifactId>javax.servlet-api</artifactId>
             <version>3.1.0</version>
@@ -233,10 +238,6 @@
             <artifactId>commons-io</artifactId>
         </dependency>
         <dependency>
-            <groupId>org.hibernate</groupId>
-            <artifactId>hibernate-validator</artifactId>
-        </dependency>
-        <dependency>
             <groupId>org.glassfish</groupId>
             <artifactId>javax.el</artifactId>
         </dependency>
@@ -266,6 +267,11 @@
             </exclusions>
         </dependency>
         <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+            <version>${spring.boot.version}</version>
+        </dependency>
+        <dependency>
             <groupId>org.flywaydb</groupId>
             <artifactId>flyway-core</artifactId>
             <version>${flyway.version}</version>
@@ -315,7 +321,7 @@
         <dependency>
             <groupId>org.flywaydb.flyway-test-extensions</groupId>
             <artifactId>flyway-spring-test</artifactId>
-            <version>${flyway.version}</version>
+            <version>${flyway.tests.version}</version>
             <scope>test</scope>
             <exclusions>
                 <exclusion>
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/CustomFlywayMigrationStrategy.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/CustomFlywayMigrationStrategy.java
index 7748acf..13954c6 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/CustomFlywayMigrationStrategy.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/CustomFlywayMigrationStrategy.java
@@ -61,7 +61,7 @@
 
     @Override
     public void migrate(Flyway flyway) {
-        final boolean newDatabase = isNewDatabase(flyway.getDataSource());
+        final boolean newDatabase = isNewDatabase(flyway.getConfiguration().getDataSource());
         if (newDatabase) {
             LOGGER.info("First time initializing database...");
         } else {
@@ -90,7 +90,7 @@
         if (newDatabase && existingLegacyDatabase) {
             final LegacyDataSourceFactory legacyDataSourceFactory = new LegacyDataSourceFactory(properties);
             final DataSource legacyDataSource = legacyDataSourceFactory.getDataSource();
-            final DataSource primaryDataSource = flyway.getDataSource();
+            final DataSource primaryDataSource = flyway.getConfiguration().getDataSource();
             migrateData(legacyDataSource, primaryDataSource);
         }
     }
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/DatabaseMetadataService.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/DatabaseMetadataService.java
index 4d32790..de9cf69 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/DatabaseMetadataService.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/DatabaseMetadataService.java
@@ -19,16 +19,27 @@
 import org.apache.nifi.registry.db.entity.BucketEntity;
 import org.apache.nifi.registry.db.entity.BucketItemEntity;
 import org.apache.nifi.registry.db.entity.BucketItemEntityType;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleVersionDependencyEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleVersionEntity;
+import org.apache.nifi.registry.db.entity.ExtensionEntity;
+import org.apache.nifi.registry.db.entity.ExtensionEntityCategory;
 import org.apache.nifi.registry.db.entity.FlowEntity;
 import org.apache.nifi.registry.db.entity.FlowSnapshotEntity;
 import org.apache.nifi.registry.db.mapper.BucketEntityRowMapper;
 import org.apache.nifi.registry.db.mapper.BucketItemEntityRowMapper;
+import org.apache.nifi.registry.db.mapper.ExtensionBundleEntityRowMapper;
+import org.apache.nifi.registry.db.mapper.ExtensionBundleEntityWithBucketNameRowMapper;
+import org.apache.nifi.registry.db.mapper.ExtensionBundleVersionDependencyEntityRowMapper;
+import org.apache.nifi.registry.db.mapper.ExtensionBundleVersionEntityRowMapper;
+import org.apache.nifi.registry.db.mapper.ExtensionEntityRowMapper;
 import org.apache.nifi.registry.db.mapper.FlowEntityRowMapper;
 import org.apache.nifi.registry.db.mapper.FlowSnapshotEntityRowMapper;
 import org.apache.nifi.registry.service.MetadataService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.dao.EmptyResultDataAccessException;
 import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.RowCallbackHandler;
 import org.springframework.stereotype.Repository;
 
 import java.util.ArrayList;
@@ -84,19 +95,7 @@
 
     @Override
     public void deleteBucket(final BucketEntity bucket) {
-        final String snapshotDeleteSql = "DELETE FROM flow_snapshot WHERE flow_id IN ( " +
-                    "SELECT f.id FROM flow f, bucket_item item WHERE f.id = item.id AND item.bucket_id = ?" +
-                ")";
-        jdbcTemplate.update(snapshotDeleteSql, bucket.getId());
-
-        final String flowDeleteSql = "DELETE FROM flow WHERE id IN ( " +
-                    "SELECT f.id FROM flow f, bucket_item item WHERE f.id = item.id AND item.bucket_id = ?" +
-                ")";
-        jdbcTemplate.update(flowDeleteSql, bucket.getId());
-
-        final String itemDeleteSql = "DELETE FROM bucket_item WHERE bucket_id = ?";
-        jdbcTemplate.update(itemDeleteSql, bucket.getId());
-
+        // NOTE: Cascading deletes will delete from all child tables
         final String sql = "DELETE FROM bucket WHERE id = ?";
         jdbcTemplate.update(sql, bucket.getId());
     }
@@ -128,25 +127,26 @@
 
     //----------------- BucketItems ---------------------------------
 
+    private static final String BASE_BUCKET_ITEMS_SQL =
+            "SELECT " +
+                "item.id as ID, " +
+                "item.name as NAME, " +
+                "item.description as DESCRIPTION, " +
+                "item.created as CREATED, " +
+                "item.modified as MODIFIED, " +
+                "item.item_type as ITEM_TYPE, " +
+                "b.id as BUCKET_ID, " +
+                "b.name as BUCKET_NAME ," +
+                "eb.bundle_type as BUNDLE_TYPE, " +
+                "eb.group_id as BUNDLE_GROUP_ID, " +
+                "eb.artifact_id as BUNDLE_ARTIFACT_ID " +
+            "FROM bucket_item item " +
+            "INNER JOIN bucket b ON item.bucket_id = b.id " +
+            "LEFT JOIN extension_bundle eb ON item.id = eb.id ";
+
     @Override
     public List<BucketItemEntity> getBucketItems(final String bucketIdentifier) {
-        final String sql =
-                "SELECT " +
-                    "item.id as ID, " +
-                    "item.name as NAME, " +
-                    "item.description as DESCRIPTION, " +
-                    "item.created as CREATED, " +
-                    "item.modified as MODIFIED, " +
-                    "item.item_type as ITEM_TYPE, " +
-                    "b.id as BUCKET_ID, " +
-                    "b.name as BUCKET_NAME " +
-                "FROM " +
-                        "bucket_item item, bucket b " +
-                "WHERE " +
-                        "item.bucket_id = b.id " +
-                "AND " +
-                        "item.bucket_id = ?";
-
+        final String sql = BASE_BUCKET_ITEMS_SQL + " WHERE item.bucket_id = ?";
         final List<BucketItemEntity> items = jdbcTemplate.query(sql, new Object[] { bucketIdentifier }, new BucketItemEntityRowMapper());
         return getItemsWithCounts(items);
     }
@@ -157,23 +157,7 @@
             return Collections.emptyList();
         }
 
-        final StringBuilder sqlBuilder = new StringBuilder(
-                "SELECT " +
-                        "item.id as ID, " +
-                        "item.name as NAME, " +
-                        "item.description as DESCRIPTION, " +
-                        "item.created as CREATED, " +
-                        "item.modified as MODIFIED, " +
-                        "item.item_type as ITEM_TYPE, " +
-                        "b.id as BUCKET_ID, " +
-                        "b.name as BUCKET_NAME " +
-                "FROM " +
-                        "bucket_item item, bucket b " +
-                "WHERE " +
-                        "item.bucket_id = b.id " +
-                "AND " +
-                        "item.bucket_id IN (");
-
+        final StringBuilder sqlBuilder = new StringBuilder(BASE_BUCKET_ITEMS_SQL + " WHERE item.bucket_id IN (");
         for (int i=0; i < bucketIds.size(); i++) {
             if (i > 0) {
                 sqlBuilder.append(", ");
@@ -188,6 +172,7 @@
 
     private List<BucketItemEntity> getItemsWithCounts(final Iterable<BucketItemEntity> items) {
         final Map<String,Long> snapshotCounts = getFlowSnapshotCounts();
+        final Map<String,Long> extensionBundleVersionCounts = getExtensionBundleVersionCounts();
 
         final List<BucketItemEntity> itemWithCounts = new ArrayList<>();
         for (final BucketItemEntity item : items) {
@@ -197,6 +182,12 @@
                     final FlowEntity flowEntity = (FlowEntity) item;
                     flowEntity.setSnapshotCount(snapshotCount);
                 }
+            } else if (item.getType() == BucketItemEntityType.EXTENSION_BUNDLE) {
+                final Long versionCount = extensionBundleVersionCounts.get(item.getId());
+                if (versionCount != null) {
+                    final ExtensionBundleEntity extensionBundleEntity = (ExtensionBundleEntity) item;
+                    extensionBundleEntity.setVersionCount(versionCount);
+                }
             }
 
             itemWithCounts.add(item);
@@ -223,6 +214,24 @@
         });
     }
 
+    private Map<String,Long> getExtensionBundleVersionCounts() {
+        final String sql = "SELECT extension_bundle_id, count(*) FROM extension_bundle_version GROUP BY extension_bundle_id";
+
+        final Map<String,Long> results = new HashMap<>();
+        jdbcTemplate.query(sql, (rs) -> {
+            results.put(rs.getString(1), rs.getLong(2));
+        });
+        return results;
+    }
+
+    private Long getExtensionBundleVersionCount(final String extensionBundleIdentifier) {
+        final String sql = "SELECT count(*) FROM extension_bundle_version WHERE extension_bundle_id = ?";
+
+        return jdbcTemplate.queryForObject(sql, new Object[] {extensionBundleIdentifier}, (rs, num) -> {
+            return rs.getLong(1);
+        });
+    }
+
     //----------------- Flows ---------------------------------
 
     @Override
@@ -309,12 +318,7 @@
 
     @Override
     public void deleteFlow(final FlowEntity flow) {
-        final String snapshotDeleteSql = "DELETE FROM flow_snapshot WHERE flow_id = ?";
-        jdbcTemplate.update(snapshotDeleteSql, flow.getId());
-
-        final String flowDeleteSql = "DELETE FROM flow WHERE id = ?";
-        jdbcTemplate.update(flowDeleteSql, flow.getId());
-
+        // NOTE: Cascading deletes will delete from child tables
         final String itemDeleteSql = "DELETE FROM bucket_item WHERE id = ?";
         jdbcTemplate.update(itemDeleteSql, flow.getId());
     }
@@ -401,7 +405,456 @@
         jdbcTemplate.update(sql, flowSnapshot.getFlowId(), flowSnapshot.getVersion());
     }
 
-    //----------------- BucketItems ---------------------------------
+    //----------------- Extension Bundles ---------------------------------
+
+    @Override
+    public ExtensionBundleEntity createExtensionBundle(final ExtensionBundleEntity extensionBundle) {
+        final String itemSql =
+                "INSERT INTO bucket_item (" +
+                    "ID, " +
+                    "NAME, " +
+                    "DESCRIPTION, " +
+                    "CREATED, " +
+                    "MODIFIED, " +
+                    "ITEM_TYPE, " +
+                    "BUCKET_ID) " +
+                "VALUES (?, ?, ?, ?, ?, ?, ?)";
+
+        jdbcTemplate.update(itemSql,
+                extensionBundle.getId(),
+                extensionBundle.getName(),
+                extensionBundle.getDescription(),
+                extensionBundle.getCreated(),
+                extensionBundle.getModified(),
+                extensionBundle.getType().toString(),
+                extensionBundle.getBucketId());
+
+        final String bundleSql =
+                "INSERT INTO extension_bundle (" +
+                    "ID, " +
+                    "BUCKET_ID, " +
+                    "BUNDLE_TYPE, " +
+                    "GROUP_ID, " +
+                    "ARTIFACT_ID) " +
+                "VALUES (?, ?, ?, ?, ?)";
+
+        jdbcTemplate.update(bundleSql,
+                extensionBundle.getId(),
+                extensionBundle.getBucketId(),
+                extensionBundle.getBundleType().toString(),
+                extensionBundle.getGroupId(),
+                extensionBundle.getArtifactId());
+
+        return extensionBundle;
+    }
+
+    @Override
+    public ExtensionBundleEntity getExtensionBundle(final String extensionBundleId) {
+        final String sql =
+                "SELECT * " +
+                "FROM extension_bundle eb, bucket_item item " +
+                "WHERE eb.id = ? AND item.id = eb.id";
+        try {
+            final ExtensionBundleEntity entity = jdbcTemplate.queryForObject(sql, new ExtensionBundleEntityRowMapper(), extensionBundleId);
+
+            final Long versionCount = getExtensionBundleVersionCount(extensionBundleId);
+            if (versionCount != null) {
+                entity.setVersionCount(versionCount);
+            }
+
+            return entity;
+        } catch (EmptyResultDataAccessException e) {
+            return null;
+        }
+    }
+
+    @Override
+    public ExtensionBundleEntity getExtensionBundle(final String bucketId, final String groupId, final String artifactId) {
+        final String sql =
+                "SELECT * " +
+                "FROM " +
+                        "extension_bundle eb, " +
+                        "bucket_item item " +
+                "WHERE " +
+                        "item.id = eb.id AND " +
+                        "eb.bucket_id = ? AND " +
+                        "eb.group_id = ? AND " +
+                        "eb.artifact_id = ?";
+        try {
+            final ExtensionBundleEntity entity = jdbcTemplate.queryForObject(sql, new ExtensionBundleEntityRowMapper(), bucketId, groupId, artifactId);
+
+            final Long versionCount = getExtensionBundleVersionCount(entity.getId());
+            if (versionCount != null) {
+                entity.setVersionCount(versionCount);
+            }
+
+            return entity;
+        } catch (EmptyResultDataAccessException e) {
+            return null;
+        }
+    }
+
+    @Override
+    public List<ExtensionBundleEntity> getExtensionBundles(final Set<String> bucketIds) {
+        if (bucketIds == null || bucketIds.isEmpty()) {
+            return Collections.emptyList();
+        }
+
+        final String selectSql =
+                "SELECT " +
+                        "item.id as ID, " +
+                        "item.name as NAME, " +
+                        "item.description as DESCRIPTION, " +
+                        "item.created as CREATED, " +
+                        "item.modified as MODIFIED, " +
+                        "item.item_type as ITEM_TYPE, " +
+                        "b.id as BUCKET_ID, " +
+                        "b.name as BUCKET_NAME ," +
+                        "eb.bundle_type as BUNDLE_TYPE, " +
+                        "eb.group_id as BUNDLE_GROUP_ID, " +
+                        "eb.artifact_id as BUNDLE_ARTIFACT_ID " +
+                "FROM " +
+                    "extension_bundle eb, " +
+                    "bucket_item item, " +
+                    "bucket b " +
+                "WHERE " +
+                    "item.id = eb.id AND " +
+                    "b.id = item.bucket_id";
+
+        final StringBuilder sqlBuilder = new StringBuilder(selectSql).append(" AND item.bucket_id IN (");
+        for (int i=0; i < bucketIds.size(); i++) {
+            if (i > 0) {
+                sqlBuilder.append(", ");
+            }
+            sqlBuilder.append("?");
+        }
+        sqlBuilder.append(") ");
+        sqlBuilder.append("ORDER BY eb.group_id ASC, eb.artifact_id ASC");
+
+        final List<ExtensionBundleEntity> bundleEntities = jdbcTemplate.query(sqlBuilder.toString(), bucketIds.toArray(), new ExtensionBundleEntityWithBucketNameRowMapper());
+        return populateVersionCounts(bundleEntities);
+    }
+
+    @Override
+    public List<ExtensionBundleEntity> getExtensionBundlesByBucket(final String bucketId) {
+        final String sql =
+                "SELECT * " +
+                "FROM " +
+                    "extension_bundle eb, " +
+                    "bucket_item item " +
+                "WHERE " +
+                    "item.id = eb.id AND " +
+                    "item.bucket_id = ? " +
+                    "ORDER BY eb.group_id ASC, eb.artifact_id ASC";
+
+        final List<ExtensionBundleEntity> bundles = jdbcTemplate.query(sql, new Object[]{bucketId}, new ExtensionBundleEntityRowMapper());
+        return populateVersionCounts(bundles);
+    }
+
+    @Override
+    public List<ExtensionBundleEntity> getExtensionBundlesByBucketAndGroup(String bucketId, String groupId) {
+        final String sql =
+                "SELECT * " +
+                    "FROM " +
+                        "extension_bundle eb, " +
+                        "bucket_item item " +
+                    "WHERE " +
+                        "item.id = eb.id AND " +
+                        "item.bucket_id = ? AND " +
+                        "eb.group_id = ?" +
+                    "ORDER BY eb.group_id ASC, eb.artifact_id ASC";
+
+        final List<ExtensionBundleEntity> bundles = jdbcTemplate.query(sql, new Object[]{bucketId, groupId}, new ExtensionBundleEntityRowMapper());
+        return populateVersionCounts(bundles);
+    }
+
+    private List<ExtensionBundleEntity> populateVersionCounts(final List<ExtensionBundleEntity> bundles) {
+        if (!bundles.isEmpty()) {
+            final Map<String, Long> versionCounts = getExtensionBundleVersionCounts();
+            for (final ExtensionBundleEntity entity : bundles) {
+                final Long versionCount = versionCounts.get(entity.getId());
+                if (versionCount != null) {
+                    entity.setVersionCount(versionCount);
+                }
+            }
+        }
+
+        return bundles;
+    }
+
+    @Override
+    public void deleteExtensionBundle(final ExtensionBundleEntity extensionBundle) {
+        deleteExtensionBundle(extensionBundle.getId());
+    }
+
+    @Override
+    public void deleteExtensionBundle(final String extensionBundleId) {
+        // NOTE: All of the foreign key constraints for extension related tables are set to cascade on delete
+        final String itemDeleteSql = "DELETE FROM bucket_item WHERE id = ?";
+        jdbcTemplate.update(itemDeleteSql, extensionBundleId);
+    }
+
+    //----------------- Extension Bundle Versions ---------------------------------
+
+    @Override
+    public ExtensionBundleVersionEntity createExtensionBundleVersion(final ExtensionBundleVersionEntity extensionBundleVersion) {
+        final String sql =
+                "INSERT INTO extension_bundle_version (" +
+                    "ID, " +
+                    "EXTENSION_BUNDLE_ID, " +
+                    "VERSION, " +
+                    "CREATED, " +
+                    "CREATED_BY, " +
+                    "DESCRIPTION, " +
+                    "SHA_256_HEX, " +
+                    "SHA_256_SUPPLIED " +
+                ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
+
+        jdbcTemplate.update(sql,
+                extensionBundleVersion.getId(),
+                extensionBundleVersion.getExtensionBundleId(),
+                extensionBundleVersion.getVersion(),
+                extensionBundleVersion.getCreated(),
+                extensionBundleVersion.getCreatedBy(),
+                extensionBundleVersion.getDescription(),
+                extensionBundleVersion.getSha256Hex(),
+                extensionBundleVersion.getSha256Supplied() ? 1 : 0);
+
+        return extensionBundleVersion;
+    }
+
+    @Override
+    public ExtensionBundleVersionEntity getExtensionBundleVersion(final String extensionBundleId, final String version) {
+        final String sql =
+                "SELECT * " +
+                "FROM extension_bundle_version " +
+                "WHERE extension_bundle_id = ? AND version = ?";
+
+        try {
+            return jdbcTemplate.queryForObject(sql, new ExtensionBundleVersionEntityRowMapper(), extensionBundleId, version);
+        } catch (EmptyResultDataAccessException e) {
+            return null;
+        }
+    }
+
+    private static final String BASE_EXTENSION_BUNDLE_SQL =
+            "SELECT " +
+                "ebv.id AS ID," +
+                "ebv.extension_bundle_id AS EXTENSION_BUNDLE_ID, " +
+                "ebv.version AS VERSION, " +
+                "ebv.created AS CREATED, " +
+                "ebv.created_by AS CREATED_BY, " +
+                "ebv.description AS DESCRIPTION, " +
+                "ebv.sha_256_hex AS SHA_256_HEX, " +
+                "ebv.sha_256_supplied AS SHA_256_SUPPLIED " +
+            "FROM extension_bundle eb, extension_bundle_version ebv " +
+            "WHERE eb.id = ebv.extension_bundle_id ";
+
+    @Override
+    public ExtensionBundleVersionEntity getExtensionBundleVersion(final String bucketId, final String groupId, final String artifactId, final String version) {
+        final String sql = BASE_EXTENSION_BUNDLE_SQL +
+                    "AND eb.bucket_id = ? " +
+                    "AND eb.group_id = ? " +
+                    "AND eb.artifact_id = ? " +
+                    "AND ebv.version = ?";
+
+        try {
+            return jdbcTemplate.queryForObject(sql, new ExtensionBundleVersionEntityRowMapper(), bucketId, groupId, artifactId, version);
+        } catch (EmptyResultDataAccessException e) {
+            return null;
+        }
+    }
+
+    @Override
+    public List<ExtensionBundleVersionEntity> getExtensionBundleVersions(final String extensionBundleId) {
+        final String sql = "SELECT * FROM extension_bundle_version WHERE extension_bundle_id = ?";
+        return jdbcTemplate.query(sql, new Object[]{extensionBundleId}, new ExtensionBundleVersionEntityRowMapper());
+    }
+
+    @Override
+    public List<ExtensionBundleVersionEntity> getExtensionBundleVersions(final String bucketId, final String groupId, final String artifactId) {
+        final String sql = BASE_EXTENSION_BUNDLE_SQL +
+                    "AND eb.bucket_id = ? " +
+                    "AND eb.group_id = ? " +
+                    "AND eb.artifact_id = ? ";
+
+        final Object[] args = {bucketId, groupId, artifactId};
+        return jdbcTemplate.query(sql, args, new ExtensionBundleVersionEntityRowMapper());
+    }
+
+    @Override
+    public List<ExtensionBundleVersionEntity> getExtensionBundleVersionsGlobal(final String groupId, final String artifactId, final String version) {
+        final String sql = BASE_EXTENSION_BUNDLE_SQL +
+                "AND eb.group_id = ? " +
+                "AND eb.artifact_id = ? " +
+                "AND ebv.version = ?";
+
+        final Object[] args = {groupId, artifactId, version};
+        return jdbcTemplate.query(sql, args, new ExtensionBundleVersionEntityRowMapper());
+    }
+
+    @Override
+    public void deleteExtensionBundleVersion(final ExtensionBundleVersionEntity extensionBundleVersion) {
+        deleteExtensionBundleVersion(extensionBundleVersion.getId());
+    }
+
+    @Override
+    public void deleteExtensionBundleVersion(final String extensionBundleVersionId) {
+        // NOTE: All of the foreign key constraints for extension related tables are set to cascade on delete
+        final String sql = "DELETE FROM extension_bundle_version WHERE id = ?";
+        jdbcTemplate.update(sql, extensionBundleVersionId);
+    }
+
+    //------------ Extension Bundle Version Dependencies ------------
+
+    @Override
+    public ExtensionBundleVersionDependencyEntity createDependency(final ExtensionBundleVersionDependencyEntity dependencyEntity) {
+        final String dependencySql =
+                "INSERT INTO extension_bundle_version_dependency (" +
+                    "ID, " +
+                    "EXTENSION_BUNDLE_VERSION_ID, " +
+                    "GROUP_ID, " +
+                    "ARTIFACT_ID, " +
+                    "VERSION " +
+                ") VALUES (?, ?, ?, ?, ?)";
+
+        jdbcTemplate.update(dependencySql,
+                dependencyEntity.getId(),
+                dependencyEntity.getExtensionBundleVersionId(),
+                dependencyEntity.getGroupId(),
+                dependencyEntity.getArtifactId(),
+                dependencyEntity.getVersion());
+
+        return dependencyEntity;
+    }
+
+    @Override
+    public List<ExtensionBundleVersionDependencyEntity> getDependenciesForBundleVersion(final String extensionBundleVersionId) {
+        final String sql = "SELECT * FROM extension_bundle_version_dependency WHERE extension_bundle_version_id = ?";
+        final Object[] args = {extensionBundleVersionId};
+        return jdbcTemplate.query(sql, args, new ExtensionBundleVersionDependencyEntityRowMapper());
+    }
+
+
+    //----------------- Extensions ---------------------------------
+
+    @Override
+    public ExtensionEntity createExtension(final ExtensionEntity extension) {
+        final String insertExtensionSql =
+                "INSERT INTO extension (" +
+                    "ID, " +
+                    "EXTENSION_BUNDLE_VERSION_ID, " +
+                    "TYPE, " +
+                    "TYPE_DESCRIPTION, " +
+                    "IS_RESTRICTED, " +
+                    "CATEGORY, " +
+                    "TAGS " +
+                ") VALUES (?, ?, ?, ?, ?, ?, ?)";
+
+        jdbcTemplate.update(insertExtensionSql,
+                extension.getId(),
+                extension.getExtensionBundleVersionId(),
+                extension.getType(),
+                extension.getTypeDescription(),
+                extension.isRestricted() ? 1 : 0,
+                extension.getCategory().toString(),
+                extension.getTags()
+        );
+
+        final String insertTagSql = "INSERT INTO extension_tag (EXTENSION_ID, TAG) VALUES (?, ?);";
+
+        if (extension.getTags() != null) {
+            final String tags[] = extension.getTags().split("[,]");
+            for (final String tag : tags) {
+                if (tag != null) {
+                    jdbcTemplate.update(insertTagSql, extension.getId(), tag.trim().toLowerCase());
+                }
+            }
+        }
+
+        return extension;
+    }
+
+    @Override
+    public ExtensionEntity getExtensionById(final String id) {
+        final String selectSql = "SELECT * FROM extension WHERE id = ?";
+        try {
+            return jdbcTemplate.queryForObject(selectSql, new ExtensionEntityRowMapper(), id);
+        } catch (EmptyResultDataAccessException e) {
+            return null;
+        }
+    }
+
+    @Override
+    public List<ExtensionEntity> getAllExtensions() {
+        final String selectSql = "SELECT * FROM extension ORDER BY type ASC";
+        return jdbcTemplate.query(selectSql, new ExtensionEntityRowMapper());
+    }
+
+    @Override
+    public List<ExtensionEntity> getExtensionsByBundleVersionId(final String extensionBundleVersionId) {
+        final String selectSql =
+                "SELECT * " +
+                "FROM extension " +
+                "WHERE extension_bundle_version_id = ?";
+
+        final Object[] args = { extensionBundleVersionId };
+        return jdbcTemplate.query(selectSql, args, new ExtensionEntityRowMapper());
+    }
+
+    @Override
+    public List<ExtensionEntity> getExtensionsByBundleCoordinate(final String bucketId, final String groupId, final String artifactId, final String version) {
+        final String sql =
+                "SELECT * " +
+                "FROM extension_bundle eb, extension_bundle_version ebv, extension e " +
+                "WHERE eb.id = ebv.extension_bundle_id " +
+                    "AND ebv.id = e.extension_bundle_version_id " +
+                    "AND eb.bucket_id = ? " +
+                    "AND eb.group_id = ? " +
+                    "AND eb.artifact_id = ? " +
+                    "AND ebv.version = ?";
+
+        final Object[] args = { bucketId, groupId, artifactId, version };
+        return jdbcTemplate.query(sql, args, new ExtensionEntityRowMapper());
+    }
+
+    @Override
+    public List<ExtensionEntity> getExtensionsByCategory(final ExtensionEntityCategory category) {
+        final String selectSql = "SELECT * FROM extension WHERE category = ?";
+        final Object[] args = { category.toString() };
+        return jdbcTemplate.query(selectSql, args, new ExtensionEntityRowMapper());
+    }
+
+    @Override
+    public List<ExtensionEntity> getExtensionsByTag(final String tag) {
+        final String selectSql =
+                "SELECT * " +
+                "FROM extension e, extension_tag et " +
+                "WHERE e.id = et.extension_id AND et.tag = ?";
+
+        final Object[] args = { tag };
+        return jdbcTemplate.query(selectSql, args, new ExtensionEntityRowMapper());
+    }
+
+    @Override
+    public Set<String> getAllExtensionTags() {
+        final String selectSql = "SELECT DISTINCT tag FROM extension_tag ORDER BY tag ASC";
+
+        final Set<String> tags = new LinkedHashSet<>();
+        final RowCallbackHandler handler = (rs) -> tags.add(rs.getString(1));
+        jdbcTemplate.query(selectSql, handler);
+        return tags;
+    }
+
+    @Override
+    public void deleteExtension(final ExtensionEntity extension) {
+        // NOTE: All of the foreign key constraints for extension related tables are set to cascade on delete
+        final String deleteSql = "DELETE FROM extension WHERE id = ?";
+        jdbcTemplate.update(deleteSql, extension.getId());
+    }
+
+
+    //----------------- Fields ---------------------------------
 
     @Override
     public Set<String> getBucketFields() {
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/BucketItemEntityType.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/BucketItemEntityType.java
index e78b2b1..2bbdd7c 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/BucketItemEntityType.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/BucketItemEntityType.java
@@ -21,7 +21,10 @@
  */
 public enum BucketItemEntityType {
 
-    FLOW(Values.FLOW);
+    FLOW(Values.FLOW),
+
+    EXTENSION_BUNDLE(Values.EXTENSION_BUNDLE);
+
 
     private final String value;
 
@@ -37,6 +40,7 @@
     // need these constants to reference from @DiscriminatorValue
     public static class Values {
         public static final String FLOW = "FLOW";
+        public static final String EXTENSION_BUNDLE = "EXTENSION_BUNDLE";
     }
 
 }
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionBundleEntity.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionBundleEntity.java
new file mode 100644
index 0000000..f79d385
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionBundleEntity.java
@@ -0,0 +1,73 @@
+/*
+ * 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.registry.db.entity;
+
+public class ExtensionBundleEntity extends BucketItemEntity {
+
+    private String groupId;
+
+    private String artifactId;
+
+    private ExtensionBundleEntityType bundleType;
+
+    private long versionCount;
+
+    public ExtensionBundleEntity() {
+        setType(BucketItemEntityType.EXTENSION_BUNDLE);
+    }
+
+    public ExtensionBundleEntityType getBundleType() {
+        return bundleType;
+    }
+
+    public void setBundleType(ExtensionBundleEntityType bundleType) {
+        this.bundleType = bundleType;
+    }
+
+    public String getGroupId() {
+        return groupId;
+    }
+
+    public void setGroupId(String groupId) {
+        this.groupId = groupId;
+    }
+
+    public String getArtifactId() {
+        return artifactId;
+    }
+
+    public void setArtifactId(String artifactId) {
+        this.artifactId = artifactId;
+    }
+
+    public long getVersionCount() {
+        return versionCount;
+    }
+
+    public void setVersionCount(long versionCount) {
+        this.versionCount = versionCount;
+    }
+
+    @Override
+    public void setType(BucketItemEntityType type) {
+        if (BucketItemEntityType.EXTENSION_BUNDLE != type) {
+            throw new IllegalStateException("Must set type to " + BucketItemEntityType.Values.EXTENSION_BUNDLE);
+        }
+        super.setType(type);
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionBundleEntityType.java
similarity index 77%
copy from nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java
copy to nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionBundleEntityType.java
index ec356fd..0f4950c 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionBundleEntityType.java
@@ -14,17 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.registry.web.link.builder;
-
-import javax.ws.rs.core.Link;
+package org.apache.nifi.registry.db.entity;
 
 /**
- * Creates a Link for a given type.
- *
- * @param <T> the type to create a link for
+ * The possible types of extension bundles.
  */
-public interface LinkBuilder<T> {
+public enum ExtensionBundleEntityType {
 
-    Link createLink(T t);
+    NIFI_NAR,
+
+    MINIFI_CPP;
 
 }
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionBundleVersionDependencyEntity.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionBundleVersionDependencyEntity.java
new file mode 100644
index 0000000..e6e2010
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionBundleVersionDependencyEntity.java
@@ -0,0 +1,73 @@
+/*
+ * 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.registry.db.entity;
+
+public class ExtensionBundleVersionDependencyEntity {
+
+    // Database id for this specific dependency
+    private String id;
+
+    // Foreign key to the extension bundle version this dependency goes with
+    private String extensionBundleVersionId;
+
+    // The bundle coordinates for this dependency
+    private String groupId;
+    private String artifactId;
+    private String version;
+
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    public String getExtensionBundleVersionId() {
+        return extensionBundleVersionId;
+    }
+
+    public void setExtensionBundleVersionId(String extensionBundleVersionId) {
+        this.extensionBundleVersionId = extensionBundleVersionId;
+    }
+
+    public String getGroupId() {
+        return groupId;
+    }
+
+    public void setGroupId(String groupId) {
+        this.groupId = groupId;
+    }
+
+    public String getArtifactId() {
+        return artifactId;
+    }
+
+    public void setArtifactId(String artifactId) {
+        this.artifactId = artifactId;
+    }
+
+    public String getVersion() {
+        return version;
+    }
+
+    public void setVersion(String version) {
+        this.version = version;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionBundleVersionEntity.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionBundleVersionEntity.java
new file mode 100644
index 0000000..08fc2c2
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionBundleVersionEntity.java
@@ -0,0 +1,107 @@
+/*
+ * 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.registry.db.entity;
+
+import java.util.Date;
+
+public class ExtensionBundleVersionEntity {
+
+    // Database id for this specific version of an extension bundle
+    private String id;
+
+    // Foreign key to the extension bundle this version goes with
+    private String extensionBundleId;
+
+    // The version of this bundle
+    private String version;
+
+    // General info about this version of the bundle
+    private Date created;
+    private String createdBy;
+    private String description;
+
+    // The hex representation of the SHA-256 digest for the binary content of this version
+    private String sha256Hex;
+
+    // Indicates whether the SHA-256 was supplied by the client, which means it matched the server's calculation, or was not supplied by the client
+    private boolean sha256Supplied;
+
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    public String getExtensionBundleId() {
+        return extensionBundleId;
+    }
+
+    public void setExtensionBundleId(String extensionBundleId) {
+        this.extensionBundleId = extensionBundleId;
+    }
+
+    public String getVersion() {
+        return version;
+    }
+
+    public void setVersion(String version) {
+        this.version = version;
+    }
+
+    public Date getCreated() {
+        return created;
+    }
+
+    public void setCreated(Date created) {
+        this.created = created;
+    }
+
+    public String getCreatedBy() {
+        return createdBy;
+    }
+
+    public void setCreatedBy(String createdBy) {
+        this.createdBy = createdBy;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    public void setDescription(String description) {
+        this.description = description;
+    }
+
+    public String getSha256Hex() {
+        return sha256Hex;
+    }
+
+    public void setSha256Hex(String sha256Hex) {
+        this.sha256Hex = sha256Hex;
+    }
+
+    public boolean getSha256Supplied() {
+        return sha256Supplied;
+    }
+
+    public void setSha256Supplied(boolean sha256Supplied) {
+        this.sha256Supplied = sha256Supplied;
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionEntity.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionEntity.java
new file mode 100644
index 0000000..c48ac9f
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionEntity.java
@@ -0,0 +1,92 @@
+/*
+ * 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.registry.db.entity;
+
+public class ExtensionEntity {
+
+    private String id;
+
+    private String extensionBundleVersionId;
+
+    private String type;
+
+    private String typeDescription;
+
+    private boolean restricted;
+
+    private ExtensionEntityCategory category;
+
+    // Comma separated list of tags so we don't have to query tag table for each extension
+    private String tags;
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    public String getExtensionBundleVersionId() {
+        return extensionBundleVersionId;
+    }
+
+    public void setExtensionBundleVersionId(String extensionBundleVersionId) {
+        this.extensionBundleVersionId = extensionBundleVersionId;
+    }
+
+    public String getType() {
+        return type;
+    }
+
+    public void setType(String type) {
+        this.type = type;
+    }
+
+    public String getTypeDescription() {
+        return typeDescription;
+    }
+
+    public void setTypeDescription(String typeDescription) {
+        this.typeDescription = typeDescription;
+    }
+
+    public boolean isRestricted() {
+        return restricted;
+    }
+
+    public void setRestricted(boolean restricted) {
+        this.restricted = restricted;
+    }
+
+    public ExtensionEntityCategory getCategory() {
+        return category;
+    }
+
+    public void setCategory(ExtensionEntityCategory category) {
+        this.category = category;
+    }
+
+    public String getTags() {
+        return tags;
+    }
+
+    public void setTags(String tags) {
+        this.tags = tags;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionEntityCategory.java
similarity index 77%
copy from nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java
copy to nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionEntityCategory.java
index ec356fd..b072b4a 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionEntityCategory.java
@@ -14,17 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.registry.web.link.builder;
+package org.apache.nifi.registry.db.entity;
 
-import javax.ws.rs.core.Link;
+public enum ExtensionEntityCategory {
 
-/**
- * Creates a Link for a given type.
- *
- * @param <T> the type to create a link for
- */
-public interface LinkBuilder<T> {
+    PROCESSOR,
 
-    Link createLink(T t);
+    CONTROLLER_SERVICE,
+
+    REPORTING_TASK;
 
 }
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionTagEntity.java
similarity index 64%
copy from nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java
copy to nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionTagEntity.java
index ec356fd..1612442 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/entity/ExtensionTagEntity.java
@@ -14,17 +14,28 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.registry.web.link.builder;
+package org.apache.nifi.registry.db.entity;
 
-import javax.ws.rs.core.Link;
+public class ExtensionTagEntity {
 
-/**
- * Creates a Link for a given type.
- *
- * @param <T> the type to create a link for
- */
-public interface LinkBuilder<T> {
+    private String extensionId;
 
-    Link createLink(T t);
+    private String tag;
+
+    public String getExtensionId() {
+        return extensionId;
+    }
+
+    public void setExtensionId(String extensionId) {
+        this.extensionId = extensionId;
+    }
+
+    public String getTag() {
+        return tag;
+    }
+
+    public void setTag(String tag) {
+        this.tag = tag;
+    }
 
 }
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/BucketItemEntityRowMapper.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/BucketItemEntityRowMapper.java
index 7b3df05..82c0a4c 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/BucketItemEntityRowMapper.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/BucketItemEntityRowMapper.java
@@ -18,6 +18,8 @@
 
 import org.apache.nifi.registry.db.entity.BucketItemEntity;
 import org.apache.nifi.registry.db.entity.BucketItemEntityType;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntityType;
 import org.apache.nifi.registry.db.entity.FlowEntity;
 import org.springframework.jdbc.core.RowMapper;
 import org.springframework.lang.Nullable;
@@ -38,6 +40,13 @@
             case FLOW:
                 item = new FlowEntity();
                 break;
+            case EXTENSION_BUNDLE:
+                final ExtensionBundleEntity bundleEntity = new ExtensionBundleEntity();
+                bundleEntity.setBundleType(ExtensionBundleEntityType.valueOf(rs.getString("BUNDLE_TYPE")));
+                bundleEntity.setGroupId(rs.getString("BUNDLE_GROUP_ID"));
+                bundleEntity.setArtifactId(rs.getString("BUNDLE_ARTIFACT_ID"));
+                item = bundleEntity;
+                break;
             default:
                 // should never happen
                 item = new BucketItemEntity();
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionBundleEntityRowMapper.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionBundleEntityRowMapper.java
new file mode 100644
index 0000000..6375411
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionBundleEntityRowMapper.java
@@ -0,0 +1,50 @@
+/*
+ * 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.registry.db.mapper;
+
+import org.apache.nifi.registry.db.entity.BucketItemEntityType;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntityType;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+public class ExtensionBundleEntityRowMapper implements RowMapper<ExtensionBundleEntity> {
+
+    @Override
+    public ExtensionBundleEntity mapRow(final ResultSet rs, final int i) throws SQLException {
+        final ExtensionBundleEntity entity = new ExtensionBundleEntity();
+
+        // BucketItemEntity fields
+        entity.setId(rs.getString("ID"));
+        entity.setName(rs.getString("NAME"));
+        entity.setDescription(rs.getString("DESCRIPTION"));
+        entity.setCreated(rs.getTimestamp("CREATED"));
+        entity.setModified(rs.getTimestamp("MODIFIED"));
+        entity.setBucketId(rs.getString("BUCKET_ID"));
+        entity.setType(BucketItemEntityType.EXTENSION_BUNDLE);
+
+        // ExtensionBundleEntity fields
+        entity.setBundleType(ExtensionBundleEntityType.valueOf(rs.getString("BUNDLE_TYPE")));
+        entity.setGroupId(rs.getString("GROUP_ID"));
+        entity.setArtifactId(rs.getString("ARTIFACT_ID"));
+
+        return entity;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionBundleEntityWithBucketNameRowMapper.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionBundleEntityWithBucketNameRowMapper.java
new file mode 100644
index 0000000..3fb22b6
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionBundleEntityWithBucketNameRowMapper.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.registry.db.mapper;
+
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntity;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+public class ExtensionBundleEntityWithBucketNameRowMapper extends ExtensionBundleEntityRowMapper {
+
+    @Override
+    public ExtensionBundleEntity mapRow(final ResultSet rs, final int i) throws SQLException {
+        final ExtensionBundleEntity entity = super.mapRow(rs, i);
+        entity.setBucketName(rs.getString("BUCKET_NAME"));
+        return entity;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionBundleVersionDependencyEntityRowMapper.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionBundleVersionDependencyEntityRowMapper.java
new file mode 100644
index 0000000..044e245
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionBundleVersionDependencyEntityRowMapper.java
@@ -0,0 +1,38 @@
+/*
+ * 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.registry.db.mapper;
+
+import org.apache.nifi.registry.db.entity.ExtensionBundleVersionDependencyEntity;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+public class ExtensionBundleVersionDependencyEntityRowMapper implements RowMapper<ExtensionBundleVersionDependencyEntity> {
+
+    @Override
+    public ExtensionBundleVersionDependencyEntity mapRow(final ResultSet rs, final int i) throws SQLException {
+        final ExtensionBundleVersionDependencyEntity entity = new ExtensionBundleVersionDependencyEntity();
+        entity.setId(rs.getString("ID"));
+        entity.setExtensionBundleVersionId(rs.getString("EXTENSION_BUNDLE_VERSION_ID"));
+        entity.setGroupId(rs.getString("GROUP_ID"));
+        entity.setArtifactId(rs.getString("ARTIFACT_ID"));
+        entity.setVersion(rs.getString("VERSION"));
+        return entity;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionBundleVersionEntityRowMapper.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionBundleVersionEntityRowMapper.java
new file mode 100644
index 0000000..60ca48f
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionBundleVersionEntityRowMapper.java
@@ -0,0 +1,43 @@
+/*
+ * 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.registry.db.mapper;
+
+import org.apache.nifi.registry.db.entity.ExtensionBundleVersionEntity;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+public class ExtensionBundleVersionEntityRowMapper implements RowMapper<ExtensionBundleVersionEntity> {
+
+    @Override
+    public ExtensionBundleVersionEntity mapRow(final ResultSet rs, final int i) throws SQLException {
+        final ExtensionBundleVersionEntity entity = new ExtensionBundleVersionEntity();
+        entity.setId(rs.getString("ID"));
+        entity.setExtensionBundleId(rs.getString("EXTENSION_BUNDLE_ID"));
+        entity.setVersion(rs.getString("VERSION"));
+        entity.setSha256Hex(rs.getString("SHA_256_HEX"));
+        entity.setSha256Supplied(rs.getInt("SHA_256_SUPPLIED") == 1);
+
+        entity.setCreated(rs.getTimestamp("CREATED"));
+        entity.setCreatedBy(rs.getString("CREATED_BY"));
+        entity.setDescription(rs.getString("DESCRIPTION"));
+
+        return entity;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionEntityRowMapper.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionEntityRowMapper.java
new file mode 100644
index 0000000..057fbdb
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionEntityRowMapper.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.registry.db.mapper;
+
+import org.apache.nifi.registry.db.entity.ExtensionEntity;
+import org.apache.nifi.registry.db.entity.ExtensionEntityCategory;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+public class ExtensionEntityRowMapper implements RowMapper<ExtensionEntity> {
+
+    @Override
+    public ExtensionEntity mapRow(ResultSet rs, int i) throws SQLException {
+        final ExtensionEntity entity = new ExtensionEntity();
+        entity.setId(rs.getString("ID"));
+        entity.setExtensionBundleVersionId(rs.getString("EXTENSION_BUNDLE_VERSION_ID"));
+        entity.setType(rs.getString("TYPE"));
+        entity.setTypeDescription(rs.getString("TYPE_DESCRIPTION"));
+        entity.setRestricted(rs.getInt("IS_RESTRICTED") == 1);
+        entity.setCategory(ExtensionEntityCategory.valueOf(rs.getString("CATEGORY")));
+        entity.setTags(rs.getString("TAGS"));
+        return entity;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionTagEntityRowMapper.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionTagEntityRowMapper.java
new file mode 100644
index 0000000..a3be127
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/db/mapper/ExtensionTagEntityRowMapper.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.registry.db.mapper;
+
+import org.apache.nifi.registry.db.entity.ExtensionTagEntity;
+import org.springframework.jdbc.core.RowMapper;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+public class ExtensionTagEntityRowMapper implements RowMapper<ExtensionTagEntity> {
+
+    @Override
+    public ExtensionTagEntity mapRow(final ResultSet rs, final int i) throws SQLException {
+        final ExtensionTagEntity entity = new ExtensionTagEntity();
+        entity.setExtensionId(rs.getString("EXTENSION_ID"));
+        entity.setTag(rs.getString("TAG"));
+        return entity;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/event/EventFactory.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/event/EventFactory.java
index b837d6d..5fc885b 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/event/EventFactory.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/event/EventFactory.java
@@ -17,6 +17,8 @@
 package org.apache.nifi.registry.event;
 
 import org.apache.nifi.registry.bucket.Bucket;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
 import org.apache.nifi.registry.flow.VersionedFlow;
 import org.apache.nifi.registry.flow.VersionedFlowSnapshot;
 import org.apache.nifi.registry.hook.Event;
@@ -94,4 +96,41 @@
                 .build();
     }
 
+    public static Event extensionBundleCreated(final ExtensionBundle bundle) {
+        return new StandardEvent.Builder()
+                .eventType(EventType.CREATE_EXTENSION_BUNDLE)
+                .addField(EventFieldName.BUCKET_ID, bundle.getBucketIdentifier())
+                .addField(EventFieldName.EXTENSION_BUNDLE_ID, bundle.getIdentifier())
+                .addField(EventFieldName.USER, NiFiUserUtils.getNiFiUserIdentity())
+                .build();
+    }
+
+    public static Event extensionBundleDeleted(final ExtensionBundle bundle) {
+        return new StandardEvent.Builder()
+                .eventType(EventType.DELETE_EXTENSION_BUNDLE)
+                .addField(EventFieldName.BUCKET_ID, bundle.getBucketIdentifier())
+                .addField(EventFieldName.EXTENSION_BUNDLE_ID, bundle.getIdentifier())
+                .addField(EventFieldName.USER, NiFiUserUtils.getNiFiUserIdentity())
+                .build();
+    }
+
+    public static Event extensionBundleVersionCreated(final ExtensionBundleVersion bundleVersion) {
+        return new StandardEvent.Builder()
+                .eventType(EventType.CREATE_EXTENSION_BUNDLE_VERSION)
+                .addField(EventFieldName.BUCKET_ID, bundleVersion.getVersionMetadata().getBucketId())
+                .addField(EventFieldName.EXTENSION_BUNDLE_ID, bundleVersion.getVersionMetadata().getExtensionBundleId())
+                .addField(EventFieldName.VERSION, String.valueOf(bundleVersion.getVersionMetadata().getVersion()))
+                .addField(EventFieldName.USER, NiFiUserUtils.getNiFiUserIdentity())
+                .build();
+    }
+
+    public static Event extensionBundleVersionDeleted(final ExtensionBundleVersion bundleVersion) {
+        return new StandardEvent.Builder()
+                .eventType(EventType.DELETE_EXTENSION_BUNDLE_VERSION)
+                .addField(EventFieldName.BUCKET_ID, bundleVersion.getVersionMetadata().getBucketId())
+                .addField(EventFieldName.EXTENSION_BUNDLE_ID, bundleVersion.getVersionMetadata().getExtensionBundleId())
+                .addField(EventFieldName.VERSION, String.valueOf(bundleVersion.getVersionMetadata().getVersion()))
+                .addField(EventFieldName.USER, NiFiUserUtils.getNiFiUserIdentity())
+                .build();
+    }
 }
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionManager.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionManager.java
index ca3259d..16e0e93 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionManager.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/extension/ExtensionManager.java
@@ -58,6 +58,7 @@
         classes.add(Authorizer.class);
         classes.add(IdentityProvider.class);
         classes.add(EventHookProvider.class);
+        classes.add(ExtensionBundlePersistenceProvider.class);
         EXTENSION_CLASSES = Collections.unmodifiableList(classes);
     }
 
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/ProviderFactory.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/ProviderFactory.java
index a3f3276..450868f 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/ProviderFactory.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/ProviderFactory.java
@@ -18,6 +18,7 @@
 
 import java.util.List;
 
+import org.apache.nifi.registry.extension.ExtensionBundlePersistenceProvider;
 import org.apache.nifi.registry.flow.FlowPersistenceProvider;
 import org.apache.nifi.registry.hook.EventHookProvider;
 
@@ -43,4 +44,9 @@
      */
     List<EventHookProvider> getEventHookProviders();
 
+    /**
+     * @return the configured ExtensionBundlePersistenceProvider
+     */
+    ExtensionBundlePersistenceProvider getExtensionBundlePersistenceProvider();
+
 }
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/StandardProviderFactory.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/StandardProviderFactory.java
index 65ba914..89b2586 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/StandardProviderFactory.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/StandardProviderFactory.java
@@ -16,6 +16,7 @@
  */
 package org.apache.nifi.registry.provider;
 
+import org.apache.nifi.registry.extension.ExtensionBundlePersistenceProvider;
 import org.apache.nifi.registry.extension.ExtensionManager;
 import org.apache.nifi.registry.flow.FlowPersistenceProvider;
 import org.apache.nifi.registry.hook.EventHookProvider;
@@ -75,6 +76,7 @@
 
     private FlowPersistenceProvider flowPersistenceProvider;
     private List<EventHookProvider> eventHookProviders;
+    private ExtensionBundlePersistenceProvider extensionBundlePersistenceProvider;
 
     @Autowired
     public StandardProviderFactory(final NiFiRegistryProperties properties, final ExtensionManager extensionManager) {
@@ -204,6 +206,45 @@
         return eventHookProviders;
     }
 
+    @Bean
+    @Override
+    public synchronized ExtensionBundlePersistenceProvider getExtensionBundlePersistenceProvider() {
+        if (extensionBundlePersistenceProvider == null) {
+            if (providersHolder.get() == null) {
+                throw new ProviderFactoryException("ProviderFactory must be initialized before obtaining a Provider");
+            }
+
+            final Providers providers = providersHolder.get();
+            final org.apache.nifi.registry.provider.generated.Provider jaxbExtensionBundleProvider = providers.getExtensionBundlePersistenceProvider();
+            final String extensionBundleProviderClassName = jaxbExtensionBundleProvider.getClazz();
+
+            try {
+                final ClassLoader classLoader = extensionManager.getExtensionClassLoader(extensionBundleProviderClassName);
+                if (classLoader == null) {
+                    throw new IllegalStateException("Extension not found in any of the configured class loaders: " + extensionBundleProviderClassName);
+                }
+
+                final Class<?> rawProviderClass = Class.forName(extensionBundleProviderClassName, true, classLoader);
+
+                final Class<? extends ExtensionBundlePersistenceProvider> extensionBundleProviderClass =
+                        rawProviderClass.asSubclass(ExtensionBundlePersistenceProvider.class);
+
+                final Constructor constructor = extensionBundleProviderClass.getConstructor();
+                extensionBundlePersistenceProvider = (ExtensionBundlePersistenceProvider) constructor.newInstance();
+
+                LOGGER.info("Instantiated ExtensionBundlePersistenceProvider with class name {}", new Object[] {extensionBundleProviderClassName});
+            } catch (Exception e) {
+                throw new ProviderFactoryException("Error creating ExtensionBundlePersistenceProvider with class name: " + extensionBundleProviderClassName, e);
+            }
+
+            final ProviderConfigurationContext configurationContext = createConfigurationContext(jaxbExtensionBundleProvider.getProperty());
+            extensionBundlePersistenceProvider.onConfigured(configurationContext);
+            LOGGER.info("Configured FlowPersistenceProvider with class name {}", new Object[] {extensionBundleProviderClassName});
+        }
+
+        return extensionBundlePersistenceProvider;
+    }
+
     private ProviderConfigurationContext createConfigurationContext(final List<Property> configProperties) {
         final Map<String,String> properties = new HashMap<>();
 
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/extension/FileSystemExtensionBundlePersistenceProvider.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/extension/FileSystemExtensionBundlePersistenceProvider.java
new file mode 100644
index 0000000..1d8c9cc
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/extension/FileSystemExtensionBundlePersistenceProvider.java
@@ -0,0 +1,231 @@
+/*
+ * 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.registry.provider.extension;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.registry.extension.ExtensionBundleContext;
+import org.apache.nifi.registry.extension.ExtensionBundlePersistenceException;
+import org.apache.nifi.registry.extension.ExtensionBundlePersistenceProvider;
+import org.apache.nifi.registry.flow.FlowPersistenceException;
+import org.apache.nifi.registry.provider.ProviderConfigurationContext;
+import org.apache.nifi.registry.provider.ProviderCreationException;
+import org.apache.nifi.registry.util.FileUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Map;
+
+/**
+ * An {@link ExtensionBundlePersistenceProvider} that uses local file-system for storage.
+ */
+public class FileSystemExtensionBundlePersistenceProvider implements ExtensionBundlePersistenceProvider {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(FileSystemExtensionBundlePersistenceProvider.class);
+
+    static final String BUNDLE_STORAGE_DIR_PROP = "Extension Bundle Storage Directory";
+
+    static final String NAR_EXTENSION = ".nar";
+    static final String CPP_EXTENSION = ".cpp";
+
+    private File bundleStorageDir;
+
+    @Override
+    public void onConfigured(final ProviderConfigurationContext configurationContext)
+            throws ProviderCreationException {
+        final Map<String,String> props = configurationContext.getProperties();
+        if (!props.containsKey(BUNDLE_STORAGE_DIR_PROP)) {
+            throw new ProviderCreationException("The property " + BUNDLE_STORAGE_DIR_PROP + " must be provided");
+        }
+
+        final String bundleStorageDirValue = props.get(BUNDLE_STORAGE_DIR_PROP);
+        if (StringUtils.isBlank(bundleStorageDirValue)) {
+            throw new ProviderCreationException("The property " + BUNDLE_STORAGE_DIR_PROP + " cannot be null or blank");
+        }
+
+        try {
+            bundleStorageDir = new File(bundleStorageDirValue);
+            FileUtils.ensureDirectoryExistAndCanReadAndWrite(bundleStorageDir);
+            LOGGER.info("Configured ExtensionBundlePersistenceProvider with Extension Bundle Storage Directory {}",
+                    new Object[] {bundleStorageDir.getAbsolutePath()});
+        } catch (IOException e) {
+            throw new ProviderCreationException(e);
+        }
+    }
+
+    @Override
+    public synchronized void saveBundleVersion(final ExtensionBundleContext context, final InputStream contentStream)
+            throws ExtensionBundlePersistenceException {
+
+        final File bundleVersionDir = getBundleVersionDirectory(bundleStorageDir, context.getBucketName(),
+                context.getBundleGroupId(), context.getBundleArtifactId(), context.getBundleVersion());
+        try {
+            FileUtils.ensureDirectoryExistAndCanReadAndWrite(bundleVersionDir);
+        } catch (IOException e) {
+            throw new FlowPersistenceException("Error accessing directory for extension bundle version at "
+                    + bundleVersionDir.getAbsolutePath(), e);
+        }
+
+        final File bundleFile = getBundleFile(bundleVersionDir, context.getBundleArtifactId(),
+                context.getBundleVersion(), context.getBundleType());
+
+        if (bundleFile.exists()) {
+            throw new ExtensionBundlePersistenceException("Unable to save because an extension bundle already exists at "
+                    + bundleFile.getAbsolutePath());
+        }
+
+        if (LOGGER.isDebugEnabled()) {
+            LOGGER.debug("Writing extension bundle to {}", new Object[]{bundleFile.getAbsolutePath()});
+        }
+
+        try (final OutputStream out = new FileOutputStream(bundleFile)) {
+            IOUtils.copy(contentStream, out);
+            out.flush();
+        } catch (Exception e) {
+            throw new FlowPersistenceException("Unable to write bundle file to disk due to " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public synchronized void getBundleVersion(final ExtensionBundleContext context, final OutputStream outputStream)
+            throws ExtensionBundlePersistenceException {
+
+        final File bundleVersionDir = getBundleVersionDirectory(bundleStorageDir, context.getBucketName(),
+                context.getBundleGroupId(), context.getBundleArtifactId(), context.getBundleVersion());
+
+        final File bundleFile = getBundleFile(bundleVersionDir, context.getBundleArtifactId(),
+                context.getBundleVersion(), context.getBundleType());
+
+        if (LOGGER.isDebugEnabled()) {
+            LOGGER.debug("Reading extension bundle from {}", new Object[]{bundleFile.getAbsolutePath()});
+        }
+
+        try (final InputStream in = new FileInputStream(bundleFile);
+             final BufferedInputStream bufIn = new BufferedInputStream(in)) {
+            IOUtils.copy(bufIn, outputStream);
+            outputStream.flush();
+        } catch (FileNotFoundException e) {
+            throw new ExtensionBundlePersistenceException("Extension bundle content was not found for: " + bundleFile.getAbsolutePath(), e);
+        } catch (IOException e) {
+            throw new ExtensionBundlePersistenceException("Error reading extension bundle content", e);
+        }
+    }
+
+    @Override
+    public synchronized void deleteBundleVersion(final ExtensionBundleContext context) throws ExtensionBundlePersistenceException {
+        final File bundleVersionDir = getBundleVersionDirectory(bundleStorageDir, context.getBucketName(),
+                context.getBundleGroupId(), context.getBundleArtifactId(), context.getBundleVersion());
+
+        final File bundleFile = getBundleFile(bundleVersionDir, context.getBundleArtifactId(),
+                context.getBundleVersion(), context.getBundleType());
+
+        if (!bundleFile.exists()) {
+            LOGGER.warn("Extension bundle content does not exist at {}", new Object[] {bundleFile.getAbsolutePath()});
+            return;
+        }
+
+        final boolean deleted = bundleFile.delete();
+        if (!deleted) {
+            throw new ExtensionBundlePersistenceException("Unable to delete extension bundle content at " + bundleFile.getAbsolutePath());
+        }
+
+        if (LOGGER.isDebugEnabled()) {
+            LOGGER.debug("Deleted extension bundle content at {}", new Object[] {bundleFile.getAbsolutePath()});
+        }
+    }
+
+    @Override
+    public synchronized void deleteAllBundleVersions(final String bucketId, final String bucketName, final String groupId, final String artifactId)
+            throws ExtensionBundlePersistenceException {
+
+        final File bundleDir = getBundleDirectory(bundleStorageDir, bucketName, groupId, artifactId);
+        if (!bundleDir.exists()) {
+            LOGGER.warn("Extension bundle directory does not exist at {}", new Object[] {bundleDir.getAbsolutePath()});
+            return;
+        }
+
+        // delete everything under the bundle directory
+        try {
+            org.apache.commons.io.FileUtils.cleanDirectory(bundleDir);
+        } catch (IOException e) {
+            throw new FlowPersistenceException("Error deleting extension bundles at " + bundleDir.getAbsolutePath(), e);
+        }
+
+        // delete the directory for the bundle
+        final boolean bundleDirDeleted = bundleDir.delete();
+        if (!bundleDirDeleted) {
+            LOGGER.error("Unable to delete extension bundle directory: " + bundleDir.getAbsolutePath());
+        }
+
+        // delete the directory for the group and bucket if there is nothing left
+        final File groupDir = bundleDir.getParentFile();
+        final File[] groupFiles = groupDir.listFiles();
+        if (groupFiles.length == 0) {
+            final boolean deletedGroup = groupDir.delete();
+            if (!deletedGroup) {
+                LOGGER.error("Unable to delete group directory: " + groupDir.getAbsolutePath());
+            } else {
+                final File bucketDir = groupDir.getParentFile();
+                final File[] bucketFiles = bucketDir.listFiles();
+                if (bucketFiles.length == 0){
+                    final boolean deletedBucket = bucketDir.delete();
+                    if (!deletedBucket) {
+                        LOGGER.error("Unable to delete bucket directory: " + bucketDir.getAbsolutePath());
+                    }
+                }
+            }
+        }
+    }
+
+    static File getBundleDirectory(final File bundleStorageDir, final String bucketName, final String groupId, final String artifactId) {
+        return new File(bundleStorageDir, sanitize(bucketName) + "/" + sanitize(groupId) + "/" + sanitize(artifactId));
+    }
+
+    static File getBundleVersionDirectory(final File bundleStorageDir, final String bucketName, final String groupId, final String artifactId, final String version) {
+        return new File(bundleStorageDir, sanitize(bucketName) + "/" + sanitize(groupId) + "/" + sanitize(artifactId) + "/" + sanitize(version));
+    }
+
+    static File getBundleFile(final File parentDir, final String artifactId, final String version, final ExtensionBundleContext.BundleType bundleType) {
+        final String bundleFileExtension = getBundleFileExtension(bundleType);
+        final String bundleFilename = sanitize(artifactId) + "-" + sanitize(version) + bundleFileExtension;
+        return new File(parentDir, bundleFilename);
+    }
+
+    static String sanitize(final String input) {
+        return FileUtils.sanitizeFilename(input).trim().toLowerCase();
+    }
+
+    static String getBundleFileExtension(final ExtensionBundleContext.BundleType bundleType) {
+        switch (bundleType) {
+            case NIFI_NAR:
+                return NAR_EXTENSION;
+            case MINIFI_CPP:
+                return CPP_EXTENSION;
+            default:
+                throw new IllegalArgumentException("Unknown bundle type: " + bundleType);
+        }
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/extension/StandardExtensionBundleContext.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/extension/StandardExtensionBundleContext.java
new file mode 100644
index 0000000..8b3e068
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/provider/extension/StandardExtensionBundleContext.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.registry.provider.extension;
+
+import org.apache.commons.lang3.Validate;
+import org.apache.nifi.registry.extension.ExtensionBundleContext;
+
+public class StandardExtensionBundleContext implements ExtensionBundleContext {
+
+    private final BundleType bundleType;
+    private final String bucketId;
+    private final String bucketName;
+    private final String bundleId;
+    private final String bundleGroupId;
+    private final String bundleArtifactId;
+    private final String bundleVersion;
+    private final String description;
+    private final String author;
+    private final long timestamp;
+
+    private StandardExtensionBundleContext(final Builder builder) {
+        this.bundleType = builder.bundleType;
+        this.bucketId = builder.bucketId;
+        this.bucketName = builder.bucketName;
+        this.bundleId = builder.bundleId;
+        this.bundleGroupId = builder.bundleGroupId;
+        this.bundleArtifactId = builder.bundleArtifactId;
+        this.bundleVersion = builder.bundleVersion;
+        this.description = builder.description;
+        this.author = builder.author;
+        this.timestamp = builder.timestamp;
+        Validate.notNull(this.bundleType);
+        Validate.notBlank(this.bucketId);
+        Validate.notBlank(this.bucketName);
+        Validate.notBlank(this.bundleId);
+        Validate.notBlank(this.bundleGroupId);
+        Validate.notBlank(this.bundleArtifactId);
+        Validate.notBlank(this.bundleVersion);
+        Validate.notBlank(this.author);
+    }
+
+
+    @Override
+    public BundleType getBundleType() {
+        return bundleType;
+    }
+
+    @Override
+    public String getBucketId() {
+        return bucketId;
+    }
+
+    @Override
+    public String getBucketName() {
+        return bucketName;
+    }
+
+    @Override
+    public String getBundleId() {
+        return bundleId;
+    }
+
+    @Override
+    public String getBundleGroupId() {
+        return bundleGroupId;
+    }
+
+    @Override
+    public String getBundleArtifactId() {
+        return bundleArtifactId;
+    }
+
+    @Override
+    public String getBundleVersion() {
+        return bundleVersion;
+    }
+
+    @Override
+    public String getDescription() {
+        return description;
+    }
+
+    @Override
+    public long getTimestamp() {
+        return timestamp;
+    }
+
+    @Override
+    public String getAuthor() {
+        return author;
+    }
+
+    public static class Builder {
+
+        private BundleType bundleType;
+        private String bucketId;
+        private String bucketName;
+        private String bundleId;
+        private String bundleGroupId;
+        private String bundleArtifactId;
+        private String bundleVersion;
+        private String description;
+        private String author;
+        private long timestamp;
+
+        public Builder bundleType(final BundleType bundleType) {
+            this.bundleType = bundleType;
+            return this;
+        }
+
+        public Builder bucketId(final String bucketId) {
+            this.bucketId = bucketId;
+            return this;
+        }
+
+        public Builder bucketName(final String bucketName) {
+            this.bucketName = bucketName;
+            return this;
+        }
+
+        public Builder bundleId(final String bundleId) {
+            this.bundleId = bundleId;
+            return this;
+        }
+
+        public Builder bundleGroupId(final String bundleGroupId) {
+            this.bundleGroupId = bundleGroupId;
+            return this;
+        }
+
+        public Builder bundleArtifactId(final String bundleArtifactId) {
+            this.bundleArtifactId = bundleArtifactId;
+            return this;
+        }
+
+        public Builder bundleVersion(final String bundleVersion) {
+            this.bundleVersion = bundleVersion;
+            return this;
+        }
+
+        public Builder description(final String description) {
+            this.description = description;
+            return this;
+        }
+
+        public Builder author(final String author) {
+            this.author = author;
+            return this;
+        }
+
+        public Builder timestamp(final long timestamp) {
+            this.timestamp = timestamp;
+            return this;
+        }
+
+        public StandardExtensionBundleContext build() {
+            return new StandardExtensionBundleContext(this);
+        }
+
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/DataModelMapper.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/DataModelMapper.java
index 3436662..6974cb9 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/DataModelMapper.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/DataModelMapper.java
@@ -19,11 +19,19 @@
 import org.apache.nifi.registry.bucket.Bucket;
 import org.apache.nifi.registry.db.entity.BucketEntity;
 import org.apache.nifi.registry.db.entity.BucketItemEntityType;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntityType;
+import org.apache.nifi.registry.db.entity.ExtensionBundleVersionDependencyEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleVersionEntity;
 import org.apache.nifi.registry.db.entity.FlowEntity;
 import org.apache.nifi.registry.db.entity.FlowSnapshotEntity;
 import org.apache.nifi.registry.db.entity.KeyEntity;
 import org.apache.nifi.registry.diff.ComponentDifference;
 import org.apache.nifi.registry.diff.ComponentDifferenceGroup;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+import org.apache.nifi.registry.extension.ExtensionBundleType;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionDependency;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionMetadata;
 import org.apache.nifi.registry.flow.VersionedComponent;
 import org.apache.nifi.registry.flow.VersionedFlow;
 import org.apache.nifi.registry.flow.VersionedFlowSnapshotMetadata;
@@ -145,6 +153,122 @@
         return null;
     }
 
+    // -- Map ExtensionBundleType
+
+    public static ExtensionBundleEntityType map(final ExtensionBundleType bundleType) {
+        switch (bundleType) {
+            case NIFI_NAR:
+                return ExtensionBundleEntityType.NIFI_NAR;
+
+            case MINIFI_CPP:
+                return ExtensionBundleEntityType.MINIFI_CPP;
+            default:
+                throw new IllegalArgumentException("Unknown bundle type: " + bundleType);
+        }
+    }
+
+    public static ExtensionBundleType map(final ExtensionBundleEntityType bundleEntityType) {
+        switch (bundleEntityType) {
+            case NIFI_NAR:
+                return ExtensionBundleType.NIFI_NAR;
+            case MINIFI_CPP:
+                return ExtensionBundleType.MINIFI_CPP;
+            default:
+                throw new IllegalArgumentException("Unknown bundle type: " + bundleEntityType);
+        }
+    }
+
+    // -- Map ExtensionBundle
+
+    public static ExtensionBundleEntity map(final ExtensionBundle bundle) {
+        final ExtensionBundleEntity entity = new ExtensionBundleEntity();
+        entity.setId(bundle.getIdentifier());
+        entity.setName(bundle.getName());
+        entity.setDescription(bundle.getDescription());
+        entity.setCreated(new Date(bundle.getCreatedTimestamp()));
+        entity.setModified(new Date(bundle.getModifiedTimestamp()));
+        entity.setType(BucketItemEntityType.EXTENSION_BUNDLE);
+        entity.setBucketId(bundle.getBucketIdentifier());
+
+        entity.setGroupId(bundle.getGroupId());
+        entity.setArtifactId(bundle.getArtifactId());
+        entity.setBundleType(map(bundle.getBundleType()));
+        return entity;
+    }
+
+    public static ExtensionBundle map(final BucketEntity bucketEntity, final ExtensionBundleEntity bundleEntity) {
+        final ExtensionBundle bundle = new ExtensionBundle();
+        bundle.setIdentifier(bundleEntity.getId());
+        bundle.setName(bundleEntity.getName());
+        bundle.setDescription(bundleEntity.getDescription());
+        bundle.setCreatedTimestamp(bundleEntity.getCreated().getTime());
+        bundle.setModifiedTimestamp(bundleEntity.getModified().getTime());
+        bundle.setBucketIdentifier(bundleEntity.getBucketId());
+
+        if (bucketEntity != null) {
+            bundle.setBucketName(bucketEntity.getName());
+        } else {
+            bundle.setBucketName(bundleEntity.getBucketName());
+        }
+
+        bundle.setGroupId(bundleEntity.getGroupId());
+        bundle.setArtifactId(bundleEntity.getArtifactId());
+        bundle.setBundleType(map(bundleEntity.getBundleType()));
+        bundle.setVersionCount(bundleEntity.getVersionCount());
+        return bundle;
+    }
+
+    // -- Map ExtensionBundleVersion
+
+    public static ExtensionBundleVersionEntity map(final ExtensionBundleVersionMetadata bundleVersionMetadata) {
+        final ExtensionBundleVersionEntity entity = new ExtensionBundleVersionEntity();
+        entity.setId(bundleVersionMetadata.getId());
+        entity.setExtensionBundleId(bundleVersionMetadata.getExtensionBundleId());
+        entity.setVersion(bundleVersionMetadata.getVersion());
+        entity.setCreated(new Date(bundleVersionMetadata.getTimestamp()));
+        entity.setCreatedBy(bundleVersionMetadata.getAuthor());
+        entity.setDescription(bundleVersionMetadata.getDescription());
+        entity.setSha256Hex(bundleVersionMetadata.getSha256());
+        entity.setSha256Supplied(bundleVersionMetadata.getSha256Supplied());
+        return entity;
+    }
+
+    public static ExtensionBundleVersionMetadata map(final BucketEntity bucketEntity, final ExtensionBundleVersionEntity bundleVersionEntity) {
+        final ExtensionBundleVersionMetadata bundleVersionMetadata = new ExtensionBundleVersionMetadata();
+        bundleVersionMetadata.setId(bundleVersionEntity.getId());
+        bundleVersionMetadata.setExtensionBundleId(bundleVersionEntity.getExtensionBundleId());
+        bundleVersionMetadata.setVersion(bundleVersionEntity.getVersion());
+        bundleVersionMetadata.setTimestamp(bundleVersionEntity.getCreated().getTime());
+        bundleVersionMetadata.setAuthor(bundleVersionEntity.getCreatedBy());
+        bundleVersionMetadata.setDescription(bundleVersionEntity.getDescription());
+        bundleVersionMetadata.setSha256(bundleVersionEntity.getSha256Hex());
+        bundleVersionMetadata.setSha256Supplied(bundleVersionEntity.getSha256Supplied());
+
+        if (bucketEntity != null) {
+            bundleVersionMetadata.setBucketId(bucketEntity.getId());
+        }
+
+        return bundleVersionMetadata;
+    }
+
+    // -- Map ExtensionBundleVersionDependency
+
+    public static ExtensionBundleVersionDependencyEntity map(final ExtensionBundleVersionDependency bundleVersionDependency) {
+        final ExtensionBundleVersionDependencyEntity entity = new ExtensionBundleVersionDependencyEntity();
+        entity.setGroupId(bundleVersionDependency.getGroupId());
+        entity.setArtifactId(bundleVersionDependency.getArtifactId());
+        entity.setVersion(bundleVersionDependency.getVersion());
+        return entity;
+    }
+
+    public static ExtensionBundleVersionDependency map(final ExtensionBundleVersionDependencyEntity dependencyEntity) {
+        final ExtensionBundleVersionDependency dependency = new ExtensionBundleVersionDependency();
+        dependency.setGroupId(dependencyEntity.getGroupId());
+        dependency.setArtifactId(dependencyEntity.getArtifactId());
+        dependency.setVersion(dependencyEntity.getVersion());
+        return dependency;
+    }
+
     // --- Map keys
 
     public static Key map(final KeyEntity keyEntity) {
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/ExtensionBundleMetadataExtractors.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/ExtensionBundleMetadataExtractors.java
new file mode 100644
index 0000000..2746c0c
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/ExtensionBundleMetadataExtractors.java
@@ -0,0 +1,45 @@
+/*
+ * 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.registry.service;
+
+import org.apache.nifi.registry.extension.BundleExtractor;
+import org.apache.nifi.registry.extension.ExtensionBundleType;
+import org.apache.nifi.registry.extension.minificpp.MiNiFiCppBundleExtractor;
+import org.apache.nifi.registry.extension.nar.NarBundleExtractor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Configuration
+public class ExtensionBundleMetadataExtractors {
+
+    private Map<ExtensionBundleType, BundleExtractor> extractors;
+
+    @Bean
+    public synchronized Map<ExtensionBundleType, BundleExtractor> getExtractors() {
+        if (extractors == null) {
+            extractors = new HashMap<>();
+            extractors.put(ExtensionBundleType.NIFI_NAR, new NarBundleExtractor());
+            extractors.put(ExtensionBundleType.MINIFI_CPP, new MiNiFiCppBundleExtractor());
+        }
+
+        return extractors;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/MetadataService.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/MetadataService.java
index ea0b214..1dc90d4 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/MetadataService.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/MetadataService.java
@@ -18,6 +18,11 @@
 
 import org.apache.nifi.registry.db.entity.BucketEntity;
 import org.apache.nifi.registry.db.entity.BucketItemEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleVersionDependencyEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleVersionEntity;
+import org.apache.nifi.registry.db.entity.ExtensionEntity;
+import org.apache.nifi.registry.db.entity.ExtensionEntityCategory;
 import org.apache.nifi.registry.db.entity.FlowEntity;
 import org.apache.nifi.registry.db.entity.FlowSnapshotEntity;
 
@@ -215,6 +220,234 @@
     // --------------------------------------------------------------------------------------------
 
     /**
+     * Creates the given extension bundle.
+     *
+     * @param extensionBundle the extension bundle to create
+     * @return the created extension bundle
+     */
+    ExtensionBundleEntity createExtensionBundle(ExtensionBundleEntity extensionBundle);
+
+    /**
+     * Retrieves the extension bundle with the given id.
+     *
+     * @param extensionBundleId the id of the extension bundle
+     * @return the extension bundle with the id, or null if one does not exist
+     */
+    ExtensionBundleEntity getExtensionBundle(String extensionBundleId);
+
+    /**
+     * Retrieves the extension bundle in the given bucket with the given group and artifact id.
+     *
+     * @return the extension bundle, or null if one does not exist
+     */
+    ExtensionBundleEntity getExtensionBundle(String bucketId, String groupId, String artifactId);
+
+    /**
+     * Retrieves all extension bundles in the buckets with the given bucket ids.
+     *
+     * @param bucketIds the bucket ids
+     * @return the list of all extension bundles in the given buckets
+     */
+    List<ExtensionBundleEntity> getExtensionBundles(Set<String> bucketIds);
+
+    /**
+     * Retrieves the extension bundles for the given bucket.
+     *
+     * @param bucketId the bucket id
+     * @return the list of extension bundles for the bucket
+     */
+    List<ExtensionBundleEntity> getExtensionBundlesByBucket(String bucketId);
+
+    /**
+     * Retrieves the extension bundles for the given bucket and group.
+     *
+     * @param bucketId the bucket id
+     * @param groupId the group id
+     * @return the list of extension bundles for the bucket and group
+     */
+    List<ExtensionBundleEntity> getExtensionBundlesByBucketAndGroup(String bucketId, String groupId);
+
+    /**
+     * Deletes the given extension bundle.
+     *
+     * @param extensionBundle the extension bundle to delete
+     */
+    void deleteExtensionBundle(ExtensionBundleEntity extensionBundle);
+
+    /**
+     * Deletes the extension bundle with the given id.
+     *
+     * @param extensionBundleId the id extension bundle to delete
+     */
+    void deleteExtensionBundle(String extensionBundleId);
+
+    // --------------------------------------------------------------------------------------------
+
+    /**
+     * Creates a version of an extension bundle.
+     *
+     * @param extensionBundleVersion the bundle version to create
+     * @return the created bundle version
+     */
+    ExtensionBundleVersionEntity createExtensionBundleVersion(ExtensionBundleVersionEntity extensionBundleVersion);
+
+    /**
+     * Retrieves the extension bundle version for the given bundle id and version.
+     *
+     * @param extensionBundleId the id of the extension bundle
+     * @param version the version of the extension bundle
+     * @return the extension bundle version, or null if does not exist
+     */
+    ExtensionBundleVersionEntity getExtensionBundleVersion(String extensionBundleId, String version);
+
+    /**
+     * Retrieves the extension bundle version by bucket, group, artifact, version.
+     *
+     * @param bucketId the bucket id
+     * @param groupId the group id
+     * @param artifactId the artifact id
+     * @param version the version
+     * @return the extension bundle version, or null if does not exist
+     */
+    ExtensionBundleVersionEntity getExtensionBundleVersion(String bucketId, String groupId, String artifactId, String version);
+
+    /**
+     * Retrieves the extension bundle versions for the given extension bundle id.
+     *
+     * @param extensionBundleId the extension bundle id
+     * @return the list of extension bundle versions
+     */
+    List<ExtensionBundleVersionEntity> getExtensionBundleVersions(String extensionBundleId);
+
+    /**
+     * Retrieves the extension bundle version with the given group id and artifact id in the given bucket.
+     *
+     * @param bucketId the bucket id
+     * @param groupId the group id
+     * @param artifactId the artifact id
+     * @return the list of extension bundles
+     */
+    List<ExtensionBundleVersionEntity> getExtensionBundleVersions(String bucketId, String groupId, String artifactId);
+
+    /**
+     * Retrieves the extension bundle versions with the given group id, artifact id, and version across all buckets.
+     *
+     * @param groupId the group id
+     * @param artifactId the artifact id
+     * @param version the versions
+     * @return all bundle versions for the group id, artifact id, and version
+     */
+    List<ExtensionBundleVersionEntity> getExtensionBundleVersionsGlobal(String groupId, String artifactId, String version);
+
+    /**
+     * Deletes the extension bundle version.
+     *
+     * @param extensionBundleVersion the extension bundle version to delete
+     */
+    void deleteExtensionBundleVersion(ExtensionBundleVersionEntity extensionBundleVersion);
+
+    /**
+     * Deletes the extension bundle version.
+     *
+     * @param extensionBundleVersionId the id of the extension bundle version
+     */
+    void deleteExtensionBundleVersion(String extensionBundleVersionId);
+
+    // --------------------------------------------------------------------------------------------
+
+    /**
+     * Creates the given extension bundle version dependency.
+     *
+     * @param dependencyEntity the dependency entity
+     * @return the created dependency
+     */
+    ExtensionBundleVersionDependencyEntity createDependency(ExtensionBundleVersionDependencyEntity dependencyEntity);
+
+    /**
+     * Retrieves the bundle dependencies for the given bundle version.
+     *
+     * @param extensionBundleVersionId the id of the extension bundle version
+     * @return the list of dependencies
+     */
+    List<ExtensionBundleVersionDependencyEntity> getDependenciesForBundleVersion(String extensionBundleVersionId);
+
+    // --------------------------------------------------------------------------------------------
+
+    /**
+     * Creates the given extension.
+     *
+     * @param extension the extension to create
+     * @return the created extension
+     */
+    ExtensionEntity createExtension(ExtensionEntity extension);
+
+    /**
+     * Retrieves the extension with the given id.
+     *
+     * @param id the id of the extension
+     * @return the extension with the id, or null if one does not exist
+     */
+    ExtensionEntity getExtensionById(String id);
+
+    /**
+     * Retrieves all extensions.
+     *
+     * @return the list of all extensions
+     */
+    List<ExtensionEntity> getAllExtensions();
+
+    /**
+     * Retrieves the extensions for the given extension bundle version.
+     *
+     * @param extensionBundleVersionId the id of the extension bundle version
+     * @return the extensions in the given bundle
+     */
+    List<ExtensionEntity> getExtensionsByBundleVersionId(String extensionBundleVersionId);
+
+    /**
+     * Retrieves the extensions for the bundle in the given bucket with the given group, artifact, and version.
+     *
+     * @param bucketId the bucket of the bundle
+     * @param groupId the group of the bundle
+     * @param artifactId the artifact id of the bundle
+     * @param version the version of the bundle
+     * @return the extensions for the bundle
+     */
+    List<ExtensionEntity> getExtensionsByBundleCoordinate(String bucketId, String groupId, String artifactId, String version);
+
+    /**
+     * Retrieves the extensions for the given category (i.e. processor, controller service, reporting task).
+     *
+     * @param category the category
+     * @return the extensions for the given category
+     */
+    List<ExtensionEntity> getExtensionsByCategory(ExtensionEntityCategory category);
+
+    /**
+     * Retrieves the extensions with the given tag.
+     *
+     * @param tag the tag
+     * @return the extensions with the given tag
+     */
+    List<ExtensionEntity> getExtensionsByTag(String tag);
+
+    /**
+     * Retrieves the set of all extension tags.
+     *
+     * @return the set of all extension tags
+     */
+    Set<String> getAllExtensionTags();
+
+    /**
+     * Deletes the given extension.
+     *
+     * @param extension the extension to delete
+     */
+    void deleteExtension(ExtensionEntity extension);
+
+    // --------------------------------------------------------------------------------------------
+
+    /**
      * @return the set of field names for Buckets
      */
     Set<String> getBucketFields();
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/RegistryService.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/RegistryService.java
index 23f1d14..091803f 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/RegistryService.java
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/RegistryService.java
@@ -23,11 +23,20 @@
 import org.apache.nifi.registry.bucket.BucketItem;
 import org.apache.nifi.registry.db.entity.BucketEntity;
 import org.apache.nifi.registry.db.entity.BucketItemEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntity;
 import org.apache.nifi.registry.db.entity.FlowEntity;
 import org.apache.nifi.registry.db.entity.FlowSnapshotEntity;
 import org.apache.nifi.registry.diff.ComponentDifferenceGroup;
 import org.apache.nifi.registry.diff.VersionedFlowDifference;
 import org.apache.nifi.registry.exception.ResourceNotFoundException;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+import org.apache.nifi.registry.extension.ExtensionBundleType;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionMetadata;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoArtifact;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoBucket;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoGroup;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersionSummary;
 import org.apache.nifi.registry.flow.FlowPersistenceProvider;
 import org.apache.nifi.registry.flow.FlowSnapshotContext;
 import org.apache.nifi.registry.flow.VersionedComponent;
@@ -44,6 +53,8 @@
 import org.apache.nifi.registry.flow.diff.StandardFlowComparator;
 import org.apache.nifi.registry.provider.flow.StandardFlowSnapshotContext;
 import org.apache.nifi.registry.serialization.Serializer;
+import org.apache.nifi.registry.service.extension.ExtensionBundleVersionCoordinate;
+import org.apache.nifi.registry.service.extension.ExtensionService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -55,7 +66,9 @@
 import javax.validation.Validator;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
+import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -85,6 +98,7 @@
     private final MetadataService metadataService;
     private final FlowPersistenceProvider flowPersistenceProvider;
     private final Serializer<VersionedProcessGroup> processGroupSerializer;
+    private final ExtensionService extensionService;
     private final Validator validator;
 
     private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
@@ -95,14 +109,17 @@
     public RegistryService(final MetadataService metadataService,
                            final FlowPersistenceProvider flowPersistenceProvider,
                            final Serializer<VersionedProcessGroup> processGroupSerializer,
+                           final ExtensionService extensionService,
                            final Validator validator) {
         this.metadataService = metadataService;
         this.flowPersistenceProvider = flowPersistenceProvider;
         this.processGroupSerializer = processGroupSerializer;
+        this.extensionService = extensionService;
         this.validator = validator;
         Validate.notNull(this.metadataService);
         Validate.notNull(this.flowPersistenceProvider);
         Validate.notNull(this.processGroupSerializer);
+        Validate.notNull(this.extensionService);
         Validate.notNull(this.validator);
     }
 
@@ -159,6 +176,25 @@
         }
     }
 
+    public Bucket getBucketByName(final String bucketName) {
+        if (bucketName == null) {
+            throw new IllegalArgumentException("Bucket name cannot be null");
+        }
+
+        readLock.lock();
+        try {
+            final List<BucketEntity> buckets = metadataService.getBucketsByName(bucketName);
+            if (buckets.isEmpty()) {
+                LOGGER.warn("The specified bucket name [{}] does not exist.", bucketName);
+                throw new ResourceNotFoundException("The specified bucket name does not exist in this registry.");
+            }
+
+            return DataModelMapper.map(buckets.get(0));
+        } finally {
+            readLock.unlock();
+        }
+    }
+
     public List<Bucket> getBuckets() {
         readLock.lock();
         try {
@@ -298,11 +334,13 @@
     }
 
     private void addBucketItem(final List<BucketItem> bucketItems, final BucketItemEntity itemEntity) {
+        // Currently we don't populate the bucket name for items so we pass in null in the map methods
         if (itemEntity instanceof FlowEntity) {
             final FlowEntity flowEntity = (FlowEntity) itemEntity;
-
-            // Currently we don't populate the bucket name for items
             bucketItems.add(DataModelMapper.map(null, flowEntity));
+        } else if (itemEntity instanceof ExtensionBundleEntity) {
+            final ExtensionBundleEntity bundleEntity = (ExtensionBundleEntity) itemEntity;
+            bucketItems.add(DataModelMapper.map(null, bundleEntity));
         } else {
             LOGGER.error("Unknown type of BucketItemEntity: " + itemEntity.getClass().getCanonicalName());
         }
@@ -977,6 +1015,128 @@
         return differenceGroups.values().stream().collect(Collectors.toSet());
     }
 
+    // ---------------------- ExtensionBundle methods ---------------------------------------------
+
+    public ExtensionBundleVersion createExtensionBundleVersion(final String bucketIdentifier, final ExtensionBundleType bundleType,
+                                                               final InputStream inputStream, final String clientSha256) throws IOException {
+        writeLock.lock();
+        try {
+            return extensionService.createExtensionBundleVersion(bucketIdentifier, bundleType, inputStream, clientSha256);
+        } finally {
+            writeLock.unlock();
+        }
+    }
+
+    public List<ExtensionBundle> getExtensionBundles(Set<String> bucketIdentifiers) {
+        readLock.lock();
+        try {
+            return extensionService.getExtensionBundles(bucketIdentifiers);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    public List<ExtensionBundle> getExtensionBundlesByBucket(final String bucketIdentifier) {
+        readLock.lock();
+        try {
+            return extensionService.getExtensionBundlesByBucket(bucketIdentifier);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    public ExtensionBundle getExtensionBundle(final String extensionBundleId) {
+        readLock.lock();
+        try {
+            return extensionService.getExtensionBundle(extensionBundleId);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    public ExtensionBundle deleteExtensionBundle(final ExtensionBundle extensionBundle) {
+        writeLock.lock();
+        try {
+            return extensionService.deleteExtensionBundle(extensionBundle);
+        } finally {
+            writeLock.unlock();
+        }
+    }
+
+    public SortedSet<ExtensionBundleVersionMetadata> getExtensionBundleVersions(final String extensionBundleIdentifier) {
+        readLock.lock();
+        try {
+            return extensionService.getExtensionBundleVersions(extensionBundleIdentifier);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    public ExtensionBundleVersion getExtensionBundleVersion(ExtensionBundleVersionCoordinate versionCoordinate) {
+        readLock.lock();
+        try {
+            return extensionService.getExtensionBundleVersion(versionCoordinate);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    public void writeExtensionBundleVersionContent(final ExtensionBundleVersion bundleVersion, final OutputStream out) {
+        readLock.lock();
+        try {
+            extensionService.writeExtensionBundleVersionContent(bundleVersion, out);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    public ExtensionBundleVersion deleteExtensionBundleVersion(final ExtensionBundleVersion bundleVersion) {
+        writeLock.lock();
+        try {
+            return extensionService.deleteExtensionBundleVersion(bundleVersion);
+        } finally {
+            writeLock.unlock();
+        }
+    }
+
+    // ---------------------- Extension Repository methods ---------------------------------------------
+
+    public SortedSet<ExtensionRepoBucket> getExtensionRepoBuckets(final Set<String> bucketIds) {
+        readLock.lock();
+        try {
+            return extensionService.getExtensionRepoBuckets(bucketIds);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    public SortedSet<ExtensionRepoGroup> getExtensionRepoGroups(final Bucket bucket) {
+        readLock.lock();
+        try {
+            return extensionService.getExtensionRepoGroups(bucket);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    public SortedSet<ExtensionRepoArtifact> getExtensionRepoArtifacts(final Bucket bucket, final String groupId) {
+        readLock.lock();
+        try {
+            return extensionService.getExtensionRepoArtifacts(bucket, groupId);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    public SortedSet<ExtensionRepoVersionSummary> getExtensionRepoVersions(final Bucket bucket, final String groupId, final String artifactId) {
+        readLock.lock();
+        try {
+            return extensionService.getExtensionRepoVersions(bucket, groupId, artifactId);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
     // ---------------------- Field methods ---------------------------------------------
 
     public Set<String> getBucketFields() {
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionBundleCoordinate.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionBundleCoordinate.java
new file mode 100644
index 0000000..ce78ce0
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionBundleCoordinate.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.registry.service.extension;
+
+import org.apache.commons.lang3.Validate;
+
+/**
+ * The unique coordinate for an extension bundle.
+ *
+ * This is an alternative to using the single uuid identifier for the bundle.
+ */
+public class ExtensionBundleCoordinate {
+
+    private final String bucketId;
+    private final String groupId;
+    private final String artifactId;
+
+    public ExtensionBundleCoordinate(final String bucketId, final String groupId, final String artifactId) {
+        this.bucketId = bucketId;
+        this.groupId = groupId;
+        this.artifactId = artifactId;
+        Validate.notBlank(this.bucketId, "Bucket id cannot be null or blank");
+        Validate.notBlank(this.groupId, "Group id cannot be null or blank");
+        Validate.notBlank(this.artifactId, "Artifact id cannot be null or blank");
+    }
+
+    public String getBucketId() {
+        return bucketId;
+    }
+
+    public String getGroupId() {
+        return groupId;
+    }
+
+    public String getArtifactId() {
+        return artifactId;
+    }
+
+    @Override
+    public String toString() {
+        return bucketId + ":" + groupId + ":" + artifactId;
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionBundleVersionCoordinate.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionBundleVersionCoordinate.java
new file mode 100644
index 0000000..38904e5
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionBundleVersionCoordinate.java
@@ -0,0 +1,42 @@
+/*
+ * 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.registry.service.extension;
+
+import org.apache.commons.lang3.Validate;
+
+/**
+ * The unique coordinate for a version of an extension bundle.
+ */
+public class ExtensionBundleVersionCoordinate extends ExtensionBundleCoordinate {
+
+    private final String version;
+
+    public ExtensionBundleVersionCoordinate(final String bucketId, final String groupId, final String artifactId, final String version) {
+        super(bucketId, groupId, artifactId);
+        this.version = version;
+        Validate.notBlank(this.version, "Version cannot be null or blank");
+    }
+
+    public String getVersion() {
+        return version;
+    }
+
+    @Override
+    public String toString() {
+        return super.toString() + ":" + version;
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionService.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionService.java
new file mode 100644
index 0000000..3ab8a07
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/ExtensionService.java
@@ -0,0 +1,130 @@
+/*
+ * 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.registry.service.extension;
+
+import org.apache.nifi.registry.bucket.Bucket;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+import org.apache.nifi.registry.extension.ExtensionBundleType;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionMetadata;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoArtifact;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoBucket;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoGroup;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersionSummary;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.List;
+import java.util.Set;
+import java.util.SortedSet;
+
+public interface ExtensionService {
+
+    /**
+     * Creates a version of an extension bundle.
+     *
+     * The InputStream is expected to contain the binary contents of a bundle in the format specified by bundleType.
+     *
+     * The metadata will be extracted from the bundle and used to determine if this is a new version of an existing bundle,
+     * or it will create a new bundle and this as the first version if one doesn't already exist.
+     *
+     * @param bucketIdentifier the bucket id
+     * @param bundleType the type of bundle
+     * @param inputStream the binary content of the bundle
+     * @param clientSha256 the SHA-256 hex supplied by the client
+     * @return the ExtensionBundleVersion representing all of the information about the bundle
+     * @throws IOException if an error occurs processing the InputStream
+     */
+    ExtensionBundleVersion createExtensionBundleVersion(String bucketIdentifier, ExtensionBundleType bundleType,
+                                                        InputStream inputStream, String clientSha256) throws IOException;
+
+    /**
+     * Retrieves the extension bundles in the given buckets.
+     *
+     * @param bucketIdentifiers the bucket identifiers
+     * @return the bundles in the given buckets
+     */
+    List<ExtensionBundle> getExtensionBundles(Set<String> bucketIdentifiers);
+
+    /**
+     * Retrieves the extension bundles in the given bucket.
+     *
+     * @param bucketIdentifier the bucket identifier
+     * @return the bundles in the given bucket
+     */
+    List<ExtensionBundle> getExtensionBundlesByBucket(String bucketIdentifier);
+
+    /**
+     * Retrieve the extension bundle with the given id.
+     *
+     * @param extensionBundleIdentifier the extension bundle id
+     * @return the bundle
+     */
+    ExtensionBundle getExtensionBundle(String extensionBundleIdentifier);
+
+    /**
+     * Deletes the given extension bundle and all it's versions.
+     *
+     * @param extensionBundle the extension bundle to delete
+     * @return the deleted bundle
+     */
+    ExtensionBundle deleteExtensionBundle(ExtensionBundle extensionBundle);
+
+    /**
+     * Retrieves the versions of the given extension bundle.
+     *
+     * @param extensionBundleIdentifier the extension bundle id
+     * @return the sorted set of versions for the given bundle
+     */
+    SortedSet<ExtensionBundleVersionMetadata> getExtensionBundleVersions(String extensionBundleIdentifier);
+
+    /**
+     * Retrieves the full ExtensionBundleVersion object, including version metadata, bundle metadata, and bucket metadata.
+     *
+     * @param versionCoordinate the coordinate of the version
+     * @return the extension bundle version
+     */
+    ExtensionBundleVersion getExtensionBundleVersion(ExtensionBundleVersionCoordinate versionCoordinate);
+
+    /**
+     * Writes the binary content of the extension bundle version to the given OutputStream.
+     *
+     * @param extensionBundleVersion the version to write the content for
+     * @param out the output stream to write to
+     */
+    void writeExtensionBundleVersionContent(ExtensionBundleVersion extensionBundleVersion, OutputStream out);
+
+    /**
+     * Deletes the given version of the extension bundle.
+     *
+     * @param bundleVersion the version to delete
+     * @return the deleted extension bundle version
+     */
+    ExtensionBundleVersion deleteExtensionBundleVersion(ExtensionBundleVersion bundleVersion);
+
+    // ----- Extension Repo Methods -----
+
+    SortedSet<ExtensionRepoBucket> getExtensionRepoBuckets(Set<String> bucketIds);
+
+    SortedSet<ExtensionRepoGroup> getExtensionRepoGroups(Bucket bucket);
+
+    SortedSet<ExtensionRepoArtifact> getExtensionRepoArtifacts(Bucket bucket, String groupId);
+
+    SortedSet<ExtensionRepoVersionSummary> getExtensionRepoVersions(Bucket bucket, String groupId, String artifactId);
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/StandardExtensionService.java b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/StandardExtensionService.java
new file mode 100644
index 0000000..e737b75
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/extension/StandardExtensionService.java
@@ -0,0 +1,620 @@
+/*
+ * 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.registry.service.extension;
+
+import org.apache.commons.codec.binary.Hex;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Validate;
+import org.apache.nifi.registry.bucket.Bucket;
+import org.apache.nifi.registry.db.entity.BucketEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntityType;
+import org.apache.nifi.registry.db.entity.ExtensionBundleVersionDependencyEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleVersionEntity;
+import org.apache.nifi.registry.exception.ResourceNotFoundException;
+import org.apache.nifi.registry.extension.BundleCoordinate;
+import org.apache.nifi.registry.extension.BundleDetails;
+import org.apache.nifi.registry.extension.BundleExtractor;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+import org.apache.nifi.registry.extension.ExtensionBundleContext;
+import org.apache.nifi.registry.extension.ExtensionBundlePersistenceProvider;
+import org.apache.nifi.registry.extension.ExtensionBundleType;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionDependency;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionMetadata;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoArtifact;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoBucket;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoGroup;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersionSummary;
+import org.apache.nifi.registry.properties.NiFiRegistryProperties;
+import org.apache.nifi.registry.provider.extension.StandardExtensionBundleContext;
+import org.apache.nifi.registry.security.authorization.user.NiFiUserUtils;
+import org.apache.nifi.registry.service.DataModelMapper;
+import org.apache.nifi.registry.service.MetadataService;
+import org.apache.nifi.registry.util.FileUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import javax.validation.ConstraintViolation;
+import javax.validation.ConstraintViolationException;
+import javax.validation.Validator;
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.DigestInputStream;
+import java.security.MessageDigest;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+@Service
+public class StandardExtensionService implements ExtensionService {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(StandardExtensionService.class);
+
+    private final MetadataService metadataService;
+    private final Map<ExtensionBundleType, BundleExtractor> extractors;
+    private final ExtensionBundlePersistenceProvider bundlePersistenceProvider;
+    private final Validator validator;
+    private final File extensionsWorkingDir;
+
+    @Autowired
+    public StandardExtensionService(final MetadataService metadataService,
+                                    final Map<ExtensionBundleType, BundleExtractor> extractors,
+                                    final ExtensionBundlePersistenceProvider bundlePersistenceProvider,
+                                    final Validator validator,
+                                    final NiFiRegistryProperties properties) {
+        this.metadataService = metadataService;
+        this.extractors = extractors;
+        this.bundlePersistenceProvider = bundlePersistenceProvider;
+        this.validator = validator;
+        this.extensionsWorkingDir = properties.getExtensionsWorkingDirectory();
+        Validate.notNull(this.metadataService);
+        Validate.notNull(this.extractors);
+        Validate.notNull(this.bundlePersistenceProvider);
+        Validate.notNull(this.validator);
+        Validate.notNull(this.extensionsWorkingDir);
+    }
+
+    private <T>  void validate(T t, String invalidMessage) {
+        final Set<ConstraintViolation<T>> violations = validator.validate(t);
+        if (violations.size() > 0) {
+            throw new ConstraintViolationException(invalidMessage, violations);
+        }
+    }
+
+    @Override
+    public ExtensionBundleVersion createExtensionBundleVersion(final String bucketIdentifier, final ExtensionBundleType bundleType,
+                                                               final InputStream inputStream, final String clientSha256) throws IOException {
+        if (StringUtils.isBlank(bucketIdentifier)) {
+            throw new IllegalArgumentException("Bucket identifier cannot be null or blank");
+        }
+
+        if (bundleType == null) {
+            throw new IllegalArgumentException("Bundle type cannot be null");
+        }
+
+        if (inputStream == null) {
+            throw new IllegalArgumentException("Extension bundle input stream cannot be null");
+        }
+
+        if (!extractors.containsKey(bundleType)) {
+            throw new IllegalArgumentException("No metadata extractor is registered for bundle-type: " + bundleType);
+        }
+
+        // ensure the bucket exists
+        final BucketEntity existingBucket = metadataService.getBucketById(bucketIdentifier);
+        if (existingBucket == null) {
+            LOGGER.warn("The specified bucket id [{}] does not exist.", bucketIdentifier);
+            throw new ResourceNotFoundException("The specified bucket ID does not exist in this registry.");
+        }
+
+        // ensure the extensions directory exists and we can read and write to it
+        FileUtils.ensureDirectoryExistAndCanReadAndWrite(extensionsWorkingDir);
+
+        final String extensionWorkingFilename = UUID.randomUUID().toString();
+        final File extensionWorkingFile = new File(extensionsWorkingDir, extensionWorkingFilename);
+        LOGGER.debug("Writing bundle contents to working directory at {}", new Object[]{extensionWorkingFile.getAbsolutePath()});
+
+        try {
+            // write the contents of the input stream to a temporary file in the extensions working directory
+            final MessageDigest sha256Digest = DigestUtils.getSha256Digest();
+            try (final DigestInputStream digestInputStream = new DigestInputStream(inputStream, sha256Digest);
+                 final OutputStream out = new FileOutputStream(extensionWorkingFile)) {
+                IOUtils.copy(digestInputStream, out);
+            }
+
+            // get the hex of the SHA-256 computed by the server and compare to the client provided SHA-256, if one was provided
+            final String sha256Hex = Hex.encodeHexString(sha256Digest.digest());
+            final boolean sha256Supplied = !StringUtils.isBlank(clientSha256);
+            if (sha256Supplied && !sha256Hex.equalsIgnoreCase(clientSha256)) {
+                LOGGER.error("Client provided SHA-256 of '{}', but server calculated '{}'", new Object[]{clientSha256, sha256Hex});
+                throw new IllegalStateException("The SHA-256 of the received extension bundle does not match the SHA-256 provided by the client");
+            }
+
+            // extract the details of the bundle from the temp file in the working directory
+            final BundleDetails bundleDetails;
+            try (final InputStream in = new FileInputStream(extensionWorkingFile)) {
+                final BundleExtractor extractor = extractors.get(bundleType);
+                bundleDetails = extractor.extract(in);
+            }
+
+            final BundleCoordinate bundleCoordinate = bundleDetails.getBundleCoordinate();
+            final Set<BundleCoordinate> dependencyCoordinates = bundleDetails.getDependencyBundleCoordinates();
+
+            final String groupId = bundleCoordinate.getGroupId();
+            final String artifactId = bundleCoordinate.getArtifactId();
+            final String version = bundleCoordinate.getVersion();
+            LOGGER.debug("Extracted bundle details - '{}' - '{}' - '{}'", new Object[]{groupId, artifactId, version});
+
+            // a bundle with the same group, artifact, and version can exist in multiple buckets, but only if it contains the same binary content,
+            // we can determine that by comparing the SHA-256 digest of the incoming bundle against existing bundles with the same group, artifact, version
+            final List<ExtensionBundleVersionEntity> allExistingVersions = metadataService.getExtensionBundleVersionsGlobal(groupId, artifactId, version);
+            for (final ExtensionBundleVersionEntity existingVersionEntity : allExistingVersions) {
+                if (!existingVersionEntity.getSha256Hex().equals(sha256Hex)) {
+                    throw new IllegalStateException("Found existing extension bundle with same group, artifact, and version, but different SHA-256 check-sum");
+                }
+            }
+
+            // get the existing extension bundle entity, or create a new one if one does not exist in the bucket with the group + artifact
+            final long currentTime = System.currentTimeMillis();
+            final ExtensionBundleEntity extensionBundle = getOrCreateExtensionBundle(bucketIdentifier, groupId, artifactId, bundleType, currentTime);
+
+            // ensure there isn't already a version of the bundle with the same version
+            final ExtensionBundleVersionEntity existingVersion = metadataService.getExtensionBundleVersion(bucketIdentifier, groupId, artifactId, version);
+            if (existingVersion != null) {
+                LOGGER.warn("The specified version [{}] already exists for extension bundle [{}].", new Object[]{version, extensionBundle.getId()});
+                throw new IllegalStateException("The specified version already exists for the given extension bundle");
+            }
+
+            // create the version metadata instance and validate it has all the required fields
+            final String userIdentity = NiFiUserUtils.getNiFiUserIdentity();
+            final ExtensionBundleVersionMetadata versionMetadata = new ExtensionBundleVersionMetadata();
+            versionMetadata.setId(UUID.randomUUID().toString());
+            versionMetadata.setExtensionBundleId(extensionBundle.getId());
+            versionMetadata.setBucketId(bucketIdentifier);
+            versionMetadata.setVersion(version);
+            versionMetadata.setTimestamp(currentTime);
+            versionMetadata.setAuthor(userIdentity);
+            versionMetadata.setSha256(sha256Hex);
+            versionMetadata.setSha256Supplied(sha256Supplied);
+
+            validate(versionMetadata, "Cannot create extension bundle version");
+
+            // create the version dependency instances and validate they have the required fields
+            final Set<ExtensionBundleVersionDependency> versionDependencies = new HashSet<>();
+            for (final BundleCoordinate dependencyCoordinate : dependencyCoordinates) {
+                final ExtensionBundleVersionDependency versionDependency = new ExtensionBundleVersionDependency();
+                versionDependency.setGroupId(dependencyCoordinate.getGroupId());
+                versionDependency.setArtifactId(dependencyCoordinate.getArtifactId());
+                versionDependency.setVersion(dependencyCoordinate.getVersion());
+
+                validate(versionDependency, "Cannot create extension bundle version dependency");
+                versionDependencies.add(versionDependency);
+            }
+
+            // create the bundle version in the metadata db
+            final ExtensionBundleVersionEntity versionEntity = DataModelMapper.map(versionMetadata);
+            metadataService.createExtensionBundleVersion(versionEntity);
+
+            // create the bundle version dependencies in the metadata db
+            for (final ExtensionBundleVersionDependency versionDependency : versionDependencies) {
+                final ExtensionBundleVersionDependencyEntity versionDependencyEntity = DataModelMapper.map(versionDependency);
+                versionDependencyEntity.setId(UUID.randomUUID().toString());
+                versionDependencyEntity.setExtensionBundleVersionId(versionEntity.getId());
+                metadataService.createDependency(versionDependencyEntity);
+            }
+
+            // persist the content of the bundle to the persistence provider
+            final ExtensionBundleContext context = new StandardExtensionBundleContext.Builder()
+                    .bundleType(getProviderBundleType(bundleType))
+                    .bucketId(existingBucket.getId())
+                    .bucketName(existingBucket.getName())
+                    .bundleId(extensionBundle.getId())
+                    .bundleGroupId(extensionBundle.getGroupId())
+                    .bundleArtifactId(extensionBundle.getArtifactId())
+                    .bundleVersion(versionMetadata.getVersion())
+                    .author(versionMetadata.getAuthor())
+                    .timestamp(versionMetadata.getTimestamp())
+                    .build();
+
+            try (final InputStream in = new FileInputStream(extensionWorkingFile);
+                 final InputStream bufIn = new BufferedInputStream(in)) {
+                bundlePersistenceProvider.saveBundleVersion(context, bufIn);
+                LOGGER.debug("Bundle saved to persistence provider - '{}' - '{}' - '{}'",
+                        new Object[]{groupId, artifactId, version});
+            }
+
+            // get the updated extension bundle so it contains the correct version count
+            final ExtensionBundleEntity updatedBundle = metadataService.getExtensionBundle(bucketIdentifier, groupId, artifactId);
+
+            // create the full ExtensionBundleVersion instance to return
+            final ExtensionBundleVersion extensionBundleVersion = new ExtensionBundleVersion();
+            extensionBundleVersion.setVersionMetadata(versionMetadata);
+            extensionBundleVersion.setExtensionBundle(DataModelMapper.map(existingBucket, updatedBundle));
+            extensionBundleVersion.setBucket(DataModelMapper.map(existingBucket));
+            extensionBundleVersion.setDependencies(versionDependencies);
+            return extensionBundleVersion;
+
+        } finally {
+            if (extensionWorkingFile.exists()) {
+                try {
+                    extensionWorkingFile.delete();
+                } catch (Exception e) {
+                    LOGGER.warn("Error removing temporary extension bundle file at {}",
+                            new Object[]{extensionWorkingFile.getAbsolutePath()});
+                }
+            }
+        }
+    }
+
+    private ExtensionBundleEntity getOrCreateExtensionBundle(final String bucketId, final String groupId,
+                                                             final String artifactId, final ExtensionBundleType bundleType,
+                                                             final long currentTime) {
+        ExtensionBundleEntity existingBundleEntity = metadataService.getExtensionBundle(bucketId, groupId, artifactId);
+        if (existingBundleEntity == null) {
+            final ExtensionBundle bundle = new ExtensionBundle();
+            bundle.setIdentifier(UUID.randomUUID().toString());
+            bundle.setBucketIdentifier(bucketId);
+            bundle.setName(groupId + ":" + artifactId);
+            bundle.setGroupId(groupId);
+            bundle.setArtifactId(artifactId);
+            bundle.setBundleType(bundleType);
+            bundle.setCreatedTimestamp(currentTime);
+            bundle.setModifiedTimestamp(currentTime);
+
+            validate(bundle, "Cannot create extension bundle");
+            existingBundleEntity = metadataService.createExtensionBundle(DataModelMapper.map(bundle));
+        } else {
+            final ExtensionBundleEntityType bundleEntityType = DataModelMapper.map(bundleType);
+            if (bundleEntityType != existingBundleEntity.getBundleType()) {
+                throw new IllegalStateException("A bundle already exists with the same group id and artifact id, but a different bundle type");
+            }
+        }
+
+        return existingBundleEntity;
+    }
+
+    private ExtensionBundleContext.BundleType getProviderBundleType(final ExtensionBundleType bundleType) {
+        switch (bundleType) {
+            case NIFI_NAR:
+                return ExtensionBundleContext.BundleType.NIFI_NAR;
+            case MINIFI_CPP:
+                return ExtensionBundleContext.BundleType.MINIFI_CPP;
+            default:
+                throw new IllegalArgumentException("Unknown bundle type: " + bundleType.toString());
+        }
+    }
+
+    @Override
+    public List<ExtensionBundle> getExtensionBundles(Set<String> bucketIdentifiers) {
+        if (bucketIdentifiers == null) {
+            throw new IllegalArgumentException("Bucket identifiers cannot be null");
+        }
+
+        final List<ExtensionBundleEntity> bundleEntities = metadataService.getExtensionBundles(bucketIdentifiers);
+        return bundleEntities.stream().map(b -> DataModelMapper.map(null, b)).collect(Collectors.toList());
+    }
+
+    @Override
+    public List<ExtensionBundle> getExtensionBundlesByBucket(final String bucketIdentifier) {
+        if (StringUtils.isBlank(bucketIdentifier)) {
+            throw new IllegalArgumentException("Bucket identifier cannot be null or blank");
+        }
+
+        // ensure the bucket exists
+        final BucketEntity existingBucket = metadataService.getBucketById(bucketIdentifier);
+        if (existingBucket == null) {
+            LOGGER.warn("The specified bucket id [{}] does not exist.", bucketIdentifier);
+            throw new ResourceNotFoundException("The specified bucket ID does not exist in this registry.");
+        }
+
+        final List<ExtensionBundleEntity> bundleEntities = metadataService.getExtensionBundlesByBucket(bucketIdentifier);
+        return bundleEntities.stream().map(b -> DataModelMapper.map(existingBucket, b)).collect(Collectors.toList());
+    }
+
+    @Override
+    public ExtensionBundle getExtensionBundle(final String extensionBundleIdentifier) {
+        if (StringUtils.isBlank(extensionBundleIdentifier)) {
+            throw new IllegalArgumentException("Extension bundle identifier cannot be null or blank");
+        }
+
+        final ExtensionBundleEntity existingBundle = metadataService.getExtensionBundle(extensionBundleIdentifier);
+        if (existingBundle == null) {
+            LOGGER.warn("The specified extension bundle id [{}] does not exist.", extensionBundleIdentifier);
+            throw new ResourceNotFoundException("The specified extension bundle ID does not exist.");
+        }
+
+        final BucketEntity existingBucket = metadataService.getBucketById(existingBundle.getBucketId());
+        return DataModelMapper.map(existingBucket, existingBundle);
+    }
+
+    @Override
+    public ExtensionBundle deleteExtensionBundle(final ExtensionBundle extensionBundle) {
+        if (extensionBundle == null) {
+            throw new IllegalArgumentException("Extension bundle cannot be null");
+        }
+
+        // delete the bundle from the database
+        metadataService.deleteExtensionBundle(extensionBundle.getIdentifier());
+
+        // delete all content associated with the bundle in the persistence provider
+        bundlePersistenceProvider.deleteAllBundleVersions(
+                extensionBundle.getBucketIdentifier(),
+                extensionBundle.getBucketName(),
+                extensionBundle.getGroupId(),
+                extensionBundle.getArtifactId());
+
+        return extensionBundle;
+    }
+
+    @Override
+    public SortedSet<ExtensionBundleVersionMetadata> getExtensionBundleVersions(final String extensionBundleIdentifier) {
+        if (StringUtils.isBlank(extensionBundleIdentifier)) {
+            throw new IllegalArgumentException("Extension bundle identifier cannot be null or blank");
+        }
+
+        // ensure the bundle exists
+        final ExtensionBundleEntity existingBundle = metadataService.getExtensionBundle(extensionBundleIdentifier);
+        if (existingBundle == null) {
+            LOGGER.warn("The specified extension bundle id [{}] does not exist.", extensionBundleIdentifier);
+            throw new ResourceNotFoundException("The specified extension bundle ID does not exist in this bucket.");
+        }
+
+        return getExtensionBundleVersionsSet(existingBundle);
+    }
+
+    private SortedSet<ExtensionBundleVersionMetadata> getExtensionBundleVersionsSet(ExtensionBundleEntity existingBundle) {
+        final SortedSet<ExtensionBundleVersionMetadata> sortedVersions = new TreeSet<>(Collections.reverseOrder());
+
+        final List<ExtensionBundleVersionEntity> existingVersions = metadataService.getExtensionBundleVersions(existingBundle.getId());
+        if (existingVersions != null) {
+            final BucketEntity existingBucket = metadataService.getBucketById(existingBundle.getBucketId());
+            existingVersions.stream().forEach(s -> sortedVersions.add(DataModelMapper.map(existingBucket, s)));
+        }
+
+        return sortedVersions;
+    }
+
+    @Override
+    public ExtensionBundleVersion getExtensionBundleVersion(ExtensionBundleVersionCoordinate versionCoordinate) {
+        if (versionCoordinate == null) {
+            throw new IllegalArgumentException("Extension bundle version coordinate cannot be null");
+        }
+
+        // ensure the bucket exists
+        final BucketEntity existingBucket = metadataService.getBucketById(versionCoordinate.getBucketId());
+        if (existingBucket == null) {
+            LOGGER.warn("The specified bucket id [{}] does not exist.", versionCoordinate.getBucketId());
+            throw new ResourceNotFoundException("The specified bucket ID does not exist in this registry.");
+        }
+
+        // ensure the bundle exists
+        final ExtensionBundleEntity existingBundle = metadataService.getExtensionBundle(
+                versionCoordinate.getBucketId(),
+                versionCoordinate.getGroupId(),
+                versionCoordinate.getArtifactId());
+
+        if (existingBundle == null) {
+            LOGGER.warn("The specified extension bundle [{}] does not exist.", versionCoordinate.toString());
+            throw new ResourceNotFoundException("The specified extension bundle does not exist in this bucket.");
+        }
+
+        //ensure the version of the bundle exists
+        final ExtensionBundleVersionEntity existingVersion = metadataService.getExtensionBundleVersion(
+                versionCoordinate.getBucketId(),
+                versionCoordinate.getGroupId(),
+                versionCoordinate.getArtifactId(),
+                versionCoordinate.getVersion());
+
+        if (existingVersion == null) {
+            LOGGER.warn("The specified extension bundle version [{}] does not exist.", versionCoordinate.toString());
+            throw new ResourceNotFoundException("The specified extension bundle version does not exist in this bucket.");
+        }
+
+        // get the dependencies for the bundle version
+        final List<ExtensionBundleVersionDependencyEntity> existingVersionDependencies = metadataService
+                .getDependenciesForBundleVersion(existingVersion.getId());
+
+        // convert the dependency db entities
+        final Set<ExtensionBundleVersionDependency> dependencies = existingVersionDependencies.stream()
+                .map(d -> DataModelMapper.map(d))
+                .collect(Collectors.toSet());
+
+        // create the full ExtensionBundleVersion instance to return
+        final ExtensionBundleVersion extensionBundleVersion = new ExtensionBundleVersion();
+        extensionBundleVersion.setVersionMetadata(DataModelMapper.map(existingBucket, existingVersion));
+        extensionBundleVersion.setExtensionBundle(DataModelMapper.map(existingBucket, existingBundle));
+        extensionBundleVersion.setBucket(DataModelMapper.map(existingBucket));
+        extensionBundleVersion.setDependencies(dependencies);
+        return extensionBundleVersion;
+    }
+
+    @Override
+    public void writeExtensionBundleVersionContent(final ExtensionBundleVersion bundleVersion, final OutputStream out) {
+        // get the content from the persistence provider and write it to the output stream
+        final ExtensionBundleContext context = getExtensionBundleContext(bundleVersion);
+        bundlePersistenceProvider.getBundleVersion(context, out);
+    }
+
+    @Override
+    public ExtensionBundleVersion deleteExtensionBundleVersion(final ExtensionBundleVersion bundleVersion) {
+        if (bundleVersion == null) {
+            throw new IllegalArgumentException("Extension bundle version cannot be null");
+        }
+
+        // delete from the metadata db
+        final String extensionBundleVersionId = bundleVersion.getVersionMetadata().getId();
+        metadataService.deleteExtensionBundleVersion(extensionBundleVersionId);
+
+        // delete content associated with the bundle version in the persistence provider
+        final ExtensionBundleContext context = new StandardExtensionBundleContext.Builder()
+                .bundleType(getProviderBundleType(bundleVersion.getExtensionBundle().getBundleType()))
+                .bucketId(bundleVersion.getBucket().getIdentifier())
+                .bucketName(bundleVersion.getBucket().getName())
+                .bundleId(bundleVersion.getExtensionBundle().getIdentifier())
+                .bundleGroupId(bundleVersion.getExtensionBundle().getGroupId())
+                .bundleArtifactId(bundleVersion.getExtensionBundle().getArtifactId())
+                .bundleVersion(bundleVersion.getVersionMetadata().getVersion())
+                .author(bundleVersion.getVersionMetadata().getAuthor())
+                .timestamp(bundleVersion.getVersionMetadata().getTimestamp())
+                .build();
+
+        bundlePersistenceProvider.deleteBundleVersion(context);
+
+        return bundleVersion;
+    }
+
+    // ------ Extension Repository Methods -------
+
+    @Override
+    public SortedSet<ExtensionRepoBucket> getExtensionRepoBuckets(final Set<String> bucketIds) {
+        if (bucketIds == null) {
+            throw new IllegalArgumentException("Bucket ids cannot be null");
+        }
+
+        if (bucketIds.isEmpty()) {
+            return new TreeSet<>();
+        }
+
+        final SortedSet<ExtensionRepoBucket> repoBuckets = new TreeSet<>();
+
+        final List<BucketEntity> buckets = metadataService.getBuckets(bucketIds);
+        buckets.forEach(b -> {
+            final ExtensionRepoBucket repoBucket = new ExtensionRepoBucket();
+            repoBucket.setBucketName(b.getName());
+            repoBuckets.add(repoBucket);
+        });
+
+        return repoBuckets;
+    }
+
+    @Override
+    public SortedSet<ExtensionRepoGroup> getExtensionRepoGroups(final Bucket bucket) {
+        if (bucket == null) {
+            throw new IllegalArgumentException("Bucket cannot be null");
+        }
+
+        final SortedSet<ExtensionRepoGroup> repoGroups = new TreeSet<>();
+
+        final List<ExtensionBundleEntity> bundleEntities = metadataService.getExtensionBundlesByBucket(bucket.getIdentifier());
+        bundleEntities.forEach(b -> {
+            final ExtensionRepoGroup repoGroup = new ExtensionRepoGroup();
+            repoGroup.setBucketName(bucket.getName());
+            repoGroup.setGroupId(b.getGroupId());
+            repoGroups.add(repoGroup);
+        });
+
+        return repoGroups;
+    }
+
+    @Override
+    public SortedSet<ExtensionRepoArtifact> getExtensionRepoArtifacts(final Bucket bucket, final String groupId) {
+        if (bucket == null) {
+            throw new IllegalArgumentException("Bucket cannot be null");
+        }
+
+        if (StringUtils.isBlank(groupId)) {
+            throw new IllegalArgumentException("Group id cannot be null or blank");
+        }
+
+        final SortedSet<ExtensionRepoArtifact> repoArtifacts = new TreeSet<>();
+
+        final List<ExtensionBundleEntity> bundleEntities = metadataService.getExtensionBundlesByBucketAndGroup(bucket.getIdentifier(), groupId);
+        bundleEntities.forEach(b -> {
+            final ExtensionRepoArtifact repoArtifact = new ExtensionRepoArtifact();
+            repoArtifact.setBucketName(bucket.getName());
+            repoArtifact.setGroupId(b.getGroupId());
+            repoArtifact.setArtifactId(b.getArtifactId());
+            repoArtifacts.add(repoArtifact);
+        });
+
+        return repoArtifacts;
+    }
+
+    @Override
+    public SortedSet<ExtensionRepoVersionSummary> getExtensionRepoVersions(final Bucket bucket, final String groupId, final String artifactId) {
+        if (bucket == null) {
+            throw new IllegalArgumentException("Bucket cannot be null");
+        }
+
+        if (StringUtils.isBlank(groupId)) {
+            throw new IllegalArgumentException("Group id cannot be null or blank");
+        }
+
+        if (StringUtils.isBlank(artifactId)) {
+            throw new IllegalArgumentException("Artifact id cannot be null or blank");
+        }
+
+        final SortedSet<ExtensionRepoVersionSummary> repoVersions = new TreeSet<>();
+
+        final List<ExtensionBundleVersionEntity> versionEntities = metadataService.getExtensionBundleVersions(bucket.getIdentifier(), groupId, artifactId);
+        if (!versionEntities.isEmpty()) {
+            final ExtensionBundleEntity bundleEntity = metadataService.getExtensionBundle(bucket.getIdentifier(), groupId, artifactId);
+            if (bundleEntity == null) {
+                // should never happen if the list of versions is not empty, but just in case
+                throw new ResourceNotFoundException("The specified extension bundle does not exist in this bucket");
+            }
+
+            versionEntities.forEach(v -> {
+                final ExtensionRepoVersionSummary repoVersion = new ExtensionRepoVersionSummary();
+                repoVersion.setBucketName(bucket.getName());
+                repoVersion.setGroupId(bundleEntity.getGroupId());
+                repoVersion.setArtifactId(bundleEntity.getArtifactId());
+                repoVersion.setVersion(v.getVersion());
+                repoVersions.add(repoVersion);
+            });
+        }
+
+        return repoVersions;
+    }
+
+    // ------ Helper Methods -------
+
+    private ExtensionBundleContext getExtensionBundleContext(final ExtensionBundleVersion bundleVersion) {
+        return getExtensionBundleContext(bundleVersion.getBucket(), bundleVersion.getExtensionBundle(), bundleVersion.getVersionMetadata());
+    }
+
+    private ExtensionBundleContext getExtensionBundleContext(final Bucket bucket, final ExtensionBundle bundle,
+                                                             final ExtensionBundleVersionMetadata bundleVersionMetadata) {
+        return new StandardExtensionBundleContext.Builder()
+                .bundleType(getProviderBundleType(bundle.getBundleType()))
+                .bucketId(bucket.getIdentifier())
+                .bucketName(bucket.getName())
+                .bundleId(bundle.getIdentifier())
+                .bundleGroupId(bundle.getGroupId())
+                .bundleArtifactId(bundle.getArtifactId())
+                .bundleVersion(bundleVersionMetadata.getVersion())
+                .author(bundleVersionMetadata.getAuthor())
+                .timestamp(bundleVersionMetadata.getTimestamp())
+                .build();
+    }
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/resources/META-INF/services/org.apache.nifi.registry.extension.ExtensionBundlePersistenceProvider b/nifi-registry-core/nifi-registry-framework/src/main/resources/META-INF/services/org.apache.nifi.registry.extension.ExtensionBundlePersistenceProvider
new file mode 100644
index 0000000..00dcbb8
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/resources/META-INF/services/org.apache.nifi.registry.extension.ExtensionBundlePersistenceProvider
@@ -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.registry.provider.extension.FileSystemExtensionBundlePersistenceProvider
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/resources/db/migration/V3__AddExtensions.sql b/nifi-registry-core/nifi-registry-framework/src/main/resources/db/migration/V3__AddExtensions.sql
new file mode 100644
index 0000000..da66cd1
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/resources/db/migration/V3__AddExtensions.sql
@@ -0,0 +1,71 @@
+-- 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.
+
+CREATE TABLE EXTENSION_BUNDLE (
+    ID VARCHAR(50) NOT NULL,
+    BUCKET_ID VARCHAR(50) NOT NULL,
+    BUNDLE_TYPE VARCHAR(200) NOT NULL,
+    GROUP_ID VARCHAR(500) NOT NULL,
+    ARTIFACT_ID VARCHAR(500) NOT NULL,
+    CONSTRAINT PK__EXTENSION_BUNDLE_ID PRIMARY KEY (ID),
+    CONSTRAINT FK__EXTENSION_BUNDLE_BUCKET_ITEM_ID FOREIGN KEY (ID) REFERENCES BUCKET_ITEM(ID) ON DELETE CASCADE,
+    CONSTRAINT FK__EXTENSION_BUNDLE_BUCKET_ID FOREIGN KEY(BUCKET_ID) REFERENCES BUCKET(ID) ON DELETE CASCADE,
+    CONSTRAINT UNIQUE__EXTENSION_BUNDLE_BUCKET_GROUP_ARTIFACT UNIQUE (BUCKET_ID, GROUP_ID, ARTIFACT_ID)
+);
+
+CREATE TABLE EXTENSION_BUNDLE_VERSION (
+    ID VARCHAR(50) NOT NULL,
+    EXTENSION_BUNDLE_ID VARCHAR(50) NOT NULL,
+    VERSION VARCHAR(100) NOT NULL,
+    CREATED TIMESTAMP NOT NULL,
+    CREATED_BY VARCHAR(4096) NOT NULL,
+    DESCRIPTION TEXT,
+    SHA_256_HEX VARCHAR(512) NOT NULL,
+    SHA_256_SUPPLIED INT NOT NULL,
+    CONSTRAINT PK__EXTENSION_BUNDLE_VERSION_ID PRIMARY KEY (ID),
+    CONSTRAINT FK__EXTENSION_BUNDLE_VERSION_EXTENSION_BUNDLE_ID FOREIGN KEY (EXTENSION_BUNDLE_ID) REFERENCES EXTENSION_BUNDLE(ID) ON DELETE CASCADE,
+    CONSTRAINT UNIQUE__EXTENSION_BUNDLE_VERSION_EXTENSION_BUNDLE_ID_VERSION UNIQUE (EXTENSION_BUNDLE_ID, VERSION)
+);
+
+CREATE TABLE EXTENSION_BUNDLE_VERSION_DEPENDENCY (
+    ID VARCHAR(50) NOT NULL,
+    EXTENSION_BUNDLE_VERSION_ID VARCHAR(50) NOT NULL,
+    GROUP_ID VARCHAR(500) NOT NULL,
+    ARTIFACT_ID VARCHAR(500) NOT NULL,
+    VERSION VARCHAR(100) NOT NULL,
+    CONSTRAINT PK__EXTENSION_BUNDLE_VERSION_DEPENDENCY_ID PRIMARY KEY (ID),
+    CONSTRAINT FK__EXTENSION_BUNDLE_VERSION_DEPENDENCY_EXTENSION_BUNDLE_VERSION_ID FOREIGN KEY (EXTENSION_BUNDLE_VERSION_ID) REFERENCES EXTENSION_BUNDLE_VERSION(ID) ON DELETE CASCADE,
+    CONSTRAINT UNIQUE__EXTENSION_BUNDLE_VERSION_DEPENDENCY_BUNDLE_ID_GROUP_ARTIFACT_VERSION UNIQUE (EXTENSION_BUNDLE_VERSION_ID, GROUP_ID, ARTIFACT_ID, VERSION)
+);
+
+CREATE TABLE EXTENSION (
+    ID VARCHAR(50) NOT NULL,
+    EXTENSION_BUNDLE_VERSION_ID VARCHAR(50) NOT NULL,
+    TYPE VARCHAR(500) NOT NULL,
+    TYPE_DESCRIPTION TEXT NOT NULL,
+    IS_RESTRICTED INT NOT NULL,
+    CATEGORY VARCHAR(100) NOT NULL,
+    TAGS TEXT,
+    CONSTRAINT PK__EXTENSION_ID PRIMARY KEY (ID),
+    CONSTRAINT FK__EXTENSION_EXTENSION_BUNDLE_VERSION_ID FOREIGN KEY (EXTENSION_BUNDLE_VERSION_ID) REFERENCES EXTENSION_BUNDLE_VERSION(ID) ON DELETE CASCADE,
+    CONSTRAINT UNIQUE__EXTENSION_EXTENSION_BUNDLE_VERSION_ID_AND_TYPE UNIQUE (EXTENSION_BUNDLE_VERSION_ID, TYPE)
+);
+
+CREATE TABLE EXTENSION_TAG (
+    EXTENSION_ID VARCHAR(50) NOT NULL,
+    TAG VARCHAR(200) NOT NULL,
+    CONSTRAINT PK__EXTENSION_TAG_EXTENSION_ID_AND_TAG PRIMARY KEY (EXTENSION_ID, TAG),
+    CONSTRAINT FK__EXTENSION_TAG_EXTENSION_ID FOREIGN KEY (EXTENSION_ID) REFERENCES EXTENSION(ID) ON DELETE CASCADE
+);
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/resources/db/migration/V4__AddCascadeOnDelete.sql b/nifi-registry-core/nifi-registry-framework/src/main/resources/db/migration/V4__AddCascadeOnDelete.sql
new file mode 100644
index 0000000..5b0e6c6
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/main/resources/db/migration/V4__AddCascadeOnDelete.sql
@@ -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.
+
+ALTER TABLE BUCKET_ITEM DROP CONSTRAINT FK__BUCKET_ITEM_BUCKET_ID;
+ALTER TABLE BUCKET_ITEM ADD CONSTRAINT FK__BUCKET_ITEM_BUCKET_ID FOREIGN KEY (BUCKET_ID) REFERENCES BUCKET(ID) ON DELETE CASCADE;
+
+ALTER TABLE FLOW DROP CONSTRAINT FK__FLOW_BUCKET_ITEM_ID;
+ALTER TABLE FLOW ADD CONSTRAINT FK__FLOW_BUCKET_ITEM_ID FOREIGN KEY (ID) REFERENCES BUCKET_ITEM(ID) ON DELETE CASCADE;
+
+ALTER TABLE FLOW_SNAPSHOT DROP CONSTRAINT FK__FLOW_SNAPSHOT_FLOW_ID;
+ALTER TABLE FLOW_SNAPSHOT ADD CONSTRAINT FK__FLOW_SNAPSHOT_FLOW_ID FOREIGN KEY (FLOW_ID) REFERENCES FLOW(ID) ON DELETE CASCADE;
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-framework/src/main/xsd/providers.xsd b/nifi-registry-core/nifi-registry-framework/src/main/xsd/providers.xsd
index ce82dcc..4e9f5d1 100644
--- a/nifi-registry-core/nifi-registry-framework/src/main/xsd/providers.xsd
+++ b/nifi-registry-core/nifi-registry-framework/src/main/xsd/providers.xsd
@@ -44,6 +44,7 @@
             <xs:sequence>
                 <xs:element name="flowPersistenceProvider" type="Provider" minOccurs="1" maxOccurs="1" />
                 <xs:element name="eventHookProvider" type="Provider" minOccurs="0" maxOccurs="unbounded" />
+                <xs:element name="extensionBundlePersistenceProvider" type="Provider" minOccurs="1" maxOccurs="1" />
             </xs:sequence>
         </xs:complexType>
     </xs:element>
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/db/TestDatabaseMetadataService.java b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/db/TestDatabaseMetadataService.java
index 35ba757..a2bacd4 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/db/TestDatabaseMetadataService.java
+++ b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/db/TestDatabaseMetadataService.java
@@ -19,6 +19,12 @@
 import org.apache.nifi.registry.db.entity.BucketEntity;
 import org.apache.nifi.registry.db.entity.BucketItemEntity;
 import org.apache.nifi.registry.db.entity.BucketItemEntityType;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleEntityType;
+import org.apache.nifi.registry.db.entity.ExtensionBundleVersionDependencyEntity;
+import org.apache.nifi.registry.db.entity.ExtensionBundleVersionEntity;
+import org.apache.nifi.registry.db.entity.ExtensionEntity;
+import org.apache.nifi.registry.db.entity.ExtensionEntityCategory;
 import org.apache.nifi.registry.db.entity.FlowEntity;
 import org.apache.nifi.registry.db.entity.FlowSnapshotEntity;
 import org.apache.nifi.registry.service.MetadataService;
@@ -30,9 +36,11 @@
 import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 import java.util.UUID;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
@@ -383,4 +391,448 @@
         assertNull(deletedEntity);
     }
 
+    //----------------- Extension Bundles ---------------------------------
+
+    @Test
+    public void testGetExtensionBundleById() {
+        final ExtensionBundleEntity entity = metadataService.getExtensionBundle("eb1");
+        assertNotNull(entity);
+
+        assertEquals("eb1", entity.getId());
+        assertEquals("nifi-example-processors-nar", entity.getName());
+        assertEquals("Example processors bundle", entity.getDescription());
+        assertNotNull(entity.getCreated());
+        assertNotNull(entity.getModified());
+        assertEquals(BucketItemEntityType.EXTENSION_BUNDLE, entity.getType());
+        assertEquals("3", entity.getBucketId());
+
+        assertEquals(ExtensionBundleEntityType.NIFI_NAR, entity.getBundleType());
+
+        assertEquals("org.apache.nifi", entity.getGroupId());
+        assertEquals("nifi-example-processors-nar", entity.getArtifactId());
+    }
+
+    @Test
+    public void testGetExtensionBundleDoesNotExist() {
+        final ExtensionBundleEntity entity = metadataService.getExtensionBundle("does-not-exist");
+        assertNull(entity);
+    }
+
+    @Test
+    public void testGetExtensionBundleByGroupArtifact() {
+        final String bucketId = "3";
+        final String group = "org.apache.nifi";
+        final String artifact = "nifi-example-service-api-nar";
+
+        final ExtensionBundleEntity entity = metadataService.getExtensionBundle(bucketId, group, artifact);
+        assertNotNull(entity);
+        assertEquals(bucketId, entity.getBucketId());
+
+        assertEquals(group, entity.getGroupId());
+        assertEquals(artifact, entity.getArtifactId());
+    }
+
+    @Test
+    public void testGetExtensionBundleByGroupArtifactDoesNotExist() {
+        final String bucketId = "3";
+        final String group = "org.apache.nifi";
+        final String artifact = "does-not-exist";
+
+        final ExtensionBundleEntity entity = metadataService.getExtensionBundle(bucketId, group, artifact);
+        assertNull(entity);
+    }
+
+    @Test
+    public void testGetExtensionBundles() {
+        final Set<String> bucketIds = new HashSet<>();
+        bucketIds.add("1");
+        bucketIds.add("2");
+        bucketIds.add("3");
+
+        final List<ExtensionBundleEntity> bundles = metadataService.getExtensionBundles(bucketIds);
+        assertNotNull(bundles);
+        assertEquals(3, bundles.size());
+
+        bundles.forEach(b -> {
+            assertTrue(b.getVersionCount() > 0);
+            assertNotNull(b.getBucketName());
+        });
+    }
+
+    @Test
+    public void testGetExtensionBundlesByBucket() {
+        final List<ExtensionBundleEntity> bundles = metadataService.getExtensionBundlesByBucket("3");
+        assertNotNull(bundles);
+        assertEquals(3, bundles.size());
+
+        final List<ExtensionBundleEntity> bundles2 = metadataService.getExtensionBundlesByBucket("6");
+        assertNotNull(bundles2);
+        assertEquals(0, bundles2.size());
+    }
+
+    @Test
+    public void testGetExtensionBundlesByBucketAndGroup() {
+        final List<ExtensionBundleEntity> bundles = metadataService.getExtensionBundlesByBucketAndGroup("3", "org.apache.nifi");
+        assertNotNull(bundles);
+        assertEquals(3, bundles.size());
+
+        final List<ExtensionBundleEntity> bundles2 = metadataService.getExtensionBundlesByBucketAndGroup("3", "does-not-exist");
+        assertNotNull(bundles2);
+        assertEquals(0, bundles2.size());
+    }
+
+    @Test
+    public void testCreateExtensionBundle() {
+        final ExtensionBundleEntity entity = new ExtensionBundleEntity();
+        entity.setId(UUID.randomUUID().toString());
+        entity.setBucketId("3");
+        entity.setName("nifi-foo-nar");
+        entity.setDescription("This is foo nar");
+        entity.setCreated(new Date());
+        entity.setModified(new Date());
+        entity.setGroupId("org.apache.nifi");
+        entity.setArtifactId("nifi-foo-nar");
+        entity.setBundleType(ExtensionBundleEntityType.NIFI_NAR);
+
+        final ExtensionBundleEntity createdEntity = metadataService.createExtensionBundle(entity);
+        assertNotNull(createdEntity);
+
+        final List<ExtensionBundleEntity> bundles = metadataService.getExtensionBundlesByBucket("3");
+        assertNotNull(bundles);
+        assertEquals(4, bundles.size());
+    }
+
+    @Test
+    public void testDeleteExtensionBundle() {
+        final List<ExtensionBundleEntity> bundles = metadataService.getExtensionBundlesByBucket("3");
+        assertNotNull(bundles);
+        assertEquals(3, bundles.size());
+
+        final ExtensionBundleEntity existingBundle = bundles.get(0);
+        metadataService.deleteExtensionBundle(existingBundle);
+
+        final ExtensionBundleEntity deletedBundle = metadataService.getExtensionBundle(existingBundle.getId());
+        assertNull(deletedBundle);
+
+        final List<ExtensionBundleEntity> bundlesAfterDelete = metadataService.getExtensionBundlesByBucket("3");
+        assertNotNull(bundlesAfterDelete);
+        assertEquals(2, bundlesAfterDelete.size());
+    }
+
+    @Test
+    public void testDeleteBucketWithExtensionBundles() {
+        final List<ExtensionBundleEntity> bundles = metadataService.getExtensionBundlesByBucket("3");
+        assertNotNull(bundles);
+        assertEquals(3, bundles.size());
+
+        final BucketEntity bucket = metadataService.getBucketById("3");
+        assertNotNull(bucket);
+        metadataService.deleteBucket(bucket);
+
+        final List<ExtensionBundleEntity> bundlesAfterDelete = metadataService.getExtensionBundlesByBucket("3");
+        assertNotNull(bundlesAfterDelete);
+        assertEquals(0, bundlesAfterDelete.size());
+    }
+
+    //----------------- Extension Bundle Versions ---------------------------------
+
+    @Test
+    public void testCreateExtensionBundleVersion() {
+        final ExtensionBundleVersionEntity bundleVersion = new ExtensionBundleVersionEntity();
+        bundleVersion.setId(UUID.randomUUID().toString());
+        bundleVersion.setExtensionBundleId("eb1");
+        bundleVersion.setVersion("1.1.0");
+        bundleVersion.setCreated(new Date());
+        bundleVersion.setCreatedBy("user2");
+        bundleVersion.setDescription("This is v1.1.0");
+        bundleVersion.setSha256Hex("123456789");
+        bundleVersion.setSha256Supplied(false);
+
+        metadataService.createExtensionBundleVersion(bundleVersion);
+
+        final ExtensionBundleVersionEntity createdBundleVersion = metadataService.getExtensionBundleVersion("eb1", "1.1.0");
+        assertNotNull(createdBundleVersion);
+        assertEquals(bundleVersion.getId(), createdBundleVersion.getId());
+        assertFalse(bundleVersion.getSha256Supplied());
+    }
+
+    @Test
+    public void testGetExtensionBundleVersionByBundleIdAndVersion() {
+        final ExtensionBundleVersionEntity bundleVersion = metadataService.getExtensionBundleVersion("eb1", "1.0.0");
+        assertNotNull(bundleVersion);
+        assertEquals("eb1-v1", bundleVersion.getId());
+        assertEquals("eb1", bundleVersion.getExtensionBundleId());
+        assertEquals("1.0.0", bundleVersion.getVersion());
+        assertNotNull(bundleVersion.getCreated());
+        assertEquals("user1", bundleVersion.getCreatedBy());
+        assertEquals("First version of eb1", bundleVersion.getDescription());
+        assertTrue(bundleVersion.getSha256Supplied());
+    }
+
+    @Test
+    public void testGetExtensionBundleVersionByBundleIdAndVersionDoesNotExist() {
+        final ExtensionBundleVersionEntity bundleVersion = metadataService.getExtensionBundleVersion("does-not-exist", "1.0.0");
+        assertNull(bundleVersion);
+    }
+
+    @Test
+    public void testGetExtensionBundleVersionByBucketGroupArtifactVersion() {
+        final String bucketId = "3";
+        final String groupId = "org.apache.nifi";
+        final String artifactId = "nifi-example-processors-nar";
+        final String version = "1.0.0";
+
+        final ExtensionBundleVersionEntity bundleVersion = metadataService.getExtensionBundleVersion(bucketId, groupId, artifactId, version);
+        assertNotNull(bundleVersion);
+        assertEquals("eb1-v1", bundleVersion.getId());
+        assertTrue(bundleVersion.getSha256Supplied());
+    }
+
+    @Test
+    public void testGetExtensionBundleVersionByBucketGroupArtifactVersionWhenDoesNotExist() {
+        final String bucketId = "3";
+        final String groupId = "org.apache.nifi";
+        final String artifactId = "nifi-example-processors-nar";
+        final String version = "FOO";
+
+        final ExtensionBundleVersionEntity bundleVersion = metadataService.getExtensionBundleVersion(bucketId, groupId, artifactId, version);
+        assertNull(bundleVersion);
+    }
+
+    @Test
+    public void testGetExtensionBundleVersionsByBundleId() {
+        final List<ExtensionBundleVersionEntity> bundleVersions = metadataService.getExtensionBundleVersions("eb1");
+        assertNotNull(bundleVersions);
+        assertEquals(1, bundleVersions.size());
+
+        final ExtensionBundleVersionEntity bundleVersion = bundleVersions.get(0);
+        assertEquals("eb1", bundleVersion.getExtensionBundleId());
+    }
+
+    @Test
+    public void testGetExtensionBundleVersionsByBundleIdWhenDoesNotExist() {
+        final List<ExtensionBundleVersionEntity> bundleVersions = metadataService.getExtensionBundleVersions("does-not-exist");
+        assertNotNull(bundleVersions);
+        assertEquals(0, bundleVersions.size());
+    }
+
+    @Test
+    public void testGetExtensionBundleVersionsByBucketGroupArtifact() {
+        final String bucketId = "3";
+        final String groupId = "org.apache.nifi";
+        final String artifactId = "nifi-example-processors-nar";
+
+        final List<ExtensionBundleVersionEntity> bundleVersions = metadataService.getExtensionBundleVersions(bucketId, groupId, artifactId);
+        assertNotNull(bundleVersions);
+        assertEquals(1, bundleVersions.size());
+
+        final ExtensionBundleVersionEntity bundleVersion = bundleVersions.get(0);
+        assertEquals("eb1-v1", bundleVersion.getId());
+    }
+
+    @Test
+    public void testGetExtensionBundleVersionsByBucketGroupArtifactWhenDoesNotExist() {
+        final String bucketId = "3";
+        final String groupId = "org.apache.nifi";
+        final String artifactId = "does-not-exist";
+
+        final List<ExtensionBundleVersionEntity> bundleVersions = metadataService.getExtensionBundleVersions(bucketId, groupId, artifactId);
+        assertNotNull(bundleVersions);
+        assertEquals(0, bundleVersions.size());
+    }
+
+    @Test
+    public void testGetExtensionBundleVersionsGlobal() {
+        final String groupId = "org.apache.nifi";
+        final String artifactId = "nifi-example-processors-nar";
+        final String version = "1.0.0";
+
+        final List<ExtensionBundleVersionEntity> bundleVersions = metadataService.getExtensionBundleVersionsGlobal(groupId, artifactId, version);
+        assertNotNull(bundleVersions);
+        assertEquals(1, bundleVersions.size());
+
+        final ExtensionBundleVersionEntity bundleVersion = bundleVersions.get(0);
+        assertEquals("eb1-v1", bundleVersion.getId());
+    }
+
+    @Test
+    public void testDeleteExtensionBundleVersion() {
+        final ExtensionBundleVersionEntity bundleVersion = metadataService.getExtensionBundleVersion("eb1", "1.0.0");
+        assertNotNull(bundleVersion);
+
+        metadataService.deleteExtensionBundleVersion(bundleVersion);
+
+        final ExtensionBundleVersionEntity deletedBundleVersion = metadataService.getExtensionBundleVersion("eb1", "1.0.0");
+        assertNull(deletedBundleVersion);
+    }
+
+    // ---------- Extension Bundle Version Dependencies ------------
+
+    @Test
+    public void testCreateExtensionBundleVersionDependency() {
+        final ExtensionBundleVersionEntity versionEntity = metadataService.getExtensionBundleVersion("eb1", "1.0.0");
+        assertNotNull(versionEntity);
+
+        final List<ExtensionBundleVersionDependencyEntity> dependencies = metadataService.getDependenciesForBundleVersion(versionEntity.getId());
+        assertNotNull(dependencies);
+        assertEquals(1, dependencies.size());
+
+        final ExtensionBundleVersionDependencyEntity dependencyEntity = new ExtensionBundleVersionDependencyEntity();
+        dependencyEntity.setId(UUID.randomUUID().toString());
+        dependencyEntity.setExtensionBundleVersionId(versionEntity.getId());
+        dependencyEntity.setGroupId("com.foo");
+        dependencyEntity.setArtifactId("foo-nar");
+        dependencyEntity.setVersion("1.1.1");
+
+        metadataService.createDependency(dependencyEntity);
+
+        final List<ExtensionBundleVersionDependencyEntity> dependencies2 = metadataService.getDependenciesForBundleVersion(versionEntity.getId());
+        assertNotNull(dependencies2);
+        assertEquals(2, dependencies2.size());
+    }
+
+    @Test
+    public void testGetExtensionBundleVersionDependencies() {
+        final List<ExtensionBundleVersionDependencyEntity> dependencies = metadataService.getDependenciesForBundleVersion("eb1-v1");
+        assertNotNull(dependencies);
+        assertEquals(1, dependencies.size());
+
+        final ExtensionBundleVersionDependencyEntity dependency = dependencies.get(0);
+        assertEquals("eb1-v1-dep1", dependency.getId());
+        assertEquals("eb1-v1", dependency.getExtensionBundleVersionId());
+        assertEquals("org.apache.nifi", dependency.getGroupId());
+        assertEquals("nifi-example-service-api-nar", dependency.getArtifactId());
+        assertEquals("2.0.0", dependency.getVersion());
+    }
+
+    @Test
+    public void testGetExtensionBundleVersionDependenciesWhenNoneExist() {
+        final List<ExtensionBundleVersionDependencyEntity> dependencies = metadataService.getDependenciesForBundleVersion("DOES-NOT-EXIST");
+        assertNotNull(dependencies);
+        assertEquals(0, dependencies.size());
+    }
+
+    //----------------- Extensions ---------------------------------
+
+    @Test
+    public void testCreateExtension() {
+        final ExtensionEntity extension = new ExtensionEntity();
+        extension.setId("4");
+        extension.setExtensionBundleVersionId("eb1-v1");
+        extension.setType("com.example.FooBarProcessor");
+        extension.setTypeDescription("This the FoorBarProcessor");
+        extension.setCategory(ExtensionEntityCategory.PROCESSOR);
+        extension.setRestricted(false);
+        extension.setTags("tag1, tag2");
+
+        metadataService.createExtension(extension);
+
+        final ExtensionEntity retrievedExtension = metadataService.getExtensionById(extension.getId());
+        assertEquals(extension.getId(), retrievedExtension.getId());
+        assertEquals(extension.getExtensionBundleVersionId(), retrievedExtension.getExtensionBundleVersionId());
+        assertEquals(extension.getType(), retrievedExtension.getType());
+        assertEquals(extension.getTypeDescription(), retrievedExtension.getTypeDescription());
+        assertEquals(extension.getCategory(), retrievedExtension.getCategory());
+        assertEquals(extension.isRestricted(), retrievedExtension.isRestricted());
+        assertEquals(extension.getTags(), retrievedExtension.getTags());
+
+        final List<ExtensionEntity> tag1Extensions = metadataService.getExtensionsByTag("tag1");
+        assertNotNull(tag1Extensions);
+        assertEquals(1, tag1Extensions.size());
+        assertEquals(extension.getId(), tag1Extensions.get(0).getId());
+
+        final List<ExtensionEntity> tag2Extensions = metadataService.getExtensionsByTag("tag2");
+        assertNotNull(tag2Extensions);
+        assertEquals(1, tag2Extensions.size());
+        assertEquals(extension.getId(), tag2Extensions.get(0).getId());
+    }
+
+    @Test
+    public void testGetExtensionById() {
+        final ExtensionEntity extension = metadataService.getExtensionById("e1");
+        assertNotNull(extension);
+        assertEquals("e1", extension.getId());
+        assertEquals("org.apache.nifi.ExampleProcessor", extension.getType());
+    }
+
+    @Test
+    public void testGetExtensionByIdDoesNotExist() {
+        final ExtensionEntity extension = metadataService.getExtensionById("does-not-exist");
+        assertNull(extension);
+    }
+
+    @Test
+    public void testGetAllExtensions() {
+        final List<ExtensionEntity> extensions = metadataService.getAllExtensions();
+        assertNotNull(extensions);
+        assertEquals(3, extensions.size());
+    }
+
+    @Test
+    public void testGetExtensionsByBundleVersionId() {
+        final List<ExtensionEntity> extensions = metadataService.getExtensionsByBundleVersionId("eb1-v1");
+        assertNotNull(extensions);
+        assertEquals(2, extensions.size());
+    }
+
+    @Test
+    public void testGetExtensionsByBundleVersionIdDoesNotExist() {
+        final List<ExtensionEntity> extensions = metadataService.getExtensionsByBundleVersionId("does-not-exist");
+        assertNotNull(extensions);
+        assertEquals(0, extensions.size());
+    }
+
+    @Test
+    public void testGetExtensionsByBundleCoordinate() {
+        final String bucketId = "3";
+        final String groupId = "org.apache.nifi";
+        final String artifactId = "nifi-example-processors-nar";
+        final String version = "1.0.0";
+
+        final List<ExtensionEntity> extensions = metadataService.getExtensionsByBundleCoordinate(bucketId, groupId, artifactId, version);
+        assertNotNull(extensions);
+        assertEquals(2, extensions.size());
+    }
+
+    @Test
+    public void testGetExtensionsByBundleCoordinateDoesNotExist() {
+        final String bucketId = "3";
+        final String groupId = "org.apache.nifi";
+        final String artifactId = "does-not-exist";
+        final String version = "1.0.0";
+
+        final List<ExtensionEntity> extensions = metadataService.getExtensionsByBundleCoordinate(bucketId, groupId, artifactId, version);
+        assertNotNull(extensions);
+        assertEquals(0, extensions.size());
+    }
+
+    @Test
+    public void testGetExtensionsByCategory() {
+        final List<ExtensionEntity> services = metadataService.getExtensionsByCategory(ExtensionEntityCategory.CONTROLLER_SERVICE);
+        assertNotNull(services);
+        assertEquals(1, services.size());
+
+        final List<ExtensionEntity> processors = metadataService.getExtensionsByCategory(ExtensionEntityCategory.PROCESSOR);
+        assertNotNull(processors);
+        assertEquals(2, processors.size());
+    }
+
+    @Test
+    public void testGetExtensionTags() {
+        final Set<String> tags = metadataService.getAllExtensionTags();
+        assertNotNull(tags);
+        assertEquals(4, tags.size());
+    }
+
+    @Test
+    public void testDeleteExtension() {
+        final ExtensionEntity extension = metadataService.getExtensionById("e1");
+        assertNotNull(extension);
+
+        metadataService.deleteExtension(extension);
+
+        final ExtensionEntity deletedExtension = metadataService.getExtensionById("e1");
+        assertNull(deletedExtension);
+    }
+
 }
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/db/migration/TestLegacyDatabaseService.java b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/db/migration/TestLegacyDatabaseService.java
index df37e8e..116ba35 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/db/migration/TestLegacyDatabaseService.java
+++ b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/db/migration/TestLegacyDatabaseService.java
@@ -52,9 +52,11 @@
 
         jdbcTemplate = new JdbcTemplate(dataSource);
 
-        flyway = new Flyway();
-        flyway.setDataSource(dataSource);
-        flyway.setLocations("db/original");
+        flyway = Flyway.configure()
+                .dataSource(dataSource)
+                .locations("db/original")
+                .load();
+
         flyway.migrate();
 
         bucketEntityV1 = new BucketEntityV1();
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/event/TestEventFactory.java b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/event/TestEventFactory.java
index a9ad911..e5075c0 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/event/TestEventFactory.java
+++ b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/event/TestEventFactory.java
@@ -17,6 +17,10 @@
 package org.apache.nifi.registry.event;
 
 import org.apache.nifi.registry.bucket.Bucket;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+import org.apache.nifi.registry.extension.ExtensionBundleType;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionMetadata;
 import org.apache.nifi.registry.flow.VersionedFlow;
 import org.apache.nifi.registry.flow.VersionedFlowSnapshot;
 import org.apache.nifi.registry.flow.VersionedFlowSnapshotMetadata;
@@ -36,6 +40,8 @@
     private Bucket bucket;
     private VersionedFlow versionedFlow;
     private VersionedFlowSnapshot versionedFlowSnapshot;
+    private ExtensionBundle extensionBundle;
+    private ExtensionBundleVersion extensionBundleVersion;
 
     @Before
     public void setup() {
@@ -60,6 +66,24 @@
         versionedFlowSnapshot = new VersionedFlowSnapshot();
         versionedFlowSnapshot.setSnapshotMetadata(metadata);
         versionedFlowSnapshot.setFlowContents(new VersionedProcessGroup());
+
+        extensionBundle = new ExtensionBundle();
+        extensionBundle.setIdentifier(UUID.randomUUID().toString());
+        extensionBundle.setBucketIdentifier(bucket.getIdentifier());
+        extensionBundle.setBundleType(ExtensionBundleType.NIFI_NAR);
+        extensionBundle.setGroupId("org.apache.nifi");
+        extensionBundle.setArtifactId("nifi-foo-nar");
+
+        final ExtensionBundleVersionMetadata bundleVersionMetadata = new ExtensionBundleVersionMetadata();
+        bundleVersionMetadata.setId(UUID.randomUUID().toString());
+        bundleVersionMetadata.setVersion("1.0.0");
+        bundleVersionMetadata.setBucketId(bucket.getIdentifier());
+        bundleVersionMetadata.setExtensionBundleId(extensionBundle.getIdentifier());
+
+        extensionBundleVersion = new ExtensionBundleVersion();
+        extensionBundleVersion.setVersionMetadata(bundleVersionMetadata);
+        extensionBundleVersion.setExtensionBundle(extensionBundle);
+        extensionBundleVersion.setBucket(bucket);
     }
 
     @Test
@@ -166,4 +190,57 @@
         assertEquals("", event.getField(EventFieldName.COMMENT).getValue());
     }
 
+    @Test
+    public void testExtensionBundleCreated() {
+        final Event event = EventFactory.extensionBundleCreated(extensionBundle);
+        event.validate();
+
+        assertEquals(EventType.CREATE_EXTENSION_BUNDLE, event.getEventType());
+        assertEquals(3, event.getFields().size());
+
+        assertEquals(bucket.getIdentifier(), event.getField(EventFieldName.BUCKET_ID).getValue());
+        assertEquals(extensionBundle.getIdentifier(), event.getField(EventFieldName.EXTENSION_BUNDLE_ID).getValue());
+        assertEquals("unknown", event.getField(EventFieldName.USER).getValue());
+    }
+
+    @Test
+    public void testExtensionBundleDeleted() {
+        final Event event = EventFactory.extensionBundleDeleted(extensionBundle);
+        event.validate();
+
+        assertEquals(EventType.DELETE_EXTENSION_BUNDLE, event.getEventType());
+        assertEquals(3, event.getFields().size());
+
+        assertEquals(bucket.getIdentifier(), event.getField(EventFieldName.BUCKET_ID).getValue());
+        assertEquals(extensionBundle.getIdentifier(), event.getField(EventFieldName.EXTENSION_BUNDLE_ID).getValue());
+        assertEquals("unknown", event.getField(EventFieldName.USER).getValue());
+    }
+
+    @Test
+    public void testExtensionBundleVersionCreated() {
+        final Event event = EventFactory.extensionBundleVersionCreated(extensionBundleVersion);
+        event.validate();
+
+        assertEquals(EventType.CREATE_EXTENSION_BUNDLE_VERSION, event.getEventType());
+        assertEquals(4, event.getFields().size());
+
+        assertEquals(bucket.getIdentifier(), event.getField(EventFieldName.BUCKET_ID).getValue());
+        assertEquals(extensionBundle.getIdentifier(), event.getField(EventFieldName.EXTENSION_BUNDLE_ID).getValue());
+        assertEquals(extensionBundleVersion.getVersionMetadata().getVersion(), event.getField(EventFieldName.VERSION).getValue());
+        assertEquals("unknown", event.getField(EventFieldName.USER).getValue());
+    }
+
+    @Test
+    public void testExtensionBundleVersionDeleted() {
+        final Event event = EventFactory.extensionBundleVersionDeleted(extensionBundleVersion);
+        event.validate();
+
+        assertEquals(EventType.DELETE_EXTENSION_BUNDLE_VERSION, event.getEventType());
+        assertEquals(4, event.getFields().size());
+
+        assertEquals(bucket.getIdentifier(), event.getField(EventFieldName.BUCKET_ID).getValue());
+        assertEquals(extensionBundle.getIdentifier(), event.getField(EventFieldName.EXTENSION_BUNDLE_ID).getValue());
+        assertEquals(extensionBundleVersion.getVersionMetadata().getVersion(), event.getField(EventFieldName.VERSION).getValue());
+        assertEquals("unknown", event.getField(EventFieldName.USER).getValue());
+    }
 }
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/MockExtensionBundlePersistenceProvider.java b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/MockExtensionBundlePersistenceProvider.java
new file mode 100644
index 0000000..ba7f12f
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/MockExtensionBundlePersistenceProvider.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.registry.provider;
+
+import org.apache.nifi.registry.extension.ExtensionBundleContext;
+import org.apache.nifi.registry.extension.ExtensionBundlePersistenceException;
+import org.apache.nifi.registry.extension.ExtensionBundlePersistenceProvider;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Map;
+
+public class MockExtensionBundlePersistenceProvider implements ExtensionBundlePersistenceProvider {
+
+    private Map<String,String> properties;
+
+    @Override
+    public void saveBundleVersion(ExtensionBundleContext context, InputStream contentStream)
+            throws ExtensionBundlePersistenceException {
+
+    }
+
+    @Override
+    public void getBundleVersion(ExtensionBundleContext context, OutputStream outputStream) throws ExtensionBundlePersistenceException {
+
+    }
+
+    @Override
+    public void deleteBundleVersion(ExtensionBundleContext context) throws ExtensionBundlePersistenceException {
+
+    }
+
+    @Override
+    public void deleteAllBundleVersions(String bucketId, String bucketName, String groupId, String artifactId) throws ExtensionBundlePersistenceException {
+
+    }
+
+    @Override
+    public void onConfigured(ProviderConfigurationContext configurationContext)
+            throws ProviderCreationException {
+        properties = configurationContext.getProperties();
+    }
+
+    public Map<String,String> getProperties() {
+        return properties;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/TestStandardProviderFactory.java b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/TestStandardProviderFactory.java
index 30f66ef..2105cff 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/TestStandardProviderFactory.java
+++ b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/TestStandardProviderFactory.java
@@ -16,6 +16,7 @@
  */
 package org.apache.nifi.registry.provider;
 
+import org.apache.nifi.registry.extension.ExtensionBundlePersistenceProvider;
 import org.apache.nifi.registry.extension.ExtensionManager;
 import org.apache.nifi.registry.flow.FlowPersistenceProvider;
 import org.apache.nifi.registry.properties.NiFiRegistryProperties;
@@ -47,6 +48,14 @@
         assertNotNull(mockFlowProvider.getProperties());
         assertEquals("flow foo", mockFlowProvider.getProperties().get("Flow Property 1"));
         assertEquals("flow bar", mockFlowProvider.getProperties().get("Flow Property 2"));
+
+        final ExtensionBundlePersistenceProvider bundlePersistenceProvider = providerFactory.getExtensionBundlePersistenceProvider();
+        assertNotNull(bundlePersistenceProvider);
+
+        final MockExtensionBundlePersistenceProvider mockBundlePersistenceProvider = (MockExtensionBundlePersistenceProvider) bundlePersistenceProvider;
+        assertNotNull(mockBundlePersistenceProvider.getProperties());
+        assertEquals("extension foo", mockBundlePersistenceProvider.getProperties().get("Extension Property 1"));
+        assertEquals("extension bar", mockBundlePersistenceProvider.getProperties().get("Extension Property 2"));
     }
 
     @Test(expected = ProviderFactoryException.class)
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/extension/TestFileSystemExtensionBundlePersistenceProvider.java b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/extension/TestFileSystemExtensionBundlePersistenceProvider.java
new file mode 100644
index 0000000..5a611bb
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/provider/extension/TestFileSystemExtensionBundlePersistenceProvider.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.registry.provider.extension;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.nifi.registry.extension.ExtensionBundleContext;
+import org.apache.nifi.registry.extension.ExtensionBundlePersistenceException;
+import org.apache.nifi.registry.extension.ExtensionBundlePersistenceProvider;
+import org.apache.nifi.registry.provider.ProviderConfigurationContext;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.mockito.Mockito.when;
+
+public class TestFileSystemExtensionBundlePersistenceProvider {
+
+    static final String EXTENSION_STORAGE_DIR = "target/extension_storage";
+
+    static final ProviderConfigurationContext CONFIGURATION_CONTEXT = new ProviderConfigurationContext() {
+        @Override
+        public Map<String, String> getProperties() {
+            final Map<String,String> props = new HashMap<>();
+            props.put(FileSystemExtensionBundlePersistenceProvider.BUNDLE_STORAGE_DIR_PROP, EXTENSION_STORAGE_DIR);
+            return props;
+        }
+    };
+
+    private File bundleStorageDir;
+    private ExtensionBundlePersistenceProvider fileSystemBundleProvider;
+
+    @Before
+    public void setup() throws IOException {
+        bundleStorageDir = new File(EXTENSION_STORAGE_DIR);
+        if (bundleStorageDir.exists()) {
+            org.apache.commons.io.FileUtils.cleanDirectory(bundleStorageDir);
+            bundleStorageDir.delete();
+        }
+
+        Assert.assertFalse(bundleStorageDir.exists());
+
+        fileSystemBundleProvider = new FileSystemExtensionBundlePersistenceProvider();
+        fileSystemBundleProvider.onConfigured(CONFIGURATION_CONTEXT);
+        Assert.assertTrue(bundleStorageDir.exists());
+    }
+
+    @Test
+    public void testSaveSuccessfully() throws IOException {
+        // first version in b1
+        final String content1 = "g1-a1-1.0.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, "b1", "g1", "a1", "1.0.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content1);
+        verifyBundleVersion(bundleStorageDir, "b1", "g1", "a1", "1.0.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content1);
+
+        // second version in b1
+        final String content2 = "g1-a1-1.1.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, "b1", "g1", "a1", "1.1.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content2);
+        verifyBundleVersion(bundleStorageDir, "b1", "g1", "a1", "1.1.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content2);
+
+        // same bundle but in b2
+        final String content3 = "g1-a1-1.1.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, "b2", "g1", "a1", "1.1.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content3);
+        verifyBundleVersion(bundleStorageDir, "b2", "g1", "a1", "1.1.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content2);
+    }
+
+    @Test
+    public void testSaveWhenBundleVersionAlreadyExists() throws IOException {
+        final String content1 = "g1-a1-1.0.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, "b1", "g1", "a1", "1.0.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content1);
+        verifyBundleVersion(bundleStorageDir, "b1", "g1", "a1", "1.0.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content1);
+
+        // try to save same bundle version that already exists
+        try {
+            final String newContent = "new content";
+            createAndSaveBundleVersion(fileSystemBundleProvider, "b1", "g1", "a1", "1.0.0",
+                    ExtensionBundleContext.BundleType.NIFI_NAR, newContent);
+            Assert.fail("Should have thrown exception");
+        } catch (ExtensionBundlePersistenceException e) {
+
+        }
+
+        // verify existing content wasn't modified
+        verifyBundleVersion(bundleStorageDir, "b1", "g1", "a1", "1.0.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content1);
+    }
+
+    @Test
+    public void testSaveAndGet() throws IOException {
+        final String bucketName = "b1";
+        final String groupId = "g1";
+        final String artifactId = "a1";
+
+        final String content1 = groupId + "-" + artifactId + "-" + "1.0.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, bucketName, groupId, artifactId, "1.0.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content1);
+
+        final String content2 = groupId + "-" + artifactId + "-" + "1.1.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, bucketName, groupId, artifactId, "1.1.0",
+                ExtensionBundleContext.BundleType.NIFI_NAR, content2);
+
+        try (final OutputStream out = new ByteArrayOutputStream()) {
+            final ExtensionBundleContext context = getExtensionBundleContext(
+                    bucketName, groupId, artifactId, "1.0.0", ExtensionBundleContext.BundleType.NIFI_NAR);
+            fileSystemBundleProvider.getBundleVersion(context, out);
+
+            final String retrievedContent1 = new String(((ByteArrayOutputStream) out).toByteArray(), StandardCharsets.UTF_8);
+            Assert.assertEquals(content1, retrievedContent1);
+        }
+
+        try (final OutputStream out = new ByteArrayOutputStream()) {
+            final ExtensionBundleContext context = getExtensionBundleContext(
+                    bucketName, groupId, artifactId, "1.1.0", ExtensionBundleContext.BundleType.NIFI_NAR);
+            fileSystemBundleProvider.getBundleVersion(context, out);
+
+            final String retrievedContent2 = new String(((ByteArrayOutputStream) out).toByteArray(), StandardCharsets.UTF_8);
+            Assert.assertEquals(content2, retrievedContent2);
+        }
+    }
+
+    @Test(expected = ExtensionBundlePersistenceException.class)
+    public void testGetWhenDoesNotExist() throws IOException {
+        final String bucketName = "b1";
+        final String groupId = "g1";
+        final String artifactId = "a1";
+
+        try (final OutputStream out = new ByteArrayOutputStream()) {
+            final ExtensionBundleContext context = getExtensionBundleContext(
+                    bucketName, groupId, artifactId, "1.0.0", ExtensionBundleContext.BundleType.NIFI_NAR);
+            fileSystemBundleProvider.getBundleVersion(context, out);
+            Assert.fail("Should have thrown exception");
+        }
+    }
+
+    @Test
+    public void testDeleteExtensionBundleVersion() throws IOException {
+        final String bucketName = "b1";
+        final String groupId = "g1";
+        final String artifactId = "a1";
+        final String version = "1.0.0";
+        final ExtensionBundleContext.BundleType bundleType = ExtensionBundleContext.BundleType.NIFI_NAR;
+
+        // create and verify the bundle version
+        final String content1 = groupId + "-" + artifactId + "-" + "1.0.0";
+        createAndSaveBundleVersion(fileSystemBundleProvider, bucketName, groupId, artifactId, version, bundleType, content1);
+        verifyBundleVersion(bundleStorageDir, bucketName, groupId, artifactId, version, bundleType, content1);
+
+        // delete the bundle version
+        fileSystemBundleProvider.deleteBundleVersion(getExtensionBundleContext(bucketName, groupId, artifactId, version, bundleType));
+
+        // verify it was deleted
+        final File bundleVersionDir = FileSystemExtensionBundlePersistenceProvider.getBundleVersionDirectory(
+                bundleStorageDir, bucketName, groupId, artifactId, version);
+
+        final File bundleFile = FileSystemExtensionBundlePersistenceProvider.getBundleFile(
+                bundleVersionDir, artifactId, version, bundleType);
+        Assert.assertFalse(bundleFile.exists());
+    }
+
+    @Test
+    public void testDeleteExtensionBundleVersionWhenDoesNotExist() throws IOException {
+        final String bucketName = "b1";
+        final String groupId = "g1";
+        final String artifactId = "a1";
+        final String version = "1.0.0";
+        final ExtensionBundleContext.BundleType bundleType = ExtensionBundleContext.BundleType.NIFI_NAR;
+
+        // verify the bundle version does not already exist
+        final File bundleVersionDir = FileSystemExtensionBundlePersistenceProvider.getBundleVersionDirectory(
+                bundleStorageDir, bucketName, groupId, artifactId, version);
+
+        final File bundleFile = FileSystemExtensionBundlePersistenceProvider.getBundleFile(
+                bundleVersionDir, artifactId, version, bundleType);
+        Assert.assertFalse(bundleFile.exists());
+
+        // delete the bundle version
+        fileSystemBundleProvider.deleteBundleVersion(getExtensionBundleContext(bucketName, groupId, artifactId, version, bundleType));
+    }
+
+    @Test
+    public void testDeleteAllBundleVersions() throws IOException {
+        final String bucketName = "b1";
+        final String groupId = "g1";
+        final String artifactId = "a1";
+        final String version1 = "1.0.0";
+        final String version2 = "2.0.0";
+        final ExtensionBundleContext.BundleType bundleType = ExtensionBundleContext.BundleType.NIFI_NAR;
+
+        // create and verify the bundle version 1
+        final String content1 = groupId + "-" + artifactId + "-" + version1;
+        createAndSaveBundleVersion(fileSystemBundleProvider, bucketName, groupId, artifactId, version1, bundleType, content1);
+        verifyBundleVersion(bundleStorageDir, bucketName, groupId, artifactId, version1, bundleType, content1);
+
+        // create and verify the bundle version 2
+        final String content2 = groupId + "-" + artifactId + "-" + version2;
+        createAndSaveBundleVersion(fileSystemBundleProvider, bucketName, groupId, artifactId, version2, bundleType, content2);
+        verifyBundleVersion(bundleStorageDir, bucketName, groupId, artifactId, version2, bundleType, content2);
+
+        fileSystemBundleProvider.deleteAllBundleVersions(bucketName, bucketName, groupId, artifactId);
+        Assert.assertEquals(0, bundleStorageDir.listFiles().length);
+    }
+
+    @Test
+    public void testDeleteAllBundleVersionsWhenDoesNotExist() throws IOException {
+        final String bucketName = "b1";
+        final String groupId = "g1";
+        final String artifactId = "a1";
+
+        Assert.assertEquals(0, bundleStorageDir.listFiles().length);
+        fileSystemBundleProvider.deleteAllBundleVersions(bucketName, bucketName, groupId, artifactId);
+        Assert.assertEquals(0, bundleStorageDir.listFiles().length);
+    }
+
+    private void createAndSaveBundleVersion(final ExtensionBundlePersistenceProvider persistenceProvider,
+                                            final String bucketName,
+                                            final String groupId,
+                                            final String artifactId,
+                                            final String version,
+                                            final ExtensionBundleContext.BundleType bundleType,
+                                            final String content) throws IOException {
+
+        final ExtensionBundleContext context = getExtensionBundleContext(bucketName, groupId, artifactId, version, bundleType);
+
+        try (final InputStream in = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
+            persistenceProvider.saveBundleVersion(context, in);
+        }
+    }
+
+    private static ExtensionBundleContext getExtensionBundleContext(final String bucketName,
+                                                                    final String groupId,
+                                                                    final String artifactId,
+                                                                    final String version,
+                                                                    final ExtensionBundleContext.BundleType bundleType) {
+        final ExtensionBundleContext context = Mockito.mock(ExtensionBundleContext.class);
+        when(context.getBucketName()).thenReturn(bucketName);
+        when(context.getBundleGroupId()).thenReturn(groupId);
+        when(context.getBundleArtifactId()).thenReturn(artifactId);
+        when(context.getBundleVersion()).thenReturn(version);
+        when(context.getBundleType()).thenReturn(bundleType);
+        return context;
+    }
+
+    private static void verifyBundleVersion(final File storageDir,
+                                     final String bucketName,
+                                     final String groupId,
+                                     final String artifactId,
+                                     final String version,
+                                     final ExtensionBundleContext.BundleType bundleType,
+                                     final String contentString) throws IOException {
+
+        final File bundleVersionDir = FileSystemExtensionBundlePersistenceProvider.getBundleVersionDirectory(
+                storageDir, bucketName, groupId, artifactId, version);
+
+        final File bundleFile = FileSystemExtensionBundlePersistenceProvider.getBundleFile(
+                bundleVersionDir, artifactId, version, bundleType);
+        Assert.assertTrue(bundleFile.exists());
+
+        try (InputStream in = new FileInputStream(bundleFile)) {
+            Assert.assertEquals(contentString, IOUtils.toString(in, StandardCharsets.UTF_8));
+        }
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/security/ldap/tenants/LdapUserGroupProviderTest.java b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/security/ldap/tenants/LdapUserGroupProviderTest.java
index 242cf28..9a408b2 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/security/ldap/tenants/LdapUserGroupProviderTest.java
+++ b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/security/ldap/tenants/LdapUserGroupProviderTest.java
@@ -67,7 +67,7 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
-import static org.mockito.Matchers.anyString;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/TestRegistryService.java b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/TestRegistryService.java
index 95e2d1a..0af08c1 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/TestRegistryService.java
+++ b/nifi-registry-core/nifi-registry-framework/src/test/java/org/apache/nifi/registry/service/TestRegistryService.java
@@ -32,6 +32,8 @@
 import org.apache.nifi.registry.flow.VersionedProcessor;
 import org.apache.nifi.registry.serialization.Serializer;
 import org.apache.nifi.registry.serialization.VersionedProcessGroupSerializer;
+import org.apache.nifi.registry.service.extension.ExtensionService;
+import org.apache.nifi.registry.service.extension.StandardExtensionService;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
@@ -73,6 +75,7 @@
     private MetadataService metadataService;
     private FlowPersistenceProvider flowPersistenceProvider;
     private Serializer<VersionedProcessGroup> snapshotSerializer;
+    private ExtensionService extensionService;
     private Validator validator;
 
     private RegistryService registryService;
@@ -82,11 +85,12 @@
         metadataService = mock(MetadataService.class);
         flowPersistenceProvider = mock(FlowPersistenceProvider.class);
         snapshotSerializer = mock(VersionedProcessGroupSerializer.class);
+        extensionService = mock(StandardExtensionService.class);
 
         final ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
         validator = validatorFactory.getValidator();
 
-        registryService = new RegistryService(metadataService, flowPersistenceProvider, snapshotSerializer, validator);
+        registryService = new RegistryService(metadataService, flowPersistenceProvider, snapshotSerializer, extensionService, validator);
     }
 
     // ---------------------- Test Bucket methods ---------------------------------------------
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/resources/db/migration/V999999.1__test-setup.sql b/nifi-registry-core/nifi-registry-framework/src/test/resources/db/migration/V999999.1__test-setup.sql
index 8f3cfa8..b8e0d3a 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/resources/db/migration/V999999.1__test-setup.sql
+++ b/nifi-registry-core/nifi-registry-framework/src/test/resources/db/migration/V999999.1__test-setup.sql
@@ -67,4 +67,225 @@
 -- test data for signing keys
 
 insert into signing_key (id, tenant_identity, key_value)
-  values ('1', 'unit_test_tenant_identity', '0123456789abcdef');
\ No newline at end of file
+  values ('1', 'unit_test_tenant_identity', '0123456789abcdef');
+
+-- test data for extension bundles
+
+-- processors bundle, depends on service api bundle
+insert into bucket_item (
+  id,
+  name,
+  description,
+  created,
+  modified,
+  item_type,
+  bucket_id
+) values (
+  'eb1',
+  'nifi-example-processors-nar',
+  'Example processors bundle',
+  parsedatetime('2018-11-02 12:56:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  parsedatetime('2018-11-02 12:56:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  'EXTENSION_BUNDLE',
+  '3'
+);
+
+insert into extension_bundle (
+  id,
+  bucket_id,
+  bundle_type,
+  group_id,
+  artifact_id
+) values (
+  'eb1',
+  '3',
+  'NIFI_NAR',
+  'org.apache.nifi',
+  'nifi-example-processors-nar'
+);
+
+insert into extension_bundle_version (
+  id,
+  extension_bundle_id,
+  version,
+  created,
+  created_by,
+  description,
+  sha_256_hex,
+  sha_256_supplied
+) values (
+  'eb1-v1',
+  'eb1',
+  '1.0.0',
+  parsedatetime('2018-11-02 13:00:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  'user1',
+  'First version of eb1',
+  '123456789',
+  '1'
+);
+
+insert into extension_bundle_version_dependency (
+  id,
+  extension_bundle_version_id,
+  group_id,
+  artifact_id,
+  version
+) values (
+  'eb1-v1-dep1',
+  'eb1-v1',
+  'org.apache.nifi',
+  'nifi-example-service-api-nar',
+  '2.0.0'
+);
+
+-- service impl bundle, depends on service api bundle
+insert into bucket_item (
+  id,
+  name,
+  description,
+  created,
+  modified,
+  item_type,
+  bucket_id
+) values (
+  'eb2',
+  'nifi-example-services-nar',
+  'Example services bundle',
+  parsedatetime('2018-11-02 12:57:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  parsedatetime('2018-11-02 12:57:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  'EXTENSION_BUNDLE',
+  '3'
+);
+
+insert into extension_bundle (
+  id,
+  bucket_id,
+  bundle_type,
+  group_id,
+  artifact_id
+) values (
+  'eb2',
+  '3',
+  'NIFI_NAR',
+  'org.apache.nifi',
+  'nifi-example-services-nar'
+);
+
+insert into extension_bundle_version (
+  id,
+  extension_bundle_id,
+  version,
+  created,
+  created_by,
+  description,
+  sha_256_hex,
+  sha_256_supplied
+) values (
+  'eb2-v1',
+  'eb2',
+  '1.0.0',
+  parsedatetime('2018-11-02 13:00:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  'user1',
+  'First version of eb2',
+  '123456789',
+  '1'
+);
+
+insert into extension_bundle_version_dependency (
+  id,
+  extension_bundle_version_id,
+  group_id,
+  artifact_id,
+  version
+) values (
+  'eb2-v1-dep1',
+  'eb2-v1',
+  'org.apache.nifi',
+  'nifi-example-service-api-nar',
+  '2.0.0'
+);
+
+-- service api bundle
+insert into bucket_item (
+  id,
+  name,
+  description,
+  created,
+  modified,
+  item_type,
+  bucket_id
+) values (
+  'eb3',
+  'nifi-example-service-api-nar',
+  'Example service API bundle',
+  parsedatetime('2018-11-02 12:58:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  parsedatetime('2017-11-02 12:58:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  'EXTENSION_BUNDLE',
+  '3'
+);
+
+insert into extension_bundle (
+  id,
+  bucket_id,
+  bundle_type,
+  group_id,
+  artifact_id
+) values (
+  'eb3',
+  '3',
+  'NIFI_NAR',
+  'org.apache.nifi',
+  'nifi-example-service-api-nar'
+);
+
+insert into extension_bundle_version (
+  id,
+  extension_bundle_id,
+  version,
+  created,
+  created_by,
+  description,
+  sha_256_hex,
+  sha_256_supplied
+) values (
+  'eb3-v1',
+  'eb3',
+  '2.0.0',
+  parsedatetime('2018-11-02 13:00:00.000 UTC', 'yyyy-MM-dd hh:mm:ss.SSS z'),
+  'user1',
+  'First version of eb3',
+  '123456789',
+  '1'
+);
+
+-- test data for extensions
+
+insert into extension (
+  id, extension_bundle_version_id, type, type_description, is_restricted, category, tags
+) values (
+  'e1', 'eb1-v1', 'org.apache.nifi.ExampleProcessor', 'This is Example Processor 1', 0, 'PROCESSOR', 'example, processor'
+);
+
+insert into extension (
+  id, extension_bundle_version_id, type, type_description, is_restricted, category, tags)
+values (
+  'e2', 'eb1-v1', 'org.apache.nifi.ExampleProcessorRestricted', 'This is Example Processor Restricted', 1, 'PROCESSOR', 'example, processor, restricted'
+);
+
+insert into extension (
+  id, extension_bundle_version_id, type, type_description, is_restricted, category, tags)
+values (
+  'e3', 'eb2-v1', 'org.apache.nifi.ExampleService', 'This is Example Service', 0, 'CONTROLLER_SERVICE', 'example, service'
+);
+
+-- test data for extension tags
+
+insert into extension_tag (extension_id, tag) values ('e1', 'example');
+insert into extension_tag (extension_id, tag) values ('e1', 'processor');
+
+insert into extension_tag (extension_id, tag) values ('e2', 'example');
+insert into extension_tag (extension_id, tag) values ('e2', 'processor');
+insert into extension_tag (extension_id, tag) values ('e2', 'restricted');
+
+insert into extension_tag (extension_id, tag) values ('e3', 'example');
+insert into extension_tag (extension_id, tag) values ('e3', 'service');
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/hook/bad-script-provider.xml b/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/hook/bad-script-provider.xml
index 568e756..1e23386 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/hook/bad-script-provider.xml
+++ b/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/hook/bad-script-provider.xml
@@ -27,4 +27,10 @@
     	<property name="Working Directory"></property>
     </eventHookProvider>
 
+    <extensionBundlePersistenceProvider>
+        <class>org.apache.nifi.registry.provider.MockExtensionBundlePersistenceProvider</class>
+        <property name="Extension Property 1">extension foo</property>
+        <property name="Extension Property 2">extension bar</property>
+    </extensionBundlePersistenceProvider>
+
 </providers>
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-class-not-found.xml b/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-class-not-found.xml
index 9adba54..7031ca9 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-class-not-found.xml
+++ b/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-class-not-found.xml
@@ -21,4 +21,10 @@
         <property name="Flow Property 2">bar</property>
     </flowPersistenceProvider>
 
+    <extensionBundlePersistenceProvider>
+        <class>org.apache.nifi.registry.provider.MockExtensionBundlePersistenceProvider</class>
+        <property name="Extension Property 1">extension foo</property>
+        <property name="Extension Property 2">extension bar</property>
+    </extensionBundlePersistenceProvider>
+
 </providers>
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-good.xml b/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-good.xml
index 32414e5..fc963f1 100644
--- a/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-good.xml
+++ b/nifi-registry-core/nifi-registry-framework/src/test/resources/provider/providers-good.xml
@@ -21,4 +21,10 @@
         <property name="Flow Property 2">flow bar</property>
     </flowPersistenceProvider>
 
+    <extensionBundlePersistenceProvider>
+        <class>org.apache.nifi.registry.provider.MockExtensionBundlePersistenceProvider</class>
+        <property name="Extension Property 1">extension foo</property>
+        <property name="Extension Property 2">extension bar</property>
+    </extensionBundlePersistenceProvider>
+
 </providers>
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java b/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
index 1dcb0f7..89a8e60 100644
--- a/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
+++ b/nifi-registry-core/nifi-registry-properties/src/main/java/org/apache/nifi/registry/properties/NiFiRegistryProperties.java
@@ -16,16 +16,16 @@
  */
 package org.apache.nifi.registry.properties;
 
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.io.File;
 import java.util.Enumeration;
 import java.util.HashSet;
 import java.util.Properties;
 import java.util.Set;
 
-import org.apache.commons.lang3.StringUtils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 public class NiFiRegistryProperties extends Properties {
 
     private static final Logger logger = LoggerFactory.getLogger(NiFiRegistryProperties.class);
@@ -58,6 +58,8 @@
 
     public static final String PROVIDERS_CONFIGURATION_FILE = "nifi.registry.providers.configuration.file";
 
+    public static final String EXTENSIONS_WORKING_DIR = "nifi.registry.extensions.working.directory";
+
     // Original DB properties
     public static final String DATABASE_DIRECTORY = "nifi.registry.db.directory";
     public static final String DATABASE_URL_APPEND = "nifi.registry.db.url.append";
@@ -86,6 +88,7 @@
     public static final String DEFAULT_SECURITY_AUTHORIZERS_CONFIGURATION_FILE = "./conf/authorizers.xml";
     public static final String DEFAULT_SECURITY_IDENTITY_PROVIDER_CONFIGURATION_FILE = "./conf/identity-providers.xml";
     public static final String DEFAULT_AUTHENTICATION_EXPIRATION = "12 hours";
+    public static final String DEFAULT_EXTENSIONS_WORKING_DIR = "./work/extensions";
 
     public int getWebThreads() {
         int webThreads = 200;
@@ -158,6 +161,10 @@
         return new File(getProperty(WEB_WORKING_DIR, DEFAULT_WEB_WORKING_DIR));
     }
 
+    public File getExtensionsWorkingDirectory() {
+        return  new File(getProperty(EXTENSIONS_WORKING_DIR, DEFAULT_EXTENSIONS_WORKING_DIR));
+    }
+
     public File getProvidersConfigurationFile() {
         return getPropertyAsFile(PROVIDERS_CONFIGURATION_FILE, DEFAULT_PROVIDERS_CONFIGURATION_FILE);
     }
diff --git a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleContext.java b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleContext.java
new file mode 100644
index 0000000..2681849
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundleContext.java
@@ -0,0 +1,79 @@
+/*
+ * 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.registry.extension;
+
+/**
+ * The context that will be passed to the {@link ExtensionBundlePersistenceProvider} when saving a new version of an extension bundle.
+ */
+public interface ExtensionBundleContext {
+
+    enum BundleType {
+        NIFI_NAR,
+        MINIFI_CPP;
+    }
+
+    /**
+     * @return the id of the bucket the bundle belongs to
+     */
+    String getBucketId();
+
+    /**
+     * @return the name of the bucket the bundle belongs to
+     */
+    String getBucketName();
+
+    /**
+     * @return the type of the bundle
+     */
+    BundleType getBundleType();
+
+    /**
+     * @return the NiFi Registry id of the bundle
+     */
+    String getBundleId();
+
+    /**
+     * @return the group id of the bundle
+     */
+    String getBundleGroupId();
+
+    /**
+     * @return the artifact id of the bundle
+     */
+    String getBundleArtifactId();
+
+    /**
+     * @return the version of the bundle
+     */
+    String getBundleVersion();
+
+    /**
+     * @return the comments for the version of the bundle
+     */
+    String getDescription();
+
+    /**
+     * @return the timestamp the bundle was created
+     */
+    long getTimestamp();
+
+    /**
+     * @return the user that created the bundle
+     */
+    String getAuthor();
+
+}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceException.java
similarity index 64%
copy from nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java
copy to nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceException.java
index ec356fd..4722604 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java
+++ b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceException.java
@@ -14,17 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.registry.web.link.builder;
-
-import javax.ws.rs.core.Link;
+package org.apache.nifi.registry.extension;
 
 /**
- * Creates a Link for a given type.
- *
- * @param <T> the type to create a link for
+ * An Exception for errors encountered when a ExtensionBundlePersistenceProvider saves or retrieves a bundle.
  */
-public interface LinkBuilder<T> {
+public class ExtensionBundlePersistenceException extends RuntimeException {
 
-    Link createLink(T t);
+    public ExtensionBundlePersistenceException(String message) {
+        super(message);
+    }
+
+    public ExtensionBundlePersistenceException(String message, Throwable cause) {
+        super(message, cause);
+    }
 
 }
diff --git a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceProvider.java b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceProvider.java
new file mode 100644
index 0000000..9ef2646
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/extension/ExtensionBundlePersistenceProvider.java
@@ -0,0 +1,67 @@
+/*
+ * 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.registry.extension;
+
+import org.apache.nifi.registry.provider.Provider;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Responsible for storing and retrieving the binary content of a version of an extension bundle.
+ */
+public interface ExtensionBundlePersistenceProvider extends Provider {
+
+    /**
+     * Persists the binary content of a version of an extension bundle.
+     *
+     * @param context the context about the bundle version being persisted
+     * @param contentStream the stream of binary content to persist
+     * @throws ExtensionBundlePersistenceException if an error occurs storing the content
+     */
+    void saveBundleVersion(ExtensionBundleContext context, InputStream contentStream) throws ExtensionBundlePersistenceException;
+
+    /**
+     * Writes the binary content of the bundle specified by the bucket-group-artifact-version to the provided OutputStream.
+     *
+     * @param context the context about the bundle version being retrieved
+     * @param outputStream the output stream to write the contents to
+     * @throws ExtensionBundlePersistenceException if an error occurs retrieving the content
+     */
+    void getBundleVersion(ExtensionBundleContext context, OutputStream outputStream) throws ExtensionBundlePersistenceException;
+
+    /**
+     * Deletes the content of the bundle version specified by bucket-group-artifact-version.
+     *
+     * @param context the context about the bundle version being deleted
+     * @throws ExtensionBundlePersistenceException if an error occurs deleting the content
+     */
+    void deleteBundleVersion(ExtensionBundleContext context) throws ExtensionBundlePersistenceException;
+
+    /**
+     * Deletes the content for all versions of the bundle specified by bucket-group-artifact.
+     *
+     * @param bucketId the id of the bucket where the bundle is located
+     * @param bucketName the bucket name where the bundle is located
+     * @param groupId the group id of the bundle
+     * @param artifactId the artifact id of the bundle
+     * @throws ExtensionBundlePersistenceException if an error occurs deleting the content
+     */
+    void deleteAllBundleVersions(String bucketId, String bucketName, String groupId, String artifactId)
+            throws ExtensionBundlePersistenceException;
+
+}
diff --git a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventFieldName.java b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventFieldName.java
index 35b0cfe..3f2fa6e 100644
--- a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventFieldName.java
+++ b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventFieldName.java
@@ -23,6 +23,7 @@
 
     BUCKET_ID,
     FLOW_ID,
+    EXTENSION_BUNDLE_ID,
     VERSION,
     USER,
     COMMENT;
diff --git a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventType.java b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventType.java
index c11a60c..0af35dc 100644
--- a/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventType.java
+++ b/nifi-registry-core/nifi-registry-provider-api/src/main/java/org/apache/nifi/registry/hook/EventType.java
@@ -41,6 +41,17 @@
             EventFieldName.VERSION,
             EventFieldName.USER,
             EventFieldName.COMMENT),
+    CREATE_EXTENSION_BUNDLE(
+            EventFieldName.BUCKET_ID,
+            EventFieldName.EXTENSION_BUNDLE_ID,
+            EventFieldName.USER
+    ),
+    CREATE_EXTENSION_BUNDLE_VERSION(
+            EventFieldName.BUCKET_ID,
+            EventFieldName.EXTENSION_BUNDLE_ID,
+            EventFieldName.VERSION,
+            EventFieldName.USER
+    ),
     REGISTRY_START(),
     UPDATE_BUCKET(
             EventFieldName.BUCKET_ID,
@@ -55,7 +66,19 @@
     DELETE_FLOW(
             EventFieldName.BUCKET_ID,
             EventFieldName.FLOW_ID,
-            EventFieldName.USER);
+            EventFieldName.USER),
+    DELETE_EXTENSION_BUNDLE(
+            EventFieldName.BUCKET_ID,
+            EventFieldName.EXTENSION_BUNDLE_ID,
+            EventFieldName.USER
+    ),
+    DELETE_EXTENSION_BUNDLE_VERSION(
+            EventFieldName.BUCKET_ID,
+            EventFieldName.EXTENSION_BUNDLE_ID,
+            EventFieldName.VERSION,
+            EventFieldName.USER
+    )
+    ;
 
 
     private List<EventFieldName> fieldNames;
diff --git a/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties b/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
index fb77a07..ce4377f 100644
--- a/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
+++ b/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/nifi-registry.properties
@@ -42,6 +42,9 @@
 # providers properties #
 nifi.registry.providers.configuration.file=${nifi.registry.providers.configuration.file}
 
+# extensions working dir #
+nifi.registry.extensions.working.directory=${nifi.registry.extensions.working.directory}
+
 # legacy database properties, used to migrate data from original DB to new DB below
 # NOTE: Users upgrading from 0.1.0 should leave these populated, but new installs after 0.1.0 should leave these empty
 nifi.registry.db.directory=${nifi.registry.db.directory}
diff --git a/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/providers.xml b/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/providers.xml
index faf8d4f..306c073 100644
--- a/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/providers.xml
+++ b/nifi-registry-core/nifi-registry-resources/src/main/resources/conf/providers.xml
@@ -51,4 +51,9 @@
     </eventHookProvider>
     -->
 
+    <extensionBundlePersistenceProvider>
+        <class>org.apache.nifi.registry.provider.extension.FileSystemExtensionBundlePersistenceProvider</class>
+        <property name="Extension Bundle Storage Directory">./extension_bundles</property>
+    </extensionBundlePersistenceProvider>
+
 </providers>
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-web-api/pom.xml b/nifi-registry-core/nifi-registry-web-api/pom.xml
index e0af632..6e9fa10 100644
--- a/nifi-registry-core/nifi-registry-web-api/pom.xml
+++ b/nifi-registry-core/nifi-registry-web-api/pom.xml
@@ -231,6 +231,13 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-jersey</artifactId>
             <version>${spring.boot.version}</version>
+            <exclusions>
+                <!-- spring-boot-starter-jersey brings in a Spring 4.x version of spring-aop which causes problems -->
+                <exclusion>
+                    <groupId>org.springframework</groupId>
+                    <artifactId>spring-aop</artifactId>
+                </exclusion>
+            </exclusions>
         </dependency>
         <!-- Exclude micrometer-core because it creates a class cast issue with logback, revisit later -->
         <dependency>
@@ -308,6 +315,10 @@
             <artifactId>swagger-annotations</artifactId>
         </dependency>
         <dependency>
+            <groupId>org.glassfish.jersey.media</groupId>
+            <artifactId>jersey-media-multipart</artifactId>
+        </dependency>
+        <dependency>
             <groupId>io.jsonwebtoken</groupId>
             <artifactId>jjwt</artifactId>
             <version>0.7.0</version>
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/NiFiRegistryApiApplication.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/NiFiRegistryApiApplication.java
index d06555d..2ffefbb 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/NiFiRegistryApiApplication.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/NiFiRegistryApiApplication.java
@@ -54,6 +54,13 @@
     protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
         final Properties defaultProperties = new Properties();
 
+        // Spring Boot 2.1.0 disabled bean overriding so this re-enables it
+        defaultProperties.setProperty("spring.main.allow-bean-definition-overriding", "true");
+
+        // Disable unnecessary Spring MVC filters that cause problems with Jersey
+        defaultProperties.setProperty("spring.mvc.hiddenmethod.filter.enabled", "false");
+        defaultProperties.setProperty("spring.mvc.formcontent.filter.enabled", "false");
+
         // Enable Actuator Endpoints
         defaultProperties.setProperty("management.endpoints.web.expose", "*");
 
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/NiFiRegistryResourceConfig.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/NiFiRegistryResourceConfig.java
index a5ab5ef..7394e8c 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/NiFiRegistryResourceConfig.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/NiFiRegistryResourceConfig.java
@@ -18,12 +18,16 @@
 
 import org.apache.nifi.registry.web.api.AccessPolicyResource;
 import org.apache.nifi.registry.web.api.AccessResource;
+import org.apache.nifi.registry.web.api.BucketExtensionResource;
 import org.apache.nifi.registry.web.api.BucketFlowResource;
 import org.apache.nifi.registry.web.api.BucketResource;
 import org.apache.nifi.registry.web.api.ConfigResource;
+import org.apache.nifi.registry.web.api.ExtensionRepositoryResource;
+import org.apache.nifi.registry.web.api.ExtensionResource;
 import org.apache.nifi.registry.web.api.FlowResource;
 import org.apache.nifi.registry.web.api.ItemResource;
 import org.apache.nifi.registry.web.api.TenantResource;
+import org.glassfish.jersey.media.multipart.MultiPartFeature;
 import org.glassfish.jersey.server.ResourceConfig;
 import org.glassfish.jersey.server.ServerProperties;
 import org.glassfish.jersey.server.filter.HttpMethodOverrideFilter;
@@ -57,11 +61,17 @@
         register(AccessResource.class);
         register(BucketResource.class);
         register(BucketFlowResource.class);
+        register(BucketExtensionResource.class);
+        register(ExtensionResource.class);
+        register(ExtensionRepositoryResource.class);
         register(FlowResource.class);
         register(ItemResource.class);
         register(TenantResource.class);
         register(ConfigResource.class);
 
+        // register multipart feature
+        register(MultiPartFeature.class);
+
         // include bean validation errors in response
         property(ServerProperties.BV_SEND_ERROR_IN_RESPONSE, true);
 
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ApplicationResource.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ApplicationResource.java
index 776a693..22c5211 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ApplicationResource.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ApplicationResource.java
@@ -71,9 +71,8 @@
         }
     }
 
-    protected String generateResourceUri(final String... path) {
+    protected URI getBaseUri() {
         final UriBuilder uriBuilder = uriInfo.getBaseUriBuilder();
-        uriBuilder.segment(path);
         URI uri = uriBuilder.build();
         try {
 
@@ -126,7 +125,13 @@
         } catch (final URISyntaxException use) {
             throw new UriBuilderException(use);
         }
-        return uri.toString();
+        return uri;
+    }
+
+    protected String generateResourceUri(final String... path) {
+        final URI baseUri = getBaseUri();
+        final URI fullUri = UriBuilder.fromUri(baseUri).segment(path).build();
+        return fullUri.toString();
     }
 
     /**
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketExtensionResource.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketExtensionResource.java
new file mode 100644
index 0000000..4477acd
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketExtensionResource.java
@@ -0,0 +1,179 @@
+/*
+ * 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.registry.web.api;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import io.swagger.annotations.ApiResponse;
+import io.swagger.annotations.ApiResponses;
+import io.swagger.annotations.Authorization;
+import io.swagger.annotations.Extension;
+import io.swagger.annotations.ExtensionProperty;
+import org.apache.nifi.registry.event.EventFactory;
+import org.apache.nifi.registry.event.EventService;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+import org.apache.nifi.registry.extension.ExtensionBundleType;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.security.authorization.RequestAction;
+import org.apache.nifi.registry.service.AuthorizationService;
+import org.apache.nifi.registry.service.RegistryService;
+import org.apache.nifi.registry.web.link.LinkService;
+import org.apache.nifi.registry.web.security.PermissionsService;
+import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
+import org.glassfish.jersey.media.multipart.FormDataParam;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+@Component
+@Path("/buckets/{bucketId}/extensions")
+@Api(
+        value = "bucket_extensions",
+        description = "Create extension bundles scoped to an existing bucket in the registry.",
+        authorizations = { @Authorization("Authorization") }
+)
+public class BucketExtensionResource extends AuthorizableApplicationResource {
+
+    private static final Logger LOGGER = LoggerFactory.getLogger(BucketExtensionResource.class);
+
+    private final RegistryService registryService;
+    private final LinkService linkService;
+    private final PermissionsService permissionsService;
+
+    @Autowired
+    public BucketExtensionResource(
+            final RegistryService registryService,
+            final LinkService linkService,
+            final PermissionsService permissionsService,
+            final AuthorizationService authorizationService,
+            final EventService eventService) {
+        super(authorizationService, eventService);
+        this.registryService = registryService;
+        this.linkService = linkService;
+        this.permissionsService =permissionsService;
+    }
+
+    @POST
+    @Path("bundles/{bundleType}")
+    @Consumes(MediaType.MULTIPART_FORM_DATA)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Creates a version of an extension bundle by uploading a binary artifact",
+            notes = "If an extension bundle already exists in the given bucket with the same group id and artifact id " +
+                    "as that of the bundle being uploaded, then it will be added as a new version to the existing bundle. " +
+                    "If an extension bundle does not already exist in the given bucket with the same group id and artifact id, " +
+                    "then a new extension bundle will be created and this version will be added to the new bundle. " +
+                    "Client's may optionally supply a SHA-256 in hex format through the multi-part form field 'sha256'. " +
+                    "If supplied, then this value will be compared against the SHA-256 computed by the server, and the bundle " +
+                    "will be rejected if the values do not match. If not supplied, the bundle will be accepted, but will be marked " +
+                    "to indicate that the client did not supply a SHA-256 during creation.",
+            response = ExtensionBundleVersion.class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "write"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response createExtensionBundleVersion(
+            @PathParam("bucketId")
+            @ApiParam("The bucket identifier")
+                final String bucketId,
+            @PathParam("bundleType")
+            @ApiParam("The type of the bundle")
+                final String bundleType,
+            @FormDataParam("file")
+                final InputStream fileInputStream,
+            @FormDataParam("file")
+                final FormDataContentDisposition fileMetaData,
+            @FormDataParam("sha256")
+                final String clientSha256) throws IOException {
+
+        authorizeBucketAccess(RequestAction.WRITE, bucketId);
+
+        final ExtensionBundleType extensionBundleType = ExtensionBundleType.fromString(bundleType);
+        LOGGER.debug("Creating extension bundle version for bundle type {}", new Object[]{extensionBundleType});
+
+        final ExtensionBundleVersion createdBundleVersion = registryService.createExtensionBundleVersion(
+                bucketId, extensionBundleType, fileInputStream, clientSha256);
+
+        publish(EventFactory.extensionBundleCreated(createdBundleVersion.getExtensionBundle()));
+        publish(EventFactory.extensionBundleVersionCreated(createdBundleVersion));
+
+        linkService.populateLinks(createdBundleVersion.getVersionMetadata());
+        linkService.populateLinks(createdBundleVersion.getExtensionBundle());
+        linkService.populateLinks(createdBundleVersion.getBucket());
+
+        permissionsService.populateItemPermissions(createdBundleVersion.getExtensionBundle());
+
+        return Response.status(Response.Status.OK).entity(createdBundleVersion).build();
+    }
+
+    @GET
+    @Path("bundles")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets all extension bundles in the given bucket",
+            response = ExtensionBundle.class,
+            responseContainer = "List",
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "read"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response getExtensionBundles(
+            @PathParam("bucketId")
+            @ApiParam("The bucket identifier")
+                final String bucketId
+    ) {
+        authorizeBucketAccess(RequestAction.READ, bucketId);
+
+        final List<ExtensionBundle> bundles = registryService.getExtensionBundlesByBucket(bucketId);
+        permissionsService.populateItemPermissions(bundles);
+        linkService.populateLinks(bundles);
+
+        return Response.status(Response.Status.OK).entity(bundles).build();
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketFlowResource.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketFlowResource.java
index 942a3d4..ccf41d9 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketFlowResource.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketFlowResource.java
@@ -119,7 +119,7 @@
         publish(EventFactory.flowCreated(createdFlow));
 
         permissionsService.populateItemPermissions(createdFlow);
-        linkService.populateFlowLinks(createdFlow);
+        linkService.populateLinks(createdFlow);
         return Response.status(Response.Status.OK).entity(createdFlow).build();
     }
 
@@ -151,7 +151,7 @@
 
         final List<VersionedFlow> flows = registryService.getFlows(bucketId);
         permissionsService.populateItemPermissions(flows);
-        linkService.populateFlowLinks(flows);
+        linkService.populateLinks(flows);
 
         return Response.status(Response.Status.OK).entity(flows).build();
     }
@@ -187,7 +187,7 @@
 
         final VersionedFlow flow = registryService.getFlow(bucketId, flowId);
         permissionsService.populateItemPermissions(flow);
-        linkService.populateFlowLinks(flow);
+        linkService.populateLinks(flow);
 
         return Response.status(Response.Status.OK).entity(flow).build();
     }
@@ -230,7 +230,7 @@
         final VersionedFlow updatedFlow = registryService.updateFlow(flow);
         publish(EventFactory.flowUpdated(updatedFlow));
         permissionsService.populateItemPermissions(updatedFlow);
-        linkService.populateFlowLinks(updatedFlow);
+        linkService.populateLinks(updatedFlow);
 
         return Response.status(Response.Status.OK).entity(updatedFlow).build();
     }
@@ -311,11 +311,11 @@
         publish(EventFactory.flowVersionCreated(createdSnapshot));
 
         if (createdSnapshot.getSnapshotMetadata() != null) {
-            linkService.populateSnapshotLinks(createdSnapshot.getSnapshotMetadata());
+            linkService.populateLinks(createdSnapshot.getSnapshotMetadata());
         }
         if (createdSnapshot.getBucket() != null) {
             permissionsService.populateBucketPermissions(createdSnapshot.getBucket());
-            linkService.populateBucketLinks(createdSnapshot.getBucket());
+            linkService.populateLinks(createdSnapshot.getBucket());
         }
         return Response.status(Response.Status.OK).entity(createdSnapshot).build();
     }
@@ -351,7 +351,7 @@
 
         final SortedSet<VersionedFlowSnapshotMetadata> snapshots = registryService.getFlowSnapshots(bucketId, flowId);
         if (snapshots != null ) {
-            linkService.populateSnapshotLinks(snapshots);
+            linkService.populateLinks(snapshots);
         }
 
         return Response.status(Response.Status.OK).entity(snapshots).build();
@@ -421,7 +421,7 @@
         authorizeBucketAccess(RequestAction.READ, bucketId);
 
         final VersionedFlowSnapshotMetadata latest = registryService.getLatestFlowSnapshotMetadata(bucketId, flowId);
-        linkService.populateSnapshotLinks(latest);
+        linkService.populateLinks(latest);
 
         return Response.status(Response.Status.OK).entity(latest).build();
     }
@@ -502,16 +502,16 @@
 
     private void populateLinksAndPermissions(VersionedFlowSnapshot snapshot) {
         if (snapshot.getSnapshotMetadata() != null) {
-            linkService.populateSnapshotLinks(snapshot.getSnapshotMetadata());
+            linkService.populateLinks(snapshot.getSnapshotMetadata());
         }
 
         if (snapshot.getFlow() != null) {
-            linkService.populateFlowLinks(snapshot.getFlow());
+            linkService.populateLinks(snapshot.getFlow());
         }
 
         if (snapshot.getBucket() != null) {
             permissionsService.populateBucketPermissions(snapshot.getBucket());
-            linkService.populateBucketLinks(snapshot.getBucket());
+            linkService.populateLinks(snapshot.getBucket());
         }
 
     }
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketResource.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketResource.java
index e905973..e7c0df4 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketResource.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/BucketResource.java
@@ -118,7 +118,7 @@
         publish(EventFactory.bucketCreated(createdBucket));
 
         permissionsService.populateBucketPermissions(createdBucket);
-        linkService.populateBucketLinks(createdBucket);
+        linkService.populateLinks(createdBucket);
         return Response.status(Response.Status.OK).entity(createdBucket).build();
     }
 
@@ -152,7 +152,7 @@
 
         final List<Bucket> buckets = registryService.getBuckets(authorizedBucketIds);
         permissionsService.populateBucketPermissions(buckets);
-        linkService.populateBucketLinks(buckets);
+        linkService.populateLinks(buckets);
 
         return Response.status(Response.Status.OK).entity(buckets).build();
     }
@@ -182,7 +182,7 @@
         authorizeBucketAccess(RequestAction.READ, bucketId);
         final Bucket bucket = registryService.getBucket(bucketId);
         permissionsService.populateBucketPermissions(bucket);
-        linkService.populateBucketLinks(bucket);
+        linkService.populateLinks(bucket);
 
         return Response.status(Response.Status.OK).entity(bucket).build();
     }
@@ -233,7 +233,7 @@
         publish(EventFactory.bucketUpdated(updatedBucket));
 
         permissionsService.populateBucketPermissions(updatedBucket);
-        linkService.populateBucketLinks(updatedBucket);
+        linkService.populateLinks(updatedBucket);
         return Response.status(Response.Status.OK).entity(updatedBucket).build();
     }
 
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionRepositoryResource.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionRepositoryResource.java
new file mode 100644
index 0000000..46be907
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionRepositoryResource.java
@@ -0,0 +1,374 @@
+/*
+ * 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.registry.web.api;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import io.swagger.annotations.ApiResponse;
+import io.swagger.annotations.ApiResponses;
+import io.swagger.annotations.Authorization;
+import io.swagger.annotations.Extension;
+import io.swagger.annotations.ExtensionProperty;
+import org.apache.nifi.registry.bucket.Bucket;
+import org.apache.nifi.registry.bucket.BucketItem;
+import org.apache.nifi.registry.event.EventService;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoArtifact;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoBucket;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoGroup;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersion;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersionSummary;
+import org.apache.nifi.registry.security.authorization.RequestAction;
+import org.apache.nifi.registry.service.AuthorizationService;
+import org.apache.nifi.registry.service.RegistryService;
+import org.apache.nifi.registry.service.extension.ExtensionBundleVersionCoordinate;
+import org.apache.nifi.registry.web.link.LinkService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Link;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.StreamingOutput;
+import java.util.ArrayList;
+import java.util.Set;
+import java.util.SortedSet;
+
+@Component
+@Path("/extensions/repo")
+@Api(
+        value = "extension_repository",
+        description = "Interact with extension bundles via the hierarchy of bucket/group/artifact/version.",
+        authorizations = { @Authorization("Authorization") }
+)
+public class ExtensionRepositoryResource extends AuthorizableApplicationResource {
+
+    public static final String CONTENT_DISPOSITION_HEADER = "content-disposition";
+    private final RegistryService registryService;
+    private final LinkService linkService;
+
+    @Autowired
+    public ExtensionRepositoryResource(
+            final RegistryService registryService,
+            final LinkService linkService,
+            final AuthorizationService authorizationService,
+            final EventService eventService) {
+        super(authorizationService, eventService);
+        this.registryService = registryService;
+        this.linkService = linkService;
+    }
+
+    @GET
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the names of the buckets the current user is authorized for in order to browse the repo by bucket",
+            response = ExtensionRepoBucket.class,
+            responseContainer = "List"
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response getExtensionRepoBuckets() {
+
+        final Set<String> authorizedBucketIds = getAuthorizedBucketIds(RequestAction.READ);
+        if (authorizedBucketIds == null || authorizedBucketIds.isEmpty()) {
+            // not authorized for any bucket, return empty list of items
+            return Response.status(Response.Status.OK).entity(new ArrayList<BucketItem>()).build();
+        }
+
+        final SortedSet<ExtensionRepoBucket> repoBuckets = registryService.getExtensionRepoBuckets(authorizedBucketIds);
+        linkService.populateFullLinks(repoBuckets, getBaseUri());
+        return Response.status(Response.Status.OK).entity(repoBuckets).build();
+    }
+
+    @GET
+    @Path("{bucketName}")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the groups in the extension repository in the bucket with the given name",
+            response = ExtensionRepoGroup.class,
+            responseContainer = "List",
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "read"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response getExtensionRepoGroups(
+            @PathParam("bucketName")
+            @ApiParam("The bucket name")
+                final String bucketName
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final SortedSet<ExtensionRepoGroup> repoGroups = registryService.getExtensionRepoGroups(bucket);
+        linkService.populateFullLinks(repoGroups, getBaseUri());
+        return Response.status(Response.Status.OK).entity(repoGroups).build();
+    }
+
+    @GET
+    @Path("{bucketName}/{groupId}")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the artifacts in the extension repository with the given group in the bucket with the given name",
+            response = ExtensionRepoArtifact.class,
+            responseContainer = "List",
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "read"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response getExtensionRepoArtifacts(
+            @PathParam("bucketName")
+            @ApiParam("The bucket name")
+                final String bucketName,
+            @PathParam("groupId")
+            @ApiParam("The group id")
+                final String groupId
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final SortedSet<ExtensionRepoArtifact> repoArtifacts = registryService.getExtensionRepoArtifacts(bucket, groupId);
+        linkService.populateFullLinks(repoArtifacts, getBaseUri());
+        return Response.status(Response.Status.OK).entity(repoArtifacts).build();
+    }
+
+    @GET
+    @Path("{bucketName}/{groupId}/{artifactId}")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the versions of the artifact in the extension repository specified by the given bucket, group, artifact, and version",
+            response = ExtensionRepoVersionSummary.class,
+            responseContainer = "List",
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "read"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response getExtensionBundleVersions(
+            @PathParam("bucketName")
+            @ApiParam("The bucket name")
+                final String bucketName,
+            @PathParam("groupId")
+            @ApiParam("The group identifier")
+                final String groupId,
+            @PathParam("artifactId")
+            @ApiParam("The artifact identifier")
+                final String artifactId
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final SortedSet<ExtensionRepoVersionSummary> repoVersions = registryService.getExtensionRepoVersions(bucket, groupId, artifactId);
+        linkService.populateFullLinks(repoVersions, getBaseUri());
+        return Response.status(Response.Status.OK).entity(repoVersions).build();
+    }
+
+    @GET
+    @Path("{bucketName}/{groupId}/{artifactId}/{version}")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the information about the version specified by the given bucket, group, artifact, and version",
+            response = ExtensionRepoVersion.class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "read"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response getExtensionBundleVersion(
+            @PathParam("bucketName")
+            @ApiParam("The bucket name")
+                final String bucketName,
+            @PathParam("groupId")
+            @ApiParam("The group identifier")
+                final String groupId,
+            @PathParam("artifactId")
+            @ApiParam("The artifact identifier")
+                final String artifactId,
+            @PathParam("version")
+            @ApiParam("The version")
+                final String version
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final ExtensionBundleVersionCoordinate versionCoordinate = new ExtensionBundleVersionCoordinate(
+                bucket.getIdentifier(), groupId, artifactId, version);
+
+        final ExtensionBundleVersion bundleVersion = registryService.getExtensionBundleVersion(versionCoordinate);
+
+        final String downloadUri = generateResourceUri(
+                "extensions", "repo",
+                bundleVersion.getBucket().getName(),
+                bundleVersion.getExtensionBundle().getGroupId(),
+                bundleVersion.getExtensionBundle().getArtifactId(),
+                bundleVersion.getVersionMetadata().getVersion(),
+                "content");
+
+        final String sha256Uri = generateResourceUri(
+                "extensions", "repo",
+                bundleVersion.getBucket().getName(),
+                bundleVersion.getExtensionBundle().getGroupId(),
+                bundleVersion.getExtensionBundle().getArtifactId(),
+                bundleVersion.getVersionMetadata().getVersion(),
+                "sha256");
+
+        final ExtensionRepoVersion repoVersion = new ExtensionRepoVersion();
+        repoVersion.setDownloadLink(Link.fromUri(downloadUri).rel("content").build());
+        repoVersion.setSha256Link(Link.fromUri(sha256Uri).rel("sha256").build());
+        repoVersion.setSha256Supplied(bundleVersion.getVersionMetadata().getSha256Supplied());
+
+        return Response.ok(repoVersion).build();
+    }
+
+    @GET
+    @Path("{bucketName}/{groupId}/{artifactId}/{version}/content")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_OCTET_STREAM)
+    @ApiOperation(
+            value = "Gets the binary content of the extension bundle specified by the given bucket, group, artifact, and version",
+            response = byte[].class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "read"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response getExtensionBundleVersionContent(
+            @PathParam("bucketName")
+            @ApiParam("The bucket name")
+                final String bucketName,
+            @PathParam("groupId")
+            @ApiParam("The group identifier")
+                final String groupId,
+            @PathParam("artifactId")
+            @ApiParam("The artifact identifier")
+                final String artifactId,
+            @PathParam("version")
+            @ApiParam("The version")
+                final String version
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final ExtensionBundleVersionCoordinate versionCoordinate = new ExtensionBundleVersionCoordinate(
+                bucket.getIdentifier(), groupId, artifactId, version);
+
+        final ExtensionBundleVersion bundleVersion = registryService.getExtensionBundleVersion(versionCoordinate);
+        final StreamingOutput streamingOutput = (output) -> registryService.writeExtensionBundleVersionContent(bundleVersion, output);
+
+        return Response.ok(streamingOutput)
+                .header(CONTENT_DISPOSITION_HEADER,"attachment; filename = " + bundleVersion.getFilename())
+                .build();
+    }
+
+    @GET
+    @Path("{bucketName}/{groupId}/{artifactId}/{version}/sha256")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.TEXT_PLAIN)
+    @ApiOperation(
+            value = "Gets the hex representation of the SHA-256 digest for the binary content of the version of the extension bundle",
+            response = String.class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "read"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response getExtensionBundleVersionSha256(
+            @PathParam("bucketName")
+            @ApiParam("The bucket name")
+                final String bucketName,
+            @PathParam("groupId")
+            @ApiParam("The group identifier")
+                final String groupId,
+            @PathParam("artifactId")
+            @ApiParam("The artifact identifier")
+                final String artifactId,
+            @PathParam("version")
+            @ApiParam("The version")
+                final String version
+    ) {
+        final Bucket bucket = registryService.getBucketByName(bucketName);
+        authorizeBucketAccess(RequestAction.READ, bucket.getIdentifier());
+
+        final ExtensionBundleVersionCoordinate versionCoordinate = new ExtensionBundleVersionCoordinate(
+                bucket.getIdentifier(), groupId, artifactId, version);
+
+        final ExtensionBundleVersion bundleVersion = registryService.getExtensionBundleVersion(versionCoordinate);
+        final String sha256Hex = bundleVersion.getVersionMetadata().getSha256();
+
+        return Response.ok(sha256Hex, MediaType.TEXT_PLAIN).build();
+    }
+
+
+}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionResource.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionResource.java
new file mode 100644
index 0000000..62c4c08
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ExtensionResource.java
@@ -0,0 +1,366 @@
+/*
+ * 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.registry.web.api;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import io.swagger.annotations.ApiResponse;
+import io.swagger.annotations.ApiResponses;
+import io.swagger.annotations.Authorization;
+import io.swagger.annotations.Extension;
+import io.swagger.annotations.ExtensionProperty;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.nifi.registry.bucket.BucketItem;
+import org.apache.nifi.registry.event.EventFactory;
+import org.apache.nifi.registry.event.EventService;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionMetadata;
+import org.apache.nifi.registry.security.authorization.RequestAction;
+import org.apache.nifi.registry.service.AuthorizationService;
+import org.apache.nifi.registry.service.RegistryService;
+import org.apache.nifi.registry.service.extension.ExtensionBundleVersionCoordinate;
+import org.apache.nifi.registry.web.link.LinkService;
+import org.apache.nifi.registry.web.security.PermissionsService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.StreamingOutput;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.SortedSet;
+
+@Component
+@Path("/extensions")
+@Api(
+        value = "extensions",
+        description = "Gets metadata about extension bundles and extensions.",
+        authorizations = { @Authorization("Authorization") }
+)
+public class ExtensionResource extends AuthorizableApplicationResource {
+
+    public static final String CONTENT_DISPOSITION_HEADER = "content-disposition";
+    private final RegistryService registryService;
+    private final LinkService linkService;
+    private final PermissionsService permissionsService;
+
+    @Autowired
+    public ExtensionResource(final RegistryService registryService,
+                             final LinkService linkService,
+                             final PermissionsService permissionsService,
+                             final AuthorizationService authorizationService,
+                             final EventService eventService) {
+        super(authorizationService, eventService);
+        this.registryService = registryService;
+        this.linkService = linkService;
+        this.permissionsService = permissionsService;
+    }
+
+    @GET
+    @Path("bundles")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Get extension bundles across all authorized buckets",
+            notes = "The returned items will include only items from buckets for which the user is authorized. " +
+                    "If the user is not authorized to any buckets, an empty list will be returned.",
+            response = ExtensionBundle.class,
+            responseContainer = "List"
+    )
+    @ApiResponses({ @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401) })
+    public Response getExtensionBundles() {
+
+        final Set<String> authorizedBucketIds = getAuthorizedBucketIds(RequestAction.READ);
+        if (authorizedBucketIds == null || authorizedBucketIds.isEmpty()) {
+            // not authorized for any bucket, return empty list of items
+            return Response.status(Response.Status.OK).entity(new ArrayList<BucketItem>()).build();
+        }
+
+        List<ExtensionBundle> bundles = registryService.getExtensionBundles(authorizedBucketIds);
+        if (bundles == null) {
+            bundles = Collections.emptyList();
+        }
+        permissionsService.populateItemPermissions(bundles);
+        linkService.populateLinks(bundles);
+
+        return Response.status(Response.Status.OK).entity(bundles).build();
+    }
+
+    @GET
+    @Path("bundles/{bundleId}")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the metadata about an extension bundle",
+            nickname = "globalGetExtensionBundle",
+            response = ExtensionBundle.class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "read"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response getExtensionBundle(
+            @PathParam("bundleId")
+            @ApiParam("The extension bundle identifier")
+                final String bundleId) {
+
+        final ExtensionBundle extensionBundle = getExtensionBundleWithBucketReadAuthorization(bundleId);
+
+        permissionsService.populateItemPermissions(extensionBundle);
+        linkService.populateLinks(extensionBundle);
+
+        return Response.status(Response.Status.OK).entity(extensionBundle).build();
+    }
+
+    @DELETE
+    @Path("bundles/{bundleId}")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Deletes the given extension bundle and all of it's versions",
+            nickname = "globalDeleteExtensionBundle",
+            response = ExtensionBundle.class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "write"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response deleteExtensionBundle(
+            @PathParam("bundleId")
+            @ApiParam("The extension bundle identifier")
+                final String bundleId) {
+
+        final ExtensionBundle extensionBundle = getExtensionBundleWithBucketReadAuthorization(bundleId);
+
+        final ExtensionBundle deletedExtensionBundle = registryService.deleteExtensionBundle(extensionBundle);
+        publish(EventFactory.extensionBundleDeleted(deletedExtensionBundle));
+
+        permissionsService.populateItemPermissions(deletedExtensionBundle);
+        linkService.populateLinks(deletedExtensionBundle);
+
+        return Response.status(Response.Status.OK).entity(deletedExtensionBundle).build();
+    }
+
+    @GET
+    @Path("bundles/{bundleId}/versions")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the metadata about the versions of an extension bundle",
+            nickname = "globalGetExtensionBundleVersions",
+            response = ExtensionBundleVersionMetadata.class,
+            responseContainer = "List",
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "read"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response getExtensionBundleVersions(
+            @PathParam("bundleId")
+            @ApiParam("The extension bundle identifier")
+                final String bundleId) {
+
+        final ExtensionBundle extensionBundle = getExtensionBundleWithBucketReadAuthorization(bundleId);
+
+        final SortedSet<ExtensionBundleVersionMetadata> bundleVersions = registryService.getExtensionBundleVersions(extensionBundle.getIdentifier());
+        linkService.populateLinks(bundleVersions);
+
+        return Response.status(Response.Status.OK).entity(bundleVersions).build();
+    }
+
+    @GET
+    @Path("bundles/{bundleId}/versions/{version}")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Gets the descriptor for the specified version of the extension bundle",
+            nickname = "globalGetExtensionBundleVersionDescriptor",
+            response = ExtensionBundleVersion.class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "read"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response getExtensionBundleVersion(
+            @PathParam("bundleId")
+            @ApiParam("The extension bundle identifier")
+                final String bundleId,
+            @PathParam("version")
+            @ApiParam("The version of the bundle")
+                final String version) {
+
+        final ExtensionBundle extensionBundle = getExtensionBundleWithBucketReadAuthorization(bundleId);
+
+        final ExtensionBundleVersionCoordinate versionCoordinate = new ExtensionBundleVersionCoordinate(
+                extensionBundle.getBucketIdentifier(),
+                extensionBundle.getGroupId(),
+                extensionBundle.getArtifactId(),
+                version);
+
+        final ExtensionBundleVersion bundleVersion = registryService.getExtensionBundleVersion(versionCoordinate);
+        linkService.populateLinks(bundleVersion);
+
+        return Response.ok(bundleVersion).build();
+    }
+
+    @GET
+    @Path("bundles/{bundleId}/versions/{version}/content")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_OCTET_STREAM)
+    @ApiOperation(
+            value = "Gets the binary content for the specified version of the extension bundle",
+            nickname = "globalGetExtensionBundleVersion",
+            response = byte[].class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "read"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response getExtensionBundleVersionContent(
+            @PathParam("bundleId")
+            @ApiParam("The extension bundle identifier")
+                final String bundleId,
+            @PathParam("version")
+            @ApiParam("The version of the bundle")
+                final String version) {
+
+        final ExtensionBundle extensionBundle = getExtensionBundleWithBucketReadAuthorization(bundleId);
+
+        final ExtensionBundleVersionCoordinate versionCoordinate = new ExtensionBundleVersionCoordinate(
+                extensionBundle.getBucketIdentifier(),
+                extensionBundle.getGroupId(),
+                extensionBundle.getArtifactId(),
+                version);
+
+        final ExtensionBundleVersion bundleVersion = registryService.getExtensionBundleVersion(versionCoordinate);
+        final StreamingOutput streamingOutput = (output) -> registryService.writeExtensionBundleVersionContent(bundleVersion, output);
+
+        return Response.ok(streamingOutput)
+                .header(CONTENT_DISPOSITION_HEADER,"attachment; filename = " + bundleVersion.getFilename())
+                .build();
+    }
+
+    @DELETE
+    @Path("bundles/{bundleId}/versions/{version}")
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @ApiOperation(
+            value = "Deletes the given extension bundle version",
+            nickname = "globalDeleteExtensionBundleVersion",
+            response = ExtensionBundleVersion.class,
+            extensions = {
+                    @Extension(name = "access-policy", properties = {
+                            @ExtensionProperty(name = "action", value = "write"),
+                            @ExtensionProperty(name = "resource", value = "/buckets/{bucketId}") })
+            }
+    )
+    @ApiResponses({
+            @ApiResponse(code = 400, message = HttpStatusMessages.MESSAGE_400),
+            @ApiResponse(code = 401, message = HttpStatusMessages.MESSAGE_401),
+            @ApiResponse(code = 403, message = HttpStatusMessages.MESSAGE_403),
+            @ApiResponse(code = 404, message = HttpStatusMessages.MESSAGE_404),
+            @ApiResponse(code = 409, message = HttpStatusMessages.MESSAGE_409) })
+    public Response deleteExtensionBundleVersion(
+            @PathParam("bundleId")
+            @ApiParam("The extension bundle identifier")
+                final String bundleId,
+            @PathParam("version")
+            @ApiParam("The version of the bundle")
+                final String version) {
+
+        final ExtensionBundle extensionBundle = getExtensionBundleWithBucketReadAuthorization(bundleId);
+
+        final ExtensionBundleVersionCoordinate versionCoordinate = new ExtensionBundleVersionCoordinate(
+                extensionBundle.getBucketIdentifier(),
+                extensionBundle.getGroupId(),
+                extensionBundle.getArtifactId(),
+                version);
+
+        final ExtensionBundleVersion bundleVersion = registryService.getExtensionBundleVersion(versionCoordinate);
+
+        final ExtensionBundleVersion deletedBundleVersion = registryService.deleteExtensionBundleVersion(bundleVersion);
+        publish(EventFactory.extensionBundleVersionDeleted(deletedBundleVersion));
+        linkService.populateLinks(deletedBundleVersion);
+
+        return Response.status(Response.Status.OK).entity(deletedBundleVersion).build();
+    }
+
+    /**
+     * Retrieves the extension bundle with the given id and ensures the current user has authorization to read the bucket it belongs to.
+     *
+     * @param bundleId the bundle id
+     * @return the extension bundle
+     */
+    private ExtensionBundle getExtensionBundleWithBucketReadAuthorization(final String bundleId) {
+        final ExtensionBundle extensionBundle = registryService.getExtensionBundle(bundleId);
+
+        // this should never happen, but if somehow the back-end didn't populate the bucket id let's make sure the flow isn't returned
+        if (StringUtils.isBlank(extensionBundle.getBucketIdentifier())) {
+            throw new IllegalStateException("Unable to authorize access because bucket identifier is null or blank");
+        }
+
+        authorizeBucketAccess(RequestAction.READ, extensionBundle.getBucketIdentifier());
+        return extensionBundle;
+    }
+
+}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/FlowResource.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/FlowResource.java
index afb8e11..9a1fe55 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/FlowResource.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/FlowResource.java
@@ -123,7 +123,7 @@
         authorizeBucketAccess(RequestAction.READ, flow.getBucketIdentifier());
 
         permissionsService.populateItemPermissions(flow);
-        linkService.populateFlowLinks(flow);
+        linkService.populateLinks(flow);
 
         return Response.status(Response.Status.OK).entity(flow).build();
     }
@@ -164,7 +164,7 @@
 
         final SortedSet<VersionedFlowSnapshotMetadata> snapshots = registryService.getFlowSnapshots(bucketId, flowId);
         if (snapshots != null ) {
-            linkService.populateSnapshotLinks(snapshots);
+            linkService.populateLinks(snapshots);
         }
 
         return Response.status(Response.Status.OK).entity(snapshots).build();
@@ -284,7 +284,7 @@
 
         authorizeBucketAccess(RequestAction.READ, bucketId);
 
-        linkService.populateSnapshotLinks(latestMetadata);
+        linkService.populateLinks(latestMetadata);
         return Response.status(Response.Status.OK).entity(latestMetadata).build();
     }
 
@@ -299,16 +299,16 @@
 
     private void populateLinksAndPermissions(VersionedFlowSnapshot snapshot) {
         if (snapshot.getSnapshotMetadata() != null) {
-            linkService.populateSnapshotLinks(snapshot.getSnapshotMetadata());
+            linkService.populateLinks(snapshot.getSnapshotMetadata());
         }
 
         if (snapshot.getFlow() != null) {
-            linkService.populateFlowLinks(snapshot.getFlow());
+            linkService.populateLinks(snapshot.getFlow());
         }
 
         if (snapshot.getBucket() != null) {
             permissionsService.populateBucketPermissions(snapshot.getBucket());
-            linkService.populateBucketLinks(snapshot.getBucket());
+            linkService.populateLinks(snapshot.getBucket());
         }
 
     }
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ItemResource.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ItemResource.java
index 02b63d2..b137569 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ItemResource.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/api/ItemResource.java
@@ -115,7 +115,7 @@
             items = Collections.emptyList();
         }
         permissionsService.populateItemPermissions(items);
-        linkService.populateItemLinks(items);
+        linkService.populateLinks(items);
 
         return Response.status(Response.Status.OK).entity(items).build();
     }
@@ -149,7 +149,7 @@
 
         final List<BucketItem> items = registryService.getBucketItems(bucketId);
         permissionsService.populateItemPermissions(items);
-        linkService.populateItemLinks(items);
+        linkService.populateLinks(items);
 
         return Response.status(Response.Status.OK).entity(items).build();
     }
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/LinkBuilder.java
similarity index 94%
rename from nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java
rename to nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/LinkBuilder.java
index ec356fd..1e2bc29 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/LinkBuilder.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/LinkBuilder.java
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.nifi.registry.web.link.builder;
+package org.apache.nifi.registry.web.link;
 
 import javax.ws.rs.core.Link;
 
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/LinkService.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/LinkService.java
index 19e2168..9b2e818 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/LinkService.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/LinkService.java
@@ -17,94 +17,239 @@
 package org.apache.nifi.registry.web.link;
 
 import org.apache.nifi.registry.bucket.Bucket;
-import org.apache.nifi.registry.bucket.BucketItem;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionMetadata;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoArtifact;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoBucket;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoGroup;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersionSummary;
 import org.apache.nifi.registry.flow.VersionedFlow;
 import org.apache.nifi.registry.flow.VersionedFlowSnapshotMetadata;
-import org.apache.nifi.registry.web.link.builder.BucketLinkBuilder;
-import org.apache.nifi.registry.web.link.builder.LinkBuilder;
-import org.apache.nifi.registry.web.link.builder.VersionedFlowLinkBuilder;
-import org.apache.nifi.registry.web.link.builder.VersionedFlowSnapshotLinkBuilder;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.apache.nifi.registry.link.LinkableEntity;
 import org.springframework.stereotype.Service;
 
 import javax.ws.rs.core.Link;
+import javax.ws.rs.core.UriBuilder;
+import java.net.URI;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
 
 @Service
 public class LinkService {
 
-    private static final Logger LOGGER = LoggerFactory.getLogger(LinkService.class);
+    private static final String BUCKET_PATH = "buckets/{id}";
 
-    private final LinkBuilder<Bucket> bucketLinkBuilder = new BucketLinkBuilder();
+    private static final String FLOW_PATH = "buckets/{bucketId}/flows/{flowId}";
+    private static final String FLOW_SNAPSHOT_PATH = "buckets/{bucketId}/flows/{flowId}/versions/{versionNumber}";
 
-    private final LinkBuilder<VersionedFlow> versionedFlowLinkBuilder = new VersionedFlowLinkBuilder();
+    private static final String EXTENSION_BUNDLE_PATH = "extensions/bundles/{bundleId}";
+    private static final String EXTENSION_BUNDLE_VERSION_PATH = "extensions/bundles/{bundleId}/versions/{version}";
+    private static final String EXTENSION_BUNDLE_VERSION_CONTENT_PATH = "extensions/bundles/{bundleId}/versions/{version}/content";
 
-    private final LinkBuilder<VersionedFlowSnapshotMetadata> snapshotMetadataLinkBuilder = new VersionedFlowSnapshotLinkBuilder();
+    private static final String EXTENSION_REPO_BUCKET_PATH = "extensions/repo/{bucketName}";
+    private static final String EXTENSION_REPO_GROUP_PATH = "extensions/repo/{bucketName}/{groupId}";
+    private static final String EXTENSION_REPO_ARTIFACT_PATH = "extensions/repo/{bucketName}/{groupId}/{artifactId}";
+    private static final String EXTENSION_REPO_VERSION_PATH = "extensions/repo/{bucketName}/{groupId}/{artifactId}/{version}";
 
-    // ---- Bucket Links
 
-    public void populateBucketLinks(final Iterable<Bucket> buckets) {
-        if (buckets == null) {
+    private static final LinkBuilder<Bucket> BUCKET_LINK_BUILDER = (bucket) -> {
+        if (bucket == null) {
+            return null;
+        }
+
+        final URI uri = UriBuilder.fromPath(BUCKET_PATH)
+                .resolveTemplate("id", bucket.getIdentifier())
+                .build();
+
+        return Link.fromUri(uri).rel("self").build();
+    };
+
+    private static final LinkBuilder<VersionedFlow> FLOW_LINK_BUILDER = (versionedFlow -> {
+        if (versionedFlow == null) {
+            return null;
+        }
+
+        final URI uri = UriBuilder.fromPath(FLOW_PATH)
+                .resolveTemplate("bucketId", versionedFlow.getBucketIdentifier())
+                .resolveTemplate("flowId", versionedFlow.getIdentifier())
+                .build();
+
+        return Link.fromUri(uri).rel("self").build();
+    });
+
+    private static final LinkBuilder<VersionedFlowSnapshotMetadata> FLOW_SNAPSHOT_LINK_BUILDER = (snapshotMetadata) -> {
+        if (snapshotMetadata == null) {
+            return null;
+        }
+
+        final URI uri = UriBuilder.fromPath(FLOW_SNAPSHOT_PATH)
+                .resolveTemplate("bucketId", snapshotMetadata.getBucketIdentifier())
+                .resolveTemplate("flowId", snapshotMetadata.getFlowIdentifier())
+                .resolveTemplate("versionNumber", snapshotMetadata.getVersion())
+                .build();
+
+        return Link.fromUri(uri).rel("content").build();
+    };
+
+    private static final LinkBuilder<ExtensionBundle> EXTENSION_BUNDLE_LINK_BUILDER = (extensionBundle -> {
+        if (extensionBundle == null) {
+            return null;
+        }
+
+        final URI uri = UriBuilder.fromPath(EXTENSION_BUNDLE_PATH)
+                .resolveTemplate("bundleId", extensionBundle.getIdentifier())
+                .build();
+
+        return Link.fromUri(uri).rel("self").build();
+    });
+
+    private static final LinkBuilder<ExtensionBundleVersionMetadata> EXTENSION_BUNDLE_VERSION_LINK_BUILDER = (bundleVersion -> {
+        if (bundleVersion == null) {
+            return null;
+        }
+
+        final URI uri = UriBuilder.fromPath(EXTENSION_BUNDLE_VERSION_PATH)
+                .resolveTemplate("bundleId", bundleVersion.getExtensionBundleId())
+                .resolveTemplate("version", bundleVersion.getVersion())
+                .build();
+
+        return Link.fromUri(uri).rel("self").build();
+    });
+
+    private static final LinkBuilder<ExtensionBundleVersion> EXTENSION_BUNDLE_VERSION_CONTENT_LINK_BUILDER = (bundleVersion -> {
+        if (bundleVersion == null) {
+            return null;
+        }
+
+        final URI uri = UriBuilder.fromPath(EXTENSION_BUNDLE_VERSION_CONTENT_PATH)
+                .resolveTemplate("bundleId", bundleVersion.getExtensionBundle().getIdentifier())
+                .resolveTemplate("version", bundleVersion.getVersionMetadata().getVersion())
+                .build();
+
+        return Link.fromUri(uri).rel("self").build();
+    });
+
+    private static final LinkBuilder<ExtensionRepoBucket> EXTENSION_REPO_BUCKET_LINK_BUILDER = (extensionRepoBucket -> {
+        if (extensionRepoBucket == null) {
+            return null;
+        }
+
+        final URI uri = UriBuilder.fromPath(EXTENSION_REPO_BUCKET_PATH)
+                .resolveTemplate("bucketName", extensionRepoBucket.getBucketName())
+                .build();
+
+        return Link.fromUri(uri).rel("self").build();
+    });
+
+    private static final LinkBuilder<ExtensionRepoGroup> EXTENSION_REPO_GROUP_LINK_BUILDER = (extensionRepoGroup -> {
+        if (extensionRepoGroup == null) {
+            return null;
+        }
+
+        final URI uri = UriBuilder.fromPath(EXTENSION_REPO_GROUP_PATH)
+                .resolveTemplate("bucketName", extensionRepoGroup.getBucketName())
+                .resolveTemplate("groupId", extensionRepoGroup.getGroupId())
+                .build();
+
+        return Link.fromUri(uri).rel("self").build();
+    });
+
+    private static final LinkBuilder<ExtensionRepoArtifact> EXTENSION_REPO_ARTIFACT_LINK_BUILDER = (extensionRepoArtifact -> {
+        if (extensionRepoArtifact == null) {
+            return null;
+        }
+
+        final URI uri = UriBuilder.fromPath(EXTENSION_REPO_ARTIFACT_PATH)
+                .resolveTemplate("bucketName", extensionRepoArtifact.getBucketName())
+                .resolveTemplate("groupId", extensionRepoArtifact.getGroupId())
+                .resolveTemplate("artifactId", extensionRepoArtifact.getArtifactId())
+                .build();
+
+        return Link.fromUri(uri).rel("self").build();
+    });
+
+    private static final LinkBuilder<ExtensionRepoVersionSummary> EXTENSION_REPO_VERSION_LINK_BUILDER = (extensionRepoVersion -> {
+        if (extensionRepoVersion == null) {
+            return null;
+        }
+
+        final URI uri = UriBuilder.fromPath(EXTENSION_REPO_VERSION_PATH)
+                .resolveTemplate("bucketName", extensionRepoVersion.getBucketName())
+                .resolveTemplate("groupId", extensionRepoVersion.getGroupId())
+                .resolveTemplate("artifactId", extensionRepoVersion.getArtifactId())
+                .resolveTemplate("version", extensionRepoVersion.getVersion())
+                .build();
+
+        return Link.fromUri(uri).rel("self").build();
+    });
+
+
+    private static final Map<Class,LinkBuilder> LINK_BUILDERS;
+    static {
+        final Map<Class,LinkBuilder> builderMap = new HashMap<>();
+        builderMap.put(Bucket.class, BUCKET_LINK_BUILDER);
+        builderMap.put(VersionedFlow.class, FLOW_LINK_BUILDER);
+        builderMap.put(VersionedFlowSnapshotMetadata.class, FLOW_SNAPSHOT_LINK_BUILDER);
+        builderMap.put(ExtensionBundle.class, EXTENSION_BUNDLE_LINK_BUILDER);
+        builderMap.put(ExtensionBundleVersionMetadata.class, EXTENSION_BUNDLE_VERSION_LINK_BUILDER);
+        builderMap.put(ExtensionBundleVersion.class, EXTENSION_BUNDLE_VERSION_CONTENT_LINK_BUILDER);
+        builderMap.put(ExtensionRepoBucket.class, EXTENSION_REPO_BUCKET_LINK_BUILDER);
+        builderMap.put(ExtensionRepoGroup.class, EXTENSION_REPO_GROUP_LINK_BUILDER);
+        builderMap.put(ExtensionRepoArtifact.class, EXTENSION_REPO_ARTIFACT_LINK_BUILDER);
+        builderMap.put(ExtensionRepoVersionSummary.class, EXTENSION_REPO_VERSION_LINK_BUILDER);
+        LINK_BUILDERS = Collections.unmodifiableMap(builderMap);
+    }
+
+    public <E extends LinkableEntity> void populateLinks(final E entity) {
+        final LinkBuilder linkBuilder = LINK_BUILDERS.get(entity.getClass());
+        if (linkBuilder == null) {
+            throw new IllegalArgumentException("No LinkBuilder found for " + entity.getClass().getCanonicalName());
+        }
+
+        final Link link = linkBuilder.createLink(entity);
+        entity.setLink(link);
+    }
+
+    public <E extends LinkableEntity> void populateLinks(final Iterable<E> entities) {
+        if (entities == null) {
             return;
         }
 
-        buckets.forEach(b -> populateBucketLinks(b));
+        entities.forEach(e -> populateLinks(e));
     }
 
-    public void populateBucketLinks(final Bucket bucket) {
-        final Link bucketLink = bucketLinkBuilder.createLink(bucket);
-        bucket.setLink(bucketLink);
+    public <E extends LinkableEntity> void populateFullLinks(final E entity, final URI baseUri) {
+        final LinkBuilder linkBuilder = LINK_BUILDERS.get(entity.getClass());
+        if (linkBuilder == null) {
+            throw new IllegalArgumentException("No LinkBuilder found for " + entity.getClass().getCanonicalName());
+        }
+
+        if (baseUri == null) {
+            throw new IllegalArgumentException("Base URI cannot be null");
+        }
+
+        final Link relativeLink = linkBuilder.createLink(entity);
+        final URI relativeUri = relativeLink.getUri();
+
+        final URI fullUri = UriBuilder.fromUri(baseUri)
+                .path(relativeUri.getPath())
+                .build();
+
+        final Link fullLink = Link.fromUri(fullUri)
+                .rel(relativeLink.getRel())
+                .build();
+
+        entity.setLink(fullLink);
     }
 
-    // ---- Flow Links
-
-    public void populateFlowLinks(final Iterable<VersionedFlow> versionedFlows) {
-        if (versionedFlows == null) {
+    public <E extends LinkableEntity> void populateFullLinks(final Iterable<E> entities, final URI baseUri) {
+        if (entities == null) {
             return;
         }
 
-        versionedFlows.forEach(f  -> populateFlowLinks(f));
+        entities.forEach(e -> populateFullLinks(e, baseUri));
     }
 
-    public void populateFlowLinks(final VersionedFlow versionedFlow) {
-        final Link flowLink = versionedFlowLinkBuilder.createLink(versionedFlow);
-        versionedFlow.setLink(flowLink);
-    }
-
-    // ---- Flow Snapshot Links
-
-    public void populateSnapshotLinks(final Iterable<VersionedFlowSnapshotMetadata> snapshotMetadatas) {
-        if (snapshotMetadatas == null) {
-            return;
-        }
-
-        snapshotMetadatas.forEach(s -> populateSnapshotLinks(s));
-    }
-
-    public void populateSnapshotLinks(final VersionedFlowSnapshotMetadata snapshotMetadata) {
-        final Link snapshotLink = snapshotMetadataLinkBuilder.createLink(snapshotMetadata);
-        snapshotMetadata.setLink(snapshotLink);
-    }
-
-    // ---- BucketItem Links
-
-    public void populateItemLinks(final Iterable<BucketItem> items) {
-        if (items == null) {
-            return;
-        }
-
-        items.forEach(i -> populateItemLinks(i));
-    }
-
-    public void populateItemLinks(final BucketItem bucketItem) {
-        if (bucketItem == null) {
-            return;
-        }
-
-        if (bucketItem instanceof VersionedFlow) {
-            populateFlowLinks((VersionedFlow)bucketItem);
-        } else {
-            LOGGER.error("Unable to create link for BucketItem with type: " + bucketItem.getClass().getCanonicalName());
-        }
-    }
 }
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/BucketLinkBuilder.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/BucketLinkBuilder.java
deleted file mode 100644
index f0409c7..0000000
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/BucketLinkBuilder.java
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * 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.registry.web.link.builder;
-
-import org.apache.nifi.registry.bucket.Bucket;
-
-import javax.ws.rs.core.Link;
-import javax.ws.rs.core.UriBuilder;
-import java.net.URI;
-
-/**
- * LinkBuilder that builds "self" links for Buckets.
- */
-public class BucketLinkBuilder implements LinkBuilder<Bucket> {
-
-    private static final String PATH = "buckets/{id}";
-
-    @Override
-    public Link createLink(final Bucket bucket) {
-        if (bucket == null) {
-            return null;
-        }
-
-        final URI uri = UriBuilder.fromPath(PATH)
-                .resolveTemplate("id", bucket.getIdentifier())
-                .build();
-
-        return Link.fromUri(uri).rel("self").build();
-    }
-
-}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/VersionedFlowLinkBuilder.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/VersionedFlowLinkBuilder.java
deleted file mode 100644
index 38d3d0e..0000000
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/VersionedFlowLinkBuilder.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * 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.registry.web.link.builder;
-
-import org.apache.nifi.registry.flow.VersionedFlow;
-
-import javax.ws.rs.core.Link;
-import javax.ws.rs.core.UriBuilder;
-import java.net.URI;
-
-/**
- * LinkBuilder that builds "self" links for VersionedFlows.
- */
-public class VersionedFlowLinkBuilder implements LinkBuilder<VersionedFlow> {
-
-    private static final String PATH = "buckets/{bucketId}/flows/{flowId}";
-
-    @Override
-    public Link createLink(final VersionedFlow versionedFlow) {
-        if (versionedFlow == null) {
-            return null;
-        }
-
-        final URI uri = UriBuilder.fromPath(PATH)
-                .resolveTemplate("bucketId", versionedFlow.getBucketIdentifier())
-                .resolveTemplate("flowId", versionedFlow.getIdentifier())
-                .build();
-
-        return Link.fromUri(uri).rel("self").build();
-    }
-
-}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/VersionedFlowSnapshotLinkBuilder.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/VersionedFlowSnapshotLinkBuilder.java
deleted file mode 100644
index 4085c6d..0000000
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/link/builder/VersionedFlowSnapshotLinkBuilder.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * 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.registry.web.link.builder;
-
-import org.apache.nifi.registry.flow.VersionedFlowSnapshotMetadata;
-
-import javax.ws.rs.core.Link;
-import javax.ws.rs.core.UriBuilder;
-import java.net.URI;
-
-/**
- * LinkBuilder that builds "self" links for VersionedFlowSnapshotMetadata.
- */
-public class VersionedFlowSnapshotLinkBuilder implements LinkBuilder<VersionedFlowSnapshotMetadata> {
-
-    private static final String PATH = "buckets/{bucketId}/flows/{flowId}/versions/{versionNumber}";
-
-    @Override
-    public Link createLink(final VersionedFlowSnapshotMetadata snapshotMetadata) {
-        if (snapshotMetadata == null) {
-            return null;
-        }
-
-        final URI uri = UriBuilder.fromPath(PATH)
-                .resolveTemplate("bucketId", snapshotMetadata.getBucketIdentifier())
-                .resolveTemplate("flowId", snapshotMetadata.getFlowIdentifier())
-                .resolveTemplate("versionNumber", snapshotMetadata.getVersion())
-                .build();
-
-        return Link.fromUri(uri).rel("content").build();
-    }
-
-}
diff --git a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/kerberos/KerberosSpnegoIdentityProvider.java b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/kerberos/KerberosSpnegoIdentityProvider.java
index e611b53..f44d766 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/kerberos/KerberosSpnegoIdentityProvider.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/main/java/org/apache/nifi/registry/web/security/authentication/kerberos/KerberosSpnegoIdentityProvider.java
@@ -39,13 +39,11 @@
 import org.springframework.security.kerberos.authentication.KerberosServiceAuthenticationProvider;
 import org.springframework.security.kerberos.authentication.KerberosServiceRequestToken;
 import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
-import org.springframework.stereotype.Component;
 
 import javax.servlet.http.HttpServletRequest;
 import java.nio.charset.StandardCharsets;
 import java.util.concurrent.TimeUnit;
 
-@Component
 public class KerberosSpnegoIdentityProvider implements IdentityProvider {
 
     private static final Logger logger = LoggerFactory.getLogger(KerberosSpnegoIdentityProvider.class);
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredNiFiRegistryClientIT.java b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredNiFiRegistryClientIT.java
index 2410234..2fb2f4a 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredNiFiRegistryClientIT.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/api/UnsecuredNiFiRegistryClientIT.java
@@ -16,11 +16,17 @@
  */
 package org.apache.nifi.registry.web.api;
 
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.registry.authorization.CurrentUser;
 import org.apache.nifi.registry.authorization.Permissions;
 import org.apache.nifi.registry.bucket.Bucket;
 import org.apache.nifi.registry.bucket.BucketItem;
+import org.apache.nifi.registry.bucket.BucketItemType;
 import org.apache.nifi.registry.client.BucketClient;
+import org.apache.nifi.registry.client.ExtensionBundleClient;
+import org.apache.nifi.registry.client.ExtensionBundleVersionClient;
+import org.apache.nifi.registry.client.ExtensionRepoClient;
 import org.apache.nifi.registry.client.FlowClient;
 import org.apache.nifi.registry.client.FlowSnapshotClient;
 import org.apache.nifi.registry.client.ItemsClient;
@@ -30,6 +36,16 @@
 import org.apache.nifi.registry.client.UserClient;
 import org.apache.nifi.registry.client.impl.JerseyNiFiRegistryClient;
 import org.apache.nifi.registry.diff.VersionedFlowDifference;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+import org.apache.nifi.registry.extension.ExtensionBundleType;
+import org.apache.nifi.registry.extension.ExtensionBundleVersion;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionDependency;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionMetadata;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoArtifact;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoBucket;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoGroup;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersion;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersionSummary;
 import org.apache.nifi.registry.field.Fields;
 import org.apache.nifi.registry.flow.VersionedFlow;
 import org.apache.nifi.registry.flow.VersionedFlowSnapshot;
@@ -37,6 +53,9 @@
 import org.apache.nifi.registry.flow.VersionedProcessGroup;
 import org.apache.nifi.registry.flow.VersionedProcessor;
 import org.apache.nifi.registry.flow.VersionedPropertyDescriptor;
+import org.apache.nifi.registry.util.FileUtils;
+import org.bouncycastle.util.encoders.Hex;
+import org.glassfish.jersey.media.multipart.MultiPartFeature;
 import org.junit.After;
 import org.junit.Assert;
 import org.junit.Before;
@@ -44,11 +63,21 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.ws.rs.client.Client;
+import javax.ws.rs.client.ClientBuilder;
+import javax.ws.rs.client.WebTarget;
+import javax.ws.rs.core.MediaType;
+import java.io.File;
+import java.io.FileInputStream;
 import java.io.IOException;
+import java.io.InputStream;
+import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 /**
  * Test all basic functionality of JerseyNiFiRegistryClient.
@@ -60,7 +89,7 @@
     private NiFiRegistryClient client;
 
     @Before
-    public void setup() {
+    public void setup() throws IOException {
         final String baseUrl = createBaseURL();
         LOGGER.info("Using base url = " + baseUrl);
 
@@ -76,6 +105,16 @@
 
         Assert.assertNotNull(client);
         this.client = client;
+
+        // Clear the extension bundles storage directory in case previous tests left data
+        final File extensionsStorageDir = new File("./target/test-classes/extension_bundles");
+        if (extensionsStorageDir.exists()) {
+            try {
+                FileUtils.deleteFile(extensionsStorageDir, true);
+            } catch (Exception e) {
+                LOGGER.warn("Unable to delete extensions storage dir due to: " + e.getMessage(), e);
+            }
+        }
     }
 
     @After
@@ -103,7 +142,7 @@
     }
 
     @Test
-    public void testNiFiRegistryClient() throws IOException, NiFiRegistryException {
+    public void testNiFiRegistryClient() throws IOException, NiFiRegistryException, NoSuchAlgorithmException {
         // ---------------------- TEST BUCKETS --------------------------//
 
         final BucketClient bucketClient = client.getBucketClient();
@@ -281,7 +320,190 @@
         Assert.assertEquals(snapshotFlow.getIdentifier(), latestMetadataWithoutBucket.getFlowIdentifier());
         Assert.assertEquals(2, latestMetadataWithoutBucket.getVersion());
 
-        // ---------------------- TEST ITEMS --------------------------//
+        // ---------------------- TEST EXTENSIONS ----------------------//
+
+        // verify we have no bundles yet
+        final ExtensionBundleClient bundleClient = client.getExtensionBundleClient();
+        final List<ExtensionBundle> allBundles = bundleClient.getAll();
+        Assert.assertEquals(0, allBundles.size());
+
+        final Bucket bundlesBucket = createdBuckets.get(1);
+        final ExtensionBundleVersionClient bundleVersionClient = client.getExtensionBundleVersionClient();
+
+        // create version 1.0.0 of nifi-test-nar
+        final String testNar1 = "src/test/resources/extensions/nars/nifi-test-nar-1.0.0.nar";
+        final ExtensionBundleVersion createdTestNarV1 = createExtensionBundleVersionWithStream(bundlesBucket, bundleVersionClient, testNar1, null);
+
+        final ExtensionBundle testNarV1Bundle = createdTestNarV1.getExtensionBundle();
+        LOGGER.info("Created bundle with id {}", new Object[]{testNarV1Bundle.getIdentifier()});
+
+        Assert.assertEquals("org.apache.nifi", testNarV1Bundle.getGroupId());
+        Assert.assertEquals("nifi-test-nar", testNarV1Bundle.getArtifactId());
+        Assert.assertEquals(ExtensionBundleType.NIFI_NAR, testNarV1Bundle.getBundleType());
+        Assert.assertEquals(1, testNarV1Bundle.getVersionCount());
+
+        Assert.assertEquals("org.apache.nifi:nifi-test-nar", testNarV1Bundle.getName());
+        Assert.assertEquals(bundlesBucket.getIdentifier(), testNarV1Bundle.getBucketIdentifier());
+        Assert.assertEquals(bundlesBucket.getName(), testNarV1Bundle.getBucketName());
+        Assert.assertNotNull(testNarV1Bundle.getPermissions());
+        Assert.assertTrue(testNarV1Bundle.getCreatedTimestamp() > 0);
+        Assert.assertTrue(testNarV1Bundle.getModifiedTimestamp() > 0);
+
+        final ExtensionBundleVersionMetadata testNarV1Metadata = createdTestNarV1.getVersionMetadata();
+        Assert.assertEquals("1.0.0", testNarV1Metadata.getVersion());
+        Assert.assertNotNull(testNarV1Metadata.getId());
+        Assert.assertNotNull(testNarV1Metadata.getSha256());
+        Assert.assertNotNull(testNarV1Metadata.getAuthor());
+        Assert.assertEquals(testNarV1Bundle.getIdentifier(), testNarV1Metadata.getExtensionBundleId());
+        Assert.assertEquals(bundlesBucket.getIdentifier(), testNarV1Metadata.getBucketId());
+        Assert.assertTrue(testNarV1Metadata.getTimestamp() > 0);
+        Assert.assertFalse(testNarV1Metadata.getSha256Supplied());
+
+        final Set<ExtensionBundleVersionDependency> dependencies = createdTestNarV1.getDependencies();
+        Assert.assertNotNull(dependencies);
+        Assert.assertEquals(1, dependencies.size());
+
+        final ExtensionBundleVersionDependency testNarV1Dependency = dependencies.stream().findFirst().get();
+        Assert.assertEquals("org.apache.nifi", testNarV1Dependency.getGroupId());
+        Assert.assertEquals("nifi-test-api-nar", testNarV1Dependency.getArtifactId());
+        Assert.assertEquals("1.0.0", testNarV1Dependency.getVersion());
+
+        final String testNar2 = "src/test/resources/extensions/nars/nifi-test-nar-2.0.0.nar";
+
+        // try to create version 2.0.0 of nifi-test-nar when the supplied SHA-256 does not match server's
+        final String madeUpSha256 = "MADE-UP-SHA-256";
+        try {
+            createExtensionBundleVersionWithStream(bundlesBucket, bundleVersionClient, testNar2, madeUpSha256);
+            Assert.fail("Should have thrown exception");
+        } catch (Exception e) {
+            // should have thrown exception from mismatched SHA-256
+        }
+
+        // create version 2.0.0 of nifi-test-nar using correct supplied SHA-256
+        final String testNar2Sha256 = calculateSha256Hex(testNar2);
+        final ExtensionBundleVersion createdTestNarV2 = createExtensionBundleVersionWithStream(bundlesBucket, bundleVersionClient, testNar2, testNar2Sha256);
+        Assert.assertTrue(createdTestNarV2.getVersionMetadata().getSha256Supplied());
+
+        final ExtensionBundle testNarV2Bundle = createdTestNarV2.getExtensionBundle();
+        LOGGER.info("Created bundle with id {}", new Object[]{testNarV2Bundle.getIdentifier()});
+
+        // create version 1.0.0 of nifi-foo-nar, use the file variant
+        final String fooNar = "src/test/resources/extensions/nars/nifi-foo-nar-1.0.0.nar";
+        final ExtensionBundleVersion createdFooNarV1 = createExtensionBundleVersionWithFile(bundlesBucket, bundleVersionClient, fooNar, null);
+        Assert.assertFalse(createdFooNarV1.getVersionMetadata().getSha256Supplied());
+
+        final ExtensionBundle fooNarV1Bundle = createdFooNarV1.getExtensionBundle();
+        LOGGER.info("Created bundle with id {}", new Object[]{fooNarV1Bundle.getIdentifier()});
+
+        // verify there are 2 bundles now
+        final List<ExtensionBundle> allBundlesAfterCreate = bundleClient.getAll();
+        Assert.assertEquals(2, allBundlesAfterCreate.size());
+
+        // verify getting bundles by bucket
+        Assert.assertEquals(2, bundleClient.getByBucket(bundlesBucket.getIdentifier()).size());
+        Assert.assertEquals(0, bundleClient.getByBucket(flowsBucket.getIdentifier()).size());
+
+        // verify getting bundles by id
+        final ExtensionBundle retrievedBundle = bundleClient.get(testNarV1Bundle.getIdentifier());
+        Assert.assertNotNull(retrievedBundle);
+        Assert.assertEquals(testNarV1Bundle.getIdentifier(), retrievedBundle.getIdentifier());
+        Assert.assertEquals(testNarV1Bundle.getGroupId(), retrievedBundle.getGroupId());
+        Assert.assertEquals(testNarV1Bundle.getArtifactId(), retrievedBundle.getArtifactId());
+
+        // verify getting list of version metadata for a bundle
+        final List<ExtensionBundleVersionMetadata> bundleVersions = bundleVersionClient.getBundleVersions(testNarV1Bundle.getIdentifier());
+        Assert.assertNotNull(bundleVersions);
+        Assert.assertEquals(2, bundleVersions.size());
+
+        // verify getting a bundle version by the bundle id + version string
+        final ExtensionBundleVersion bundleVersion1 = bundleVersionClient.getBundleVersion(testNarV1Bundle.getIdentifier(), "1.0.0");
+        Assert.assertNotNull(bundleVersion1);
+        Assert.assertEquals("1.0.0", bundleVersion1.getVersionMetadata().getVersion());
+        Assert.assertNotNull(bundleVersion1.getDependencies());
+        Assert.assertEquals(1, bundleVersion1.getDependencies().size());
+
+        final ExtensionBundleVersion bundleVersion2 = bundleVersionClient.getBundleVersion(testNarV1Bundle.getIdentifier(), "2.0.0");
+        Assert.assertNotNull(bundleVersion2);
+        Assert.assertEquals("2.0.0", bundleVersion2.getVersionMetadata().getVersion());
+
+        // verify getting the input stream for a bundle version
+        try (final InputStream bundleVersion1InputStream = bundleVersionClient.getBundleVersionContent(testNarV1Bundle.getIdentifier(), "1.0.0")) {
+            final String sha256Hex = DigestUtils.sha256Hex(bundleVersion1InputStream);
+            Assert.assertEquals(testNarV1Metadata.getSha256(), sha256Hex);
+        }
+
+        // verify writing a bundle version to an output stream
+        final File targetDir = new File("./target");
+        final File bundleFile = bundleVersionClient.writeBundleVersionContent(testNarV1Bundle.getIdentifier(), "1.0.0", targetDir);
+        Assert.assertNotNull(bundleFile);
+
+        try (final InputStream bundleInputStream = new FileInputStream(bundleFile)) {
+            final String sha256Hex = DigestUtils.sha256Hex(bundleInputStream);
+            Assert.assertEquals(testNarV1Metadata.getSha256(), sha256Hex);
+        }
+
+        // Verify deleting a bundle version
+        final ExtensionBundleVersion deletedBundleVersion2 = bundleVersionClient.delete(testNarV1Bundle.getIdentifier(), "2.0.0");
+        Assert.assertNotNull(deletedBundleVersion2);
+        Assert.assertEquals(testNarV1Bundle.getIdentifier(), deletedBundleVersion2.getExtensionBundle().getIdentifier());
+        Assert.assertEquals("2.0.0", deletedBundleVersion2.getVersionMetadata().getVersion());
+
+        try {
+            bundleVersionClient.getBundleVersion(testNarV1Bundle.getIdentifier(), "2.0.0");
+            Assert.fail("Should have thrown exception");
+        } catch (Exception e) {
+            // should catch exception
+        }
+
+        // ---------------------- TEST EXTENSION REPO ----------------------//
+
+        final ExtensionRepoClient extensionRepoClient = client.getExtensionRepoClient();
+
+        final List<ExtensionRepoBucket> repoBuckets = extensionRepoClient.getBuckets();
+        Assert.assertEquals(createdBuckets.size(), repoBuckets.size());
+
+        final String bundlesBucketName = bundlesBucket.getName();
+        final List<ExtensionRepoGroup> repoGroups = extensionRepoClient.getGroups(bundlesBucketName);
+        Assert.assertEquals(1, repoGroups.size());
+
+        final String repoGroupId = "org.apache.nifi";
+        final ExtensionRepoGroup repoGroup = repoGroups.get(0);
+        Assert.assertEquals(repoGroupId, repoGroup.getGroupId());
+
+        final List<ExtensionRepoArtifact> repoArtifacts = extensionRepoClient.getArtifacts(bundlesBucketName, repoGroupId);
+        Assert.assertEquals(2, repoArtifacts.size());
+
+        final String repoArtifactId = "nifi-test-nar";
+        final List<ExtensionRepoVersionSummary> repoVersions = extensionRepoClient.getVersions(bundlesBucketName, repoGroupId, repoArtifactId);
+        Assert.assertEquals(1, repoVersions.size());
+
+        final String repoVersionString = "1.0.0";
+        final ExtensionRepoVersion repoVersion = extensionRepoClient.getVersion(bundlesBucketName, repoGroupId, repoArtifactId, repoVersionString);
+        Assert.assertNotNull(repoVersion);
+        Assert.assertNotNull(repoVersion.getDownloadLink());
+        Assert.assertNotNull(repoVersion.getSha256Link());
+
+        // verify the version links for content and sha256
+        final Client jerseyClient = ClientBuilder.newBuilder().register(MultiPartFeature.class).build();
+
+        final WebTarget downloadLinkTarget = jerseyClient.target(repoVersion.getDownloadLink().getUri());
+        try (final InputStream downloadLinkInputStream = downloadLinkTarget.request()
+                .accept(MediaType.APPLICATION_OCTET_STREAM_TYPE).get().readEntity(InputStream.class)) {
+            final String sha256DownloadResult = DigestUtils.sha256Hex(downloadLinkInputStream);
+
+            final WebTarget sha256LinkTarget = jerseyClient.target(repoVersion.getSha256Link().getUri());
+            final String sha256LinkResult = sha256LinkTarget.request().get(String.class);
+            Assert.assertEquals(sha256DownloadResult, sha256LinkResult);
+        }
+
+        // verify the client methods for content input stream and content sha256
+        try (final InputStream repoVersionInputStream = extensionRepoClient.getVersionContent(bundlesBucketName, repoGroupId, repoArtifactId, repoVersionString)) {
+            final String sha256Hex = DigestUtils.sha256Hex(repoVersionInputStream);
+            final String repoSha256Hex = extensionRepoClient.getVersionSha256(bundlesBucketName, repoGroupId, repoArtifactId, repoVersionString);
+            Assert.assertEquals(sha256Hex, repoSha256Hex);
+        }
+
+        // ---------------------- TEST ITEMS -------------------------- //
 
         final ItemsClient itemsClient = client.getItemsClient();
 
@@ -292,10 +514,25 @@
 
         // get all items
         final List<BucketItem> allItems = itemsClient.getAll();
-        Assert.assertEquals(2, allItems.size());
-        allItems.stream().forEach(i -> Assert.assertNotNull(i.getBucketName()));
+        Assert.assertEquals(4, allItems.size());
+        allItems.stream().forEach(i -> {
+            Assert.assertNotNull(i.getBucketName());
+            Assert.assertNotNull(i.getLink());
+        });
         allItems.stream().forEach(i -> LOGGER.info("All items, item " + i.getIdentifier()));
 
+        // verify 2 flow items
+        final List<BucketItem> flowItems = allItems.stream()
+                .filter(i -> i.getType() == BucketItemType.Flow)
+                .collect(Collectors.toList());
+        Assert.assertEquals(2, flowItems.size());
+
+        // verify 2 bundle items
+        final List<BucketItem> extensionBundleItems = allItems.stream()
+                .filter(i -> i.getType() == BucketItemType.Extension_Bundle)
+                .collect(Collectors.toList());
+        Assert.assertEquals(2, extensionBundleItems.size());
+
         // get items for bucket
         final List<BucketItem> bucketItems = itemsClient.getByBucket(flowsBucket.getIdentifier());
         Assert.assertEquals(2, bucketItems.size());
@@ -325,6 +562,14 @@
         Assert.assertNotNull(deletedFlow2);
         LOGGER.info("Deleted flow " + deletedFlow2.getIdentifier());
 
+        final ExtensionBundle deletedBundle1 = bundleClient.delete(testNarV1Bundle.getIdentifier());
+        Assert.assertNotNull(deletedBundle1);
+        LOGGER.info("Deleted extension bundle " + deletedBundle1.getIdentifier());
+
+        final ExtensionBundle deletedBundle2 = bundleClient.delete(fooNarV1Bundle.getIdentifier());
+        Assert.assertNotNull(deletedBundle2);
+        LOGGER.info("Deleted extension bundle " + deletedBundle2.getIdentifier());
+
         // delete each bucket
         for (final Bucket bucket : createdBuckets) {
             final Bucket deletedBucket = bucketClient.delete(bucket.getIdentifier());
@@ -337,6 +582,58 @@
 
     }
 
+    private ExtensionBundleVersion createExtensionBundleVersionWithStream(final Bucket bundlesBucket,
+                                                                          final ExtensionBundleVersionClient bundleVersionClient,
+                                                                          final String narFile, final String sha256)
+            throws IOException, NiFiRegistryException {
+
+        final ExtensionBundleVersion createdBundleVersion;
+        try (final InputStream bundleInputStream = new FileInputStream(narFile)) {
+            if (StringUtils.isBlank(sha256)) {
+                createdBundleVersion = bundleVersionClient.create(
+                        bundlesBucket.getIdentifier(), ExtensionBundleType.NIFI_NAR, bundleInputStream);
+            } else {
+                createdBundleVersion = bundleVersionClient.create(
+                        bundlesBucket.getIdentifier(), ExtensionBundleType.NIFI_NAR, bundleInputStream, sha256);
+            }
+        }
+
+        Assert.assertNotNull(createdBundleVersion);
+        Assert.assertNotNull(createdBundleVersion.getBucket());
+        Assert.assertNotNull(createdBundleVersion.getExtensionBundle());
+        Assert.assertNotNull(createdBundleVersion.getVersionMetadata());
+
+        return createdBundleVersion;
+    }
+
+    private ExtensionBundleVersion createExtensionBundleVersionWithFile(final Bucket bundlesBucket,
+                                                                        final ExtensionBundleVersionClient bundleVersionClient,
+                                                                        final String narFile, final String sha256)
+            throws IOException, NiFiRegistryException {
+
+        final ExtensionBundleVersion createdBundleVersion;
+        if (StringUtils.isBlank(sha256)) {
+            createdBundleVersion = bundleVersionClient.create(
+                    bundlesBucket.getIdentifier(), ExtensionBundleType.NIFI_NAR, new File(narFile));
+        } else {
+            createdBundleVersion = bundleVersionClient.create(
+                    bundlesBucket.getIdentifier(), ExtensionBundleType.NIFI_NAR, new File(narFile), sha256);
+        }
+
+        Assert.assertNotNull(createdBundleVersion);
+        Assert.assertNotNull(createdBundleVersion.getBucket());
+        Assert.assertNotNull(createdBundleVersion.getExtensionBundle());
+        Assert.assertNotNull(createdBundleVersion.getVersionMetadata());
+
+        return createdBundleVersion;
+    }
+
+    private String calculateSha256Hex(final String narFile) throws IOException {
+        try (final InputStream bundleInputStream = new FileInputStream(narFile)) {
+            return Hex.toHexString(DigestUtils.sha256(bundleInputStream));
+        }
+    }
+
     private static Bucket createBucket(BucketClient bucketClient, int num) throws IOException, NiFiRegistryException {
         final Bucket bucket = new Bucket();
         bucket.setName("Bucket #" + num);
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/link/TestLinkService.java b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/link/TestLinkService.java
index bfc9a46..4e1f239 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/link/TestLinkService.java
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/java/org/apache/nifi/registry/web/link/TestLinkService.java
@@ -18,17 +18,29 @@
 
 import org.apache.nifi.registry.bucket.Bucket;
 import org.apache.nifi.registry.bucket.BucketItem;
+import org.apache.nifi.registry.bucket.BucketItemType;
+import org.apache.nifi.registry.extension.ExtensionBundle;
+import org.apache.nifi.registry.extension.ExtensionBundleVersionMetadata;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoArtifact;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoBucket;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoGroup;
+import org.apache.nifi.registry.extension.repo.ExtensionRepoVersionSummary;
 import org.apache.nifi.registry.flow.VersionedFlow;
 import org.apache.nifi.registry.flow.VersionedFlowSnapshotMetadata;
 import org.junit.Assert;
 import org.junit.Before;
 import org.junit.Test;
 
+import javax.ws.rs.core.UriBuilder;
+import java.net.URI;
 import java.util.ArrayList;
 import java.util.List;
 
 public class TestLinkService {
 
+    private static final String BASE_URI = "http://localhost:18080/nifi-registry-api";
+    private URI baseUri = UriBuilder.fromUri(BASE_URI).build();
+
     private LinkService linkService;
 
     private List<Bucket> buckets;
@@ -36,6 +48,14 @@
     private List<VersionedFlowSnapshotMetadata> snapshots;
     private List<BucketItem> items;
 
+    private List<ExtensionBundle> extensionBundles;
+    private List<ExtensionBundleVersionMetadata> extensionBundleVersionMetadata;
+
+    private List<ExtensionRepoBucket> extensionRepoBuckets;
+    private List<ExtensionRepoGroup> extensionRepoGroups;
+    private List<ExtensionRepoArtifact> extensionRepoArtifacts;
+    private List<ExtensionRepoVersionSummary> extensionRepoVersions;
+
     @Before
     public void setup() {
         linkService = new LinkService();
@@ -43,11 +63,11 @@
         // setup buckets
         final Bucket bucket1 = new Bucket();
         bucket1.setIdentifier("b1");
-        bucket1.setName("Bucket 1");
+        bucket1.setName("Bucket_1");
 
         final Bucket bucket2 = new Bucket();
         bucket2.setIdentifier("b2");
-        bucket2.setName("Bucket 2");
+        bucket2.setName("Bucket_2");
 
         buckets = new ArrayList<>();
         buckets.add(bucket1);
@@ -56,12 +76,12 @@
         // setup flows
         final VersionedFlow flow1 = new VersionedFlow();
         flow1.setIdentifier("f1");
-        flow1.setName("Flow 1");
+        flow1.setName("Flow_1");
         flow1.setBucketIdentifier(bucket1.getIdentifier());
 
         final VersionedFlow flow2 = new VersionedFlow();
         flow2.setIdentifier("f2");
-        flow2.setName("Flow 2");
+        flow2.setName("Flow_2");
         flow2.setBucketIdentifier(bucket1.getIdentifier());
 
         flows = new ArrayList<>();
@@ -83,42 +103,197 @@
         snapshots.add(snapshotMetadata1);
         snapshots.add(snapshotMetadata2);
 
+        // setup extension bundles
+        final ExtensionBundle bundle1 = new ExtensionBundle();
+        bundle1.setIdentifier("eb1");
+
+        final ExtensionBundle bundle2 = new ExtensionBundle();
+        bundle2.setIdentifier("eb2");
+
+        extensionBundles = new ArrayList<>();
+        extensionBundles.add(bundle1);
+        extensionBundles.add(bundle2);
+
+        // setup extension bundle versions
+        final ExtensionBundleVersionMetadata bundleVersion1 = new ExtensionBundleVersionMetadata();
+        bundleVersion1.setExtensionBundleId(bundle1.getIdentifier());
+        bundleVersion1.setVersion("1.0.0");
+
+        final ExtensionBundleVersionMetadata bundleVersion2 = new ExtensionBundleVersionMetadata();
+        bundleVersion2.setExtensionBundleId(bundle1.getIdentifier());
+        bundleVersion2.setVersion("2.0.0");
+
+        extensionBundleVersionMetadata = new ArrayList<>();
+        extensionBundleVersionMetadata.add(bundleVersion1);
+        extensionBundleVersionMetadata.add(bundleVersion2);
+
+        // setup extension repo buckets
+        final ExtensionRepoBucket rb1 = new ExtensionRepoBucket();
+        rb1.setBucketName(bucket1.getName());
+
+        final ExtensionRepoBucket rb2 = new ExtensionRepoBucket();
+        rb2.setBucketName(bucket2.getName());
+
+        extensionRepoBuckets = new ArrayList<>();
+        extensionRepoBuckets.add(rb1);
+        extensionRepoBuckets.add(rb2);
+
+        // setup extension repo groups
+        final ExtensionRepoGroup rg1 = new ExtensionRepoGroup();
+        rg1.setBucketName(rb1.getBucketName());
+        rg1.setGroupId("g1");
+
+        final ExtensionRepoGroup rg2 = new ExtensionRepoGroup();
+        rg2.setBucketName(rb1.getBucketName());
+        rg2.setGroupId("g2");
+
+        extensionRepoGroups = new ArrayList<>();
+        extensionRepoGroups.add(rg1);
+        extensionRepoGroups.add(rg2);
+
+        // setup extension repo artifacts
+        final ExtensionRepoArtifact ra1 = new ExtensionRepoArtifact();
+        ra1.setBucketName(rb1.getBucketName());
+        ra1.setGroupId(rg1.getGroupId());
+        ra1.setArtifactId("a1");
+
+        final ExtensionRepoArtifact ra2 = new ExtensionRepoArtifact();
+        ra2.setBucketName(rb1.getBucketName());
+        ra2.setGroupId(rg1.getGroupId());
+        ra2.setArtifactId("a2");
+
+        extensionRepoArtifacts = new ArrayList<>();
+        extensionRepoArtifacts.add(ra1);
+        extensionRepoArtifacts.add(ra2);
+
+        // setup extension repo versions
+        final ExtensionRepoVersionSummary rv1 = new ExtensionRepoVersionSummary();
+        rv1.setBucketName(rb1.getBucketName());
+        rv1.setGroupId(rg1.getGroupId());
+        rv1.setArtifactId(ra1.getArtifactId());
+        rv1.setVersion("1.0.0");
+
+        final ExtensionRepoVersionSummary rv2 = new ExtensionRepoVersionSummary();
+        rv2.setBucketName(rb1.getBucketName());
+        rv2.setGroupId(rg1.getGroupId());
+        rv2.setArtifactId(ra1.getArtifactId());
+        rv2.setVersion("2.0.0");
+
+        extensionRepoVersions = new ArrayList<>();
+        extensionRepoVersions.add(rv1);
+        extensionRepoVersions.add(rv2);
+
         // setup items
         items = new ArrayList<>();
         items.add(flow1);
         items.add(flow2);
+        items.add(bundle1);
+        items.add(bundle2);
     }
 
     @Test
     public void testPopulateBucketLinks() {
-        buckets.stream().forEach(b -> Assert.assertNull(b.getLink()));
-        linkService.populateBucketLinks(buckets);
-        buckets.stream().forEach(b -> Assert.assertEquals(
+        buckets.forEach(b -> Assert.assertNull(b.getLink()));
+        linkService.populateLinks(buckets);
+        buckets.forEach(b -> Assert.assertEquals(
                 "buckets/" + b.getIdentifier(), b.getLink().getUri().toString()));
     }
 
     @Test
     public void testPopulateFlowLinks() {
-        flows.stream().forEach(f -> Assert.assertNull(f.getLink()));
-        linkService.populateFlowLinks(flows);
-        flows.stream().forEach(f -> Assert.assertEquals(
+        flows.forEach(f -> Assert.assertNull(f.getLink()));
+        linkService.populateLinks(flows);
+        flows.forEach(f -> Assert.assertEquals(
                 "buckets/" + f.getBucketIdentifier() + "/flows/" + f.getIdentifier(), f.getLink().getUri().toString()));
     }
 
     @Test
     public void testPopulateSnapshotLinks() {
-        snapshots.stream().forEach(s -> Assert.assertNull(s.getLink()));
-        linkService.populateSnapshotLinks(snapshots);
-        snapshots.stream().forEach(s -> Assert.assertEquals(
+        snapshots.forEach(s -> Assert.assertNull(s.getLink()));
+        linkService.populateLinks(snapshots);
+        snapshots.forEach(s -> Assert.assertEquals(
                 "buckets/" + s.getBucketIdentifier() + "/flows/" + s.getFlowIdentifier() + "/versions/" + s.getVersion(), s.getLink().getUri().toString()));
     }
 
     @Test
     public void testPopulateItemLinks() {
-        items.stream().forEach(i -> Assert.assertNull(i.getLink()));
-        linkService.populateItemLinks(items);
-        items.stream().forEach(i -> Assert.assertEquals(
-                "buckets/" + i.getBucketIdentifier() + "/flows/" + i.getIdentifier(), i.getLink().getUri().toString()));
+        items.forEach(i -> Assert.assertNull(i.getLink()));
+        linkService.populateLinks(items);
+        items.forEach(i -> {
+            if (i.getType() == BucketItemType.Flow) {
+                Assert.assertEquals("buckets/" + i.getBucketIdentifier() + "/flows/" + i.getIdentifier(), i.getLink().getUri().toString());
+            } else {
+                Assert.assertEquals("extensions/bundles/" + i.getIdentifier(), i.getLink().getUri().toString());
+            }
+        });
     }
 
+    @Test
+    public void testPopulateExtensionBundleLinks() {
+        extensionBundles.forEach(i -> Assert.assertNull(i.getLink()));
+        linkService.populateLinks(extensionBundles);
+        extensionBundles.forEach(eb -> Assert.assertEquals("extensions/bundles/" + eb.getIdentifier(), eb.getLink().getUri().toString()));
+    }
+
+    @Test
+    public void testPopulateExtensionBundleVersionLinks() {
+        extensionBundleVersionMetadata.forEach(i -> Assert.assertNull(i.getLink()));
+        linkService.populateLinks(extensionBundleVersionMetadata);
+        extensionBundleVersionMetadata.forEach(eb -> Assert.assertEquals(
+                "extensions/bundles/" + eb.getExtensionBundleId() + "/versions/" + eb.getVersion(), eb.getLink().getUri().toString()));
+    }
+
+    @Test
+    public void testPopulateExtensionRepoBucketLinks() {
+        extensionRepoBuckets.forEach(i -> Assert.assertNull(i.getLink()));
+        linkService.populateLinks(extensionRepoBuckets);
+        extensionRepoBuckets.forEach(i -> Assert.assertEquals(
+                "extensions/repo/" + i.getBucketName(),
+                i.getLink().getUri().toString())
+        );
+    }
+
+    @Test
+    public void testPopulateExtensionRepoGroupLinks() {
+        extensionRepoGroups.forEach(i -> Assert.assertNull(i.getLink()));
+        linkService.populateLinks(extensionRepoGroups);
+        extensionRepoGroups.forEach(i -> {
+            Assert.assertEquals(
+                    "extensions/repo/" + i.getBucketName() + "/" + i.getGroupId(),
+                    i.getLink().getUri().toString()); }
+        );
+    }
+
+    @Test
+    public void testPopulateExtensionRepoArtifactLinks() {
+        extensionRepoArtifacts.forEach(i -> Assert.assertNull(i.getLink()));
+        linkService.populateLinks(extensionRepoArtifacts);
+        extensionRepoArtifacts.forEach(i -> {
+            Assert.assertEquals(
+                    "extensions/repo/" + i.getBucketName() + "/" + i.getGroupId() + "/" + i.getArtifactId(),
+                    i.getLink().getUri().toString()); }
+        );
+    }
+
+    @Test
+    public void testPopulateExtensionRepoVersionLinks() {
+        extensionRepoVersions.forEach(i -> Assert.assertNull(i.getLink()));
+        linkService.populateLinks(extensionRepoVersions);
+        extensionRepoVersions.forEach(i -> {
+            Assert.assertEquals(
+                    "extensions/repo/" + i.getBucketName() + "/" + i.getGroupId() + "/" + i.getArtifactId() + "/" + i.getVersion(),
+                    i.getLink().getUri().toString()); }
+        );
+    }
+
+    @Test
+    public void testPopulateExtensionRepoVersionFullLinks() {
+        extensionRepoVersions.forEach(i -> Assert.assertNull(i.getLink()));
+        linkService.populateFullLinks(extensionRepoVersions, baseUri);
+        extensionRepoVersions.forEach(i -> {
+            Assert.assertEquals(
+                    BASE_URI + "/extensions/repo/" + i.getBucketName() + "/" + i.getGroupId() + "/" + i.getArtifactId() + "/" + i.getVersion(),
+                    i.getLink().getUri().toString()); }
+        );
+    }
 }
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/resources/application.properties b/nifi-registry-core/nifi-registry-web-api/src/test/resources/application.properties
index efa0290..721b949 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/test/resources/application.properties
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/resources/application.properties
@@ -23,3 +23,6 @@
 #logging.level.org.springframework.core.io.support: DEBUG
 #logging.level.org.springframework.context.annotation: DEBUG
 #logging.level.org.springframework.web: DEBUG
+
+# Need to allow overriding of beans in integration tests
+spring.main.allow-bean-definition-overriding=true
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/resources/conf/providers.xml b/nifi-registry-core/nifi-registry-web-api/src/test/resources/conf/providers.xml
index fd002be..c5609ec 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/test/resources/conf/providers.xml
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/resources/conf/providers.xml
@@ -22,4 +22,9 @@
         <property name="Flow Storage Directory">./target/test-classes/flow_storage</property>
     </flowPersistenceProvider>
 
+    <extensionBundlePersistenceProvider>
+        <class>org.apache.nifi.registry.provider.extension.FileSystemExtensionBundlePersistenceProvider</class>
+        <property name="Extension Bundle Storage Directory">./target/test-classes/extension_bundles</property>
+    </extensionBundlePersistenceProvider>
+
 </providers>
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/resources/conf/unsecured/nifi-registry.properties b/nifi-registry-core/nifi-registry-web-api/src/test/resources/conf/unsecured/nifi-registry.properties
index 113773c..70ca5e3 100644
--- a/nifi-registry-core/nifi-registry-web-api/src/test/resources/conf/unsecured/nifi-registry.properties
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/resources/conf/unsecured/nifi-registry.properties
@@ -21,5 +21,8 @@
 # providers properties #
 nifi.registry.providers.configuration.file=./target/test-classes/conf/providers.xml
 
+# extensions working dir #
+nifi.registry.extensions.working.directory=./target/work/extensions
+
 # database properties
 nifi.registry.db.url.append=;LOCK_TIMEOUT=25000;WRITE_DELAY=0;AUTO_SERVER=FALSE
\ No newline at end of file
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-foo-nar-1.0.0.nar b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-foo-nar-1.0.0.nar
new file mode 100644
index 0000000..4a91f31
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-foo-nar-1.0.0.nar
Binary files differ
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-1.0.0.nar b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-1.0.0.nar
new file mode 100644
index 0000000..c3fccf3
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-1.0.0.nar
Binary files differ
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0-bad-manifest.nar b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0-bad-manifest.nar
new file mode 100644
index 0000000..4400589
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0-bad-manifest.nar
Binary files differ
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0-diff-checksum.nar b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0-diff-checksum.nar
new file mode 100644
index 0000000..7055b64
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0-diff-checksum.nar
Binary files differ
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0-missing-manifest.nar b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0-missing-manifest.nar
new file mode 100644
index 0000000..cbee3ab
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0-missing-manifest.nar
Binary files differ
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0-no-dependency.nar b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0-no-dependency.nar
new file mode 100644
index 0000000..68e78d3
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0-no-dependency.nar
Binary files differ
diff --git a/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0.nar b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0.nar
new file mode 100644
index 0000000..8af4909
--- /dev/null
+++ b/nifi-registry-core/nifi-registry-web-api/src/test/resources/extensions/nars/nifi-test-nar-2.0.0.nar
Binary files differ
diff --git a/nifi-registry-core/nifi-registry-web-ui/pom.xml b/nifi-registry-core/nifi-registry-web-ui/pom.xml
index e8c7548..4a0f4e7 100644
--- a/nifi-registry-core/nifi-registry-web-ui/pom.xml
+++ b/nifi-registry-core/nifi-registry-web-ui/pom.xml
@@ -216,6 +216,53 @@
                             </resources>
                         </configuration>
                     </execution>
+
+                    <!--
+                        Stage the final bundle of JS to be included in the .war
+                    -->
+                    <execution>
+                        <id>copy-web-ui-bundle</id>
+                        <phase>prepare-package</phase>
+                        <goals>
+                            <goal>copy-resources</goal>
+                        </goals>
+                        <configuration>
+                            <outputDirectory>${project.build.directory}/${project.build.finalName}
+                            </outputDirectory>
+                            <resources>
+                                <resource>
+                                    <directory>${frontend.working.dir}/webapp</directory>
+                                    <filtering>false</filtering>
+                                    <includes>
+                                        <include>nf-registry.bundle.*</include>
+                                    </includes>
+                                </resource>
+                            </resources>
+                        </configuration>
+                    </execution>
+                    <!--
+                        Stage the localization files to be included in the .war
+                    -->
+                    <execution>
+                        <id>copy-localization</id>
+                        <phase>prepare-package</phase>
+                        <goals>
+                            <goal>copy-resources</goal>
+                        </goals>
+                        <configuration>
+                            <outputDirectory>${project.build.directory}/${project.build.finalName}
+                            </outputDirectory>
+                            <resources>
+                                <resource>
+                                    <directory>${frontend.working.dir}/locale</directory>
+                                    <filtering>false</filtering>
+                                    <includes>
+                                        <include>*</include>
+                                    </includes>
+                                </resource>
+                            </resources>
+                        </configuration>
+                    </execution>
                 </executions>
             </plugin>
             <!--
@@ -307,58 +354,6 @@
                 </executions>
             </plugin>
             <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-resources-plugin</artifactId>
-                <executions>
-                    <!--
-                        Stage the final bundle of JS to be included in the .war
-                    -->
-                    <execution>
-                        <id>copy-web-ui-bundle</id>
-                        <phase>prepare-package</phase>
-                        <goals>
-                            <goal>copy-resources</goal>
-                        </goals>
-                        <configuration>
-                            <outputDirectory>${project.build.directory}/${project.build.finalName}
-                            </outputDirectory>
-                            <resources>
-                                <resource>
-                                    <directory>${frontend.working.dir}/webapp</directory>
-                                    <filtering>false</filtering>
-                                    <includes>
-                                        <include>nf-registry.bundle.*</include>
-                                    </includes>
-                                </resource>
-                            </resources>
-                        </configuration>
-                    </execution>
-                    <!--
-                        Stage the localization files to be included in the .war
-                    -->
-                    <execution>
-                        <id>copy-localization</id>
-                        <phase>prepare-package</phase>
-                        <goals>
-                            <goal>copy-resources</goal>
-                        </goals>
-                        <configuration>
-                            <outputDirectory>${project.build.directory}/${project.build.finalName}
-                            </outputDirectory>
-                            <resources>
-                                <resource>
-                                    <directory>${frontend.working.dir}/locale</directory>
-                                    <filtering>false</filtering>
-                                    <includes>
-                                        <include>*</include>
-                                    </includes>
-                                </resource>
-                            </resources>
-                        </configuration>
-                    </execution>
-                </executions>
-            </plugin>
-            <plugin>
                 <groupId>org.apache.rat</groupId>
                 <artifactId>apache-rat-plugin</artifactId>
                 <configuration>
diff --git a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/components/explorer/grid-list/registry/nf-registry-grid-list-viewer.html b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/components/explorer/grid-list/registry/nf-registry-grid-list-viewer.html
index 8fbd6d4..fa41287 100644
--- a/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/components/explorer/grid-list/registry/nf-registry-grid-list-viewer.html
+++ b/nifi-registry-core/nifi-registry-web-ui/src/main/webapp/components/explorer/grid-list/registry/nf-registry-grid-list-viewer.html
@@ -44,7 +44,7 @@
                             [disabled]="disabled" (expanded)="nfRegistryService.getDropletSnapshotMetadata(droplet)">
             <ng-template td-expansion-panel-label>
                 <div fxLayout="column" fxLayoutAlign="space-between start">
-                    <span class="md-title capitalize">{{droplet.name}} - {{droplet.bucketName}}</span>
+                    <span class="md-title">{{droplet.name}} - {{droplet.bucketName}}</span>
                     <span class="md-subhead">{{droplet.type}}</span>
                 </div>
             </ng-template>
diff --git a/nifi-registry-core/pom.xml b/nifi-registry-core/pom.xml
index 13c7bd2..e09a698 100644
--- a/nifi-registry-core/pom.xml
+++ b/nifi-registry-core/pom.xml
@@ -45,6 +45,7 @@
         <module>nifi-registry-docs</module>
 	<module>nifi-registry-client</module>
         <module>nifi-registry-docker</module>
+	<module>nifi-registry-bundle-utils</module>
     </modules>
 
     <dependencyManagement>
@@ -97,12 +98,7 @@
             <dependency>
                 <groupId>javax.validation</groupId>
                 <artifactId>validation-api</artifactId>
-                <version>2.0.0.Final</version>
-            </dependency>
-            <dependency>
-                <groupId>org.hibernate</groupId>
-                <artifactId>hibernate-validator</artifactId>
-                <version>6.0.2.Final</version>
+                <version>2.0.1.Final</version>
             </dependency>
             <dependency>
                 <groupId>org.glassfish</groupId>
@@ -140,6 +136,11 @@
                 <artifactId>swagger-annotations</artifactId>
                 <version>1.5.16</version>
             </dependency>
+            <dependency>
+                <groupId>org.glassfish.jersey.media</groupId>
+                <artifactId>jersey-media-multipart</artifactId>
+                <version>${jersey.version}</version>
+            </dependency>
         </dependencies>
     </dependencyManagement>
 
diff --git a/pom.xml b/pom.xml
index 57d8f22..323c703 100644
--- a/pom.xml
+++ b/pom.xml
@@ -92,11 +92,12 @@
         <org.slf4j.version>1.7.12</org.slf4j.version>
         <jetty.version>9.4.11.v20180605</jetty.version>
         <jax.rs.api.version>2.1</jax.rs.api.version>
-        <jersey.version>2.26</jersey.version>
-        <jackson.version>2.9.6</jackson.version>
-        <spring.boot.version>2.0.4.RELEASE</spring.boot.version>
-        <spring.security.version>5.0.7.RELEASE</spring.security.version>
-        <flyway.version>4.2.0</flyway.version>
+        <jersey.version>2.27</jersey.version>
+        <jackson.version>2.9.7</jackson.version>
+        <spring.boot.version>2.1.0.RELEASE</spring.boot.version>
+        <spring.security.version>5.1.1.RELEASE</spring.security.version>
+        <flyway.version>5.2.1</flyway.version>
+        <flyway.tests.version>5.1.0</flyway.tests.version>
         <swagger.ui.version>3.12.0</swagger.ui.version>
     </properties>