4db1615d94
1) Also check for matching organization names 2) Ensure that errors don't leave the throbber
651 lines
21 KiB
Python
651 lines
21 KiB
Python
""" Manage organizations, members and OAuth applications. """
|
|
|
|
import logging
|
|
|
|
from flask import request
|
|
|
|
import features
|
|
|
|
from app import billing as stripe, avatar, all_queues, authentication
|
|
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
|
|
related_user_resource, internal_only, require_user_admin, log_action,
|
|
show_if, path_param, require_scope, require_fresh_login)
|
|
from endpoints.exception import Unauthorized, NotFound
|
|
from endpoints.api.user import User, PrivateRepositories
|
|
from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission,
|
|
CreateRepositoryPermission, ViewTeamPermission)
|
|
from auth.auth_context import get_authenticated_user
|
|
from auth import scopes
|
|
from data import model
|
|
from data.billing import get_plan
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
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()
|
|
|
|
view = {
|
|
'name': o.username,
|
|
'email': o.email if is_admin 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
|
|
|
|
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',
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
@require_user_admin
|
|
@nickname('createOrganization')
|
|
@validate_json_request('NewOrg')
|
|
def post(self):
|
|
""" Create a new organization. """
|
|
user = get_authenticated_user()
|
|
org_data = request.get_json()
|
|
existing = None
|
|
|
|
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')
|
|
|
|
try:
|
|
model.organization.create_organization(org_data['name'], org_data.get('email'), user,
|
|
email_required=features.MAILING)
|
|
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
|
|
if OrganizationMemberPermission(orgname).can():
|
|
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():
|
|
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'])
|
|
|
|
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)
|
|
|
|
if 'email' in org_data and org_data['email'] != org.email:
|
|
new_email = org_data['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)
|
|
|
|
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'])
|
|
|
|
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():
|
|
try:
|
|
org = model.organization.get_organization(orgname)
|
|
except model.InvalidOrganizationException:
|
|
raise NotFound()
|
|
|
|
model.user.delete_user(org, all_queues)
|
|
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
|
|
}
|
|
|
|
if organization.stripe_id:
|
|
cus = stripe.Customer.retrieve(organization.stripe_id)
|
|
if cus.subscription:
|
|
repos_allowed = 0
|
|
plan = get_plan(cus.subscription.plan.id)
|
|
if plan:
|
|
repos_allowed = plan['privateRepos']
|
|
|
|
data['privateAllowed'] = (private_repos < repos_allowed)
|
|
|
|
|
|
if AdministerOrganizationPermission(orgname).can():
|
|
data['privateCount'] = private_repos
|
|
|
|
return data
|
|
|
|
raise Unauthorized()
|
|
|
|
|
|
@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():
|
|
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': 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():
|
|
# 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:
|
|
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():
|
|
# 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()
|
|
|
|
return {
|
|
'name': application.name,
|
|
'description': application.description,
|
|
'application_uri': application.application_uri,
|
|
|
|
'client_id': application.client_id,
|
|
'client_secret': application.client_secret if is_admin else None,
|
|
'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():
|
|
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():
|
|
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():
|
|
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():
|
|
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():
|
|
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():
|
|
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()
|