mirror of
https://github.com/quay/quay.git
synced 2025-11-17 23:02:34 +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>
468 lines
16 KiB
Python
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)
|