1
0
mirror of https://github.com/quay/quay.git synced 2025-11-17 23:02:34 +03:00
Files
quay/endpoints/api/repository_models_pre_oci.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

324 lines
12 KiB
Python

from collections import defaultdict
from datetime import datetime, timedelta
import features
from auth.permissions import ReadRepositoryPermission
from data import model
from data.database import Repository as RepositoryTable
from data.database import RepositoryState
from data.registry_model import registry_model
from data.registry_model.datatypes import RepositoryReference
from endpoints.api import allow_if_global_readonly_superuser, allow_if_superuser
from endpoints.api.repository_models_interface import (
ApplicationRepository,
Channel,
Count,
ImageRepositoryRepository,
Release,
Repository,
RepositoryBaseElement,
RepositoryDataInterface,
Tag,
)
MAX_DAYS_IN_3_MONTHS = 92
REPOS_PER_PAGE = 100
def _create_channel(channel, releases_channels_map):
releases_channels_map[channel.linked_tag.name].append(channel.name)
return Channel(channel.name, channel.linked_tag.name, channel.linked_tag.lifetime_start)
class PreOCIModel(RepositoryDataInterface):
"""
PreOCIModel implements the data model for the Repo Email using a database schema before it was
changed to support the OCI specification.
"""
def check_repository_usage(self, username, plan_found):
private_repos = model.user.get_private_repo_count(username)
if plan_found is None:
repos_allowed = 0
else:
repos_allowed = plan_found["privateRepos"]
user_or_org = model.user.get_namespace_user(username)
if private_repos > repos_allowed:
model.notification.create_unique_notification(
"over_private_usage", user_or_org, {"namespace": username}
)
else:
model.notification.delete_notifications_by_kind(user_or_org, "over_private_usage")
def mark_repository_for_deletion(self, namespace_name, repository_name, repository_gc_queue):
model.repository.mark_repository_for_deletion(
namespace_name, repository_name, repository_gc_queue
)
user = model.user.get_namespace_user(namespace_name)
return user.username
def set_description(self, namespace_name, repository_name, description):
repo = model.repository.get_repository(namespace_name, repository_name)
model.repository.set_description(repo, description)
def set_trust(self, namespace_name, repository_name, trust):
repo = model.repository.get_repository(namespace_name, repository_name)
model.repository.set_trust(repo, trust)
def set_repository_visibility(self, namespace_name, repository_name, visibility):
repo = model.repository.get_repository(namespace_name, repository_name)
model.repository.set_repository_visibility(repo, visibility)
def set_repository_state(self, namespace_name, repository_name, state):
repo = model.repository.get_repository(namespace_name, repository_name)
model.repository.set_repository_state(repo, state)
def get_repo_list(
self,
starred,
user,
repo_kind,
namespace,
username,
public,
page_token,
last_modified,
popularity,
):
next_page_token = None
# Lookup the requested repositories (either starred or non-starred.)
if starred:
# Return the full list of repos starred by the current user that are still visible to them.
def can_view_repo(repo):
assert repo.state != RepositoryState.MARKED_FOR_DELETION
can_view = ReadRepositoryPermission(repo.namespace_user.username, repo.name).can()
return can_view or model.repository.is_repository_public(repo)
unfiltered_repos = model.repository.get_user_starred_repositories(
user, kind_filter=repo_kind
)
repos = [repo for repo in unfiltered_repos if can_view_repo(repo)]
if not page_token in [{}, None]:
if page_token.get("start_index") == 0:
page_token["start_index"] = 1
# since this is a list and not a database result we need to start with 0
# list start: index (min1) -1 -> 0 * REPOS_PER_PAGE (100) == 0,100,200,300,...
# list end: index (min1) -1 -> 0 * REPOS_PER_PAGE (100) == 101, 201, 301, ...
# but we need a check to not exceed the list size with the list end value
ls = (page_token.get("start_index", 1) - 1) * REPOS_PER_PAGE
le = ((page_token.get("start_index", 1) - 1) * REPOS_PER_PAGE) + REPOS_PER_PAGE + 1
if len(repos) < le:
le = len(repos)
repos = repos[ls:le]
next_page_token = {
"start_index": page_token.get("start_index") + 1,
"page_number": page_token.get("start_index") + 1,
"is_datetime": False,
"offset_val": 0,
}
else:
repos = repos[0 : REPOS_PER_PAGE - 1]
else:
# Determine the starting offset for pagination. Note that we don't use the normal
# model.modelutil.paginate method here, as that does not operate over UNION queries, which
# get_visible_repositories will return if there is a logged-in user (for performance reasons).
#
# Also note the +1 on the limit, as paginate_query uses the extra result to determine whether
# there is a next page.
start_id = model.modelutil.pagination_start(page_token)
# Global readonly superusers can always see all repos, regular superusers need FULL_ACCESS
repo_query = model.repository.get_visible_repositories(
username=username,
include_public=public,
start_id=start_id,
limit=REPOS_PER_PAGE + 1,
kind_filter=repo_kind,
namespace=namespace,
is_superuser=(
allow_if_global_readonly_superuser()
or (features.SUPERUSERS_FULL_ACCESS and allow_if_superuser())
),
)
repos, next_page_token = model.modelutil.paginate_query(
repo_query, limit=REPOS_PER_PAGE, sort_field_name="rid"
)
repos = list(repos)
assert len(repos) <= REPOS_PER_PAGE
# Collect the IDs of the repositories found for subsequent lookup of popularity
# and/or last modified.
last_modified_map = {}
action_sum_map = {}
if last_modified or popularity:
repository_refs = [RepositoryReference.for_id(repo.rid) for repo in repos]
repository_ids = [repo.rid for repo in repos]
if last_modified:
last_modified_map = registry_model.get_most_recent_tag_lifetime_start(
repository_refs
)
if popularity:
action_sum_map = model.log.get_repositories_action_sums(repository_ids)
# Collect the IDs of the repositories that are starred for the user, so we can mark them
# in the returned results.
star_set = set()
if username:
starred_repos = model.repository.get_user_starred_repositories(user, repo_kind)
star_set = {starred.id for starred in starred_repos}
return (
[
RepositoryBaseElement(
repo.rid,
repo.namespace_user.username,
repo.name,
repo.rid in star_set,
model.repository.is_repository_public(repo),
repo_kind,
repo.description,
repo.namespace_user.organization,
repo.namespace_user.removed_tag_expiration_s,
last_modified_map.get(repo.rid),
action_sum_map.get(repo.rid),
last_modified,
popularity,
username,
None,
repo.state,
)
for repo in repos
],
next_page_token,
)
def repo_exists(self, namespace_name, repository_name):
repo = model.repository.get_repository(namespace_name, repository_name)
if repo is None:
return False
return True
def create_repo(
self,
namespace_name,
repository_name,
owner,
description,
visibility="private",
repo_kind="image",
):
repo = model.repository.create_repository(
namespace_name,
repository_name,
owner,
visibility,
repo_kind=repo_kind,
description=description,
)
if repo is None:
return None
return Repository(namespace_name, repository_name)
def get_repo(self, namespace_name, repository_name, user, include_tags=True, max_tags=500):
repo = model.repository.get_repository(namespace_name, repository_name)
if repo is None:
return None
is_starred = model.repository.repository_is_starred(user, repo) if user else False
is_public = model.repository.is_repository_public(repo)
kind_name = RepositoryTable.kind.get_name(repo.kind_id)
base = RepositoryBaseElement(
repo.id,
namespace_name,
repository_name,
is_starred,
is_public,
kind_name,
repo.description,
repo.namespace_user.organization,
repo.namespace_user.removed_tag_expiration_s,
None,
None,
False,
False,
False,
repo.namespace_user.stripe_id is None,
repo.state,
)
tags = None
repo_ref = RepositoryReference.for_repo_obj(repo)
if include_tags:
tags, _ = registry_model.list_repository_tag_history(
repo_ref, page=1, size=max_tags, active_tags_only=True
)
tags = [
Tag(
tag.name,
tag.manifest_layers_size,
tag.lifetime_start_ts,
tag.manifest_digest,
tag.lifetime_end_ts,
)
for tag in tags
]
start_date = datetime.now() - timedelta(days=MAX_DAYS_IN_3_MONTHS)
counts = model.log.get_repository_action_counts(repo, start_date)
assert repo.state is not None
return ImageRepositoryRepository(
base,
tags,
[Count(count.date, count.count) for count in counts],
repo.badge_token,
repo.trust_enabled,
repo.state,
)
def add_quota_view(self, repos):
namespace_limit_bytes = {}
repos_with_view = []
repo_sizes = model.repository.get_repository_sizes([repo.id for repo in repos])
for repo in repos:
repo_with_view = repo.to_dict()
repos_with_view.append(repo_with_view)
if (
repo_with_view.get("namespace", None) is None
or repo_with_view.get("name", None) is None
):
continue
# Caching result in namespace_limit_bytes
if repo_with_view.get("namespace") not in namespace_limit_bytes:
quotas = model.namespacequota.get_namespace_quota_list(
repo_with_view.get("namespace")
)
# Currently only one quota per namespace is supported
namespace_limit_bytes[repo_with_view.get("namespace")] = (
quotas[0].limit_bytes
if quotas
else model.namespacequota.fetch_system_default(quotas)
)
# If FEATURE_QUOTA_MANAGEMENT is enabled & quota is not set for an org,
# we still want to report repo's storage consumption
repo_with_view["quota_report"] = {
"quota_bytes": repo_sizes.get(repo.id, 0),
"configured_quota": namespace_limit_bytes[repo_with_view.get("namespace")],
}
return repos_with_view
pre_oci_model = PreOCIModel()