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

1093 lines
39 KiB
Python

"""
Manage organizations, members and OAuth applications.
"""
import logging
import time
import recaptcha2
from flask import request
import features
from app import all_queues, app, authentication, avatar
from app import billing as stripe
from app import ip_resolver, marketplace_subscriptions, namespace_gc_queue, usermanager
from auth import scopes
from auth.auth_context import get_authenticated_user
from auth.permissions import (
AdministerOrganizationPermission,
CreateRepositoryPermission,
GlobalReadOnlySuperUserPermission,
OrganizationMemberPermission,
SuperUserPermission,
ViewTeamPermission,
)
from data import model
from data.billing import get_plan, get_plan_using_rh_sku
from data.database import ProxyCacheConfig
from data.model import organization_skus
from endpoints.api import (
ApiResource,
allow_if_any_superuser,
allow_if_global_readonly_superuser,
allow_if_superuser,
allow_if_superuser_with_full_access,
internal_only,
log_action,
nickname,
path_param,
related_user_resource,
request_error,
require_fresh_login,
require_scope,
require_user_admin,
resource,
show_if,
validate_json_request,
)
from endpoints.api.user import PrivateRepositories, User
from endpoints.exception import NotFound, Unauthorized
from proxy import Proxy, UpstreamRegistryError
from util.marketplace import MarketplaceSubscriptionApi
from util.names import parse_robot_username
from util.request import get_request_ip
logger = logging.getLogger(__name__)
def quota_view(quota):
quota_limits = list(model.namespacequota.get_namespace_quota_limit_list(quota))
return {
"id": quota.id, # Generate uuid instead?
"limit_bytes": quota.limit_bytes,
"limits": [limit_view(limit) for limit in quota_limits],
}
def limit_view(limit):
return {
"id": limit.id,
"type": limit.quota_type.name,
"limit_percent": limit.percent_of_limit,
}
def team_view(orgname, team):
return {
"name": team.name,
"description": team.description,
"role": team.role_name,
"avatar": avatar.get_data_for_team(team),
"can_view": ViewTeamPermission(orgname, team.name).can(),
"repo_count": team.repo_count,
"member_count": team.member_count,
"is_synced": team.is_synced,
}
def org_view(o, teams):
is_admin = AdministerOrganizationPermission(o.username).can()
is_member = OrganizationMemberPermission(o.username).can()
# Global readonly superusers can always view, regular superusers need FULL_ACCESS
can_view_as_superuser = allow_if_global_readonly_superuser() or (
features.SUPERUSERS_FULL_ACCESS and allow_if_superuser()
)
view = {
"name": o.username,
"email": o.email if is_admin or can_view_as_superuser else "",
"avatar": avatar.get_data_for_user(o),
"is_admin": is_admin,
"is_member": is_member,
}
if teams is not None:
teams = sorted(teams, key=lambda team: team.id)
view["teams"] = {t.name: team_view(o.username, t) for t in teams}
view["ordered_teams"] = [team.name for team in teams]
if is_admin:
view["invoice_email"] = o.invoice_email
view["invoice_email_address"] = o.invoice_email_address
view["tag_expiration_s"] = o.removed_tag_expiration_s
view["is_free_account"] = o.stripe_id is None
if is_admin or is_member:
if features.QUOTA_MANAGEMENT and features.EDIT_QUOTA:
quotas = model.namespacequota.get_namespace_quota_list(o.username)
view["quotas"] = [quota_view(quota) for quota in quotas] if quotas else []
view["quota_report"] = model.namespacequota.get_quota_for_view(o.username)
return view
@resource("/v1/organization/")
class OrganizationList(ApiResource):
"""
Resource for creating organizations.
"""
schemas = {
"NewOrg": {
"type": "object",
"description": "Description of a new organization.",
"required": [
"name",
],
"properties": {
"name": {
"type": "string",
"description": "Organization username",
},
"email": {
"type": "string",
"description": "Organization contact email",
},
"recaptcha_response": {
"type": "string",
"description": "The (may be disabled) recaptcha response code for verification",
},
},
},
}
@require_user_admin(disallow_for_restricted_users=features.RESTRICTED_USERS)
@nickname("createOrganization")
@validate_json_request("NewOrg")
def post(self):
"""
Create a new organization.
"""
# When SUPERUSERS_ORG_CREATION_ONLY is enabled, only regular superusers can create orgs
if features.SUPERUSERS_ORG_CREATION_ONLY:
if not allow_if_superuser():
raise Unauthorized()
user = get_authenticated_user()
org_data = request.get_json()
existing = None
# Super users should be able to create new orgs regardless of user restriction
if user.username not in app.config.get("SUPER_USERS", None):
if features.RESTRICTED_USERS and usermanager.is_restricted_user(user.username):
raise Unauthorized()
try:
existing = model.organization.get_organization(org_data["name"])
except model.InvalidOrganizationException:
pass
if not existing:
existing = model.user.get_user(org_data["name"])
if existing:
msg = "A user or organization with this name already exists"
raise request_error(message=msg)
if features.MAILING and not org_data.get("email"):
raise request_error(message="Email address is required")
# If recaptcha is enabled, then verify the user is a human.
if features.RECAPTCHA:
# check if the user is whitelisted to bypass recaptcha security check
if user.username not in app.config["RECAPTCHA_WHITELISTED_USERS"]:
recaptcha_response = org_data.get("recaptcha_response", "")
result = recaptcha2.verify(
app.config["RECAPTCHA_SECRET_KEY"], recaptcha_response, get_request_ip()
)
if not result["success"]:
return {"message": "Are you a bot? If not, please revalidate the captcha."}, 400
is_possible_abuser = ip_resolver.is_ip_possible_threat(get_request_ip())
try:
model.organization.create_organization(
org_data["name"],
org_data.get("email"),
user,
email_required=features.MAILING,
is_possible_abuser=is_possible_abuser,
)
log_action(
"org_create",
org_data["name"],
{"email": org_data.get("email"), "namespace": org_data["name"]},
)
return "Created", 201
except model.DataModelException as ex:
raise request_error(exception=ex)
@resource("/v1/organization/<orgname>")
@path_param("orgname", "The name of the organization")
@related_user_resource(User)
class Organization(ApiResource):
"""
Resource for managing organizations.
"""
schemas = {
"UpdateOrg": {
"type": "object",
"description": "Description of updates for an existing organization",
"properties": {
"email": {
"type": "string",
"description": "Organization contact email",
},
"invoice_email": {
"type": "boolean",
"description": "Whether the organization desires to receive emails for invoices",
},
"invoice_email_address": {
"type": ["string", "null"],
"description": "The email address at which to receive invoices",
},
"tag_expiration_s": {
"type": "integer",
"minimum": 0,
"description": "The number of seconds for tag expiration",
},
},
},
}
@nickname("getOrganization")
def get(self, orgname):
"""
Get the details for the specified organization.
"""
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
teams = None
# Global readonly superusers can always view teams, regular superusers need FULL_ACCESS
if (
OrganizationMemberPermission(orgname).can()
or allow_if_global_readonly_superuser()
or (features.SUPERUSERS_FULL_ACCESS and allow_if_superuser())
):
has_syncing = features.TEAM_SYNCING and bool(authentication.federated_service)
teams = model.team.get_teams_within_org(org, has_syncing)
return org_view(org, teams)
@require_scope(scopes.ORG_ADMIN)
@nickname("changeOrganizationDetails")
@validate_json_request("UpdateOrg")
def put(self, orgname):
"""
Change the details for the specified organization.
"""
permission = AdministerOrganizationPermission(orgname)
if permission.can() or allow_if_superuser_with_full_access():
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
org_data = request.get_json()
if "invoice_email" in org_data:
logger.debug("Changing invoice_email for organization: %s", org.username)
model.user.change_send_invoice_email(org, org_data["invoice_email"])
log_action(
"org_change_invoicing",
orgname,
{"invoice_email": org_data["invoice_email"], "namespace": orgname},
)
if (
"invoice_email_address" in org_data
and org_data["invoice_email_address"] != org.invoice_email_address
):
new_email = org_data["invoice_email_address"]
logger.debug("Changing invoice email address for organization: %s", org.username)
model.user.change_invoice_email_address(org, new_email)
log_action(
"org_change_invoicing",
orgname,
{"invoice_email_address": new_email, "namespace": orgname},
)
if "email" in org_data and org_data["email"] != org.email:
new_email = org_data["email"]
old_email = org.email
if model.user.find_user_by_email(new_email):
raise request_error(message="E-mail address already used")
logger.debug("Changing email address for organization: %s", org.username)
model.user.update_email(org, new_email)
log_action(
"org_change_email",
orgname,
{"email": new_email, "namespace": orgname, "old_email": old_email},
)
if features.CHANGE_TAG_EXPIRATION and "tag_expiration_s" in org_data:
logger.debug(
"Changing organization tag expiration to: %ss", org_data["tag_expiration_s"]
)
model.user.change_user_tag_expiration(org, org_data["tag_expiration_s"])
log_action(
"org_change_tag_expiration",
orgname,
{"tag_expiration": org_data["tag_expiration_s"], "namespace": orgname},
)
teams = model.team.get_teams_within_org(org)
return org_view(org, teams)
raise Unauthorized()
@require_scope(scopes.ORG_ADMIN)
@require_fresh_login
@nickname("deleteAdminedOrganization")
def delete(self, orgname):
"""
Deletes the specified organization.
"""
permission = AdministerOrganizationPermission(orgname)
if permission.can() or allow_if_superuser_with_full_access():
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
log_action(
"org_delete", orgname, {"namespace": orgname}
) # we need to do this before the deletion, as the org will be gone after
model.user.mark_namespace_for_deletion(org, all_queues, namespace_gc_queue)
return "", 204
raise Unauthorized()
@resource("/v1/organization/<orgname>/private")
@path_param("orgname", "The name of the organization")
@internal_only
@related_user_resource(PrivateRepositories)
@show_if(features.BILLING)
class OrgPrivateRepositories(ApiResource):
"""
Custom verb to compute whether additional private repositories are available.
"""
@require_scope(scopes.ORG_ADMIN)
@nickname("getOrganizationPrivateAllowed")
def get(self, orgname):
"""
Return whether or not this org is allowed to create new private repositories.
"""
permission = CreateRepositoryPermission(orgname)
if permission.can():
organization = model.organization.get_organization(orgname)
private_repos = model.user.get_private_repo_count(organization.username)
data = {"privateAllowed": False}
repos_allowed = 0
if organization.stripe_id:
cus = stripe.Customer.retrieve(organization.stripe_id)
if cus.subscription:
plan = get_plan(cus.subscription.plan.id)
if plan:
repos_allowed = plan["privateRepos"]
if features.RH_MARKETPLACE:
query = organization_skus.get_org_subscriptions(organization.id)
rh_subscriptions = list(query.dicts()) if query is not None else []
now_ms = time.time() * 1000
for subscription in rh_subscriptions:
subscription_details = marketplace_subscriptions.get_subscription_details(
subscription["subscription_id"]
)
expired_at = subscription_details["expiration_date"]
terminated_at = subscription_details["terminated_date"]
if expired_at < now_ms or (
terminated_at is not None and terminated_at < now_ms
):
organization_skus.remove_subscription_from_org(
organization.id, subscription["subscription_id"]
)
continue
equivalent_stripe_plan = get_plan_using_rh_sku(subscription_details["sku"])
if equivalent_stripe_plan:
if subscription.get("quantity") is None:
quantity = 1
else:
quantity = subscription["quantity"]
repos_allowed += quantity * equivalent_stripe_plan["privateRepos"]
data["privateAllowed"] = private_repos < repos_allowed
if AdministerOrganizationPermission(orgname).can():
data["privateCount"] = private_repos
return data
raise Unauthorized()
@resource("/v1/organization/<orgname>/collaborators")
@path_param("orgname", "The name of the organization")
class OrganizationCollaboratorList(ApiResource):
"""
Resource for listing outside collaborators of an organization.
Collaborators are users that do not belong to any team in the organiztion, but who have direct
permissions on one or more repositories belonging to the organization.
"""
@require_scope(scopes.ORG_ADMIN)
@nickname("getOrganizationCollaborators")
def get(self, orgname):
"""
List outside collaborators of the specified organization.
"""
permission = AdministerOrganizationPermission(orgname)
# Global readonly superusers can always view, regular superusers need FULL_ACCESS
if (
not permission.can()
and not allow_if_global_readonly_superuser()
and not (features.SUPERUSERS_FULL_ACCESS and allow_if_superuser())
):
raise Unauthorized()
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
all_perms = model.permission.list_organization_member_permissions(org)
membership = model.team.list_organization_members_by_teams(org)
org_members = set(m.user.username for m in membership)
collaborators = {}
for perm in all_perms:
username = perm.user.username
# Only interested in non-member permissions.
if username in org_members:
continue
if username not in collaborators:
collaborators[username] = {
"kind": "user",
"name": username,
"avatar": avatar.get_data_for_user(perm.user),
"repositories": [],
}
collaborators[username]["repositories"].append(perm.repository.name)
return {"collaborators": list(collaborators.values())}
@resource("/v1/organization/<orgname>/members")
@path_param("orgname", "The name of the organization")
class OrganizationMemberList(ApiResource):
"""
Resource for listing the members of an organization.
"""
@require_scope(scopes.ORG_ADMIN)
@nickname("getOrganizationMembers")
def get(self, orgname):
"""
List the human members of the specified organization.
"""
permission = AdministerOrganizationPermission(orgname)
if permission.can() or (features.SUPERUSERS_FULL_ACCESS and allow_if_any_superuser()):
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
# Loop to create the members dictionary. Note that the members collection
# will return an entry for *every team* a member is on, so we will have
# duplicate keys (which is why we pre-build the dictionary).
members_dict = {}
members = model.team.list_organization_members_by_teams(org)
for member in members:
if member.user.robot:
continue
if not member.user.username in members_dict:
member_data = {
"name": member.user.username,
"kind": "user",
"avatar": avatar.get_data_for_user(member.user),
"teams": [],
"repositories": [],
}
members_dict[member.user.username] = member_data
members_dict[member.user.username]["teams"].append(
{
"name": member.team.name,
"avatar": avatar.get_data_for_team(member.team),
}
)
# Loop to add direct repository permissions.
for permission in model.permission.list_organization_member_permissions(org):
username = permission.user.username
if not username in members_dict:
continue
members_dict[username]["repositories"].append(permission.repository.name)
return {"members": list(members_dict.values())}
raise Unauthorized()
@resource("/v1/organization/<orgname>/members/<membername>")
@path_param("orgname", "The name of the organization")
@path_param("membername", "The username of the organization member")
class OrganizationMember(ApiResource):
"""
Resource for managing individual organization members.
"""
@require_scope(scopes.ORG_ADMIN)
@nickname("getOrganizationMember")
def get(self, orgname, membername):
"""
Retrieves the details of a member of the organization.
"""
permission = AdministerOrganizationPermission(orgname)
if permission.can() or (features.SUPERUSERS_FULL_ACCESS and allow_if_any_superuser()):
# Lookup the user.
member = model.user.get_user(membername)
if not member:
raise NotFound()
organization = model.user.get_user_or_org(orgname)
if not organization:
raise NotFound()
# Lookup the user's information in the organization.
teams = list(model.team.get_user_teams_within_org(membername, organization))
if not teams:
# 404 if the user is not a robot under the organization, as that means the referenced
# user or robot is not a member of this organization.
if not member.robot:
raise NotFound()
namespace, _ = parse_robot_username(member.username)
if namespace != orgname:
raise NotFound()
repo_permissions = model.permission.list_organization_member_permissions(
organization, member
)
def local_team_view(team):
return {
"name": team.name,
"avatar": avatar.get_data_for_team(team),
}
return {
"name": member.username,
"kind": "robot" if member.robot else "user",
"avatar": avatar.get_data_for_user(member),
"teams": [local_team_view(team) for team in teams],
"repositories": [permission.repository.name for permission in repo_permissions],
}
raise Unauthorized()
@require_scope(scopes.ORG_ADMIN)
@nickname("removeOrganizationMember")
def delete(self, orgname, membername):
"""
Removes a member from an organization, revoking all its repository priviledges and removing
it from all teams in the organization.
"""
permission = AdministerOrganizationPermission(orgname)
if permission.can() or allow_if_superuser_with_full_access():
# Lookup the user.
user = model.user.get_nonrobot_user(membername)
if not user:
raise NotFound()
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
# Remove the user from the organization.
model.organization.remove_organization_member(org, user)
return "", 204
raise Unauthorized()
@resource("/v1/app/<client_id>")
@path_param("client_id", "The OAuth client ID")
class ApplicationInformation(ApiResource):
"""
Resource that returns public information about a registered application.
"""
@nickname("getApplicationInformation")
def get(self, client_id):
"""
Get information on the specified application.
"""
application = model.oauth.get_application_for_client_id(client_id)
if not application:
raise NotFound()
app_email = application.avatar_email or application.organization.email
app_data = avatar.get_data(application.name, app_email, "app")
return {
"name": application.name,
"description": application.description,
"uri": application.application_uri,
"avatar": app_data,
"organization": org_view(application.organization, []),
}
def app_view(application):
is_admin = AdministerOrganizationPermission(application.organization.username).can()
client_secret = None
if is_admin:
if application.secure_client_secret is not None:
client_secret = application.secure_client_secret.decrypt()
assert (client_secret is not None) == is_admin
return {
"name": application.name,
"description": application.description,
"application_uri": application.application_uri,
"client_id": application.client_id,
"client_secret": client_secret,
"redirect_uri": application.redirect_uri if is_admin else None,
"avatar_email": application.avatar_email if is_admin else None,
}
@resource("/v1/organization/<orgname>/applications")
@path_param("orgname", "The name of the organization")
class OrganizationApplications(ApiResource):
"""
Resource for managing applications defined by an organization.
"""
schemas = {
"NewApp": {
"type": "object",
"description": "Description of a new organization application.",
"required": [
"name",
],
"properties": {
"name": {
"type": "string",
"description": "The name of the application",
},
"redirect_uri": {
"type": "string",
"description": "The URI for the application's OAuth redirect",
},
"application_uri": {
"type": "string",
"description": "The URI for the application's homepage",
},
"description": {
"type": "string",
"description": "The human-readable description for the application",
},
"avatar_email": {
"type": "string",
"description": "The e-mail address of the avatar to use for the application",
},
},
},
}
@require_scope(scopes.ORG_ADMIN)
@nickname("getOrganizationApplications")
def get(self, orgname):
"""
List the applications for the specified organization.
"""
permission = AdministerOrganizationPermission(orgname)
if permission.can() or (features.SUPERUSERS_FULL_ACCESS and allow_if_any_superuser()):
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
applications = model.oauth.list_applications_for_org(org)
return {"applications": [app_view(application) for application in applications]}
raise Unauthorized()
@require_scope(scopes.ORG_ADMIN)
@nickname("createOrganizationApplication")
@validate_json_request("NewApp")
def post(self, orgname):
"""
Creates a new application under this organization.
"""
permission = AdministerOrganizationPermission(orgname)
if permission.can() or allow_if_superuser_with_full_access():
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
app_data = request.get_json()
application = model.oauth.create_application(
org,
app_data["name"],
app_data.get("application_uri", ""),
app_data.get("redirect_uri", ""),
description=app_data.get("description", ""),
avatar_email=app_data.get("avatar_email", None),
)
app_data.update(
{"application_name": application.name, "client_id": application.client_id}
)
log_action("create_application", orgname, app_data)
return app_view(application)
raise Unauthorized()
@resource("/v1/organization/<orgname>/applications/<client_id>")
@path_param("orgname", "The name of the organization")
@path_param("client_id", "The OAuth client ID")
class OrganizationApplicationResource(ApiResource):
"""
Resource for managing an application defined by an organizations.
"""
schemas = {
"UpdateApp": {
"type": "object",
"description": "Description of an updated application.",
"required": ["name", "redirect_uri", "application_uri"],
"properties": {
"name": {
"type": "string",
"description": "The name of the application",
},
"redirect_uri": {
"type": "string",
"description": "The URI for the application's OAuth redirect",
},
"application_uri": {
"type": "string",
"description": "The URI for the application's homepage",
},
"description": {
"type": "string",
"description": "The human-readable description for the application",
},
"avatar_email": {
"type": "string",
"description": "The e-mail address of the avatar to use for the application",
},
},
},
}
@require_scope(scopes.ORG_ADMIN)
@nickname("getOrganizationApplication")
def get(self, orgname, client_id):
"""
Retrieves the application with the specified client_id under the specified organization.
"""
permission = AdministerOrganizationPermission(orgname)
if permission.can() or (features.SUPERUSERS_FULL_ACCESS and allow_if_any_superuser()):
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
application = model.oauth.lookup_application(org, client_id)
if not application:
raise NotFound()
return app_view(application)
raise Unauthorized()
@require_scope(scopes.ORG_ADMIN)
@nickname("updateOrganizationApplication")
@validate_json_request("UpdateApp")
def put(self, orgname, client_id):
"""
Updates an application under this organization.
"""
permission = AdministerOrganizationPermission(orgname)
if permission.can() or allow_if_superuser_with_full_access():
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
application = model.oauth.lookup_application(org, client_id)
if not application:
raise NotFound()
app_data = request.get_json()
application.name = app_data["name"]
application.application_uri = app_data["application_uri"]
application.redirect_uri = app_data["redirect_uri"]
application.description = app_data.get("description", "")
application.avatar_email = app_data.get("avatar_email", None)
application.save()
app_data.update(
{"application_name": application.name, "client_id": application.client_id}
)
log_action("update_application", orgname, app_data)
return app_view(application)
raise Unauthorized()
@require_scope(scopes.ORG_ADMIN)
@nickname("deleteOrganizationApplication")
def delete(self, orgname, client_id):
"""
Deletes the application under this organization.
"""
permission = AdministerOrganizationPermission(orgname)
if permission.can() or allow_if_superuser_with_full_access():
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
application = model.oauth.delete_application(org, client_id)
if not application:
raise NotFound()
log_action(
"delete_application",
orgname,
{"application_name": application.name, "client_id": client_id},
)
return "", 204
raise Unauthorized()
@resource("/v1/organization/<orgname>/applications/<client_id>/resetclientsecret")
@path_param("orgname", "The name of the organization")
@path_param("client_id", "The OAuth client ID")
@internal_only
class OrganizationApplicationResetClientSecret(ApiResource):
"""
Custom verb for resetting the client secret of an application.
"""
@nickname("resetOrganizationApplicationClientSecret")
def post(self, orgname, client_id):
"""
Resets the client secret of the application.
"""
permission = AdministerOrganizationPermission(orgname)
if permission.can() or allow_if_superuser_with_full_access():
try:
org = model.organization.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
application = model.oauth.lookup_application(org, client_id)
if not application:
raise NotFound()
application = model.oauth.reset_client_secret(application)
log_action(
"reset_application_client_secret",
orgname,
{"application_name": application.name, "client_id": client_id},
)
return app_view(application)
raise Unauthorized()
def proxy_cache_view(proxy_cache_config):
return {
"upstream_registry": proxy_cache_config.upstream_registry if proxy_cache_config else "",
"expiration_s": proxy_cache_config.expiration_s if proxy_cache_config else "",
"insecure": proxy_cache_config.insecure if proxy_cache_config else "",
}
@resource("/v1/organization/<orgname>/proxycache")
@path_param("orgname", "The name of the organization")
@show_if(features.PROXY_CACHE)
class OrganizationProxyCacheConfig(ApiResource):
"""
Resource for managing Proxy Cache Config.
"""
schemas = {
"NewProxyCacheConfig": {
"type": "object",
"description": "Proxy cache configuration for an organization",
"required": ["upstream_registry"],
"properties": {
"upstream_registry": {
"type": "string",
"description": "Name of the upstream registry that is to be cached",
},
},
},
}
@nickname("getProxyCacheConfig")
def get(self, orgname):
"""
Retrieves the proxy cache configuration of the organization.
"""
permission = OrganizationMemberPermission(orgname)
# Global readonly superusers can always view, regular superusers need FULL_ACCESS
if (
not permission.can()
and not allow_if_global_readonly_superuser()
and not (features.SUPERUSERS_FULL_ACCESS and allow_if_superuser())
):
raise Unauthorized()
try:
config = model.proxy_cache.get_proxy_cache_config_for_org(orgname)
except model.InvalidProxyCacheConfigException:
return proxy_cache_view(None)
return proxy_cache_view(config)
@nickname("createProxyCacheConfig")
@validate_json_request("NewProxyCacheConfig")
def post(self, orgname):
"""
Creates proxy cache configuration for the organization.
"""
permission = AdministerOrganizationPermission(orgname)
if not permission.can() and not allow_if_superuser_with_full_access():
raise Unauthorized()
try:
model.proxy_cache.get_proxy_cache_config_for_org(orgname)
raise request_error("Proxy Cache Configuration already exists")
except model.InvalidProxyCacheConfigException:
pass
data = request.get_json()
# filter None values
data = {k: v for k, v in data.items() if (v is not None or not "")}
try:
config = model.proxy_cache.create_proxy_cache_config(**data)
if config is not None:
log_action(
"create_proxy_cache_config",
orgname,
{
"upstream_registry": data["upstream_registry"]
if data["upstream_registry"]
else None
},
)
return "Created", 201
except model.DataModelException as e:
logger.error("Error while creating Proxy cache configuration as: %s", str(e))
return request_error("Error while creating Proxy cache configuration")
@nickname("deleteProxyCacheConfig")
def delete(self, orgname):
"""
Delete proxy cache configuration for the organization.
"""
permission = AdministerOrganizationPermission(orgname)
if not permission.can() and not allow_if_superuser_with_full_access():
raise Unauthorized()
try:
model.proxy_cache.get_proxy_cache_config_for_org(orgname)
except model.InvalidProxyCacheConfigException:
raise NotFound()
try:
success = model.proxy_cache.delete_proxy_cache_config(orgname)
if success:
log_action("delete_proxy_cache_config", orgname)
return "Deleted", 201
except model.DataModelException as e:
logger.error("Error while deleting Proxy cache configuration as: %s", str(e))
raise request_error(message="Proxy Cache Configuration failed to delete")
@resource("/v1/organization/<orgname>/validateproxycache")
@show_if(features.PROXY_CACHE)
class ProxyCacheConfigValidation(ApiResource):
"""
Resource for validating Proxy Cache Config.
"""
schemas = {
"NewProxyCacheConfig": {
"type": "object",
"description": "Proxy cache configuration for an organization",
"required": ["upstream_registry"],
"properties": {
"upstream_registry": {
"type": "string",
"description": "Name of the upstream registry that is to be cached",
},
},
},
}
@nickname("validateProxyCacheConfig")
@validate_json_request("NewProxyCacheConfig")
def post(self, orgname):
permission = AdministerOrganizationPermission(orgname)
if not permission.can() and not allow_if_superuser_with_full_access():
raise Unauthorized()
try:
model.proxy_cache.get_proxy_cache_config_for_org(orgname)
request_error("Proxy Cache Configuration already exists")
except model.InvalidProxyCacheConfigException:
pass
data = request.get_json()
# filter None values
data = {k: v for k, v in data.items() if v is not None}
try:
config = ProxyCacheConfig(**data)
existing = model.organization.get_organization(orgname)
config.organization = existing
proxy = Proxy(config, validation=True)
response = proxy.get(f"{proxy.base_url}/v2/")
if response.status_code == 200:
return "Valid", 202
if response.status_code == 401:
return "Anonymous", 202
except UpstreamRegistryError as e:
raise request_error(
message="Failed login to remote registry. Please verify entered details and try again."
)
raise request_error(message="Failed to validate Proxy cache configuration")