Repository: osbuild/osbuild-composer
Pull request #2391 (2022-03-04 16:17:59)

dnf-json codestyle cleanup
While working on other things, I did some cleanup of `dnf-json`.  Since it's a pretty important component of the service, I think it would be good to do a quick `pylint` run on it in the CI workflows.

achilleas-k: Rebased on `main`

Patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 9deeb2c7f..93531e2cc 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -23,4 +23,14 @@ test/README.md and add these additional items:
 - [ ] QE has approved reproducer/new tests and
 - [ ] Subsequent commits provide bug fixes without modifying the reproducer; CI reports PASS and
 - [ ] QE approves this PR; RHBZ status is set to `MODIFIED + Verified=Tested`
+
+Information regarding our GitLab pipeline (Schutzbot):
+
+CI will not be ran automatically if WIP label is applied or the PR is in DRAFT state, instead only
+a link will be provided to the pipeline which can then be triggered manually if desired. To run the
+CI automatically either switch the PR to ready or apply WIP+test label.
+
+Outside contributors need manual approval from one of the osbuild-composer maintainers.
+
+Schutzbot will only be triggered if all Tests jobs in GitHub workflow succeed.
 -->
diff --git a/.github/workflows/trigger-gitlab.yml b/.github/workflows/trigger-gitlab.yml
index ef7c7e8f6..899fef7ed 100644
--- a/.github/workflows/trigger-gitlab.yml
+++ b/.github/workflows/trigger-gitlab.yml
@@ -13,7 +13,7 @@ jobs:
     runs-on: ubuntu-latest
     env:
       SCHUTZBOT_SSH_KEY: ${{ secrets.SCHUTZBOT_SSH_KEY }}
-      SKIP_CI: ${{ github.event.pull_request.draft == true || contains(github.event.pull_request.labels.*.name, 'WIP') }}
+      SKIP_CI: ${{ (github.event.pull_request.draft == true || contains(github.event.pull_request.labels.*.name, 'WIP')) && !contains(github.event.pull_request.labels.*.name, 'WIP+test') }}
     steps:
       - name: Report status
         uses: haya14busa/action-workflow_run-status@v1
@@ -59,5 +59,5 @@ jobs:
           touch ~/.ssh/known_hosts
           ssh-keyscan -t rsa gitlab.com >> ~/.ssh/known_hosts
           git remote add ci git@gitlab.com:osbuild/ci/osbuild-composer.git
-          [[ "${SKIP_CI}" == true ]] && PUSH_OPTION="-o ci.skip" || PUSH_OPTION=""
+          [[ "${SKIP_CI}" == true ]] && PUSH_OPTION='-o ci.variable="SKIP_CI=true"' || PUSH_OPTION=""
           git push -f ${PUSH_OPTION} ci
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index ec8066ece..e73c6b0e7 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -40,6 +40,21 @@ init:
   script:
     - schutzbot/update_github_status.sh start
 
+.build_rules:
+  rules:
+    - if: '$CI_PIPELINE_SOURCE != "schedule" && $SKIP_CI == "false"'
+    - if: '$CI_PIPELINE_SOURCE != "schedule" && $SKIP_CI == "true"'
+      when: manual
+
+.upstream_rules:
+  rules:
+    - if: '$CI_PIPELINE_SOURCE != "schedule"'
+
+.nightly_rules:
+  rules:
+    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-9.0-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "9"'
+    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-8.6-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "8"'
+
 
 .RPM_RUNNERS_RHEL: &RPM_RUNNERS_RHEL
   RUNNER:
@@ -56,7 +71,7 @@ RPM:
   stage: rpmbuild
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
+    - !reference [.build_rules, rules]
   script:
     - sh "schutzbot/mockbuild.sh"
   after_script:
@@ -79,8 +94,7 @@ Build -tests RPM for RHEL:
   stage: rpmbuild
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-9.0-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "9"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-8.6-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "8"'
+    - !reference [.nightly_rules, rules]
   script:
     - sh "schutzbot/mockbuild.sh"
   interruptible: true
@@ -95,7 +109,7 @@ Container:
   stage: rpmbuild
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
+    - !reference [.build_rules, rules]
   script:
     - sh "schutzbot/containerbuild.sh"
   parallel:
@@ -117,8 +131,7 @@ Prepare-rhel-internal:
   stage: prepare-rhel-internal
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-9.0-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "9"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-8.6-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "8"'
+    - !reference [.nightly_rules, rules]
   script:
     - schutzbot/prepare-rhel-internal.sh
   artifacts:
@@ -140,9 +153,8 @@ Base:
   stage: test
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-9.0-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "9"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-8.6-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "8"'
+    - !reference [.upstream_rules, rules]
+    - !reference [.nightly_rules, rules]
   script:
     - schutzbot/deploy.sh
     - /usr/libexec/tests/osbuild-composer/base_tests.sh
@@ -170,9 +182,8 @@ Manifests:
   stage: test
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-9.0-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "9"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-8.6-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "8"'
+    - !reference [.upstream_rules, rules]
+    - !reference [.nightly_rules, rules]
   script:
     - schutzbot/deploy.sh
     - /usr/libexec/tests/osbuild-composer/manifest_tests.sh
@@ -186,9 +197,8 @@ Regression:
   stage: test
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-9.0-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "9"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-8.6-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "8"'
+    - !reference [.upstream_rules, rules]
+    - !reference [.nightly_rules, rules]
   script:
     - schutzbot/deploy.sh
     - /usr/libexec/tests/osbuild-composer/regression.sh
@@ -216,8 +226,7 @@ Image Tests:
   stage: test
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-9.0-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "9"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-8.6-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "8"'
+    - !reference [.nightly_rules, rules]
   script:
     - schutzbot/deploy.sh
     - /usr/libexec/tests/osbuild-composer/image_tests.sh
@@ -235,7 +244,7 @@ Test Case Generation:
   stage: test
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
+    - !reference [.upstream_rules, rules]
   script:
     - schutzbot/deploy.sh
     - /usr/libexec/tests/osbuild-composer/generation.sh
@@ -249,9 +258,8 @@ OSTree:
   stage: test
   extends: .terraform/openstack
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-9.0-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "9"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-8.6-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "8"'
+    - !reference [.upstream_rules, rules]
+    - !reference [.nightly_rules, rules]
   script:
     - schutzbot/deploy.sh
     - /usr/libexec/tests/osbuild-composer/ostree.sh
@@ -333,9 +341,8 @@ Integration:
   stage: test
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-9.0-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "9"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-8.6-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "8"'
+    - !reference [.upstream_rules, rules]
+    - !reference [.nightly_rules, rules]
   script:
     - schutzbot/deploy.sh
     - /usr/libexec/tests/osbuild-composer/${SCRIPT}
@@ -368,7 +375,7 @@ API:
   stage: test
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
+    - !reference [.upstream_rules, rules]
     # note: cloud API is not supported for on-prem installations so
     # don't run this test case for nightly trees
   script:
@@ -387,9 +394,8 @@ libvirt:
   stage: test
   extends: .terraform/openstack
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-9.0-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "9"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-8.6-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "8"'
+    - !reference [.upstream_rules, rules]
+    - !reference [.nightly_rules, rules]
   script:
     - schutzbot/deploy.sh
     - /usr/libexec/tests/osbuild-composer/libvirt.sh
@@ -397,18 +403,16 @@ libvirt:
     matrix:
       - RUNNER:
           - rhos-01/centos-stream-8-x86_64
-      - RUNNER:
           - rhos-01/rhel-8.5-ga-x86_64
           - rhos-01/rhel-8.6-nightly-x86_64
           - rhos-01/rhel-9.0-nightly-x86_64
           - rhos-01/centos-stream-9-x86_64
-        INTERNAL_NETWORK: ["true"]
 
 RHEL 9 on 8:
   stage: test
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
+    - !reference [.upstream_rules, rules]
   script:
     - schutzbot/deploy.sh
     - /usr/libexec/tests/osbuild-composer/koji.sh
@@ -417,11 +421,23 @@ RHEL 9 on 8:
     INTERNAL_NETWORK: "true"
     DISTRO_CODE: rhel-90
 
+Multi-tenancy:
+  stage: test
+  extends: .terraform
+  rules:
+    - if: '$CI_PIPELINE_SOURCE != "schedule"'
+  script:
+    - schutzbot/deploy.sh
+    - /usr/libexec/tests/osbuild-composer/multi-tenancy.sh
+  variables:
+    RUNNER: aws/rhel-8.5-ga-x86_64
+    INTERNAL_NETWORK: "true"
+
 Upgrade:
   stage: test
   extends: .terraform/openstack
   rules:
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-9\.[0-9]-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "9"'
+    - !reference [.nightly_rules, rules]
   script:
     - schutzbot/deploy.sh
     - /usr/libexec/tests/osbuild-composer/upgrade8to9.sh
@@ -434,6 +450,7 @@ Upgrade:
       - install_console.log
       - upgrade_console.log
       - leapp-preupgrade.log
+      - leapp-upgrade.log
       - leapp-report.txt
       - osbuild-upgrade8to9.log
       - osbuild-upgrade8to9.json
@@ -462,9 +479,8 @@ Installer:
   stage: test
   extends: .terraform/openstack
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-9\.[0-9]-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "9"'
-    - if: '$CI_PIPELINE_SOURCE == "schedule" && $RUNNER =~ /[\S]+rhel-8\.[0-9]-[^ga][\S]+/ && $NIGHTLY == "true" && $RHEL_MAJOR == "8"'
+    - !reference [.upstream_rules, rules]
+    - !reference [.nightly_rules, rules]
   script:
     - schutzbot/deploy.sh
     - /usr/libexec/tests/osbuild-composer/installers.sh
@@ -492,7 +508,7 @@ SonarQube:
   stage: test
   extends: .terraform
   rules:
-    - if: '$CI_PIPELINE_SOURCE != "schedule"'
+    - !reference [.upstream_rules, rules]
   script:
     - schutzbot/sonarqube.sh
   variables:
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 000000000..c39c34a7e
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,7 @@
+[FORMAT]
+max-line-length=120
+
+[MESSAGES CONTROL]
+disable=missing-module-docstring,
+    missing-class-docstring,
+    missing-function-docstring
diff --git a/cmd/osbuild-composer/composer.go b/cmd/osbuild-composer/composer.go
index ba82c79aa..f5f1b0e9a 100644
--- a/cmd/osbuild-composer/composer.go
+++ b/cmd/osbuild-composer/composer.go
@@ -19,6 +19,7 @@ import (
 
 	"github.com/osbuild/osbuild-composer/internal/auth"
 	"github.com/osbuild/osbuild-composer/internal/cloudapi"
+	v2 "github.com/osbuild/osbuild-composer/internal/cloudapi/v2"
 	"github.com/osbuild/osbuild-composer/internal/distroregistry"
 	"github.com/osbuild/osbuild-composer/internal/jobqueue"
 	"github.com/osbuild/osbuild-composer/internal/jobqueue/dbjobqueue"
@@ -53,10 +54,15 @@ func NewComposer(config *ComposerConfigFile, stateDir, cacheDir string) (*Compos
 		cacheDir: cacheDir,
 	}
 
+	workerConfig := worker.Config{
+		BasePath:             config.Worker.BasePath,
+		JWTEnabled:           config.Worker.EnableJWT,
+		TenantProviderFields: config.Worker.JWTTenantProviderFields,
+	}
+
 	var err error
-	artifactsDir := ""
 	if config.Worker.EnableArtifacts {
-		artifactsDir, err = c.ensureStateDirectory("artifacts", 0755)
+		workerConfig.ArtifactsDir, err = c.ensureStateDirectory("artifacts", 0755)
 		if err != nil {
 			return nil, err
 		}
@@ -97,12 +103,12 @@ func NewComposer(config *ComposerConfigFile, stateDir, cacheDir string) (*Compos
 		}
 	}
 
-	requestJobTimeout, err := time.ParseDuration(config.Worker.RequestJobTimeout)
+	workerConfig.RequestJobTimeout, err = time.ParseDuration(config.Worker.RequestJobTimeout)
 	if err != nil {
 		return nil, fmt.Errorf("Unable to parse request job timeout: %v", err)
 	}
 
-	c.workers = worker.NewServer(c.logger, jobs, artifactsDir, requestJobTimeout, config.Worker.BasePath)
+	c.workers = worker.NewServer(c.logger, jobs, workerConfig)
 
 	return &c, nil
 }
@@ -119,7 +125,13 @@ func (c *Composer) InitWeldr(repoPaths []string, weldrListener net.Listener,
 }
 
 func (c *Composer) InitAPI(cert, key string, enableTLS bool, enableMTLS bool, enableJWT bool, l net.Listener) error {
-	c.api = cloudapi.NewServer(c.workers, c.distros, c.config.Koji.AWS.Bucket)
+	config := v2.ServerConfig{
+		AWSBucket:            c.config.Koji.AWS.Bucket,
+		JWTEnabled:           c.config.Koji.EnableJWT,
+		TenantProviderFields: c.config.Koji.JWTTenantProviderFields,
+	}
+
+	c.api = cloudapi.NewServer(c.workers, c.distros, config)
 	c.koji = kojiapi.NewServer(c.logger, c.workers, c.rpm, c.distros)
 
 	if !enableTLS {
diff --git a/cmd/osbuild-composer/config.go b/cmd/osbuild-composer/config.go
index 865ca5b22..5bb8b28e8 100644
--- a/cmd/osbuild-composer/config.go
+++ b/cmd/osbuild-composer/config.go
@@ -19,15 +19,16 @@ type ComposerConfigFile struct {
 }
 
 type KojiAPIConfig struct {
-	AllowedDomains []string  `toml:"allowed_domains"`
-	CA             string    `toml:"ca"`
-	EnableTLS      bool      `toml:"enable_tls"`
-	EnableMTLS     bool      `toml:"enable_mtls"`
-	EnableJWT      bool      `toml:"enable_jwt"`
-	JWTKeysURLs    []string  `toml:"jwt_keys_urls"`
-	JWTKeysCA      string    `toml:"jwt_ca_file"`
-	JWTACLFile     string    `toml:"jwt_acl_file"`
-	AWS            AWSConfig `toml:"aws_config"`
+	AllowedDomains          []string  `toml:"allowed_domains"`
+	CA                      string    `toml:"ca"`
+	EnableTLS               bool      `toml:"enable_tls"`
+	EnableMTLS              bool      `toml:"enable_mtls"`
+	EnableJWT               bool      `toml:"enable_jwt"`
+	JWTKeysURLs             []string  `toml:"jwt_keys_urls"`
+	JWTKeysCA               string    `toml:"jwt_ca_file"`
+	JWTACLFile              string    `toml:"jwt_acl_file"`
+	JWTTenantProviderFields []string  `toml:"jwt_tenant_provider_fields"`
+	AWS                     AWSConfig `toml:"aws_config"`
 }
 
 type AWSConfig struct {
@@ -35,24 +36,25 @@ type AWSConfig struct {
 }
 
 type WorkerAPIConfig struct {
-	AllowedDomains    []string `toml:"allowed_domains"`
-	CA                string   `toml:"ca"`
-	RequestJobTimeout string   `toml:"request_job_timeout"`
-	BasePath          string   `toml:"base_path"`
-	EnableArtifacts   bool     `toml:"enable_artifacts"`
-	PGHost            string   `toml:"pg_host" env:"PGHOST"`
-	PGPort            string   `toml:"pg_port" env:"PGPORT"`
-	PGDatabase        string   `toml:"pg_database" env:"PGDATABASE"`
-	PGUser            string   `toml:"pg_user" env:"PGUSER"`
-	PGPassword        string   `toml:"pg_password" env:"PGPASSWORD"`
-	PGSSLMode         string   `toml:"pg_ssl_mode" env:"PGSSLMODE"`
-	PGMaxConns        int      `toml:"pg_max_conns" env:"PGMAXCONNS"`
-	EnableTLS         bool     `toml:"enable_tls"`
-	EnableMTLS        bool     `toml:"enable_mtls"`
-	EnableJWT         bool     `toml:"enable_jwt"`
-	JWTKeysURLs       []string `toml:"jwt_keys_urls"`
-	JWTKeysCA         string   `toml:"jwt_ca_file"`
-	JWTACLFile        string   `toml:"jwt_acl_file"`
+	AllowedDomains          []string `toml:"allowed_domains"`
+	CA                      string   `toml:"ca"`
+	RequestJobTimeout       string   `toml:"request_job_timeout"`
+	BasePath                string   `toml:"base_path"`
+	EnableArtifacts         bool     `toml:"enable_artifacts"`
+	PGHost                  string   `toml:"pg_host" env:"PGHOST"`
+	PGPort                  string   `toml:"pg_port" env:"PGPORT"`
+	PGDatabase              string   `toml:"pg_database" env:"PGDATABASE"`
+	PGUser                  string   `toml:"pg_user" env:"PGUSER"`
+	PGPassword              string   `toml:"pg_password" env:"PGPASSWORD"`
+	PGSSLMode               string   `toml:"pg_ssl_mode" env:"PGSSLMODE"`
+	PGMaxConns              int      `toml:"pg_max_conns" env:"PGMAXCONNS"`
+	EnableTLS               bool     `toml:"enable_tls"`
+	EnableMTLS              bool     `toml:"enable_mtls"`
+	EnableJWT               bool     `toml:"enable_jwt"`
+	JWTKeysURLs             []string `toml:"jwt_keys_urls"`
+	JWTKeysCA               string   `toml:"jwt_ca_file"`
+	JWTACLFile              string   `toml:"jwt_acl_file"`
+	JWTTenantProviderFields []string `toml:"jwt_tenant_provider_fields"`
 }
 
 type WeldrAPIConfig struct {
diff --git a/cmd/osbuild-mock-openid-provider/main.go b/cmd/osbuild-mock-openid-provider/main.go
index 2be1d7b23..f6365a847 100644
--- a/cmd/osbuild-mock-openid-provider/main.go
+++ b/cmd/osbuild-mock-openid-provider/main.go
@@ -82,13 +82,20 @@ func main() {
 			Type      string `json:"typ"`
 			ExpiresAt int64  `json:"exp"`
 			IssuedAt  int64  `json:"iat"`
+			RHOrgID   string `json:"rh-org-id"`
 			jwt.Claims
 		}
 
+		if err := r.ParseForm(); err != nil {
+			panic(err)
+		}
+
 		cc := customClaims{
 			Type:      "Bearer",
 			ExpiresAt: 0,
 			IssuedAt:  time.Now().Unix(),
+			// Use refresh_token as rh-org-id
+			RHOrgID: r.Form.Get("refresh_token"),
 		}
 		token := jwt.NewWithClaims(jwt.SigningMethodRS256, cc)
 		token.Header["kid"] = "key-id"
diff --git a/dnf-json b/dnf-json
index e56f65174..6cadae9d9 100755
--- a/dnf-json
+++ b/dnf-json
@@ -1,30 +1,29 @@
 #!/usr/bin/python3
+# pylint: disable=invalid-name
 
-import datetime
 import hashlib
 import json
-import tempfile
+import logging
 import os
+import pickle
+import shutil
 import socket
 import socketserver
-import logging
 import sys
+import tempfile
+
+from datetime import datetime, timedelta
 from http.server import BaseHTTPRequestHandler
-import pathlib
 from multiprocessing import Lock
-import queue
-import shutil
-from datetime import datetime, timedelta
 
 import dnf
 import hawkey
-import pickle
 
 # Logging setup (to systemd if available)
 formatter = logging.Formatter(
-        fmt="%(asctime)s %(name)s.%(levelname)s: %(message)s",
-        datefmt="%Y.%m.%d %H:%M:%S"
-        )
+    fmt="%(asctime)s %(name)s.%(levelname)s: %(message)s",
+    datefmt="%Y.%m.%d %H:%M:%S"
+)
 handler = logging.StreamHandler(stream=sys.stdout)
 handler.setFormatter(formatter)
 log = logging.getLogger('dnf-json')
@@ -34,6 +33,7 @@ log.setLevel(logging.INFO)
 # Synchronisation necessary for the multiprocess request handling.
 process_lock = Lock()
 
+
 class CacheState():
     """
     A CacheState keeps track of the cache folders.
@@ -43,12 +43,14 @@ class CacheState():
     Meaning that the folders having reach a certain timeout will be deleted.
     """
 
-    def __init__(self, cache_dir, cache_timeout, folder_dict={}):
+    def __init__(self, cache_dir, cache_timeout, folder_dict=None):
         """
         cache_dir the place where the state is stored
         folder_dict a dict containing the existing list of cache folders
         cache_timeout a timedelta before a cache folder can be deleted
         """
+        if folder_dict is None:
+            folder_dict = {}
         self.cache_dir = cache_dir
         self.folder_dict = folder_dict
         self.cache_timeout = cache_timeout
@@ -76,20 +78,22 @@ class CacheState():
             del self.folder_dict[folder]
             shutil.rmtree(folder)
 
-    @staticmethod
-    def load_cache_state_from_disk(cache_dir):
+    @classmethod
+    def load(cls, cache_dir):
         try:
-            with open(os.path.join(cache_dir,"cache_state.pkl"), "rb") as inp:
+            with open(os.path.join(cache_dir, "cache_state.pkl"), "rb") as inp:
                 return pickle.load(inp)
         except FileNotFoundError:
-            return CacheState(cache_dir, timedelta(hours=24))
+            return cls(cache_dir, timedelta(hours=24))
 
-    def store_on_disk(self):
+    def store(self):
         with open(os.path.join(self.cache_dir, "cache_state.pkl"), "wb") as outp:
             return pickle.dump(self, outp)
 
+
 class Solver():
 
+    # pylint: disable=too-many-arguments
     def __init__(self, repos, module_platform_id, persistdir, cachedir, arch):
         self.base = dnf.Base()
 
@@ -129,7 +133,8 @@ class Solver():
             self.base.repos.add(self._dnfrepo(repo, self.base.conf))
         self.base.fill_sack(load_system_repo=False)
 
-    def _dnfrepo(self, desc, parent_conf=None):
+    @staticmethod
+    def _dnfrepo(desc, parent_conf=None):
         """Makes a dnf.repo.Repo out of a JSON repository description"""
 
         repo = dnf.repo.Repo(desc["id"], parent_conf)
@@ -188,9 +193,9 @@ class Solver():
 
         return checksums
 
-    def _timestamp_to_rfc3339(self, timestamp):
-        d = datetime.utcfromtimestamp(timestamp)
-        return d.strftime('%Y-%m-%dT%H:%M:%SZ')
+    @staticmethod
+    def _timestamp_to_rfc3339(timestamp):
+        return datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%dT%H:%M:%SZ')
 
     def dump(self):
         packages = []
@@ -243,11 +248,15 @@ class Solver():
             "dependencies": dependencies
         }
 
+
 class DnfJsonRequestHandler(BaseHTTPRequestHandler):
     """
     Answers Http requests to depsolve or dump packages.
     """
 
+    def __init__(self, *args, **kwargs):
+        self.cache_dir = None
+        super().__init__(*args, **kwargs)
 
     def init_cache_folder_list(self, repos):
         cache_folders = []
@@ -266,7 +275,7 @@ class DnfJsonRequestHandler(BaseHTTPRequestHandler):
         return cache_folders
 
     def _send(self):
-        self.client_address=('',)
+        self.client_address = ('', 0)
 
     def response_with_dnf_error(self, kind: str, reason: str):
         self._send()
@@ -274,7 +283,7 @@ class DnfJsonRequestHandler(BaseHTTPRequestHandler):
         self.send_header("Content-Type", "application/json")
         self.end_headers()
         self.wfile.write(json.dumps({"kind": kind, "reason":
-            reason}).encode("utf-8"))
+                                     reason}).encode("utf-8"))
 
     def response_failure(self, json_object):
         self._send()
