Repository: osbuild/osbuild-composer
Pull request #2402 (2022-03-08 09:18:53)

🍏  test/image-tests: temporarily skip azure_rhui image testing
This manifest is intended only for internal use and is currently failing
in nightly pipelines. This will be moved to a different test script in
the future, see COMPOSER-1397.


This pull request includes:

- [ ] adequate testing for the new functionality or fixed issue
- [ ] adequate documentation informing people about the change such as
  - [ ] submit a PR for the [guides](https://github.com/osbuild/guides) repository if this PR changed any behavior described there: https://www.osbuild.org/guides/

<!--
Thanks for proposing a change to osbuild-composer!

Please don't remove the above check list. These are things that each pull
request must have before it is merged. It helps maintainers to not forget
anything.

If the reason for ticking any of the boxes is ambiguous, please add a short
note explaining why.

In addition, if this pull request fixes a downstream issue, please refer to
test/README.md and add these additional items:

- [ ] 1st commit of any `rhbz#` related PR contains bug reproducer; CI reports FAIL or
- [ ] PR contains automated tests for new functionality and
- [ ] 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`
-->



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 e79132409..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
@@ -463,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
@@ -493,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/templates/composer.yml b/templates/composer.yml
index d3927add9..259ade6e4 100644
--- a/templates/composer.yml
+++ b/templates/composer.yml
@@ -202,7 +202,9 @@ objects:
       - claim: user_id
         pattern: ^(54629121|54629180|54597799|54676085|54968801)$
       - claim: rh-org-id
-        pattern: ^(13826359)$
+        pattern: ^(13826359|15842261)$
+      - claim: account_id
+        pattern: ^(15842261)$
     osbuild-composer.toml: |
       log_level = "info"
       [koji]
@@ -211,6 +213,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 +225,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


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 b5c6f3321..8ad259276 100755
--- a/test/cases/image_tests.sh
+++ b/test/cases/image_tests.sh
@@ -49,7 +49,10 @@ get_test_cases () {
     TEST_CASE_SELECTOR="${DISTRO_CODE/-/_}-${ARCH}"
     pushd $IMAGE_TEST_CASES_PATH > /dev/null
         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)
+        SKIP_OSTREE=$(jq -r 'if (.manifest.sources."org.osbuild.ostree" != null) then input_filename else empty end' "${TEST_CASE_SELECTOR}"*.json)
+        # temporarily skip azure-rhui image, see COMPOSER-1397 for details
+        SKIP_TMP=$(grep azure_rhui <<< "$ALL_CASES")
+        SKIP_CASES=("${SKIP_OSTREE[@]}" "$SKIP_TMP")
         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