mirror of
https://github.com/quay/quay.git
synced 2026-01-27 18:42:52 +03:00
Adds audit logging for all quota management operations: - org_create_quota, org_change_quota, org_delete_quota - org_create_quota_limit, org_change_quota_limit, org_delete_quota_limit Backend changes: - Add LogEntryKind types in initdb.py - Add log_action calls in namespacequota.py endpoints - Add Alembic migration for new log kinds - Add unit tests for audit logging Frontend changes: - Add log descriptions in UseLogDescriptions.tsx - Add Cypress e2e test for quota log display 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
560 lines
19 KiB
Python
560 lines
19 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
|
|
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,
|
|
allow_if_superuser_with_full_access,
|
|
log_action,
|
|
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 allow_if_superuser_with_full_access():
|
|
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)
|
|
log_action(
|
|
"org_create_quota",
|
|
orgname,
|
|
{
|
|
"namespace": orgname,
|
|
"limit_bytes": limit_bytes,
|
|
"limit": bitmath.Byte(limit_bytes).best_prefix().format("{value:.1f} {unit}"),
|
|
},
|
|
)
|
|
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 allow_if_superuser_with_full_access():
|
|
raise Unauthorized()
|
|
|
|
quota_data = request.get_json()
|
|
quota = get_quota(orgname, quota_id)
|
|
previous_limit_bytes = quota.limit_bytes
|
|
|
|
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)
|
|
log_action(
|
|
"org_change_quota",
|
|
orgname,
|
|
{
|
|
"namespace": orgname,
|
|
"limit_bytes": limit_bytes,
|
|
"limit": bitmath.Byte(limit_bytes)
|
|
.best_prefix()
|
|
.format("{value:.1f} {unit}"),
|
|
"previous_limit_bytes": previous_limit_bytes,
|
|
"previous_limit": bitmath.Byte(previous_limit_bytes)
|
|
.best_prefix()
|
|
.format("{value:.1f} {unit}"),
|
|
},
|
|
)
|
|
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 allow_if_superuser_with_full_access():
|
|
raise Unauthorized()
|
|
|
|
quota = get_quota(orgname, quota_id)
|
|
|
|
# Capture values before deletion for audit logging
|
|
deleted_quota_id = quota.id
|
|
limit_bytes = quota.limit_bytes
|
|
|
|
# Perform deletion first - exceptions are unexpected and raised
|
|
model.namespacequota.delete_namespace_quota(quota)
|
|
|
|
# Log after successful deletion
|
|
log_action(
|
|
"org_delete_quota",
|
|
orgname,
|
|
{
|
|
"namespace": orgname,
|
|
"quota_id": deleted_quota_id,
|
|
"limit_bytes": limit_bytes,
|
|
"limit": bitmath.Byte(limit_bytes).best_prefix().format("{value:.1f} {unit}"),
|
|
},
|
|
)
|
|
|
|
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):
|
|
permission = OrganizationMemberPermission(orgname)
|
|
if (
|
|
permission.can()
|
|
or allow_if_global_readonly_superuser()
|
|
or allow_if_superuser_with_full_access()
|
|
):
|
|
quota = get_quota(orgname, quota_id)
|
|
return [
|
|
limit_view(limit)
|
|
for limit in model.namespacequota.get_namespace_quota_limit_list(quota)
|
|
]
|
|
|
|
raise Unauthorized()
|
|
|
|
@nickname("createOrganizationQuotaLimit")
|
|
@validate_json_request("NewOrgQuotaLimit")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def post(self, orgname, quota_id):
|
|
if not allow_if_superuser_with_full_access():
|
|
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,
|
|
)
|
|
log_action(
|
|
"org_create_quota_limit",
|
|
orgname,
|
|
{
|
|
"namespace": orgname,
|
|
"type": quota_type,
|
|
"threshold_percent": 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):
|
|
permission = OrganizationMemberPermission(orgname)
|
|
if (
|
|
permission.can()
|
|
or allow_if_global_readonly_superuser()
|
|
or allow_if_superuser_with_full_access()
|
|
):
|
|
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)
|
|
|
|
raise Unauthorized()
|
|
|
|
@nickname("changeOrganizationQuotaLimit")
|
|
@validate_json_request("UpdateOrgQuotaLimit")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def put(self, orgname, quota_id, limit_id):
|
|
if not allow_if_superuser_with_full_access():
|
|
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()
|
|
|
|
# Capture previous values for audit logging
|
|
previous_type = quota_limit.quota_type.name
|
|
previous_threshold = quota_limit.percent_of_limit
|
|
|
|
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)
|
|
|
|
# Build metadata with current values after updates
|
|
metadata = {
|
|
"namespace": orgname,
|
|
"type": quota_limit_data.get("type", previous_type),
|
|
"threshold_percent": quota_limit_data.get("threshold_percent", previous_threshold),
|
|
"previous_type": previous_type,
|
|
"previous_threshold_percent": previous_threshold,
|
|
}
|
|
log_action("org_change_quota_limit", orgname, metadata)
|
|
|
|
return quota_view(quota)
|
|
|
|
@nickname("deleteOrganizationQuotaLimit")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def delete(self, orgname, quota_id, limit_id):
|
|
if not allow_if_superuser_with_full_access():
|
|
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()
|
|
|
|
# Capture values before deletion for audit logging
|
|
deleted_limit_id = quota_limit.id
|
|
quota_type = quota_limit.quota_type.name
|
|
threshold_percent = quota_limit.percent_of_limit
|
|
|
|
try:
|
|
# Perform deletion first - exceptions are unexpected and raised
|
|
model.namespacequota.delete_namespace_quota_limit(quota_limit)
|
|
except model.DataModelException as ex:
|
|
raise request_error(exception=ex)
|
|
|
|
# Log after successful deletion
|
|
log_action(
|
|
"org_delete_quota_limit",
|
|
orgname,
|
|
{
|
|
"namespace": orgname,
|
|
"limit_id": deleted_limit_id,
|
|
"type": quota_type,
|
|
"threshold_percent": threshold_percent,
|
|
},
|
|
)
|
|
|
|
return "", 204
|
|
|
|
|
|
@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)
|