@@ -323,8 +332,8 @@ class DnfJsonRequestHandler(BaseHTTPRequestHandler):
                     self.cache_dir = arguments.get("cachedir", "")
 
                 if not self.cache_dir:
-                    self.response_failure({ "kind": "Error", "reason": "No cache dir set" })
-                cache_state = CacheState.load_cache_state_from_disk(self.cache_dir)
+                    self.response_failure({"kind": "Error", "reason": "No cache dir set"})
+                cache_state = CacheState.load(self.cache_dir)
 
                 with tempfile.TemporaryDirectory() as persistdir:
                     try:
@@ -340,11 +349,11 @@ class DnfJsonRequestHandler(BaseHTTPRequestHandler):
                             log.info("dump success")
                         elif command == "depsolve":
                             self.response_success(
-                                    solver.depsolve(
-                                        arguments["package-specs"],
-                                        arguments.get("exclude-specs", [])
-                                        )
-                                    )
+                                solver.depsolve(
+                                    arguments["package-specs"],
+                                    arguments.get("exclude-specs", [])
+                                )
+                            )
                             log.info("depsolve success")
 
                     except dnf.exceptions.MarkingErrors as e:
@@ -370,7 +379,8 @@ class DnfJsonRequestHandler(BaseHTTPRequestHandler):
                 for cache_folder in self.init_cache_folder_list(repos):
                     cache_state.update_used(cache_folder)
                 cache_state.clean_unused()
-                cache_state.store_on_disk()
+                cache_state.store()
+
 
 log.info("Starting the dnf-json server")
 
@@ -378,6 +388,7 @@ LISTEN_FDS = int(os.environ.get("LISTEN_FDS", 0))
 # set from entrypoint if differs from 3
 LISTEN_FD = int(os.environ.get("LISTEN_FD", 3))
 
+
 # The dnf-json web server has to use forks to serve the requests. Because the
 # dnf library is leaking memory in its Cpp side.
 class SystemDActivationSocketServer(socketserver.ForkingMixIn, socketserver.UnixStreamServer):
@@ -390,6 +401,7 @@ class SystemDActivationSocketServer(socketserver.ForkingMixIn, socketserver.Unix
             log.warning("More than one LISTEN_FDS")
         self.socket = socket.fromfd(LISTEN_FD, self.address_family, self.socket_type)
 
+
 # start the web server
 server = SystemDActivationSocketServer('', DnfJsonRequestHandler)
 server.serve_forever()
diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go
new file mode 100644
index 000000000..f0ef09ad0
--- /dev/null
+++ b/internal/auth/jwt.go
@@ -0,0 +1,39 @@
+package auth
+
+import (
+	"context"
+	"errors"
+
+	"github.com/golang-jwt/jwt"
+	"github.com/openshift-online/ocm-sdk-go/authentication"
+)
+
+var NoJWTError = errors.New("request doesn't contain JWT")
+var NoKeyError = errors.New("cannot find key in jwt claims")
+
+// GetFromClaims returns a value of JWT claim with the specified key
+//
+// Caller can specify multiple keys. The value of first one that exists and is
+// non-empty is returned.
+//
+// If no claim is found, NoKeyError is returned
+func GetFromClaims(ctx context.Context, keys []string) (string, error) {
+	token, err := authentication.TokenFromContext(ctx)
+	if err != nil {
+		return "", err
+	} else if token == nil {
+		return "", NoJWTError
+	}
+
+	claims := token.Claims.(jwt.MapClaims)
+	for _, f := range keys {
+		value, exists := claims[f]
+		valueStr, isString := value.(string)
+		if exists && isString && valueStr != "" {
+			return valueStr, nil
+		}
+
+	}
+
+	return "", NoKeyError
+}
diff --git a/internal/cloudapi/server.go b/internal/cloudapi/server.go
index cea9c499d..bc1f4ac77 100644
--- a/internal/cloudapi/server.go
+++ b/internal/cloudapi/server.go
@@ -13,9 +13,9 @@ type Server struct {
 	v2 *v2.Server
 }
 
-func NewServer(workers *worker.Server, distros *distroregistry.Registry, awsBucket string) *Server {
+func NewServer(workers *worker.Server, distros *distroregistry.Registry, config v2.ServerConfig) *Server {
 	server := &Server{
-		v2: v2.NewServer(workers, distros, awsBucket),
+		v2: v2.NewServer(workers, distros, config),
 	}
 	return server
 }
diff --git a/internal/cloudapi/v2/errors.go b/internal/cloudapi/v2/errors.go
index 7145bf514..238b84728 100644
--- a/internal/cloudapi/v2/errors.go
+++ b/internal/cloudapi/v2/errors.go
@@ -6,6 +6,7 @@ import (
 	"strings"
 
 	"github.com/labstack/echo/v4"
+
 	"github.com/osbuild/osbuild-composer/internal/prometheus"
 )
 
@@ -40,6 +41,7 @@ const (
 	ErrorInvalidNumberOfImageBuilds   ServiceErrorCode = 25
 	ErrorInvalidJobType               ServiceErrorCode = 26
 	ErrorInvalidOSTreeParams          ServiceErrorCode = 27
+	ErrorTenantNotFound               ServiceErrorCode = 28
 
 	// Internal errors, these are bugs
 	ErrorFailedToInitializeBlueprint              ServiceErrorCode = 1000
@@ -104,6 +106,7 @@ func getServiceErrors() serviceErrors {
 		serviceError{ErrorInvalidJobType, http.StatusNotFound, "Requested job has invalid type"},
 		serviceError{ErrorInvalidNumberOfImageBuilds, http.StatusBadRequest, "Compose request has unsupported number of image builds"},
 		serviceError{ErrorInvalidOSTreeParams, http.StatusBadRequest, "Invalid OSTree parameters or parameter combination"},
+		serviceError{ErrorTenantNotFound, http.StatusBadRequest, "Tenant not found in JWT claims"},
 
 		serviceError{ErrorFailedToInitializeBlueprint, http.StatusInternalServerError, "Failed to initialize blueprint"},
 		serviceError{ErrorFailedToGenerateManifestSeed, http.StatusInternalServerError, "Failed to generate manifest seed"},
diff --git a/internal/cloudapi/v2/v2.go b/internal/cloudapi/v2/v2.go
index b653a6ae5..7835de013 100644
--- a/internal/cloudapi/v2/v2.go
+++ b/internal/cloudapi/v2/v2.go
@@ -18,6 +18,7 @@ import (
 	"github.com/labstack/echo/v4/middleware"
 	"github.com/sirupsen/logrus"
 
+	"github.com/osbuild/osbuild-composer/internal/auth"
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/osbuild/osbuild-composer/internal/distro"
@@ -34,9 +35,15 @@ import (
 
 // Server represents the state of the cloud Server
 type Server struct {
-	workers   *worker.Server
-	distros   *distroregistry.Registry
-	awsBucket string
+	workers *worker.Server
+	distros *distroregistry.Registry
+	config  ServerConfig
+}
+
+type ServerConfig struct {
+	AWSBucket            string
+	TenantProviderFields []string
+	JWTEnabled           bool
 }
 
 type apiHandlers struct {
@@ -45,11 +52,11 @@ type apiHandlers struct {
 
 type binder struct{}
 
-func NewServer(workers *worker.Server, distros *distroregistry.Registry, bucket string) *Server {
+func NewServer(workers *worker.Server, distros *distroregistry.Registry, config ServerConfig) *Server {
 	server := &Server{
-		workers:   workers,
-		distros:   distros,
-		awsBucket: bucket,
+		workers: workers,
+		distros: distros,
+		config:  config,
 	}
 	return server
 }
@@ -161,6 +168,18 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 		return err
 	}
 
+	// channel is empty if JWT is not enabled
+	var channel string
+	if h.server.config.JWTEnabled {
+		tenant, err := auth.GetFromClaims(ctx.Request().Context(), h.server.config.TenantProviderFields)
+		if err != nil {
+			return HTTPErrorWithInternal(ErrorTenantNotFound, err)
+		}
+
+		// prefix the tenant to prevent collisions if support for specifying channels in a request is ever added
+		channel = "org-" + tenant
+	}
+
 	distribution := h.server.distros.GetDistro(request.Distribution)
 	if distribution == nil {
 		return HTTPError(ErrorUnsupportedDistribution)
@@ -345,7 +364,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 				t := target.NewAWSTarget(&target.AWSTargetOptions{
 					Filename:          imageType.Filename(),
 					Region:            awsUploadOptions.Region,
-					Bucket:            h.server.awsBucket,
+					Bucket:            h.server.config.AWSBucket,
 					Key:               key,
 					ShareWithAccounts: awsUploadOptions.ShareWithAccounts,
 				})
@@ -381,7 +400,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 				t := target.NewAWSS3Target(&target.AWSS3TargetOptions{
 					Filename: imageType.Filename(),
 					Region:   awsS3UploadOptions.Region,
-					Bucket:   h.server.awsBucket,
+					Bucket:   h.server.config.AWSBucket,
 					Key:      key,
 				})
 				t.ImageName = key
@@ -465,12 +484,12 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 
 	var id uuid.UUID
 	if request.Koji != nil {
-		id, err = enqueueKojiCompose(h.server.workers, uint64(request.Koji.TaskId), request.Koji.Server, request.Koji.Name, request.Koji.Version, request.Koji.Release, distribution, bp, manifestSeed, irs)
+		id, err = enqueueKojiCompose(h.server.workers, uint64(request.Koji.TaskId), request.Koji.Server, request.Koji.Name, request.Koji.Version, request.Koji.Release, distribution, bp, manifestSeed, irs, channel)
 		if err != nil {
 			return err
 		}
 	} else {
-		id, err = enqueueCompose(h.server.workers, distribution, bp, manifestSeed, irs)
+		id, err = enqueueCompose(h.server.workers, distribution, bp, manifestSeed, irs, channel)
 		if err != nil {
 			return err
 		}
@@ -488,7 +507,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 	})
 }
 
-func enqueueCompose(workers *worker.Server, distribution distro.Distro, bp blueprint.Blueprint, manifestSeed int64, irs []imageRequest) (uuid.UUID, error) {
+func enqueueCompose(workers *worker.Server, distribution distro.Distro, bp blueprint.Blueprint, manifestSeed int64, irs []imageRequest, channel string) (uuid.UUID, error) {
 	var id uuid.UUID
 	if len(irs) != 1 {
 		return id, HTTPError(ErrorInvalidNumberOfImageBuilds)
@@ -502,12 +521,12 @@ func enqueueCompose(workers *worker.Server, distribution distro.Distro, bp bluep
 		Arch:             ir.arch.Name(),
 		Releasever:       distribution.Releasever(),
 		PackageSetsRepos: ir.packageSetsRepositories,
-	})
+	}, channel)
 	if err != nil {
 		return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 	}
 
-	manifestJobID, err := workers.EnqueueManifestJobByID(&worker.ManifestJobByID{}, depsolveJobID)
+	manifestJobID, err := workers.EnqueueManifestJobByID(&worker.ManifestJobByID{}, depsolveJobID, channel)
 	if err != nil {
 		return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 	}
@@ -519,7 +538,7 @@ func enqueueCompose(workers *worker.Server, distribution distro.Distro, bp bluep
 			Build:   ir.imageType.BuildPipelines(),
 			Payload: ir.imageType.PayloadPipelines(),
 		},
-	}, manifestJobID)
+	}, manifestJobID, channel)
 	if err != nil {
 		return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 	}
@@ -530,7 +549,7 @@ func enqueueCompose(workers *worker.Server, distribution distro.Distro, bp bluep
 	return id, nil
 }
 
-func enqueueKojiCompose(workers *worker.Server, taskID uint64, server, name, version, release string, distribution distro.Distro, bp blueprint.Blueprint, manifestSeed int64, irs []imageRequest) (uuid.UUID, error) {
+func enqueueKojiCompose(workers *worker.Server, taskID uint64, server, name, version, release string, distribution distro.Distro, bp blueprint.Blueprint, manifestSeed int64, irs []imageRequest, channel string) (uuid.UUID, error) {
 	var id uuid.UUID
 	kojiDirectory := "osbuild-composer-koji-" + uuid.New().String()
 
@@ -539,7 +558,7 @@ func enqueueKojiCompose(workers *worker.Server, taskID uint64, server, name, ver
 		Name:    name,
 		Version: version,
 		Release: release,
-	})
+	}, channel)
 	if err != nil {
 		return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 	}
@@ -554,12 +573,12 @@ func enqueueKojiCompose(workers *worker.Server, taskID uint64, server, name, ver
 			Arch:             ir.arch.Name(),
 			Releasever:       distribution.Releasever(),
 			PackageSetsRepos: ir.packageSetsRepositories,
-		})
+		}, channel)
 		if err != nil {
 			return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 		}
 
-		manifestJobID, err := workers.EnqueueManifestJobByID(&worker.ManifestJobByID{}, depsolveJobID)
+		manifestJobID, err := workers.EnqueueManifestJobByID(&worker.ManifestJobByID{}, depsolveJobID, channel)
 		if err != nil {
 			return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 		}
@@ -581,7 +600,7 @@ func enqueueKojiCompose(workers *worker.Server, taskID uint64, server, name, ver
 			KojiServer:    server,
 			KojiDirectory: kojiDirectory,
 			KojiFilename:  kojiFilename,
-		}, manifestJobID, initID)
+		}, manifestJobID, initID, channel)
 		if err != nil {
 			return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 		}
@@ -600,7 +619,7 @@ func enqueueKojiCompose(workers *worker.Server, taskID uint64, server, name, ver
 		KojiDirectory: kojiDirectory,
 		TaskID:        taskID,
 		StartTime:     uint64(time.Now().Unix()),
-	}, initID, buildIDs)
+	}, initID, buildIDs, channel)
 	if err != nil {
 		return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 	}
