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

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()