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>
1445 lines
48 KiB
Python
1445 lines
48 KiB
Python
"""
|
|
Superuser API.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import socket
|
|
import string
|
|
from datetime import datetime
|
|
from random import SystemRandom
|
|
|
|
import bitmath
|
|
from cryptography.hazmat.primitives import serialization
|
|
from flask import jsonify, make_response, request
|
|
|
|
import features
|
|
from _init import ROOT_DIR
|
|
from app import app, authentication, avatar, config_provider, usermanager
|
|
from auth import scopes
|
|
from auth.auth_context import get_authenticated_user
|
|
from auth.permissions import SuperUserPermission
|
|
from data import model
|
|
from data.database import ServiceKeyApprovalType
|
|
from data.logs_model import logs_model
|
|
from data.model import DataModelException, InvalidNamespaceQuota, namespacequota, user
|
|
from data.model.quota import get_registry_size, queue_registry_size_calculation
|
|
from endpoints.api import (
|
|
ApiResource,
|
|
InvalidRequest,
|
|
InvalidResponse,
|
|
NotFound,
|
|
Unauthorized,
|
|
allow_if_any_superuser,
|
|
allow_if_global_readonly_superuser,
|
|
allow_if_superuser_with_full_access,
|
|
format_date,
|
|
internal_only,
|
|
log_action,
|
|
nickname,
|
|
page_support,
|
|
parse_args,
|
|
path_param,
|
|
query_param,
|
|
request_error,
|
|
require_fresh_login,
|
|
require_scope,
|
|
resource,
|
|
show_if,
|
|
validate_json_request,
|
|
verify_not_prod,
|
|
)
|
|
from endpoints.api.build import get_logs_or_log_url
|
|
from endpoints.api.logs import _validate_logs_arguments
|
|
from endpoints.api.namespacequota import get_quota, limit_view, quota_view
|
|
from endpoints.api.superuser_models_pre_oci import (
|
|
InvalidRepositoryBuildException,
|
|
ServiceKeyAlreadyApproved,
|
|
ServiceKeyDoesNotExist,
|
|
pre_oci_model,
|
|
)
|
|
from util.config.schema import CONFIG_SCHEMA
|
|
from util.parsing import truthy_bool
|
|
from util.request import get_request_ip
|
|
from util.useremails import send_confirmation_email, send_recovery_email
|
|
from util.validation import validate_service_key_name
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_immediate_subdirectories(directory):
|
|
return [name for name in os.listdir(directory) if os.path.isdir(os.path.join(directory, name))]
|
|
|
|
|
|
def get_services():
|
|
services = set(get_immediate_subdirectories(app.config["SYSTEM_SERVICES_PATH"]))
|
|
services = services - set(app.config["SYSTEM_SERVICE_BLACKLIST"])
|
|
return services
|
|
|
|
|
|
@resource("/v1/superuser/aggregatelogs")
|
|
@internal_only
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserAggregateLogs(ApiResource):
|
|
"""
|
|
Resource for fetching aggregated logs for the current user.
|
|
"""
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("listAllAggregateLogs")
|
|
@parse_args()
|
|
@query_param("starttime", "Earliest time from which to get logs. (%m/%d/%Y %Z)", type=str)
|
|
@query_param("endtime", "Latest time to which to get logs. (%m/%d/%Y %Z)", type=str)
|
|
def get(self, parsed_args):
|
|
"""
|
|
Returns the aggregated logs for the current system.
|
|
"""
|
|
if allow_if_any_superuser():
|
|
(start_time, end_time) = _validate_logs_arguments(
|
|
parsed_args["starttime"], parsed_args["endtime"]
|
|
)
|
|
aggregated_logs = logs_model.get_aggregated_log_counts(start_time, end_time)
|
|
return {"aggregated": [log.to_dict() for log in aggregated_logs]}
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
LOGS_PER_PAGE = 20
|
|
|
|
|
|
@resource("/v1/superuser/logs")
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserLogs(ApiResource):
|
|
"""
|
|
Resource for fetching all logs in the system.
|
|
"""
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("listAllLogs")
|
|
@parse_args()
|
|
@query_param("starttime", "Earliest time from which to get logs (%m/%d/%Y %Z)", type=str)
|
|
@query_param("endtime", "Latest time to which to get logs (%m/%d/%Y %Z)", type=str)
|
|
@query_param("page", "The page number for the logs", type=int, default=1)
|
|
@page_support()
|
|
@require_scope(scopes.SUPERUSER)
|
|
def get(self, parsed_args, page_token):
|
|
"""
|
|
List the usage logs for the current system.
|
|
"""
|
|
if allow_if_any_superuser():
|
|
start_time = parsed_args["starttime"]
|
|
end_time = parsed_args["endtime"]
|
|
|
|
(start_time, end_time) = _validate_logs_arguments(start_time, end_time)
|
|
log_entry_page = logs_model.lookup_logs(start_time, end_time, page_token=page_token)
|
|
return (
|
|
{
|
|
"start_time": format_date(start_time),
|
|
"end_time": format_date(end_time),
|
|
"logs": [
|
|
log.to_dict(avatar, include_namespace=True) for log in log_entry_page.logs
|
|
],
|
|
},
|
|
log_entry_page.next_page_token,
|
|
)
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
def org_view(org):
|
|
return {
|
|
"name": org.username,
|
|
"email": org.email,
|
|
"avatar": avatar.get_data_for_org(org),
|
|
}
|
|
|
|
|
|
def user_view(user, password=None):
|
|
user_data = {
|
|
"kind": "user",
|
|
"name": user.username,
|
|
"username": user.username,
|
|
"email": user.email,
|
|
"verified": user.verified,
|
|
"avatar": avatar.get_data_for_user(user),
|
|
"super_user": usermanager.is_superuser(user.username),
|
|
"enabled": user.enabled,
|
|
}
|
|
|
|
if password is not None:
|
|
user_data["encrypted_password"] = authentication.encrypt_user_password(password).decode(
|
|
"ascii"
|
|
)
|
|
|
|
return user_data
|
|
|
|
|
|
@resource("/v1/superuser/changelog/")
|
|
@internal_only
|
|
@show_if(features.SUPER_USERS)
|
|
class ChangeLog(ApiResource):
|
|
"""
|
|
Resource for returning the change log for enterprise customers.
|
|
"""
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("getChangeLog")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def get(self):
|
|
"""
|
|
Returns the change log for this installation.
|
|
"""
|
|
if allow_if_any_superuser():
|
|
with open(os.path.join(ROOT_DIR, "CHANGELOG.md"), "r") as f:
|
|
return {"log": f.read()}
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superuser/organizations/")
|
|
@internal_only
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserOrganizationList(ApiResource):
|
|
"""
|
|
Resource for listing organizations in the system.
|
|
"""
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("listAllOrganizations")
|
|
@parse_args()
|
|
@query_param(
|
|
"limit",
|
|
"Limit to the number of results to return per page. Max 100.",
|
|
type=int,
|
|
default=None,
|
|
)
|
|
@require_scope(scopes.SUPERUSER)
|
|
@page_support()
|
|
def get(self, parsed_args, page_token):
|
|
"""
|
|
Returns a list of all organizations in the system.
|
|
"""
|
|
if allow_if_any_superuser():
|
|
if parsed_args["limit"] is not None and parsed_args["limit"] > 100:
|
|
raise InvalidRequest("Page limit cannot be above 100")
|
|
|
|
if parsed_args["limit"] is None:
|
|
return {
|
|
"organizations": [org.to_dict() for org in pre_oci_model.get_organizations()]
|
|
}, None
|
|
else:
|
|
orgs, next_page_token = pre_oci_model.get_organizations_paginated(
|
|
limit=parsed_args["limit"],
|
|
page_token=page_token,
|
|
)
|
|
return {"organizations": [org.to_dict() for org in orgs]}, next_page_token
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superuser/registrysize/")
|
|
@internal_only
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserRegistrySize(ApiResource):
|
|
"""
|
|
Resource for the current registry size.
|
|
"""
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("getRegistrySize")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def get(self):
|
|
"""
|
|
Returns size of the registry
|
|
"""
|
|
if allow_if_any_superuser():
|
|
registry_size = get_registry_size()
|
|
if registry_size is not None:
|
|
return {
|
|
"size_bytes": registry_size.size_bytes,
|
|
"last_ran": registry_size.completed_ms,
|
|
"queued": registry_size.queued,
|
|
"running": registry_size.running,
|
|
}
|
|
else:
|
|
return {"size_bytes": 0, "last_ran": None, "running": False, "queued": False}
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("queueRegistrySizeCalculation")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def post(self):
|
|
"""
|
|
Queues registry size calculation
|
|
"""
|
|
if SuperUserPermission().can():
|
|
queued, already_queued = queue_registry_size_calculation()
|
|
if already_queued:
|
|
return "", 202
|
|
elif queued:
|
|
return "", 201
|
|
else:
|
|
raise InvalidRequest("Could not queue registry size calculation")
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource(
|
|
"/v1/superuser/users/<namespace>/quota",
|
|
"/v1/superuser/organization/<namespace>/quota",
|
|
)
|
|
@show_if(features.SUPER_USERS)
|
|
@show_if(features.QUOTA_MANAGEMENT and features.EDIT_QUOTA)
|
|
class SuperUserUserQuotaList(ApiResource):
|
|
|
|
schemas = {
|
|
"NewNamespaceQuota": {
|
|
"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)?)$",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname(["listUserQuotaSuperUser", "listOrganizationQuotaSuperUser"])
|
|
@require_scope(scopes.SUPERUSER)
|
|
def get(self, namespace):
|
|
if allow_if_any_superuser():
|
|
|
|
try:
|
|
namespace_user = user.get_user_or_org(namespace)
|
|
except DataModelException as ex:
|
|
raise request_error(exception=ex)
|
|
|
|
if not namespace_user:
|
|
raise NotFound()
|
|
|
|
quotas = namespacequota.get_namespace_quota_list(namespace_user.username)
|
|
return [quota_view(quota) for quota in quotas]
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname(["createUserQuotaSuperUser", "createOrganizationQuotaSuperUser"])
|
|
@require_scope(scopes.SUPERUSER)
|
|
@validate_json_request("NewNamespaceQuota")
|
|
def post(self, namespace):
|
|
if SuperUserPermission().can():
|
|
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")
|
|
else:
|
|
limit_bytes = quota_data["limit_bytes"]
|
|
|
|
namespace_user = user.get_user_or_org(namespace)
|
|
quotas = namespacequota.get_namespace_quota_list(namespace_user.username)
|
|
|
|
if quotas:
|
|
raise request_error(message="Quota for '%s' already exists" % namespace)
|
|
|
|
try:
|
|
newquota = namespacequota.create_namespace_quota(namespace_user, limit_bytes)
|
|
return "Created", 201
|
|
except DataModelException as ex:
|
|
raise request_error(exception=ex)
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource(
|
|
"/v1/superuser/users/<namespace>/quota/<quota_id>",
|
|
"/v1/superuser/organization/<namespace>/quota/<quota_id>",
|
|
)
|
|
@show_if(features.SUPER_USERS)
|
|
@show_if(features.QUOTA_MANAGEMENT and features.EDIT_QUOTA)
|
|
class SuperUserUserQuota(ApiResource):
|
|
|
|
schemas = {
|
|
"UpdateNamespaceQuota": {
|
|
"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,
|
|
},
|
|
],
|
|
},
|
|
}
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname(["changeUserQuotaSuperUser", "changeOrganizationQuotaSuperUser"])
|
|
@require_scope(scopes.SUPERUSER)
|
|
@validate_json_request("UpdateNamespaceQuota")
|
|
def put(self, namespace, quota_id):
|
|
if SuperUserPermission().can():
|
|
quota_data = request.get_json()
|
|
|
|
namespace_user = user.get_user_or_org(namespace)
|
|
quota = get_quota(namespace_user.username, 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")
|
|
elif "limit_bytes" in quota_data:
|
|
limit_bytes = quota_data["limit_bytes"]
|
|
|
|
if limit_bytes:
|
|
namespacequota.update_namespace_quota_size(quota, limit_bytes)
|
|
except DataModelException as ex:
|
|
raise request_error(exception=ex)
|
|
|
|
return quota_view(quota)
|
|
|
|
raise Unauthorized()
|
|
|
|
@nickname(["deleteUserQuotaSuperUser", "deleteOrganizationQuotaSuperUser"])
|
|
@require_scope(scopes.SUPERUSER)
|
|
def delete(self, namespace, quota_id):
|
|
if SuperUserPermission().can():
|
|
namespace_user = user.get_user_or_org(namespace)
|
|
quota = get_quota(namespace_user.username, quota_id)
|
|
namespacequota.delete_namespace_quota(quota)
|
|
|
|
return "", 204
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superuser/users/")
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserList(ApiResource):
|
|
"""
|
|
Resource for listing users in the system.
|
|
"""
|
|
|
|
schemas = {
|
|
"CreateInstallUser": {
|
|
"id": "CreateInstallUser",
|
|
"description": "Data for creating a user",
|
|
"required": ["username"],
|
|
"properties": {
|
|
"username": {
|
|
"type": "string",
|
|
"description": "The username of the user being created",
|
|
},
|
|
"email": {
|
|
"type": "string",
|
|
"description": "The email address of the user being created",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("listAllUsers")
|
|
@parse_args()
|
|
@query_param(
|
|
"disabled", "If false, only enabled users will be returned.", type=truthy_bool, default=True
|
|
)
|
|
@query_param(
|
|
"limit",
|
|
"Limit to the number of results to return per page. Max 100.",
|
|
type=int,
|
|
default=None,
|
|
)
|
|
@require_scope(scopes.SUPERUSER)
|
|
@page_support()
|
|
def get(self, parsed_args, page_token):
|
|
"""
|
|
Returns a list of all users in the system.
|
|
"""
|
|
if allow_if_any_superuser():
|
|
if parsed_args["limit"] is not None and parsed_args["limit"] > 100:
|
|
raise InvalidRequest("Page limit cannot be above 100")
|
|
|
|
if parsed_args["limit"] is None:
|
|
users = pre_oci_model.get_active_users(disabled=parsed_args["disabled"])
|
|
return {"users": [user.to_dict() for user in users]}, None
|
|
else:
|
|
users, next_page_token = pre_oci_model.get_active_users_paginated(
|
|
disabled=parsed_args["disabled"],
|
|
limit=parsed_args["limit"],
|
|
page_token=page_token,
|
|
)
|
|
return {"users": [user.to_dict() for user in users]}, next_page_token
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("createInstallUser")
|
|
@validate_json_request("CreateInstallUser")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def post(self):
|
|
"""
|
|
Creates a new user.
|
|
"""
|
|
# Ensure that we are using database auth.
|
|
if app.config["AUTHENTICATION_TYPE"] != "Database":
|
|
raise InvalidRequest("Cannot create a user in a non-database auth system")
|
|
|
|
user_information = request.get_json()
|
|
if SuperUserPermission().can():
|
|
# Generate a temporary password for the user.
|
|
random = SystemRandom()
|
|
password = "".join(
|
|
[random.choice(string.ascii_uppercase + string.digits) for _ in range(32)]
|
|
)
|
|
|
|
# Create the user.
|
|
username = user_information["username"]
|
|
email = user_information.get("email")
|
|
install_user, confirmation_code = pre_oci_model.create_install_user(
|
|
username, password, email
|
|
)
|
|
|
|
authed_user = get_authenticated_user()
|
|
|
|
log_action(
|
|
"user_create",
|
|
username,
|
|
{"email": email, "username": username, "superuser": authed_user.username},
|
|
)
|
|
|
|
if features.MAILING:
|
|
send_confirmation_email(
|
|
install_user.username, install_user.email, confirmation_code
|
|
)
|
|
|
|
return {
|
|
"username": username,
|
|
"email": email,
|
|
"password": password,
|
|
"encrypted_password": authentication.encrypt_user_password(password).decode(
|
|
"ascii"
|
|
),
|
|
}
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superusers/users/<username>/sendrecovery")
|
|
@internal_only
|
|
@show_if(features.SUPER_USERS)
|
|
@show_if(features.MAILING)
|
|
class SuperUserSendRecoveryEmail(ApiResource):
|
|
"""
|
|
Resource for sending a recovery user on behalf of a user.
|
|
"""
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("sendInstallUserRecoveryEmail")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def post(self, username):
|
|
# Ensure that we are using database auth.
|
|
if app.config["AUTHENTICATION_TYPE"] != "Database":
|
|
raise InvalidRequest("Cannot send a recovery e-mail for non-database auth")
|
|
|
|
if SuperUserPermission().can():
|
|
user = pre_oci_model.get_nonrobot_user(username)
|
|
if user is None:
|
|
raise NotFound()
|
|
|
|
if usermanager.is_superuser(username):
|
|
raise InvalidRequest("Cannot send a recovery email for a superuser")
|
|
|
|
code = pre_oci_model.create_reset_password_email_code(user.email)
|
|
send_recovery_email(user.email, code)
|
|
return {"email": user.email}
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superuser/users/<username>")
|
|
@path_param("username", "The username of the user being managed")
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserManagement(ApiResource):
|
|
"""
|
|
Resource for managing users in the system.
|
|
"""
|
|
|
|
schemas = {
|
|
"UpdateUser": {
|
|
"id": "UpdateUser",
|
|
"type": "object",
|
|
"description": "Description of updates for a user",
|
|
"properties": {
|
|
"password": {
|
|
"type": "string",
|
|
"description": "The new password for the user",
|
|
},
|
|
"email": {
|
|
"type": "string",
|
|
"description": "The new e-mail address for the user",
|
|
},
|
|
"enabled": {"type": "boolean", "description": "Whether the user is enabled"},
|
|
},
|
|
},
|
|
}
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("getInstallUser")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def get(self, username):
|
|
"""
|
|
Returns information about the specified user.
|
|
"""
|
|
if allow_if_any_superuser():
|
|
user = pre_oci_model.get_nonrobot_user(username)
|
|
if user is None:
|
|
raise NotFound()
|
|
|
|
return user.to_dict()
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("deleteInstallUser")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def delete(self, username):
|
|
"""
|
|
Deletes the specified user.
|
|
"""
|
|
if SuperUserPermission().can():
|
|
user = pre_oci_model.get_nonrobot_user(username)
|
|
if user is None:
|
|
raise NotFound()
|
|
|
|
if usermanager.is_superuser(username):
|
|
raise InvalidRequest("Cannot delete a superuser")
|
|
|
|
log_action("user_delete", username, {"username": username})
|
|
|
|
pre_oci_model.mark_user_for_deletion(username)
|
|
return "", 204
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("changeInstallUser")
|
|
@validate_json_request("UpdateUser")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def put(self, username):
|
|
"""
|
|
Updates information about the specified user.
|
|
"""
|
|
if SuperUserPermission().can():
|
|
user = pre_oci_model.get_nonrobot_user(username)
|
|
if user is None:
|
|
raise NotFound()
|
|
|
|
if usermanager.is_superuser(username):
|
|
raise InvalidRequest("Cannot update a superuser")
|
|
|
|
authed_user = get_authenticated_user()
|
|
|
|
user_data = request.get_json()
|
|
if "password" in user_data:
|
|
# Ensure that we are using database auth.
|
|
if app.config["AUTHENTICATION_TYPE"] != "Database":
|
|
raise InvalidRequest("Cannot change password in non-database auth")
|
|
|
|
log_action(
|
|
"user_change_password",
|
|
username,
|
|
{"username": username, "superuser": authed_user.username},
|
|
)
|
|
|
|
pre_oci_model.change_password(username, user_data["password"])
|
|
|
|
if "email" in user_data:
|
|
# Ensure that we are using database auth.
|
|
if app.config["AUTHENTICATION_TYPE"] not in ["Database", "AppToken"]:
|
|
raise InvalidRequest("Cannot change e-mail in non-database auth")
|
|
|
|
old_email = user.email
|
|
new_email = user_data["email"]
|
|
|
|
pre_oci_model.update_email(username, user_data["email"], auto_verify=True)
|
|
|
|
log_action(
|
|
"user_change_email",
|
|
username,
|
|
{"old_email": old_email, "email": new_email, "superuser": authed_user.username},
|
|
)
|
|
|
|
if "enabled" in user_data:
|
|
# Disable/enable the user.
|
|
enabled = bool(user_data["enabled"])
|
|
|
|
authed_user = get_authenticated_user()
|
|
|
|
if enabled:
|
|
log_action(
|
|
"user_enable",
|
|
username,
|
|
{"username": username, "superuser": authed_user.username},
|
|
)
|
|
else:
|
|
log_action(
|
|
"user_disable",
|
|
username,
|
|
{"username": username, "superuser": authed_user.username},
|
|
)
|
|
|
|
pre_oci_model.update_enabled(username, enabled)
|
|
|
|
if "superuser" in user_data:
|
|
config_object = config_provider.get_config()
|
|
superusers_set = set(config_object["SUPER_USERS"])
|
|
|
|
if user_data["superuser"]:
|
|
superusers_set.add(username)
|
|
elif username in superusers_set:
|
|
superusers_set.remove(username)
|
|
|
|
config_object["SUPER_USERS"] = list(superusers_set)
|
|
config_provider.save_config(config_object)
|
|
|
|
return_value = user.to_dict()
|
|
if user_data.get("password") is not None:
|
|
password = user_data.get("password")
|
|
return_value["encrypted_password"] = authentication.encrypt_user_password(
|
|
password
|
|
).decode("ascii")
|
|
if user_data.get("email") is not None:
|
|
return_value["email"] = user_data.get("email")
|
|
|
|
return return_value
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superuser/takeownership/<namespace>")
|
|
@path_param("namespace", "The namespace of the user or organization being managed")
|
|
@internal_only
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserTakeOwnership(ApiResource):
|
|
"""
|
|
Resource for a superuser to take ownership of a namespace.
|
|
"""
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("takeOwnership")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def post(self, namespace):
|
|
"""
|
|
Takes ownership of the specified organization or user.
|
|
"""
|
|
if SuperUserPermission().can():
|
|
# Disallow for superusers.
|
|
if usermanager.is_superuser(namespace):
|
|
raise InvalidRequest("Cannot take ownership of a superuser")
|
|
|
|
authed_user = get_authenticated_user()
|
|
entity_id, was_user = pre_oci_model.take_ownership(namespace, authed_user)
|
|
if entity_id is None:
|
|
raise NotFound()
|
|
|
|
# Log the change.
|
|
log_metadata = {
|
|
"entity_id": entity_id,
|
|
"namespace": namespace,
|
|
"was_user": was_user,
|
|
"superuser": authed_user.username,
|
|
}
|
|
|
|
log_action("take_ownership", authed_user.username, log_metadata)
|
|
|
|
return jsonify({"namespace": namespace})
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superuser/organizations/<name>")
|
|
@path_param("name", "The name of the organizaton being managed")
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserOrganizationManagement(ApiResource):
|
|
"""
|
|
Resource for managing organizations in the system.
|
|
"""
|
|
|
|
schemas = {
|
|
"UpdateOrg": {
|
|
"id": "UpdateOrg",
|
|
"type": "object",
|
|
"description": "Description of updates for an organization",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "The new name for the organization",
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("deleteOrganization")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def delete(self, name):
|
|
"""
|
|
Deletes the specified organization.
|
|
"""
|
|
if SuperUserPermission().can():
|
|
pre_oci_model.mark_organization_for_deletion(name)
|
|
return "", 204
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("changeOrganization")
|
|
@validate_json_request("UpdateOrg")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def put(self, name):
|
|
"""
|
|
Updates information about the specified user.
|
|
"""
|
|
if SuperUserPermission().can():
|
|
org_data = request.get_json()
|
|
new_name = org_data["name"] if "name" in org_data else None
|
|
|
|
authed_user = get_authenticated_user()
|
|
|
|
log_action(
|
|
"org_change_name",
|
|
name,
|
|
{"old_name": name, "new_name": new_name, "superuser": authed_user.username},
|
|
)
|
|
|
|
org = pre_oci_model.change_organization_name(name, new_name)
|
|
return org.to_dict()
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
def key_view(key):
|
|
return {
|
|
"name": key.name,
|
|
"kid": key.kid,
|
|
"service": key.service,
|
|
"jwk": key.jwk,
|
|
"metadata": key.metadata,
|
|
"created_date": key.created_date,
|
|
"expiration_date": key.expiration_date,
|
|
"rotation_duration": key.rotation_duration,
|
|
"approval": approval_view(key.approval) if key.approval is not None else None,
|
|
}
|
|
|
|
|
|
def approval_view(approval):
|
|
return {
|
|
"approver": user_view(approval.approver) if approval.approver else None,
|
|
"approval_type": approval.approval_type,
|
|
"approved_date": approval.approved_date,
|
|
"notes": approval.notes,
|
|
}
|
|
|
|
|
|
@resource("/v1/superuser/keys")
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserServiceKeyManagement(ApiResource):
|
|
"""
|
|
Resource for managing service keys.
|
|
"""
|
|
|
|
schemas = {
|
|
"CreateServiceKey": {
|
|
"id": "CreateServiceKey",
|
|
"type": "object",
|
|
"description": "Description of creation of a service key",
|
|
"required": ["service", "expiration"],
|
|
"properties": {
|
|
"service": {
|
|
"type": "string",
|
|
"description": "The service authenticating with this key",
|
|
},
|
|
"name": {
|
|
"type": "string",
|
|
"description": "The friendly name of a service key",
|
|
},
|
|
"metadata": {
|
|
"type": "object",
|
|
"description": "The key/value pairs of this key's metadata",
|
|
},
|
|
"notes": {
|
|
"type": "string",
|
|
"description": "If specified, the extra notes for the key",
|
|
},
|
|
"expiration": {
|
|
"description": "The expiration date as a unix timestamp",
|
|
"anyOf": [{"type": "number"}, {"type": "null"}],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
@verify_not_prod
|
|
@nickname("listServiceKeys")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def get(self):
|
|
if allow_if_any_superuser():
|
|
keys = pre_oci_model.list_all_service_keys()
|
|
|
|
return jsonify(
|
|
{
|
|
"keys": [key.to_dict() for key in keys],
|
|
}
|
|
)
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("createServiceKey")
|
|
@require_scope(scopes.SUPERUSER)
|
|
@validate_json_request("CreateServiceKey")
|
|
def post(self):
|
|
if SuperUserPermission().can():
|
|
body = request.get_json()
|
|
key_name = body.get("name", "")
|
|
if not validate_service_key_name(key_name):
|
|
raise InvalidRequest("Invalid service key friendly name: %s" % key_name)
|
|
|
|
# Ensure we have a valid expiration date if specified.
|
|
expiration_date = body.get("expiration", None)
|
|
if expiration_date is not None:
|
|
try:
|
|
expiration_date = datetime.utcfromtimestamp(float(expiration_date))
|
|
except ValueError as ve:
|
|
raise InvalidRequest("Invalid expiration date: %s" % ve)
|
|
|
|
if expiration_date <= datetime.now():
|
|
raise InvalidRequest("Expiration date cannot be in the past")
|
|
|
|
# Create the metadata for the key.
|
|
user = get_authenticated_user()
|
|
metadata = body.get("metadata", {})
|
|
metadata.update(
|
|
{
|
|
"created_by": "Quay Superuser Panel",
|
|
"creator": user.username,
|
|
"ip": get_request_ip(),
|
|
}
|
|
)
|
|
|
|
# Generate a key with a private key that we *never save*.
|
|
(private_key, key_id) = pre_oci_model.generate_service_key(
|
|
body["service"], expiration_date, metadata=metadata, name=key_name
|
|
)
|
|
# Auto-approve the service key.
|
|
pre_oci_model.approve_service_key(
|
|
key_id, user, ServiceKeyApprovalType.SUPERUSER, notes=body.get("notes", "")
|
|
)
|
|
|
|
# Log the creation and auto-approval of the service key.
|
|
key_log_metadata = {
|
|
"kid": key_id,
|
|
"preshared": True,
|
|
"service": body["service"],
|
|
"name": key_name,
|
|
"expiration_date": expiration_date,
|
|
"auto_approved": True,
|
|
}
|
|
|
|
log_action("service_key_create", None, key_log_metadata)
|
|
log_action("service_key_approve", None, key_log_metadata)
|
|
|
|
public_pem = private_key.public_key().public_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
)
|
|
|
|
private_pem = private_key.private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
)
|
|
|
|
return jsonify(
|
|
{
|
|
"kid": key_id,
|
|
"name": key_name,
|
|
"service": body["service"],
|
|
"public_key": public_pem.decode("ascii"),
|
|
"private_key": private_pem.decode("ascii"),
|
|
}
|
|
)
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superuser/keys/<kid>")
|
|
@path_param("kid", "The unique identifier for a service key")
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserServiceKey(ApiResource):
|
|
"""
|
|
Resource for managing service keys.
|
|
"""
|
|
|
|
schemas = {
|
|
"PutServiceKey": {
|
|
"id": "PutServiceKey",
|
|
"type": "object",
|
|
"description": "Description of updates for a service key",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string",
|
|
"description": "The friendly name of a service key",
|
|
},
|
|
"metadata": {
|
|
"type": "object",
|
|
"description": "The key/value pairs of this key's metadata",
|
|
},
|
|
"expiration": {
|
|
"description": "The expiration date as a unix timestamp",
|
|
"anyOf": [{"type": "number"}, {"type": "null"}],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
@verify_not_prod
|
|
@nickname("getServiceKey")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def get(self, kid):
|
|
if allow_if_any_superuser():
|
|
try:
|
|
key = pre_oci_model.get_service_key(kid, approved_only=False, alive_only=False)
|
|
return jsonify(key.to_dict())
|
|
except ServiceKeyDoesNotExist:
|
|
raise NotFound()
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("updateServiceKey")
|
|
@require_scope(scopes.SUPERUSER)
|
|
@validate_json_request("PutServiceKey")
|
|
def put(self, kid):
|
|
if SuperUserPermission().can():
|
|
body = request.get_json()
|
|
try:
|
|
key = pre_oci_model.get_service_key(kid, approved_only=False, alive_only=False)
|
|
except ServiceKeyDoesNotExist:
|
|
raise NotFound()
|
|
|
|
key_log_metadata = {
|
|
"kid": key.kid,
|
|
"service": key.service,
|
|
"name": body.get("name", key.name),
|
|
"expiration_date": key.expiration_date,
|
|
}
|
|
|
|
if "expiration" in body:
|
|
expiration_date = body["expiration"]
|
|
if expiration_date is not None and expiration_date != "":
|
|
try:
|
|
expiration_date = datetime.utcfromtimestamp(float(expiration_date))
|
|
except ValueError as ve:
|
|
raise InvalidRequest("Invalid expiration date: %s" % ve)
|
|
|
|
if expiration_date <= datetime.now():
|
|
raise InvalidRequest("Cannot have an expiration date in the past")
|
|
|
|
key_log_metadata.update(
|
|
{
|
|
"old_expiration_date": key.expiration_date,
|
|
"expiration_date": expiration_date,
|
|
}
|
|
)
|
|
|
|
log_action("service_key_extend", None, key_log_metadata)
|
|
pre_oci_model.set_key_expiration(kid, expiration_date)
|
|
|
|
if "name" in body or "metadata" in body:
|
|
key_name = body.get("name")
|
|
if not validate_service_key_name(key_name):
|
|
raise InvalidRequest("Invalid service key friendly name: %s" % key_name)
|
|
|
|
pre_oci_model.update_service_key(kid, key_name, body.get("metadata"))
|
|
log_action("service_key_modify", None, key_log_metadata)
|
|
|
|
updated_key = pre_oci_model.get_service_key(kid, approved_only=False, alive_only=False)
|
|
return jsonify(updated_key.to_dict())
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("deleteServiceKey")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def delete(self, kid):
|
|
if SuperUserPermission().can():
|
|
try:
|
|
key = pre_oci_model.delete_service_key(kid)
|
|
except ServiceKeyDoesNotExist:
|
|
raise NotFound()
|
|
|
|
key_log_metadata = {
|
|
"kid": kid,
|
|
"service": key.service,
|
|
"name": key.name,
|
|
"created_date": key.created_date,
|
|
"expiration_date": key.expiration_date,
|
|
}
|
|
|
|
log_action("service_key_delete", None, key_log_metadata)
|
|
return make_response("", 204)
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superuser/approvedkeys/<kid>")
|
|
@path_param("kid", "The unique identifier for a service key")
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserServiceKeyApproval(ApiResource):
|
|
"""
|
|
Resource for approving service keys.
|
|
"""
|
|
|
|
schemas = {
|
|
"ApproveServiceKey": {
|
|
"id": "ApproveServiceKey",
|
|
"type": "object",
|
|
"description": "Information for approving service keys",
|
|
"properties": {
|
|
"notes": {
|
|
"type": "string",
|
|
"description": "Optional approval notes",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("approveServiceKey")
|
|
@require_scope(scopes.SUPERUSER)
|
|
@validate_json_request("ApproveServiceKey")
|
|
def post(self, kid):
|
|
if SuperUserPermission().can():
|
|
notes = request.get_json().get("notes", "")
|
|
approver = get_authenticated_user()
|
|
try:
|
|
key = pre_oci_model.approve_service_key(
|
|
kid, approver, ServiceKeyApprovalType.SUPERUSER, notes=notes
|
|
)
|
|
|
|
# Log the approval of the service key.
|
|
key_log_metadata = {
|
|
"kid": kid,
|
|
"service": key.service,
|
|
"name": key.name,
|
|
"expiration_date": key.expiration_date,
|
|
}
|
|
|
|
log_action("service_key_approve", None, key_log_metadata)
|
|
except ServiceKeyDoesNotExist:
|
|
raise NotFound()
|
|
except ServiceKeyAlreadyApproved:
|
|
pass
|
|
|
|
return make_response("", 201)
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
def _token_view(token):
|
|
"""
|
|
Helper function to format app token data for API responses.
|
|
"""
|
|
data = {
|
|
"uuid": token.uuid,
|
|
"title": token.title,
|
|
"last_accessed": format_date(token.last_accessed),
|
|
"created": format_date(token.created),
|
|
"expiration": format_date(token.expiration),
|
|
}
|
|
|
|
return data
|
|
|
|
|
|
@resource("/v1/superuser/apptokens")
|
|
@show_if(features.APP_SPECIFIC_TOKENS)
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserAppTokens(ApiResource):
|
|
"""
|
|
Resource for listing all app specific tokens across all users in the system.
|
|
"""
|
|
|
|
@require_fresh_login
|
|
@nickname("listAllAppTokens")
|
|
@parse_args()
|
|
@query_param("expiring", "If true, only returns those tokens expiring soon", type=truthy_bool)
|
|
@require_scope(scopes.SUPERUSER)
|
|
def get(self, parsed_args):
|
|
"""
|
|
Returns a list of all app specific tokens in the system.
|
|
|
|
This endpoint is for system-wide auditing by global read-only superusers and
|
|
full access superusers only. Regular superusers without full access are denied.
|
|
"""
|
|
# Global readonly superusers can always audit, regular superusers need FULL_ACCESS
|
|
if allow_if_global_readonly_superuser() or allow_if_superuser_with_full_access():
|
|
expiring = parsed_args["expiring"]
|
|
|
|
if expiring:
|
|
expiration = app.config.get("APP_SPECIFIC_TOKEN_EXPIRATION")
|
|
import math
|
|
from datetime import timedelta
|
|
|
|
from util.timedeltastring import convert_to_timedelta
|
|
|
|
_DEFAULT_TOKEN_EXPIRATION_WINDOW = "4w"
|
|
token_expiration = convert_to_timedelta(
|
|
expiration or _DEFAULT_TOKEN_EXPIRATION_WINDOW
|
|
)
|
|
seconds = math.ceil(token_expiration.total_seconds() * 0.1) or 1
|
|
soon = timedelta(seconds=seconds)
|
|
tokens = model.appspecifictoken.get_all_expiring_tokens(soon)
|
|
else:
|
|
tokens = model.appspecifictoken.list_all_tokens()
|
|
|
|
return {
|
|
"tokens": [_token_view(token) for token in tokens],
|
|
"only_expiring": expiring,
|
|
}
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superuser/<build_uuid>/logs")
|
|
@path_param("build_uuid", "The UUID of the build")
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserRepositoryBuildLogs(ApiResource):
|
|
"""
|
|
Resource for loading repository build logs for the superuser.
|
|
"""
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("getRepoBuildLogsSuperUser")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def get(self, build_uuid):
|
|
"""
|
|
Return the build logs for the build specified by the build uuid.
|
|
"""
|
|
if allow_if_any_superuser():
|
|
try:
|
|
repo_build = pre_oci_model.get_repository_build(build_uuid)
|
|
return get_logs_or_log_url(repo_build)
|
|
except InvalidRepositoryBuildException as e:
|
|
raise InvalidResponse(str(e))
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superuser/<build_uuid>/status")
|
|
@path_param("repository", "The full path of the repository. e.g. namespace/name")
|
|
@path_param("build_uuid", "The UUID of the build")
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserRepositoryBuildStatus(ApiResource):
|
|
"""
|
|
Resource for dealing with repository build status.
|
|
"""
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("getRepoBuildStatusSuperUser")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def get(self, build_uuid):
|
|
"""
|
|
Return the status for the builds specified by the build uuids.
|
|
"""
|
|
if allow_if_any_superuser():
|
|
try:
|
|
build = pre_oci_model.get_repository_build(build_uuid)
|
|
except InvalidRepositoryBuildException as e:
|
|
raise InvalidResponse(str(e))
|
|
return build.to_dict()
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superuser/<build_uuid>/build")
|
|
@path_param("repository", "The full path of the repository. e.g. namespace/name")
|
|
@path_param("build_uuid", "The UUID of the build")
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserRepositoryBuildResource(ApiResource):
|
|
"""
|
|
Resource for dealing with repository builds as a super user.
|
|
"""
|
|
|
|
@require_fresh_login
|
|
@verify_not_prod
|
|
@nickname("getRepoBuildSuperUser")
|
|
@require_scope(scopes.SUPERUSER)
|
|
def get(self, build_uuid):
|
|
"""
|
|
Returns information about a build.
|
|
"""
|
|
if allow_if_any_superuser():
|
|
try:
|
|
build = pre_oci_model.get_repository_build(build_uuid)
|
|
except InvalidRepositoryBuildException:
|
|
raise NotFound()
|
|
|
|
return build.to_dict()
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/superuser/config")
|
|
@show_if(features.SUPER_USERS)
|
|
class SuperUserDumpConfig(ApiResource):
|
|
# NOTE: any changes made here must also be reflected in the nginx config
|
|
# this API returns a complete set of options. ! NOTE ! changing options listed
|
|
# but not documented is not supported
|
|
|
|
@require_fresh_login
|
|
@require_scope(scopes.SUPERUSER)
|
|
def get(self):
|
|
cfg = {}
|
|
warn = {}
|
|
|
|
def obfuscate(data, key=None, obf=False):
|
|
# obfuscate sensitive data
|
|
if key is not None:
|
|
return obfuscate({key: data})[key]
|
|
if isinstance(data, dict):
|
|
odata = {}
|
|
for k in data.keys():
|
|
if k.lower() in (
|
|
"password",
|
|
"db_uri",
|
|
"secret_key",
|
|
"access_key",
|
|
"enable_health_debug_secret",
|
|
"client_secret",
|
|
"s3_access_key",
|
|
"s3_secret_key",
|
|
"cloudfront_key_id",
|
|
"sts_user_access_key",
|
|
"sts_user_secret_key",
|
|
"ldap_admin_passwd",
|
|
"page_token_key",
|
|
"security_scanner_v4_psk",
|
|
"database_secret_key",
|
|
):
|
|
odata[k] = obfuscate(data[k], obf=True)
|
|
else:
|
|
odata[k] = obfuscate(data[k])
|
|
return odata
|
|
elif isinstance(data, list):
|
|
return list(map(lambda x: obfuscate(x), data))
|
|
elif isinstance(data, str):
|
|
if obf:
|
|
return f"{('*' * len(data))[:10]} ({len(data)})"
|
|
return data
|
|
elif any([isinstance(data, bool), isinstance(data, int)]):
|
|
return data
|
|
|
|
def process_config():
|
|
for k, v in app.config.items():
|
|
if not type(v) in (list, dict, tuple, int, str, bool):
|
|
continue
|
|
try:
|
|
# obfuscate passwords
|
|
if not CONFIG_SCHEMA["properties"].get(k, False) is False:
|
|
cfg[k] = obfuscate(v, key=k)
|
|
else:
|
|
warn[k] = obfuscate(v, key=k)
|
|
except Exception as procerr:
|
|
logger.error(f"Cannot parse config, error {procerr}")
|
|
continue
|
|
try:
|
|
return dict(config=cfg, warning=warn, env=dict(os.environ), schema=CONFIG_SCHEMA)
|
|
except TypeError as jsonerr:
|
|
# we shouldn't populate keys with methods/class/functions
|
|
# but to ensure we do not raise an Exception
|
|
logger.error(f"Cannot parse json, error {jsonerr}")
|
|
return dict(
|
|
config=str(cfg), warning=str(warn), env=dict(os.environ), schema=CONFIG_SCHEMA
|
|
)
|
|
|
|
# requesting Scope only doesn't restrict so we need superuserpermissions.can
|
|
if allow_if_any_superuser():
|
|
if features.SUPERUSER_CONFIGDUMP:
|
|
return process_config()
|
|
raise Unauthorized()
|