diff --git a/internal/jobqueue/dbjobqueue/dbjobqueue.go b/internal/jobqueue/dbjobqueue/dbjobqueue.go
index 84f289a03..f3d129633 100644
--- a/internal/jobqueue/dbjobqueue/dbjobqueue.go
+++ b/internal/jobqueue/dbjobqueue/dbjobqueue.go
@@ -28,7 +28,7 @@ const (
 	sqlListen   = `LISTEN jobs`
 	sqlUnlisten = `UNLISTEN jobs`
 
-	sqlEnqueue = `INSERT INTO jobs(id, type, args, queued_at) VALUES ($1, $2, $3, NOW())`
+	sqlEnqueue = `INSERT INTO jobs(id, type, args, queued_at, channel) VALUES ($1, $2, $3, NOW(), $4)`
 	sqlDequeue = `
 		UPDATE jobs
 		SET token = $1, started_at = now()
@@ -37,7 +37,7 @@ const (
 		  FROM ready_jobs
 			  -- use ANY here, because "type in ()" doesn't work with bound parameters
 			  -- literal syntax for this is '{"a", "b"}': https://www.postgresql.org/docs/13/arrays.html
-		  WHERE type = ANY($2)
+		  WHERE type = ANY($2) AND channel = ANY($3)
 		  LIMIT 1
 		  FOR UPDATE SKIP LOCKED
 		)
@@ -62,7 +62,7 @@ const (
 		WHERE job_id = $1`
 
 	sqlQueryJob = `
-		SELECT type, args, started_at, finished_at, canceled
+		SELECT type, args, channel, started_at, finished_at, canceled
 		FROM jobs
 		WHERE id = $1`
 	sqlQueryJobStatus = `
@@ -141,7 +141,7 @@ func (q *DBJobQueue) Close() {
 	q.pool.Close()
 }
 
-func (q *DBJobQueue) Enqueue(jobType string, args interface{}, dependencies []uuid.UUID) (uuid.UUID, error) {
+func (q *DBJobQueue) Enqueue(jobType string, args interface{}, dependencies []uuid.UUID, channel string) (uuid.UUID, error) {
 	conn, err := q.pool.Acquire(context.Background())
 	if err != nil {
 		return uuid.Nil, fmt.Errorf("error connecting to database: %v", err)
@@ -160,7 +160,7 @@ func (q *DBJobQueue) Enqueue(jobType string, args interface{}, dependencies []uu
 	}()
 
 	id := uuid.New()
-	_, err = conn.Exec(context.Background(), sqlEnqueue, id, jobType, args)
+	_, err = conn.Exec(context.Background(), sqlEnqueue, id, jobType, args, channel)
 	if err != nil {
 		return uuid.Nil, fmt.Errorf("error enqueuing job: %v", err)
 	}
@@ -187,7 +187,7 @@ func (q *DBJobQueue) Enqueue(jobType string, args interface{}, dependencies []uu
 	return id, nil
 }
 
-func (q *DBJobQueue) Dequeue(ctx context.Context, jobTypes []string) (uuid.UUID, uuid.UUID, []uuid.UUID, string, json.RawMessage, error) {
+func (q *DBJobQueue) Dequeue(ctx context.Context, jobTypes []string, channels []string) (uuid.UUID, uuid.UUID, []uuid.UUID, string, json.RawMessage, error) {
 	// Return early if the context is already canceled.
 	if err := ctx.Err(); err != nil {
 		return uuid.Nil, uuid.Nil, nil, "", nil, jobqueue.ErrDequeueTimeout
@@ -216,7 +216,7 @@ func (q *DBJobQueue) Dequeue(ctx context.Context, jobTypes []string) (uuid.UUID,
 	var started, queued *time.Time
 	token := uuid.New()
 	for {
-		err = conn.QueryRow(ctx, sqlDequeue, token, jobTypes).Scan(&id, &token, &jobType, &args, &queued, &started)
+		err = conn.QueryRow(ctx, sqlDequeue, token, jobTypes, channels).Scan(&id, &token, &jobType, &args, &queued, &started)
 		if err == nil {
 			break
 		}
@@ -310,7 +310,7 @@ func (q *DBJobQueue) FinishJob(id uuid.UUID, result interface{}) error {
 	var started, finished *time.Time
 	var jobType string
 	canceled := false
-	err = conn.QueryRow(context.Background(), sqlQueryJob, id).Scan(&jobType, nil, &started, &finished, &canceled)
+	err = conn.QueryRow(context.Background(), sqlQueryJob, id).Scan(&jobType, nil, nil, &started, &finished, &canceled)
 	if err == pgx.ErrNoRows {
 		return jobqueue.ErrNotExist
 	}
@@ -409,14 +409,14 @@ func (q *DBJobQueue) JobStatus(id uuid.UUID) (jobType string, result json.RawMes
 }
 
 // Job returns all the parameters that define a job (everything provided during Enqueue).
-func (q *DBJobQueue) Job(id uuid.UUID) (jobType string, args json.RawMessage, dependencies []uuid.UUID, err error) {
+func (q *DBJobQueue) Job(id uuid.UUID) (jobType string, args json.RawMessage, dependencies []uuid.UUID, channel string, err error) {
 	conn, err := q.pool.Acquire(context.Background())
 	if err != nil {
 		return
 	}
 	defer conn.Release()
 
-	err = conn.QueryRow(context.Background(), sqlQueryJob, id).Scan(&jobType, &args, nil, nil, nil)
+	err = conn.QueryRow(context.Background(), sqlQueryJob, id).Scan(&jobType, &args, &channel, nil, nil, nil)
 	if err == pgx.ErrNoRows {
 		err = jobqueue.ErrNotExist
 		return
diff --git a/internal/jobqueue/dbjobqueue/schemas/002_channels.sql b/internal/jobqueue/dbjobqueue/schemas/002_channels.sql
new file mode 100644
index 000000000..5c46e88f4
--- /dev/null
+++ b/internal/jobqueue/dbjobqueue/schemas/002_channels.sql
@@ -0,0 +1,15 @@
+ALTER TABLE jobs
+ADD COLUMN channel varchar NOT NULL DEFAULT '';
+
+-- We added a column, thus we have to recreate the view.
+CREATE OR REPLACE VIEW ready_jobs AS
+SELECT *
+FROM jobs
+WHERE started_at IS NULL
+  AND canceled = FALSE
+  AND id NOT IN (
+    SELECT job_id
+    FROM job_dependencies JOIN jobs ON dependency_id = id
+    WHERE finished_at IS NULL
+)
+ORDER BY queued_at ASC
diff --git a/internal/jobqueue/fsjobqueue/fsjobqueue.go b/internal/jobqueue/fsjobqueue/fsjobqueue.go
index 949c7b256..e3c8c5797 100644
--- a/internal/jobqueue/fsjobqueue/fsjobqueue.go
+++ b/internal/jobqueue/fsjobqueue/fsjobqueue.go
@@ -67,6 +67,7 @@ type job struct {
 	Args         json.RawMessage `json:"args,omitempty"`
 	Dependencies []uuid.UUID     `json:"dependencies"`
 	Result       json.RawMessage `json:"result,omitempty"`
+	Channel      string          `json:"channel"`
 
 	QueuedAt   time.Time `json:"queued_at,omitempty"`
 	StartedAt  time.Time `json:"started_at,omitempty"`
@@ -127,7 +128,7 @@ func New(dir string) (*fsJobQueue, error) {
 	return q, nil
 }
 
-func (q *fsJobQueue) Enqueue(jobType string, args interface{}, dependencies []uuid.UUID) (uuid.UUID, error) {
+func (q *fsJobQueue) Enqueue(jobType string, args interface{}, dependencies []uuid.UUID, channel string) (uuid.UUID, error) {
 	q.mu.Lock()
 	defer q.mu.Unlock()
 
@@ -137,6 +138,7 @@ func (q *fsJobQueue) Enqueue(jobType string, args interface{}, dependencies []uu
 		Type:         jobType,
 		Dependencies: dependencies,
 		QueuedAt:     time.Now(),
+		Channel:      channel,
 	}
 
 	var err error
@@ -172,7 +174,7 @@ func (q *fsJobQueue) Enqueue(jobType string, args interface{}, dependencies []uu
 	return j.Id, nil
 }
 
-func (q *fsJobQueue) Dequeue(ctx context.Context, jobTypes []string) (uuid.UUID, uuid.UUID, []uuid.UUID, string, json.RawMessage, error) {
+func (q *fsJobQueue) Dequeue(ctx context.Context, jobTypes []string, channels []string) (uuid.UUID, uuid.UUID, []uuid.UUID, string, json.RawMessage, error) {
 	q.mu.Lock()
 	defer q.mu.Unlock()
 
@@ -191,7 +193,7 @@ func (q *fsJobQueue) Dequeue(ctx context.Context, jobTypes []string) (uuid.UUID,
 	for {
 		var found bool
 		var err error
-		j, found, err = q.dequeueSuitableJob(jobTypes)
+		j, found, err = q.dequeueSuitableJob(jobTypes, channels)
 		if err != nil {
 			return uuid.Nil, uuid.Nil, nil, "", nil, err
 		}
@@ -358,7 +360,7 @@ func (q *fsJobQueue) JobStatus(id uuid.UUID) (jobType string, result json.RawMes
 	return
 }
 
-func (q *fsJobQueue) Job(id uuid.UUID) (jobType string, args json.RawMessage, dependencies []uuid.UUID, err error) {
+func (q *fsJobQueue) Job(id uuid.UUID) (jobType string, args json.RawMessage, dependencies []uuid.UUID, channel string, err error) {
 	j, err := q.readJob(id)
 	if err != nil {
 		return
@@ -367,6 +369,7 @@ func (q *fsJobQueue) Job(id uuid.UUID) (jobType string, args json.RawMessage, de
 	jobType = j.Type
 	args = j.Args
 	dependencies = j.Dependencies
+	channel = j.Channel
 
 	return
 }
@@ -476,10 +479,11 @@ func (q *fsJobQueue) hasAllFinishedDependencies(j *job) (bool, error) {
 // - must be pending
 // - its dependencies must be finished
 // - must be of one of the type from jobTypes
+// - must be of one of the channel from channels
 //
 // If a suitable job is not found, false is returned.
 // If an error occurs during the search, it's returned.
-func (q *fsJobQueue) dequeueSuitableJob(jobTypes []string) (*job, bool, error) {
+func (q *fsJobQueue) dequeueSuitableJob(jobTypes []string, channels []string) (*job, bool, error) {
 	el := q.pending.Front()
 	for el != nil {
 		id := el.Value.(uuid.UUID)
@@ -489,7 +493,7 @@ func (q *fsJobQueue) dequeueSuitableJob(jobTypes []string) (*job, bool, error) {
 			return nil, false, err
 		}
 
-		if !jobMatchesCriteria(j, jobTypes) {
+		if !jobMatchesCriteria(j, jobTypes, channels) {
 			el = el.Next()
 			continue
 		}
@@ -527,12 +531,17 @@ func (q *fsJobQueue) removePendingJob(id uuid.UUID) {
 //
 // Criteria:
 //  - the job's type is one of the acceptedJobTypes
-func jobMatchesCriteria(j *job, acceptedJobTypes []string) bool {
-	for _, jt := range acceptedJobTypes {
-		if jt == j.Type {
-			return true
+//  - the job's channel is one of the acceptedChannels
+func jobMatchesCriteria(j *job, acceptedJobTypes []string, acceptedChannels []string) bool {
+	contains := func(slice []string, str string) bool {
+		for _, item := range slice {
+			if str == item {
+				return true
+			}
 		}
+
+		return false
 	}
 
-	return false
+	return contains(acceptedJobTypes, j.Type) && contains(acceptedChannels, j.Channel)
 }
diff --git a/internal/jobqueue/jobqueue.go b/internal/jobqueue/jobqueue.go
index 02cb98066..28ae645d2 100644
--- a/internal/jobqueue/jobqueue.go
+++ b/internal/jobqueue/jobqueue.go
@@ -32,16 +32,16 @@ type JobQueue interface {
 	// have finished.
 	//
 	// Returns the id of the new job, or an error.
-	Enqueue(jobType string, args interface{}, dependencies []uuid.UUID) (uuid.UUID, error)
+	Enqueue(jobType string, args interface{}, dependencies []uuid.UUID, channel string) (uuid.UUID, error)
 
 	// Dequeues a job, blocking until one is available.
 	//
-	// Waits until a job with a type of any of `jobTypes` is available, or `ctx` is
-	// canceled.
+	// Waits until a job with a type of any of `jobTypes` and any of `channels`
+	// is available, or `ctx` is canceled.
 	//
 	// Returns the job's id, token, dependencies, type, and arguments, or an error. Arguments
 	// can be unmarshaled to the type given in Enqueue().
-	Dequeue(ctx context.Context, jobTypes []string) (uuid.UUID, uuid.UUID, []uuid.UUID, string, json.RawMessage, error)
+	Dequeue(ctx context.Context, jobTypes []string, channels []string) (uuid.UUID, uuid.UUID, []uuid.UUID, string, json.RawMessage, error)
 
 	// Dequeues a pending job by its ID in a non-blocking way.
 	//
@@ -67,7 +67,7 @@ type JobQueue interface {
 	JobStatus(id uuid.UUID) (jobType string, result json.RawMessage, queued, started, finished time.Time, canceled bool, deps []uuid.UUID, err error)
 
 	// Job returns all the parameters that define a job (everything provided during Enqueue).
-	Job(id uuid.UUID) (jobType string, args json.RawMessage, dependencies []uuid.UUID, err error)
+	Job(id uuid.UUID) (jobType string, args json.RawMessage, dependencies []uuid.UUID, channel string, err error)
 
 	// Find job by token, this will return an error if the job hasn't been dequeued
 	IdFromToken(token uuid.UUID) (id uuid.UUID, err error)
diff --git a/internal/kojiapi/server.go b/internal/kojiapi/server.go
index e73f8866c..b49130730 100644
--- a/internal/kojiapi/server.go
+++ b/internal/kojiapi/server.go
@@ -161,7 +161,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 		Name:    request.Name,
 		Version: request.Version,
 		Release: request.Release,
-	})
+	}, "")
 	if err != nil {
 		// This is a programming error.
 		panic(err)
@@ -177,7 +177,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 			KojiServer:    request.Koji.Server,
 			KojiDirectory: kojiDirectory,
 			KojiFilename:  kojiFilenames[i],
-		}, initID)
+		}, initID, "")
 		if err != nil {
 			// This is a programming error.
 			panic(err)
@@ -194,7 +194,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 		KojiDirectory: kojiDirectory,
 		TaskID:        uint64(request.Koji.TaskId),
 		StartTime:     uint64(time.Now().Unix()),
-	}, initID, buildIDs)
+	}, initID, buildIDs, "")
 	if err != nil {
 		// This is a programming error.
 		panic(err)
diff --git a/internal/mocks/rpmmd/fixtures.go b/internal/mocks/rpmmd/fixtures.go
index 2669b1886..abe7706ec 100644
--- a/internal/mocks/rpmmd/fixtures.go
+++ b/internal/mocks/rpmmd/fixtures.go
@@ -57,7 +57,7 @@ func createBaseWorkersFixture(tmpdir string) *worker.Server {
 	if err != nil {
 		panic(err)
 	}
-	return worker.NewServer(nil, q, "", time.Duration(0), "/api/worker/v1")
+	return worker.NewServer(nil, q, worker.Config{BasePath: "/api/worker/v1"})
 }
 
 func createBaseDepsolveFixture() []rpmmd.PackageSpec {
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index 7be7d1bbd..ab08f6145 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -2337,7 +2337,7 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 				Build:   imageType.BuildPipelines(),
 				Payload: imageType.PayloadPipelines(),
 			},
-		})
+		}, "")
 		if err == nil {
 			err = api.store.PushCompose(composeID, manifest, imageType, bp, size, targets, jobId, packageSets["packages"])
 		}
diff --git a/internal/worker/api/errors.go b/internal/worker/api/errors.go
index 25b1571e3..55c9524d7 100644
--- a/internal/worker/api/errors.go
+++ b/internal/worker/api/errors.go
@@ -22,6 +22,7 @@ const (
 	ErrorNotAcceptable        ServiceErrorCode = 13
 	ErrorErrorNotFound        ServiceErrorCode = 14
 	ErrorInvalidJobType       ServiceErrorCode = 15
+	ErrorTenantNotFound       ServiceErrorCode = 16
 	// ErrorTokenNotFound ServiceErrorCode = 6
 
 	// internal errors
@@ -74,6 +75,7 @@ func getServiceErrors() serviceErrors {
 		serviceError{ErrorNotAcceptable, http.StatusNotAcceptable, "Only 'application/json' content is supported"},
 		serviceError{ErrorErrorNotFound, http.StatusNotFound, "Error with given id not found"},
 		serviceError{ErrorInvalidJobType, http.StatusBadRequest, "Requested job type cannot be dequeued"},
+		serviceError{ErrorTenantNotFound, http.StatusBadRequest, "Tenant not found in JWT claims"},
 
 		serviceError{ErrorUnspecified, http.StatusInternalServerError, "Unspecified internal error "},
 		serviceError{ErrorNotHTTPError, http.StatusInternalServerError, "Error is not an instance of HTTPError"},
diff --git a/internal/worker/server.go b/internal/worker/server.go
index 4ba38579a..b42fdb3d9 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -20,6 +20,7 @@ import (
 	"github.com/labstack/echo/v4/middleware"
 	"github.com/sirupsen/logrus"
 
+	"github.com/osbuild/osbuild-composer/internal/auth"
 	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/osbuild/osbuild-composer/internal/jobqueue"
 	"github.com/osbuild/osbuild-composer/internal/prometheus"
@@ -28,10 +29,9 @@ import (
 )
 
 type Server struct {
-	jobs              jobqueue.JobQueue
-	logger            *log.Logger
-	artifactsDir      string
-	requestJobTimeout time.Duration
+	jobs   jobqueue.JobQueue
+	logger *log.Logger
+	config Config
 }
 
 type JobStatus struct {
@@ -45,15 +45,22 @@ var ErrInvalidToken = errors.New("token does not exist")
 var ErrJobNotRunning = errors.New("job isn't running")
 var ErrInvalidJobType = errors.New("job has invalid type")
 
-func NewServer(logger *log.Logger, jobs jobqueue.JobQueue, artifactsDir string, requestJobTimeout time.Duration, basePath string) *Server {
+type Config struct {
+	ArtifactsDir         string
+	RequestJobTimeout    time.Duration
+	BasePath             string
+	JWTEnabled           bool
+	TenantProviderFields []string
+}
+
+func NewServer(logger *log.Logger, jobs jobqueue.JobQueue, config Config) *Server {
 	s := &Server{
-		jobs:              jobs,
-		logger:            logger,
-		artifactsDir:      artifactsDir,
-		requestJobTimeout: requestJobTimeout,
+		jobs:   jobs,
+		logger: logger,
+		config: config,
 	}
 
-	api.BasePath = basePath
+	api.BasePath = config.BasePath
 
 	go s.WatchHeartbeats()
 	return s
@@ -93,41 +100,41 @@ func (s *Server) WatchHeartbeats() {
 	}
 }
 
-func (s *Server) EnqueueOSBuild(arch string, job *OSBuildJob) (uuid.UUID, error) {
-	return s.enqueue("osbuild:"+arch, job, nil)
+func (s *Server) EnqueueOSBuild(arch string, job *OSBuildJob, channel string) (uuid.UUID, error) {
+	return s.enqueue("osbuild:"+arch, job, nil, channel)
 }
 
-func (s *Server) EnqueueOSBuildAsDependency(arch string, job *OSBuildJob, manifestID uuid.UUID) (uuid.UUID, error) {
-	return s.enqueue("osbuild:"+arch, job, []uuid.UUID{manifestID})
+func (s *Server) EnqueueOSBuildAsDependency(arch string, job *OSBuildJob, manifestID uuid.UUID, channel string) (uuid.UUID, error) {
+	return s.enqueue("osbuild:"+arch, job, []uuid.UUID{manifestID}, channel)
 }
 
-func (s *Server) EnqueueOSBuildKoji(arch string, job *OSBuildKojiJob, initID uuid.UUID) (uuid.UUID, error) {
-	return s.enqueue("osbuild-koji:"+arch, job, []uuid.UUID{initID})
+func (s *Server) EnqueueOSBuildKoji(arch string, job *OSBuildKojiJob, initID uuid.UUID, channel string) (uuid.UUID, error) {
+	return s.enqueue("osbuild-koji:"+arch, job, []uuid.UUID{initID}, channel)
 }
 
-func (s *Server) EnqueueOSBuildKojiAsDependency(arch string, job *OSBuildKojiJob, manifestID, initID uuid.UUID) (uuid.UUID, error) {
-	return s.enqueue("osbuild-koji:"+arch, job, []uuid.UUID{initID, manifestID})
+func (s *Server) EnqueueOSBuildKojiAsDependency(arch string, job *OSBuildKojiJob, manifestID, initID uuid.UUID, channel string) (uuid.UUID, error) {
+	return s.enqueue("osbuild-koji:"+arch, job, []uuid.UUID{initID, manifestID}, channel)
 }
 
-func (s *Server) EnqueueKojiInit(job *KojiInitJob) (uuid.UUID, error) {
-	return s.enqueue("koji-init", job, nil)
+func (s *Server) EnqueueKojiInit(job *KojiInitJob, channel string) (uuid.UUID, error) {
+	return s.enqueue("koji-init", job, nil, channel)
 }
 
-func (s *Server) EnqueueKojiFinalize(job *KojiFinalizeJob, initID uuid.UUID, buildIDs []uuid.UUID) (uuid.UUID, error) {
-	return s.enqueue("koji-finalize", job, append([]uuid.UUID{initID}, buildIDs...))
+func (s *Server) EnqueueKojiFinalize(job *KojiFinalizeJob, initID uuid.UUID, buildIDs []uuid.UUID, channel string) (uuid.UUID, error) {
+	return s.enqueue("koji-finalize", job, append([]uuid.UUID{initID}, buildIDs...), channel)
 }
 
-func (s *Server) EnqueueDepsolve(job *DepsolveJob) (uuid.UUID, error) {
-	return s.enqueue("depsolve", job, nil)
+func (s *Server) EnqueueDepsolve(job *DepsolveJob, channel string) (uuid.UUID, error) {
+	return s.enqueue("depsolve", job, nil, channel)
 }
 
-func (s *Server) EnqueueManifestJobByID(job *ManifestJobByID, parent uuid.UUID) (uuid.UUID, error) {
-	return s.enqueue("manifest-id-only", job, []uuid.UUID{parent})
+func (s *Server) EnqueueManifestJobByID(job *ManifestJobByID, parent uuid.UUID, channel string) (uuid.UUID, error) {
+	return s.enqueue("manifest-id-only", job, []uuid.UUID{parent}, channel)
 }
 
-func (s *Server) enqueue(jobType string, job interface{}, dependencies []uuid.UUID) (uuid.UUID, error) {
+func (s *Server) enqueue(jobType string, job interface{}, dependencies []uuid.UUID, channel string) (uuid.UUID, error) {
 	prometheus.EnqueueJobMetrics(jobType)
-	return s.jobs.Enqueue(jobType, job, dependencies)
+	return s.jobs.Enqueue(jobType, job, dependencies, channel)
 }
 
 func (s *Server) OSBuildJobStatus(id uuid.UUID, result *OSBuildJobResult) (*JobStatus, []uuid.UUID, error) {
@@ -289,7 +296,7 @@ func (s *Server) jobStatus(id uuid.UUID, result interface{}) (string, *JobStatus
 
 // OSBuildJob returns the parameters of an OSBuildJob
 func (s *Server) OSBuildJob(id uuid.UUID, job *OSBuildJob) error {
-	jobType, rawArgs, _, err := s.jobs.Job(id)
+	jobType, rawArgs, _, _, err := s.jobs.Job(id)
 	if err != nil {
 		return err
 	}
@@ -307,7 +314,7 @@ func (s *Server) OSBuildJob(id uuid.UUID, job *OSBuildJob) error {
 
 // OSBuildKojiJob returns the parameters of an OSBuildKojiJob
 func (s *Server) OSBuildKojiJob(id uuid.UUID, job *OSBuildKojiJob) error {
-	jobType, rawArgs, _, err := s.jobs.Job(id)
+	jobType, rawArgs, _, _, err := s.jobs.Job(id)
 	if err != nil {
 		return err
 	}
@@ -325,7 +332,7 @@ func (s *Server) OSBuildKojiJob(id uuid.UUID, job *OSBuildKojiJob) error {
 
 // JobType returns the type of the job
 func (s *Server) JobType(id uuid.UUID) (string, error) {
-	jobType, _, _, err := s.jobs.Job(id)
+	jobType, _, _, _, err := s.jobs.Job(id)
 	// the architecture is internally encdode in the job type, but hide that
 	// from this API
 	return strings.Split(jobType, ":")[0], err
@@ -344,7 +351,7 @@ func (s *Server) Cancel(id uuid.UUID) error {
 // Provides access to artifacts of a job. Returns an io.Reader for the artifact
 // and the artifact's size.
 func (s *Server) JobArtifact(id uuid.UUID, name string) (io.Reader, int64, error) {
-	if s.artifactsDir == "" {
+	if s.config.ArtifactsDir == "" {
 		return nil, 0, errors.New("Artifacts not enabled")
 	}
 
@@ -357,7 +364,7 @@ func (s *Server) JobArtifact(id uuid.UUID, name string) (io.Reader, int64, error
 		return nil, 0, fmt.Errorf("Cannot access artifacts before job is finished: %s", id)
 	}
 
-	p := path.Join(s.artifactsDir, id.String(), name)
+	p := path.Join(s.config.ArtifactsDir, id.String(), name)
 	f, err := os.Open(p)
 	if err != nil {
 		return nil, 0, fmt.Errorf("Error accessing artifact %s for job %s: %v", name, id, err)
@@ -373,7 +380,7 @@ func (s *Server) JobArtifact(id uuid.UUID, name string) (io.Reader, int64, error
 
 // Deletes all artifacts for job `id`.
 func (s *Server) DeleteArtifacts(id uuid.UUID) error {
-	if s.artifactsDir == "" {
+	if s.config.ArtifactsDir == "" {
 		return errors.New("Artifacts not enabled")
 	}
 
@@ -386,18 +393,18 @@ func (s *Server) DeleteArtifacts(id uuid.UUID) error {
 		return fmt.Errorf("Cannot delete artifacts before job is finished: %s", id)
 	}
 
-	return os.RemoveAll(path.Join(s.artifactsDir, id.String()))
+	return os.RemoveAll(path.Join(s.config.ArtifactsDir, id.String()))
 }
 
-func (s *Server) RequestJob(ctx context.Context, arch string, jobTypes []string) (uuid.UUID, uuid.UUID, string, json.RawMessage, []json.RawMessage, error) {
-	return s.requestJob(ctx, arch, jobTypes, uuid.Nil)
+func (s *Server) RequestJob(ctx context.Context, arch string, jobTypes []string, channels []string) (uuid.UUID, uuid.UUID, string, json.RawMessage, []json.RawMessage, error) {
+	return s.requestJob(ctx, arch, jobTypes, uuid.Nil, channels)
 }
 
 func (s *Server) RequestJobById(ctx context.Context, arch string, requestedJobId uuid.UUID) (uuid.UUID, uuid.UUID, string, json.RawMessage, []json.RawMessage, error) {
-	return s.requestJob(ctx, arch, []string{}, requestedJobId)
+	return s.requestJob(ctx, arch, []string{}, requestedJobId, nil)
 }
 
-func (s *Server) requestJob(ctx context.Context, arch string, jobTypes []string, requestedJobId uuid.UUID) (
+func (s *Server) requestJob(ctx context.Context, arch string, jobTypes []string, requestedJobId uuid.UUID, channels []string) (
 	jobId uuid.UUID, token uuid.UUID, jobType string, args json.RawMessage, dynamicArgs []json.RawMessage, err error) {
 	// treat osbuild jobs specially until we have found a generic way to
 	// specify dequeuing restrictions. For now, we only have one
@@ -415,8 +422,8 @@ func (s *Server) requestJob(ctx context.Context, arch string, jobTypes []string,
 
 	dequeueCtx := ctx
 	var cancel context.CancelFunc
-	if s.requestJobTimeout != 0 {
-		dequeueCtx, cancel = context.WithTimeout(ctx, s.requestJobTimeout)
+	if s.config.RequestJobTimeout != 0 {
+		dequeueCtx, cancel = context.WithTimeout(ctx, s.config.RequestJobTimeout)
 		defer cancel()
 	}
 
@@ -425,7 +432,7 @@ func (s *Server) requestJob(ctx context.Context, arch string, jobTypes []string,
 		jobId = requestedJobId
 		token, depIDs, jobType, args, err = s.jobs.DequeueByID(dequeueCtx, requestedJobId)
 	} else {
-		jobId, token, depIDs, jobType, args, err = s.jobs.Dequeue(dequeueCtx, jts)
+		jobId, token, depIDs, jobType, args, err = s.jobs.Dequeue(dequeueCtx, jts, channels)
 	}
 	if err != nil {
 		return
@@ -444,8 +451,8 @@ func (s *Server) requestJob(ctx context.Context, arch string, jobTypes []string,
 		dynamicArgs = append(dynamicArgs, result)
 	}
 
-	if s.artifactsDir != "" {
-		err = os.MkdirAll(path.Join(s.artifactsDir, "tmp", token.String()), 0700)
+	if s.config.ArtifactsDir != "" {
+		err = os.MkdirAll(path.Join(s.config.ArtifactsDir, "tmp", token.String()), 0700)
 		if err != nil {
 			return
 		}
@@ -493,8 +500,8 @@ func (s *Server) FinishJob(token uuid.UUID, result json.RawMessage) error {
 	// Move artifacts from the temporary location to the final job
 	// location. Log any errors, but do not treat them as fatal. The job is
 	// already finished.
-	if s.artifactsDir != "" {
-		err := os.Rename(path.Join(s.artifactsDir, "tmp", token.String()), path.Join(s.artifactsDir, jobId.String()))
+	if s.config.ArtifactsDir != "" {
+		err := os.Rename(path.Join(s.config.ArtifactsDir, "tmp", token.String()), path.Join(s.config.ArtifactsDir, jobId.String()))
 		if err != nil {
 			logrus.Errorf("Error moving artifacts for job %s: %v", jobId, err)
 		}
@@ -550,7 +557,19 @@ func (h *apiHandlers) RequestJob(ctx echo.Context) error {
 		return err
 	}
 
-	jobId, token, jobType, jobArgs, dynamicJobArgs, err := h.server.RequestJob(ctx.Request().Context(), body.Arch, body.Types)
+	// channel is empty if JWT is not enabled
+	var channel string
+	if h.server.config.JWTEnabled {
+		tenant, err := auth.GetFromClaims(ctx.Request().Context(), h.server.config.TenantProviderFields)
+		if err != nil {
+			return api.HTTPErrorWithInternal(api.ErrorTenantNotFound, err)
+		}
+
+		// prefix the tenant to prevent collisions if support for specifying channels in a request is ever added
+		channel = "org-" + tenant
+	}
+
+	jobId, jobToken, jobType, jobArgs, dynamicJobArgs, err := h.server.RequestJob(ctx.Request().Context(), body.Arch, body.Types, []string{channel})
 	if err != nil {
 		if err == jobqueue.ErrDequeueTimeout {
 			return ctx.JSON(http.StatusNoContent, api.ObjectReference{
@@ -580,8 +599,8 @@ func (h *apiHandlers) RequestJob(ctx echo.Context) error {
 			Id:   jobId.String(),
 			Kind: "RequestJob",
 		},
-		Location:         fmt.Sprintf("%s/jobs/%v", api.BasePath, token),
-		ArtifactLocation: fmt.Sprintf("%s/jobs/%v/artifacts/", api.BasePath, token),
+		Location:         fmt.Sprintf("%s/jobs/%v", api.BasePath, jobToken),
+		ArtifactLocation: fmt.Sprintf("%s/jobs/%v/artifacts/", api.BasePath, jobToken),
 		Type:             jobType,
 		Args:             respArgs,
 		DynamicArgs:      respDynArgs,
@@ -672,7 +691,7 @@ func (h *apiHandlers) UploadJobArtifact(ctx echo.Context, tokenstr string, name
 
 	request := ctx.Request()
 
-	if h.server.artifactsDir == "" {
+	if h.server.config.ArtifactsDir == "" {
 		_, err := io.Copy(ioutil.Discard, request.Body)
 		if err != nil {
 			return api.HTTPErrorWithInternal(api.ErrorDiscardingArtifact, err)
@@ -680,7 +699,7 @@ func (h *apiHandlers) UploadJobArtifact(ctx echo.Context, tokenstr string, name
 		return ctx.NoContent(http.StatusOK)
 	}
 
-	f, err := os.Create(path.Join(h.server.artifactsDir, "tmp", token.String(), name))
+	f, err := os.Create(path.Join(h.server.config.ArtifactsDir, "tmp", token.String(), name))
 	if err != nil {
 		return api.HTTPErrorWithInternal(api.ErrorDiscardingArtifact, err)
 	}
diff --git a/osbuild-composer.spec b/osbuild-composer.spec
index 89b75432c..3fade851a 100644
--- a/osbuild-composer.spec
+++ b/osbuild-composer.spec
@@ -419,10 +419,7 @@ Requires:   httpd
 Requires:   mod_ssl
 Requires:   openssl
 Requires:   firewalld
-# see https://bugzilla.redhat.com/show_bug.cgi?id=1986333
-%if 0%{?rhel} && 0%{?rhel} != 9
 Requires:   podman-plugins
-%endif
 Requires:   dnf-plugins-core
 Requires:   skopeo
 %if 0%{?fedora}
diff --git a/schutzbot/prepare-rhel-internal.sh b/schutzbot/prepare-rhel-internal.sh
index 9099bcb29..b8ddd4cf0 100755
--- a/schutzbot/prepare-rhel-internal.sh
+++ b/schutzbot/prepare-rhel-internal.sh
@@ -52,12 +52,12 @@ for ARCH in $ALL_ARCHES; do
         {
             "name": "baseos-${ARCH}",
             "baseurl": "${COMPOSE_URL}/compose/BaseOS/${ARCH}/os/",
-            "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n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\nvt61kpkXoRGxjTlc6h+69aidSg==\n=ls8J\n-----END PGP PUBLIC KEY BLOCK-----\n"
+            "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFsy23UBEACUKSphFEIEvNpy68VeW4Dt6qv+mU6am9a2AAl10JANLj1oqWX+\noYk3en1S6cVe2qehSL5DGVa3HMUZkP3dtbD4SgzXzxPodebPcr4+0QNWigkUisri\nXGL5SCEcOP30zDhZvg+4mpO2jMi7Kc1DLPzBBkgppcX91wa0L1pQzBcvYMPyV/Dh\nKbQHR75WdkP6OA2JXdfC94nxYq+2e0iPqC1hCP3Elh+YnSkOkrawDPmoB1g4+ft/\nxsiVGVy/W0ekXmgvYEHt6si6Y8NwXgnTMqxeSXQ9YUgVIbTpsxHQKGy76T5lMlWX\n4LCOmEVomBJg1SqF6yi9Vu8TeNThaDqT4/DddYInd0OO69s0kGIXalVgGYiW2HOD\nx2q5R1VGCoJxXomz+EbOXY+HpKPOHAjU0DB9MxbU3S248LQ69nIB5uxysy0PSco1\nsdZ8sxRNQ9Dw6on0Nowx5m6Thefzs5iK3dnPGBqHTT43DHbnWc2scjQFG+eZhe98\nEll/kb6vpBoY4bG9/wCG9qu7jj9Z+BceCNKeHllbezVLCU/Hswivr7h2dnaEFvPD\nO4GqiWiwOF06XaBMVgxA8p2HRw0KtXqOpZk+o+sUvdPjsBw42BB96A1yFX4jgFNA\nPyZYnEUdP6OOv9HSjnl7k/iEkvHq/jGYMMojixlvXpGXhnt5jNyc4GSUJQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5KSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjkEEwECACMFAlsy23UCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIX\ngAAKCRD3b2bD1AgnknqOD/9fB2ASuG2aJIiap4kK58R+RmOVM4qgclAnaG57+vjI\nnKvyfV3NH/keplGNRxwqHekfPCqvkpABwhdGEXIE8ILqnPewIMr6PZNZWNJynZ9i\neSMzVuCG7jDoGyQ5/6B0f6xeBtTeBDiRl7+Alehet1twuGL1BJUYG0QuLgcEzkaE\n/gkuumeVcazLzz7L12D22nMk66GxmgXfqS5zcbqOAuZwaA6VgSEgFdV2X2JU79zS\nBQJXv7NKc+nDXFG7M7EHjY3Rma3HXkDbkT8bzh9tJV7Z7TlpT829pStWQyoxKCVq\nsEX8WsSapTKA3P9YkYCwLShgZu4HKRFvHMaIasSIZWzLu+RZH/4yyHOhj0QB7XMY\neHQ6fGSbtJ+K6SrpHOOsKQNAJ0hVbSrnA1cr5+2SDfel1RfYt0W9FA6DoH/S5gAR\ndzT1u44QVwwp3U+eFpHphFy//uzxNMtCjjdkpzhYYhOCLNkDrlRPb+bcoL/6ePSr\n016PA7eEnuC305YU1Ml2WcCn7wQV8x90o33klJmEkWtXh3X39vYtI4nCPIvZn1eP\nVy+F+wWt4vN2b8oOdlzc2paOembbCo2B+Wapv5Y9peBvlbsDSgqtJABfK8KQq/jK\nYl3h5elIa1I3uNfczeHOnf1enLOUOlq630yeM/yHizz99G1g+z/guMh5+x/OHraW\niA==\n=+Gxh\n-----END PGP PUBLIC KEY BLOCK-----\n"
         },
         {
             "name": "appstream-${ARCH}",
             "baseurl": "${COMPOSE_URL}/compose/AppStream/${ARCH}/os/",
-            "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n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\nvt61kpkXoRGxjTlc6h+69aidSg==\n=ls8J\n-----END PGP PUBLIC KEY BLOCK-----\n"
+            "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFsy23UBEACUKSphFEIEvNpy68VeW4Dt6qv+mU6am9a2AAl10JANLj1oqWX+\noYk3en1S6cVe2qehSL5DGVa3HMUZkP3dtbD4SgzXzxPodebPcr4+0QNWigkUisri\nXGL5SCEcOP30zDhZvg+4mpO2jMi7Kc1DLPzBBkgppcX91wa0L1pQzBcvYMPyV/Dh\nKbQHR75WdkP6OA2JXdfC94nxYq+2e0iPqC1hCP3Elh+YnSkOkrawDPmoB1g4+ft/\nxsiVGVy/W0ekXmgvYEHt6si6Y8NwXgnTMqxeSXQ9YUgVIbTpsxHQKGy76T5lMlWX\n4LCOmEVomBJg1SqF6yi9Vu8TeNThaDqT4/DddYInd0OO69s0kGIXalVgGYiW2HOD\nx2q5R1VGCoJxXomz+EbOXY+HpKPOHAjU0DB9MxbU3S248LQ69nIB5uxysy0PSco1\nsdZ8sxRNQ9Dw6on0Nowx5m6Thefzs5iK3dnPGBqHTT43DHbnWc2scjQFG+eZhe98\nEll/kb6vpBoY4bG9/wCG9qu7jj9Z+BceCNKeHllbezVLCU/Hswivr7h2dnaEFvPD\nO4GqiWiwOF06XaBMVgxA8p2HRw0KtXqOpZk+o+sUvdPjsBw42BB96A1yFX4jgFNA\nPyZYnEUdP6OOv9HSjnl7k/iEkvHq/jGYMMojixlvXpGXhnt5jNyc4GSUJQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5KSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjkEEwECACMFAlsy23UCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIX\ngAAKCRD3b2bD1AgnknqOD/9fB2ASuG2aJIiap4kK58R+RmOVM4qgclAnaG57+vjI\nnKvyfV3NH/keplGNRxwqHekfPCqvkpABwhdGEXIE8ILqnPewIMr6PZNZWNJynZ9i\neSMzVuCG7jDoGyQ5/6B0f6xeBtTeBDiRl7+Alehet1twuGL1BJUYG0QuLgcEzkaE\n/gkuumeVcazLzz7L12D22nMk66GxmgXfqS5zcbqOAuZwaA6VgSEgFdV2X2JU79zS\nBQJXv7NKc+nDXFG7M7EHjY3Rma3HXkDbkT8bzh9tJV7Z7TlpT829pStWQyoxKCVq\nsEX8WsSapTKA3P9YkYCwLShgZu4HKRFvHMaIasSIZWzLu+RZH/4yyHOhj0QB7XMY\neHQ6fGSbtJ+K6SrpHOOsKQNAJ0hVbSrnA1cr5+2SDfel1RfYt0W9FA6DoH/S5gAR\ndzT1u44QVwwp3U+eFpHphFy//uzxNMtCjjdkpzhYYhOCLNkDrlRPb+bcoL/6ePSr\n016PA7eEnuC305YU1Ml2WcCn7wQV8x90o33klJmEkWtXh3X39vYtI4nCPIvZn1eP\nVy+F+wWt4vN2b8oOdlzc2paOembbCo2B+Wapv5Y9peBvlbsDSgqtJABfK8KQq/jK\nYl3h5elIa1I3uNfczeHOnf1enLOUOlq630yeM/yHizz99G1g+z/guMh5+x/OHraW\niA==\n=+Gxh\n-----END PGP PUBLIC KEY BLOCK-----\n"
         }
 EOF
 
@@ -67,7 +67,7 @@ EOF
         ,{
             "name": "rt-${ARCH}",
             "baseurl": "${COMPOSE_URL}/compose/RT/${ARCH}/os/",
-            "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n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\nvt61kpkXoRGxjTlc6h+69aidSg==\n=ls8J\n-----END PGP PUBLIC KEY BLOCK-----\n"
+            "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFsy23UBEACUKSphFEIEvNpy68VeW4Dt6qv+mU6am9a2AAl10JANLj1oqWX+\noYk3en1S6cVe2qehSL5DGVa3HMUZkP3dtbD4SgzXzxPodebPcr4+0QNWigkUisri\nXGL5SCEcOP30zDhZvg+4mpO2jMi7Kc1DLPzBBkgppcX91wa0L1pQzBcvYMPyV/Dh\nKbQHR75WdkP6OA2JXdfC94nxYq+2e0iPqC1hCP3Elh+YnSkOkrawDPmoB1g4+ft/\nxsiVGVy/W0ekXmgvYEHt6si6Y8NwXgnTMqxeSXQ9YUgVIbTpsxHQKGy76T5lMlWX\n4LCOmEVomBJg1SqF6yi9Vu8TeNThaDqT4/DddYInd0OO69s0kGIXalVgGYiW2HOD\nx2q5R1VGCoJxXomz+EbOXY+HpKPOHAjU0DB9MxbU3S248LQ69nIB5uxysy0PSco1\nsdZ8sxRNQ9Dw6on0Nowx5m6Thefzs5iK3dnPGBqHTT43DHbnWc2scjQFG+eZhe98\nEll/kb6vpBoY4bG9/wCG9qu7jj9Z+BceCNKeHllbezVLCU/Hswivr7h2dnaEFvPD\nO4GqiWiwOF06XaBMVgxA8p2HRw0KtXqOpZk+o+sUvdPjsBw42BB96A1yFX4jgFNA\nPyZYnEUdP6OOv9HSjnl7k/iEkvHq/jGYMMojixlvXpGXhnt5jNyc4GSUJQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5KSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjkEEwECACMFAlsy23UCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIX\ngAAKCRD3b2bD1AgnknqOD/9fB2ASuG2aJIiap4kK58R+RmOVM4qgclAnaG57+vjI\nnKvyfV3NH/keplGNRxwqHekfPCqvkpABwhdGEXIE8ILqnPewIMr6PZNZWNJynZ9i\neSMzVuCG7jDoGyQ5/6B0f6xeBtTeBDiRl7+Alehet1twuGL1BJUYG0QuLgcEzkaE\n/gkuumeVcazLzz7L12D22nMk66GxmgXfqS5zcbqOAuZwaA6VgSEgFdV2X2JU79zS\nBQJXv7NKc+nDXFG7M7EHjY3Rma3HXkDbkT8bzh9tJV7Z7TlpT829pStWQyoxKCVq\nsEX8WsSapTKA3P9YkYCwLShgZu4HKRFvHMaIasSIZWzLu+RZH/4yyHOhj0QB7XMY\neHQ6fGSbtJ+K6SrpHOOsKQNAJ0hVbSrnA1cr5+2SDfel1RfYt0W9FA6DoH/S5gAR\ndzT1u44QVwwp3U+eFpHphFy//uzxNMtCjjdkpzhYYhOCLNkDrlRPb+bcoL/6ePSr\n016PA7eEnuC305YU1Ml2WcCn7wQV8x90o33klJmEkWtXh3X39vYtI4nCPIvZn1eP\nVy+F+wWt4vN2b8oOdlzc2paOembbCo2B+Wapv5Y9peBvlbsDSgqtJABfK8KQq/jK\nYl3h5elIa1I3uNfczeHOnf1enLOUOlq630yeM/yHizz99G1g+z/guMh5+x/OHraW\niA==\n=+Gxh\n-----END PGP PUBLIC KEY BLOCK-----\n"
         }
 EOF
     fi
diff --git a/templates/composer.yml b/templates/composer.yml
index d3927add9..07288cf92 100644
--- a/templates/composer.yml
+++ b/templates/composer.yml
@@ -211,6 +211,7 @@ objects:
       enable_jwt = true
       jwt_keys_urls = ["${RH_SSO_BASE_URL}/protocol/openid-connect/certs", "${MAS_SSO_BASE_URL}/protocol/openid-connect/certs"]
       jwt_acl_file = "${COMPOSER_CONFIG_DIR}/acl.yml"
+      jwt_tenant_provider_fields = ["rh-org-id", "account_id"]
       [koji.aws_config]
       bucket = "${COMPOSER_CONFIG_BUCKET_NAME}"
       [worker]
@@ -222,6 +223,7 @@ objects:
       enable_jwt = true
       jwt_keys_urls = ["${RH_SSO_BASE_URL}/protocol/openid-connect/certs", "${MAS_SSO_BASE_URL}/protocol/openid-connect/certs"]
       jwt_acl_file = "${COMPOSER_CONFIG_DIR}/acl.yml"
+      jwt_tenant_provider_fields = ["rh-org-id", "account_id"]
 
 - apiVersion: batch/v1
   kind: CronJob
diff --git a/templates/packer/ansible/roles/common/files/worker-initialization-scripts/worker_external_creds.sh b/templates/packer/ansible/roles/common/files/worker-initialization-scripts/worker_external_creds.sh
index 949f84c54..15370c355 100755
--- a/templates/packer/ansible/roles/common/files/worker-initialization-scripts/worker_external_creds.sh
+++ b/templates/packer/ansible/roles/common/files/worker-initialization-scripts/worker_external_creds.sh
@@ -1,5 +1,5 @@
 #!/bin/bash
-set -euo pipefail
+set -eo pipefail
 source /tmp/cloud_init_vars
 
 echo "Deploy cloud credentials for workers."


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 6945c7cc4..2cdab277b 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -12,7 +12,7 @@ on:
 
 jobs:
   lint:
-    name: "🛃 Unit tests"
+    name: "⌨ Lint"
     runs-on: ubuntu-20.04
     steps:
 
@@ -37,8 +37,22 @@ jobs:
       - name: Send coverage to codecov.io
         run: bash <(curl -s https://codecov.io/bash)
 
+  python-lint:
+    name: "🐍 Lint (dnf-json)"
+    runs-on: ubuntu-latest
+    container:
+      image: docker.io/library/fedora:latest
+    steps:
+    - uses: actions/checkout@v2.4.0
+    - name: Install dependencies
+      run: |
+        dnf -y install python3-pylint
+    - name: Analysing the code with pylint
+      run: |
+        python3 -m pylint dnf-json
+
   unit-tests:
-    name: "⌨ Lint"
+    name: "🛃 Unit tests"
     runs-on: ubuntu-20.04
     steps:
 
diff --git a/cmd/osbuild-composer-dbjobqueue-tests/main_test.go b/cmd/osbuild-composer-dbjobqueue-tests/main_test.go
index 1134fc3c8..be3f20ed2 100644
--- a/cmd/osbuild-composer-dbjobqueue-tests/main_test.go
+++ b/cmd/osbuild-composer-dbjobqueue-tests/main_test.go
@@ -82,19 +82,19 @@ func testJobsUptoByType(t *testing.T, q *dbjobqueue.DBJobQueue) {
 	date85 := time.Date(1985, time.January, 1, 0, 0, 0, 0, time.UTC)
 	date90 := time.Date(1990, time.January, 1, 0, 0, 0, 0, time.UTC)
 
-	id80, err := q.Enqueue("octopus", nil, nil)
+	id80, err := q.Enqueue("octopus", nil, nil, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id80)
-	_,_,_,_,_, err = q.Dequeue(context.Background(), []string{"octopus"})
+	_, _, _, _, _, err = q.Dequeue(context.Background(), []string{"octopus"}, []string{""})
 	require.NoError(t, err)
 	err = q.FinishJob(id80, nil)
 	require.NoError(t, err)
 	setFinishedAt(t, q, id80, date80)
 
-	id85, err := q.Enqueue("octopus", nil, nil)
+	id85, err := q.Enqueue("octopus", nil, nil, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id85)
-	_,_,_,_,_, err = q.Dequeue(context.Background(), []string{"octopus"})
+	_, _, _, _, _, err = q.Dequeue(context.Background(), []string{"octopus"}, []string{""})
 	require.NoError(t, err)
 	err = q.FinishJob(id85, nil)
 	require.NoError(t, err)
@@ -111,65 +111,65 @@ func testJobsUptoByType(t *testing.T, q *dbjobqueue.DBJobQueue) {
 
 func testDeleteJobAndDependencies(t *testing.T, q *dbjobqueue.DBJobQueue) {
 	// id1 -> id2 -> id3
-	id1, err := q.Enqueue("octopus", nil, nil)
+	id1, err := q.Enqueue("octopus", nil, nil, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id1)
-	id2, err := q.Enqueue("octopus", nil, []uuid.UUID{id1})
+	id2, err := q.Enqueue("octopus", nil, []uuid.UUID{id1}, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id2)
-	id3, err := q.Enqueue("octopus", nil, []uuid.UUID{id2})
+	id3, err := q.Enqueue("octopus", nil, []uuid.UUID{id2}, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id3)
 
-	c1, err := q.Enqueue("octopus", nil, nil)
+	c1, err := q.Enqueue("octopus", nil, nil, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, c1)
-	c2, err := q.Enqueue("octopus", nil, []uuid.UUID{c1})
+	c2, err := q.Enqueue("octopus", nil, []uuid.UUID{c1}, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, c2)
-	c3, err := q.Enqueue("octopus", nil, []uuid.UUID{c2})
+	c3, err := q.Enqueue("octopus", nil, []uuid.UUID{c2}, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, c3)
 	controls := []uuid.UUID{c1, c2, c3}
 
-	_,_,_, err = q.Job(c1)
+	_, _, _, _, err = q.Job(c1)
 	require.NoError(t, err)
 
 	require.NoError(t, q.DeleteJobIncludingDependencies(id3))
 	for _, id := range []uuid.UUID{id1, id2, id3} {
-		_,_,_, err = q.Job(id)
+		_, _, _, _, err = q.Job(id)
 		require.ErrorIs(t, err, jobqueue.ErrNotExist)
 	}
 
 	// controls should still exist
 	for _, c := range controls {
-		_,_,_, err = q.Job(c)
+		_, _, _, _, err = q.Job(c)
 		require.NoError(t, err)
 	}
 
 	// id1 -> id2 -> id4 && id3 -> id4
-	id1, err = q.Enqueue("octopus", nil, nil)
+	id1, err = q.Enqueue("octopus", nil, nil, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id1)
-	id2, err = q.Enqueue("octopus", nil, []uuid.UUID{id1})
+	id2, err = q.Enqueue("octopus", nil, []uuid.UUID{id1}, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id2)
-	id3, err = q.Enqueue("octopus", nil, nil)
+	id3, err = q.Enqueue("octopus", nil, nil, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id3)
-	id4, err := q.Enqueue("octopus", nil, []uuid.UUID{id2, id3})
+	id4, err := q.Enqueue("octopus", nil, []uuid.UUID{id2, id3}, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id4)
 
 	require.NoError(t, q.DeleteJobIncludingDependencies(id4))
 	for _, id := range []uuid.UUID{id1, id2, id3, id4} {
-		_,_,_, err = q.Job(id)
+		_, _, _, _, err = q.Job(id)
 		require.ErrorIs(t, err, jobqueue.ErrNotExist)
 	}
 
 	// controls should still exist
 	for _, c := range controls {
-		_,_,_, err = q.Job(c)
+		_, _, _, _, err = q.Job(c)
 		require.NoError(t, err)
 	}
 
@@ -177,45 +177,44 @@ func testDeleteJobAndDependencies(t *testing.T, q *dbjobqueue.DBJobQueue) {
 	// situation as it does not occur in the service.  This should be changed once we allow
 	// multiple build job per depsolve job, and the depsolve job should only be removed once all
 	// the build jobs have been dealt with.
-	id1, err = q.Enqueue("octopus", nil, nil)
+	id1, err = q.Enqueue("octopus", nil, nil, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id1)
-	id2a, err := q.Enqueue("octopus", nil, []uuid.UUID{id1})
+	id2a, err := q.Enqueue("octopus", nil, []uuid.UUID{id1}, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id2a)
-	id2b, err := q.Enqueue("octopus", nil, []uuid.UUID{id1})
+	id2b, err := q.Enqueue("octopus", nil, []uuid.UUID{id1}, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id2b)
-	id3, err = q.Enqueue("octopus", nil, []uuid.UUID{id2a})
+	id3, err = q.Enqueue("octopus", nil, []uuid.UUID{id2a}, "")
 	require.NoError(t, err)
 	require.NotEqual(t, uuid.Nil, id3)
 
 	require.NoError(t, q.DeleteJobIncludingDependencies(id3))
 	for _, id := range []uuid.UUID{id1, id2a, id3} {
-		_,_,_, err = q.Job(id)
+		_, _, _, _, err = q.Job(id)
 		require.ErrorIs(t, err, jobqueue.ErrNotExist)
 	}
 
 	// id2b still exists
-	_,_,_, err = q.Job(id2b)
+	_, _, _, _, err = q.Job(id2b)
 	require.NoError(t, err)
 
 	// id2b can still be deleted with it's dependencies missing
 	require.NoError(t, q.DeleteJobIncludingDependencies(id2b))
-	_,_,_, err = q.Job(id2b)
+	_, _, _, _, err = q.Job(id2b)
 	require.ErrorIs(t, err, jobqueue.ErrNotExist)
 
 	// controls should still exist
 	for _, c := range controls {
-		_,_,_, err = q.Job(c)
+		_, _, _, _, err = q.Job(c)
 		require.NoError(t, err)
 	}
 
 	require.NoError(t, q.DeleteJobIncludingDependencies(uuid.Nil))
 	// controls should still exist
 	for _, c := range controls {
-		_,_,_, err = q.Job(c)
+		_, _, _, _, err = q.Job(c)
 		require.NoError(t, err)
 	}
 }
-
diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go
new file mode 100644
index 000000000..bedda2635
--- /dev/null
+++ b/internal/auth/jwt_test.go
@@ -0,0 +1,66 @@
+package auth_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/golang-jwt/jwt"
+	"github.com/openshift-online/ocm-sdk-go/authentication"
+	"github.com/stretchr/testify/require"
+
+	"github.com/osbuild/osbuild-composer/internal/auth"
+)
+
+func TestChannelFromContext(t *testing.T) {
+	// tokens generated by https://jwt.io/ and signed by "osbuild"
+	tests := []struct {
+		name           string
+		token          string
+		value          string
+		expectedFields []string
+		err            error
+	}{
+		{
+			name:           "rh-org-id=42",
+			token:          "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyaC1vcmctaWQiOiI0MiJ9.D5EwgcrlCPcPamM9hL63bWI7xxr0YVWxsJ4f80toQv4",
+			value:          "42",
+			expectedFields: []string{"rh-org-id"},
+			err:            nil,
+		},
+		{
+			name:           "no rh-org-id",
+			token:          "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.AmoXfoVMgoq4H-XsD7lTGgY6QJCW1914aYlmGnj7wtY",
+			value:          "",
+			expectedFields: []string{"rh-org-id"},
+			err:            auth.NoKeyError,
+		},
+		{
+			name:           "no rh-org-id but account_id=123",
+			token:          "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50X2lkIjoiMTIzIn0.fng__koaJeF3Ef6E8kFOKCMm6U2MTwyFQ4s0G4LBUss",
+			value:          "123",
+			expectedFields: []string{"rh-org-id", "account_id"},
+			err:            nil,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			claims := jwt.MapClaims{}
+			token, err := jwt.ParseWithClaims(tt.token, claims, func(token *jwt.Token) (interface{}, error) {
+				return []byte("osbuild"), nil
+			})
+			require.NoError(t, err)
+
+			ctx := authentication.ContextWithToken(context.Background(), token)
+			channel, err := auth.GetFromClaims(ctx, tt.expectedFields)
+			require.Equal(t, tt.err, err)
+			require.Equal(t, tt.value, channel)
+
+		})
+	}
+
+	t.Run("no jwt token in context", func(t *testing.T) {
+		channel, err := auth.GetFromClaims(context.Background(), []string{"osbuild!"})
+		require.ErrorIs(t, err, auth.NoJWTError)
+		require.Equal(t, "", channel)
+	})
+}
diff --git a/internal/cloudapi/v2/v2_koji_test.go b/internal/cloudapi/v2/v2_koji_test.go
index 24d4d46ef..069693d81 100644
--- a/internal/cloudapi/v2/v2_koji_test.go
+++ b/internal/cloudapi/v2/v2_koji_test.go
@@ -12,6 +12,8 @@ import (
 	"time"
 
 	"github.com/google/uuid"
+	"github.com/stretchr/testify/require"
+
 	v2 "github.com/osbuild/osbuild-composer/internal/cloudapi/v2"
 	"github.com/osbuild/osbuild-composer/internal/distro/test_distro"
 	"github.com/osbuild/osbuild-composer/internal/kojiapi/api"
@@ -19,7 +21,6 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/test"
 	"github.com/osbuild/osbuild-composer/internal/worker"
 	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
-	"github.com/stretchr/testify/require"
 )
 
 type jobResult struct {
@@ -30,7 +31,7 @@ func TestKojiCompose(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	kojiServer, workerServer, cancel := newV2Server(t, dir)
+	kojiServer, workerServer, _, cancel := newV2Server(t, dir, []string{""}, false)
 	handler := kojiServer.Handler("/api/image-builder-composer/v2")
 	workerHandler := workerServer.Handler()
 	defer cancel()
@@ -368,7 +369,7 @@ func TestKojiCompose(t *testing.T) {
 			c.composeReplyCode, c.composeReply, "id", "operation_id")
 
 		// handle koji-init
-		_, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"koji-init"})
+		_, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"koji-init"}, []string{""})
 		require.NoError(t, err)
 		require.Equal(t, "koji-init", jobType)
 
@@ -386,7 +387,7 @@ func TestKojiCompose(t *testing.T) {
 			fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
 
 		// handle osbuild-koji #1
-		_, token, jobType, rawJob, _, err = workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild-koji"})
+		_, token, jobType, rawJob, _, err = workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild-koji"}, []string{""})
 		require.NoError(t, err)
 		require.Equal(t, "osbuild-koji", jobType)
 
@@ -403,7 +404,7 @@ func TestKojiCompose(t *testing.T) {
 			fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
 
 		// handle osbuild-koji #2
-		_, token, jobType, rawJob, _, err = workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild-koji"})
+		_, token, jobType, rawJob, _, err = workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild-koji"}, []string{""})
 		require.NoError(t, err)
 		require.Equal(t, "osbuild-koji", jobType)
 
@@ -427,7 +428,7 @@ func TestKojiCompose(t *testing.T) {
 			fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
 
 		// handle koji-finalize
-		finalizeID, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"koji-finalize"})
+		finalizeID, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"koji-finalize"}, []string{""})
 		require.NoError(t, err)
 		require.Equal(t, "koji-finalize", jobType)
 
@@ -463,7 +464,7 @@ func TestKojiRequest(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	server, _, cancel := newV2Server(t, dir)
+	server, _, _, cancel := newV2Server(t, dir, []string{""}, false)
 	handler := server.Handler("/api/image-builder-composer/v2")
 	defer cancel()
 
@@ -495,7 +496,7 @@ func TestKojiJobTypeValidation(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	server, workers, cancel := newV2Server(t, dir)
+	server, workers, _, cancel := newV2Server(t, dir, []string{""}, false)
 	handler := server.Handler("/api/image-builder-composer/v2")
 	defer cancel()
 
@@ -510,7 +511,7 @@ func TestKojiJobTypeValidation(t *testing.T) {
 		Version: "42",
 		Release: "1",
 	}
-	initID, err := workers.EnqueueKojiInit(&initJob)
+	initID, err := workers.EnqueueKojiInit(&initJob, "")
 	require.NoError(t, err)
 
 	buildJobs := make([]worker.OSBuildKojiJob, nImages)
@@ -524,7 +525,7 @@ func TestKojiJobTypeValidation(t *testing.T) {
 			KojiDirectory: "koji-server-test-dir",
 			KojiFilename:  fname,
 		}
-		buildID, err := workers.EnqueueOSBuildKoji(fmt.Sprintf("fake-arch-%d", idx), &buildJob, initID)
+		buildID, err := workers.EnqueueOSBuildKoji(fmt.Sprintf("fake-arch-%d", idx), &buildJob, initID, "")
 		require.NoError(t, err)
 
 		buildJobs[idx] = buildJob
@@ -542,7 +543,7 @@ func TestKojiJobTypeValidation(t *testing.T) {
 		TaskID:        0,
 		StartTime:     uint64(time.Now().Unix()),
 	}
-	finalizeID, err := workers.EnqueueKojiFinalize(&finalizeJob, initID, buildJobIDs)
+	finalizeID, err := workers.EnqueueKojiFinalize(&finalizeJob, initID, buildJobIDs, "")
 	require.NoError(t, err)
 
 	// ----- Jobs queued - Test API endpoints (status, manifests, logs) ----- //
diff --git a/internal/cloudapi/v2/v2_multi_tenancy_test.go b/internal/cloudapi/v2/v2_multi_tenancy_test.go
new file mode 100644
index 000000000..d6ff6dda6
--- /dev/null
+++ b/internal/cloudapi/v2/v2_multi_tenancy_test.go
@@ -0,0 +1,278 @@
+package v2_test
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/golang-jwt/jwt"
+	"github.com/google/uuid"
+	"github.com/openshift-online/ocm-sdk-go/authentication"
+	"github.com/stretchr/testify/require"
+
+	"github.com/osbuild/osbuild-composer/internal/cloudapi/v2"
+	"github.com/osbuild/osbuild-composer/internal/distro/test_distro"
+	"github.com/osbuild/osbuild-composer/internal/jobqueue"
+	"github.com/osbuild/osbuild-composer/internal/test"
+)
+
+func kojiRequest() string {
+	return fmt.Sprintf(`
+		{
+			"distribution":"%[1]s",
+			"image_requests": [
+				{
+					"architecture": "%[2]s",
+					"image_type": "%[3]s",
+					"repositories": [
+						{
+							"baseurl": "https://repo.example.com/"
+						}
+					]
+				}
+			],
+			"koji": {
+				"server": "koji.example.com",
+				"task_id": 1,
+				"name":"foo",
+				"version":"1",
+				"release":"2"
+			}
+		}`, test_distro.TestDistroName, test_distro.TestArch3Name, string(v2.ImageTypesGuestImage))
+}
+
+func s3Request() string {
+	return fmt.Sprintf(`
+		{
+			"distribution":"%[1]s",
+			"image_requests": [
+				{
+					"architecture": "%[2]s",
+					"image_type": "%[3]s",
+					"repositories": [
+						{
+							"baseurl": "https://repo.example.com/"
+						}
+					],
+					"upload_options": {
+						"region": "us-east-1"
+					}
+				}
+			]
+		}`, test_distro.TestDistroName, test_distro.TestArch3Name, string(v2.ImageTypesGuestImage))
+}
+
+var reqContextCallCount = 0
+
+func reqContext(orgID string) context.Context {
+	// Alternate between rh-org-id and account_id so we verify that the APIs understand
+	// both fields.
+	tenantFields := []string{"rh-org-id", "account_id"}
+	tenantField := tenantFields[reqContextCallCount%2]
+	reqContextCallCount++
+	return authentication.ContextWithToken(context.Background(), &jwt.Token{
+		Claims: jwt.MapClaims{
+			tenantField: orgID,
+		},
+	})
+}
+
+func scheduleRequest(t *testing.T, handler http.Handler, orgID, request string) uuid.UUID {
+	result := test.APICall{
+		Handler:        handler,
+		Context:        reqContext(orgID),
+		Method:         http.MethodPost,
+		Path:           "/api/image-builder-composer/v2/compose",
+		RequestBody:    test.JSONRequestBody(request),
+		ExpectedStatus: http.StatusCreated,
+	}.Do(t)
+
+	// Parse ID
+	var id v2.ComposeId
+	require.NoError(t, json.Unmarshal(result.Body, &id))
+
+	return uuid.MustParse(id.Id)
+}
+
+func getAllJobsOfCompose(t *testing.T, q jobqueue.JobQueue, finalJob uuid.UUID) []uuid.UUID {
+	// This is basically a BFS on job dependencies
+	// It may return duplicates (job dependencies are not a tree) but we don't care for our purposes
+
+	jobsToQuery := []uuid.UUID{finalJob}
+	discovered := []uuid.UUID{finalJob}
+
+	for len(jobsToQuery) > 0 {
+		current := jobsToQuery[0]
+		jobsToQuery = jobsToQuery[1:]
+
+		_, _, deps, _, err := q.Job(current)
+		require.NoError(t, err)
+		discovered = append(discovered, deps...)
+		jobsToQuery = append(jobsToQuery, deps...)
+	}
+
+	return discovered
+}
+
+func jobRequest() string {
+	return fmt.Sprintf(`
+		{
+			"types": [
+				"koji-init",
+				"osbuild",
+				"osbuild-koji",
+				"koji-finalize",
+				"depsolve"
+			],
+			"arch": "%s"
+		}`, test_distro.TestArch3Name)
+}
+
+func runNextJob(t *testing.T, jobs []uuid.UUID, workerHandler http.Handler, orgID string) {
+	// test that a different tenant doesn't get any job
+	// 100ms ought to be enough 🤞
+	ctx, cancel := context.WithDeadline(reqContext("987"), time.Now().Add(time.Millisecond*100))
+	defer cancel()
+	test.APICall{
+		Handler:        workerHandler,
+		Method:         http.MethodPost,
+		Path:           "/api/worker/v1/jobs",
+		RequestBody:    test.JSONRequestBody(jobRequest()),
+		Context:        ctx,
+		ExpectedStatus: http.StatusNoContent,
+	}.Do(t)
+
+	// get a job using the right tenant
+	resp := test.APICall{
+		Handler:        workerHandler,
+		Method:         http.MethodPost,
+		Path:           "/api/worker/v1/jobs",
+		RequestBody:    test.JSONRequestBody(jobRequest()),
+		Context:        reqContext(orgID),
+		ExpectedStatus: http.StatusCreated,
+	}.Do(t)
+
+	// get the job ID and test if it belongs to the list of jobs belonging to a particular compose (and thus tenant)
+	var job struct {
+		ID       string `json:"id"`
+		Location string `json:"location"`
+	}
+	require.NoError(t, json.Unmarshal(resp.Body, &job))
+	jobID := uuid.MustParse(job.ID)
+	require.Contains(t, jobs, jobID)
+
+	// finish the job
+	test.APICall{
+		Handler:        workerHandler,
+		Method:         http.MethodPatch,
+		Path:           job.Location,
+		RequestBody:    test.JSONRequestBody(`{"result": {"job_result":{}}}`),
+		ExpectedStatus: http.StatusOK,
+	}.Do(t)
+}
+
+// TestMultitenancy tests that the cloud API is securely multi-tenant.
+//
+// It creates 4 composes (mixed s3 and koji ones) and then simulates workers of
+// 5 different tenants and makes sure that the workers only pick jobs that
+// belong to their tenant.
+//
+// The test is not written in a parallel way but since our queue is FIFO and
+// the test is running the job in a LIFO way, it should cover everything.
+//
+// It's important to acknowledge that this test is not E2E. We don't pass raw
+// JWT here but an already parsed one inside a request context. A proper E2E
+// also exists to test the full setup. Unfortunately, it cannot properly test
+// that all jobs are assigned to the correct channel, therefore we need also
+// this test.
+func TestMultitenancy(t *testing.T) {
+	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
+	require.NoError(t, err)
+	defer os.RemoveAll(dir)
+
+	// Passing an empty list as depsolving channels, we want to do depsolves
+	// ourselvess
+	apiServer, workerServer, q, cancel := newV2Server(t, dir, []string{}, true)
+	handler := apiServer.Handler("/api/image-builder-composer/v2")
+	defer cancel()
+
+	// define 4 composes
+	composes := []*struct {
+		koji   bool
+		orgID  string
+		id     uuid.UUID
+		jobIDs []uuid.UUID
+	}{
+		{
+			koji:  true,
+			orgID: "42",
+		},
+		{
+			koji:  false,
+			orgID: "123",
+		},
+		{
+			koji:  true,
+			orgID: "2022",
+		},
+		{
+			koji:  true,
+			orgID: "1995",
+		},
+	}
+
+	// schedule all composes and retrieve some information about them
+	for _, c := range composes {
+		var request string
+		if c.koji {
+			request = kojiRequest()
+		} else {
+			request = s3Request()
+		}
+		id := scheduleRequest(t, handler, c.orgID, request)
+		c.id = id
+
+		// make sure that the channel is prefixed with "org-"
+		_, _, _, channel, err := q.Job(id)
+		require.NoError(t, err)
+		require.Equal(t, "org-"+c.orgID, channel)
+
+		// get all jobs belonging to this compose
+		c.jobIDs = getAllJobsOfCompose(t, q, id)
+	}
+
+	// Run the composes in a LIFO way
+	for i := len(composes) - 1; i >= 0; i -= 1 {
+		c := composes[i]
+
+		// We have to run 2 jobs for S3 composes (depsolve, osbuild)
+		// 4 jobs for koji composes (depsolve, koji-init, osbuild-koji, koji-finalize)
+		numjobs := 2
+		if c.koji {
+			numjobs = 4
+		}
+
+		// Run all jobs
+		for j := 0; j < numjobs; j++ {
+			runNextJob(t, c.jobIDs, workerServer.Handler(), c.orgID)
+		}
+
+		// Make sure that the compose is not pending (i.e. all jobs did run)
+		resp := test.APICall{
+			Handler:        handler,
+			Method:         http.MethodGet,
+			Path:           "/api/image-builder-composer/v2/composes/" + c.id.String(),
+			ExpectedStatus: http.StatusOK,
+		}.Do(t)
+		var result struct {
+			Status string `json:"status"`
+		}
+		require.NoError(t, json.Unmarshal(resp.Body, &result))
+		require.NotEqual(t, "pending", result.Status)
+	}
+}
diff --git a/internal/cloudapi/v2/v2_test.go b/internal/cloudapi/v2/v2_test.go
index 85f958c5e..7e28f48be 100644
--- a/internal/cloudapi/v2/v2_test.go
+++ b/internal/cloudapi/v2/v2_test.go
@@ -8,12 +8,12 @@ import (
 	"net/http"
 	"os"
 	"testing"
-	"time"
 
 	"github.com/stretchr/testify/require"
 
 	v2 "github.com/osbuild/osbuild-composer/internal/cloudapi/v2"
 	"github.com/osbuild/osbuild-composer/internal/distro/test_distro"
+	"github.com/osbuild/osbuild-composer/internal/jobqueue"
 	"github.com/osbuild/osbuild-composer/internal/jobqueue/fsjobqueue"
 	distro_mock "github.com/osbuild/osbuild-composer/internal/mocks/distro"
 	"github.com/osbuild/osbuild-composer/internal/ostree/mock_ostree_repo"
@@ -23,23 +23,28 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
 )
 
-func newV2Server(t *testing.T, dir string) (*v2.Server, *worker.Server, context.CancelFunc) {
+func newV2Server(t *testing.T, dir string, depsolveChannels []string, enableJWT bool) (*v2.Server, *worker.Server, jobqueue.JobQueue, context.CancelFunc) {
 	q, err := fsjobqueue.New(dir)
 	require.NoError(t, err)
-	workerServer := worker.NewServer(nil, q, "", time.Duration(0), "/api/worker/v1")
+	workerServer := worker.NewServer(nil, q, worker.Config{BasePath: "/api/worker/v1", JWTEnabled: enableJWT, TenantProviderFields: []string{"rh-org-id", "account_id"}})
 
 	distros, err := distro_mock.NewDefaultRegistry()
 	require.NoError(t, err)
 	require.NotNil(t, distros)
 
-	v2Server := v2.NewServer(workerServer, distros, "image-builder.service")
+	config := v2.ServerConfig{
+		AWSBucket:            "image-builder.service",
+		JWTEnabled:           enableJWT,
+		TenantProviderFields: []string{"rh-org-id", "account_id"},
+	}
+	v2Server := v2.NewServer(workerServer, distros, config)
 	require.NotNil(t, v2Server)
 
 	// start a routine which just completes depsolve jobs
 	depsolveContext, cancel := context.WithCancel(context.Background())
 	go func() {
 		for {
-			_, token, _, _, _, err := workerServer.RequestJob(context.Background(), test_distro.TestDistroName, []string{"depsolve"})
+			_, token, _, _, _, err := workerServer.RequestJob(context.Background(), test_distro.TestDistroName, []string{"depsolve"}, depsolveChannels)
 			if err != nil {
 				continue
 			}
@@ -59,14 +64,14 @@ func newV2Server(t *testing.T, dir string) (*v2.Server, *worker.Server, context.
 		}
 	}()
 
-	return v2Server, workerServer, cancel
+	return v2Server, workerServer, q, cancel
 }
 
 func TestUnknownRoute(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	srv, _, cancel := newV2Server(t, dir)
+	srv, _, _, cancel := newV2Server(t, dir, []string{""}, false)
 	defer cancel()
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", "/api/image-builder-composer/v2/badroute", ``, http.StatusNotFound, `
@@ -83,7 +88,7 @@ func TestGetError(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	srv, _, cancel := newV2Server(t, dir)
+	srv, _, _, cancel := newV2Server(t, dir, []string{""}, false)
 	defer cancel()
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", "/api/image-builder-composer/v2/errors/4", ``, http.StatusOK, `
@@ -109,7 +114,7 @@ func TestGetErrorList(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	srv, _, cancel := newV2Server(t, dir)
+	srv, _, _, cancel := newV2Server(t, dir, []string{""}, false)
 	defer cancel()
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", "/api/image-builder-composer/v2/errors?page=3&size=1", ``, http.StatusOK, `
@@ -131,7 +136,7 @@ func TestCompose(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	srv, _, cancel := newV2Server(t, dir)
+	srv, _, _, cancel := newV2Server(t, dir, []string{""}, false)
 	defer cancel()
 
 	// create two ostree repos, one to serve the default test_distro ref (for fallback tests) and one to serve a custom ref
@@ -552,7 +557,7 @@ func TestComposeStatusSuccess(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	srv, wrksrv, cancel := newV2Server(t, dir)
+	srv, wrksrv, _, cancel := newV2Server(t, dir, []string{""}, false)
 	defer cancel()
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
@@ -575,7 +580,7 @@ func TestComposeStatusSuccess(t *testing.T) {
 		"kind": "ComposeId"
 	}`, "id")
 
-	jobId, token, jobType, args, dynArgs, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"})
+	jobId, token, jobType, args, dynArgs, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, "osbuild", jobType)
 
@@ -624,7 +629,7 @@ func TestComposeStatusFailure(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	srv, wrksrv, cancel := newV2Server(t, dir)
+	srv, wrksrv, _, cancel := newV2Server(t, dir, []string{""}, false)
 	defer cancel()
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
@@ -647,7 +652,7 @@ func TestComposeStatusFailure(t *testing.T) {
 		"kind": "ComposeId"
 	}`, "id")
 
-	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"})
+	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, "osbuild", jobType)
 
@@ -676,7 +681,7 @@ func TestComposeLegacyError(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	srv, wrksrv, cancel := newV2Server(t, dir)
+	srv, wrksrv, _, cancel := newV2Server(t, dir, []string{""}, false)
 	defer cancel()
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
@@ -699,7 +704,7 @@ func TestComposeLegacyError(t *testing.T) {
 		"kind": "ComposeId"
 	}`, "id")
 
-	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"})
+	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, "osbuild", jobType)
 
@@ -731,7 +736,7 @@ func TestComposeJobError(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	srv, wrksrv, cancel := newV2Server(t, dir)
+	srv, wrksrv, _, cancel := newV2Server(t, dir, []string{""}, false)
 	defer cancel()
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
@@ -754,7 +759,7 @@ func TestComposeJobError(t *testing.T) {
 		"kind": "ComposeId"
 	}`, "id")
 
-	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"})
+	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, "osbuild", jobType)
 
@@ -789,7 +794,7 @@ func TestComposeCustomizations(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	srv, _, cancel := newV2Server(t, dir)
+	srv, _, _, cancel := newV2Server(t, dir, []string{""}, false)
 	defer cancel()
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
@@ -838,7 +843,7 @@ func TestImageTypes(t *testing.T) {
 	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
 	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	srv, _, cancel := newV2Server(t, dir)
+	srv, _, _, cancel := newV2Server(t, dir, []string{""}, false)
 	defer cancel()
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
diff --git a/internal/jobqueue/jobqueuetest/jobqueuetest.go b/internal/jobqueue/jobqueuetest/jobqueuetest.go
index b7f403a3e..c072323dd 100644
--- a/internal/jobqueue/jobqueuetest/jobqueuetest.go
+++ b/internal/jobqueue/jobqueuetest/jobqueuetest.go
@@ -41,18 +41,19 @@ func TestJobQueue(t *testing.T, makeJobQueue MakeJobQueue) {
 	t.Run("heartbeats", wrap(testHeartbeats))
 	t.Run("timeout", wrap(testDequeueTimeout))
 	t.Run("dequeue-by-id", wrap(testDequeueByID))
+	t.Run("multiple-channels", wrap(testMultipleChannels))
 }
 
-func pushTestJob(t *testing.T, q jobqueue.JobQueue, jobType string, args interface{}, dependencies []uuid.UUID) uuid.UUID {
+func pushTestJob(t *testing.T, q jobqueue.JobQueue, jobType string, args interface{}, dependencies []uuid.UUID, channel string) uuid.UUID {
 	t.Helper()
-	id, err := q.Enqueue(jobType, args, dependencies)
+	id, err := q.Enqueue(jobType, args, dependencies, channel)
 	require.NoError(t, err)
 	require.NotEmpty(t, id)
 	return id
 }
 
 func finishNextTestJob(t *testing.T, q jobqueue.JobQueue, jobType string, result interface{}, deps []uuid.UUID) uuid.UUID {
-	id, tok, d, typ, args, err := q.Dequeue(context.Background(), []string{jobType})
+	id, tok, d, typ, args, err := q.Dequeue(context.Background(), []string{jobType}, []string{""})
 	require.NoError(t, err)
 	require.NotEmpty(t, id)
 	require.NotEmpty(t, tok)
@@ -68,18 +69,18 @@ func finishNextTestJob(t *testing.T, q jobqueue.JobQueue, jobType string, result
 
 func testErrors(t *testing.T, q jobqueue.JobQueue) {
 	// not serializable to JSON
-	id, err := q.Enqueue("test", make(chan string), nil)
+	id, err := q.Enqueue("test", make(chan string), nil, "")
 	require.Error(t, err)
 	require.Equal(t, uuid.Nil, id)
 
 	// invalid dependency
-	id, err = q.Enqueue("test", "arg0", []uuid.UUID{uuid.New()})
+	id, err = q.Enqueue("test", "arg0", []uuid.UUID{uuid.New()}, "")
 	require.Error(t, err)
 	require.Equal(t, uuid.Nil, id)
 
 	// token gets removed
-	pushTestJob(t, q, "octopus", nil, nil)
-	id, tok, _, _, _, err := q.Dequeue(context.Background(), []string{"octopus"})
+	pushTestJob(t, q, "octopus", nil, nil, "")
+	id, tok, _, _, _, err := q.Dequeue(context.Background(), []string{"octopus"}, []string{""})
 	require.NoError(t, err)
 	require.NotEmpty(t, tok)
 
@@ -103,14 +104,34 @@ func testArgs(t *testing.T, q jobqueue.JobQueue) {
 	}
 
 	oneargs := argument{7, "🐠"}
-	one := pushTestJob(t, q, "fish", oneargs, nil)
+	var parsedArgs argument
+
+	one := pushTestJob(t, q, "fish", oneargs, nil, "toucan")
+
+	// Read job params before Dequeue
+	jtype, jargs, jdeps, jchan, err := q.Job(one)
+	require.NoError(t, err)
+	err = json.Unmarshal(jargs, &parsedArgs)
+	require.NoError(t, err)
+	require.Equal(t, oneargs, parsedArgs)
+	require.Empty(t, jdeps)
+	require.Equal(t, "toucan", jchan)
+	require.Equal(t, "fish", jtype)
 
 	twoargs := argument{42, "🐙"}
-	two := pushTestJob(t, q, "octopus", twoargs, nil)
+	two := pushTestJob(t, q, "octopus", twoargs, nil, "kingfisher")
 
-	var parsedArgs argument
+	// Read job params before Dequeue
+	jtype, jargs, jdeps, jchan, err = q.Job(two)
+	require.NoError(t, err)
+	err = json.Unmarshal(jargs, &parsedArgs)
+	require.NoError(t, err)
+	require.Equal(t, twoargs, parsedArgs)
+	require.Empty(t, jdeps)
+	require.Equal(t, "kingfisher", jchan)
+	require.Equal(t, "octopus", jtype)
 
-	id, tok, deps, typ, args, err := q.Dequeue(context.Background(), []string{"octopus"})
+	id, tok, deps, typ, args, err := q.Dequeue(context.Background(), []string{"octopus"}, []string{"kingfisher"})
 	require.NoError(t, err)
 	require.Equal(t, two, id)
 	require.NotEmpty(t, tok)
@@ -121,13 +142,14 @@ func testArgs(t *testing.T, q jobqueue.JobQueue) {
 	require.Equal(t, twoargs, parsedArgs)
 
 	// Read job params after Dequeue
-	jtype, jargs, jdeps, err := q.Job(id)
+	jtype, jargs, jdeps, jchan, err = q.Job(id)
 	require.NoError(t, err)
 	require.Equal(t, args, jargs)
 	require.Equal(t, deps, jdeps)
+	require.Equal(t, "kingfisher", jchan)
 	require.Equal(t, typ, jtype)
 
-	id, tok, deps, typ, args, err = q.Dequeue(context.Background(), []string{"fish"})
+	id, tok, deps, typ, args, err = q.Dequeue(context.Background(), []string{"fish"}, []string{"toucan"})
 	require.NoError(t, err)
 	require.Equal(t, one, id)
 	require.NotEmpty(t, tok)
@@ -137,26 +159,27 @@ func testArgs(t *testing.T, q jobqueue.JobQueue) {
 	require.NoError(t, err)
 	require.Equal(t, oneargs, parsedArgs)
 
-	jtype, jargs, jdeps, err = q.Job(id)
+	jtype, jargs, jdeps, jchan, err = q.Job(id)
 	require.NoError(t, err)
 	require.Equal(t, args, jargs)
 	require.Equal(t, deps, jdeps)
+	require.Equal(t, "toucan", jchan)
 	require.Equal(t, typ, jtype)
 
-	_, _, _, err = q.Job(uuid.New())
+	_, _, _, _, err = q.Job(uuid.New())
 	require.Error(t, err)
 }
 
 func testJobTypes(t *testing.T, q jobqueue.JobQueue) {
-	one := pushTestJob(t, q, "octopus", nil, nil)
-	two := pushTestJob(t, q, "clownfish", nil, nil)
+	one := pushTestJob(t, q, "octopus", nil, nil, "")
+	two := pushTestJob(t, q, "clownfish", nil, nil, "")
 
 	require.Equal(t, two, finishNextTestJob(t, q, "clownfish", testResult{}, nil))
 	require.Equal(t, one, finishNextTestJob(t, q, "octopus", testResult{}, nil))
 
 	ctx, cancel := context.WithCancel(context.Background())
 	cancel()
-	id, tok, deps, typ, args, err := q.Dequeue(ctx, []string{"zebra"})
+	id, tok, deps, typ, args, err := q.Dequeue(ctx, []string{"zebra"}, []string{""})
 	require.Equal(t, err, jobqueue.ErrDequeueTimeout)
 	require.Equal(t, uuid.Nil, id)
 	require.Equal(t, uuid.Nil, tok)
@@ -168,26 +191,26 @@ func testJobTypes(t *testing.T, q jobqueue.JobQueue) {
 func testDequeueTimeout(t *testing.T, q jobqueue.JobQueue) {
 	ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*20)
 	defer cancel()
-	_, _, _, _, _, err := q.Dequeue(ctx, []string{"octopus"})
+	_, _, _, _, _, err := q.Dequeue(ctx, []string{"octopus"}, []string{""})
 	require.Equal(t, jobqueue.ErrDequeueTimeout, err)
 
 	ctx2, cancel2 := context.WithCancel(context.Background())
 	cancel2()
-	_, _, _, _, _, err = q.Dequeue(ctx2, []string{"octopus"})
+	_, _, _, _, _, err = q.Dequeue(ctx2, []string{"octopus"}, []string{""})
 	require.Equal(t, jobqueue.ErrDequeueTimeout, err)
 }
 
 func testDependencies(t *testing.T, q jobqueue.JobQueue) {
 	t.Run("done-before-pushing-dependant", func(t *testing.T) {
-		one := pushTestJob(t, q, "test", nil, nil)
-		two := pushTestJob(t, q, "test", nil, nil)
+		one := pushTestJob(t, q, "test", nil, nil, "")
+		two := pushTestJob(t, q, "test", nil, nil, "")
 
 		r := []uuid.UUID{}
 		r = append(r, finishNextTestJob(t, q, "test", testResult{}, nil))
 		r = append(r, finishNextTestJob(t, q, "test", testResult{}, nil))
 		require.ElementsMatch(t, []uuid.UUID{one, two}, r)
 
-		j := pushTestJob(t, q, "test", nil, []uuid.UUID{one, two})
+		j := pushTestJob(t, q, "test", nil, []uuid.UUID{one, two}, "")
 		jobType, _, queued, started, finished, canceled, deps, err := q.JobStatus(j)
 		require.NoError(t, err)
 		require.Equal(t, jobType, "test")
@@ -213,10 +236,10 @@ func testDependencies(t *testing.T, q jobqueue.JobQueue) {
 	})
 
 	t.Run("done-after-pushing-dependant", func(t *testing.T) {
-		one := pushTestJob(t, q, "test", nil, nil)
-		two := pushTestJob(t, q, "test", nil, nil)
+		one := pushTestJob(t, q, "test", nil, nil, "")
+		two := pushTestJob(t, q, "test", nil, nil, "")
 
-		j := pushTestJob(t, q, "test", nil, []uuid.UUID{one, two})
+		j := pushTestJob(t, q, "test", nil, []uuid.UUID{one, two}, "")
 		jobType, _, queued, started, finished, canceled, deps, err := q.JobStatus(j)
 		require.NoError(t, err)
 		require.Equal(t, jobType, "test")
@@ -255,7 +278,7 @@ func testMultipleWorkers(t *testing.T, q jobqueue.JobQueue) {
 		defer close(done)
 		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 		defer cancel()
-		id, tok, deps, typ, args, err := q.Dequeue(ctx, []string{"octopus"})
+		id, tok, deps, typ, args, err := q.Dequeue(ctx, []string{"octopus"}, []string{""})
 		require.NoError(t, err)
 		require.NotEmpty(t, id)
 		require.NotEmpty(t, tok)
@@ -269,8 +292,8 @@ func testMultipleWorkers(t *testing.T, q jobqueue.JobQueue) {
 	time.Sleep(10 * time.Millisecond)
 
 	// This call to Dequeue() should not block on the one in the goroutine.
-	id := pushTestJob(t, q, "clownfish", nil, nil)
-	r, tok, deps, typ, args, err := q.Dequeue(context.Background(), []string{"clownfish"})
+	id := pushTestJob(t, q, "clownfish", nil, nil, "")
+	r, tok, deps, typ, args, err := q.Dequeue(context.Background(), []string{"clownfish"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, id, r)
 	require.NotEmpty(t, tok)
@@ -279,7 +302,7 @@ func testMultipleWorkers(t *testing.T, q jobqueue.JobQueue) {
 	require.Equal(t, json.RawMessage("null"), args)
 
 	// Now wake up the Dequeue() in the goroutine and wait for it to finish.
-	_ = pushTestJob(t, q, "octopus", nil, nil)
+	_ = pushTestJob(t, q, "octopus", nil, nil, "")
 	<-done
 }
 
@@ -293,7 +316,7 @@ func testMultipleWorkersSingleJobType(t *testing.T, q jobqueue.JobQueue) {
 			defer wg.Add(-1)
 			ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 			defer cancel()
-			id, tok, deps, typ, args, err := q.Dequeue(ctx, []string{"clownfish"})
+			id, tok, deps, typ, args, err := q.Dequeue(ctx, []string{"clownfish"}, []string{""})
 			require.NoError(t, err)
 			require.NotEmpty(t, id)
 			require.NotEmpty(t, tok)
@@ -308,13 +331,13 @@ func testMultipleWorkersSingleJobType(t *testing.T, q jobqueue.JobQueue) {
 	time.Sleep(10 * time.Millisecond)
 
 	// Satisfy the first listener
-	_ = pushTestJob(t, q, "clownfish", nil, nil)
+	_ = pushTestJob(t, q, "clownfish", nil, nil, "")
 
 	// Wait a bit for the listener to process the job
 	time.Sleep(10 * time.Millisecond)
 
 	// Satisfy the second listener
-	_ = pushTestJob(t, q, "clownfish", nil, nil)
+	_ = pushTestJob(t, q, "clownfish", nil, nil, "")
 
 	wg.Wait()
 }
@@ -325,7 +348,7 @@ func testCancel(t *testing.T, q jobqueue.JobQueue) {
 	require.Error(t, err)
 
 	// Cancel a pending job
-	id := pushTestJob(t, q, "clownfish", nil, nil)
+	id := pushTestJob(t, q, "clownfish", nil, nil, "")
 	require.NotEmpty(t, id)
 	err = q.CancelJob(id)
 	require.NoError(t, err)
@@ -338,9 +361,9 @@ func testCancel(t *testing.T, q jobqueue.JobQueue) {
 	require.Error(t, err)
 
 	// Cancel a running job, which should not dequeue the canceled job from above
-	id = pushTestJob(t, q, "clownfish", nil, nil)
+	id = pushTestJob(t, q, "clownfish", nil, nil, "")
 	require.NotEmpty(t, id)
-	r, tok, deps, typ, args, err := q.Dequeue(context.Background(), []string{"clownfish"})
+	r, tok, deps, typ, args, err := q.Dequeue(context.Background(), []string{"clownfish"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, id, r)
 	require.NotEmpty(t, tok)
@@ -358,9 +381,9 @@ func testCancel(t *testing.T, q jobqueue.JobQueue) {
 	require.Error(t, err)
 
 	// Cancel a finished job, which is a no-op
-	id = pushTestJob(t, q, "clownfish", nil, nil)
+	id = pushTestJob(t, q, "clownfish", nil, nil, "")
 	require.NotEmpty(t, id)
-	r, tok, deps, typ, args, err = q.Dequeue(context.Background(), []string{"clownfish"})
+	r, tok, deps, typ, args, err = q.Dequeue(context.Background(), []string{"clownfish"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, id, r)
 	require.NotEmpty(t, tok)
@@ -381,11 +404,11 @@ func testCancel(t *testing.T, q jobqueue.JobQueue) {
 }
 
 func testHeartbeats(t *testing.T, q jobqueue.JobQueue) {
-	id := pushTestJob(t, q, "octopus", nil, nil)
+	id := pushTestJob(t, q, "octopus", nil, nil, "")
 	// No heartbeats for queued job
 	require.Empty(t, q.Heartbeats(time.Second*0))
 
-	r, tok, _, _, _, err := q.Dequeue(context.Background(), []string{"octopus"})
+	r, tok, _, _, _, err := q.Dequeue(context.Background(), []string{"octopus"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, id, r)
 	require.NotEmpty(t, tok)
@@ -417,8 +440,8 @@ func testHeartbeats(t *testing.T, q jobqueue.JobQueue) {
 
 func testDequeueByID(t *testing.T, q jobqueue.JobQueue) {
 	t.Run("basic", func(t *testing.T) {
-		one := pushTestJob(t, q, "octopus", nil, nil)
-		two := pushTestJob(t, q, "octopus", nil, nil)
+		one := pushTestJob(t, q, "octopus", nil, nil, "")
+		two := pushTestJob(t, q, "octopus", nil, nil, "")
 
 		tok, d, typ, args, err := q.DequeueByID(context.Background(), one)
 		require.NoError(t, err)
@@ -434,8 +457,8 @@ func testDequeueByID(t *testing.T, q jobqueue.JobQueue) {
 	})
 
 	t.Run("cannot dequeue a job without finished deps", func(t *testing.T) {
-		one := pushTestJob(t, q, "octopus", nil, nil)
-		two := pushTestJob(t, q, "octopus", nil, []uuid.UUID{one})
+		one := pushTestJob(t, q, "octopus", nil, nil, "")
+		two := pushTestJob(t, q, "octopus", nil, []uuid.UUID{one}, "")
 
 		_, _, _, _, err := q.DequeueByID(context.Background(), two)
 		require.Equal(t, jobqueue.ErrNotPending, err)
@@ -445,9 +468,9 @@ func testDequeueByID(t *testing.T, q jobqueue.JobQueue) {
 	})
 
 	t.Run("cannot dequeue a non-pending job", func(t *testing.T) {
-		one := pushTestJob(t, q, "octopus", nil, nil)
+		one := pushTestJob(t, q, "octopus", nil, nil, "")
 
-		_, _, _, _, _, err := q.Dequeue(context.Background(), []string{"octopus"})
+		_, _, _, _, _, err := q.Dequeue(context.Background(), []string{"octopus"}, []string{""})
 		require.NoError(t, err)
 
 		_, _, _, _, err = q.DequeueByID(context.Background(), one)
@@ -460,3 +483,105 @@ func testDequeueByID(t *testing.T, q jobqueue.JobQueue) {
 		require.Equal(t, jobqueue.ErrNotPending, err)
 	})
 }
+
+func testMultipleChannels(t *testing.T, q jobqueue.JobQueue) {
+	t.Run("two single channel dequeuers", func(t *testing.T) {
+		var wg sync.WaitGroup
+
+		oneChan := make(chan uuid.UUID, 1)
+		twoChan := make(chan uuid.UUID, 1)
+
+		// dequeue kingfisher channel
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+
+			id, _, _, _, _, err := q.Dequeue(context.Background(), []string{"octopus"}, []string{"kingfisher"})
+			require.NoError(t, err)
+
+			expectedID := <-twoChan
+			require.Equal(t, expectedID, id)
+		}()
+
+		// dequeue toucan channel
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+
+			id, _, _, _, _, err := q.Dequeue(context.Background(), []string{"octopus"}, []string{"toucan"})
+			require.NoError(t, err)
+
+			expectedID := <-oneChan
+			require.Equal(t, expectedID, id)
+		}()
+
+		// enqueue into toucan channel
+		one := pushTestJob(t, q, "octopus", nil, nil, "toucan")
+		oneChan <- one
+		// enqueue into kingfisher channel
+		two := pushTestJob(t, q, "octopus", nil, nil, "kingfisher")
+		twoChan <- two
+		wg.Wait()
+	})
+
+	t.Run("one double channel dequeuers", func(t *testing.T) {
+		var wg sync.WaitGroup
+
+		oneChan := make(chan uuid.UUID, 1)
+		twoChan := make(chan uuid.UUID, 1)
+
+		// dequeue kingfisher channel
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+
+			id, _, _, _, _, err := q.Dequeue(context.Background(), []string{"octopus"}, []string{"kingfisher", "toucan"})
+			require.NoError(t, err)
+
+			expectedID := <-oneChan
+			require.Equal(t, expectedID, id)
+
+			id, _, _, _, _, err = q.Dequeue(context.Background(), []string{"octopus"}, []string{"kingfisher", "toucan"})
+			require.NoError(t, err)
+
+			expectedID = <-twoChan
+			require.Equal(t, expectedID, id)
+		}()
+
+		// enqueue into toucan channel
+		one := pushTestJob(t, q, "octopus", nil, nil, "toucan")
+		oneChan <- one
+		// enqueue into kingfisher channel
+		two := pushTestJob(t, q, "octopus", nil, nil, "kingfisher")
+		twoChan <- two
+		wg.Wait()
+	})
+
+	t.Run("dequeing no-existing channel", func(t *testing.T) {
+		// enqueue into toucan channel
+		pushTestJob(t, q, "octopus", nil, nil, "toucan")
+
+		// dequeue from an empty channel
+		ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Millisecond*100))
+		defer cancel()
+		_, _, _, _, _, err := q.Dequeue(ctx, []string{"octopus"}, []string{""})
+		require.ErrorIs(t, err, jobqueue.ErrDequeueTimeout)
+
+		// dequeue from toucan channel
+		_, _, _, _, _, err = q.Dequeue(context.Background(), []string{"octopus"}, []string{"toucan"})
+		require.NoError(t, err)
+
+		// enqueue into an empty channel
+		pushTestJob(t, q, "octopus", nil, nil, "")
+
+		// dequeue from toucan channel
+		ctx2, cancel2 := context.WithDeadline(context.Background(), time.Now().Add(time.Millisecond*100))
+		defer cancel2()
+		_, _, _, _, _, err = q.Dequeue(ctx2, []string{"octopus"}, []string{"toucan"})
+		require.ErrorIs(t, err, jobqueue.ErrDequeueTimeout)
+
+		// dequeue from an empty channel
+		_, _, _, _, _, err = q.Dequeue(context.Background(), []string{"octopus"}, []string{""})
+		require.NoError(t, err)
+	})
+}
diff --git a/internal/kojiapi/server_test.go b/internal/kojiapi/server_test.go
index e3cd1a1dc..f133a7e0b 100644
--- a/internal/kojiapi/server_test.go
+++ b/internal/kojiapi/server_test.go
@@ -14,6 +14,8 @@ import (
 	"time"
 
 	"github.com/google/uuid"
+	"github.com/stretchr/testify/require"
+
 	"github.com/osbuild/osbuild-composer/internal/distro/test_distro"
 	"github.com/osbuild/osbuild-composer/internal/kojiapi"
 	"github.com/osbuild/osbuild-composer/internal/kojiapi/api"
@@ -23,7 +25,6 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/test"
 	"github.com/osbuild/osbuild-composer/internal/worker"
 	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
-	"github.com/stretchr/testify/require"
 )
 
 func newTestKojiServer(t *testing.T, dir string) (*kojiapi.Server, *worker.Server) {
@@ -334,7 +335,7 @@ func TestCompose(t *testing.T) {
 		wg.Add(1)
 
 		go func(t *testing.T, result worker.KojiInitJobResult) {
-			_, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"koji-init"})
+			_, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"koji-init"}, []string{""})
 			require.NoError(t, err)
 			require.Equal(t, "koji-init", jobType)
 
@@ -387,7 +388,7 @@ func TestCompose(t *testing.T) {
 			c.composeReplyCode, c.composeReply, "id")
 		wg.Wait()
 
-		_, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"osbuild-koji"})
+		_, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"osbuild-koji"}, []string{""})
 		require.NoError(t, err)
 		require.Equal(t, "osbuild-koji", jobType)
 
@@ -403,7 +404,7 @@ func TestCompose(t *testing.T) {
 		test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(buildJobResult), http.StatusOK,
 			fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
 
-		_, token, jobType, rawJob, _, err = workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"osbuild-koji"})
+		_, token, jobType, rawJob, _, err = workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"osbuild-koji"}, []string{""})
 		require.NoError(t, err)
 		require.Equal(t, "osbuild-koji", jobType)
 
@@ -426,7 +427,7 @@ func TestCompose(t *testing.T) {
 		}`, test_distro.TestArchName, test_distro.TestDistroName), http.StatusOK,
 			fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
 
-		finalizeID, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"koji-finalize"})
+		finalizeID, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"koji-finalize"}, []string{""})
 		require.NoError(t, err)
 		require.Equal(t, "koji-finalize", jobType)
 
@@ -507,7 +508,7 @@ func TestJobTypeValidation(t *testing.T) {
 		Version: "42",
 		Release: "1",
 	}
-	initID, err := workers.EnqueueKojiInit(&initJob)
+	initID, err := workers.EnqueueKojiInit(&initJob, "")
 	require.NoError(t, err)
 
 	buildJobs := make([]worker.OSBuildKojiJob, nImages)
@@ -521,7 +522,7 @@ func TestJobTypeValidation(t *testing.T) {
 			KojiDirectory: "koji-server-test-dir",
 			KojiFilename:  fname,
 		}
-		buildID, err := workers.EnqueueOSBuildKoji(fmt.Sprintf("fake-arch-%d", idx), &buildJob, initID)
+		buildID, err := workers.EnqueueOSBuildKoji(fmt.Sprintf("fake-arch-%d", idx), &buildJob, initID, "")
 		require.NoError(t, err)
 
 		buildJobs[idx] = buildJob
@@ -539,7 +540,7 @@ func TestJobTypeValidation(t *testing.T) {
 		TaskID:        0,
 		StartTime:     uint64(time.Now().Unix()),
 	}
-	finalizeID, err := workers.EnqueueKojiFinalize(&finalizeJob, initID, buildJobIDs)
+	finalizeID, err := workers.EnqueueKojiFinalize(&finalizeJob, initID, buildJobIDs, "")
 	require.NoError(t, err)
 
 	// ----- Jobs queued - Test API endpoints (status, manifests, logs) ----- //
diff --git a/internal/test/apicall.go b/internal/test/apicall.go
new file mode 100644
index 000000000..7a5610b48
--- /dev/null
+++ b/internal/test/apicall.go
@@ -0,0 +1,99 @@
+package test
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// APICall is a small function object for testing HTTP APIs
+type APICall struct {
+	// http.Handler to run the call against
+	Handler http.Handler
+
+	// HTTP method, e.g. http.MethodPatch
+	Method string
+
+	// Request Path
+	Path string
+
+	// Request body. If nil, an empty body is sent
+	RequestBody RequestBody
+
+	// Request header. If nil, default header is sent
+	Header http.Header
+
+	// Request context. If nil, default context is used
+	Context context.Context
+
+	// Status that's expected to be received. If set to 0, the status is not checked
+	ExpectedStatus int
+
+	// Validator for the response body. If set to nil, the body is not validated
+	ExpectedBody BodyValidator
+}
+
+// Do performs the request as defined in the APICall struct.
+//
+// If any errors occur when doing the request, or any of the validators fail, t.FailNow() is called
+// Note that HTTP error status is not checked if ExpectedStatus == 0
+//
+// The result of the HTTP call is returned
+func (a APICall) Do(t *testing.T) APICallResult {
+	t.Helper()
+
+	var bodyReader io.Reader
+	if a.RequestBody != nil {
+		bodyReader = bytes.NewReader(a.RequestBody.Body())
+	}
+
+	req := httptest.NewRequest(a.Method, a.Path, bodyReader)
+
+	if a.Context != nil {
+		req = req.WithContext(a.Context)
+	}
+
+	req.Header = a.Header
+	if req.Header == nil {
+		req.Header = http.Header{}
+	}
+
+	if a.RequestBody != nil && a.RequestBody.ContentType() != "" {
+		req.Header.Set("Content-Type", a.RequestBody.ContentType())
+	}
+	respRecorder := httptest.NewRecorder()
+	a.Handler.ServeHTTP(respRecorder, req)
+	resp := respRecorder.Result()
+
+	body, err := ioutil.ReadAll(resp.Body)
+	require.NoErrorf(t, err, "%s: could not read response body", a.Path)
+
+	if a.ExpectedStatus != 0 {
+		assert.Equalf(t, a.ExpectedStatus, resp.StatusCode, "%s: SendHTTP failed for path", a.Path)
+	}
+	if a.ExpectedBody != nil {
+		err = a.ExpectedBody.Validate(body)
+		require.NoError(t, err, "%s: cannot validate response body", a.Path)
+	}
+
+	return APICallResult{
+		Body:       body,
+		StatusCode: resp.StatusCode,
+	}
+}
+
+// APICallResult holds a parsed response for an APICall
+type APICallResult struct {
+	// Full body as read from the server
+	Body []byte
+
+	// Status code returned from the server
+	StatusCode int
+}
diff --git a/internal/test/request.go b/internal/test/request.go
new file mode 100644
index 000000000..893dab465
--- /dev/null
+++ b/internal/test/request.go
@@ -0,0 +1,23 @@
+package test
+
+// RequestBody is an abstract interface for defining request bodies for APICall
+type RequestBody interface {
+	// Body returns the intended request body as a slice of bytes
+	Body() []byte
+
+	// ContentType returns value for Content-Type request header
+	ContentType() string
+}
+
+// JSONRequestBody is just a simple wrapper over plain string.
+//
+// Body is just the string converted to a slice of bytes and content type is set to application/json
+type JSONRequestBody string
+
+func (b JSONRequestBody) Body() []byte {
+	return []byte(b)
+}
+
+func (b JSONRequestBody) ContentType() string {
+	return "application/json"
+}
diff --git a/internal/test/validator.go b/internal/test/validator.go
new file mode 100644
index 000000000..c568e47d9
--- /dev/null
+++ b/internal/test/validator.go
@@ -0,0 +1,48 @@
+package test
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+// BodyValidator is an abstract interface for defining validators for response bodies
+type BodyValidator interface {
+	// Validate returns nil if the body is valid. If the body isn't valid, a descriptive error is returned.
+	Validate(body []byte) error
+}
+
+// JSONValidator is a simple validator for validating JSON responses
+type JSONValidator struct {
+	// Content is the expected json content of the body
+	//
+	// Note that the key order of maps is arbitrary
+	Content string
+
+	// IgnoreFields is a list of JSON keys that should be removed from both expected body and actual body
+	IgnoreFields []string
+}
+
+func (b JSONValidator) Validate(body []byte) error {
+	var reply, expected interface{}
+	err := json.Unmarshal(body, &reply)
+	if err != nil {
+		return fmt.Errorf("json.Unmarshal failed: %s\n%w", string(body), err)
+	}
+
+	err = json.Unmarshal([]byte(b.Content), &expected)
+	if err != nil {
+		return fmt.Errorf("expected JSON is invalid: %s\n%w", string(b.Content), err)
+	}
+
+	dropFields(reply, b.IgnoreFields...)
+	dropFields(expected, b.IgnoreFields...)
+
+	diff := cmp.Diff(expected, reply)
+	if diff != "" {
+		return fmt.Errorf("bodies don't match: %s", diff)
+	}
+
+	return nil
+}
diff --git a/internal/weldr/compose_test.go b/internal/weldr/compose_test.go
index 926fa01d3..d63de4ace 100644
--- a/internal/weldr/compose_test.go
+++ b/internal/weldr/compose_test.go
@@ -7,12 +7,13 @@ import (
 	"os"
 	"testing"
 
+	"github.com/stretchr/testify/require"
+
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/distro/test_distro"
 	rpmmd_mock "github.com/osbuild/osbuild-composer/internal/mocks/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/worker"
 	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
-	"github.com/stretchr/testify/require"
 )
 
 func TestComposeStatusFromLegacyError(t *testing.T) {
@@ -41,10 +42,10 @@ func TestComposeStatusFromLegacyError(t *testing.T) {
 		t.Fatalf("error creating osbuild manifest: %v", err)
 	}
 
-	jobId, err := api.workers.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
+	jobId, err := api.workers.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
-	j, token, _, _, _, err := api.workers.RequestJob(context.Background(), arch.Name(), []string{"osbuild"})
+	j, token, _, _, _, err := api.workers.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, jobId, j)
 
@@ -87,10 +88,10 @@ func TestComposeStatusFromJobError(t *testing.T) {
 		t.Fatalf("error creating osbuild manifest: %v", err)
 	}
 
-	jobId, err := api.workers.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
+	jobId, err := api.workers.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
-	j, token, _, _, _, err := api.workers.RequestJob(context.Background(), arch.Name(), []string{"osbuild"})
+	j, token, _, _, _, err := api.workers.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, jobId, j)
 
diff --git a/internal/worker/server_test.go b/internal/worker/server_test.go
index e3f4a1323..1de2391a2 100644
--- a/internal/worker/server_test.go
+++ b/internal/worker/server_test.go
@@ -30,7 +30,12 @@ func newTestServer(t *testing.T, tempdir string, jobRequestTimeout time.Duration
 	if err != nil {
 		t.Fatalf("error creating fsjobqueue: %v", err)
 	}
-	return worker.NewServer(nil, q, "", jobRequestTimeout, basePath)
+
+	config := worker.Config{
+		RequestJobTimeout: jobRequestTimeout,
+		BasePath:          basePath,
+	}
+	return worker.NewServer(nil, q, config)
 }
 
 // Ensure that the status request returns OK.
@@ -129,7 +134,7 @@ func TestCreate(t *testing.T) {
 	server := newTestServer(t, tempdir, time.Duration(0), "/api/worker/v1")
 	handler := server.Handler()
 
-	_, err = server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
+	_, err = server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
 	test.TestRoute(t, handler, false, "POST", "/api/worker/v1/jobs",
@@ -158,10 +163,10 @@ func TestCancel(t *testing.T) {
 	server := newTestServer(t, tempdir, time.Duration(0), "/api/worker/v1")
 	handler := server.Handler()
 
-	jobId, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
+	jobId, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
-	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"})
+	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, jobId, j)
 	require.Equal(t, "osbuild", typ)
@@ -199,10 +204,10 @@ func TestUpdate(t *testing.T) {
 	server := newTestServer(t, tempdir, time.Duration(0), "/api/worker/v1")
 	handler := server.Handler()
 
-	jobId, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
+	jobId, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
-	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"})
+	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, jobId, j)
 	require.Equal(t, "osbuild", typ)
@@ -238,10 +243,10 @@ func TestArgs(t *testing.T) {
 			Payload: []string{"x", "y", "z"},
 		},
 	}
-	jobId, err := server.EnqueueOSBuild(arch.Name(), &job)
+	jobId, err := server.EnqueueOSBuild(arch.Name(), &job, "")
 	require.NoError(t, err)
 
-	_, _, _, args, _, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"})
+	_, _, _, args, _, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
 	require.NoError(t, err)
 	require.NotNil(t, args)
 
@@ -272,10 +277,10 @@ func TestUpload(t *testing.T) {
 	server := newTestServer(t, tempdir, time.Duration(0), "/api/worker/v1")
 	handler := server.Handler()
 
-	jobID, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
+	jobID, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
-	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"})
+	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, jobID, j)
 	require.Equal(t, "osbuild", typ)
@@ -306,10 +311,10 @@ func TestUploadAlteredBasePath(t *testing.T) {
 	server := newTestServer(t, tempdir, time.Duration(0), "/api/image-builder-worker/v1")
 	handler := server.Handler()
 
-	jobID, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
+	jobID, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
-	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"})
+	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, jobID, j)
 	require.Equal(t, "osbuild", typ)
@@ -326,7 +331,11 @@ func TestOAuth(t *testing.T) {
 
 	q, err := fsjobqueue.New(tempdir)
 	require.NoError(t, err)
-	workerServer := worker.NewServer(nil, q, tempdir, time.Duration(0), "/api/image-builder-worker/v1")
+	config := worker.Config{
+		ArtifactsDir: tempdir,
+		BasePath:     "/api/image-builder-worker/v1",
+	}
+	workerServer := worker.NewServer(nil, q, config)
 	handler := workerServer.Handler()
 
 	workSrv := httptest.NewServer(handler)
@@ -379,7 +388,7 @@ func TestOAuth(t *testing.T) {
 		t.Fatalf("error creating osbuild manifest: %v", err)
 	}
 
-	_, err = workerServer.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest})
+	_, err = workerServer.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
 	client, err := worker.NewClient(proxySrv.URL, nil, &offlineToken, &oauthSrv.URL, "/api/image-builder-worker/v1")
@@ -404,7 +413,7 @@ func TestTimeout(t *testing.T) {
 	}
 	server := newTestServer(t, tempdir, time.Millisecond*10, "/api/image-builder-worker/v1")
 
-	_, _, _, _, _, err = server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"})
+	_, _, _, _, _, err = server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
 	require.Equal(t, jobqueue.ErrDequeueTimeout, err)
 
 	test.TestRoute(t, server.Handler(), false, "POST", "/api/image-builder-worker/v1/jobs", `{"arch":"arch","types":["types"]}`, http.StatusNoContent,
@@ -424,10 +433,10 @@ func TestRequestJobById(t *testing.T) {
 	server := newTestServer(t, tempdir, time.Duration(0), "/api/worker/v1")
 	handler := server.Handler()
 
-	depsolveJobId, err := server.EnqueueDepsolve(&worker.DepsolveJob{})
+	depsolveJobId, err := server.EnqueueDepsolve(&worker.DepsolveJob{}, "")
 	require.NoError(t, err)
 
-	jobId, err := server.EnqueueManifestJobByID(&worker.ManifestJobByID{}, depsolveJobId)
+	jobId, err := server.EnqueueManifestJobByID(&worker.ManifestJobByID{}, depsolveJobId, "")
 	require.NoError(t, err)
 
 	test.TestRoute(t, server.Handler(), false, "POST", "/api/worker/v1/jobs", `{"arch":"arch","types":["manifest-id-only"]}`, http.StatusBadRequest,
@@ -436,7 +445,7 @@ func TestRequestJobById(t *testing.T) {
 	_, _, _, _, _, err = server.RequestJobById(context.Background(), arch.Name(), jobId)
 	require.Error(t, jobqueue.ErrNotPending, err)
 
-	_, token, _, _, _, err := server.RequestJob(context.Background(), arch.Name(), []string{"depsolve"})
+	_, token, _, _, _, err := server.RequestJob(context.Background(), arch.Name(), []string{"depsolve"}, []string{""})
 	require.NoError(t, err)
 
 	depsolveJR, err := json.Marshal(worker.DepsolveJobResult{})
@@ -472,7 +481,7 @@ func TestMixedOSBuildJob(t *testing.T) {
 		Manifest:  emptyManifestV2,
 		ImageName: "no-pipeline-names",
 	}
-	oldJobID, err := server.EnqueueOSBuild("x", &oldJob)
+	oldJobID, err := server.EnqueueOSBuild("x", &oldJob, "")
 	require.NoError(err)
 
 	newJob := worker.OSBuildJob{
@@ -483,7 +492,7 @@ func TestMixedOSBuildJob(t *testing.T) {
 			Payload: []string{"other", "pipelines"},
 		},
 	}
-	newJobID, err := server.EnqueueOSBuild("x", &newJob)
+	newJobID, err := server.EnqueueOSBuild("x", &newJob, "")
 	require.NoError(err)
 
 	var oldJobRead worker.OSBuildJob
@@ -511,7 +520,7 @@ func TestMixedOSBuildJob(t *testing.T) {
 		// don't block forever if the jobs weren't added or can't be retrieved
 		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
 		defer cancel()
-		id, token, _, _, _, err := server.RequestJob(ctx, "x", []string{"osbuild"})
+		id, token, _, _, _, err := server.RequestJob(ctx, "x", []string{"osbuild"}, []string{""})
 		require.NoError(err)
 		return id, token
 	}
@@ -612,9 +621,9 @@ func TestMixedOSBuildKojiJob(t *testing.T) {
 
 	enqueueKojiJob := func(job *worker.OSBuildKojiJob) uuid.UUID {
 		initJob := new(worker.KojiInitJob)
-		initJobID, err := server.EnqueueKojiInit(initJob)
+		initJobID, err := server.EnqueueKojiInit(initJob, "")
 		require.NoError(err)
-		jobID, err := server.EnqueueOSBuildKoji("k", job, initJobID)
+		jobID, err := server.EnqueueOSBuildKoji("k", job, initJobID, "")
 		require.NoError(err)
 		return jobID
 	}
@@ -659,7 +668,7 @@ func TestMixedOSBuildKojiJob(t *testing.T) {
 	for idx := uint(0); idx < 2; idx++ {
 		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
 		defer cancel()
-		_, token, _, _, _, err := server.RequestJob(ctx, "k", []string{"koji-init"})
+		_, token, _, _, _, err := server.RequestJob(ctx, "k", []string{"koji-init"}, []string{""})
 		require.NoError(err)
 		require.NoError(server.FinishJob(token, nil))
 	}
@@ -668,7 +677,7 @@ func TestMixedOSBuildKojiJob(t *testing.T) {
 		// don't block forever if the jobs weren't added or can't be retrieved
 		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
 		defer cancel()
-		id, token, _, _, _, err := server.RequestJob(ctx, "k", []string{"osbuild-koji"})
+		id, token, _, _, _, err := server.RequestJob(ctx, "k", []string{"osbuild-koji"}, []string{""})
 		require.NoError(err)
 		return id, token
 	}
@@ -769,10 +778,10 @@ func TestDepsolveLegacyErrorConversion(t *testing.T) {
 	}
 	server := newTestServer(t, tempdir, time.Duration(0), "/api/worker/v1")
 
-	depsolveJobId, err := server.EnqueueDepsolve(&worker.DepsolveJob{})
+	depsolveJobId, err := server.EnqueueDepsolve(&worker.DepsolveJob{}, "")
 	require.NoError(t, err)
 
-	_, _, _, _, _, err = server.RequestJob(context.Background(), arch.Name(), []string{"depsolve"})
+	_, _, _, _, _, err = server.RequestJob(context.Background(), arch.Name(), []string{"depsolve"}, []string{""})
 	require.NoError(t, err)
 
 	reason := "Depsolve failed"
@@ -812,7 +821,7 @@ func TestMixedOSBuildJobErrors(t *testing.T) {
 		Manifest:  emptyManifestV2,
 		ImageName: "no-pipeline-names",
 	}
-	oldJobID, err := server.EnqueueOSBuild("x", &oldJob)
+	oldJobID, err := server.EnqueueOSBuild("x", &oldJob, "")
 	require.NoError(err)
 
 	newJob := worker.OSBuildJob{
@@ -823,7 +832,7 @@ func TestMixedOSBuildJobErrors(t *testing.T) {
 			Payload: []string{"other", "pipelines"},
 		},
 	}
-	newJobID, err := server.EnqueueOSBuild("x", &newJob)
+	newJobID, err := server.EnqueueOSBuild("x", &newJob, "")
 	require.NoError(err)
 
 	oldJobRead := new(worker.OSBuildJob)
@@ -844,7 +853,7 @@ func TestMixedOSBuildJobErrors(t *testing.T) {
 		// don't block forever if the jobs weren't added or can't be retrieved
 		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
 		defer cancel()
-		id, token, _, _, _, err := server.RequestJob(ctx, "x", []string{"osbuild"})
+		id, token, _, _, _, err := server.RequestJob(ctx, "x", []string{"osbuild"}, []string{""})
 		require.NoError(err)
 		return id, token
 	}
@@ -917,9 +926,9 @@ func TestMixedOSBuildKojiJobErrors(t *testing.T) {
 
 	enqueueKojiJob := func(job *worker.OSBuildKojiJob) uuid.UUID {
 		initJob := new(worker.KojiInitJob)
-		initJobID, err := server.EnqueueKojiInit(initJob)
+		initJobID, err := server.EnqueueKojiInit(initJob, "")
 		require.NoError(err)
-		jobID, err := server.EnqueueOSBuildKoji("k", job, initJobID)
+		jobID, err := server.EnqueueOSBuildKoji("k", job, initJobID, "")
 		require.NoError(err)
 		return jobID
 	}
@@ -957,7 +966,7 @@ func TestMixedOSBuildKojiJobErrors(t *testing.T) {
 	for idx := uint(0); idx < 2; idx++ {
 		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
 		defer cancel()
-		_, token, _, _, _, err := server.RequestJob(ctx, "k", []string{"koji-init"})
+		_, token, _, _, _, err := server.RequestJob(ctx, "k", []string{"koji-init"}, []string{""})
 		require.NoError(err)
 		require.NoError(server.FinishJob(token, nil))
 	}
@@ -966,7 +975,7 @@ func TestMixedOSBuildKojiJobErrors(t *testing.T) {
 		// don't block forever if the jobs weren't added or can't be retrieved
 		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
 		defer cancel()
-		id, token, _, _, _, err := server.RequestJob(ctx, "k", []string{"osbuild-koji"})
+		id, token, _, _, _, err := server.RequestJob(ctx, "k", []string{"osbuild-koji"}, []string{""})
 		require.NoError(err)
 		return id, token
 	}
diff --git a/test/cases/api.sh b/test/cases/api.sh
index 4ce5d5838..e757c71a5 100755
--- a/test/cases/api.sh
+++ b/test/cases/api.sh
@@ -1265,6 +1265,7 @@ enable_jwt = true
 jwt_keys_urls = ["https://localhost:8080/certs"]
 jwt_ca_file = "/etc/osbuild-composer/ca-crt.pem"
 jwt_acl_file = ""
+jwt_tenant_provider_fields = ["rh-org-id"]
 [worker]
 pg_host = "localhost"
 pg_port = "5432"
@@ -1278,6 +1279,7 @@ enable_mtls = false
 enable_jwt = true
 jwt_keys_urls = ["https://localhost:8080/certs"]
 jwt_ca_file = "/etc/osbuild-composer/ca-crt.pem"
+jwt_tenant_provider_fields = ["rh-org-id"]
 EOF
 
 cat <<EOF | sudo tee "/etc/osbuild-worker/token"
diff --git a/test/cases/image_tests.sh b/test/cases/image_tests.sh
index 9acc234c2..b5c6f3321 100755
--- a/test/cases/image_tests.sh
+++ b/test/cases/image_tests.sh
@@ -42,10 +42,16 @@ test_divider () {
 }
 
 # Get a list of test cases.
+# Exclude test cases that require external dependencies like a http ostree repo.
+# These manifests exist only for correct manifest creation testing and cannot
+# be built during this test.
 get_test_cases () {
     TEST_CASE_SELECTOR="${DISTRO_CODE/-/_}-${ARCH}"
     pushd $IMAGE_TEST_CASES_PATH > /dev/null
-        ls "$TEST_CASE_SELECTOR"*.json
+        ALL_CASES=$(ls "$TEST_CASE_SELECTOR"*.json)
+        SKIP_CASES=$(jq -r 'if (.manifest.sources."org.osbuild.ostree" != null) then input_filename else empty end' "${TEST_CASE_SELECTOR}"*.json)
+        mapfile -t TEST_CASES < <(grep -vxFf <(printf '%s\n' "${SKIP_CASES[@]}") <(printf '%s\n' "${ALL_CASES[@]}"))
+        echo "${TEST_CASES[@]}"
     popd > /dev/null
 }
 
diff --git a/test/cases/koji.sh b/test/cases/koji.sh
index 31386d883..ff7ddb4ec 100755
--- a/test/cases/koji.sh
+++ b/test/cases/koji.sh
@@ -19,9 +19,6 @@ fi
 # Provision the software under test.
 /usr/libexec/osbuild-composer-test/provision.sh
 
-# Make sure podman-plugins are installed
-sudo dnf -y install podman-plugins
-
 greenprint "Starting containers"
 sudo /usr/libexec/osbuild-composer-test/run-koji-container.sh start
 
diff --git a/test/cases/multi-tenancy.sh b/test/cases/multi-tenancy.sh
new file mode 100755
index 000000000..8a76fe211
--- /dev/null
+++ b/test/cases/multi-tenancy.sh
@@ -0,0 +1,256 @@
+#!/bin/bash
+
+# Tests the multi-tenancy feature of cloud and remote worker API.
+#
+# Note that the power of this is very limited. It cannot check that a certain
+# tenant can only access jobs on its channel. It has its value though that
+# it checks the whole E2E setup including parsing of the JWT token which is
+# not tested in the unit test.
+
+
+set -euo pipefail
+
+OSBUILD_COMPOSER_TEST_DATA=/usr/share/tests/osbuild-composer/
+
+# Get OS data.
+source /usr/libexec/osbuild-composer-test/set-env-variables.sh
+
+# Colorful output.
+function greenprint {
+    echo -e "\033[1;32m[$(date -Isecond)] ${1}\033[0m"
+}
+
+# Provision the software under test.
+/usr/libexec/osbuild-composer-test/provision.sh
+
+greenprint "Registering clean ups"
+KILL_PIDS=()
+function cleanup() {
+  set +eu
+  greenprint "Stopping containers"
+  sudo /usr/libexec/osbuild-composer-test/run-koji-container.sh stop
+
+  greenprint "Removing generated CA cert"
+  sudo rm \
+      /etc/pki/ca-trust/source/anchors/osbuild-composer-tests-ca-crt.pem
+  sudo update-ca-trust
+
+  for P in "${KILL_PIDS[@]}"; do
+      sudo pkill -P "$P"
+  done
+  set -eu
+}
+trap cleanup EXIT
+
+greenprint "Adding the testsuite's CA cert to the system trust store"
+# the worker cannot handle koji with self-signed certs
+sudo cp \
+    /etc/osbuild-composer/ca-crt.pem \
+    /etc/pki/ca-trust/source/anchors/osbuild-composer-tests-ca-crt.pem
+sudo update-ca-trust
+
+greenprint "Starting containers"
+sudo /usr/libexec/osbuild-composer-test/run-koji-container.sh start
+
+greenprint "Adding kerberos config"
+sudo cp \
+    /tmp/osbuild-composer-koji-test/client.keytab \
+    /etc/osbuild-worker/client.keytab
+sudo cp \
+    "${OSBUILD_COMPOSER_TEST_DATA}"/kerberos/krb5-local.conf \
+    /etc/krb5.conf.d/local
+
+greenprint "Configuring composer and worker"
+sudo tee "/etc/osbuild-composer/osbuild-composer.toml" >/dev/null <<EOF
+[koji]
+enable_tls = false
+enable_mtls = false
+enable_jwt = true
+jwt_keys_urls = ["https://localhost:8082/certs"]
+jwt_ca_file = "/etc/osbuild-composer/ca-crt.pem"
+jwt_acl_file = ""
+jwt_tenant_provider_fields = ["rh-org-id"]
+[koji.aws_config]
+bucket = "${AWS_BUCKET}"
+[worker]
+enable_artifacts = false
+enable_tls = true
+enable_mtls = false
+enable_jwt = true
+jwt_keys_urls = ["https://localhost:8082/certs"]
+jwt_ca_file = "/etc/osbuild-composer/ca-crt.pem"
+jwt_tenant_provider_fields = ["rh-org-id"]
+EOF
+
+# No compose will get this orgID
+sudo tee "/etc/osbuild-worker/token" >/dev/null <<EOF
+123456789
+EOF
+
+sudo tee "/etc/osbuild-worker/osbuild-worker.toml" >/dev/null <<EOF
+[authentication]
+oauth_url = "http://localhost:8081/token"
+offline_token = "/etc/osbuild-worker/token"
+
+[koji.localhost.kerberos]
+principal = "osbuild-krb@LOCAL"
+keytab = "/etc/osbuild-worker/client.keytab"
+
+[aws]
+credentials = "/etc/osbuild-worker/aws-credentials.toml"
+EOF
+
+greenprint "Starting mock servers"
+# Spin up an https instance for the composer-api and worker-api; the auth handler needs to hit an ssl `/certs` endpoint
+sudo /usr/libexec/osbuild-composer-test/osbuild-mock-openid-provider -a localhost:8082 -rsaPubPem /etc/osbuild-composer/client-crt.pem -rsaPem /etc/osbuild-composer/client-key.pem -cert /etc/osbuild-composer/composer-crt.pem -key /etc/osbuild-composer/composer-key.pem &
+KILL_PIDS+=("$!")
+# Spin up an http instance for the worker client to bypass the need to specify an extra CA
+sudo /usr/libexec/osbuild-composer-test/osbuild-mock-openid-provider -a localhost:8081 -rsaPubPem /etc/osbuild-composer/client-crt.pem -rsaPem /etc/osbuild-composer/client-key.pem &
+KILL_PIDS+=("$!")
+
+greenprint "Restarting composer, stopping a local worker and starting a remote worker"
+sudo systemctl restart osbuild-composer
+sudo systemctl stop osbuild-worker@1
+sudo systemctl start osbuild-remote-worker@localhost:8700
+
+DISTRO=rhel-86
+
+function s3_request {
+  cat <<EOF
+{
+  "distribution": "$DISTRO",
+  "image_request": {
+    "architecture": "$ARCH",
+    "image_type": "guest-image",
+    "repositories": $(jq ".\"$ARCH\"" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json),
+    "upload_options": {
+      "region": "${AWS_REGION}"
+    }
+  }
+}
+EOF
+}
+
+function koji_request {
+  local task_id="$1"
+  cat <<EOF
+{
+  "distribution": "$DISTRO",
+  "image_request": {
+    "architecture": "$ARCH",
+    "image_type": "guest-image",
+    "repositories": $(jq ".\"$ARCH\"" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json)
+  },
+  "koji": {
+    "server": "https://localhost:4343/kojihub",
+    "task_id": $task_id,
+    "name": "name",
+    "version": "version",
+    "release": "release"
+  }
+}
+EOF
+}
+
+function access_token {
+  local refresh_token="$1"
+  curl --request POST \
+    --data "refresh_token=$refresh_token" \
+    --header "Content-Type: application/x-www-form-urlencoded" \
+    --silent \
+    --show-error \
+    --fail \
+    localhost:8081/token | jq -r .access_token
+}
+
+function send_compose {
+  local request_file="$1"
+  local refresh_token="$2"
+  curl \
+    --silent \
+    --show-error \
+    --fail \
+    --header 'Content-Type: application/json' \
+    --header "Authorization: Bearer $(access_token "$refresh_token")" \
+    --request POST \
+    --data @"$request_file" \
+    http://localhost:443/api/image-builder-composer/v2/compose | jq -r '.id'
+}
+
+function compose_status {
+  local compose="$1"
+  local refresh_token="$2"
+  curl \
+    --silent \
+    --show-error \
+    --fail \
+    --header "Authorization: Bearer $(access_token "$refresh_token")" \
+    "http://localhost:443/api/image-builder-composer/v2/composes/$compose" | jq -r '.status'
+}
+
+function assert_status {
+  local compose="$1"
+  local refresh_token="$2"
+  local status="$3"
+  [[ $(compose_status "$compose" "$refresh_token") == "$status" ]]
+}
+
+function wait_for_status {
+  local compose="$1"
+  local refresh_token="$2"
+  local desired_status="$3"
+  while true
+  do
+    local current_status
+    current_status=$(compose_status "$compose" "$refresh_token")
+
+    case "$current_status" in
+      "$desired_status")
+        break
+        ;;
+      # default undesired state
+      "failure")
+        echo "Image compose failed"
+        exit 1
+        ;;
+    esac
+
+    sleep 10
+    done
+}
+
+function set_worker_org {
+  local org="$1"
+  greenprint "Setting worker's org ID to $org"
+  sudo tee "/etc/osbuild-worker/token" >/dev/null <<EOF
+$org
+EOF
+  sudo systemctl restart osbuild-remote-worker@localhost:8700
+}
+
+ORG=42
+greenprint "Sending 1st compose, koji, org id = $ORG"
+koji --server=http://localhost:8080/kojihub --user kojiadmin --password kojipass --authtype=password make-task image
+ID=$(send_compose <( koji_request 1 ) $ORG)
+
+greenprint "Making sure that a different worker doesn't pick up the compose."
+set_worker_org 100
+sleep 10
+assert_status "$ID" $ORG pending
+
+greenprint "Building the compose."
+set_worker_org $ORG
+wait_for_status "$ID" $ORG success
+
+ORG=2022
+greenprint "Sending 2nd compose, s3, org id = $ORG"
+ID=$(send_compose <( s3_request ) $ORG)
+
+greenprint "Making sure that a different worker doesn't pick up the compose."
+set_worker_org 42
+sleep 10
+assert_status "$ID" $ORG pending
+
+greenprint "Building the compose."
+set_worker_org $ORG
+wait_for_status "$ID" $ORG success
diff --git a/test/cases/ostree-rebase.sh b/test/cases/ostree-rebase.sh
index 2cb4a7af2..5bf60bb09 100755
--- a/test/cases/ostree-rebase.sh
+++ b/test/cases/ostree-rebase.sh
@@ -1,13 +1,17 @@
 #!/bin/bash
 set -euo pipefail
 
+# Get OS data.
+source /usr/libexec/osbuild-composer-test/set-env-variables.sh
+
+# Get compose url if it's running on unsubscried RHEL
+if [[ ${ID} == "rhel" ]] && ! sudo subscription-manager status; then
+    source /usr/libexec/osbuild-composer-test/define-compose-url.sh
+fi
+
 # Provision the software under test.
 /usr/libexec/osbuild-composer-test/provision.sh
 
-# Get OS data.
-source /etc/os-release
-ARCH=$(uname -m)
-
 # Colorful output.
 function greenprint {
     echo -e "\033[1;32m[$(date -Isecond)] ${1}\033[0m"
diff --git a/test/cases/upgrade8to9.sh b/test/cases/upgrade8to9.sh
index 485c67d88..04b4a565f 100755
--- a/test/cases/upgrade8to9.sh
+++ b/test/cases/upgrade8to9.sh
@@ -122,6 +122,7 @@ sudo scp "${SSH_OPTIONS[@]}" -q -i "${SSH_KEY}" /usr/share/tests/osbuild-compose
 set +e
 sudo ssh "${SSH_OPTIONS[@]}" -q -i "${SSH_KEY}" root@"$INSTANCE_ADDRESS" 'source /root/upgrade_prepare.sh'
 sudo scp "${SSH_OPTIONS[@]}" -q -i "${SSH_KEY}" root@"$INSTANCE_ADDRESS":/var/log/leapp/leapp-preupgrade.log .
+sudo scp "${SSH_OPTIONS[@]}" -q -i "${SSH_KEY}" root@"$INSTANCE_ADDRESS":/var/log/leapp/leapp-upgrade.log .
 sudo scp "${SSH_OPTIONS[@]}" -q -i "${SSH_KEY}" root@"$INSTANCE_ADDRESS":/var/log/leapp/leapp-report.txt .
 set -e
 
diff --git a/test/data/upgrade8to9/upgrade_prepare.sh b/test/data/upgrade8to9/upgrade_prepare.sh
index 74ed81011..efdfc91f7 100755
--- a/test/data/upgrade8to9/upgrade_prepare.sh
+++ b/test/data/upgrade8to9/upgrade_prepare.sh
@@ -35,6 +35,9 @@ EOF
 # AllowZoneDrifting is disabled in RHEL-9, see rhbz#2054271 for more details
 sed -i "s/^AllowZoneDrifting=.*/AllowZoneDrifting=no/" /etc/firewalld/firewalld.conf
 
+# This user choice has to be made or else it inhibits the upgrade
+leapp answer --add --section check_vdo.no_vdo_devices=True
+
 # check upgrade
 leapp preupgrade --debug --no-rhsm