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

468 lines
16 KiB
Python

import logging
import bitmath
from flask import request
import features
from auth import scopes
from auth.auth_context import get_authenticated_user
from auth.permissions import OrganizationMemberPermission, SuperUserPermission
from data import model
from data.model import config
from endpoints.api import (
ApiResource,
allow_if_any_superuser,
allow_if_global_readonly_superuser,
allow_if_superuser,
nickname,
request_error,
require_scope,
require_user_admin,
resource,
show_if,
validate_json_request,
)
from endpoints.exception import NotFound, Unauthorized
logger = logging.getLogger(__name__)
def quota_view(quota, default_config=False):
quota_limits = []
if quota:
quota_limits = list(model.namespacequota.get_namespace_quota_limit_list(quota))
else:
# If no quota is defined for the org, return systems default quota if set
if config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0:
quota = model.namespacequota.get_system_default_quota()
default_config = True
return {
"id": quota.id,
"limit_bytes": quota.limit_bytes,
"limit": bitmath.Byte(quota.limit_bytes).best_prefix().format("{value:.1f} {unit}"),
"default_config": default_config,
"limits": [limit_view(limit) for limit in quota_limits],
"default_config_exists": (
True if config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0 else False
),
}
def limit_view(limit):
return {
"id": limit.id,
"type": limit.quota_type.name,
"limit_percent": limit.percent_of_limit,
}
def get_quota(namespace_name, quota_id):
quota = model.namespacequota.get_namespace_quota(namespace_name, quota_id)
if quota is None:
raise NotFound()
return quota
@resource("/v1/organization/<orgname>/quota")
@show_if(features.SUPER_USERS)
@show_if(features.QUOTA_MANAGEMENT and features.EDIT_QUOTA)
class OrganizationQuotaList(ApiResource):
schemas = {
"NewOrgQuota": {
"type": "object",
"description": "Description of a new organization quota",
"oneOf": [
{
"required": ["limit_bytes"],
"properties": {
"limit_bytes": {
"type": "integer",
"description": "Number of bytes the organization is allowed",
},
},
},
{
"required": ["limit"],
"properties": {
"limit": {
"type": "string",
"description": "Human readable storage capacity of the organization",
"pattern": r"^(\d+\s?(B|KiB|MiB|GiB|TiB|PiB|EiB|ZiB|YiB|Ki|Mi|Gi|Ti|Pi|Ei|Zi|Yi|KB|MB|GB|TB|PB|EB|ZB|YB|K|M|G|T|P|E|Z|Y)?)$",
},
},
},
],
},
}
@nickname("listOrganizationQuota")
def get(self, orgname):
orgperm = OrganizationMemberPermission(orgname)
# Global readonly superusers can always view, regular superusers need FULL_ACCESS
if (
not orgperm.can()
and not allow_if_global_readonly_superuser()
and not (features.SUPERUSERS_FULL_ACCESS and allow_if_superuser())
):
raise Unauthorized()
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
default_config = False
quotas = model.namespacequota.get_namespace_quota_list(orgname)
# If no quota is defined for the org, return systems default quota
if not quotas and config.app_config.get("DEFAULT_SYSTEM_REJECT_QUOTA_BYTES") != 0:
quotas = [model.namespacequota.get_system_default_quota(orgname)]
default_config = True
return [quota_view(quota, default_config) for quota in quotas]
@nickname("createOrganizationQuota")
@validate_json_request("NewOrgQuota")
@require_scope(scopes.SUPERUSER)
def post(self, orgname):
"""
Create a new organization quota.
"""
if not SuperUserPermission().can():
raise Unauthorized()
quota_data = request.get_json()
if "limit" in quota_data:
try:
limit_bytes = bitmath.parse_string_unsafe(quota_data["limit"]).to_Byte().value
except ValueError:
raise request_error(
message="Invalid limit format, use a number followed by a unit (e.g. 1GiB)"
)
else:
limit_bytes = quota_data["limit_bytes"]
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
# Currently only supporting one quota definition per namespace
quotas = model.namespacequota.get_namespace_quota_list(orgname)
if quotas:
raise request_error(message="Organization quota for '%s' already exists" % orgname)
try:
model.namespacequota.create_namespace_quota(org, limit_bytes)
return "Created", 201
except model.DataModelException as ex:
raise request_error(exception=ex)
@resource("/v1/organization/<orgname>/quota/<quota_id>")
@show_if(features.SUPER_USERS)
@show_if(features.QUOTA_MANAGEMENT and features.EDIT_QUOTA)
class OrganizationQuota(ApiResource):
schemas = {
"UpdateOrgQuota": {
"type": "object",
"description": "Description of a new organization quota",
"oneOf": [
{
"properties": {
"limit_bytes": {
"type": "integer",
"description": "Number of bytes the organization is allowed",
},
},
"required": ["limit_bytes"],
"additionalProperties": False,
},
{
"properties": {
"limit": {
"type": "string",
"description": "Human readable storage capacity of the organization",
"pattern": r"^(\d+\s?(B|KiB|MiB|GiB|TiB|PiB|EiB|ZiB|YiB|Ki|Mi|Gi|Ti|Pi|Ei|Zi|Yi|KB|MB|GB|TB|PB|EB|ZB|YB|K|M|G|T|P|E|Z|Y)?)$",
},
},
"required": ["limit"],
"additionalProperties": False,
},
{
"properties": {
"limit_bytes": {"not": {}},
"limit": {"not": {}},
},
"additionalProperties": False,
},
],
},
}
@nickname("getOrganizationQuota")
def get(self, orgname, quota_id):
orgperm = OrganizationMemberPermission(orgname)
# Global readonly superusers can always view, regular superusers need FULL_ACCESS
if (
not orgperm.can()
and not allow_if_global_readonly_superuser()
and not (features.SUPERUSERS_FULL_ACCESS and allow_if_superuser())
):
raise Unauthorized()
quota = get_quota(orgname, quota_id)
return quota_view(quota)
@nickname("changeOrganizationQuota")
@require_scope(scopes.SUPERUSER)
@validate_json_request("UpdateOrgQuota")
def put(self, orgname, quota_id):
if not SuperUserPermission().can():
raise Unauthorized()
quota_data = request.get_json()
quota = get_quota(orgname, quota_id)
try:
limit_bytes = None
if "limit" in quota_data:
try:
limit_bytes = bitmath.parse_string_unsafe(quota_data["limit"]).to_Byte().value
except ValueError:
raise request_error(
message="Invalid limit format, use a number followed by a unit (e.g. 1GiB)"
)
elif "limit_bytes" in quota_data:
limit_bytes = quota_data["limit_bytes"]
if limit_bytes:
model.namespacequota.update_namespace_quota_size(quota, limit_bytes)
except model.DataModelException as ex:
raise request_error(exception=ex)
return quota_view(quota)
@nickname("deleteOrganizationQuota")
@require_scope(scopes.SUPERUSER)
def delete(self, orgname, quota_id):
if not SuperUserPermission().can():
raise Unauthorized()
quota = get_quota(orgname, quota_id)
# Exceptions by`delete_instance` are unexpected and raised
model.namespacequota.delete_namespace_quota(quota)
return "", 204
@resource("/v1/organization/<orgname>/quota/<quota_id>/limit")
@show_if(features.SUPER_USERS)
@show_if(features.QUOTA_MANAGEMENT and features.EDIT_QUOTA)
class OrganizationQuotaLimitList(ApiResource):
schemas = {
"NewOrgQuotaLimit": {
"type": "object",
"description": "Description of a new organization quota limit",
"required": ["type", "threshold_percent"],
"properties": {
"type": {
"type": "string",
"description": 'Type of quota limit: "Warning" or "Reject"',
},
"threshold_percent": {
"type": "integer",
"description": "Quota threshold, in percent of quota",
},
},
},
}
@nickname("listOrganizationQuotaLimit")
def get(self, orgname, quota_id):
orgperm = OrganizationMemberPermission(orgname)
if not orgperm.can() and not (features.SUPERUSERS_FULL_ACCESS and allow_if_any_superuser()):
raise Unauthorized()
quota = get_quota(orgname, quota_id)
return [
limit_view(limit)
for limit in model.namespacequota.get_namespace_quota_limit_list(quota)
]
@nickname("createOrganizationQuotaLimit")
@validate_json_request("NewOrgQuotaLimit")
@require_scope(scopes.SUPERUSER)
def post(self, orgname, quota_id):
if not SuperUserPermission().can():
raise Unauthorized()
quota_limit_data = request.get_json()
quota_type = quota_limit_data["type"]
quota_limit_threshold = quota_limit_data["threshold_percent"]
quota = get_quota(orgname, quota_id)
quota_limit = model.namespacequota.get_namespace_quota_limit_list(
quota,
quota_type=quota_type,
percent_of_limit=quota_limit_threshold,
)
if quota_limit:
msg = "Quota limit already exists"
raise request_error(message=msg)
if quota_limit_data["type"].lower() == "reject" and quota_limit:
raise request_error(message="Only one quota limit of type 'Reject' allowed.")
try:
model.namespacequota.create_namespace_quota_limit(
quota,
quota_type,
quota_limit_threshold,
)
return "Created", 201
except model.DataModelException as ex:
raise request_error(exception=ex)
@resource("/v1/organization/<orgname>/quota/<quota_id>/limit/<limit_id>")
@show_if(features.SUPER_USERS)
@show_if(features.QUOTA_MANAGEMENT and features.EDIT_QUOTA)
class OrganizationQuotaLimit(ApiResource):
schemas = {
"UpdateOrgQuotaLimit": {
"type": "object",
"description": "Description of changing organization quota limit",
"properties": {
"type": {
"type": "string",
"description": 'Type of quota limit: "Warning" or "Reject"',
},
"threshold_percent": {
"type": "integer",
"description": "Quota threshold, in percent of quota",
},
},
},
}
@nickname("getOrganizationQuotaLimit")
def get(self, orgname, quota_id, limit_id):
orgperm = OrganizationMemberPermission(orgname)
if not orgperm.can() and not (features.SUPERUSERS_FULL_ACCESS and allow_if_any_superuser()):
raise Unauthorized()
quota = get_quota(orgname, quota_id)
quota_limit = model.namespacequota.get_namespace_quota_limit(quota, limit_id)
if quota_limit is None:
raise NotFound()
return limit_view(quota_limit)
@nickname("changeOrganizationQuotaLimit")
@validate_json_request("UpdateOrgQuotaLimit")
@require_scope(scopes.SUPERUSER)
def put(self, orgname, quota_id, limit_id):
if not SuperUserPermission().can():
raise Unauthorized()
quota_limit_data = request.get_json()
quota = get_quota(orgname, quota_id)
quota_limit = model.namespacequota.get_namespace_quota_limit(quota, limit_id)
if quota_limit is None:
raise NotFound()
if "type" in quota_limit_data:
new_type = quota_limit_data["type"]
model.namespacequota.update_namespace_quota_limit_type(quota_limit, new_type)
if "threshold_percent" in quota_limit_data:
new_threshold = quota_limit_data["threshold_percent"]
model.namespacequota.update_namespace_quota_limit_threshold(quota_limit, new_threshold)
return quota_view(quota)
@nickname("deleteOrganizationQuotaLimit")
@require_scope(scopes.SUPERUSER)
def delete(self, orgname, quota_id, limit_id):
if not SuperUserPermission().can():
raise Unauthorized()
quota = get_quota(orgname, quota_id)
quota_limit = model.namespacequota.get_namespace_quota_limit(quota, limit_id)
if quota_limit is None:
raise NotFound()
try:
# Exceptions by`delete_instance` are unexpected and raised
model.namespacequota.delete_namespace_quota_limit(quota_limit)
return "", 204
except model.DataModelException as ex:
raise request_error(exception=ex)
@resource("/v1/user/quota")
@show_if(features.SUPER_USERS)
@show_if(features.QUOTA_MANAGEMENT and features.EDIT_QUOTA)
class UserQuotaList(ApiResource):
@require_user_admin()
@nickname("listUserQuota")
def get(self):
parent = get_authenticated_user()
user_quotas = model.namespacequota.get_namespace_quota_list(parent.username)
return [quota_view(quota) for quota in user_quotas]
@resource("/v1/user/quota/<quota_id>")
@show_if(features.SUPER_USERS)
@show_if(features.QUOTA_MANAGEMENT and features.EDIT_QUOTA)
class UserQuota(ApiResource):
@require_user_admin()
@nickname("getUserQuota")
def get(self, quota_id):
parent = get_authenticated_user()
quota = get_quota(parent.username, quota_id)
return quota_view(quota)
@resource("/v1/user/quota/<quota_id>/limit")
@show_if(features.SUPER_USERS)
@show_if(features.QUOTA_MANAGEMENT and features.EDIT_QUOTA)
class UserQuotaLimitList(ApiResource):
@require_user_admin()
@nickname("listUserQuotaLimit")
def get(self, quota_id):
parent = get_authenticated_user()
quota = get_quota(parent.username, quota_id)
return [
limit_view(limit)
for limit in model.namespacequota.get_namespace_quota_limit_list(quota)
]
@resource("/v1/user/quota/<quota_id>/limit/<limit_id>")
@show_if(features.SUPER_USERS)
@show_if(features.QUOTA_MANAGEMENT and features.EDIT_QUOTA)
class UserQuotaLimit(ApiResource):
@require_user_admin()
@nickname("getUserQuotaLimit")
def get(self, quota_id, limit_id):
parent = get_authenticated_user()
quota = get_quota(parent.username, quota_id)
quota_limit = model.namespacequota.get_namespace_quota_limit(quota, limit_id)
if quota_limit is None:
raise NotFound()
return quota_view(quota)