mirror of
https://github.com/quay/quay.git
synced 2025-11-16 11:42:27 +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>
518 lines
18 KiB
Python
518 lines
18 KiB
Python
"""
|
|
Manage user and organization robot accounts.
|
|
"""
|
|
import json
|
|
import logging
|
|
|
|
from flask import abort, request
|
|
|
|
import features
|
|
from auth import scopes
|
|
from auth.auth_context import get_authenticated_user
|
|
from auth.permissions import (
|
|
AdministerOrganizationPermission,
|
|
OrganizationMemberPermission,
|
|
)
|
|
from data.database import FederatedLogin, LoginService
|
|
from data.model import InvalidRobotException
|
|
from data.model.user import (
|
|
attach_federated_login,
|
|
create_federated_user,
|
|
create_robot_federation_config,
|
|
delete_robot_federation_config,
|
|
get_robot_federation_config,
|
|
lookup_robot,
|
|
)
|
|
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,
|
|
max_json_size,
|
|
nickname,
|
|
parse_args,
|
|
path_param,
|
|
query_param,
|
|
related_user_resource,
|
|
request_error,
|
|
require_scope,
|
|
require_user_admin,
|
|
resource,
|
|
validate_json_request,
|
|
)
|
|
from endpoints.api.robot_models_pre_oci import pre_oci_model as model
|
|
from endpoints.exception import Unauthorized
|
|
from util.names import format_robot_username
|
|
from util.parsing import truthy_bool
|
|
|
|
CREATE_ROBOT_SCHEMA = {
|
|
"type": "object",
|
|
"description": "Optional data for creating a robot",
|
|
"properties": {
|
|
"description": {
|
|
"type": "string",
|
|
"description": "Optional text description for the robot",
|
|
"maxLength": 255,
|
|
},
|
|
"unstructured_metadata": {
|
|
"type": "object",
|
|
"description": "Optional unstructured metadata for the robot",
|
|
},
|
|
},
|
|
}
|
|
|
|
CREATE_ROBOT_FEDERATION_SCHEMA = {
|
|
"type": "array",
|
|
"description": "Federation configuration for the robot",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"issuer": {
|
|
"type": "string",
|
|
"description": "The issuer of the token",
|
|
},
|
|
"subject": {
|
|
"type": "string",
|
|
"description": "The subject of the token",
|
|
},
|
|
},
|
|
"required": ["issuer", "subject"],
|
|
},
|
|
}
|
|
|
|
ROBOT_MAX_SIZE = 1024 * 1024 # 1 KB.
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def robots_list(prefix, include_permissions=False, include_token=False, limit=None):
|
|
robots = model.list_entity_robot_permission_teams(
|
|
prefix, limit=limit, include_token=include_token, include_permissions=include_permissions
|
|
)
|
|
return {"robots": [robot.to_dict(include_token=include_token) for robot in robots]}
|
|
|
|
|
|
@resource("/v1/user/robots")
|
|
class UserRobotList(ApiResource):
|
|
"""
|
|
Resource for listing user robots.
|
|
"""
|
|
|
|
@require_user_admin()
|
|
@nickname("getUserRobots")
|
|
@parse_args()
|
|
@query_param(
|
|
"permissions",
|
|
"Whether to include repositories and teams in which the robots have permission.",
|
|
type=truthy_bool,
|
|
default=False,
|
|
)
|
|
@query_param(
|
|
"token", "If false, the robot's token is not returned.", type=truthy_bool, default=True
|
|
)
|
|
@query_param("limit", "If specified, the number of robots to return.", type=int, default=None)
|
|
def get(self, parsed_args):
|
|
"""
|
|
List the available robots for the user.
|
|
"""
|
|
user = get_authenticated_user()
|
|
return robots_list(
|
|
user.username,
|
|
include_token=parsed_args.get("token", True),
|
|
include_permissions=parsed_args.get("permissions", False),
|
|
limit=parsed_args.get("limit"),
|
|
)
|
|
|
|
|
|
@resource("/v1/user/robots/<robot_shortname>")
|
|
@path_param(
|
|
"robot_shortname", "The short name for the robot, without any user or organization prefix"
|
|
)
|
|
class UserRobot(ApiResource):
|
|
"""
|
|
Resource for managing a user's robots.
|
|
"""
|
|
|
|
schemas = {
|
|
"CreateRobot": CREATE_ROBOT_SCHEMA,
|
|
}
|
|
|
|
@require_user_admin()
|
|
@nickname("getUserRobot")
|
|
def get(self, robot_shortname):
|
|
"""
|
|
Returns the user's robot with the specified name.
|
|
"""
|
|
parent = get_authenticated_user()
|
|
robot = model.get_user_robot(robot_shortname, parent)
|
|
return robot.to_dict(include_metadata=True, include_token=True)
|
|
|
|
@require_user_admin(disallow_for_restricted_users=True)
|
|
@nickname("createUserRobot")
|
|
@max_json_size(ROBOT_MAX_SIZE)
|
|
@validate_json_request("CreateRobot", optional=True)
|
|
def put(self, robot_shortname):
|
|
"""
|
|
Create a new user robot with the specified name.
|
|
"""
|
|
parent = get_authenticated_user()
|
|
create_data = request.get_json(silent=True) or {}
|
|
|
|
try:
|
|
robot = model.create_user_robot(
|
|
robot_shortname,
|
|
parent,
|
|
create_data.get("description"),
|
|
create_data.get("unstructured_metadata"),
|
|
)
|
|
except InvalidRobotException as e:
|
|
raise request_error(message=str(e))
|
|
log_action(
|
|
"create_robot",
|
|
parent.username,
|
|
{
|
|
"robot": robot_shortname,
|
|
"description": create_data.get("description"),
|
|
"unstructured_metadata": create_data.get("unstructured_metadata"),
|
|
},
|
|
)
|
|
return robot.to_dict(include_metadata=True, include_token=True), 201
|
|
|
|
@require_user_admin(disallow_for_restricted_users=True)
|
|
@nickname("deleteUserRobot")
|
|
def delete(self, robot_shortname):
|
|
"""
|
|
Delete an existing robot.
|
|
"""
|
|
parent = get_authenticated_user()
|
|
robot_username = format_robot_username(parent.username, robot_shortname)
|
|
|
|
if not model.robot_has_mirror(robot_username):
|
|
model.delete_robot(robot_username)
|
|
log_action("delete_robot", parent.username, {"robot": robot_shortname})
|
|
return "", 204
|
|
else:
|
|
raise request_error(message="Robot is being used by a mirror")
|
|
|
|
|
|
@resource("/v1/organization/<orgname>/robots")
|
|
@path_param("orgname", "The name of the organization")
|
|
@related_user_resource(UserRobotList)
|
|
class OrgRobotList(ApiResource):
|
|
"""
|
|
Resource for listing an organization's robots.
|
|
"""
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@nickname("getOrgRobots")
|
|
@parse_args()
|
|
@query_param(
|
|
"permissions",
|
|
"Whether to include repostories and teams in which the robots have permission.",
|
|
type=truthy_bool,
|
|
default=False,
|
|
)
|
|
@query_param(
|
|
"token", "If false, the robot's token is not returned.", type=truthy_bool, default=True
|
|
)
|
|
@query_param("limit", "If specified, the number of robots to return.", type=int, default=None)
|
|
def get(self, orgname, parsed_args):
|
|
"""
|
|
List the organization's robots.
|
|
"""
|
|
permission = OrganizationMemberPermission(orgname)
|
|
# Global readonly superusers can always view, regular superusers need FULL_ACCESS
|
|
if (
|
|
permission.can()
|
|
or allow_if_global_readonly_superuser()
|
|
or (features.SUPERUSERS_FULL_ACCESS and allow_if_superuser())
|
|
):
|
|
include_token = (
|
|
AdministerOrganizationPermission(orgname).can()
|
|
or allow_if_global_readonly_superuser()
|
|
) and parsed_args.get("token", True)
|
|
include_permissions = AdministerOrganizationPermission(
|
|
orgname
|
|
).can() and parsed_args.get("permissions", False)
|
|
return robots_list(
|
|
orgname,
|
|
include_permissions=include_permissions,
|
|
include_token=include_token,
|
|
limit=parsed_args.get("limit"),
|
|
)
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/organization/<orgname>/robots/<robot_shortname>")
|
|
@path_param("orgname", "The name of the organization")
|
|
@path_param(
|
|
"robot_shortname", "The short name for the robot, without any user or organization prefix"
|
|
)
|
|
@related_user_resource(UserRobot)
|
|
class OrgRobot(ApiResource):
|
|
"""
|
|
Resource for managing an organization's robots.
|
|
"""
|
|
|
|
schemas = {
|
|
"CreateRobot": CREATE_ROBOT_SCHEMA,
|
|
}
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@nickname("getOrgRobot")
|
|
def get(self, orgname, robot_shortname):
|
|
"""
|
|
Returns the organization's robot with the specified name.
|
|
"""
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
# Global readonly superusers can always view, regular superusers need FULL_ACCESS
|
|
if (
|
|
permission.can()
|
|
or allow_if_global_readonly_superuser()
|
|
or (features.SUPERUSERS_FULL_ACCESS and allow_if_superuser())
|
|
):
|
|
robot = model.get_org_robot(robot_shortname, orgname)
|
|
return robot.to_dict(include_metadata=True, include_token=True)
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@nickname("createOrgRobot")
|
|
@max_json_size(ROBOT_MAX_SIZE)
|
|
@validate_json_request("CreateRobot", optional=True)
|
|
def put(self, orgname, robot_shortname):
|
|
"""
|
|
Create a new robot in the organization.
|
|
"""
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
if permission.can() or allow_if_superuser_with_full_access():
|
|
create_data = request.get_json(silent=True) or {}
|
|
|
|
try:
|
|
robot = model.create_org_robot(
|
|
robot_shortname,
|
|
orgname,
|
|
create_data.get("description"),
|
|
create_data.get("unstructured_metadata"),
|
|
)
|
|
except InvalidRobotException as e:
|
|
raise request_error(message=str(e))
|
|
log_action(
|
|
"create_robot",
|
|
orgname,
|
|
{
|
|
"robot": robot_shortname,
|
|
"description": create_data.get("description"),
|
|
"unstructured_metadata": create_data.get("unstructured_metadata"),
|
|
},
|
|
)
|
|
return robot.to_dict(include_metadata=True, include_token=True), 201
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@nickname("deleteOrgRobot")
|
|
def delete(self, orgname, robot_shortname):
|
|
"""
|
|
Delete an existing organization robot.
|
|
"""
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
if permission.can() or allow_if_superuser_with_full_access():
|
|
robot_username = format_robot_username(orgname, robot_shortname)
|
|
if not model.robot_has_mirror(robot_username):
|
|
model.delete_robot(robot_username)
|
|
log_action("delete_robot", orgname, {"robot": robot_shortname})
|
|
return "", 204
|
|
else:
|
|
raise request_error(message="Robot is being used by a mirror")
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/user/robots/<robot_shortname>/permissions")
|
|
@path_param(
|
|
"robot_shortname", "The short name for the robot, without any user or organization prefix"
|
|
)
|
|
class UserRobotPermissions(ApiResource):
|
|
"""
|
|
Resource for listing the permissions a user's robot has in the system.
|
|
"""
|
|
|
|
@require_user_admin()
|
|
@nickname("getUserRobotPermissions")
|
|
def get(self, robot_shortname):
|
|
"""
|
|
Returns the list of repository permissions for the user's robot.
|
|
"""
|
|
parent = get_authenticated_user()
|
|
robot = model.get_user_robot(robot_shortname, parent)
|
|
permissions = model.list_robot_permissions(robot.name)
|
|
|
|
return {"permissions": [permission.to_dict() for permission in permissions]}
|
|
|
|
|
|
@resource("/v1/organization/<orgname>/robots/<robot_shortname>/permissions")
|
|
@path_param("orgname", "The name of the organization")
|
|
@path_param(
|
|
"robot_shortname", "The short name for the robot, without any user or organization prefix"
|
|
)
|
|
@related_user_resource(UserRobotPermissions)
|
|
class OrgRobotPermissions(ApiResource):
|
|
"""
|
|
Resource for listing the permissions an org's robot has in the system.
|
|
"""
|
|
|
|
@require_user_admin()
|
|
@nickname("getOrgRobotPermissions")
|
|
def get(self, orgname, robot_shortname):
|
|
"""
|
|
Returns the list of repository permissions for the org's robot.
|
|
"""
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
# Global readonly superusers can always view, regular superusers need FULL_ACCESS
|
|
if (
|
|
permission.can()
|
|
or allow_if_global_readonly_superuser()
|
|
or (features.SUPERUSERS_FULL_ACCESS and allow_if_superuser())
|
|
):
|
|
robot = model.get_org_robot(robot_shortname, orgname)
|
|
permissions = model.list_robot_permissions(robot.name)
|
|
|
|
return {"permissions": [permission.to_dict() for permission in permissions]}
|
|
|
|
abort(403)
|
|
|
|
|
|
@resource("/v1/user/robots/<robot_shortname>/regenerate")
|
|
@path_param(
|
|
"robot_shortname", "The short name for the robot, without any user or organization prefix"
|
|
)
|
|
class RegenerateUserRobot(ApiResource):
|
|
"""
|
|
Resource for regenerate an organization's robot's token.
|
|
"""
|
|
|
|
@require_user_admin(disallow_for_restricted_users=True)
|
|
@nickname("regenerateUserRobotToken")
|
|
def post(self, robot_shortname):
|
|
"""
|
|
Regenerates the token for a user's robot.
|
|
"""
|
|
parent = get_authenticated_user()
|
|
robot = model.regenerate_user_robot_token(robot_shortname, parent)
|
|
log_action("regenerate_robot_token", parent.username, {"robot": robot_shortname})
|
|
return robot.to_dict(include_token=True)
|
|
|
|
|
|
@resource("/v1/organization/<orgname>/robots/<robot_shortname>/regenerate")
|
|
@path_param("orgname", "The name of the organization")
|
|
@path_param(
|
|
"robot_shortname", "The short name for the robot, without any user or organization prefix"
|
|
)
|
|
@related_user_resource(RegenerateUserRobot)
|
|
class RegenerateOrgRobot(ApiResource):
|
|
"""
|
|
Resource for regenerate an organization's robot's token.
|
|
"""
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@nickname("regenerateOrgRobotToken")
|
|
def post(self, orgname, robot_shortname):
|
|
"""
|
|
Regenerates the token for an organization robot.
|
|
"""
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
if permission.can() or allow_if_superuser_with_full_access():
|
|
robot = model.regenerate_org_robot_token(robot_shortname, orgname)
|
|
log_action("regenerate_robot_token", orgname, {"robot": robot_shortname})
|
|
return robot.to_dict(include_token=True)
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/organization/<orgname>/robots/<robot_shortname>/federation")
|
|
@path_param("orgname", "The name of the organization")
|
|
@path_param(
|
|
"robot_shortname", "The short name for the robot, without any user or organization prefix"
|
|
)
|
|
@related_user_resource(UserRobot)
|
|
class OrgRobotFederation(ApiResource):
|
|
|
|
schemas = {
|
|
"CreateRobotFederation": CREATE_ROBOT_FEDERATION_SCHEMA,
|
|
}
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
def get(self, orgname, robot_shortname):
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
# Global readonly superusers can always view, regular superusers need FULL_ACCESS
|
|
if (
|
|
permission.can()
|
|
or allow_if_global_readonly_superuser()
|
|
or (features.SUPERUSERS_FULL_ACCESS and allow_if_superuser())
|
|
):
|
|
robot_username = format_robot_username(orgname, robot_shortname)
|
|
robot = lookup_robot(robot_username)
|
|
return get_robot_federation_config(robot)
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@validate_json_request("CreateRobotFederation", optional=False)
|
|
def post(self, orgname, robot_shortname):
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
if permission.can() or allow_if_superuser_with_full_access():
|
|
fed_config = self._parse_federation_config(request)
|
|
|
|
robot_username = format_robot_username(orgname, robot_shortname)
|
|
robot = lookup_robot(robot_username)
|
|
create_robot_federation_config(robot, fed_config)
|
|
log_action(
|
|
"create_robot_federation",
|
|
orgname,
|
|
{"config": fed_config, "robot": robot_shortname},
|
|
)
|
|
return fed_config
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
def delete(self, orgname, robot_shortname):
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
if permission.can() or allow_if_superuser_with_full_access():
|
|
robot_username = format_robot_username(orgname, robot_shortname)
|
|
robot = lookup_robot(robot_username)
|
|
delete_robot_federation_config(robot)
|
|
log_action(
|
|
"delete_robot_federation",
|
|
orgname,
|
|
{"robot": robot_shortname},
|
|
)
|
|
return "", 204
|
|
raise Unauthorized()
|
|
|
|
def _parse_federation_config(self, request):
|
|
fed_config = list()
|
|
seen = set()
|
|
for item in request.json:
|
|
if not item:
|
|
raise request_error(message="Missing one or more required fields (issuer, subject)")
|
|
issuer = item.get("issuer")
|
|
subject = item.get("subject")
|
|
if not issuer or not subject:
|
|
raise request_error(message="Missing one or more required fields (issuer, subject)")
|
|
if not (issuer.startswith("http://") or issuer.startswith("https://")):
|
|
raise request_error(message="Issuer must be a URL (http:// or https://)")
|
|
entry = {"issuer": issuer, "subject": subject}
|
|
|
|
if f"{issuer}:{subject}" in seen:
|
|
raise request_error(message="Duplicate federation config entry")
|
|
|
|
seen.add(f"{issuer}:{subject}")
|
|
fed_config.append(entry)
|
|
|
|
return list(fed_config)
|