1
0
mirror of https://github.com/quay/quay.git synced 2025-11-16 11:42:27 +03:00
Files
quay/endpoints/api/repository.py
Dave O'Connor 2511b45e89 fix(api): superuser panel access without SUPERUSERS_FULL_ACCESS (PROJQUAY-9693) (#4455)
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>
2025-11-13 09:38:11 -05:00

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}