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>
643 lines
21 KiB
Python
643 lines
21 KiB
Python
"""
|
|
Create, list and manage an organization's teams.
|
|
"""
|
|
|
|
import json
|
|
from functools import wraps
|
|
|
|
from flask import request
|
|
|
|
import features
|
|
from app import app, authentication, avatar
|
|
from auth import scopes
|
|
from auth.auth_context import get_authenticated_user
|
|
from auth.permissions import (
|
|
AdministerOrganizationPermission,
|
|
GlobalReadOnlySuperUserPermission,
|
|
SuperUserPermission,
|
|
ViewTeamPermission,
|
|
)
|
|
from data import model
|
|
from data.database import Team
|
|
from endpoints.api import (
|
|
ApiResource,
|
|
allow_if_any_superuser,
|
|
allow_if_global_readonly_superuser,
|
|
allow_if_superuser,
|
|
allow_if_superuser_with_full_access,
|
|
format_date,
|
|
internal_only,
|
|
log_action,
|
|
nickname,
|
|
parse_args,
|
|
path_param,
|
|
query_param,
|
|
request_error,
|
|
require_fresh_login,
|
|
require_scope,
|
|
require_user_admin,
|
|
resource,
|
|
show_if,
|
|
validate_json_request,
|
|
verify_not_prod,
|
|
)
|
|
from endpoints.exception import InvalidRequest, NotFound, Unauthorized
|
|
from util.names import parse_robot_username
|
|
from util.parsing import truthy_bool
|
|
from util.useremails import send_org_invite_email
|
|
|
|
|
|
def permission_view(permission):
|
|
return {
|
|
"repository": {
|
|
"name": permission.repository.name,
|
|
"is_public": model.repository.is_repository_public(permission.repository),
|
|
},
|
|
"role": permission.role.name,
|
|
}
|
|
|
|
|
|
def try_accept_invite(code, user):
|
|
(team, inviter) = model.team.confirm_team_invite(code, user)
|
|
|
|
model.notification.delete_matching_notifications(
|
|
user, "org_team_invite", org=team.organization.username
|
|
)
|
|
|
|
orgname = team.organization.username
|
|
log_action(
|
|
"org_team_member_invite_accepted",
|
|
orgname,
|
|
{"member": user.username, "team": team.name, "inviter": inviter.username},
|
|
)
|
|
|
|
return team
|
|
|
|
|
|
def handle_addinvite_team(inviter, team, user=None, email=None):
|
|
requires_invite = features.MAILING and features.REQUIRE_TEAM_INVITE
|
|
invite = model.team.add_or_invite_to_team(
|
|
inviter, team, user, email, requires_invite=requires_invite
|
|
)
|
|
if not invite:
|
|
# User was added to the team directly.
|
|
return
|
|
|
|
orgname = team.organization.username
|
|
if user:
|
|
model.notification.create_notification(
|
|
"org_team_invite",
|
|
user,
|
|
metadata={
|
|
"code": invite.invite_token,
|
|
"inviter": inviter.username,
|
|
"org": orgname,
|
|
"team": team.name,
|
|
},
|
|
)
|
|
|
|
send_org_invite_email(
|
|
user.username if user else email,
|
|
user.email if user else email,
|
|
orgname,
|
|
team.name,
|
|
inviter.username,
|
|
invite.invite_token,
|
|
)
|
|
return invite
|
|
|
|
|
|
def team_view(orgname, team, is_new_team=False):
|
|
view_permission = ViewTeamPermission(orgname, team.name)
|
|
return {
|
|
"name": team.name,
|
|
"description": team.description,
|
|
"can_view": view_permission.can(),
|
|
"role": Team.role.get_name(team.role_id),
|
|
"avatar": avatar.get_data_for_team(team),
|
|
"new_team": is_new_team,
|
|
}
|
|
|
|
|
|
def member_view(member, invited=False):
|
|
return {
|
|
"name": member.username,
|
|
"kind": "user",
|
|
"is_robot": member.robot,
|
|
"avatar": avatar.get_data_for_user(member),
|
|
"invited": invited,
|
|
}
|
|
|
|
|
|
def invite_view(invite):
|
|
if invite.user:
|
|
return member_view(invite.user, invited=True)
|
|
else:
|
|
return {
|
|
"email": invite.email,
|
|
"kind": "invite",
|
|
"avatar": avatar.get_data(invite.email, invite.email, "user"),
|
|
"invited": True,
|
|
}
|
|
|
|
|
|
def disallow_for_synced_team(except_robots=False):
|
|
"""
|
|
Disallows the decorated operation for a team that is marked as being synced from an internal
|
|
auth provider such as LDAP.
|
|
|
|
If except_robots is True, then the operation is allowed if the member specified on the operation
|
|
is a robot account.
|
|
"""
|
|
|
|
def inner(func):
|
|
@wraps(func)
|
|
def wrapper(self, *args, **kwargs):
|
|
# Team syncing can only be enabled if we have a federated service.
|
|
if features.TEAM_SYNCING and authentication.federated_service:
|
|
orgname = kwargs["orgname"]
|
|
teamname = kwargs["teamname"]
|
|
if model.team.get_team_sync_information(orgname, teamname):
|
|
if not except_robots or not parse_robot_username(kwargs.get("membername", "")):
|
|
raise InvalidRequest("Cannot call this method on an auth-synced team")
|
|
|
|
return func(self, *args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return inner
|
|
|
|
|
|
disallow_nonrobots_for_synced_team = disallow_for_synced_team(except_robots=True)
|
|
disallow_all_for_synced_team = disallow_for_synced_team(except_robots=False)
|
|
|
|
|
|
@resource("/v1/organization/<orgname>/team/<teamname>")
|
|
@path_param("orgname", "The name of the organization")
|
|
@path_param("teamname", "The name of the team")
|
|
class OrganizationTeam(ApiResource):
|
|
"""
|
|
Resource for manging an organization's teams.
|
|
"""
|
|
|
|
schemas = {
|
|
"TeamDescription": {
|
|
"type": "object",
|
|
"description": "Description of a team",
|
|
"required": [
|
|
"role",
|
|
],
|
|
"properties": {
|
|
"role": {
|
|
"type": "string",
|
|
"description": "Org wide permissions that should apply to the team",
|
|
"enum": [
|
|
"member",
|
|
"creator",
|
|
"admin",
|
|
],
|
|
},
|
|
"description": {
|
|
"type": "string",
|
|
"description": "Markdown description for the team",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@nickname("updateOrganizationTeam")
|
|
@validate_json_request("TeamDescription")
|
|
def put(self, orgname, teamname):
|
|
"""
|
|
Update the org-wide permission for the specified team.
|
|
"""
|
|
edit_permission = AdministerOrganizationPermission(orgname)
|
|
if edit_permission.can() or allow_if_superuser_with_full_access():
|
|
team = None
|
|
|
|
details = request.get_json()
|
|
is_existing = False
|
|
try:
|
|
team = model.team.get_organization_team(orgname, teamname)
|
|
is_existing = True
|
|
except model.InvalidTeamException:
|
|
# Create the new team.
|
|
description = details["description"] if "description" in details else ""
|
|
role = details["role"] if "role" in details else "member"
|
|
|
|
org = model.organization.get_organization(orgname)
|
|
team = model.team.create_team(teamname, org, role, description)
|
|
log_action("org_create_team", orgname, {"team": teamname})
|
|
|
|
if is_existing:
|
|
if "description" in details and team.description != details["description"]:
|
|
team.description = details["description"]
|
|
team.save()
|
|
log_action(
|
|
"org_set_team_description",
|
|
orgname,
|
|
{"team": teamname, "description": team.description},
|
|
)
|
|
|
|
if "role" in details:
|
|
role = Team.role.get_name(team.role_id)
|
|
if role != details["role"]:
|
|
team = model.team.set_team_org_permission(
|
|
team, details["role"], get_authenticated_user().username
|
|
)
|
|
log_action(
|
|
"org_set_team_role",
|
|
orgname,
|
|
{"team": teamname, "role": details["role"]},
|
|
)
|
|
|
|
return team_view(orgname, team, is_new_team=not is_existing), 200
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@nickname("deleteOrganizationTeam")
|
|
def delete(self, orgname, teamname):
|
|
"""
|
|
Delete the specified team.
|
|
"""
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
if permission.can() or allow_if_superuser_with_full_access():
|
|
model.team.remove_team(orgname, teamname, get_authenticated_user().username)
|
|
log_action("org_delete_team", orgname, {"team": teamname})
|
|
return "", 204
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
def _syncing_setup_allowed(orgname):
|
|
"""
|
|
Returns whether syncing setup is allowed for the current user over the matching org.
|
|
"""
|
|
if not features.NONSUPERUSER_TEAM_SYNCING_SETUP and not SuperUserPermission().can():
|
|
return False
|
|
|
|
return AdministerOrganizationPermission(orgname).can()
|
|
|
|
|
|
@resource("/v1/organization/<orgname>/team/<teamname>/syncing")
|
|
@path_param("orgname", "The name of the organization")
|
|
@path_param("teamname", "The name of the team")
|
|
@show_if(features.TEAM_SYNCING)
|
|
class OrganizationTeamSyncing(ApiResource):
|
|
"""
|
|
Resource for managing syncing of a team by a backing group.
|
|
"""
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@require_scope(scopes.SUPERUSER)
|
|
@nickname("enableOrganizationTeamSync")
|
|
@verify_not_prod
|
|
@require_fresh_login
|
|
def post(self, orgname, teamname):
|
|
if _syncing_setup_allowed(orgname):
|
|
try:
|
|
team = model.team.get_organization_team(orgname, teamname)
|
|
except model.InvalidTeamException:
|
|
raise NotFound()
|
|
|
|
config = request.get_json()
|
|
|
|
# Ensure that the specified config points to a valid group.
|
|
status, err = authentication.check_group_lookup_args(config)
|
|
if not status:
|
|
raise InvalidRequest("Could not sync to group: %s" % err)
|
|
|
|
# Set the team's syncing config.
|
|
model.team.set_team_syncing(team, authentication.federated_service, config)
|
|
log_action("enable_team_sync", orgname, {"team": teamname})
|
|
|
|
if app.config["AUTHENTICATION_TYPE"] == "OIDC":
|
|
# delete existing team members, team membership will be synced with OIDC group
|
|
model.team.delete_all_team_members(team)
|
|
|
|
return team_view(orgname, team)
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@require_scope(scopes.SUPERUSER)
|
|
@nickname("disableOrganizationTeamSync")
|
|
@verify_not_prod
|
|
@require_fresh_login
|
|
def delete(self, orgname, teamname):
|
|
if _syncing_setup_allowed(orgname):
|
|
try:
|
|
team = model.team.get_organization_team(orgname, teamname)
|
|
except model.InvalidTeamException:
|
|
raise NotFound()
|
|
|
|
model.team.remove_team_syncing(orgname, teamname)
|
|
log_action("disable_team_sync", orgname, {"team": teamname})
|
|
return team_view(orgname, team)
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/organization/<orgname>/team/<teamname>/members")
|
|
@path_param("orgname", "The name of the organization")
|
|
@path_param("teamname", "The name of the team")
|
|
class TeamMemberList(ApiResource):
|
|
"""
|
|
Resource for managing the list of members for a team.
|
|
"""
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@parse_args()
|
|
@query_param(
|
|
"includePending", "Whether to include pending members", type=truthy_bool, default=False
|
|
)
|
|
@nickname("getOrganizationTeamMembers")
|
|
def get(self, orgname, teamname, parsed_args):
|
|
"""
|
|
Retrieve the list of members for the specified team.
|
|
"""
|
|
view_permission = ViewTeamPermission(orgname, teamname)
|
|
edit_permission = AdministerOrganizationPermission(orgname)
|
|
|
|
if view_permission.can() or (features.SUPERUSERS_FULL_ACCESS and allow_if_any_superuser()):
|
|
team = None
|
|
try:
|
|
team = model.team.get_organization_team(orgname, teamname)
|
|
except model.InvalidTeamException:
|
|
raise NotFound()
|
|
|
|
members = model.organization.get_organization_team_members(team.id)
|
|
invites = []
|
|
|
|
if parsed_args["includePending"] and edit_permission.can():
|
|
invites = model.team.get_organization_team_member_invites(team.id)
|
|
|
|
data = {
|
|
"name": teamname,
|
|
"members": [member_view(m) for m in members] + [invite_view(i) for i in invites],
|
|
"can_edit": edit_permission.can(),
|
|
}
|
|
|
|
if features.TEAM_SYNCING and authentication.federated_service:
|
|
if _syncing_setup_allowed(orgname):
|
|
data["can_sync"] = {
|
|
"service": authentication.federated_service,
|
|
}
|
|
|
|
data["can_sync"].update(authentication.service_metadata())
|
|
|
|
sync_info = model.team.get_team_sync_information(orgname, teamname)
|
|
if sync_info is not None:
|
|
data["synced"] = {
|
|
"service": sync_info.service.name,
|
|
}
|
|
|
|
if features.NONSUPERUSER_TEAM_SYNCING_SETUP or SuperUserPermission().can():
|
|
data["synced"].update(
|
|
{
|
|
"last_updated": format_date(sync_info.last_updated),
|
|
"config": json.loads(sync_info.config),
|
|
}
|
|
)
|
|
|
|
return data
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/organization/<orgname>/team/<teamname>/members/<membername>")
|
|
@path_param("orgname", "The name of the organization")
|
|
@path_param("teamname", "The name of the team")
|
|
@path_param("membername", "The username of the team member")
|
|
class TeamMember(ApiResource):
|
|
"""
|
|
Resource for managing individual members of a team.
|
|
"""
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@nickname("updateOrganizationTeamMember")
|
|
@disallow_nonrobots_for_synced_team
|
|
def put(self, orgname, teamname, membername):
|
|
"""
|
|
Adds or invites a member to an existing team.
|
|
"""
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
if permission.can() or allow_if_superuser_with_full_access():
|
|
team = None
|
|
user = None
|
|
|
|
# Find the team.
|
|
try:
|
|
team = model.team.get_organization_team(orgname, teamname)
|
|
except model.InvalidTeamException:
|
|
raise NotFound()
|
|
|
|
# Find the user.
|
|
user = model.user.get_user(membername)
|
|
if not user:
|
|
raise request_error(message="Unknown user")
|
|
|
|
# Add or invite the user to the team.
|
|
inviter = get_authenticated_user()
|
|
invite = handle_addinvite_team(inviter, team, user=user)
|
|
if not invite:
|
|
log_action("org_add_team_member", orgname, {"member": membername, "team": teamname})
|
|
return member_view(user, invited=False)
|
|
|
|
# User was invited.
|
|
log_action(
|
|
"org_invite_team_member",
|
|
orgname,
|
|
{"user": membername, "member": membername, "team": teamname},
|
|
)
|
|
return member_view(user, invited=True)
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@nickname("deleteOrganizationTeamMember")
|
|
@disallow_nonrobots_for_synced_team
|
|
def delete(self, orgname, teamname, membername):
|
|
"""
|
|
Delete a member of a team.
|
|
|
|
If the user is merely invited to join the team, then the invite is removed instead.
|
|
"""
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
if permission.can() or allow_if_superuser_with_full_access():
|
|
# Remote the user from the team.
|
|
invoking_user = get_authenticated_user().username
|
|
|
|
# Find the team.
|
|
try:
|
|
team = model.team.get_organization_team(orgname, teamname)
|
|
except model.InvalidTeamException:
|
|
raise NotFound()
|
|
|
|
# Find the member.
|
|
member = model.user.get_user(membername)
|
|
if not member:
|
|
raise NotFound()
|
|
|
|
# First attempt to delete an invite for the user to this team. If none found,
|
|
# then we try to remove the user directly.
|
|
if model.team.delete_team_user_invite(team, member):
|
|
log_action(
|
|
"org_delete_team_member_invite",
|
|
orgname,
|
|
{"user": membername, "team": teamname, "member": membername},
|
|
)
|
|
return "", 204
|
|
|
|
model.team.remove_user_from_team(orgname, teamname, membername, invoking_user)
|
|
if features.RH_MARKETPLACE:
|
|
org_id = model.organization.get_organization(orgname).id
|
|
model.organization_skus.remove_all_owner_subscriptions_from_org(member.id, org_id)
|
|
log_action("org_remove_team_member", orgname, {"member": membername, "team": teamname})
|
|
return "", 204
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/organization/<orgname>/team/<teamname>/invite/<email>")
|
|
@show_if(features.MAILING)
|
|
class InviteTeamMember(ApiResource):
|
|
"""
|
|
Resource for inviting a team member via email address.
|
|
"""
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@nickname("inviteTeamMemberEmail")
|
|
@disallow_all_for_synced_team
|
|
def put(self, orgname, teamname, email):
|
|
"""
|
|
Invites an email address to an existing team.
|
|
"""
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
if permission.can() or allow_if_superuser_with_full_access():
|
|
team = None
|
|
|
|
# Find the team.
|
|
try:
|
|
team = model.team.get_organization_team(orgname, teamname)
|
|
except model.InvalidTeamException:
|
|
raise NotFound()
|
|
|
|
# Invite the email to the team.
|
|
inviter = get_authenticated_user()
|
|
invite = handle_addinvite_team(inviter, team, email=email)
|
|
log_action(
|
|
"org_invite_team_member",
|
|
orgname,
|
|
{"email": email, "team": teamname, "member": email},
|
|
)
|
|
return invite_view(invite)
|
|
|
|
raise Unauthorized()
|
|
|
|
@require_scope(scopes.ORG_ADMIN)
|
|
@nickname("deleteTeamMemberEmailInvite")
|
|
def delete(self, orgname, teamname, email):
|
|
"""
|
|
Delete an invite of an email address to join a team.
|
|
"""
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
if permission.can() or allow_if_superuser_with_full_access():
|
|
team = None
|
|
|
|
# Find the team.
|
|
try:
|
|
team = model.team.get_organization_team(orgname, teamname)
|
|
except model.InvalidTeamException:
|
|
raise NotFound()
|
|
|
|
# Delete the invite.
|
|
if not model.team.delete_team_email_invite(team, email):
|
|
raise NotFound()
|
|
|
|
log_action(
|
|
"org_delete_team_member_invite",
|
|
orgname,
|
|
{"email": email, "team": teamname, "member": email},
|
|
)
|
|
return "", 204
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/organization/<orgname>/team/<teamname>/permissions")
|
|
@path_param("orgname", "The name of the organization")
|
|
@path_param("teamname", "The name of the team")
|
|
class TeamPermissions(ApiResource):
|
|
"""
|
|
Resource for listing the permissions an org's team has in the system.
|
|
"""
|
|
|
|
@nickname("getOrganizationTeamPermissions")
|
|
def get(self, orgname, teamname):
|
|
"""
|
|
Returns the list of repository permissions for the org's team.
|
|
"""
|
|
permission = AdministerOrganizationPermission(orgname)
|
|
if permission.can() or (features.SUPERUSERS_FULL_ACCESS and allow_if_any_superuser()):
|
|
try:
|
|
team = model.team.get_organization_team(orgname, teamname)
|
|
except model.InvalidTeamException:
|
|
raise NotFound()
|
|
|
|
permissions = model.permission.list_team_permissions(team)
|
|
|
|
return {"permissions": [permission_view(permission) for permission in permissions]}
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@resource("/v1/teaminvite/<code>")
|
|
@internal_only
|
|
@show_if(features.MAILING)
|
|
class TeamMemberInvite(ApiResource):
|
|
"""
|
|
Resource for managing invites to join a team.
|
|
"""
|
|
|
|
@require_user_admin()
|
|
@nickname("acceptOrganizationTeamInvite")
|
|
def put(self, code):
|
|
"""
|
|
Accepts an invite to join a team in an organization.
|
|
"""
|
|
# Accept the invite for the current user.
|
|
team = try_accept_invite(code, get_authenticated_user())
|
|
if not team:
|
|
raise NotFound()
|
|
|
|
orgname = team.organization.username
|
|
return {"org": orgname, "team": team.name}
|
|
|
|
@nickname("declineOrganizationTeamInvite")
|
|
@require_user_admin()
|
|
def delete(self, code):
|
|
"""
|
|
Delete an existing invitation to join a team.
|
|
"""
|
|
(team, inviter) = model.team.delete_team_invite(code, user_obj=get_authenticated_user())
|
|
|
|
model.notification.delete_matching_notifications(
|
|
get_authenticated_user(), "org_team_invite", code=code
|
|
)
|
|
|
|
orgname = team.organization.username
|
|
log_action(
|
|
"org_team_member_invite_declined",
|
|
orgname,
|
|
{
|
|
"member": get_authenticated_user().username,
|
|
"team": team.name,
|
|
"inviter": inviter.username,
|
|
},
|
|
)
|
|
|
|
return "", 204
|