1
0
mirror of https://github.com/quay/quay.git synced 2026-01-27 18:42:52 +03:00
Files
quay/endpoints/decorators.py
Ivan Bazulic 62e42f8f54 storage: Disable pushes on registry (PROJQUAY-6870) (#2755)
* storage: Disable pushes on registry (PROJQUAY-6870)

The current read-only option for Quay is not sometimes feasible, since it requires an insert of the service key and other manual config changes. For instance, if you want to just recalculate quota on the registry, but would like to allow all registry operations (including UI) without the possibility of pushes until recalculation is done, setting the whole registry `read-only` cannot be done since it makes the database read only as well.

This PR introduces a new flag called `DISABLE_PUSHES` which allows all registry operations to continue (changing tags, repo editing, robot account creation/deletion, user creation etc.) but will disable pushes of new images to the registry (i.e. backend storage will not change). If a registry already contains the image and a new tag is simply being added, that operation should succeed.

The following message would appear in the logs:

~~~
gunicorn-registry stdout | 2024-03-13 20:19:49,414 [369] [DEBUG] [endpoints.v2] sending response: b'{"errors":[{"code":"METHOD NOT ALLOWED","detail":{},"message":"Pushes to the registry are currently disabled. Please contact the administrator for more information."}]}\n'
gunicorn-registry stdout | 2024-03-13 20:19:49,414 [369] [INFO] [gunicorn.access] 172.17.0.1 - - [13/Mar/2024:20:19:49 +0000] "PUT /v2/ibazulic/mariadb/manifests/sha256:c4694ba424e0259694a5117bbb510d67340051f0bdb7f9fa8033941a2d66e53e HTTP/1.1" 405 169 "-" "skopeo/1.9.3"
nginx stdout | 172.17.0.1 (-) - - [13/Mar/2024:20:19:49 +0000] "PUT /v2/ibazulic/mariadb/manifests/sha256:c4694ba424e0259694a5117bbb510d67340051f0bdb7f9fa8033941a2d66e53e HTTP/1.1" 405 169 "-" "skopeo/1.9.3" (0.002 3813 0.002)
~~~

The flag defaults to `False` (pushes enabled), unless set otherwise.

* Removed constraint on storage replication when pushes are disabled

* Rebase

* Fix isort sorting

* Fix isort sorting #2

* Removed constraint on storage replication when pushes are disabled

* Rebase

* Remove constraint on storage replication worker

* Fix linting on config.py
2024-08-07 15:40:10 -04:00

420 lines
13 KiB
Python

"""
Various decorators for endpoint and API handlers.
"""
import logging
import os
from functools import wraps
from flask import request
from ua_parser import user_agent_parser
import features
from app import app, ip_resolver, model_cache, usermanager
from auth.auth_context import get_authenticated_context, get_authenticated_user
from data.database import RepositoryState
from data.model import InvalidProxyCacheConfigException, PushesDisabledException
from data.model.repo_mirror import get_mirror, get_mirroring_robot
from data.model.repository import get_repository, get_repository_state
from data.readreplica import ReadOnlyModeException
from data.registry_model import registry_model
from data.registry_model.registry_proxy_model import ProxyModel
from util.http import abort
from util.names import ImplicitLibraryNamespaceNotAllowed, parse_namespace_repository
from util.request import get_request_ip
logger = logging.getLogger(__name__)
def inject_registry_model():
"""
Injects the correct registry model into the decorated view.
When the namespace is an org, and is configured for proxy cache, injects the
ProxyModel registry model.
When the namespace is a user or an org that is not configured for proxy cache,
injects the OCIModel registry model.
"""
def inner(func):
@wraps(func)
def wrapper(namespace_name, repo_name, *args, **kwargs):
new_registry_model = registry_model
if features.PROXY_CACHE:
try:
new_registry_model = ProxyModel(
namespace_name,
repo_name,
get_authenticated_user(),
)
except InvalidProxyCacheConfigException as e:
logger.debug(f"Skipping pull through proxy cache: {e}")
kwargs["registry_model"] = new_registry_model
return func(namespace_name, repo_name, *args, **kwargs)
return wrapper
return inner
def parse_repository_name(
include_tag=False,
ns_kwarg_name="namespace_name",
repo_kwarg_name="repo_name",
tag_kwarg_name="tag_name",
incoming_repo_kwarg="repository",
):
"""
Decorator which parses the repository name found in the incoming_repo_kwarg argument, and
applies its pieces to the decorated function.
"""
def inner(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
repo_name_components = parse_namespace_repository(
kwargs[incoming_repo_kwarg],
app.config["LIBRARY_NAMESPACE"],
include_tag=include_tag,
allow_library=features.LIBRARY_SUPPORT,
)
except ImplicitLibraryNamespaceNotAllowed:
abort(400, message="A namespace must be specified explicitly")
del kwargs[incoming_repo_kwarg]
kwargs[ns_kwarg_name] = repo_name_components[0]
kwargs[repo_kwarg_name] = repo_name_components[1]
if include_tag:
kwargs[tag_kwarg_name] = repo_name_components[2]
return func(*args, **kwargs)
return wrapper
return inner
def param_required(param_name, allow_body=False):
"""
Marks a route as requiring a parameter with the given name to exist in the request's arguments
or (if allow_body=True) in its body values.
If the parameter is not present, the request will fail with a 400.
"""
def wrapper(wrapped):
@wraps(wrapped)
def decorated(*args, **kwargs):
if param_name not in request.args:
if not allow_body or param_name not in request.values:
abort(400, message="Required param: %s" % param_name)
return wrapped(*args, **kwargs)
return decorated
return wrapper
def readonly_call_allowed(func):
"""
Marks a method as allowing for invocation when the registry is in a read only state.
Only necessary on non-GET methods.
"""
func.__readonly_call_allowed = True
return func
def restricted_user_readonly_call_allowed(func):
"""
Marks a method as allowing for invocation when the registry is in a read only state.
Only necessary on non-GET methods.
"""
func.__restricted_user_readonly_call_allowed = True
return func
def anon_allowed(func):
"""
Marks a method to allow anonymous access where it would otherwise be disallowed.
"""
func.__anon_allowed = True
return func
def anon_protect(func):
"""
Marks a method as requiring some form of valid user auth before it can be executed.
"""
func.__anon_protected = True
return check_anon_protection(func)
def check_anon_protection(func):
"""
Validates a method as requiring some form of valid user auth before it can be executed.
"""
@wraps(func)
def wrapper(*args, **kwargs):
# Skip if anonymous access is allowed.
if features.ANONYMOUS_ACCESS or "__anon_allowed" in dir(func):
return func(*args, **kwargs)
# Check for validated context. If none exists, fail with a 401.
if get_authenticated_context() and not get_authenticated_context().is_anonymous:
return func(*args, **kwargs)
abort(401, message="Anonymous access is not allowed")
return wrapper
def check_readonly(func):
"""
Validates that a non-GET method is not invoked when the registry is in read-only mode, unless
explicitly marked as being allowed.
"""
@wraps(func)
def wrapper(*args, **kwargs):
# Skip if a GET method.
if request.method == "GET":
return func(*args, **kwargs)
# Skip if not in read only mode.
if app.config.get("REGISTRY_STATE", "normal") != "readonly":
return func(*args, **kwargs)
# Skip if readonly access is allowed.
if hasattr(func, "__readonly_call_allowed"):
return func(*args, **kwargs)
raise ReadOnlyModeException()
return wrapper
def check_pushes_disabled(func):
"""
Validates that a non-GET method is not invoked when the registry has pushes disabled, unless
explicitly marked as being allowed.
"""
@wraps(func)
def wrapper(*args, **kwargs):
# Skip if a GET method.
if request.method == "GET":
return func(*args, **kwargs)
# Skip if pushes are enabled.
if not app.config.get("DISABLE_PUSHES", False):
return func(*args, **kwargs)
# Skip if readonly access is allowed.
if hasattr(func, "__readonly_call_allowed"):
return func(*args, **kwargs)
raise PushesDisabledException()
return wrapper
def route_show_if(value):
"""
Adds/shows the decorated route if the given value is True.
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not value:
abort(404)
return f(*args, **kwargs)
return decorated_function
return decorator
def disallow_for_account_recovery_mode(func):
"""
Disable route if ACCOUNT_RECOVERY_MODE is set.
"""
@wraps(func)
def wrapper(*args, **kwargs):
if app.config.get("ACCOUNT_RECOVERY_MODE", False):
abort(405, "Quay running for account recoveries only.")
return func(*args, **kwargs)
return wrapper
def require_xhr_from_browser(func):
"""
Requires that API GET calls made from browsers are made via XHR, in order to prevent reflected
text attacks.
"""
# https://github.com/pallets/werkzeug/issues/2078
browsers = (
"aol",
"ask",
"camino",
"chrome",
"firefox",
"galeon",
"google",
"kmeleon",
"konqueror",
"links",
"lynx",
"msie",
"msn",
"netscape",
"opera",
"safari",
"seamonkey",
"webkit",
"yahoo",
)
@wraps(func)
def wrapper(*args, **kwargs):
if app.config.get("BROWSER_API_CALLS_XHR_ONLY", False):
if (
request.method == "GET"
and request.user_agent.string
and user_agent_parser.ParseUserAgent(request.user_agent.string)["family"].lower()
in browsers
):
has_xhr_header = request.headers.get("X-Requested-With") == "XMLHttpRequest"
if not has_xhr_header and not app.config.get("DEBUGGING") == True:
logger.warning(
"Disallowed possible RTA to URL %s with user agent %s",
request.path,
request.user_agent,
)
abort(
400,
message="API calls must be invoked with an X-Requested-With header "
+ "if called from a browser",
)
return func(*args, **kwargs)
return wrapper
def check_region_blacklisted(error_class=None, namespace_name_kwarg=None):
"""
Decorator which checks if the incoming request is from a region geo IP blocked for the current
namespace.
The first argument to the wrapped function must be the namespace name.
"""
def wrapper(wrapped):
@wraps(wrapped)
def decorated(*args, **kwargs):
if namespace_name_kwarg:
namespace_name = kwargs[namespace_name_kwarg]
else:
namespace_name = args[0]
region_blacklist = registry_model.get_cached_namespace_region_blacklist(
model_cache, namespace_name
)
if region_blacklist:
# Resolve the IP information and block if on the namespace's blacklist.
remote_ip = get_request_ip()
resolved_ip_info = ip_resolver.resolve_ip(remote_ip)
logger.debug("Resolved IP information for IP %s: %s", remote_ip, resolved_ip_info)
if (
resolved_ip_info
and resolved_ip_info.country_iso_code
and resolved_ip_info.country_iso_code in region_blacklist
):
if error_class:
raise error_class()
abort(403, "Pulls of this data have been restricted geographically")
return wrapped(*args, **kwargs)
return decorated
return wrapper
def check_repository_state(f):
@wraps(f)
def wrapper(namespace_name, repo_name, *args, **kwargs):
"""
Conditionally allow changes depending on the Repository's state.
NORMAL -> Pass READ_ONLY -> Block all POST/PUT/DELETE MIRROR -> Same as READ_ONLY,
except treat the Mirroring Robot User as Normal MARKED_FOR_DELETION -> Block everything as a
404
"""
user = get_authenticated_user()
if user is None:
# NOTE: Remaining auth checks will be handled by subsequent decorators.
return f(namespace_name, repo_name, *args, **kwargs)
repository = get_repository(namespace_name, repo_name)
if not repository:
return f(namespace_name, repo_name, *args, **kwargs)
if repository.state == RepositoryState.MARKED_FOR_DELETION:
abort(404)
if repository.state == RepositoryState.READ_ONLY:
abort(405, "%s/%s is in read-only mode." % (namespace_name, repo_name))
if repository.state == RepositoryState.MIRROR:
mirror = get_mirror(repository)
robot = mirror.internal_robot if mirror is not None else None
if mirror is None:
abort(
500,
"Repository %s/%s is set as a mirror but the Mirror configuration is missing."
% (namespace_name, repo_name),
)
elif robot is None:
abort(
400,
"Repository %s/%s is configured for mirroring but no robot is assigned."
% (namespace_name, repo_name),
)
elif user.id != robot.id:
abort(
405,
"Repository %s/%s is a mirror. Mirrored repositories cannot be modified directly."
% (namespace_name, repo_name),
)
elif user.id == robot.id:
pass # User is designated robot for this mirror repo.
else:
msg = (
"An internal error has occurred while verifying repository %s/%s state. Please report "
"this to an administrator."
) % (namespace_name, repo_name)
raise Exception(msg)
return f(namespace_name, repo_name, *args, **kwargs)
return wrapper