mirror of
https://github.com/quay/quay.git
synced 2025-11-16 11:42:27 +03:00
fix(api): implement proper superuser permission model and fix access controls Fixes multiple issues with superuser functionality and implements a comprehensive permission model for FEATURE_SUPERUSERS_FULL_ACCESS: **Permission Model:** - Global Readonly Superusers (auditors): Always have read access to all content, independent of FEATURE_SUPERUSERS_FULL_ACCESS setting - Regular Superusers: Can access /v1/superuser endpoints and their own content. Require FEATURE_SUPERUSERS_FULL_ACCESS=true for cross-namespace read access - Full Access Superusers: Regular superusers with FULL_ACCESS enabled, can perform CRUD on content they don't own - Write operations: Only allowed for full access superusers (global readonly superusers never get write access) **Key Fixes:** 1. Fixed superuser panel endpoints returning 403 when FULL_ACCESS was disabled. Basic panel operations (user list, logs, org list, messages) now work with just FEATURE_SUPER_USERS enabled. 2. Updated decorators to properly differentiate between basic superuser operations and permission bypass operations. 3. Implemented license bypass: Superusers with FULL_ACCESS now bypass license/quota limits when creating or modifying private repositories. 4. Fixed 18 permission checks across 7 files to properly implement cross-namespace access controls for different superuser types. **Changes:** - endpoints/api/__init__.py: Fixed allow_if_superuser(), require_repo_permission, and decorators - endpoints/api/superuser.py: Updated SuperUserAppTokens permission check - endpoints/api/organization.py: Updated 4 GET endpoints to require FULL_ACCESS - endpoints/api/namespacequota.py: Updated 2 GET endpoints to require FULL_ACCESS - endpoints/api/team.py: Updated 2 GET endpoints to require FULL_ACCESS - endpoints/api/prototype.py: Updated 1 GET endpoint to require FULL_ACCESS - endpoints/api/policy.py: Updated auto-prune policy endpoints - endpoints/api/robot.py: Updated robot endpoints - endpoints/api/build.py: Updated repository build logs - endpoints/api/repository.py: Added license bypass for superusers with FULL_ACCESS - endpoints/api/repository_models_pre_oci.py: Updated repository visibility query - endpoints/api/logs.py: Fixed log access to require FULL_ACCESS for permission bypass - endpoints/api/test/test_superuser_full_access.py: Added comprehensive test suite - endpoints/api/test/test_appspecifictoken.py: Updated test mocking and added 403 test - test/test_api_usage.py: Updated test expectations for license bypass behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
548 lines
18 KiB
Python
548 lines
18 KiB
Python
"""
|
|
List, create and manage repositories.
|
|
"""
|
|
|
|
import logging
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta
|
|
|
|
from flask import abort, request
|
|
|
|
import features
|
|
from app import (
|
|
app,
|
|
dockerfile_build_queue,
|
|
repository_gc_queue,
|
|
tuf_metadata_api,
|
|
usermanager,
|
|
)
|
|
from auth import scopes
|
|
from auth.auth_context import get_authenticated_user
|
|
from auth.permissions import (
|
|
AdministerRepositoryPermission,
|
|
CreateRepositoryPermission,
|
|
ModifyRepositoryPermission,
|
|
ReadRepositoryPermission,
|
|
)
|
|
from data.database import RepositoryState
|
|
from endpoints.api import (
|
|
ApiResource,
|
|
RepositoryParamResource,
|
|
allow_if_superuser,
|
|
allow_if_superuser_with_full_access,
|
|
format_date,
|
|
log_action,
|
|
nickname,
|
|
page_support,
|
|
parse_args,
|
|
path_param,
|
|
query_param,
|
|
request_error,
|
|
require_repo_admin,
|
|
require_repo_read,
|
|
require_repo_write,
|
|
require_scope,
|
|
resource,
|
|
show_if,
|
|
validate_json_request,
|
|
)
|
|
from endpoints.api.billing import get_namespace_plan, lookup_allowed_private_repos
|
|
from endpoints.api.repository_models_pre_oci import pre_oci_model as model
|
|
from endpoints.api.subscribe import check_repository_usage
|
|
from endpoints.exception import (
|
|
DownstreamIssue,
|
|
ExceedsLicenseException,
|
|
InvalidRequest,
|
|
NotFound,
|
|
Unauthorized,
|
|
)
|
|
from util.names import REPOSITORY_NAME_EXTENDED_REGEX, REPOSITORY_NAME_REGEX
|
|
from util.parsing import truthy_bool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_DAYS_IN_3_MONTHS = 92
|
|
|
|
|
|
def check_allowed_private_repos(namespace):
|
|
"""
|
|
Checks to see if the given namespace has reached its private repository limit.
|
|
|
|
If so, raises a ExceedsLicenseException.
|
|
"""
|
|
# Superusers with full access bypass license limits
|
|
if allow_if_superuser_with_full_access():
|
|
return
|
|
|
|
# Not enabled if billing is disabled.
|
|
if not features.BILLING:
|
|
return
|
|
|
|
if not lookup_allowed_private_repos(namespace):
|
|
raise ExceedsLicenseException()
|
|
|
|
|
|
@resource("/v1/repository")
|
|
class RepositoryList(ApiResource):
|
|
"""
|
|
Operations for creating and listing repositories.
|
|
"""
|
|
|
|
schemas = {
|
|
"NewRepo": {
|
|
"type": "object",
|
|
"description": "Description of a new repository",
|
|
"required": [
|
|
"repository",
|
|
"visibility",
|
|
"description",
|
|
],
|
|
"properties": {
|
|
"repository": {
|
|
"type": "string",
|
|
"description": "Repository name",
|
|
},
|
|
"visibility": {
|
|
"type": "string",
|
|
"description": "Visibility which the repository will start with",
|
|
"enum": [
|
|
"public",
|
|
"private",
|
|
],
|
|
},
|
|
"namespace": {
|
|
"type": "string",
|
|
"description": (
|
|
"Namespace in which the repository should be created. If omitted, the "
|
|
"username of the caller is used"
|
|
),
|
|
},
|
|
"description": {
|
|
"type": "string",
|
|
"description": "Markdown encoded description for the repository",
|
|
},
|
|
"repo_kind": {
|
|
"type": ["string", "null"],
|
|
"description": "The kind of repository",
|
|
"enum": ["image", "application", None],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
@require_scope(scopes.CREATE_REPO)
|
|
@nickname("createRepo")
|
|
@validate_json_request("NewRepo")
|
|
def post(self):
|
|
"""
|
|
Create a new repository.
|
|
"""
|
|
owner = get_authenticated_user()
|
|
req = request.get_json()
|
|
|
|
if owner is None and "namespace" not in "req":
|
|
raise InvalidRequest("Must provide a namespace or must be logged in.")
|
|
|
|
namespace_name = req["namespace"] if "namespace" in req else owner.username
|
|
|
|
permission = CreateRepositoryPermission(namespace_name)
|
|
|
|
if (permission.can() or allow_if_superuser_with_full_access()) and not (
|
|
features.RESTRICTED_USERS
|
|
and usermanager.is_restricted_user(owner.username)
|
|
and owner.username == namespace_name
|
|
and owner.username not in app.config.get("SUPER_USERS", None)
|
|
):
|
|
|
|
repository_name = req["repository"]
|
|
visibility = req["visibility"]
|
|
|
|
if model.repo_exists(namespace_name, repository_name):
|
|
raise request_error(message="Repository already exists")
|
|
|
|
visibility = req["visibility"]
|
|
if visibility == "private":
|
|
check_allowed_private_repos(namespace_name)
|
|
|
|
# Verify that the repository name is valid.
|
|
if features.EXTENDED_REPOSITORY_NAMES:
|
|
valid_repository_name = REPOSITORY_NAME_EXTENDED_REGEX.match(repository_name)
|
|
else:
|
|
valid_repository_name = REPOSITORY_NAME_REGEX.match(repository_name)
|
|
|
|
if not valid_repository_name:
|
|
raise InvalidRequest("Invalid repository name")
|
|
|
|
kind = req.get("repo_kind", "image") or "image"
|
|
created = model.create_repo(
|
|
namespace_name,
|
|
repository_name,
|
|
owner,
|
|
req["description"],
|
|
visibility=visibility,
|
|
repo_kind=kind,
|
|
)
|
|
if created is None:
|
|
raise InvalidRequest("Could not create repository")
|
|
|
|
log_action(
|
|
"create_repo",
|
|
namespace_name,
|
|
{"repo": repository_name, "namespace": namespace_name},
|
|
repo_name=repository_name,
|
|
)
|
|
return {
|
|
"namespace": namespace_name,
|
|
"name": repository_name,
|
|
"kind": kind,
|
|
}, 201
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_scope(scopes.READ_REPO)
|
|
@nickname("listRepos")
|
|
@parse_args()
|
|
@query_param("namespace", "Filters the repositories returned to this namespace", type=str)
|
|
@query_param(
|
|
"starred",
|
|
"Filters the repositories returned to those starred by the user",
|
|
type=truthy_bool,
|
|
default=False,
|
|
)
|
|
@query_param(
|
|
"public",
|
|
"Adds any repositories visible to the user by virtue of being public",
|
|
type=truthy_bool,
|
|
default=False,
|
|
)
|
|
@query_param(
|
|
"last_modified",
|
|
"Whether to include when the repository was last modified.",
|
|
type=truthy_bool,
|
|
default=False,
|
|
)
|
|
@query_param(
|
|
"popularity",
|
|
"Whether to include the repository's popularity metric.",
|
|
type=truthy_bool,
|
|
default=False,
|
|
)
|
|
@query_param("repo_kind", "The kind of repositories to return", type=str, default="image")
|
|
@page_support()
|
|
def get(self, page_token, parsed_args):
|
|
"""
|
|
Fetch the list of repositories visible to the current user under a variety of situations.
|
|
"""
|
|
# Ensure that the user requests either filtered by a namespace, only starred repositories,
|
|
# or public repositories. This ensures that the user is not requesting *all* visible repos,
|
|
# which can cause a surge in DB CPU usage.
|
|
if (
|
|
not parsed_args["namespace"]
|
|
and not parsed_args["starred"]
|
|
and not parsed_args["public"]
|
|
):
|
|
raise InvalidRequest("namespace, starred or public are required for this API call")
|
|
|
|
user = get_authenticated_user()
|
|
username = user.username if user else None
|
|
last_modified = parsed_args["last_modified"]
|
|
popularity = parsed_args["popularity"]
|
|
|
|
if parsed_args["starred"] and not username:
|
|
# No repositories should be returned, as there is no user.
|
|
abort(400)
|
|
|
|
repos, next_page_token = model.get_repo_list(
|
|
parsed_args["starred"],
|
|
user,
|
|
parsed_args["repo_kind"],
|
|
parsed_args["namespace"],
|
|
username,
|
|
parsed_args["public"],
|
|
page_token,
|
|
last_modified,
|
|
popularity,
|
|
)
|
|
|
|
if features.QUOTA_MANAGEMENT and features.EDIT_QUOTA:
|
|
repositories_with_view = model.add_quota_view(repos)
|
|
else:
|
|
repositories_with_view = [repo.to_dict() for repo in repos]
|
|
|
|
return {"repositories": repositories_with_view}, next_page_token
|
|
|
|
|
|
@resource("/v1/repository/<apirepopath:repository>")
|
|
@path_param("repository", "The full path of the repository. e.g. namespace/name")
|
|
class Repository(RepositoryParamResource):
|
|
"""
|
|
Operations for managing a specific repository.
|
|
"""
|
|
|
|
schemas = {
|
|
"RepoUpdate": {
|
|
"type": "object",
|
|
"description": "Fields which can be updated in a repository.",
|
|
"required": [
|
|
"description",
|
|
],
|
|
"properties": {
|
|
"description": {
|
|
"type": "string",
|
|
"description": "Markdown encoded description for the repository",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
@parse_args()
|
|
@query_param(
|
|
"includeStats", "Whether to include action statistics", type=truthy_bool, default=False
|
|
)
|
|
@query_param(
|
|
"includeTags", "Whether to include repository tags", type=truthy_bool, default=True
|
|
)
|
|
@require_repo_read(allow_for_superuser=True, allow_for_global_readonly_superuser=True)
|
|
@nickname("getRepo")
|
|
def get(self, namespace, repository, parsed_args):
|
|
"""
|
|
Fetch the specified repository.
|
|
"""
|
|
logger.debug("Get repo: %s/%s" % (namespace, repository))
|
|
include_tags = parsed_args["includeTags"]
|
|
max_tags = 500
|
|
repo = model.get_repo(
|
|
namespace, repository, get_authenticated_user(), include_tags, max_tags
|
|
)
|
|
if repo is None:
|
|
raise NotFound()
|
|
|
|
has_write_permission = ModifyRepositoryPermission(namespace, repository).can()
|
|
has_write_permission = has_write_permission and repo.state == RepositoryState.NORMAL
|
|
|
|
repo_data = repo.to_dict()
|
|
repo_data["can_write"] = has_write_permission
|
|
repo_data["can_admin"] = AdministerRepositoryPermission(namespace, repository).can()
|
|
|
|
if parsed_args["includeStats"] and repo.repository_base_elements.kind_name != "application":
|
|
stats = []
|
|
found_dates = {}
|
|
|
|
for count in repo.counts:
|
|
stats.append(count.to_dict())
|
|
found_dates["%s/%s" % (count.date.month, count.date.day)] = True
|
|
|
|
# Fill in any missing stats with zeros.
|
|
for day in range(1, MAX_DAYS_IN_3_MONTHS):
|
|
day_date = datetime.now() - timedelta(days=day)
|
|
key = "%s/%s" % (day_date.month, day_date.day)
|
|
if key not in found_dates:
|
|
stats.append(
|
|
{
|
|
"date": day_date.date().isoformat(),
|
|
"count": 0,
|
|
}
|
|
)
|
|
|
|
repo_data["stats"] = stats
|
|
return repo_data
|
|
|
|
@require_repo_write(allow_for_superuser=True)
|
|
@nickname("updateRepo")
|
|
@validate_json_request("RepoUpdate")
|
|
def put(self, namespace, repository):
|
|
"""
|
|
Update the description in the specified repository.
|
|
"""
|
|
if not model.repo_exists(namespace, repository):
|
|
raise NotFound()
|
|
|
|
values = request.get_json()
|
|
model.set_description(namespace, repository, values["description"])
|
|
|
|
log_action(
|
|
"set_repo_description",
|
|
namespace,
|
|
{"repo": repository, "namespace": namespace, "description": values["description"]},
|
|
repo_name=repository,
|
|
)
|
|
return {"success": True}
|
|
|
|
@require_repo_admin(allow_for_superuser=True)
|
|
@nickname("deleteRepository")
|
|
def delete(self, namespace, repository):
|
|
"""
|
|
Delete a repository.
|
|
"""
|
|
username = model.mark_repository_for_deletion(namespace, repository, repository_gc_queue)
|
|
|
|
if features.BILLING:
|
|
plan = get_namespace_plan(namespace)
|
|
model.check_repository_usage(username, plan)
|
|
|
|
# Remove any builds from the queue.
|
|
dockerfile_build_queue.delete_namespaced_items(namespace, repository)
|
|
|
|
log_action("delete_repo", namespace, {"repo": repository, "namespace": namespace})
|
|
return "", 204
|
|
|
|
|
|
@resource("/v1/repository/<apirepopath:repository>/changevisibility")
|
|
@path_param("repository", "The full path of the repository. e.g. namespace/name")
|
|
class RepositoryVisibility(RepositoryParamResource):
|
|
"""
|
|
Custom verb for changing the visibility of the repository.
|
|
"""
|
|
|
|
schemas = {
|
|
"ChangeVisibility": {
|
|
"type": "object",
|
|
"description": "Change the visibility for the repository.",
|
|
"required": [
|
|
"visibility",
|
|
],
|
|
"properties": {
|
|
"visibility": {
|
|
"type": "string",
|
|
"description": "Visibility which the repository will start with",
|
|
"enum": [
|
|
"public",
|
|
"private",
|
|
],
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
@require_repo_admin(allow_for_superuser=True)
|
|
@nickname("changeRepoVisibility")
|
|
@validate_json_request("ChangeVisibility")
|
|
def post(self, namespace, repository):
|
|
"""
|
|
Change the visibility of a repository.
|
|
"""
|
|
if model.repo_exists(namespace, repository):
|
|
values = request.get_json()
|
|
visibility = values["visibility"]
|
|
if visibility == "private":
|
|
check_allowed_private_repos(namespace)
|
|
|
|
model.set_repository_visibility(namespace, repository, visibility)
|
|
log_action(
|
|
"change_repo_visibility",
|
|
namespace,
|
|
{"repo": repository, "namespace": namespace, "visibility": values["visibility"]},
|
|
repo_name=repository,
|
|
)
|
|
return {"success": True}
|
|
|
|
|
|
@resource("/v1/repository/<apirepopath:repository>/changetrust")
|
|
@path_param("repository", "The full path of the repository. e.g. namespace/name")
|
|
class RepositoryTrust(RepositoryParamResource):
|
|
"""
|
|
Custom verb for changing the trust settings of the repository.
|
|
"""
|
|
|
|
schemas = {
|
|
"ChangeRepoTrust": {
|
|
"type": "object",
|
|
"description": "Change the trust settings for the repository.",
|
|
"required": [
|
|
"trust_enabled",
|
|
],
|
|
"properties": {
|
|
"trust_enabled": {
|
|
"type": "boolean",
|
|
"description": "Whether or not signing is enabled for the repository.",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
@show_if(features.SIGNING)
|
|
@require_repo_admin(allow_for_superuser=True)
|
|
@nickname("changeRepoTrust")
|
|
@validate_json_request("ChangeRepoTrust")
|
|
def post(self, namespace, repository):
|
|
"""
|
|
Change the visibility of a repository.
|
|
"""
|
|
if not model.repo_exists(namespace, repository):
|
|
raise NotFound()
|
|
|
|
tags, _ = tuf_metadata_api.get_default_tags_with_expiration(namespace, repository)
|
|
if tags and not tuf_metadata_api.delete_metadata(namespace, repository):
|
|
raise DownstreamIssue("Unable to delete downstream trust metadata")
|
|
|
|
values = request.get_json()
|
|
model.set_trust(namespace, repository, values["trust_enabled"])
|
|
|
|
log_action(
|
|
"change_repo_trust",
|
|
namespace,
|
|
{"repo": repository, "namespace": namespace, "trust_enabled": values["trust_enabled"]},
|
|
repo_name=repository,
|
|
)
|
|
|
|
return {"success": True}
|
|
|
|
|
|
@resource("/v1/repository/<apirepopath:repository>/changestate")
|
|
@path_param("repository", "The full path of the repository. e.g. namespace/name")
|
|
@show_if(features.REPO_MIRROR)
|
|
class RepositoryStateResource(RepositoryParamResource):
|
|
"""
|
|
Custom verb for changing the state of the repository.
|
|
"""
|
|
|
|
schemas = {
|
|
"ChangeRepoState": {
|
|
"type": "object",
|
|
"description": "Change the state of the repository.",
|
|
"required": ["state"],
|
|
"properties": {
|
|
"state": {
|
|
"type": "string",
|
|
"description": "Determines whether pushes are allowed.",
|
|
"enum": ["NORMAL", "READ_ONLY", "MIRROR"],
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
@require_repo_admin(allow_for_superuser=True)
|
|
@nickname("changeRepoState")
|
|
@validate_json_request("ChangeRepoState")
|
|
def put(self, namespace, repository):
|
|
"""
|
|
Change the state of a repository.
|
|
"""
|
|
if not model.repo_exists(namespace, repository):
|
|
raise NotFound()
|
|
|
|
values = request.get_json()
|
|
state_name = values["state"]
|
|
|
|
try:
|
|
state = RepositoryState[state_name]
|
|
except KeyError:
|
|
state = None
|
|
|
|
if state == RepositoryState.MIRROR and not features.REPO_MIRROR:
|
|
return {"detail": "Unknown Repository State: %s" % state_name}, 400
|
|
|
|
if state is None:
|
|
return {"detail": "%s is not a valid Repository state." % state_name}, 400
|
|
|
|
model.set_repository_state(namespace, repository, state)
|
|
|
|
log_action(
|
|
"change_repo_state",
|
|
namespace,
|
|
{"repo": repository, "namespace": namespace, "state_changed": state_name},
|
|
repo_name=repository,
|
|
)
|
|
|
|
return {"success": True}
|