From 28ceb135340f40a67741fffc7551287774182d4f Mon Sep 17 00:00:00 2001 From: Lubos Mjachky Date: Tue, 27 Aug 2019 19:56:57 +0200 Subject: [PATCH] Add a support for Bearer token authentication closes #4938 https://pulp.plan.io/issues/4938 --- .travis/install.sh | 4 +- .travis/post_before_script.sh | 4 + .travis/pulp-smash-config.json | 22 +++ CHANGES/4938.feature | 1 + docs/_static/api.json | 2 +- docs/workflows/authentication.rst | 97 ++++++++++ docs/workflows/index.rst | 7 + pulp_docker/app/authorization.py | 178 ++++++++++++++++++ pulp_docker/app/content.py | 5 + pulp_docker/app/downloaders.py | 1 + pulp_docker/app/registry.py | 18 +- pulp_docker/app/settings.py | 2 + pulp_docker/app/tasks/recursive_add.py | 1 + pulp_docker/app/tasks/recursive_remove.py | 1 + pulp_docker/app/tasks/sync_stages.py | 7 + pulp_docker/app/token_verification.py | 177 +++++++++++++++++ .../api/test_token_authentication.py | 162 ++++++++++++++++ setup.py | 1 + 18 files changed, 684 insertions(+), 6 deletions(-) create mode 100644 .travis/pulp-smash-config.json create mode 100644 CHANGES/4938.feature create mode 100644 docs/workflows/authentication.rst create mode 100644 pulp_docker/app/authorization.py create mode 100644 pulp_docker/app/settings.py create mode 100644 pulp_docker/app/token_verification.py create mode 100644 pulp_docker/tests/functional/api/test_token_authentication.py diff --git a/.travis/install.sh b/.travis/install.sh index ebd7eb45..fa82cccb 100755 --- a/.travis/install.sh +++ b/.travis/install.sh @@ -47,7 +47,6 @@ else TAG=$(git rev-parse --abbrev-ref HEAD | tr / _) fi - PLUGIN=pulp_docker @@ -99,8 +98,9 @@ spec: pulp_settings: content_host: $(hostname):24816 token_server: $(hostname):24816/token + private_key_path: /var/lib/pulp/tmp/private.pem + public_key_path: /var/lib/pulp/tmp/public.pem token_signature_algorithm: ES256 - CRYAML # Install k3s, lightweight Kubernetes diff --git a/.travis/post_before_script.sh b/.travis/post_before_script.sh index 9cd81500..ad55ad65 100755 --- a/.travis/post_before_script.sh +++ b/.travis/post_before_script.sh @@ -8,3 +8,7 @@ machine 127.0.0.1 login admin password password " > ~/.netrc + +$CMD_PREFIX bash -c "dnf install -y openssl" +$CMD_PREFIX bash -c "openssl ecparam -genkey -name prime256v1 -noout -out /var/lib/pulp/tmp/private.key" +$CMD_PREFIX bash -c "openssl ec -in /var/lib/pulp/tmp/private.key -pubout -out /var/lib/pulp/tmp/public.key" diff --git a/.travis/pulp-smash-config.json b/.travis/pulp-smash-config.json new file mode 100644 index 00000000..fcaede33 --- /dev/null +++ b/.travis/pulp-smash-config.json @@ -0,0 +1,22 @@ + +{ + "pulp": { + "auth": ["admin", "password"], + "selinux enabled": false, + "version": "3" + }, + "hosts": [ + { + "hostname": "localhost", + "roles": { + "api": {"port": 24817, "scheme": "http", "service": "nginx"}, + "content": {"port": 24816, "scheme": "http", "service": "pulp_content_app"}, + "token auth": {"private key": "/var/lib/pulp/tmp/private.pem", "public key": "/var/lib/pulp/tmp/public.pem"}, + "pulp resource manager": {}, + "pulp workers": {}, + "redis": {}, + "shell": {"transport": "kubectl"} + } + } + ] +} diff --git a/CHANGES/4938.feature b/CHANGES/4938.feature new file mode 100644 index 00000000..f5af47f2 --- /dev/null +++ b/CHANGES/4938.feature @@ -0,0 +1 @@ +Add support for pulling content using token authentication \ No newline at end of file diff --git a/docs/_static/api.json b/docs/_static/api.json index b76def69..0d5df3f9 100644 --- a/docs/_static/api.json +++ b/docs/_static/api.json @@ -1 +1 @@ -{ "swagger": "2.0", "info": { "title": "Pulp 3 API", "logo": { "url": "https://pulp.plan.io/attachments/download/517478/pulp_logo_word_rectangle.svg" }, "version": "v3" }, "host": "localhost:24817", "schemes": [ "http" ], "basePath": "/", "consumes": [ "application/json" ], "produces": [ "application/json" ], "securityDefinitions": { "Basic": { "type": "basic" } }, "security": [ { "Basic": [] } ], "paths": { "/pulp/api/v3/content/docker/blobs/": { "get": { "operationId": "content_docker_blobs_list", "summary": "List blobs", "description": "ViewSet for Blobs.", "parameters": [ { "name": "digest", "in": "query", "description": "Filter results where digest matches value", "required": false, "type": "string" }, { "name": "digest__in", "in": "query", "description": "Filter results where digest is in a comma-separated list of values", "required": false, "type": "string" }, { "name": "repository_version", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string" }, { "name": "repository_version_added", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string" }, { "name": "repository_version_removed", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string" }, { "name": "media_type", "in": "query", "description": "", "required": false, "type": "string" }, { "name": "limit", "in": "query", "description": "Number of results to return per page.", "required": false, "type": "integer" }, { "name": "offset", "in": "query", "description": "The initial index from which to return the results.", "required": false, "type": "integer" }, { "name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string" } ], "responses": { "200": { "description": "", "schema": { "required": [ "count", "results" ], "type": "object", "properties": { "count": { "type": "integer" }, "next": { "type": "string", "format": "uri", "x-nullable": true }, "previous": { "type": "string", "format": "uri", "x-nullable": true }, "results": { "type": "array", "items": { "$ref": "#/definitions/Blob" } } } } } }, "tags": [ "content: blobs" ] }, "post": { "operationId": "content_docker_blobs_create", "summary": "Create a blob", "description": "Create a new Blob from a request.", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Blob" } } ], "responses": { "201": { "description": "", "schema": { "$ref": "#/definitions/Blob" } } }, "tags": [ "content: blobs" ] }, "parameters": [] }, "{blob_href}": { "get": { "operationId": "content_docker_blobs_read", "summary": "Inspect a blob", "description": "ViewSet for Blobs.", "parameters": [ { "name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string" } ], "responses": { "200": { "description": "", "schema": { "$ref": "#/definitions/Blob" } } }, "tags": [ "content: blobs" ] }, "parameters": [ { "name": "blob_href", "in": "path", "description": "URI of Blob. e.g.: /pulp/api/v3/content/docker/blobs/1/", "required": true, "type": "string" } ] }, "/pulp/api/v3/content/docker/manifests/": { "get": { "operationId": "content_docker_manifests_list", "summary": "List manifests", "description": "ViewSet for Manifest.", "parameters": [ { "name": "digest", "in": "query", "description": "Filter results where digest matches value", "required": false, "type": "string" }, { "name": "digest__in", "in": "query", "description": "Filter results where digest is in a comma-separated list of values", "required": false, "type": "string" }, { "name": "repository_version", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string" }, { "name": "repository_version_added", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string" }, { "name": "repository_version_removed", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string" }, { "name": "media_type", "in": "query", "description": "", "required": false, "type": "string" }, { "name": "limit", "in": "query", "description": "Number of results to return per page.", "required": false, "type": "integer" }, { "name": "offset", "in": "query", "description": "The initial index from which to return the results.", "required": false, "type": "integer" }, { "name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string" } ], "responses": { "200": { "description": "", "schema": { "required": [ "count", "results" ], "type": "object", "properties": { "count": { "type": "integer" }, "next": { "type": "string", "format": "uri", "x-nullable": true }, "previous": { "type": "string", "format": "uri", "x-nullable": true }, "results": { "type": "array", "items": { "$ref": "#/definitions/Manifest" } } } } } }, "tags": [ "content: manifests" ] }, "post": { "operationId": "content_docker_manifests_create", "summary": "Create a manifest", "description": "Create a new Manifest from a request.", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Manifest" } } ], "responses": { "201": { "description": "", "schema": { "$ref": "#/definitions/Manifest" } } }, "tags": [ "content: manifests" ] }, "parameters": [] }, "{manifest_href}": { "get": { "operationId": "content_docker_manifests_read", "summary": "Inspect a manifest", "description": "ViewSet for Manifest.", "parameters": [ { "name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string" } ], "responses": { "200": { "description": "", "schema": { "$ref": "#/definitions/Manifest" } } }, "tags": [ "content: manifests" ] }, "parameters": [ { "name": "manifest_href", "in": "path", "description": "URI of Manifest. e.g.: /pulp/api/v3/content/docker/manifests/1/", "required": true, "type": "string" } ] }, "/pulp/api/v3/content/docker/tags/": { "get": { "operationId": "content_docker_tags_list", "summary": "List tags", "description": "ViewSet for Tag.", "parameters": [ { "name": "name", "in": "query", "description": "Filter results where name matches value", "required": false, "type": "string" }, { "name": "name__in", "in": "query", "description": "Filter results where name is in a comma-separated list of values", "required": false, "type": "string" }, { "name": "repository_version", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string" }, { "name": "repository_version_added", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string" }, { "name": "repository_version_removed", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string" }, { "name": "media_type", "in": "query", "description": "", "required": false, "type": "string" }, { "name": "digest", "in": "query", "description": "Multiple values may be separated by commas.", "required": false, "type": "string" }, { "name": "limit", "in": "query", "description": "Number of results to return per page.", "required": false, "type": "integer" }, { "name": "offset", "in": "query", "description": "The initial index from which to return the results.", "required": false, "type": "integer" }, { "name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string" } ], "responses": { "200": { "description": "", "schema": { "required": [ "count", "results" ], "type": "object", "properties": { "count": { "type": "integer" }, "next": { "type": "string", "format": "uri", "x-nullable": true }, "previous": { "type": "string", "format": "uri", "x-nullable": true }, "results": { "type": "array", "items": { "$ref": "#/definitions/Tag" } } } } } }, "tags": [ "content: tags" ] }, "post": { "operationId": "content_docker_tags_create", "summary": "Create a tag", "description": "Create a new Tag from a request.", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/Tag" } } ], "responses": { "201": { "description": "", "schema": { "$ref": "#/definitions/Tag" } } }, "tags": [ "content: tags" ] }, "parameters": [] }, "{tag_href}": { "get": { "operationId": "content_docker_tags_read", "summary": "Inspect a tag", "description": "ViewSet for Tag.", "parameters": [ { "name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string" } ], "responses": { "200": { "description": "", "schema": { "$ref": "#/definitions/Tag" } } }, "tags": [ "content: tags" ] }, "parameters": [ { "name": "tag_href", "in": "path", "description": "URI of Tag. e.g.: /pulp/api/v3/content/docker/tags/1/", "required": true, "type": "string" } ] }, "/pulp/api/v3/distributions/docker/docker/": { "get": { "operationId": "distributions_docker_docker_list", "summary": "List docker distributions", "description": "The Docker Distribution will serve the latest version of a Repository if\n``repository`` is specified. The Docker Distribution will serve a specific\nrepository version if ``repository_version``. Note that **either**\n``repository`` or ``repository_version`` can be set on a Docker\nDistribution, but not both.", "parameters": [ { "name": "name", "in": "query", "description": "", "required": false, "type": "string" }, { "name": "name__in", "in": "query", "description": "Filter results where name is in a comma-separated list of values", "required": false, "type": "string" }, { "name": "base_path", "in": "query", "description": "", "required": false, "type": "string" }, { "name": "base_path__contains", "in": "query", "description": "Filter results where base_path contains value", "required": false, "type": "string" }, { "name": "base_path__icontains", "in": "query", "description": "Filter results where base_path contains value", "required": false, "type": "string" }, { "name": "base_path__in", "in": "query", "description": "Filter results where base_path is in a comma-separated list of values", "required": false, "type": "string" }, { "name": "limit", "in": "query", "description": "Number of results to return per page.", "required": false, "type": "integer" }, { "name": "offset", "in": "query", "description": "The initial index from which to return the results.", "required": false, "type": "integer" }, { "name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string" } ], "responses": { "200": { "description": "", "schema": { "required": [ "count", "results" ], "type": "object", "properties": { "count": { "type": "integer" }, "next": { "type": "string", "format": "uri", "x-nullable": true }, "previous": { "type": "string", "format": "uri", "x-nullable": true }, "results": { "type": "array", "items": { "$ref": "#/definitions/DockerDistribution" } } } } } }, "tags": [ "distributions: docker" ] }, "post": { "operationId": "distributions_docker_docker_create", "summary": "Create a docker distribution", "description": "Trigger an asynchronous create task", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/DockerDistribution" } } ], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "distributions: docker" ] }, "parameters": [] }, "{docker_distribution_href}": { "get": { "operationId": "distributions_docker_docker_read", "summary": "Inspect a docker distribution", "description": "The Docker Distribution will serve the latest version of a Repository if\n``repository`` is specified. The Docker Distribution will serve a specific\nrepository version if ``repository_version``. Note that **either**\n``repository`` or ``repository_version`` can be set on a Docker\nDistribution, but not both.", "parameters": [ { "name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string" } ], "responses": { "200": { "description": "", "schema": { "$ref": "#/definitions/DockerDistribution" } } }, "tags": [ "distributions: docker" ] }, "put": { "operationId": "distributions_docker_docker_update", "summary": "Update a docker distribution", "description": "Trigger an asynchronous update task", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/DockerDistribution" } } ], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "distributions: docker" ] }, "patch": { "operationId": "distributions_docker_docker_partial_update", "summary": "Partially update a docker distribution", "description": "Trigger an asynchronous partial update task", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/DockerDistribution" } } ], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "distributions: docker" ] }, "delete": { "operationId": "distributions_docker_docker_delete", "summary": "Delete a docker distribution", "description": "Trigger an asynchronous delete task", "parameters": [], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "distributions: docker" ] }, "parameters": [ { "name": "docker_distribution_href", "in": "path", "description": "URI of Docker Distribution. e.g.: /pulp/api/v3/distributions/docker/docker/1/", "required": true, "type": "string" } ] }, "/pulp/api/v3/docker/manifests/copy/": { "post": { "operationId": "docker_manifests_copy_create", "description": "Trigger an asynchronous task to copy manifests", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/ManifestCopy" } } ], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "docker: copy" ] }, "parameters": [] }, "/pulp/api/v3/docker/recursive-add/": { "post": { "operationId": "docker_recursive-add_create", "description": "Trigger an asynchronous task to recursively add docker content.", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/RecursiveManage" } } ], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "docker: recursive-add" ] }, "parameters": [] }, "/pulp/api/v3/docker/recursive-remove/": { "post": { "operationId": "docker_recursive-remove_create", "description": "Trigger an asynchronous task to recursively remove docker content.", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/RecursiveManage" } } ], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "docker: recursive-remove" ] }, "parameters": [] }, "/pulp/api/v3/docker/tag/": { "post": { "operationId": "docker_tag_create", "description": "Trigger an asynchronous task to create a new repository", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/TagImage" } } ], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "docker: tag" ] }, "parameters": [] }, "/pulp/api/v3/docker/tags/copy/": { "post": { "operationId": "docker_tags_copy_create", "description": "Trigger an asynchronous task to copy tags", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/TagCopy" } } ], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "docker: copy" ] }, "parameters": [] }, "/pulp/api/v3/docker/untag/": { "post": { "operationId": "docker_untag_create", "description": "Trigger an asynchronous task to create a new repository", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/UnTagImage" } } ], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "docker: untag" ] }, "parameters": [] }, "/pulp/api/v3/remotes/docker/docker/": { "get": { "operationId": "remotes_docker_docker_list", "summary": "List docker remotes", "description": "Docker remotes represent an external repository that implements the Docker\nRegistry API. Docker remotes support deferred downloading by configuring\nthe ``policy`` field. ``on_demand`` and ``streamed`` policies can provide\nsignificant disk space savings.", "parameters": [ { "name": "name", "in": "query", "description": "", "required": false, "type": "string" }, { "name": "name__in", "in": "query", "description": "Filter results where name is in a comma-separated list of values", "required": false, "type": "string" }, { "name": "pulp_last_updated__lt", "in": "query", "description": "Filter results where pulp_last_updated is less than value", "required": false, "type": "string" }, { "name": "pulp_last_updated__lte", "in": "query", "description": "Filter results where pulp_last_updated is less than or equal to value", "required": false, "type": "string" }, { "name": "pulp_last_updated__gt", "in": "query", "description": "Filter results where pulp_last_updated is greater than value", "required": false, "type": "string" }, { "name": "pulp_last_updated__gte", "in": "query", "description": "Filter results where pulp_last_updated is greater than or equal to value", "required": false, "type": "string" }, { "name": "pulp_last_updated__range", "in": "query", "description": "Filter results where pulp_last_updated is between two comma separated values", "required": false, "type": "string" }, { "name": "pulp_last_updated", "in": "query", "description": "ISO 8601 formatted dates are supported", "required": false, "type": "string" }, { "name": "limit", "in": "query", "description": "Number of results to return per page.", "required": false, "type": "integer" }, { "name": "offset", "in": "query", "description": "The initial index from which to return the results.", "required": false, "type": "integer" }, { "name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string" } ], "responses": { "200": { "description": "", "schema": { "required": [ "count", "results" ], "type": "object", "properties": { "count": { "type": "integer" }, "next": { "type": "string", "format": "uri", "x-nullable": true }, "previous": { "type": "string", "format": "uri", "x-nullable": true }, "results": { "type": "array", "items": { "$ref": "#/definitions/DockerRemote" } } } } } }, "tags": [ "remotes: docker" ] }, "post": { "operationId": "remotes_docker_docker_create", "summary": "Create a docker remote", "description": "Docker remotes represent an external repository that implements the Docker\nRegistry API. Docker remotes support deferred downloading by configuring\nthe ``policy`` field. ``on_demand`` and ``streamed`` policies can provide\nsignificant disk space savings.", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/DockerRemote" } } ], "responses": { "201": { "description": "", "schema": { "$ref": "#/definitions/DockerRemote" } } }, "tags": [ "remotes: docker" ] }, "parameters": [] }, "{docker_remote_href}": { "get": { "operationId": "remotes_docker_docker_read", "summary": "Inspect a docker remote", "description": "Docker remotes represent an external repository that implements the Docker\nRegistry API. Docker remotes support deferred downloading by configuring\nthe ``policy`` field. ``on_demand`` and ``streamed`` policies can provide\nsignificant disk space savings.", "parameters": [ { "name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string" } ], "responses": { "200": { "description": "", "schema": { "$ref": "#/definitions/DockerRemote" } } }, "tags": [ "remotes: docker" ] }, "put": { "operationId": "remotes_docker_docker_update", "summary": "Update a docker remote", "description": "Trigger an asynchronous update task", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/DockerRemote" } } ], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "remotes: docker" ] }, "patch": { "operationId": "remotes_docker_docker_partial_update", "summary": "Partially update a docker remote", "description": "Trigger an asynchronous partial update task", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/DockerRemote" } } ], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "remotes: docker" ] }, "delete": { "operationId": "remotes_docker_docker_delete", "summary": "Delete a docker remote", "description": "Trigger an asynchronous delete task", "parameters": [], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "remotes: docker" ] }, "parameters": [ { "name": "docker_remote_href", "in": "path", "description": "URI of Docker Remote. e.g.: /pulp/api/v3/remotes/docker/docker/1/", "required": true, "type": "string" } ] }, "{docker_remote_href}sync/": { "post": { "operationId": "remotes_docker_docker_sync", "description": "Trigger an asynchronous task to sync content.", "parameters": [ { "name": "data", "in": "body", "required": true, "schema": { "$ref": "#/definitions/RepositorySyncURL" } } ], "responses": { "202": { "description": "", "schema": { "$ref": "#/definitions/AsyncOperationResponse" } } }, "tags": [ "remotes: docker" ] }, "parameters": [ { "name": "docker_remote_href", "in": "path", "description": "URI of Docker Remote. e.g.: /pulp/api/v3/remotes/docker/docker/1/", "required": true, "type": "string" } ] } }, "definitions": { "Blob": { "required": [ "artifact", "relative_path", "digest", "media_type" ], "type": "object", "properties": { "pulp_href": { "title": " href", "type": "string", "format": "uri", "readOnly": true }, "pulp_created": { "title": " created", "description": "Timestamp of creation.", "type": "string", "format": "date-time", "readOnly": true }, "artifact": { "title": "Artifact", "description": "Artifact file representing the physical content", "type": "string", "format": "uri" }, "relative_path": { "title": "Relative path", "description": "Path where the artifact is located relative to distributions base_path", "type": "string", "minLength": 1 }, "digest": { "title": "Digest", "description": "sha256 of the Blob file", "type": "string", "minLength": 1 }, "media_type": { "title": "Media type", "description": "Docker media type of the file", "type": "string", "minLength": 1 } } }, "Manifest": { "required": [ "artifact", "relative_path", "digest", "schema_version", "media_type", "listed_manifests", "config_blob", "blobs" ], "type": "object", "properties": { "pulp_href": { "title": " href", "type": "string", "format": "uri", "readOnly": true }, "pulp_created": { "title": " created", "description": "Timestamp of creation.", "type": "string", "format": "date-time", "readOnly": true }, "artifact": { "title": "Artifact", "description": "Artifact file representing the physical content", "type": "string", "format": "uri" }, "relative_path": { "title": "Relative path", "description": "Path where the artifact is located relative to distributions base_path", "type": "string", "minLength": 1 }, "digest": { "title": "Digest", "description": "sha256 of the Manifest file", "type": "string", "minLength": 1 }, "schema_version": { "title": "Schema version", "description": "Docker schema version", "type": "integer" }, "media_type": { "title": "Media type", "description": "Docker media type of the file", "type": "string", "minLength": 1 }, "listed_manifests": { "description": "Manifests that are referenced by this Manifest List", "type": "array", "items": { "description": "Manifests that are referenced by this Manifest List", "type": "string", "format": "uri" }, "uniqueItems": true }, "config_blob": { "title": "Config blob", "description": "Blob that contains configuration for this Manifest", "type": "string", "format": "uri" }, "blobs": { "description": "Blobs that are referenced by this Manifest", "type": "array", "items": { "description": "Blobs that are referenced by this Manifest", "type": "string", "format": "uri" }, "uniqueItems": true } } }, "Tag": { "required": [ "artifact", "relative_path", "name", "tagged_manifest" ], "type": "object", "properties": { "pulp_href": { "title": " href", "type": "string", "format": "uri", "readOnly": true }, "pulp_created": { "title": " created", "description": "Timestamp of creation.", "type": "string", "format": "date-time", "readOnly": true }, "artifact": { "title": "Artifact", "description": "Artifact file representing the physical content", "type": "string", "format": "uri" }, "relative_path": { "title": "Relative path", "description": "Path where the artifact is located relative to distributions base_path", "type": "string", "minLength": 1 }, "name": { "title": "Name", "description": "Tag name", "type": "string", "minLength": 1 }, "tagged_manifest": { "title": "Tagged manifest", "description": "Manifest that is tagged", "type": "string", "format": "uri" } } }, "DockerDistribution": { "required": [ "name", "base_path" ], "type": "object", "properties": { "pulp_href": { "title": " href", "type": "string", "format": "uri", "readOnly": true }, "content_guard": { "title": "Content guard", "description": "An optional content-guard.", "type": "string", "format": "uri", "x-nullable": true }, "repository_version": { "title": "Repository version", "description": "RepositoryVersion to be served", "type": "string", "format": "uri", "x-nullable": true }, "pulp_created": { "title": " created", "description": "Timestamp of creation.", "type": "string", "format": "date-time", "readOnly": true }, "name": { "title": "Name", "description": "A unique name. Ex, `rawhide` and `stable`.", "type": "string", "maxLength": 255, "minLength": 1 }, "repository": { "title": "Repository", "description": "The latest RepositoryVersion for this Repository will be served.", "type": "string", "format": "uri", "x-nullable": true }, "base_path": { "title": "Base path", "description": "The base (relative) path component of the published url. Avoid paths that overlap with other distribution base paths (e.g. \"foo\" and \"foo/bar\")", "type": "string", "maxLength": 255, "minLength": 1 }, "registry_path": { "title": "Registry path", "description": "The Registry hostame:port/name/ to use with docker pull command defined by this distribution.", "type": "string", "readOnly": true, "minLength": 1 } } }, "AsyncOperationResponse": { "required": [ "task" ], "type": "object", "properties": { "task": { "title": "Task", "description": "The href of the task.", "type": "string", "format": "uri" } } }, "ManifestCopy": { "required": [ "destination_repository" ], "type": "object", "properties": { "source_repository": { "title": "Repository", "description": "A URI of the repository to copy content from.", "type": "string", "format": "uri" }, "source_repository_version": { "title": "Source repository version", "description": "A URI of the repository version to copy content from.", "type": "string", "format": "uri" }, "destination_repository": { "title": "Repository", "description": "A URI of the repository to copy content to.", "type": "string", "format": "uri" }, "digests": { "description": "A list of manifest digests to copy.", "type": "array", "items": { "type": "string" } }, "media_types": { "description": "A list of media_types to copy.", "type": "array", "items": { "type": "string", "enum": [ "application/vnd.docker.distribution.manifest.v1+json", "application/vnd.docker.distribution.manifest.v2+json", "application/vnd.docker.distribution.manifest.list.v2+json" ] } } } }, "RecursiveManage": { "required": [ "repository" ], "type": "object", "properties": { "repository": { "title": "Repository", "description": "A URI of the repository to add content.", "type": "string", "format": "uri" }, "content_units": { "description": "A list of content units to operate on.", "type": "array", "items": { "type": "string" } } } }, "TagImage": { "required": [ "repository", "tag", "digest" ], "type": "object", "properties": { "repository": { "title": "Repository", "description": "A URI of the repository.", "type": "string", "format": "uri" }, "tag": { "title": "Tag", "description": "A tag name", "type": "string", "minLength": 1 }, "digest": { "title": "Digest", "description": "sha256 of the Manifest file", "type": "string", "minLength": 1 } } }, "TagCopy": { "required": [ "destination_repository" ], "type": "object", "properties": { "source_repository": { "title": "Repository", "description": "A URI of the repository to copy content from.", "type": "string", "format": "uri" }, "source_repository_version": { "title": "Source repository version", "description": "A URI of the repository version to copy content from.", "type": "string", "format": "uri" }, "destination_repository": { "title": "Repository", "description": "A URI of the repository to copy content to.", "type": "string", "format": "uri" }, "names": { "description": "A list of tag names to copy.", "type": "array", "items": { "type": "string" } } } }, "UnTagImage": { "required": [ "repository", "tag" ], "type": "object", "properties": { "repository": { "title": "Repository", "description": "A URI of the repository.", "type": "string", "format": "uri" }, "tag": { "title": "Tag", "description": "A tag name", "type": "string", "minLength": 1 } } }, "DockerRemote": { "required": [ "name", "url", "upstream_name" ], "type": "object", "properties": { "pulp_href": { "title": " href", "type": "string", "format": "uri", "readOnly": true }, "pulp_created": { "title": " created", "description": "Timestamp of creation.", "type": "string", "format": "date-time", "readOnly": true }, "name": { "title": "Name", "description": "A unique name for this remote.", "type": "string", "minLength": 1 }, "url": { "title": "Url", "description": "The URL of an external content source.", "type": "string", "minLength": 1 }, "ssl_ca_certificate": { "title": "Ssl ca certificate", "description": "A string containing the PEM encoded CA certificate used to validate the server certificate presented by the remote server. All new line characters must be escaped. Returns SHA256 sum on GET.", "type": "string", "minLength": 1, "x-nullable": true }, "ssl_client_certificate": { "title": "Ssl client certificate", "description": "A string containing the PEM encoded client certificate used for authentication. All new line characters must be escaped. Returns SHA256 sum on GET.", "type": "string", "minLength": 1, "x-nullable": true }, "ssl_client_key": { "title": "Ssl client key", "description": "A PEM encoded private key used for authentication. Returns SHA256 sum on GET.", "type": "string", "minLength": 1, "x-nullable": true }, "ssl_validation": { "title": "Ssl validation", "description": "If True, SSL peer validation must be performed.", "type": "boolean" }, "proxy_url": { "title": "Proxy url", "description": "The proxy URL. Format: scheme://user:password@host:port", "type": "string", "minLength": 1, "x-nullable": true }, "username": { "title": "Username", "description": "The username to be used for authentication when syncing.", "type": "string", "minLength": 1, "x-nullable": true }, "password": { "title": "Password", "description": "The password to be used for authentication when syncing.", "type": "string", "minLength": 1, "x-nullable": true }, "pulppulp_last_updated": { "title": " last updated", "description": "Timestamp of the most recent update of the remote.", "type": "string", "format": "date-time", "readOnly": true }, "download_concurrency": { "title": "Download concurrency", "description": "Total number of simultaneous connections.", "type": "integer", "minimum": 1 }, "policy": { "title": "Policy", "description": "\n immediate - All manifests and blobs are downloaded and saved during a sync.\n on_demand - Only tags and manifests are downloaded. Blobs are not\n downloaded until they are requested for the first time by a client.\n streamed - Blobs are streamed to the client with every request and never saved.\n ", "type": "string", "enum": [ "immediate", "on_demand", "streamed" ], "default": "immediate" }, "upstream_name": { "title": "Upstream name", "description": "Name of the upstream repository", "type": "string", "minLength": 1 }, "whitelist_tags": { "title": "Whitelist tags", "description": "A comma separated string of tags to sync.\n Example:\n\n latest,1.27.0\n ", "type": "string", "minLength": 1, "x-nullable": true } } }, "RepositorySyncURL": { "required": [ "repository" ], "type": "object", "properties": { "repository": { "title": "Repository", "description": "A URI of the repository to be synchronized.", "type": "string", "format": "uri" }, "mirror": { "title": "Mirror", "description": "If ``True``, synchronization will remove all content that is not present in the remote repository. If ``False``, sync will be additive only.", "type": "boolean", "default": false } } } }, "tags": [ { "name": "content: blobs", "x-displayName": "Content: Blobs" }, { "name": "content: manifests", "x-displayName": "Content: Manifests" }, { "name": "content: tags", "x-displayName": "Content: Tags" }, { "name": "distributions: docker", "x-displayName": "Distributions: Docker" }, { "name": "docker: copy", "x-displayName": "Docker: Copy" }, { "name": "docker: recursive-add", "x-displayName": "Docker: Recursive-Add" }, { "name": "docker: recursive-remove", "x-displayName": "Docker: Recursive-Remove" }, { "name": "docker: tag", "x-displayName": "Docker: Tag" }, { "name": "docker: untag", "x-displayName": "Docker: Untag" }, { "name": "remotes: docker", "x-displayName": "Remotes: Docker" } ] } +{"swagger": "2.0", "info": {"title": "Pulp 3 API", "logo": {"url": "https://pulp.plan.io/attachments/download/517478/pulp_logo_word_rectangle.svg"}, "version": "v3"}, "host": "localhost:24817", "schemes": ["http"], "basePath": "/", "consumes": ["application/json"], "produces": ["application/json"], "securityDefinitions": {"Basic": {"type": "basic"}}, "security": [{"Basic": []}], "paths": {"/pulp/api/v3/content/docker/blobs/": {"get": {"operationId": "content_docker_blobs_list", "summary": "List blobs", "description": "ViewSet for Blobs.", "parameters": [{"name": "digest", "in": "query", "description": "Filter results where digest matches value", "required": false, "type": "string"}, {"name": "digest__in", "in": "query", "description": "Filter results where digest is in a comma-separated list of values", "required": false, "type": "string"}, {"name": "repository_version", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string"}, {"name": "repository_version_added", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string"}, {"name": "repository_version_removed", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string"}, {"name": "media_type", "in": "query", "description": "", "required": false, "type": "string"}, {"name": "limit", "in": "query", "description": "Number of results to return per page.", "required": false, "type": "integer"}, {"name": "offset", "in": "query", "description": "The initial index from which to return the results.", "required": false, "type": "integer"}, {"name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string"}, {"name": "exclude_fields", "in": "query", "description": "A list of fields to exclude from the response.", "required": false, "type": "string"}], "responses": {"200": {"description": "", "schema": {"required": ["count", "results"], "type": "object", "properties": {"count": {"type": "integer"}, "next": {"type": "string", "format": "uri", "x-nullable": true}, "previous": {"type": "string", "format": "uri", "x-nullable": true}, "results": {"type": "array", "items": {"$ref": "#/definitions/Blob"}}}}}}, "tags": ["content: blobs"]}, "parameters": []}, "{blob_href}": {"get": {"operationId": "content_docker_blobs_read", "summary": "Inspect a blob", "description": "ViewSet for Blobs.", "parameters": [{"name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string"}, {"name": "exclude_fields", "in": "query", "description": "A list of fields to exclude from the response.", "required": false, "type": "string"}], "responses": {"200": {"description": "", "schema": {"$ref": "#/definitions/Blob"}}}, "tags": ["content: blobs"]}, "parameters": [{"name": "blob_href", "in": "path", "description": "URI of Blob. e.g.: /pulp/api/v3/content/docker/blobs/1/", "required": true, "type": "string"}]}, "/pulp/api/v3/content/docker/manifests/": {"get": {"operationId": "content_docker_manifests_list", "summary": "List manifests", "description": "ViewSet for Manifest.", "parameters": [{"name": "digest", "in": "query", "description": "Filter results where digest matches value", "required": false, "type": "string"}, {"name": "digest__in", "in": "query", "description": "Filter results where digest is in a comma-separated list of values", "required": false, "type": "string"}, {"name": "repository_version", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string"}, {"name": "repository_version_added", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string"}, {"name": "repository_version_removed", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string"}, {"name": "media_type", "in": "query", "description": "", "required": false, "type": "string"}, {"name": "limit", "in": "query", "description": "Number of results to return per page.", "required": false, "type": "integer"}, {"name": "offset", "in": "query", "description": "The initial index from which to return the results.", "required": false, "type": "integer"}, {"name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string"}, {"name": "exclude_fields", "in": "query", "description": "A list of fields to exclude from the response.", "required": false, "type": "string"}], "responses": {"200": {"description": "", "schema": {"required": ["count", "results"], "type": "object", "properties": {"count": {"type": "integer"}, "next": {"type": "string", "format": "uri", "x-nullable": true}, "previous": {"type": "string", "format": "uri", "x-nullable": true}, "results": {"type": "array", "items": {"$ref": "#/definitions/Manifest"}}}}}}, "tags": ["content: manifests"]}, "parameters": []}, "{manifest_href}": {"get": {"operationId": "content_docker_manifests_read", "summary": "Inspect a manifest", "description": "ViewSet for Manifest.", "parameters": [{"name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string"}, {"name": "exclude_fields", "in": "query", "description": "A list of fields to exclude from the response.", "required": false, "type": "string"}], "responses": {"200": {"description": "", "schema": {"$ref": "#/definitions/Manifest"}}}, "tags": ["content: manifests"]}, "parameters": [{"name": "manifest_href", "in": "path", "description": "URI of Manifest. e.g.: /pulp/api/v3/content/docker/manifests/1/", "required": true, "type": "string"}]}, "/pulp/api/v3/content/docker/tags/": {"get": {"operationId": "content_docker_tags_list", "summary": "List tags", "description": "ViewSet for Tag.", "parameters": [{"name": "name", "in": "query", "description": "Filter results where name matches value", "required": false, "type": "string"}, {"name": "name__in", "in": "query", "description": "Filter results where name is in a comma-separated list of values", "required": false, "type": "string"}, {"name": "repository_version", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string"}, {"name": "repository_version_added", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string"}, {"name": "repository_version_removed", "in": "query", "description": "Repository Version referenced by HREF", "required": false, "type": "string"}, {"name": "media_type", "in": "query", "description": "", "required": false, "type": "string"}, {"name": "digest", "in": "query", "description": "Multiple values may be separated by commas.", "required": false, "type": "string"}, {"name": "limit", "in": "query", "description": "Number of results to return per page.", "required": false, "type": "integer"}, {"name": "offset", "in": "query", "description": "The initial index from which to return the results.", "required": false, "type": "integer"}, {"name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string"}, {"name": "exclude_fields", "in": "query", "description": "A list of fields to exclude from the response.", "required": false, "type": "string"}], "responses": {"200": {"description": "", "schema": {"required": ["count", "results"], "type": "object", "properties": {"count": {"type": "integer"}, "next": {"type": "string", "format": "uri", "x-nullable": true}, "previous": {"type": "string", "format": "uri", "x-nullable": true}, "results": {"type": "array", "items": {"$ref": "#/definitions/Tag"}}}}}}, "tags": ["content: tags"]}, "parameters": []}, "{tag_href}": {"get": {"operationId": "content_docker_tags_read", "summary": "Inspect a tag", "description": "ViewSet for Tag.", "parameters": [{"name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string"}, {"name": "exclude_fields", "in": "query", "description": "A list of fields to exclude from the response.", "required": false, "type": "string"}], "responses": {"200": {"description": "", "schema": {"$ref": "#/definitions/Tag"}}}, "tags": ["content: tags"]}, "parameters": [{"name": "tag_href", "in": "path", "description": "URI of Tag. e.g.: /pulp/api/v3/content/docker/tags/1/", "required": true, "type": "string"}]}, "/pulp/api/v3/distributions/docker/docker/": {"get": {"operationId": "distributions_docker_docker_list", "summary": "List docker distributions", "description": "The Docker Distribution will serve the latest version of a Repository if\n``repository`` is specified. The Docker Distribution will serve a specific\nrepository version if ``repository_version``. Note that **either**\n``repository`` or ``repository_version`` can be set on a Docker\nDistribution, but not both.", "parameters": [{"name": "name", "in": "query", "description": "", "required": false, "type": "string"}, {"name": "name__in", "in": "query", "description": "Filter results where name is in a comma-separated list of values", "required": false, "type": "string"}, {"name": "base_path", "in": "query", "description": "", "required": false, "type": "string"}, {"name": "base_path__contains", "in": "query", "description": "Filter results where base_path contains value", "required": false, "type": "string"}, {"name": "base_path__icontains", "in": "query", "description": "Filter results where base_path contains value", "required": false, "type": "string"}, {"name": "base_path__in", "in": "query", "description": "Filter results where base_path is in a comma-separated list of values", "required": false, "type": "string"}, {"name": "limit", "in": "query", "description": "Number of results to return per page.", "required": false, "type": "integer"}, {"name": "offset", "in": "query", "description": "The initial index from which to return the results.", "required": false, "type": "integer"}, {"name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string"}, {"name": "exclude_fields", "in": "query", "description": "A list of fields to exclude from the response.", "required": false, "type": "string"}], "responses": {"200": {"description": "", "schema": {"required": ["count", "results"], "type": "object", "properties": {"count": {"type": "integer"}, "next": {"type": "string", "format": "uri", "x-nullable": true}, "previous": {"type": "string", "format": "uri", "x-nullable": true}, "results": {"type": "array", "items": {"$ref": "#/definitions/DockerDistribution"}}}}}}, "tags": ["distributions: docker"]}, "post": {"operationId": "distributions_docker_docker_create", "summary": "Create a docker distribution", "description": "Trigger an asynchronous create task", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/DockerDistribution"}}], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["distributions: docker"]}, "parameters": []}, "{docker_distribution_href}": {"get": {"operationId": "distributions_docker_docker_read", "summary": "Inspect a docker distribution", "description": "The Docker Distribution will serve the latest version of a Repository if\n``repository`` is specified. The Docker Distribution will serve a specific\nrepository version if ``repository_version``. Note that **either**\n``repository`` or ``repository_version`` can be set on a Docker\nDistribution, but not both.", "parameters": [{"name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string"}, {"name": "exclude_fields", "in": "query", "description": "A list of fields to exclude from the response.", "required": false, "type": "string"}], "responses": {"200": {"description": "", "schema": {"$ref": "#/definitions/DockerDistribution"}}}, "tags": ["distributions: docker"]}, "put": {"operationId": "distributions_docker_docker_update", "summary": "Update a docker distribution", "description": "Trigger an asynchronous update task", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/DockerDistribution"}}], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["distributions: docker"]}, "patch": {"operationId": "distributions_docker_docker_partial_update", "summary": "Partially update a docker distribution", "description": "Trigger an asynchronous partial update task", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/DockerDistribution"}}], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["distributions: docker"]}, "delete": {"operationId": "distributions_docker_docker_delete", "summary": "Delete a docker distribution", "description": "Trigger an asynchronous delete task", "parameters": [], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["distributions: docker"]}, "parameters": [{"name": "docker_distribution_href", "in": "path", "description": "URI of Docker Distribution. e.g.: /pulp/api/v3/distributions/docker/docker/1/", "required": true, "type": "string"}]}, "/pulp/api/v3/docker/manifests/copy/": {"post": {"operationId": "docker_manifests_copy_create", "description": "Trigger an asynchronous task to copy manifests", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/ManifestCopy"}}], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["docker: copy"]}, "parameters": []}, "/pulp/api/v3/docker/recursive-add/": {"post": {"operationId": "docker_recursive-add_create", "description": "Trigger an asynchronous task to recursively add docker content.", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/RecursiveManage"}}], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["docker: recursive-add"]}, "parameters": []}, "/pulp/api/v3/docker/recursive-remove/": {"post": {"operationId": "docker_recursive-remove_create", "description": "Trigger an asynchronous task to recursively remove docker content.", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/RecursiveManage"}}], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["docker: recursive-remove"]}, "parameters": []}, "/pulp/api/v3/docker/tag/": {"post": {"operationId": "docker_tag_create", "description": "Trigger an asynchronous task to create a new repository", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/TagImage"}}], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["docker: tag"]}, "parameters": []}, "/pulp/api/v3/docker/tags/copy/": {"post": {"operationId": "docker_tags_copy_create", "description": "Trigger an asynchronous task to copy tags", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/TagCopy"}}], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["docker: copy"]}, "parameters": []}, "/pulp/api/v3/docker/untag/": {"post": {"operationId": "docker_untag_create", "description": "Trigger an asynchronous task to create a new repository", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/UnTagImage"}}], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["docker: untag"]}, "parameters": []}, "/pulp/api/v3/remotes/docker/docker/": {"get": {"operationId": "remotes_docker_docker_list", "summary": "List docker remotes", "description": "Docker remotes represent an external repository that implements the Docker\nRegistry API. Docker remotes support deferred downloading by configuring\nthe ``policy`` field. ``on_demand`` and ``streamed`` policies can provide\nsignificant disk space savings.", "parameters": [{"name": "name", "in": "query", "description": "", "required": false, "type": "string"}, {"name": "name__in", "in": "query", "description": "Filter results where name is in a comma-separated list of values", "required": false, "type": "string"}, {"name": "_last_updated__lt", "in": "query", "description": "Filter results where _last_updated is less than value", "required": false, "type": "string"}, {"name": "_last_updated__lte", "in": "query", "description": "Filter results where _last_updated is less than or equal to value", "required": false, "type": "string"}, {"name": "_last_updated__gt", "in": "query", "description": "Filter results where _last_updated is greater than value", "required": false, "type": "string"}, {"name": "_last_updated__gte", "in": "query", "description": "Filter results where _last_updated is greater than or equal to value", "required": false, "type": "string"}, {"name": "_last_updated__range", "in": "query", "description": "Filter results where _last_updated is between two comma separated values", "required": false, "type": "string"}, {"name": "_last_updated", "in": "query", "description": "ISO 8601 formatted dates are supported", "required": false, "type": "string"}, {"name": "limit", "in": "query", "description": "Number of results to return per page.", "required": false, "type": "integer"}, {"name": "offset", "in": "query", "description": "The initial index from which to return the results.", "required": false, "type": "integer"}, {"name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string"}, {"name": "exclude_fields", "in": "query", "description": "A list of fields to exclude from the response.", "required": false, "type": "string"}], "responses": {"200": {"description": "", "schema": {"required": ["count", "results"], "type": "object", "properties": {"count": {"type": "integer"}, "next": {"type": "string", "format": "uri", "x-nullable": true}, "previous": {"type": "string", "format": "uri", "x-nullable": true}, "results": {"type": "array", "items": {"$ref": "#/definitions/DockerRemote"}}}}}}, "tags": ["remotes: docker"]}, "post": {"operationId": "remotes_docker_docker_create", "summary": "Create a docker remote", "description": "Docker remotes represent an external repository that implements the Docker\nRegistry API. Docker remotes support deferred downloading by configuring\nthe ``policy`` field. ``on_demand`` and ``streamed`` policies can provide\nsignificant disk space savings.", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/DockerRemote"}}], "responses": {"201": {"description": "", "schema": {"$ref": "#/definitions/DockerRemote"}}}, "tags": ["remotes: docker"]}, "parameters": []}, "{docker_remote_href}": {"get": {"operationId": "remotes_docker_docker_read", "summary": "Inspect a docker remote", "description": "Docker remotes represent an external repository that implements the Docker\nRegistry API. Docker remotes support deferred downloading by configuring\nthe ``policy`` field. ``on_demand`` and ``streamed`` policies can provide\nsignificant disk space savings.", "parameters": [{"name": "fields", "in": "query", "description": "A list of fields to include in the response.", "required": false, "type": "string"}, {"name": "exclude_fields", "in": "query", "description": "A list of fields to exclude from the response.", "required": false, "type": "string"}], "responses": {"200": {"description": "", "schema": {"$ref": "#/definitions/DockerRemote"}}}, "tags": ["remotes: docker"]}, "put": {"operationId": "remotes_docker_docker_update", "summary": "Update a docker remote", "description": "Trigger an asynchronous update task", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/DockerRemote"}}], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["remotes: docker"]}, "patch": {"operationId": "remotes_docker_docker_partial_update", "summary": "Partially update a docker remote", "description": "Trigger an asynchronous partial update task", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/DockerRemote"}}], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["remotes: docker"]}, "delete": {"operationId": "remotes_docker_docker_delete", "summary": "Delete a docker remote", "description": "Trigger an asynchronous delete task", "parameters": [], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["remotes: docker"]}, "parameters": [{"name": "docker_remote_href", "in": "path", "description": "URI of Docker Remote. e.g.: /pulp/api/v3/remotes/docker/docker/1/", "required": true, "type": "string"}]}, "{docker_remote_href}sync/": {"post": {"operationId": "remotes_docker_docker_sync", "description": "Trigger an asynchronous task to sync content.", "parameters": [{"name": "data", "in": "body", "required": true, "schema": {"$ref": "#/definitions/RepositorySyncURL"}}], "responses": {"202": {"description": "", "schema": {"$ref": "#/definitions/AsyncOperationResponse"}}}, "tags": ["remotes: docker"]}, "parameters": [{"name": "docker_remote_href", "in": "path", "description": "URI of Docker Remote. e.g.: /pulp/api/v3/remotes/docker/docker/1/", "required": true, "type": "string"}]}}, "definitions": {"Blob": {"required": ["artifact", "relative_path", "digest", "media_type"], "type": "object", "properties": {"_href": {"title": " href", "type": "string", "format": "uri", "readOnly": true}, "_created": {"title": " created", "description": "Timestamp of creation.", "type": "string", "format": "date-time", "readOnly": true}, "_type": {"title": " type", "type": "string", "readOnly": true, "minLength": 1}, "artifact": {"title": "Artifact", "description": "Artifact file representing the physical content", "type": "string", "format": "uri"}, "relative_path": {"title": "Relative path", "description": "Path where the artifact is located relative to distributions base_path", "type": "string", "minLength": 1}, "digest": {"title": "Digest", "description": "sha256 of the Blob file", "type": "string", "minLength": 1}, "media_type": {"title": "Media type", "description": "Docker media type of the file", "type": "string", "minLength": 1}}}, "Manifest": {"required": ["artifact", "relative_path", "digest", "schema_version", "media_type", "listed_manifests", "config_blob", "blobs"], "type": "object", "properties": {"_href": {"title": " href", "type": "string", "format": "uri", "readOnly": true}, "_created": {"title": " created", "description": "Timestamp of creation.", "type": "string", "format": "date-time", "readOnly": true}, "_type": {"title": " type", "type": "string", "readOnly": true, "minLength": 1}, "artifact": {"title": "Artifact", "description": "Artifact file representing the physical content", "type": "string", "format": "uri"}, "relative_path": {"title": "Relative path", "description": "Path where the artifact is located relative to distributions base_path", "type": "string", "minLength": 1}, "digest": {"title": "Digest", "description": "sha256 of the Manifest file", "type": "string", "minLength": 1}, "schema_version": {"title": "Schema version", "description": "Docker schema version", "type": "integer"}, "media_type": {"title": "Media type", "description": "Docker media type of the file", "type": "string", "minLength": 1}, "listed_manifests": {"description": "Manifests that are referenced by this Manifest List", "type": "array", "items": {"description": "Manifests that are referenced by this Manifest List", "type": "string", "format": "uri"}, "uniqueItems": true}, "config_blob": {"title": "Config blob", "description": "Blob that contains configuration for this Manifest", "type": "string", "format": "uri"}, "blobs": {"description": "Blobs that are referenced by this Manifest", "type": "array", "items": {"description": "Blobs that are referenced by this Manifest", "type": "string", "format": "uri"}, "uniqueItems": true}}}, "Tag": {"required": ["artifact", "relative_path", "name", "tagged_manifest"], "type": "object", "properties": {"_href": {"title": " href", "type": "string", "format": "uri", "readOnly": true}, "_created": {"title": " created", "description": "Timestamp of creation.", "type": "string", "format": "date-time", "readOnly": true}, "_type": {"title": " type", "type": "string", "readOnly": true, "minLength": 1}, "artifact": {"title": "Artifact", "description": "Artifact file representing the physical content", "type": "string", "format": "uri"}, "relative_path": {"title": "Relative path", "description": "Path where the artifact is located relative to distributions base_path", "type": "string", "minLength": 1}, "name": {"title": "Name", "description": "Tag name", "type": "string", "minLength": 1}, "tagged_manifest": {"title": "Tagged manifest", "description": "Manifest that is tagged", "type": "string", "format": "uri"}}}, "DockerDistribution": {"required": ["base_path", "name"], "type": "object", "properties": {"content_guard": {"title": "Content guard", "description": "An optional content-guard.", "type": "string", "format": "uri", "x-nullable": true}, "repository_version": {"title": "Repository version", "description": "RepositoryVersion to be served", "type": "string", "format": "uri", "x-nullable": true}, "base_path": {"title": "Base path", "description": "The base (relative) path component of the published url. Avoid paths that overlap with other distribution base paths (e.g. \"foo\" and \"foo/bar\")", "type": "string", "maxLength": 255, "minLength": 1}, "_href": {"title": " href", "type": "string", "format": "uri", "readOnly": true}, "repository": {"title": "Repository", "description": "The latest RepositoryVersion for this Repository will be served.", "type": "string", "format": "uri", "x-nullable": true}, "name": {"title": "Name", "description": "A unique name. Ex, `rawhide` and `stable`.", "type": "string", "maxLength": 255, "minLength": 1}, "_created": {"title": " created", "description": "Timestamp of creation.", "type": "string", "format": "date-time", "readOnly": true}, "registry_path": {"title": "Registry path", "description": "The Registry hostame:port/name/ to use with docker pull command defined by this distribution.", "type": "string", "readOnly": true, "minLength": 1}}}, "AsyncOperationResponse": {"required": ["task"], "type": "object", "properties": {"task": {"title": "Task", "description": "The href of the task.", "type": "string", "format": "uri"}}}, "ManifestCopy": {"required": ["destination_repository"], "type": "object", "properties": {"source_repository": {"title": "Repository", "description": "A URI of the repository to copy content from.", "type": "string", "format": "uri"}, "source_repository_version": {"title": "Source repository version", "description": "A URI of the repository version to copy content from.", "type": "string", "format": "uri"}, "destination_repository": {"title": "Repository", "description": "A URI of the repository to copy content to.", "type": "string", "format": "uri"}, "digests": {"description": "A list of manifest digests to copy.", "type": "array", "items": {"type": "string"}}, "media_types": {"description": "A list of media_types to copy.", "type": "array", "items": {"type": "string", "enum": ["application/vnd.docker.distribution.manifest.v1+json", "application/vnd.docker.distribution.manifest.v2+json", "application/vnd.docker.distribution.manifest.list.v2+json"]}}}}, "RecursiveManage": {"required": ["repository"], "type": "object", "properties": {"repository": {"title": "Repository", "description": "A URI of the repository to add content.", "type": "string", "format": "uri"}, "content_units": {"description": "A list of content units to operate on.", "type": "array", "items": {"type": "string"}}}}, "TagImage": {"required": ["repository", "tag", "digest"], "type": "object", "properties": {"repository": {"title": "Repository", "description": "A URI of the repository.", "type": "string", "format": "uri"}, "tag": {"title": "Tag", "description": "A tag name", "type": "string", "minLength": 1}, "digest": {"title": "Digest", "description": "sha256 of the Manifest file", "type": "string", "minLength": 1}}}, "TagCopy": {"required": ["destination_repository"], "type": "object", "properties": {"source_repository": {"title": "Repository", "description": "A URI of the repository to copy content from.", "type": "string", "format": "uri"}, "source_repository_version": {"title": "Source repository version", "description": "A URI of the repository version to copy content from.", "type": "string", "format": "uri"}, "destination_repository": {"title": "Repository", "description": "A URI of the repository to copy content to.", "type": "string", "format": "uri"}, "names": {"description": "A list of tag names to copy.", "type": "array", "items": {"type": "string"}}}}, "UnTagImage": {"required": ["repository", "tag"], "type": "object", "properties": {"repository": {"title": "Repository", "description": "A URI of the repository.", "type": "string", "format": "uri"}, "tag": {"title": "Tag", "description": "A tag name", "type": "string", "minLength": 1}}}, "DockerRemote": {"required": ["name", "url", "upstream_name"], "type": "object", "properties": {"_href": {"title": " href", "type": "string", "format": "uri", "readOnly": true}, "_created": {"title": " created", "description": "Timestamp of creation.", "type": "string", "format": "date-time", "readOnly": true}, "_type": {"title": " type", "type": "string", "readOnly": true, "minLength": 1}, "name": {"title": "Name", "description": "A unique name for this remote.", "type": "string", "minLength": 1}, "url": {"title": "Url", "description": "The URL of an external content source.", "type": "string", "minLength": 1}, "ssl_ca_certificate": {"title": "Ssl ca certificate", "description": "A string containing the PEM encoded CA certificate used to validate the server certificate presented by the remote server. All new line characters must be escaped. Returns SHA256 sum on GET.", "type": "string", "minLength": 1, "x-nullable": true}, "ssl_client_certificate": {"title": "Ssl client certificate", "description": "A string containing the PEM encoded client certificate used for authentication. All new line characters must be escaped. Returns SHA256 sum on GET.", "type": "string", "minLength": 1, "x-nullable": true}, "ssl_client_key": {"title": "Ssl client key", "description": "A PEM encoded private key used for authentication. Returns SHA256 sum on GET.", "type": "string", "minLength": 1, "x-nullable": true}, "ssl_validation": {"title": "Ssl validation", "description": "If True, SSL peer validation must be performed.", "type": "boolean"}, "proxy_url": {"title": "Proxy url", "description": "The proxy URL. Format: scheme://user:password@host:port", "type": "string", "minLength": 1, "x-nullable": true}, "username": {"title": "Username", "description": "The username to be used for authentication when syncing.", "type": "string", "minLength": 1, "x-nullable": true}, "password": {"title": "Password", "description": "The password to be used for authentication when syncing.", "type": "string", "minLength": 1, "x-nullable": true}, "_last_updated": {"title": " last updated", "description": "Timestamp of the most recent update of the remote.", "type": "string", "format": "date-time", "readOnly": true}, "download_concurrency": {"title": "Download concurrency", "description": "Total number of simultaneous connections.", "type": "integer", "minimum": 1}, "policy": {"title": "Policy", "description": "\n immediate - All manifests and blobs are downloaded and saved during a sync.\n on_demand - Only tags and manifests are downloaded. Blobs are not\n downloaded until they are requested for the first time by a client.\n streamed - Blobs are streamed to the client with every request and never saved.\n ", "type": "string", "enum": ["immediate", "on_demand", "streamed"], "default": "immediate"}, "upstream_name": {"title": "Upstream name", "description": "Name of the upstream repository", "type": "string", "minLength": 1}, "whitelist_tags": {"title": "Whitelist tags", "description": "A comma separated string of tags to sync.\n Example:\n\n latest,1.27.0\n ", "type": "string", "minLength": 1, "x-nullable": true}}}, "RepositorySyncURL": {"required": ["repository"], "type": "object", "properties": {"repository": {"title": "Repository", "description": "A URI of the repository to be synchronized.", "type": "string", "format": "uri"}, "mirror": {"title": "Mirror", "description": "If ``True``, synchronization will remove all content that is not present in the remote repository. If ``False``, sync will be additive only.", "type": "boolean", "default": false}}}}, "tags": [{"name": "content: blobs", "x-displayName": "Content: Blobs"}, {"name": "content: manifests", "x-displayName": "Content: Manifests"}, {"name": "content: tags", "x-displayName": "Content: Tags"}, {"name": "distributions: docker", "x-displayName": "Distributions: Docker"}, {"name": "docker: copy", "x-displayName": "Docker: Copy"}, {"name": "docker: recursive-add", "x-displayName": "Docker: Recursive-Add"}, {"name": "docker: recursive-remove", "x-displayName": "Docker: Recursive-Remove"}, {"name": "docker: tag", "x-displayName": "Docker: Tag"}, {"name": "docker: untag", "x-displayName": "Docker: Untag"}, {"name": "remotes: docker", "x-displayName": "Remotes: Docker"}]} \ No newline at end of file diff --git a/docs/workflows/authentication.rst b/docs/workflows/authentication.rst new file mode 100644 index 00000000..2e04f465 --- /dev/null +++ b/docs/workflows/authentication.rst @@ -0,0 +1,97 @@ +.. _authentication: + +Registry Token Authentication +============================= + +Pulp registry supports the `token authentication `_. +This enables users to pull content with an authorized access. A token server grants access based on the +user's privileges and current scope. + +The feature is enabled by default. However, it is required to define the following settings first: + + - **A fully qualified domain name of a token server**. The token server is responsible for generating + Bearer tokens. Append the constant ``TOKEN_SERVER`` to the settings file ``pulp_docker/app/settings.py``. + - **A token signature algorithm**. A particular signature algorithm can be chosen only from the list of + `supported algorithms `_. + Pulp uses exclusively asymmetric cryptography to sign and validate tokens. Therefore, it is possible + only to choose from the algorithms, such as ES256, RS256, or PS256. Append the the constant + ``TOKEN_SIGNATURE_ALGORITHM`` with a selected algorithm to the settings file. + - **Paths to secure keys**. These keys are going to be used for a signing and validation of tokens. + Remember that the keys have to be specified in the **PEM format**. To generate keys, one could use + the openssl utility. In the following example, the utility is used to generate keys with the algorithm + ES256. + + 1. Generate a private key:: + + $ openssl ecparam -genkey -name prime256v1 -noout -out /tmp/private_key.pem + + 2. Generate a public key out of the private key:: + + $ openssl ec -in /tmp/private_key.pem -pubout -out /tmp/public_key.pem + +Below is provided and example of the settings file: + +.. code-block:: python + + TOKEN_SERVER = "localhost:24816/token" + TOKEN_SIGNATURE_ALGORITHM = 'ES256' + PUBLIC_KEY_PATH = '/tmp/public_key.pem' + PRIVATE_KEY_PATH = '/tmp/private_key.pem' + +To learn more about Pulp settings, take a look at `Configuration +`_. + +Restart Pulp services in order to reload the updated settings. Pulp will fetch a domain for the token +server and will initialize all handlers according to that. Check if the token authentication was +successfully configured by initiating the following set of commands in your environment:: + + $ http 'http://localhost:24816/v2/' + + HTTP/1.1 401 Access to the requested resource is not authorized. A provided Bearer token is invalid. + Content-Length: 92 + Content-Type: text/plain; charset=utf-8 + Date: Mon, 14 Oct 2019 16:46:48 GMT + Docker-Distribution-API-Version: registry/2.0 + Server: Python/3.7 aiohttp/3.6.1 + Www-Authenticate: Bearer realm="http://localhost:24816/token",service="localhost:24816" + + 401: Access to the requested resource is not authorized. A provided Bearer token is invalid. + +Send a request to a specified realm:: + + $ http 'http://localhost:24816/token?service=localhost:24816' + + HTTP/1.1 200 OK + Content-Length: 566 + Content-Type: application/json; charset=utf-8 + Date: Mon, 14 Oct 2019 16:47:33 GMT + Server: Python/3.7 aiohttp/3.6.1 + + { + "expires_in": 300, + "issued_at": "2019-10-14T16:47:33.107118Z", + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IkhBM1Q6SVlSUjpHUTNUOklPTEM6TVE0RzpFT0xDOkdGUVQ6QVpURTpHQlNXOkNaUlY6TUlZVzpLTkpWIn0.eyJhY2Nlc3MiOlt7InR5cGUiOiIiLCJuYW1lIjoiIiwiYWN0aW9ucyI6W119XSwiYXVkIjoibG9jYWxob3N0OjI0ODE2IiwiZXhwIjoxNTcxMDcxOTUzLCJpYXQiOjE1NzEwNzE2NTMsImlzcyI6ImxvY2FsaG9zdDoyNDgxNi90b2tlbiIsImp0aSI6IjRmYTliYTYwLTY0ZTUtNDA3MC1hMzMyLWZmZTRlMTk2YzVjNyIsIm5iZiI6MTU3MTA3MTY1Mywic3ViIjoiIn0.pirj8yhbjYnldxmZ-jIZ72VJrzxkAnwLXLu1ND9QAL-kl3gZrvPbp98w2xdhEoQ_7WEka4veb6uU5ZzmD87X1Q" + } + +Use the generated token to access the root again:: + + $ http 'localhost:24816/v2/' --auth-type=jwt --auth="eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IkhBM1Q6SVlSUjpHUTNUOklPTEM6TVE0RzpFT0xDOkdGUVQ6QVpURTpHQlNXOkNaUlY6TUlZVzpLTkpWIn0.eyJhY2Nlc3MiOlt7InR5cGUiOiIiLCJuYW1lIjoiIiwiYWN0aW9ucyI6W119XSwiYXVkIjoibG9jYWxob3N0OjI0ODE2IiwiZXhwIjoxNTcxMDcxOTUzLCJpYXQiOjE1NzEwNzE2NTMsImlzcyI6ImxvY2FsaG9zdDoyNDgxNi90b2tlbiIsImp0aSI6IjRmYTliYTYwLTY0ZTUtNDA3MC1hMzMyLWZmZTRlMTk2YzVjNyIsIm5iZiI6MTU3MTA3MTY1Mywic3ViIjoiIn0.pirj8yhbjYnldxmZ-jIZ72VJrzxkAnwLXLu1ND9QAL-kl3gZrvPbp98w2xdhEoQ_7WEka4veb6uU5ZzmD87X1Q" + + HTTP/1.1 200 OK + Content-Length: 2 + Content-Type: application/json; charset=utf-8 + Date: Mon, 14 Oct 2019 16:50:26 GMT + Docker-Distribution-API-Version: registry/2.0 + Server: Python/3.7 aiohttp/3.6.1 + + {} + +After performing multiple HTTP requests, the root responded with a default value ``{}``. Received +token can be used to access all endpoints within the requested scope too. + +Regular container engines, like docker, or podman, can take advantage of the token authentication. +The authentication is handled by the engines as shown before. + +.. code-block:: bash + + podman pull localhost:24816/foo/bar diff --git a/docs/workflows/index.rst b/docs/workflows/index.rst index 1db66013..b7a39ea5 100644 --- a/docs/workflows/index.rst +++ b/docs/workflows/index.rst @@ -21,6 +21,12 @@ in the home directory. The ``.netrc`` should have the following configuration: login admin password admin +One should observe that ``httpie`` uses the configuration retrieved from ``.netrc`` by default. +Due to this, a custom Authorization header is always overwritten by the Basic Authorization with +the provided login and password. In order to send HTTP requests which contain JWT Authorization +headers, ensure yourself that the plugin `JWTAuth plugin `_ +was already installed. + If you configured the ``admin`` user with a different password, adjust the configuration accordingly. If you prefer to specify the username and password with each request, please see ``httpie`` documentation on how to do that. @@ -49,3 +55,4 @@ Container Workflows sync host manage-content + authentication diff --git a/pulp_docker/app/authorization.py b/pulp_docker/app/authorization.py new file mode 100644 index 00000000..2aac03a5 --- /dev/null +++ b/pulp_docker/app/authorization.py @@ -0,0 +1,178 @@ +import base64 +import hashlib +import random +import uuid + +import jwt + +from datetime import datetime +from aiohttp import web + +from django.conf import settings +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization + +from pulpcore.plugin.content import Handler + +TOKEN_EXPIRATION_TIME = 300 + +KNOWN_SERVICES = [settings.CONTENT_HOST] +ANONYMOUS_USER = '' +EMPTY_ACCESS_SCOPE = '::' + + +class AuthorizationService(Handler): + """ + A class responsible for generating and managing a Bearer token. + + This class represents a token server which manages and grants permissions + according to a user's scope. + """ + + async def generate_token(self, request): + """ + Generate a Bearer token. + + A signed JSON web token is generated in this method. The structure of the token is + adjusted according the documentation https://docs.docker.com/registry/spec/auth/jwt/. + + Args: + request(:class:`~aiohttp.web.Request`): The request to prepare a response for. + + Returns: + class:`aiohttp.web_response.Response`: A newly generated Bearer token. + + """ + with open(settings.PUBLIC_KEY_PATH, 'rb') as public_key: + kid = self.generate_kid_header(public_key.read()) + + current_datetime = datetime.now() + + token_queries = TokenRequestQueries.init_from(request) + access = self.determine_access(ANONYMOUS_USER, token_queries.scope) + token_server = getattr(settings, 'TOKEN_SERVER', '') + claim_set = self._generate_claim_set( + access=[access], + audience=token_queries.service, + issued_at=int(current_datetime.timestamp()), + issuer=token_server, + subject=ANONYMOUS_USER + ) + + with open(settings.PRIVATE_KEY_PATH, 'rb') as private_key: + binary_token = jwt.encode( + claim_set, private_key.read(), + algorithm=settings.TOKEN_SIGNATURE_ALGORITHM, + headers={'kid': kid} + ) + token = binary_token.decode('utf8') + current_datetime_utc = current_datetime.strftime('%Y-%m-%dT%H:%M:%S.%fZ') + return web.json_response({ + 'expires_in': TOKEN_EXPIRATION_TIME, + 'issued_at': current_datetime_utc, + 'token': token + }) + + def generate_kid_header(self, public_key): + """Generate kid header in a libtrust compatible format.""" + decoded_key = self._convert_key_format_from_pem_to_der(public_key) + truncated_sha256 = hashlib.sha256(decoded_key).hexdigest()[:30].encode('utf8') + encoded_base32 = base64.b32encode(truncated_sha256).decode('utf8') + return self._split_into_encoded_groups(encoded_base32) + + def _convert_key_format_from_pem_to_der(self, public_key): + key_in_pem_format = serialization.load_pem_public_key(public_key, default_backend()) + key_in_der_format = key_in_pem_format.public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo + ) + return key_in_der_format + + def _split_into_encoded_groups(self, encoded_base32): + """Split encoded and truncated base32 into 12 groups separated by ':'.""" + kid = encoded_base32[:4] + for index, char in enumerate(encoded_base32[4:], start=0): + if index % 4 == 0: + kid += ':' + char + else: + kid += char + return kid + + def determine_access(self, user, scope): + """ + Determine access permissions for a corresponding user. + + This method determines whether the user has a valid access permission or not. + The determination is based on role based access control. For now, the access + is given out to anybody because the role based access control is not implemented + yet. + + Args: + user (str): A name of the user who is trying to access a registry. + scope (str): A requested scope. + + Returns: + list: An intersected set of the requested and the allowed access. + + """ + typ, name, actions = scope.split(':') + actions_list = actions.split(',') + permitted_actions = list(set(actions_list).intersection(['pull'])) + return {'type': typ, 'name': name, 'actions': permitted_actions} + + def _generate_claim_set(self, issuer, issued_at, subject, audience, access): + token_id = str(uuid.UUID(int=random.getrandbits(128), version=4)) + expiration = issued_at + TOKEN_EXPIRATION_TIME + return { + 'access': access, + 'aud': audience, + 'exp': expiration, + 'iat': issued_at, + 'iss': issuer, + 'jti': token_id, + 'nbf': issued_at, + 'sub': subject + } + + +class TokenRequestQueries: + """A data class that holds data retrieved from the request queries.""" + + def __init__(self, scope, service): + """ + Store a scope and a service. + + Args: + scope (str): A requested scope. + service (str): A service that request a Bearer token. + + """ + self.scope = scope + self.service = service + + @classmethod + def init_from(cls, request): + """ + Initialize the actual class with data retrieved from the request queries. + + In this method, a validity and a presence of required queries (scope, service) + is checked as well. If the scope is not specified, the method checks if a user + is trying to access root endpoint only. Then, the scope is not relevant anymore + and initialized to empty type, name, and requested actions ('::'). + """ + try: + scope = request.query['scope'] + except KeyError: + if request.match_info: + raise web.HTTPBadRequest(reason='A scope was not provided.') + else: + scope = EMPTY_ACCESS_SCOPE + + try: + service = request.query['service'] + except KeyError: + raise web.HTTPBadRequest(reason='A service name was not provided.') + if service not in KNOWN_SERVICES: + raise web.HTTPBadRequest(reason='A provided service is unknown.') + + return cls(scope, service) diff --git a/pulp_docker/app/content.py b/pulp_docker/app/content.py index 648f2191..f93debfa 100644 --- a/pulp_docker/app/content.py +++ b/pulp_docker/app/content.py @@ -2,6 +2,7 @@ from pulpcore.content import app from pulp_docker.app.registry import Registry +from pulp_docker.app.authorization import AuthorizationService registry = Registry() @@ -10,3 +11,7 @@ app.add_routes([web.get(r'/v2/{path:.+}/manifests/sha256:{digest:.+}', registry.get_by_digest)]) app.add_routes([web.get(r'/v2/{path:.+}/manifests/{tag_name}', registry.get_tag)]) app.add_routes([web.get(r'/v2/{path:.+}/tags/list', registry.tags_list)]) + +authorization_service = AuthorizationService() + +app.add_routes([web.get('/token', authorization_service.generate_token)]) diff --git a/pulp_docker/app/downloaders.py b/pulp_docker/app/downloaders.py index 9cab5b3a..e0792825 100644 --- a/pulp_docker/app/downloaders.py +++ b/pulp_docker/app/downloaders.py @@ -45,6 +45,7 @@ async def _run(self, handle_401=True, extra_data=None): Args: handle_401(bool): If true, catch 401, request a new token and retry. + """ headers = {} repo_name = None diff --git a/pulp_docker/app/registry.py b/pulp_docker/app/registry.py index d8f13e72..f95915b3 100644 --- a/pulp_docker/app/registry.py +++ b/pulp_docker/app/registry.py @@ -9,9 +9,9 @@ from pulpcore.plugin.content import Handler, PathNotResolved from pulpcore.plugin.models import ContentArtifact from pulp_docker.app.models import DockerDistribution, Tag +from pulp_docker.app.token_verification import TokenVerifier from pulp_docker.constants import MEDIA_TYPE - log = logging.getLogger(__name__) v2_headers = MultiDict() @@ -96,15 +96,17 @@ async def _dispatch(path, headers): async def serve_v2(request): """ Handler for Docker Registry v2 root. - - The docker client uses this endpoint to discover that the V2 API is available. """ + Registry.verify_token(request, 'pull') + return web.json_response({}, headers=v2_headers) async def tags_list(self, request): """ Handler for Docker Registry v2 tags/list API. """ + Registry.verify_token(request, 'pull') + path = request.match_info['path'] distribution = self._match_distribution(path) tags = {'name': path, 'tags': set()} @@ -132,6 +134,8 @@ async def get_tag(self, request): streamed back to the client. """ + Registry.verify_token(request, 'pull') + path = request.match_info['path'] tag_name = request.match_info['tag_name'] distribution = self._match_distribution(path) @@ -194,6 +198,8 @@ async def get_by_digest(self, request): """ Return a response to the "GET" action. """ + Registry.verify_token(request, 'pull') + path = request.match_info['path'] digest = "sha256:{digest}".format(digest=request.match_info['digest']) distribution = self._match_distribution(path) @@ -214,3 +220,9 @@ async def get_by_digest(self, request): headers) else: return await self._stream_content_artifact(request, web.StreamResponse(), ca) + + @staticmethod + def verify_token(request, access_action): + """Verify a Bearer token.""" + token_verifier = TokenVerifier(request, access_action) + token_verifier.verify() diff --git a/pulp_docker/app/settings.py b/pulp_docker/app/settings.py new file mode 100644 index 00000000..6143f5ac --- /dev/null +++ b/pulp_docker/app/settings.py @@ -0,0 +1,2 @@ +TOKEN_SERVER = 'localhost:24816/token' +TOKEN_SIGNATURE_ALGORITHM = 'ES256' diff --git a/pulp_docker/app/tasks/recursive_add.py b/pulp_docker/app/tasks/recursive_add.py index 3044d58d..837512e6 100644 --- a/pulp_docker/app/tasks/recursive_add.py +++ b/pulp_docker/app/tasks/recursive_add.py @@ -16,6 +16,7 @@ def recursive_add_content(repository_pk, content_units): should be created. content_units (list): List of PKs for :class:`~pulpcore.app.models.Content` that should be added to the previous Repository Version for this Repository. + """ repository = Repository.objects.get(pk=repository_pk) diff --git a/pulp_docker/app/tasks/recursive_remove.py b/pulp_docker/app/tasks/recursive_remove.py index f35cae1a..59d917bd 100644 --- a/pulp_docker/app/tasks/recursive_remove.py +++ b/pulp_docker/app/tasks/recursive_remove.py @@ -27,6 +27,7 @@ def recursive_remove_content(repository_pk, content_units): should be created. content_units (list): List of PKs for :class:`~pulpcore.app.models.Content` that should be removed from the Repository. + """ repository = Repository.objects.get(pk=repository_pk) latest_version = RepositoryVersion.latest(repository) diff --git a/pulp_docker/app/tasks/sync_stages.py b/pulp_docker/app/tasks/sync_stages.py index 9588f7ce..1acd60bf 100644 --- a/pulp_docker/app/tasks/sync_stages.py +++ b/pulp_docker/app/tasks/sync_stages.py @@ -200,6 +200,7 @@ def create_tagged_manifest_list(self, tag_dc, manifest_list_data): Args: tag_dc (pulpcore.plugin.stages.DeclarativeContent): dc for a Tag manifest_list_data (dict): Data about a ManifestList + """ digest = "sha256:{digest}".format(digest=tag_dc.d_artifacts[0].artifact.sha256) relative_url = '/v2/{name}/manifests/{digest}'.format( @@ -231,6 +232,7 @@ def create_tagged_manifest(self, tag_dc, manifest_data, raw_data): tag_dc (pulpcore.plugin.stages.DeclarativeContent): dc for a Tag manifest_data (dict): Data about a single new ImageManifest. raw_data: (str): The raw JSON representation of the ImageManifest. + """ media_type = manifest_data.get('mediaType', MEDIA_TYPE.MANIFEST_V1) if media_type == MEDIA_TYPE.MANIFEST_V2: @@ -265,6 +267,7 @@ def create_manifest(self, list_dc, manifest_data): Args: list_dc (pulpcore.plugin.stages.DeclarativeContent): dc for a ManifestList manifest_data (dict): Data about a single new ImageManifest. + """ digest = manifest_data['digest'] relative_url = '/v2/{name}/manifests/{digest}'.format( @@ -453,6 +456,7 @@ def relate_config_blob(self, dc): Args: dc (pulpcore.plugin.stages.DeclarativeContent): dc for a Blob + """ configured_dc = dc.extra_data.get('config_relation') configured_dc.content.config_blob = dc.content @@ -464,6 +468,7 @@ def relate_blob(self, dc): Args: dc (pulpcore.plugin.stages.DeclarativeContent): dc for a Blob + """ related_dc = dc.extra_data.get('blob_relation') thru = BlobManifest(manifest=related_dc.content, manifest_blob=dc.content) @@ -478,6 +483,7 @@ def relate_manifest_tag(self, dc): Args: dc (pulpcore.plugin.stages.DeclarativeContent): dc for a Tag + """ related_dc = dc.extra_data.get('man_relation') dc.content.tagged_manifest = related_dc.content @@ -495,6 +501,7 @@ def relate_manifest_to_list(self, dc): Args: dc (pulpcore.plugin.stages.DeclarativeContent): dc for a ImageManifest + """ related_dc = dc.extra_data.get('relation') platform = dc.extra_data.get('platform') diff --git a/pulp_docker/app/token_verification.py b/pulp_docker/app/token_verification.py new file mode 100644 index 00000000..60370541 --- /dev/null +++ b/pulp_docker/app/token_verification.py @@ -0,0 +1,177 @@ +import jwt + +from aiohttp import web +from django.conf import settings + + +class TokenVerifier: + """A class used for a token verification.""" + + def __init__(self, request, access_action): + """ + Store data required for the token verification. + + Args: + request (:class:`~aiohttp.web.Request`): The request with an Authorization header. + access_action (str): A required action to perform pulling/pushing. + + """ + self.request = request + self.access_action = access_action + + def verify(self): + """Verify a Bearer token.""" + authorization = self.get_authorization_header() + self.check_authorization_token(authorization) + + def get_authorization_header(self): + """ + Fetch an Authorization header from the request. + + Raises: + web.HTTPUnauthorized: An Authorization header is missing in the header. + + Returns: + A raw string containing a Bearer token. + + """ + try: + return self.request.headers['Authorization'] + except KeyError: + raise web.HTTPUnauthorized( + headers=self._build_response_headers(), + reason='Access to the requested resource is not authorized. ' + 'A Bearer token is missing in a request header.' + ) + + def check_authorization_token(self, authorization): + """ + Check if a Bearer token is valid. + + Args: + authorization: A string containing a Bearer token. + + Raises: + web.HTTPUnauthorized: A Bearer token is not valid. + + """ + token = self._get_token(authorization) + if not self.is_token_valid(token): + raise web.HTTPUnauthorized( + headers=self._build_response_headers(), + reason='Access to the requested resource is not authorized. ' + 'A provided Bearer token is invalid.' + ) + + def _get_token(self, authorization): + """ + Get a raw token string from the header. + + This method returns a string that skips the keyword 'Bearer' and an additional + space from the header (e.g. "Bearer abcdef123456" -> "abcdef123456"). + """ + return authorization[7:] + + def _build_response_headers(self): + """ + Build headers that a registry returns as a response to unauthorized access. + + The method creates a value for the Www-Authenticate header. This value is used + by a client for requesting a Bearer token from a token server. The header + Docker-Distribution-API-Version is generated too to inform the client about + the supported schema type. + """ + source_path = self._get_current_content_path() + authenticate_header = self._build_authenticate_string(source_path) + + headers = { + 'Docker-Distribution-API-Version': 'registry/2.0', + 'Www-Authenticate': authenticate_header + } + return headers + + def _build_authenticate_string(self, source_path): + """ + Build a formatted authenticate string. + + For example, A created string is the following format: + realm="https://token",service="docker.io",scope="repository:my-app:push". + """ + realm = f'{self.request.scheme}://{settings.TOKEN_SERVER}' + authenticate_string = f'Bearer realm="{realm}",service="{settings.CONTENT_HOST}"' + + if not self._is_verifying_root_endpoint(): + scope = f'repository:{source_path}:pull' + authenticate_string += f',scope="{scope}"' + + return authenticate_string + + def is_token_valid(self, encoded_token): + """Decode and validate a token.""" + with open(settings.PUBLIC_KEY_PATH, 'rb') as public_key: + decoded_token = self.decode_token(encoded_token, public_key.read()) + + return self.contains_accessible_actions(decoded_token) + + def decode_token(self, encoded_token, public_key): + """ + Decode token and verify a signature with a public key. + + If the token could not be decoded with a success, a client does not have a + permission to operate with a registry. + """ + jwt_config = self._init_jwt_decoder_config() + try: + decoded_token = jwt.decode(encoded_token, public_key, **jwt_config) + except jwt.exceptions.InvalidTokenError: + decoded_token = {'access': []} + return decoded_token + + def _init_jwt_decoder_config(self): + """Initialize a basic configuration used for sanitizing and decoding a token.""" + return { + 'algorithms': [settings.TOKEN_SIGNATURE_ALGORITHM], + 'issuer': settings.TOKEN_SERVER, + 'audience': settings.CONTENT_HOST + } + + def contains_accessible_actions(self, decoded_token): + """Check if a client has an access permission to execute the pull/push operation.""" + for access in decoded_token['access']: + if self._targets_current_content_path(access): + return True + + return False + + def _targets_current_content_path(self, access): + """ + Check if a client targets a valid content path. + + When a client targets the root endpoint, the verifier does not necessary need to + check for the pull or push access permission, therefore, it is granted automatically. + """ + content_path = self._get_current_content_path() + + if content_path == access['name']: + if self.access_action in access['actions']: + return True + if self._is_verifying_root_endpoint(): + return True + + return False + + def _get_current_content_path(self): + """ + Retrieve a content path from the request. + + If the path does not exist, it means that a client is querying a root endpoint. + """ + try: + content_path = self.request.match_info['path'] + except KeyError: + content_path = '' + return content_path + + def _is_verifying_root_endpoint(self): + """If the root endpoint is queried, no matching info is present.""" + return not bool(self.request.match_info) diff --git a/pulp_docker/tests/functional/api/test_token_authentication.py b/pulp_docker/tests/functional/api/test_token_authentication.py new file mode 100644 index 00000000..2d52abda --- /dev/null +++ b/pulp_docker/tests/functional/api/test_token_authentication.py @@ -0,0 +1,162 @@ +# coding=utf-8 +"""Tests for token authentication.""" +import unittest + +from urllib.parse import urljoin +from requests.exceptions import HTTPError +from requests.auth import AuthBase + +from pulp_smash import api, config, cli +from pulp_smash.pulp3.utils import gen_repo, sync, gen_distribution +from pulp_smash.pulp3.constants import REPO_PATH + +from pulp_docker.tests.functional.utils import set_up_module as setUpModule # noqa:F401 +from pulp_docker.tests.functional.utils import gen_docker_remote + +from pulp_docker.tests.functional.constants import ( + DOCKER_TAG_PATH, + DOCKER_REMOTE_PATH, + DOCKERHUB_PULP_FIXTURE_1, + DOCKER_DISTRIBUTION_PATH +) +from pulp_docker.constants import MEDIA_TYPE + + +""" +@unittest.skip( + "A handler for a token authentication relies on a provided token server's " + "fully qualified domain name (TOKEN_SERVER) in the file /etc/pulp/settings.py; " + "therefore, it is necessary to check if TOKEN_SERVER was specified; otherwise, " + "these tests are no longer valid because the token authentication is turned off " + "by default." +) +""" + + +class TokenAuthenticationTestCase(unittest.TestCase): + """ + A test case for authenticating users via Bearer token. + + This tests targets the following issue: + + * `Pulp #4938 `_ + """ + + @classmethod + def setUpClass(cls): + """Create class wide-variables.""" + cls.cfg = config.get_config() + + token_auth = cls.cfg.hosts[0].roles['token auth'] + client = cli.Client(cls.cfg) + client.run('openssl ecparam -genkey -name prime256v1 -noout -out {}' + .format(token_auth['private key']).split()) + client.run('openssl ec -in {} -pubout -out {}'.format( + token_auth['private key'], token_auth['public key']).split()) + + cls.client = api.Client(cls.cfg, api.page_handler) + + cls.repository = cls.client.post(REPO_PATH, gen_repo()) + remote_data = gen_docker_remote(upstream_name=DOCKERHUB_PULP_FIXTURE_1) + cls.remote = cls.client.post(DOCKER_REMOTE_PATH, remote_data) + sync(cls.cfg, cls.remote, cls.repository) + + cls.distribution = cls.client.using_handler(api.task_handler).post( + DOCKER_DISTRIBUTION_PATH, + gen_distribution(repository=cls.repository['_href']) + ) + + @classmethod + def tearDownClass(cls): + """Clean generated resources.""" + cls.client.delete(cls.repository['_href']) + cls.client.delete(cls.remote['_href']) + cls.client.delete(cls.distribution['_href']) + + def test_pull_image_with_raw_http_requests(self): + """ + Test if a content was pulled from a registry by using raw HTTP requests. + + The registry offers a reference to a certified authority which generates a + Bearer token. The generated Bearer token is afterwards used to pull the image. + All requests are sent via aiohttp modules. + """ + image_path = '/v2/{}/manifests/{}'.format(self.distribution['base_path'], 'manifest_a') + latest_image_url = urljoin(self.cfg.get_content_host_base_url(), image_path) + + with self.assertRaises(HTTPError) as cm: + self.client.get(latest_image_url, headers={'Accept': MEDIA_TYPE.MANIFEST_V2}) + + content_response = cm.exception.response + self.assertEqual(content_response.status_code, 401) + + authenticate_header = content_response.headers['Www-Authenticate'] + queries = AuthenticationHeaderQueries(authenticate_header) + content_response = self.client.get( + queries.realm, + params={'service': queries.service, 'scope': queries.scope} + ) + content_response = self.client.get( + latest_image_url, + auth=BearerTokenAuth(content_response['token']), + headers={'Accept': MEDIA_TYPE.MANIFEST_V2} + ) + self.compare_config_blob_digests(content_response['config']['digest']) + + def test_pull_image_with_real_docker_client(self): + """ + Test if a CLI client is able to pull an image from an authenticated registry. + + This test checks if ordinary clients, like docker, or podman, are able to pull the + image from a secured registry. + """ + registry = cli.RegistryClient(self.cfg) + registry.raise_if_unsupported(unittest.SkipTest, 'Test requires podman/docker') + + image_url = urljoin( + self.cfg.get_content_host_base_url(), + self.distribution['base_path'] + ) + image_with_tag = f'{image_url}:manifest_a' + registry.pull(image_with_tag) + + image = registry.inspect(image_with_tag) + self.compare_config_blob_digests(image[0]['Id']) + + def compare_config_blob_digests(self, pulled_manifest_digest): + """Check if a valid config was pulled from a registry.""" + tags_by_name_url = f'{DOCKER_TAG_PATH}?name=manifest_a' + tag_response = self.client.get(tags_by_name_url) + + tagged_manifest_href = tag_response[0]['tagged_manifest'] + manifest_response = self.client.get(tagged_manifest_href) + + config_blob_response = self.client.get(manifest_response['config_blob']) + self.assertEqual(pulled_manifest_digest, config_blob_response['digest']) + + +class AuthenticationHeaderQueries: + """A data class to store header queries located in the Www-Authenticate header.""" + + def __init__(self, authenticate_header): + """Extract service, realm, and scope from the header.""" + realm, service, scope = authenticate_header[7:].split(',') + # realm="rlm" -> rlm + self.realm = realm[6:][1:-1] + # service="srv" -> srv + self.service = service[8:][1:-1] + # scope="scp" -> scp + self.scope = scope[6:][1:-1] + + +class BearerTokenAuth(AuthBase): + """A subclass for building a JWT Authorization header out of a provided token.""" + + def __init__(self, token): + """Store a Bearer token that is going to be used in the request object.""" + self.token = token + + def __call__(self, r): + """Attaches a Bearer token authentication to the given request object.""" + r.headers['Authorization'] = 'Bearer {}'.format(self.token) + return r diff --git a/setup.py b/setup.py index 27f7d4af..f4595661 100644 --- a/setup.py +++ b/setup.py @@ -6,6 +6,7 @@ "pulpcore~=3.0rc7", 'ecdsa~=0.13.2', 'pyjwkest~=1.4.0', + 'pyjwt[crypto]~=1.7.1' ]