diff --git a/README.md b/README.md index aeae03870..c2d045c08 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,13 @@ running the tests: STACK=test python -m unittest discover ``` +running the tests with coverage (requires coverage module): + +``` +STACK=test coverage run -m unittest discover +coverage html +``` + generating screenshots: ``` diff --git a/app.py b/app.py index 3870e9b93..488c108d4 100644 --- a/app.py +++ b/app.py @@ -8,7 +8,7 @@ from flask.ext.login import LoginManager from flask.ext.mail import Mail from config import (ProductionConfig, DebugConfig, LocalHostedConfig, - TestConfig) + TestConfig, StagingConfig) from util import analytics @@ -20,6 +20,9 @@ stack = os.environ.get('STACK', '').strip().lower() if stack.startswith('prod'): logger.info('Running with production config.') config = ProductionConfig() +elif stack.startswith('staging'): + logger.info('Running with staging config on production data.') + config = StagingConfig() elif stack.startswith('localhosted'): logger.info('Running with debug config on production data.') config = LocalHostedConfig() @@ -32,7 +35,7 @@ else: app.config.from_object(config) -Principal(app, use_sessions=True) +Principal(app, use_sessions=False) login_manager = LoginManager() login_manager.init_app(app) diff --git a/application.py b/application.py index 2d6660866..91062d4f6 100644 --- a/application.py +++ b/application.py @@ -9,22 +9,24 @@ application.config['LOGGING_CONFIG']() # Turn off debug logging for boto logging.getLogger('boto').setLevel(logging.CRITICAL) -from endpoints.api import api +from endpoints.api import api_bp from endpoints.index import index from endpoints.web import web from endpoints.tags import tags from endpoints.registry import registry from endpoints.webhooks import webhooks from endpoints.realtime import realtime +from endpoints.callbacks import callback logger = logging.getLogger(__name__) application.register_blueprint(web) +application.register_blueprint(callback, url_prefix='/oauth2') application.register_blueprint(index, url_prefix='/v1') application.register_blueprint(tags, url_prefix='/v1') application.register_blueprint(registry, url_prefix='/v1') -application.register_blueprint(api, url_prefix='/api') +application.register_blueprint(api_bp, url_prefix='/api') application.register_blueprint(webhooks, url_prefix='/webhooks') application.register_blueprint(realtime, url_prefix='/realtime') @@ -37,9 +39,5 @@ def close_db(exc): application.teardown_request(close_db) - -# Remove this for prod config -application.debug = True - if __name__ == '__main__': application.run(port=5000, debug=True, threaded=True, host='0.0.0.0') diff --git a/auth/auth.py b/auth/auth.py index fb08b9184..ac78102a4 100644 --- a/auth/auth.py +++ b/auth/auth.py @@ -1,20 +1,69 @@ import logging from functools import wraps -from flask import request, _request_ctx_stack, session +from datetime import datetime +from flask import request, session from flask.ext.principal import identity_changed, Identity +from flask.ext.login import current_user from base64 import b64decode +import scopes + from data import model +from data.model import oauth from app import app from permissions import QuayDeferredPermissionUser - -from util.names import parse_namespace_repository +from auth_context import (set_authenticated_user, set_validated_token, + set_authenticated_user_deferred, set_validated_oauth_token) from util.http import abort logger = logging.getLogger(__name__) + +def _load_user_from_cookie(): + if not current_user.is_anonymous(): + logger.debug('Loading user from cookie: %s', current_user.get_id()) + set_authenticated_user_deferred(current_user.get_id()) + loaded = QuayDeferredPermissionUser(current_user.get_id(), 'username', {scopes.DIRECT_LOGIN}) + identity_changed.send(app, identity=loaded) + return current_user.db_user() + return None + + +def _validate_and_apply_oauth_token(token): + validated = oauth.validate_access_token(token) + if not validated: + logger.warning('OAuth access token could not be validated: %s', token) + authenticate_header = { + 'WWW-Authenticate': ('Bearer error="invalid_token", ' + 'error_description="The access token is invalid"'), + } + abort(401, message='OAuth access token could not be validated: %(token)s', + issue='invalid-oauth-token', token=token, headers=authenticate_header) + elif validated.expires_at <= datetime.now(): + logger.info('OAuth access with an expired token: %s', token) + authenticate_header = { + 'WWW-Authenticate': ('Bearer error="invalid_token", ' + 'error_description="The access token expired"'), + } + abort(401, message='OAuth access token has expired: %(token)s', + issue='invalid-oauth-token', token=token, headers=authenticate_header) + + # We have a valid token + scope_set = scopes.scopes_from_scope_string(validated.scope) + logger.debug('Successfully validated oauth access token: %s with scope: %s', token, + scope_set) + + set_authenticated_user(validated.authorized_user) + set_validated_oauth_token(validated) + + new_identity = QuayDeferredPermissionUser(validated.authorized_user.username, 'username', + scope_set) + identity_changed.send(app, identity=new_identity) + + + def process_basic_auth(auth): normalized = [part.strip() for part in auth.split(' ') if part] if normalized[0].lower() != 'basic' or len(normalized) != 2: @@ -31,8 +80,7 @@ def process_basic_auth(auth): try: token = model.load_token_data(credentials[1]) logger.debug('Successfully validated token: %s' % credentials[1]) - ctx = _request_ctx_stack.top - ctx.validated_token = token + set_validated_token(token) identity_changed.send(app, identity=Identity(token.code, 'token')) return @@ -40,16 +88,21 @@ def process_basic_auth(auth): except model.DataModelException: logger.debug('Invalid token: %s' % credentials[1]) + elif credentials[0] == '$oauthtoken': + oauth_token = credentials[1] + _validate_and_apply_oauth_token(oauth_token) + elif '+' in credentials[0]: logger.debug('Trying robot auth with credentials %s' % str(credentials)) # Use as robot auth try: robot = model.verify_robot(credentials[0], credentials[1]) logger.debug('Successfully validated robot: %s' % credentials[0]) - ctx = _request_ctx_stack.top - ctx.authenticated_user = robot + set_authenticated_user(robot) - identity_changed.send(app, identity=Identity(robot.username, 'username')) + deferred_robot = QuayDeferredPermissionUser(robot.username, 'username', + {scopes.DIRECT_LOGIN}) + identity_changed.send(app, identity=deferred_robot) return except model.InvalidRobotException: logger.debug('Invalid robot or password for robot: %s' % credentials[0]) @@ -59,11 +112,10 @@ def process_basic_auth(auth): if authenticated: logger.debug('Successfully validated user: %s' % authenticated.username) - ctx = _request_ctx_stack.top - ctx.authenticated_user = authenticated + set_authenticated_user(authenticated) - new_identity = QuayDeferredPermissionUser(authenticated.username, - 'username') + new_identity = QuayDeferredPermissionUser(authenticated.username, 'username', + {scopes.DIRECT_LOGIN}) identity_changed.send(app, identity=new_identity) return @@ -81,31 +133,49 @@ def process_token(auth): if len(token_details) != 1: logger.warning('Invalid token format: %s' % auth) - abort(401, message="Invalid token format: %(auth)", issue='invalid-auth-token', auth=auth) + abort(401, message='Invalid token format: %(auth)s', issue='invalid-auth-token', auth=auth) token_vals = {val[0]: val[1] for val in (detail.split('=') for detail in token_details)} if 'signature' not in token_vals: logger.warning('Token does not contain signature: %s' % auth) - abort(401, message="Token does not contain a valid signature: %(auth)", issue='invalid-auth-token', auth=auth) + abort(401, message='Token does not contain a valid signature: %(auth)s', + issue='invalid-auth-token', auth=auth) try: token_data = model.load_token_data(token_vals['signature']) except model.InvalidTokenException: - logger.warning('Token could not be validated: %s' % - token_vals['signature']) - abort(401, message="Token could not be validated: %(auth)", issue='invalid-auth-token', auth=auth) + logger.warning('Token could not be validated: %s', token_vals['signature']) + abort(401, message='Token could not be validated: %(auth)s', issue='invalid-auth-token', + auth=auth) - logger.debug('Successfully validated token: %s' % token_data.code) - ctx = _request_ctx_stack.top - ctx.validated_token = token_data + logger.debug('Successfully validated token: %s', token_data.code) + set_validated_token(token_data) identity_changed.send(app, identity=Identity(token_data.code, 'token')) -def process_auth(f): - @wraps(f) +def process_oauth(func): + @wraps(func) + def wrapper(*args, **kwargs): + auth = request.headers.get('authorization', '') + if auth: + normalized = [part.strip() for part in auth.split(' ') if part] + if normalized[0].lower() != 'bearer' or len(normalized) != 2: + logger.debug('Invalid oauth bearer token format.') + return + + token = normalized[1] + _validate_and_apply_oauth_token(token) + elif _load_user_from_cookie() is None: + logger.debug('No auth header or login cookie.') + return func(*args, **kwargs) + return wrapper + + +def process_auth(func): + @wraps(func) def wrapper(*args, **kwargs): auth = request.headers.get('authorization', '') @@ -116,17 +186,26 @@ def process_auth(f): else: logger.debug('No auth header.') - return f(*args, **kwargs) + return func(*args, **kwargs) return wrapper -def extract_namespace_repo_from_session(f): - @wraps(f) +def require_session_login(func): + @wraps(func) + def wrapper(*args, **kwargs): + loaded = _load_user_from_cookie() + if loaded is None or loaded.organization: + abort(401, message='Method requires login and no valid login could be loaded.') + return func(*args, **kwargs) + return wrapper + + +def extract_namespace_repo_from_session(func): + @wraps(func) def wrapper(*args, **kwargs): if 'namespace' not in session or 'repository' not in session: - logger.error('Unable to load namespace or repository from session: %s' % - session) - abort(400, message="Missing namespace in request") + logger.error('Unable to load namespace or repository from session: %s' % session) + abort(400, message='Missing namespace in request') - return f(session['namespace'], session['repository'], *args, **kwargs) + return func(session['namespace'], session['repository'], *args, **kwargs) return wrapper diff --git a/auth/auth_context.py b/auth/auth_context.py index 09ff8d759..2aad14685 100644 --- a/auth/auth_context.py +++ b/auth/auth_context.py @@ -1,7 +1,53 @@ +import logging + from flask import _request_ctx_stack +from data import model + + +logger = logging.getLogger(__name__) + def get_authenticated_user(): - return getattr(_request_ctx_stack.top, 'authenticated_user', None) + user = getattr(_request_ctx_stack.top, 'authenticated_user', None) + if not user: + username = getattr(_request_ctx_stack.top, 'authenticated_username', None) + if not username: + logger.debug('No authenticated user or deferred username.') + return None + + logger.debug('Loading deferred authenticated user.') + loaded = model.get_user(username) + set_authenticated_user(loaded) + user = loaded + + logger.debug('Returning authenticated user: %s', user.username) + return user + + +def set_authenticated_user(user_or_robot): + ctx = _request_ctx_stack.top + ctx.authenticated_user = user_or_robot + + +def set_authenticated_user_deferred(username_or_robotname): + logger.debug('Deferring loading of authenticated user object: %s', username_or_robotname) + ctx = _request_ctx_stack.top + ctx.authenticated_username = username_or_robotname + + +def get_validated_oauth_token(): + return getattr(_request_ctx_stack.top, 'validated_oauth_token', None) + + +def set_validated_oauth_token(token): + ctx = _request_ctx_stack.top + ctx.validated_oauth_token = token + def get_validated_token(): return getattr(_request_ctx_stack.top, 'validated_token', None) + + +def set_validated_token(token): + ctx = _request_ctx_stack.top + ctx.validated_token = token diff --git a/auth/permissions.py b/auth/permissions.py index 0e1655337..59af7be42 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -1,10 +1,11 @@ import logging -from flask.ext.principal import (identity_loaded, UserNeed, Permission, - Identity, identity_changed) -from collections import namedtuple +from flask.ext.principal import identity_loaded, Permission, Identity, identity_changed +from collections import namedtuple, defaultdict from functools import partial +import scopes + from data import model from app import app @@ -14,44 +15,117 @@ logger = logging.getLogger(__name__) _ResourceNeed = namedtuple('resource', ['type', 'namespace', 'name', 'role']) _RepositoryNeed = partial(_ResourceNeed, 'repository') -_OrganizationNeed = namedtuple('organization', ['orgname', 'role']) -_TeamNeed = namedtuple('orgteam', ['orgname', 'teamname', 'role']) +_NamespaceWideNeed = namedtuple('namespacewide', ['type', 'namespace', 'role']) +_OrganizationNeed = partial(_NamespaceWideNeed, 'organization') +_OrganizationRepoNeed = partial(_NamespaceWideNeed, 'organizationrepo') +_TeamTypeNeed = namedtuple('teamwideneed', ['type', 'orgname', 'teamname', 'role']) +_TeamNeed = partial(_TeamTypeNeed, 'orgteam') +_UserTypeNeed = namedtuple('userspecificneed', ['type', 'username', 'role']) +_UserNeed = partial(_UserTypeNeed, 'user') + + +REPO_ROLES = [None, 'read', 'write', 'admin'] +TEAM_ROLES = [None, 'member', 'creator', 'admin'] +USER_ROLES = [None, 'read', 'admin'] + +TEAM_REPO_ROLES = { + 'admin': 'admin', + 'creator': 'read', + 'member': 'read', +} + +SCOPE_MAX_REPO_ROLES = defaultdict(lambda: None) +SCOPE_MAX_REPO_ROLES.update({ + scopes.READ_REPO: 'read', + scopes.WRITE_REPO: 'write', + scopes.ADMIN_REPO: 'admin', + scopes.DIRECT_LOGIN: 'admin', +}) + +SCOPE_MAX_TEAM_ROLES = defaultdict(lambda: None) +SCOPE_MAX_TEAM_ROLES.update({ + scopes.CREATE_REPO: 'creator', + scopes.DIRECT_LOGIN: 'admin', +}) + +SCOPE_MAX_USER_ROLES = defaultdict(lambda: None) +SCOPE_MAX_USER_ROLES.update({ + scopes.READ_USER: 'read', + scopes.DIRECT_LOGIN: 'admin', +}) class QuayDeferredPermissionUser(Identity): - def __init__(self, id, auth_type=None): + def __init__(self, id, auth_type, scopes): super(QuayDeferredPermissionUser, self).__init__(id, auth_type) self._permissions_loaded = False + self._scope_set = scopes + + def _translate_role_for_scopes(self, cardinality, max_roles, role): + if self._scope_set is None: + return role + + max_for_scopes = max({cardinality.index(max_roles[scope]) for scope in self._scope_set}) + + if max_for_scopes < cardinality.index(role): + logger.debug('Translated permission %s -> %s', role, cardinality[max_for_scopes]) + return cardinality[max_for_scopes] + else: + return role + + def _team_role_for_scopes(self, role): + return self._translate_role_for_scopes(TEAM_ROLES, SCOPE_MAX_TEAM_ROLES, role) + + def _repo_role_for_scopes(self, role): + return self._translate_role_for_scopes(REPO_ROLES, SCOPE_MAX_REPO_ROLES, role) + + def _user_role_for_scopes(self, role): + return self._translate_role_for_scopes(USER_ROLES, SCOPE_MAX_USER_ROLES, role) def can(self, permission): if not self._permissions_loaded: logger.debug('Loading user permissions after deferring.') user_object = model.get_user(self.id) - # Add the user specific permissions - user_grant = UserNeed(user_object.username) + # Add the user specific permissions, only for non-oauth permission + user_grant = _UserNeed(user_object.username, self._user_role_for_scopes('admin')) + logger.debug('User permission: {0}'.format(user_grant)) self.provides.add(user_grant) # Every user is the admin of their own 'org' - user_namespace = _OrganizationNeed(user_object.username, 'admin') + user_namespace = _OrganizationNeed(user_object.username, self._team_role_for_scopes('admin')) + logger.debug('User namespace permission: {0}'.format(user_namespace)) self.provides.add(user_namespace) + # Org repo roles can differ for scopes + user_repos = _OrganizationRepoNeed(user_object.username, self._repo_role_for_scopes('admin')) + logger.debug('User namespace repo permission: {0}'.format(user_repos)) + self.provides.add(user_repos) + # Add repository permissions for perm in model.get_all_user_permissions(user_object): - grant = _RepositoryNeed(perm.repository.namespace, - perm.repository.name, perm.role.name) - logger.debug('User added permission: {0}'.format(grant)) - self.provides.add(grant) + repo_grant = _RepositoryNeed(perm.repository.namespace, perm.repository.name, + self._repo_role_for_scopes(perm.role.name)) + logger.debug('User added permission: {0}'.format(repo_grant)) + self.provides.add(repo_grant) # Add namespace permissions derived for team in model.get_org_wide_permissions(user_object): - grant = _OrganizationNeed(team.organization.username, team.role.name) - logger.debug('Organization team added permission: {0}'.format(grant)) - self.provides.add(grant) + team_org_grant = _OrganizationNeed(team.organization.username, + self._team_role_for_scopes(team.role.name)) + logger.debug('Organization team added permission: {0}'.format(team_org_grant)) + self.provides.add(team_org_grant) + + + team_repo_role = TEAM_REPO_ROLES[team.role.name] + org_repo_grant = _OrganizationRepoNeed(team.organization.username, + self._repo_role_for_scopes(team_repo_role)) + logger.debug('Organization team added repo permission: {0}'.format(org_repo_grant)) + self.provides.add(org_repo_grant) team_grant = _TeamNeed(team.organization.username, team.name, - team.role.name) + self._team_role_for_scopes(team.role.name)) logger.debug('Team added permission: {0}'.format(team_grant)) self.provides.add(team_grant) @@ -64,9 +138,10 @@ class ModifyRepositoryPermission(Permission): def __init__(self, namespace, name): admin_need = _RepositoryNeed(namespace, name, 'admin') write_need = _RepositoryNeed(namespace, name, 'write') - org_admin_need = _OrganizationNeed(namespace, 'admin') - super(ModifyRepositoryPermission, self).__init__(admin_need, write_need, - org_admin_need) + org_admin_need = _OrganizationRepoNeed(namespace, 'admin') + org_write_need = _OrganizationRepoNeed(namespace, 'write') + super(ModifyRepositoryPermission, self).__init__(admin_need, write_need, org_admin_need, + org_write_need) class ReadRepositoryPermission(Permission): @@ -74,15 +149,17 @@ class ReadRepositoryPermission(Permission): admin_need = _RepositoryNeed(namespace, name, 'admin') write_need = _RepositoryNeed(namespace, name, 'write') read_need = _RepositoryNeed(namespace, name, 'read') - org_admin_need = _OrganizationNeed(namespace, 'admin') - super(ReadRepositoryPermission, self).__init__(admin_need, write_need, - read_need, org_admin_need) + org_admin_need = _OrganizationRepoNeed(namespace, 'admin') + org_write_need = _OrganizationRepoNeed(namespace, 'write') + org_read_need = _OrganizationRepoNeed(namespace, 'read') + super(ReadRepositoryPermission, self).__init__(admin_need, write_need, read_need, + org_admin_need, org_read_need, org_write_need) class AdministerRepositoryPermission(Permission): def __init__(self, namespace, name): admin_need = _RepositoryNeed(namespace, name, 'admin') - org_admin_need = _OrganizationNeed(namespace, 'admin') + org_admin_need = _OrganizationRepoNeed(namespace, 'admin') super(AdministerRepositoryPermission, self).__init__(admin_need, org_admin_need) @@ -95,10 +172,17 @@ class CreateRepositoryPermission(Permission): create_repo_org) -class UserPermission(Permission): +class UserAdminPermission(Permission): def __init__(self, username): - user_need = UserNeed(username) - super(UserPermission, self).__init__(user_need) + user_admin = _UserNeed(username, 'admin') + super(UserAdminPermission, self).__init__(user_admin) + + +class UserReadPermission(Permission): + def __init__(self, username): + user_admin = _UserNeed(username, 'admin') + user_read = _UserNeed(username, 'read') + super(UserReadPermission, self).__init__(user_read, user_admin) class AdministerOrganizationPermission(Permission): @@ -132,14 +216,15 @@ def on_identity_loaded(sender, identity): # We have verified an identity, load in all of the permissions if isinstance(identity, QuayDeferredPermissionUser): - logger.debug('Deferring permissions for user: %s' % identity.id) + logger.debug('Deferring permissions for user: %s', identity.id) elif identity.auth_type == 'username': - switch_to_deferred = QuayDeferredPermissionUser(identity.id, 'username') + logger.debug('Switching username permission to deferred object: %s', identity.id) + switch_to_deferred = QuayDeferredPermissionUser(identity.id, 'username', {scopes.DIRECT_LOGIN}) identity_changed.send(app, identity=switch_to_deferred) elif identity.auth_type == 'token': - logger.debug('Loading permissions for token: %s' % identity.id) + logger.debug('Loading permissions for token: %s', identity.id) token_data = model.load_token_data(identity.id) repo_grant = _RepositoryNeed(token_data.repository.namespace, @@ -149,4 +234,4 @@ def on_identity_loaded(sender, identity): identity.provides.add(repo_grant) else: - logger.error('Unknown identity auth type: %s' % identity.auth_type) + logger.error('Unknown identity auth type: %s', identity.auth_type) diff --git a/auth/scopes.py b/auth/scopes.py new file mode 100644 index 000000000..aad91182b --- /dev/null +++ b/auth/scopes.py @@ -0,0 +1,92 @@ +from collections import namedtuple + + +Scope = namedtuple('scope', ['scope', 'icon', 'title', 'description']) + + +READ_REPO = Scope(scope='repo:read', + icon='fa-hdd-o', + title='View all visible repositories', + description=('This application will be able to view and pull all repositories ' + 'visible to the granting user or robot account')) + +WRITE_REPO = Scope(scope='repo:write', + icon='fa-hdd-o', + title='Read/Write to any accessible repositories', + description=('This application will be able to view, push and pull to all ' + 'repositories to which the granting user or robot account has ' + 'write access')) + +ADMIN_REPO = Scope(scope='repo:admin', + icon='fa-hdd-o', + title='Administer Repositories', + description=('This application will have administrator access to all ' + 'repositories to which the granting user or robot account has ' + 'access')) + +CREATE_REPO = Scope(scope='repo:create', + icon='fa-plus', + title='Create Repositories', + description=('This application will be able to create repositories in to any ' + 'namespaces that the granting user or robot account is allowed to ' + 'create repositories')) + +READ_USER = Scope(scope= 'user:read', + icon='fa-user', + title='Read User Information', + description=('This application will be able to read user information such as ' + 'username and email address.')) + + +DIRECT_LOGIN = Scope(scope='direct_user_login', + icon='fa-exclamation-triangle', + title='Full Access', + description=('This scope should not be available to OAuth applications. ' + 'Never approve a request for this scope!')) + + +ALL_SCOPES = {scope.scope:scope for scope in (READ_REPO, WRITE_REPO, ADMIN_REPO, CREATE_REPO, + READ_USER)} + +IMPLIED_SCOPES = { + ADMIN_REPO: {ADMIN_REPO, WRITE_REPO, READ_REPO}, + WRITE_REPO: {WRITE_REPO, READ_REPO}, + READ_REPO: {READ_REPO}, + CREATE_REPO: {CREATE_REPO}, + READ_USER: {READ_USER}, + None: set(), +} + + +def scopes_from_scope_string(scopes): + return {ALL_SCOPES.get(scope, None) for scope in scopes.split(',')} + + +def validate_scope_string(scopes): + decoded = scopes_from_scope_string(scopes) + return None not in decoded and len(decoded) > 0 + + +def is_subset_string(full_string, expected_string): + """ Returns true if the scopes found in expected_string are also found + in full_string. + """ + full_scopes = scopes_from_scope_string(full_string) + full_implied_scopes = set.union(*[IMPLIED_SCOPES[scope] for scope in full_scopes]) + expected_scopes = scopes_from_scope_string(expected_string) + return expected_scopes.issubset(full_implied_scopes) + + +def get_scope_information(scopes_string): + scopes = scopes_from_scope_string(scopes_string) + scope_info = [] + for scope in scopes: + if scope: + scope_info.append({ + 'title': scope.title, + 'scope': scope.scope, + 'description': scope.description, + 'icon': scope.icon, + }) + + return scope_info diff --git a/buildstatus/building.svg b/buildstatus/building.svg new file mode 100644 index 000000000..6a2e8fc8c --- /dev/null +++ b/buildstatus/building.svg @@ -0,0 +1 @@ +Docker ImageDocker Imagebuildingbuilding \ No newline at end of file diff --git a/buildstatus/failed.svg b/buildstatus/failed.svg new file mode 100644 index 000000000..1a3d79f75 --- /dev/null +++ b/buildstatus/failed.svg @@ -0,0 +1 @@ +Docker ImageDocker Imagebuild failedbuild failed \ No newline at end of file diff --git a/buildstatus/none.svg b/buildstatus/none.svg new file mode 100644 index 000000000..0f513d9cd --- /dev/null +++ b/buildstatus/none.svg @@ -0,0 +1 @@ +Docker ImageDocker Imagenonenone \ No newline at end of file diff --git a/buildstatus/ready.svg b/buildstatus/ready.svg new file mode 100644 index 000000000..4ea770599 --- /dev/null +++ b/buildstatus/ready.svg @@ -0,0 +1 @@ +Docker ImageDocker Imagereadyready \ No newline at end of file diff --git a/config.py b/config.py index 073dde963..2428a9a1e 100644 --- a/config.py +++ b/config.py @@ -1,5 +1,7 @@ import logging import logstash_formatter +import requests +import os.path from peewee import MySQLDatabase, SqliteDatabase from storage.s3 import S3Storage @@ -18,6 +20,7 @@ class FlaskConfig(object): SECRET_KEY = '1cb18882-6d12-440d-a4cc-b7430fb5f884' JSONIFY_PRETTYPRINT_REGULAR = False + class FlaskProdConfig(FlaskConfig): SESSION_COOKIE_SECURE = True @@ -44,7 +47,8 @@ class RealTransactions(object): class SQLiteDB(RealTransactions): DB_NAME = 'test/data/test.db' DB_CONNECTION_ARGS = { - 'threadlocals': True + 'threadlocals': True, + 'autorollback': True, } DB_DRIVER = SqliteDatabase @@ -76,6 +80,7 @@ class RDSMySQL(RealTransactions): 'user': 'fluxmonkey', 'passwd': '8eifM#uoZ85xqC^', 'threadlocals': True, + 'autorollback': True, } DB_DRIVER = MySQLDatabase @@ -154,6 +159,11 @@ class GitHubTestConfig(object): GITHUB_USER_EMAILS = GITHUB_USER_URL + '/emails' +class GitHubStagingConfig(GitHubTestConfig): + GITHUB_CLIENT_ID = '4886304accbc444f0471' + GITHUB_CLIENT_SECRET = '27d8a5d99af02dda821eb10883bcb2e785e70a62' + + class GitHubProdConfig(GitHubTestConfig): GITHUB_CLIENT_ID = '5a8c08b06c48d89d4d1e' GITHUB_CLIENT_SECRET = 'f89d8bb28ea3bd4e1c68808500d185a816be53b1' @@ -185,36 +195,79 @@ def logs_init_builder(level=logging.DEBUG, return init_logs +def build_requests_session(): + sess = requests.Session() + adapter = requests.adapters.HTTPAdapter(pool_connections=100, + pool_maxsize=100) + sess.mount('http://', adapter) + sess.mount('https://', adapter) + return sess + + +class LargePoolHttpClient(object): + HTTPCLIENT = build_requests_session() + + +class StatusTagConfig(object): + STATUS_TAGS = {} + + for tag_name in ['building', 'failed', 'none', 'ready']: + tag_path = os.path.join('buildstatus', tag_name + '.svg') + with open(tag_path) as tag_svg: + STATUS_TAGS[tag_name] = tag_svg.read() + + class TestConfig(FlaskConfig, FakeStorage, EphemeralDB, FakeUserfiles, FakeAnalytics, StripeTestConfig, RedisBuildLogs, - UserEventConfig): + UserEventConfig, LargePoolHttpClient, StatusTagConfig): LOGGING_CONFIG = logs_init_builder(logging.WARN) POPULATE_DB_TEST_DATA = True TESTING = True + URL_SCHEME = 'http' + URL_HOST = 'localhost:5000' class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB, StripeTestConfig, MixpanelTestConfig, GitHubTestConfig, DigitalOceanConfig, BuildNodeConfig, S3Userfiles, - UserEventConfig, TestBuildLogs): + UserEventConfig, TestBuildLogs, LargePoolHttpClient, + StatusTagConfig): LOGGING_CONFIG = logs_init_builder(formatter=logging.Formatter()) SEND_FILE_MAX_AGE_DEFAULT = 0 POPULATE_DB_TEST_DATA = True + URL_SCHEME = 'http' + URL_HOST = 'ci.devtable.com:5000' class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL, StripeLiveConfig, MixpanelTestConfig, GitHubProdConfig, DigitalOceanConfig, BuildNodeConfig, S3Userfiles, RedisBuildLogs, - UserEventConfig): - LOGGING_CONFIG = logs_init_builder() + UserEventConfig, LargePoolHttpClient, + StatusTagConfig): + LOGGING_CONFIG = logs_init_builder(formatter=logging.Formatter()) SEND_FILE_MAX_AGE_DEFAULT = 0 + URL_SCHEME = 'http' + URL_HOST = 'ci.devtable.com:5000' + + +class StagingConfig(FlaskProdConfig, MailConfig, S3Storage, RDSMySQL, + StripeLiveConfig, MixpanelProdConfig, + GitHubStagingConfig, DigitalOceanConfig, BuildNodeConfig, + S3Userfiles, RedisBuildLogs, UserEventConfig, + LargePoolHttpClient, StatusTagConfig): + LOGGING_CONFIG = logs_init_builder(formatter=logging.Formatter()) + SEND_FILE_MAX_AGE_DEFAULT = 0 + URL_SCHEME = 'https' + URL_HOST = 'staging.quay.io' class ProductionConfig(FlaskProdConfig, MailConfig, S3Storage, RDSMySQL, StripeLiveConfig, MixpanelProdConfig, GitHubProdConfig, DigitalOceanConfig, BuildNodeConfig, - S3Userfiles, RedisBuildLogs, UserEventConfig): - + S3Userfiles, RedisBuildLogs, UserEventConfig, + LargePoolHttpClient, StatusTagConfig): LOGGING_CONFIG = logs_init_builder() SEND_FILE_MAX_AGE_DEFAULT = 0 + URL_SCHEME = 'https' + URL_HOST = 'quay.io' diff --git a/data/buildlogs.py b/data/buildlogs.py index bb96ac7dc..817fbc2b4 100644 --- a/data/buildlogs.py +++ b/data/buildlogs.py @@ -40,9 +40,12 @@ class BuildLogs(object): Returns a tuple of the current length of the list and an iterable of the requested log entries. """ - llen = self._redis.llen(self._logs_key(build_id)) - log_entries = self._redis.lrange(self._logs_key(build_id), start_index, -1) - return (llen, (json.loads(entry) for entry in log_entries)) + try: + llen = self._redis.llen(self._logs_key(build_id)) + log_entries = self._redis.lrange(self._logs_key(build_id), start_index, -1) + return (llen, (json.loads(entry) for entry in log_entries)) + except redis.ConnectionError: + return (0, []) @staticmethod def _status_key(build_id): @@ -59,5 +62,9 @@ class BuildLogs(object): """ Loads the status information for the specified build id. """ - fetched = self._redis.get(self._status_key(build_id)) + try: + fetched = self._redis.get(self._status_key(build_id)) + except redis.ConnectionError: + return None + return json.loads(fetched) if fetched else None diff --git a/data/database.py b/data/database.py index 9c5f48efd..d99f56c77 100644 --- a/data/database.py +++ b/data/database.py @@ -101,6 +101,7 @@ class Repository(BaseModel): name = CharField() visibility = ForeignKeyField(Visibility) description = TextField(null=True) + badge_token = CharField(default=uuid_generator) class Meta: database = db @@ -163,6 +164,20 @@ class AccessToken(BaseModel): temporary = BooleanField(default=True) +class BuildTriggerService(BaseModel): + name = CharField(index=True) + + +class RepositoryBuildTrigger(BaseModel): + uuid = CharField(default=uuid_generator) + service = ForeignKeyField(BuildTriggerService, index=True) + repository = ForeignKeyField(Repository, index=True) + connected_user = ForeignKeyField(User) + auth_token = CharField() + config = TextField(default='{}') + write_token = ForeignKeyField(AccessToken, null=True) + + class EmailConfirmation(BaseModel): code = CharField(default=random_string_generator(), unique=True, index=True) user = ForeignKeyField(User) @@ -223,11 +238,12 @@ class RepositoryBuild(BaseModel): uuid = CharField(default=uuid_generator, index=True) repository = ForeignKeyField(Repository, index=True) access_token = ForeignKeyField(AccessToken) - resource_key = CharField() - tag = CharField() + resource_key = CharField(index=True) + job_config = TextField() phase = CharField(default='waiting') started = DateTimeField(default=datetime.now) display_name = CharField() + trigger = ForeignKeyField(RepositoryBuildTrigger, null=True, index=True) class QueueItem(BaseModel): @@ -255,8 +271,52 @@ class LogEntry(BaseModel): metadata_json = TextField(default='{}') -all_models = [User, Repository, Image, AccessToken, Role, - RepositoryPermission, Visibility, RepositoryTag, - EmailConfirmation, FederatedLogin, LoginService, QueueItem, - RepositoryBuild, Team, TeamMember, TeamRole, Webhook, - LogEntryKind, LogEntry, PermissionPrototype, ImageStorage] +class OAuthApplication(BaseModel): + client_id = CharField(index=True, default=random_string_generator(length=20)) + client_secret = CharField(default=random_string_generator(length=40)) + redirect_uri = CharField() + application_uri = CharField() + organization = ForeignKeyField(User) + + name = CharField() + description = TextField(default='') + gravatar_email = CharField(null=True) + + +class OAuthAuthorizationCode(BaseModel): + application = ForeignKeyField(OAuthApplication) + code = CharField(index=True) + scope = CharField() + data = TextField() # Context for the code, such as the user + + +class OAuthAccessToken(BaseModel): + uuid = CharField(default=uuid_generator, index=True) + application = ForeignKeyField(OAuthApplication) + authorized_user = ForeignKeyField(User) + scope = CharField() + access_token = CharField(index=True) + token_type = CharField(default='Bearer') + expires_at = DateTimeField() + refresh_token = CharField(index=True, null=True) + data = TextField() # This is context for which this token was generated, such as the user + + +class NotificationKind(BaseModel): + name = CharField(index=True) + + +class Notification(BaseModel): + uuid = CharField(default=uuid_generator, index=True) + kind = ForeignKeyField(NotificationKind, index=True) + target = ForeignKeyField(User, index=True) + metadata_json = TextField(default='{}') + created = DateTimeField(default=datetime.now, index=True) + + +all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission, Visibility, + RepositoryTag, EmailConfirmation, FederatedLogin, LoginService, QueueItem, + RepositoryBuild, Team, TeamMember, TeamRole, Webhook, LogEntryKind, LogEntry, + PermissionPrototype, ImageStorage, BuildTriggerService, RepositoryBuildTrigger, + OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind, + Notification] diff --git a/data/model/__init__.py b/data/model/__init__.py new file mode 100644 index 000000000..8258c9c94 --- /dev/null +++ b/data/model/__init__.py @@ -0,0 +1 @@ +from data.model.legacy import * \ No newline at end of file diff --git a/data/model.py b/data/model/legacy.py similarity index 87% rename from data/model.py rename to data/model/legacy.py index 876f4e0a5..00d9c9b26 100644 --- a/data/model.py +++ b/data/model/legacy.py @@ -2,11 +2,10 @@ import bcrypt import logging import datetime import dateutil.parser -import operator import json -from database import * +from data.database import * from util.validation import * from util.names import format_robot_username @@ -55,7 +54,11 @@ class InvalidWebhookException(DataModelException): pass -def create_user(username, password, email): +class InvalidBuildTriggerException(DataModelException): + pass + + +def create_user(username, password, email, is_organization=False): if not validate_email(email): raise InvalidEmailAddressException('Invalid email address: %s' % email) if not validate_username(username): @@ -89,6 +92,12 @@ def create_user(username, password, email): new_user = User.create(username=username, password_hash=pw_hash, email=email) + + # If the password is None, then add a notification for the user to change + # their password ASAP. + if not pw_hash and not is_organization: + create_notification('password_required', new_user) + return new_user except Exception as ex: raise DataModelException(ex.message) @@ -97,7 +106,7 @@ def create_user(username, password, email): def create_organization(name, email, creating_user): try: # Create the org - new_org = create_user(name, None, email) + new_org = create_user(name, None, email, is_organization=True) new_org.organization = True new_org.save() @@ -546,9 +555,9 @@ def get_visible_repository_count(username=None, include_public=True, def get_visible_repositories(username=None, include_public=True, page=None, limit=None, sort=False, namespace=None): - query = _visible_repository_query(username=username, - include_public=include_public, page=page, - limit=limit, namespace=namespace) + query = _visible_repository_query(username=username, include_public=include_public, page=page, + limit=limit, namespace=namespace, + select_models=[Repository, Visibility]) if sort: query = query.order_by(Repository.description.desc()) @@ -560,9 +569,9 @@ def get_visible_repositories(username=None, include_public=True, page=None, def _visible_repository_query(username=None, include_public=True, limit=None, - page=None, namespace=None): + page=None, namespace=None, select_models=[]): query = (Repository - .select() # Note: We need to leave this blank for the get_count case. Otherwise, MySQL/RDS complains. + .select(*select_models) # Note: We need to leave this blank for the get_count case. Otherwise, MySQL/RDS complains. .distinct() .join(Visibility) .switch(Repository) @@ -658,6 +667,9 @@ def change_password(user, new_password): user.password_hash = pw_hash user.save() + # Remove any password required notifications for the user. + delete_notifications_by_kind(user, 'password_required') + def change_invoice_email(user, invoice_email): user.invoice_email = invoice_email @@ -771,14 +783,15 @@ def get_all_repo_users(namespace_name, repository_name): def get_repository_for_resource(resource_key): - joined = Repository.select().join(RepositoryBuild) - query = joined.where(RepositoryBuild.resource_key == resource_key).limit(1) - result = list(query) - if not result: + try: + return (Repository + .select() + .join(RepositoryBuild) + .where(RepositoryBuild.resource_key == resource_key) + .get()) + except Repository.DoesNotExist: return None - return result[0] - def get_repository(namespace_name, repository_name): try: @@ -1284,7 +1297,10 @@ def set_user_repo_permission(username, namespace_name, repository_name, if username == namespace_name: raise DataModelException('Namespace owner must always be admin.') - user = User.get(User.username == username) + try: + user = User.get(User.username == username) + except User.DoesNotExist: + raise InvalidUsernameException('Invalid username: %s' % username) return __set_entity_repo_permission(user, 'user', namespace_name, repository_name, role_name) @@ -1327,8 +1343,9 @@ def create_access_token(repository, role): return new_token -def create_delegate_token(namespace_name, repository_name, friendly_name): - read_only = Role.get(name='read') +def create_delegate_token(namespace_name, repository_name, friendly_name, + role='read'): + read_only = Role.get(name=role) repo = Repository.get(Repository.name == repository_name, Repository.namespace == namespace_name) new_token = AccessToken.create(repository=repo, role=read_only, @@ -1388,35 +1405,49 @@ def load_token_data(code): def get_repository_build(namespace_name, repository_name, build_uuid): - joined = RepositoryBuild.select().join(Repository) - fetched = list(joined.where(Repository.name == repository_name, - Repository.namespace == namespace_name, - RepositoryBuild.uuid == build_uuid)) + try: + query = list_repository_builds(namespace_name, repository_name, 1) + return query.where(RepositoryBuild.uuid == build_uuid).get() - if not fetched: + except RepositoryBuild.DoesNotExist: msg = 'Unable to locate a build by id: %s' % build_uuid raise InvalidRepositoryBuildException(msg) - return fetched[0] - -def list_repository_builds(namespace_name, repository_name, +def list_repository_builds(namespace_name, repository_name, limit, include_inactive=True): - joined = RepositoryBuild.select().join(Repository) - filtered = joined + query = (RepositoryBuild + .select(RepositoryBuild, RepositoryBuildTrigger, BuildTriggerService) + .join(Repository) + .switch(RepositoryBuild) + .join(RepositoryBuildTrigger, JOIN_LEFT_OUTER) + .join(BuildTriggerService, JOIN_LEFT_OUTER) + .where(Repository.name == repository_name, + Repository.namespace == namespace_name) + .order_by(RepositoryBuild.started.desc()) + .limit(limit)) + if not include_inactive: - filtered = filtered.where(RepositoryBuild.phase != 'error', - RepositoryBuild.phase != 'complete') - fetched = list(filtered.where(Repository.name == repository_name, - Repository.namespace == namespace_name)) - return fetched + query = query.where(RepositoryBuild.phase != 'error', + RepositoryBuild.phase != 'complete') + + return query -def create_repository_build(repo, access_token, resource_key, tag, - display_name): +def get_recent_repository_build(namespace_name, repository_name): + query = list_repository_builds(namespace_name, repository_name, 1) + try: + return query.get() + except RepositoryBuild.DoesNotExist: + return None + + +def create_repository_build(repo, access_token, job_config_obj, dockerfile_id, + display_name, trigger=None): return RepositoryBuild.create(repository=repo, access_token=access_token, - resource_key=resource_key, tag=tag, - display_name=display_name) + job_config=json.dumps(job_config_obj), + display_name=display_name, trigger=trigger, + resource_key=dockerfile_id) def create_webhook(repo, params_obj): @@ -1445,27 +1476,116 @@ def delete_webhook(namespace_name, repository_name, public_id): webhook = get_webhook(namespace_name, repository_name, public_id) webhook.delete_instance() return webhook - -def list_logs(user_or_organization_name, start_time, end_time, performer = None, repository = None): - joined = LogEntry.select().join(User) - if repository: - joined = joined.where(LogEntry.repository == repository) - if performer: - joined = joined.where(LogEntry.performer == performer) - return joined.where( - User.username == user_or_organization_name, - LogEntry.datetime >= start_time, - LogEntry.datetime < end_time).order_by(LogEntry.datetime.desc()) +def list_logs(user_or_organization_name, start_time, end_time, performer=None, + repository=None): + joined = LogEntry.select().join(User) + if repository: + joined = joined.where(LogEntry.repository == repository) -def log_action(kind_name, user_or_organization_name, performer=None, repository=None, - access_token=None, ip=None, metadata={}, timestamp=None): + if performer: + joined = joined.where(LogEntry.performer == performer) + + return joined.where( + User.username == user_or_organization_name, + LogEntry.datetime >= start_time, + LogEntry.datetime < end_time).order_by(LogEntry.datetime.desc()) + + +def log_action(kind_name, user_or_organization_name, performer=None, + repository=None, access_token=None, ip=None, metadata={}, + timestamp=None): if not timestamp: timestamp = datetime.today() kind = LogEntryKind.get(LogEntryKind.name == kind_name) account = User.get(User.username == user_or_organization_name) - entry = LogEntry.create(kind=kind, account=account, performer=performer, - repository=repository, access_token=access_token, ip=ip, - metadata_json=json.dumps(metadata), datetime=timestamp) + LogEntry.create(kind=kind, account=account, performer=performer, + repository=repository, access_token=access_token, ip=ip, + metadata_json=json.dumps(metadata), datetime=timestamp) + + +def create_build_trigger(repo, service_name, auth_token, user): + service = BuildTriggerService.get(name=service_name) + trigger = RepositoryBuildTrigger.create(repository=repo, service=service, + auth_token=auth_token, + connected_user=user) + return trigger + + +def get_build_trigger(namespace_name, repository_name, trigger_uuid): + try: + return (RepositoryBuildTrigger + .select(RepositoryBuildTrigger, BuildTriggerService, Repository) + .join(BuildTriggerService) + .switch(RepositoryBuildTrigger) + .join(Repository) + .switch(RepositoryBuildTrigger) + .join(User) + .where(RepositoryBuildTrigger.uuid == trigger_uuid, + Repository.namespace == namespace_name, + Repository.name == repository_name) + .get()) + except RepositoryBuildTrigger.DoesNotExist: + msg = 'No build trigger with uuid: %s' % trigger_uuid + raise InvalidBuildTriggerException(msg) + + +def list_build_triggers(namespace_name, repository_name): + return (RepositoryBuildTrigger + .select(RepositoryBuildTrigger, BuildTriggerService, Repository) + .join(BuildTriggerService) + .switch(RepositoryBuildTrigger) + .join(Repository) + .where(Repository.namespace == namespace_name, + Repository.name == repository_name)) + + +def list_trigger_builds(namespace_name, repository_name, trigger_uuid, + limit): + return (list_repository_builds(namespace_name, repository_name, limit) + .where(RepositoryBuildTrigger.uuid == trigger_uuid)) + + +def create_notification(kind, target, metadata={}): + kind_ref = NotificationKind.get(name=kind) + notification = Notification.create(kind=kind_ref, target=target, + metadata_json=json.dumps(metadata)) + return notification + + +def list_notifications(user, kind=None): + Org = User.alias() + AdminTeam = Team.alias() + AdminTeamMember = TeamMember.alias() + AdminUser = User.alias() + + query = (Notification.select() + .join(User) + + .switch(Notification) + .join(Org, JOIN_LEFT_OUTER, on=(Org.id == Notification.target)) + .join(AdminTeam, JOIN_LEFT_OUTER, on=(Org.id == + AdminTeam.organization)) + .join(TeamRole, JOIN_LEFT_OUTER, on=(AdminTeam.role == TeamRole.id)) + .switch(AdminTeam) + .join(AdminTeamMember, JOIN_LEFT_OUTER, on=(AdminTeam.id == + AdminTeamMember.team)) + .join(AdminUser, JOIN_LEFT_OUTER, on=(AdminTeamMember.user == + AdminUser.id))) + + where_clause = ((Notification.target == user) | + ((AdminUser.id == user) & + (TeamRole.name == 'admin'))) + + if kind: + where_clause = where_clause & (NotificationKind.name == kind) + + return query.where(where_clause).order_by(Notification.created).desc() + + +def delete_notifications_by_kind(target, kind): + kind_ref = NotificationKind.get(name=kind) + Notification.delete().where(Notification.target == target, + Notification.kind == kind_ref).execute() diff --git a/data/model/oauth.py b/data/model/oauth.py new file mode 100644 index 000000000..b99a9cb58 --- /dev/null +++ b/data/model/oauth.py @@ -0,0 +1,281 @@ +import logging +import json + +from datetime import datetime, timedelta +from oauth2lib.provider import AuthorizationProvider +from oauth2lib import utils + +from data.database import (OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, User, + random_string_generator) +from data.model.legacy import get_user +from auth import scopes + + +logger = logging.getLogger(__name__) + + +class DatabaseAuthorizationProvider(AuthorizationProvider): + def get_authorized_user(self): + raise NotImplementedError('Subclasses must fill in the ability to get the authorized_user.') + + def _generate_data_string(self): + return json.dumps({'username': self.get_authorized_user().username}) + + @property + def token_expires_in(self): + """Property method to get the token expiration time in seconds. + """ + return int(60*60*24*365.25*10) # 10 Years + + def validate_client_id(self, client_id): + return self.get_application_for_client_id(client_id) is not None + + def get_application_for_client_id(self, client_id): + try: + return OAuthApplication.get(client_id=client_id) + except OAuthApplication.DoesNotExist: + return None + + def validate_client_secret(self, client_id, client_secret): + try: + OAuthApplication.get(client_id=client_id, client_secret=client_secret) + return True + except OAuthApplication.DoesNotExist: + return False + + def validate_redirect_uri(self, client_id, redirect_uri): + try: + app = OAuthApplication.get(client_id=client_id) + if app.redirect_uri and redirect_uri.startswith(app.redirect_uri): + return True + return False + except OAuthApplication.DoesNotExist: + return False + + def validate_scope(self, client_id, scopes_string): + return scopes.validate_scope_string(scopes_string) + + def validate_access(self): + return self.get_authorized_user() is not None + + def load_authorized_scope_string(self, client_id, username): + found = (OAuthAccessToken + .select() + .join(OAuthApplication) + .switch(OAuthAccessToken) + .join(User) + .where(OAuthApplication.client_id == client_id, User.username == username, + OAuthAccessToken.expires_at > datetime.now())) + found = list(found) + logger.debug('Found %s matching tokens.', len(found)) + long_scope_string = ','.join([token.scope for token in found]) + logger.debug('Computed long scope string: %s', long_scope_string) + return long_scope_string + + def validate_has_scopes(self, client_id, username, scope): + long_scope_string = self.load_authorized_scope_string(client_id, username) + + # Make sure the token contains the given scopes (at least). + return scopes.is_subset_string(long_scope_string, scope) + + def from_authorization_code(self, client_id, code, scope): + try: + found = (OAuthAuthorizationCode + .select() + .join(OAuthApplication) + .where(OAuthApplication.client_id == client_id, OAuthAuthorizationCode.code == code, + OAuthAuthorizationCode.scope == scope) + .get()) + logger.debug('Returning data: %s', found.data) + return found.data + except OAuthAuthorizationCode.DoesNotExist: + return None + + def from_refresh_token(self, client_id, refresh_token, scope): + try: + found = (OAuthAccessToken + .select() + .join(OAuthApplication) + .where(OAuthApplication.client_id == client_id, + OAuthAccessToken.refresh_token == refresh_token, + OAuthAccessToken.scope == scope) + .get()) + return found.data + except OAuthAccessToken.DoesNotExist: + return None + + def persist_authorization_code(self, client_id, code, scope): + app = OAuthApplication.get(client_id=client_id) + data = self._generate_data_string() + OAuthAuthorizationCode.create(application=app, code=code, scope=scope, data=data) + + def persist_token_information(self, client_id, scope, access_token, token_type, expires_in, + refresh_token, data): + user = get_user(json.loads(data)['username']) + if not user: + raise RuntimeError('Username must be in the data field') + + app = OAuthApplication.get(client_id=client_id) + expires_at = datetime.now() + timedelta(seconds=expires_in) + OAuthAccessToken.create(application=app, authorized_user=user, scope=scope, + access_token=access_token, token_type=token_type, + expires_at=expires_at, refresh_token=refresh_token, data=data) + + def discard_authorization_code(self, client_id, code): + found = (OAuthAuthorizationCode + .select() + .join(OAuthApplication) + .where(OAuthApplication.client_id == client_id, OAuthAuthorizationCode.code == code) + .get()) + found.delete_instance() + + def discard_refresh_token(self, client_id, refresh_token): + found = (AccessToken + .select() + .join(OAuthApplication) + .where(OAuthApplication.client_id == client_id, + OAuthAccessToken.refresh_token == refresh_token) + .get()) + found.delete_instance() + + + def get_auth_denied_response(self, response_type, client_id, redirect_uri, **params): + # Ensure proper response_type + if response_type != 'token': + err = 'unsupported_response_type' + return self._make_redirect_error_response(redirect_uri, err) + + # Check redirect URI + is_valid_redirect_uri = self.validate_redirect_uri(client_id, redirect_uri) + if not is_valid_redirect_uri: + return self._invalid_redirect_uri_response() + + return self._make_redirect_error_response(redirect_uri, 'authorization_denied') + + + def get_token_response(self, response_type, client_id, redirect_uri, **params): + # Ensure proper response_type + if response_type != 'token': + err = 'unsupported_response_type' + return self._make_redirect_error_response(redirect_uri, err) + + # Check redirect URI + is_valid_redirect_uri = self.validate_redirect_uri(client_id, redirect_uri) + if not is_valid_redirect_uri: + return self._invalid_redirect_uri_response() + + # Check conditions + is_valid_client_id = self.validate_client_id(client_id) + is_valid_access = self.validate_access() + scope = params.get('scope', '') + are_valid_scopes = self.validate_scope(client_id, scope) + + # Return proper error responses on invalid conditions + if not is_valid_client_id: + err = 'unauthorized_client' + return self._make_redirect_error_response(redirect_uri, err) + + if not is_valid_access: + err = 'access_denied' + return self._make_redirect_error_response(redirect_uri, err) + + if not are_valid_scopes: + err = 'invalid_scope' + return self._make_redirect_error_response(redirect_uri, err) + + access_token = self.generate_access_token() + token_type = self.token_type + expires_in = self.token_expires_in + refresh_token = None # No refresh token for this kind of flow + + data = self._generate_data_string() + self.persist_token_information(client_id=client_id, scope=scope, access_token=access_token, + token_type=token_type, expires_in=expires_in, + refresh_token=refresh_token, data=data) + + url = utils.build_url(redirect_uri, params) + url += '#access_token=%s&token_type=%s&expires_in=%s' % (access_token, token_type, expires_in) + + return self._make_response(headers={'Location': url}, status_code=302) + + +def create_application(org, name, application_uri, redirect_uri, **kwargs): + return OAuthApplication.create(organization=org, name=name, application_uri=application_uri, + redirect_uri=redirect_uri, **kwargs) + + +def validate_access_token(access_token): + try: + found = (OAuthAccessToken + .select(OAuthAccessToken, User) + .join(User) + .where(OAuthAccessToken.access_token == access_token) + .get()) + return found + except OAuthAccessToken.DoesNotExist: + return None + + +def get_application_for_client_id(client_id): + try: + return OAuthApplication.get(client_id=client_id) + except OAuthApplication.DoesNotExist: + return None + + +def reset_client_secret(application): + application.client_secret = random_string_generator(length=40)() + application.save() + return application + + +def lookup_application(org, client_id): + try: + return OAuthApplication.get(organization = org, client_id=client_id) + except OAuthApplication.DoesNotExist: + return None + + +def delete_application(org, client_id): + application = lookup_application(org, client_id) + if not application: + return + + application.delete_instance(recursive=True, delete_nullable=True) + return application + + +def lookup_access_token_for_user(user, token_uuid): + try: + return OAuthAccessToken.get(OAuthAccessToken.authorized_user == user, + OAuthAccessToken.uuid == token_uuid) + except OAuthAccessToken.DoesNotExist: + return None + + +def list_access_tokens_for_user(user): + query = (OAuthAccessToken + .select() + .join(OAuthApplication) + .switch(OAuthAccessToken) + .join(User) + .where(OAuthAccessToken.authorized_user == user)) + + return query + + +def list_applications_for_org(org): + query = (OAuthApplication + .select() + .join(User) + .where(OAuthApplication.organization == org)) + + return query + + +def create_access_token_for_testing(user, client_id, scope): + expires_at = datetime.now() + timedelta(seconds=10000) + application = get_application_for_client_id(client_id) + OAuthAccessToken.create(application=application, authorized_user=user, scope=scope, + token_type='token', access_token='test', + expires_at=expires_at, refresh_token='', data='') diff --git a/data/queue.py b/data/queue.py index 46db150bf..09e90f1a1 100644 --- a/data/queue.py +++ b/data/queue.py @@ -68,5 +68,5 @@ class WorkQueue(object): image_diff_queue = WorkQueue('imagediff') -dockerfile_build_queue = WorkQueue('dockerfilebuild2') +dockerfile_build_queue = WorkQueue('dockerfilebuild3') webhook_queue = WorkQueue('webhook') diff --git a/data/userfiles.py b/data/userfiles.py index c2a8bc63c..cc314a47f 100644 --- a/data/userfiles.py +++ b/data/userfiles.py @@ -40,14 +40,15 @@ class UserRequestFiles(object): encrypt_key=True) return (url, file_id) - def store_file(self, flask_file): + def store_file(self, file_like_obj, content_type): self._initialize_s3() file_id = str(uuid4()) full_key = os.path.join(self._prefix, file_id) k = Key(self._bucket, full_key) - logger.debug('Setting s3 content type to: %s' % flask_file.content_type) - k.set_metadata('Content-Type', flask_file.content_type) - bytes_written = k.set_contents_from_file(flask_file, encrypt_key=True) + logger.debug('Setting s3 content type to: %s' % content_type) + k.set_metadata('Content-Type', content_type) + bytes_written = k.set_contents_from_file(file_like_obj, encrypt_key=True, + rewind=True) if bytes_written == 0: raise S3FileWriteException('Unable to write file to S3') diff --git a/endpoints/api.py b/endpoints/api.py deleted file mode 100644 index d480923b5..000000000 --- a/endpoints/api.py +++ /dev/null @@ -1,2309 +0,0 @@ -import logging -import stripe -import requests -import urlparse -import json - -from flask import request, make_response, jsonify, abort, url_for, Blueprint, session -from flask.ext.login import current_user, logout_user -from flask.ext.principal import identity_changed, AnonymousIdentity -from functools import wraps -from collections import defaultdict - -from data import model -from data.queue import dockerfile_build_queue -from data.plans import PLANS, get_plan -from app import app -from util.email import send_confirmation_email, send_recovery_email, send_change_email -from util.names import parse_repository_name, format_robot_username -from util.gravatar import compute_hash - -from auth.permissions import (ReadRepositoryPermission, - ModifyRepositoryPermission, - AdministerRepositoryPermission, - CreateRepositoryPermission, - AdministerOrganizationPermission, - OrganizationMemberPermission, - ViewTeamPermission) -from endpoints.common import common_login, truthy_param -from util.cache import cache_control -from datetime import datetime, timedelta - -store = app.config['STORAGE'] -user_files = app.config['USERFILES'] -build_logs = app.config['BUILDLOGS'] -logger = logging.getLogger(__name__) - -route_data = None - -api = Blueprint('api', __name__) - - -@api.before_request -def csrf_protect(): - if request.method != "GET" and request.method != "HEAD": - token = session.get('_csrf_token', None) - found_token = request.values.get('_csrf_token', None) - - # TODO: add if not token here, once we are sure all sessions have a token. - if token != found_token: - msg = 'CSRF Failure. Session token was %s and request token was %s' - logger.error(msg, token, found_token) - - if not token: - logger.warning('No CSRF token in session.') - - -def request_error(exception=None, **kwargs): - data = kwargs.copy() - if exception: - data['message'] = exception.message - - return make_response(jsonify(data), 400) - - -def get_route_data(): - global route_data - if route_data: - return route_data - - routes = [] - for rule in app.url_map.iter_rules(): - if rule.endpoint.startswith('api.'): - endpoint_method = app.view_functions[rule.endpoint] - is_internal = '__internal_call' in dir(endpoint_method) - is_org_api = '__user_call' in dir(endpoint_method) - methods = list(rule.methods.difference(['HEAD', 'OPTIONS'])) - - route = { - 'name': rule.endpoint[4:], - 'methods': methods, - 'path': rule.rule, - 'parameters': list(rule.arguments) - } - - if is_org_api: - route['user_method'] = endpoint_method.__user_call - - routes.append(route) - - route_data = { - 'endpoints': routes - } - return route_data - - -def log_action(kind, user_or_orgname, metadata={}, repo=None): - performer = current_user.db_user() - model.log_action(kind, user_or_orgname, performer=performer, - ip=request.remote_addr, metadata=metadata, repository=repo) - - -def api_login_required(f): - @wraps(f) - def decorated_view(*args, **kwargs): - if not current_user.is_authenticated(): - abort(401) - - if (current_user and current_user.db_user() and - current_user.db_user().organization): - abort(401) - - if (current_user and current_user.db_user() and - current_user.db_user().robot): - abort(401) - - return f(*args, **kwargs) - return decorated_view - - -def internal_api_call(f): - @wraps(f) - def decorated_view(*args, **kwargs): - return f(*args, **kwargs) - - decorated_view.__internal_call = True - return decorated_view - - -def org_api_call(user_call_name): - def internal_decorator(f): - @wraps(f) - def decorated_view(*args, **kwargs): - return f(*args, **kwargs) - - decorated_view.__user_call = user_call_name - return decorated_view - - return internal_decorator - - -@api.route('/discovery') -def discovery(): - return jsonify(get_route_data()) - - -@api.route('/') -@internal_api_call -def welcome(): - return jsonify({'version': '0.5'}) - - -@api.route('/plans/') -def list_plans(): - return jsonify({ - 'plans': PLANS, - }) - - -def user_view(user): - def org_view(o): - admin_org = AdministerOrganizationPermission(o.username) - return { - 'name': o.username, - 'gravatar': compute_hash(o.email), - 'is_org_admin': admin_org.can(), - 'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can(), - 'preferred_namespace': not (o.stripe_id is None) - } - - organizations = model.get_user_organizations(user.username) - - def login_view(login): - return { - 'service': login.service.name, - 'service_identifier': login.service_ident, - } - - logins = model.list_federated_logins(user) - - return { - 'verified': user.verified, - 'anonymous': False, - 'username': user.username, - 'email': user.email, - 'gravatar': compute_hash(user.email), - 'askForPassword': user.password_hash is None, - 'organizations': [org_view(o) for o in organizations], - 'logins': [login_view(login) for login in logins], - 'can_create_repo': True, - 'invoice_email': user.invoice_email, - 'preferred_namespace': not (user.stripe_id is None) - } - - -@api.route('/user/', methods=['GET']) -@internal_api_call -def get_logged_in_user(): - if current_user.is_anonymous(): - return jsonify({'anonymous': True}) - - user = current_user.db_user() - if not user or user.organization: - return jsonify({'anonymous': True}) - - return jsonify(user_view(user)) - - -@api.route('/user/private', methods=['GET']) -@api_login_required -@internal_api_call -def get_user_private_count(): - user = current_user.db_user() - private_repos = model.get_private_repo_count(user.username) - repos_allowed = 0 - - if user.stripe_id: - cus = stripe.Customer.retrieve(user.stripe_id) - if cus.subscription: - plan = get_plan(cus.subscription.plan.id) - if plan: - repos_allowed = plan['privateRepos'] - - return jsonify({ - 'privateCount': private_repos, - 'reposAllowed': repos_allowed - }) - - -@api.route('/user/convert', methods=['POST']) -@api_login_required -@internal_api_call -def convert_user_to_organization(): - user = current_user.db_user() - convert_data = request.get_json() - - # Ensure that the new admin user is the not user being converted. - admin_username = convert_data['adminUser'] - if admin_username == user.username: - return request_error(reason='invaliduser', - message='The admin user is not valid') - - # Ensure that the sign in credentials work. - admin_password = convert_data['adminPassword'] - if not model.verify_user(admin_username, admin_password): - return request_error(reason='invaliduser', - message='The admin user credentials are not valid') - - # Subscribe the organization to the new plan. - plan = convert_data['plan'] - subscribe(user, plan, None, True) # Require business plans - - # Convert the user to an organization. - model.convert_user_to_organization(user, model.get_user(admin_username)) - log_action('account_convert', user.username) - - # And finally login with the admin credentials. - return conduct_signin(admin_username, admin_password) - - -@api.route('/user/', methods=['PUT']) -@api_login_required -@internal_api_call -def change_user_details(): - user = current_user.db_user() - user_data = request.get_json() - - try: - if 'password' in user_data: - logger.debug('Changing password for user: %s', user.username) - log_action('account_change_password', user.username) - model.change_password(user, user_data['password']) - - if 'invoice_email' in user_data: - logger.debug('Changing invoice_email for user: %s', user.username) - model.change_invoice_email(user, user_data['invoice_email']) - - if 'email' in user_data and user_data['email'] != user.email: - new_email = user_data['email'] - if model.find_user_by_email(new_email): - # Email already used. - return request_error(message='E-mail address already used') - - logger.debug('Sending email to change email address for user: %s', - user.username) - code = model.create_confirm_email_code(user, new_email=new_email) - send_change_email(user.username, user_data['email'], code.code) - - except model.InvalidPasswordException, ex: - return request_error(exception=ex) - - return jsonify(user_view(user)) - - -@api.route('/user/', methods=['POST']) -@internal_api_call -def create_new_user(): - user_data = request.get_json() - - existing_user = model.get_user(user_data['username']) - if existing_user: - return request_error(message='The username already exists') - - try: - new_user = model.create_user(user_data['username'], user_data['password'], - user_data['email']) - code = model.create_confirm_email_code(new_user) - send_confirmation_email(new_user.username, new_user.email, code.code) - return make_response('Created', 201) - except model.DataModelException as ex: - return request_error(exception=ex) - - -@api.route('/signin', methods=['POST']) -@internal_api_call -def signin_user(): - signin_data = request.get_json() - if not signin_data: - abort(404) - - username = signin_data['username'] - password = signin_data['password'] - - return conduct_signin(username, password) - - -def conduct_signin(username_or_email, password): - needs_email_verification = False - invalid_credentials = False - - verified = model.verify_user(username_or_email, password) - if verified: - if common_login(verified): - return jsonify({'success': True}) - else: - needs_email_verification = True - - else: - invalid_credentials = True - - response = jsonify({ - 'needsEmailVerification': needs_email_verification, - 'invalidCredentials': invalid_credentials, - }) - response.status_code = 403 - return response - - -@api.route("/signout", methods=['POST']) -@api_login_required -@internal_api_call -def logout(): - logout_user() - identity_changed.send(app, identity=AnonymousIdentity()) - return jsonify({'success': True}) - - -@api.route("/recovery", methods=['POST']) -@internal_api_call -def request_recovery_email(): - email = request.get_json()['email'] - code = model.create_reset_password_email_code(email) - send_recovery_email(email, code.code) - return make_response('Created', 201) - - -@api.route('/users/', methods=['GET']) -@api_login_required -def get_matching_users(prefix): - users = model.get_matching_users(prefix) - - return jsonify({ - 'users': [user.username for user in users] - }) - - -@api.route('/entities/', methods=['GET']) -@api_login_required -def get_matching_entities(prefix): - teams = [] - - namespace_name = request.args.get('namespace', '') - robot_namespace = None - organization = None - - try: - organization = model.get_organization(namespace_name) - - # namespace name was an org - permission = OrganizationMemberPermission(namespace_name) - if permission.can(): - robot_namespace = namespace_name - - if truthy_param(request.args.get('includeTeams', False)): - teams = model.get_matching_teams(prefix, organization) - - except model.InvalidOrganizationException: - # namespace name was a user - if current_user.db_user().username == namespace_name: - robot_namespace = namespace_name - - users = model.get_matching_users(prefix, robot_namespace, organization) - - def entity_team_view(team): - result = { - 'name': team.name, - 'kind': 'team', - 'is_org_member': True - } - return result - - def user_view(user): - user_json = { - 'name': user.username, - 'kind': 'user', - 'is_robot': user.is_robot, - } - - if organization is not None: - user_json['is_org_member'] = user.is_robot or user.is_org_member - - return user_json - - team_data = [entity_team_view(team) for team in teams] - user_data = [user_view(user) for user in users] - - return jsonify({ - 'results': team_data + user_data - }) - - -def team_view(orgname, team): - view_permission = ViewTeamPermission(orgname, team.name) - role = model.get_team_org_role(team).name - return { - 'id': team.id, - 'name': team.name, - 'description': team.description, - 'can_view': view_permission.can(), - 'role': role - } - - -@api.route('/organization/', methods=['POST']) -@api_login_required -@internal_api_call -def create_organization(): - org_data = request.get_json() - existing = None - - try: - existing = model.get_organization(org_data['name']) - except model.InvalidOrganizationException: - pass - - if not existing: - try: - existing = model.get_user(org_data['name']) - except model.InvalidUserException: - pass - - if existing: - msg = 'A user or organization with this name already exists' - return request_error(message=msg) - - try: - model.create_organization(org_data['name'], org_data['email'], - current_user.db_user()) - return make_response('Created', 201) - except model.DataModelException as ex: - return request_error(exception=ex) - - -def org_view(o, teams): - admin_org = AdministerOrganizationPermission(o.username) - is_admin = admin_org.can() - view = { - 'name': o.username, - 'email': o.email if is_admin else '', - 'gravatar': compute_hash(o.email), - 'teams': {t.name : team_view(o.username, t) for t in teams}, - 'is_admin': is_admin - } - - if is_admin: - view['invoice_email'] = o.invoice_email - - return view - - -@api.route('/organization/', methods=['GET']) -@api_login_required -def get_organization(orgname): - permission = OrganizationMemberPermission(orgname) - if permission.can(): - try: - org = model.get_organization(orgname) - except model.InvalidOrganizationException: - abort(404) - - teams = model.get_teams_within_org(org) - return jsonify(org_view(org, teams)) - - abort(403) - - -@api.route('/organization/', methods=['PUT']) -@api_login_required -@org_api_call('change_user_details') -def change_organization_details(orgname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - try: - org = model.get_organization(orgname) - except model.InvalidOrganizationException: - abort(404) - - org_data = request.get_json() - if 'invoice_email' in org_data: - logger.debug('Changing invoice_email for organization: %s', org.username) - model.change_invoice_email(org, org_data['invoice_email']) - - if 'email' in org_data and org_data['email'] != org.email: - new_email = org_data['email'] - if model.find_user_by_email(new_email): - return request_error(message='E-mail address already used') - - logger.debug('Changing email address for organization: %s', org.username) - model.update_email(org, new_email) - - teams = model.get_teams_within_org(org) - return jsonify(org_view(org, teams)) - - abort(403) - -def prototype_view(proto, org_members): - def prototype_user_view(user): - return { - 'name': user.username, - 'is_robot': user.robot, - 'kind': 'user', - 'is_org_member': user.robot or user.username in org_members, - } - - if proto.delegate_user: - delegate_view = prototype_user_view(proto.delegate_user) - else: - delegate_view = { - 'name': proto.delegate_team.name, - 'kind': 'team', - } - - return { - 'activating_user': prototype_user_view(proto.activating_user) if proto.activating_user else None, - 'delegate': delegate_view, - 'role': proto.role.name, - 'id': proto.uuid, - } - -@api.route('/organization//prototypes', methods=['GET']) -@api_login_required -def get_organization_prototype_permissions(orgname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - try: - org = model.get_organization(orgname) - except model.InvalidOrganizationException: - abort(404) - - permissions = model.get_prototype_permissions(org) - org_members = model.get_organization_member_set(orgname) - return jsonify({'prototypes': [prototype_view(p, org_members) - for p in permissions]}) - - abort(403) - - -def log_prototype_action(action_kind, orgname, prototype, **kwargs): - username = current_user.db_user().username - log_params = { - 'prototypeid': prototype.uuid, - 'username': username, - 'activating_username': prototype.activating_user.username if prototype.activating_user else None, - 'role': prototype.role.name - } - - for key, value in kwargs.items(): - log_params[key] = value - - if prototype.delegate_user: - log_params['delegate_user'] = prototype.delegate_user.username - elif prototype.delegate_team: - log_params['delegate_team'] = prototype.delegate_team.name - - log_action(action_kind, orgname, log_params) - - -@api.route('/organization//prototypes', methods=['POST']) -@api_login_required -def create_organization_prototype_permission(orgname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - try: - org = model.get_organization(orgname) - except model.InvalidOrganizationException: - abort(404) - - details = request.get_json() - activating_username = None - - if ('activating_user' in details and details['activating_user'] and - 'name' in details['activating_user']): - activating_username = details['activating_user']['name'] - - delegate = details['delegate'] if 'delegate' in details else {} - delegate_kind = delegate.get('kind', None) - delegate_name = delegate.get('name', None) - - delegate_username = delegate_name if delegate_kind == 'user' else None - delegate_teamname = delegate_name if delegate_kind == 'team' else None - - activating_user = (model.get_user(activating_username) - if activating_username else None) - delegate_user = (model.get_user(delegate_username) - if delegate_username else None) - delegate_team = (model.get_organization_team(orgname, delegate_teamname) - if delegate_teamname else None) - - if activating_username and not activating_user: - return request_error(message='Unknown activating user') - - if not delegate_user and not delegate_team: - return request_error(message='Missing delegate user or team') - - role_name = details['role'] - - prototype = model.add_prototype_permission(org, role_name, activating_user, - delegate_user, delegate_team) - log_prototype_action('create_prototype_permission', orgname, prototype) - org_members = model.get_organization_member_set(orgname) - return jsonify(prototype_view(prototype, org_members)) - - abort(403) - - -@api.route('/organization//prototypes/', - methods=['DELETE']) -@api_login_required -def delete_organization_prototype_permission(orgname, prototypeid): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - try: - org = model.get_organization(orgname) - except model.InvalidOrganizationException: - abort(404) - - prototype = model.delete_prototype_permission(org, prototypeid) - if not prototype: - abort(404) - - log_prototype_action('delete_prototype_permission', orgname, prototype) - - return make_response('Deleted', 204) - - abort(403) - - -@api.route('/organization//prototypes/', - methods=['PUT']) -@api_login_required -def update_organization_prototype_permission(orgname, prototypeid): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - try: - org = model.get_organization(orgname) - except model.InvalidOrganizationException: - abort(404) - - existing = model.get_prototype_permission(org, prototypeid) - if not existing: - abort(404) - - details = request.get_json() - role_name = details['role'] - prototype = model.update_prototype_permission(org, prototypeid, role_name) - if not prototype: - abort(404) - - log_prototype_action('modify_prototype_permission', orgname, prototype, - original_role=existing.role.name) - org_members = model.get_organization_member_set(orgname) - return jsonify(prototype_view(prototype, org_members)) - - abort(403) - - -@api.route('/organization//members', methods=['GET']) -@api_login_required -def get_organization_members(orgname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - try: - org = model.get_organization(orgname) - except model.InvalidOrganizationException: - abort(404) - - # 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.get_organization_members_with_teams(org) - for member in members: - if not member.user.username in members_dict: - members_dict[member.user.username] = {'name': member.user.username, - 'kind': 'user', - 'is_robot': member.user.robot, - 'teams': []} - - members_dict[member.user.username]['teams'].append(member.team.name) - - return jsonify({'members': members_dict}) - - abort(403) - - -@api.route('/organization//members/', methods=['GET']) -@api_login_required -def get_organization_member(orgname, membername): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - try: - org = model.get_organization(orgname) - except model.InvalidOrganizationException: - abort(404) - - member_dict = None - member_teams = model.get_organization_members_with_teams(org, membername=membername) - for member in member_teams: - if not member_dict: - member_dict = {'name': member.user.username, - 'kind': 'user', - 'is_robot': member.user.robot, - 'teams': []} - - member_dict['teams'].append(member.team.name) - - if not member_dict: - abort(404) - - return jsonify({'member': member_dict}) - - abort(403) - - -@api.route('/organization//private', methods=['GET']) -@api_login_required -@internal_api_call -def get_organization_private_allowed(orgname): - permission = CreateRepositoryPermission(orgname) - if permission.can(): - organization = model.get_organization(orgname) - - private_repos = model.get_private_repo_count(organization.username) - 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'] - - return jsonify({ - 'privateAllowed': (private_repos < repos_allowed) - }) - - return jsonify({ - 'privateAllowed': False - }) - - abort(403) - - -def member_view(member): - return { - 'name': member.username, - 'kind': 'user', - 'is_robot': member.robot, - } - - -@api.route('/organization//team/', - methods=['PUT', 'POST']) -@api_login_required -def update_organization_team(orgname, teamname): - edit_permission = AdministerOrganizationPermission(orgname) - if edit_permission.can(): - team = None - - details = request.get_json() - is_existing = False - try: - team = model.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.get_organization(orgname) - team = model.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 = model.get_team_org_role(team).name - if role != details['role']: - team = model.set_team_org_permission(team, details['role'], - current_user.db_user().username) - log_action('org_set_team_role', orgname, - {'team': teamname, 'role': details['role']}) - - resp = jsonify(team_view(orgname, team)) - if not is_existing: - resp.status_code = 201 - return resp - - abort(403) - - -@api.route('/organization//team/', - methods=['DELETE']) -@api_login_required -def delete_organization_team(orgname, teamname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - model.remove_team(orgname, teamname, current_user.db_user().username) - log_action('org_delete_team', orgname, {'team': teamname}) - return make_response('Deleted', 204) - - abort(403) - - -@api.route('/organization//team//members', - methods=['GET']) -@api_login_required -def get_organization_team_members(orgname, teamname): - view_permission = ViewTeamPermission(orgname, teamname) - edit_permission = AdministerOrganizationPermission(orgname) - - if view_permission.can(): - team = None - try: - team = model.get_organization_team(orgname, teamname) - except model.InvalidTeamException: - abort(404) - - members = model.get_organization_team_members(team.id) - return jsonify({ - 'members': { m.username : member_view(m) for m in members }, - 'can_edit': edit_permission.can() - }) - - abort(403) - - -@api.route('/organization//team//members/', - methods=['PUT', 'POST']) -@api_login_required -def update_organization_team_member(orgname, teamname, membername): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - team = None - user = None - - # Find the team. - try: - team = model.get_organization_team(orgname, teamname) - except model.InvalidTeamException: - abort(404) - - # Find the user. - user = model.get_user(membername) - if not user: - return request_error(message='Unknown user') - - # Add the user to the team. - model.add_user_to_team(user, team) - log_action('org_add_team_member', orgname, - {'member': membername, 'team': teamname}) - return jsonify(member_view(user)) - - abort(403) - - -@api.route('/organization//team//members/', - methods=['DELETE']) -@api_login_required -def delete_organization_team_member(orgname, teamname, membername): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - # Remote the user from the team. - invoking_user = current_user.db_user().username - model.remove_user_from_team(orgname, teamname, membername, invoking_user) - log_action('org_remove_team_member', orgname, - {'member': membername, 'team': teamname}) - return make_response('Deleted', 204) - - abort(403) - - -@api.route('/repository', methods=['POST']) -@api_login_required -def create_repo(): - owner = current_user.db_user() - req = request.get_json() - namespace_name = req['namespace'] if 'namespace' in req else owner.username - - permission = CreateRepositoryPermission(namespace_name) - if permission.can(): - repository_name = req['repository'] - visibility = req['visibility'] - - existing = model.get_repository(namespace_name, repository_name) - if existing: - return request_error(message='Repository already exists') - - visibility = req['visibility'] - - repo = model.create_repository(namespace_name, repository_name, owner, - visibility) - repo.description = req['description'] - repo.save() - - log_action('create_repo', namespace_name, - {'repo': repository_name, 'namespace': namespace_name}, - repo=repo) - return jsonify({ - 'namespace': namespace_name, - 'name': repository_name - }) - - abort(403) - - -@api.route('/find/repository', methods=['GET']) -def find_repos(): - prefix = request.args.get('query', '') - - def repo_view(repo): - return { - 'namespace': repo.namespace, - 'name': repo.name, - 'description': repo.description - } - - username = None - if current_user.is_authenticated(): - username = current_user.db_user().username - - matching = model.get_matching_repositories(prefix, username) - response = { - 'repositories': [repo_view(repo) for repo in matching] - } - - return jsonify(response) - - -@api.route('/repository/', methods=['GET']) -def list_repos(): - def repo_view(repo_obj): - return { - 'namespace': repo_obj.namespace, - 'name': repo_obj.name, - 'description': repo_obj.description, - 'is_public': repo_obj.visibility.name == 'public', - } - - page = request.args.get('page', None) - limit = request.args.get('limit', None) - namespace_filter = request.args.get('namespace', None) - include_public = truthy_param(request.args.get('public', True)) - include_private = truthy_param(request.args.get('private', True)) - sort = truthy_param(request.args.get('sort', False)) - include_count = truthy_param(request.args.get('count', False)) - - try: - limit = int(limit) if limit else None - except TypeError: - limit = None - - if page: - try: - page = int(page) - except Exception: - page = None - - username = None - if current_user.is_authenticated() and include_private: - username = current_user.db_user().username - - repo_count = None - if include_count: - repo_count = model.get_visible_repository_count(username, - include_public=include_public, - namespace=namespace_filter) - - repo_query = model.get_visible_repositories(username, limit=limit, page=page, - include_public=include_public, - sort=sort, - namespace=namespace_filter) - - repos = [repo_view(repo) for repo in repo_query] - response = { - 'repositories': repos - } - - if include_count: - response['count'] = repo_count - - return jsonify(response) - - -@api.route('/repository/', methods=['PUT']) -@api_login_required -@parse_repository_name -def update_repo(namespace, repository): - permission = ModifyRepositoryPermission(namespace, repository) - if permission.can(): - repo = model.get_repository(namespace, repository) - if repo: - values = request.get_json() - repo.description = values['description'] - repo.save() - - log_action('set_repo_description', namespace, - {'repo': repository, 'description': values['description']}, - repo=repo) - return jsonify({ - 'success': True - }) - - abort(403) - - -@api.route('/repository//changevisibility', - methods=['POST']) -@api_login_required -@parse_repository_name -def change_repo_visibility(namespace, repository): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - repo = model.get_repository(namespace, repository) - if repo: - values = request.get_json() - model.set_repository_visibility(repo, values['visibility']) - log_action('change_repo_visibility', namespace, - {'repo': repository, 'visibility': values['visibility']}, - repo=repo) - return jsonify({ - 'success': True - }) - - abort(403) - - -@api.route('/repository/', methods=['DELETE']) -@api_login_required -@parse_repository_name -def delete_repository(namespace, repository): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - model.purge_repository(namespace, repository) - log_action('delete_repo', namespace, - {'repo': repository, 'namespace': namespace}) - return make_response('Deleted', 204) - - abort(403) - - -def image_view(image): - extended_props = image - if image.storage and image.storage.id: - extended_props = image.storage - - command = extended_props.command - return { - 'id': image.docker_image_id, - 'created': extended_props.created, - 'comment': extended_props.comment, - 'command': json.loads(command) if command else None, - 'ancestors': image.ancestors, - 'dbid': image.id, - 'size': extended_props.image_size, - } - - -@api.route('/repository/', methods=['GET']) -@parse_repository_name -def get_repo(namespace, repository): - logger.debug('Get repo: %s/%s' % (namespace, repository)) - - def tag_view(tag): - image = model.get_tag_image(namespace, repository, tag.name) - if not image: - return {} - - return { - 'name': tag.name, - 'image': image_view(image), - } - - organization = None - try: - organization = model.get_organization(namespace) - except model.InvalidOrganizationException: - pass - - permission = ReadRepositoryPermission(namespace, repository) - is_public = model.repository_is_public(namespace, repository) - if permission.can() or is_public: - repo = model.get_repository(namespace, repository) - if repo: - tags = model.list_repository_tags(namespace, repository) - tag_dict = {tag.name: tag_view(tag) for tag in tags} - can_write = ModifyRepositoryPermission(namespace, repository).can() - can_admin = AdministerRepositoryPermission(namespace, repository).can() - active_builds = model.list_repository_builds(namespace, repository, - include_inactive=False) - - return jsonify({ - 'namespace': namespace, - 'name': repository, - 'description': repo.description, - 'tags': tag_dict, - 'can_write': can_write, - 'can_admin': can_admin, - 'is_public': is_public, - 'is_building': len(active_builds) > 0, - 'is_organization': bool(organization) - }) - - abort(404) # Not found - abort(403) # Permission denied - - -def build_status_view(build_obj, can_write=False): - status = build_logs.get_status(build_obj.uuid) - return { - 'id': build_obj.uuid, - 'phase': build_obj.phase, - 'started': build_obj.started, - 'display_name': build_obj.display_name, - 'status': status, - 'resource_key': build_obj.resource_key if can_write else None, - 'is_writer': can_write - } - - -@api.route('/repository//build/', methods=['GET']) -@parse_repository_name -def get_repo_builds(namespace, repository): - permission = ReadRepositoryPermission(namespace, repository) - is_public = model.repository_is_public(namespace, repository) - if permission.can() or is_public: - can_write = ModifyRepositoryPermission(namespace, repository).can() - builds = model.list_repository_builds(namespace, repository) - return jsonify({ - 'builds': [build_status_view(build, can_write) for build in builds] - }) - - abort(403) # Permission denied - - -@api.route('/repository//build//status', - methods=['GET']) -@parse_repository_name -def get_repo_build_status(namespace, repository, build_uuid): - permission = ReadRepositoryPermission(namespace, repository) - is_public = model.repository_is_public(namespace, repository) - if permission.can() or is_public: - build = model.get_repository_build(namespace, repository, build_uuid) - if not build: - abort(404) - - can_write = ModifyRepositoryPermission(namespace, repository).can() - return jsonify(build_status_view(build, can_write)) - - abort(403) # Permission denied - - -@api.route('/repository//build//archiveurl', - methods=['GET']) -@parse_repository_name -def get_repo_build_archive_url(namespace, repository, build_uuid): - permission = ModifyRepositoryPermission(namespace, repository) - if permission.can(): - build = model.get_repository_build(namespace, repository, build_uuid) - if not build: - abort(404) - - url = user_files.get_file_url(build.resource_key) - return jsonify({ - 'url': url - }) - - abort(403) # Permission denied - - -@api.route('/repository//build//logs', - methods=['GET']) -@parse_repository_name -def get_repo_build_logs(namespace, repository, build_uuid): - permission = ModifyRepositoryPermission(namespace, repository) - if permission.can(): - response_obj = {} - - build = model.get_repository_build(namespace, repository, build_uuid) - - start = int(request.args.get('start', 0)) - - count, logs = build_logs.get_log_entries(build.uuid, start) - - response_obj.update({ - 'start': start, - 'total': count, - 'logs': [log for log in logs], - }) - - return jsonify(response_obj) - - abort(403) # Permission denied - - -@api.route('/repository//build/', methods=['POST']) -@api_login_required -@parse_repository_name -def request_repo_build(namespace, repository): - permission = ModifyRepositoryPermission(namespace, repository) - if permission.can(): - logger.debug('User requested repository initialization.') - dockerfile_id = request.get_json()['file_id'] - - # Check if the dockerfile resource has already been used. If so, then it can only be reused if the - # user has access to the repository for which it was used. - associated_repository = model.get_repository_for_resource(dockerfile_id) - if associated_repository: - if not ModifyRepositoryPermission(associated_repository.namespace, associated_repository.name): - abort(403) - - # Start the build. - repo = model.get_repository(namespace, repository) - token = model.create_access_token(repo, 'write') - display_name = user_files.get_file_checksum(dockerfile_id) - logger.debug('**********Md5: %s' % display_name) - - host = urlparse.urlparse(request.url).netloc - tag = '%s/%s/%s' % (host, repo.namespace, repo.name) - build_request = model.create_repository_build(repo, token, dockerfile_id, - tag, display_name) - dockerfile_build_queue.put(json.dumps({ - 'build_uuid': build_request.uuid, - 'namespace': namespace, - 'repository': repository, - }), retries_remaining=1) - - log_action('build_dockerfile', namespace, - {'repo': repository, 'namespace': namespace, - 'fileid': dockerfile_id}, repo=repo) - - resp = jsonify(build_status_view(build_request, True)) - repo_string = '%s/%s' % (namespace, repository) - resp.headers['Location'] = url_for('api.get_repo_build_status', - repository=repo_string, - build_uuid=build_request.uuid) - resp.status_code = 201 - return resp - - abort(403) # Permissions denied - - -def webhook_view(webhook): - return { - 'public_id': webhook.public_id, - 'parameters': json.loads(webhook.parameters), - } - - -@api.route('/repository//webhook/', methods=['POST']) -@api_login_required -@parse_repository_name -def create_webhook(namespace, repository): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - repo = model.get_repository(namespace, repository) - webhook = model.create_webhook(repo, request.get_json()) - resp = jsonify(webhook_view(webhook)) - repo_string = '%s/%s' % (namespace, repository) - resp.headers['Location'] = url_for('api.get_webhook', - repository=repo_string, - public_id=webhook.public_id) - log_action('add_repo_webhook', namespace, - {'repo': repository, 'webhook_id': webhook.public_id}, - repo=repo) - return resp - - abort(403) # Permissions denied - - -@api.route('/repository//webhook/', - methods=['GET']) -@api_login_required -@parse_repository_name -def get_webhook(namespace, repository, public_id): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - try: - webhook = model.get_webhook(namespace, repository, public_id) - except model.InvalidWebhookException: - abort(404) - - return jsonify(webhook_view(webhook)) - - abort(403) # Permission denied - - -@api.route('/repository//webhook/', methods=['GET']) -@api_login_required -@parse_repository_name -def list_webhooks(namespace, repository): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - webhooks = model.list_webhooks(namespace, repository) - return jsonify({ - 'webhooks': [webhook_view(webhook) for webhook in webhooks] - }) - - abort(403) # Permission denied - - -@api.route('/repository//webhook/', - methods=['DELETE']) -@api_login_required -@parse_repository_name -def delete_webhook(namespace, repository, public_id): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - model.delete_webhook(namespace, repository, public_id) - log_action('delete_repo_webhook', namespace, - {'repo': repository, 'webhook_id': public_id}, - repo=model.get_repository(namespace, repository)) - return make_response('No Content', 204) - - abort(403) # Permission denied - - -@api.route('/filedrop/', methods=['POST']) -@api_login_required -@internal_api_call -def get_filedrop_url(): - mime_type = request.get_json()['mimeType'] - (url, file_id) = user_files.prepare_for_drop(mime_type) - return jsonify({ - 'url': url, - 'file_id': file_id - }) - -def role_view(repo_perm_obj): - return { - 'role': repo_perm_obj.role.name, - } - - -def wrap_role_view_user(role_json, user): - role_json['is_robot'] = user.robot - return role_json - - -def wrap_role_view_org(role_json, user, org_members): - role_json['is_org_member'] = user.robot or user.username in org_members - return role_json - - -@api.route('/repository//image/', methods=['GET']) -@parse_repository_name -def list_repository_images(namespace, repository): - permission = ReadRepositoryPermission(namespace, repository) - if permission.can() or model.repository_is_public(namespace, repository): - all_images = model.get_repository_images(namespace, repository) - all_tags = model.list_repository_tags(namespace, repository) - - tags_by_image_id = defaultdict(list) - for tag in all_tags: - tags_by_image_id[tag.image.docker_image_id].append(tag.name) - - - def add_tags(image_json): - image_json['tags'] = tags_by_image_id[image_json['id']] - return image_json - - - return jsonify({ - 'images': [add_tags(image_view(image)) for image in all_images] - }) - - abort(403) - - -@api.route('/repository//image/', - methods=['GET']) -@parse_repository_name -def get_image(namespace, repository, image_id): - permission = ReadRepositoryPermission(namespace, repository) - if permission.can() or model.repository_is_public(namespace, repository): - image = model.get_repo_image(namespace, repository, image_id) - if not image: - abort(404) - - return jsonify(image_view(image)) - abort(403) - - -@api.route('/repository//image//changes', - methods=['GET']) -@cache_control(max_age=60*60) # Cache for one hour -@parse_repository_name -def get_image_changes(namespace, repository, image_id): - permission = ReadRepositoryPermission(namespace, repository) - if permission.can() or model.repository_is_public(namespace, repository): - image = model.get_repo_image(namespace, repository, image_id) - - if not image: - abort(404) - - uuid = image.storage and image.storage.uuid - diffs_path = store.image_file_diffs_path(namespace, repository, image_id, - uuid) - - try: - response_json = store.get_content(diffs_path) - return make_response(response_json) - except IOError: - abort(404) - - abort(403) - - -@api.route('/repository//tag/', - methods=['PUT']) -@parse_repository_name -def change_tag_image(namespace, repository, tag): - permission = ModifyRepositoryPermission(namespace, repository) - if permission.can(): - image_id = request.get_json()['image'] - image = model.get_repo_image(namespace, repository, image_id) - if not image: - abort(404) - - original_image_id = None - try: - original_tag_image = model.get_tag_image(namespace, repository, tag) - if original_tag_image: - original_image_id = original_tag_image.docker_image_id - except model.DataModelException: - # This is a new tag. - pass - - model.create_or_update_tag(namespace, repository, tag, image_id) - model.garbage_collect_repository(namespace, repository) - - username = current_user.db_user().username - log_action('move_tag' if original_image_id else 'create_tag', namespace, - { 'username': username, 'repo': repository, 'tag': tag, - 'image': image_id, 'original_image': original_image_id }, - repo=model.get_repository(namespace, repository)) - - return make_response('Updated', 201) - - abort(403) # Permission denied - - -@api.route('/repository//tag/', - methods=['DELETE']) -@parse_repository_name -def delete_full_tag(namespace, repository, tag): - permission = ModifyRepositoryPermission(namespace, repository) - if permission.can(): - model.delete_tag(namespace, repository, tag) - model.garbage_collect_repository(namespace, repository) - - username = current_user.db_user().username - log_action('delete_tag', namespace, - {'username': username, 'repo': repository, 'tag': tag}, - repo=model.get_repository(namespace, repository)) - - return make_response('Deleted', 204) - - abort(403) # Permission denied - - -@api.route('/repository//tag//images', - methods=['GET']) -@parse_repository_name -def list_tag_images(namespace, repository, tag): - permission = ReadRepositoryPermission(namespace, repository) - if permission.can() or model.repository_is_public(namespace, repository): - try: - tag_image = model.get_tag_image(namespace, repository, tag) - except model.DataModelException: - abort(404) - - parent_images = model.get_parent_images(tag_image) - - parents = list(parent_images) - parents.reverse() - all_images = [tag_image] + parents - - return jsonify({ - 'images': [image_view(image) for image in all_images] - }) - - abort(403) # Permission denied - - -@api.route('/repository//permissions/team/', - methods=['GET']) -@api_login_required -@parse_repository_name -def list_repo_team_permissions(namespace, repository): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - repo_perms = model.get_all_repo_teams(namespace, repository) - - return jsonify({ - 'permissions': {repo_perm.team.name: role_view(repo_perm) - for repo_perm in repo_perms} - }) - - abort(403) # Permission denied - - -@api.route('/repository//permissions/user/', - methods=['GET']) -@api_login_required -@parse_repository_name -def list_repo_user_permissions(namespace, repository): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - # Lookup the organization (if any). - org = None - try: - org = model.get_organization(namespace) # Will raise an error if not org - except model.InvalidOrganizationException: - # This repository isn't under an org - pass - - # Determine how to wrap the role(s). - def wrapped_role_view(repo_perm): - return wrap_role_view_user(role_view(repo_perm), repo_perm.user) - - role_view_func = wrapped_role_view - - if org: - org_members = model.get_organization_member_set(namespace) - current_func = role_view_func - - def wrapped_role_org_view(repo_perm): - return wrap_role_view_org(current_func(repo_perm), repo_perm.user, - org_members) - - role_view_func = wrapped_role_org_view - - # Load and return the permissions. - repo_perms = model.get_all_repo_users(namespace, repository) - return jsonify({ - 'permissions': {perm.user.username: role_view_func(perm) - for perm in repo_perms} - }) - - abort(403) # Permission denied - - -@api.route('/repository//permissions/user/', - methods=['GET']) -@api_login_required -@parse_repository_name -def get_user_permissions(namespace, repository, username): - logger.debug('Get repo: %s/%s permissions for user %s' % - (namespace, repository, username)) - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - perm = model.get_user_reponame_permission(username, namespace, repository) - perm_view = wrap_role_view_user(role_view(perm), perm.user) - - try: - model.get_organization(namespace) - org_members = model.get_organization_member_set(namespace) - perm_view = wrap_role_view_org(perm_view, perm.user, org_members) - except model.InvalidOrganizationException: - # This repository is not part of an organization - pass - - return jsonify(perm_view) - - abort(403) # Permission denied - - -@api.route('/repository//permissions/team/', - methods=['GET']) -@api_login_required -@parse_repository_name -def get_team_permissions(namespace, repository, teamname): - logger.debug('Get repo: %s/%s permissions for team %s' % - (namespace, repository, teamname)) - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - perm = model.get_team_reponame_permission(teamname, namespace, repository) - return jsonify(role_view(perm)) - - abort(403) # Permission denied - - -@api.route('/repository//permissions/user/', - methods=['PUT', 'POST']) -@api_login_required -@parse_repository_name -def change_user_permissions(namespace, repository, username): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - new_permission = request.get_json() - - logger.debug('Setting permission to: %s for user %s' % - (new_permission['role'], username)) - - perm = model.set_user_repo_permission(username, namespace, repository, - new_permission['role']) - perm_view = wrap_role_view_user(role_view(perm), perm.user) - - try: - model.get_organization(namespace) - org_members = model.get_organization_member_set(namespace) - perm_view = wrap_role_view_org(perm_view, perm.user, org_members) - except model.InvalidOrganizationException: - # This repository is not part of an organization - pass - except model.DataModelException as ex: - return request_error(exception=ex) - - log_action('change_repo_permission', namespace, - {'username': username, 'repo': repository, - 'role': new_permission['role']}, - repo=model.get_repository(namespace, repository)) - - resp = jsonify(perm_view) - if request.method == 'POST': - resp.status_code = 201 - return resp - - abort(403) # Permission denied - - -@api.route('/repository//permissions/team/', - methods=['PUT', 'POST']) -@api_login_required -@parse_repository_name -def change_team_permissions(namespace, repository, teamname): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - new_permission = request.get_json() - - logger.debug('Setting permission to: %s for team %s' % - (new_permission['role'], teamname)) - - perm = model.set_team_repo_permission(teamname, namespace, repository, - new_permission['role']) - - log_action('change_repo_permission', namespace, - {'team': teamname, 'repo': repository, - 'role': new_permission['role']}, - repo=model.get_repository(namespace, repository)) - - resp = jsonify(role_view(perm)) - if request.method == 'POST': - resp.status_code = 201 - return resp - - abort(403) # Permission denied - - -@api.route('/repository//permissions/user/', - methods=['DELETE']) -@api_login_required -@parse_repository_name -def delete_user_permissions(namespace, repository, username): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - try: - model.delete_user_permission(username, namespace, repository) - except model.DataModelException as ex: - return request_error(exception=ex) - - log_action('delete_repo_permission', namespace, - {'username': username, 'repo': repository}, - repo=model.get_repository(namespace, repository)) - - return make_response('Deleted', 204) - - abort(403) # Permission denied - - -@api.route('/repository//permissions/team/', - methods=['DELETE']) -@api_login_required -@parse_repository_name -def delete_team_permissions(namespace, repository, teamname): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - model.delete_team_permission(teamname, namespace, repository) - - log_action('delete_repo_permission', namespace, - {'team': teamname, 'repo': repository}, - repo=model.get_repository(namespace, repository)) - - return make_response('Deleted', 204) - - abort(403) # Permission denied - - -def token_view(token_obj): - return { - 'friendlyName': token_obj.friendly_name, - 'code': token_obj.code, - 'role': token_obj.role.name, - } - - -@api.route('/repository//tokens/', methods=['GET']) -@api_login_required -@parse_repository_name -def list_repo_tokens(namespace, repository): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - tokens = model.get_repository_delegate_tokens(namespace, repository) - - return jsonify({ - 'tokens': {token.code: token_view(token) for token in tokens} - }) - - abort(403) # Permission denied - - -@api.route('/repository//tokens/', methods=['GET']) -@api_login_required -@parse_repository_name -def get_tokens(namespace, repository, code): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - try: - perm = model.get_repo_delegate_token(namespace, repository, code) - except model.InvalidTokenException: - abort(404) - - return jsonify(token_view(perm)) - - abort(403) # Permission denied - - -@api.route('/repository//tokens/', methods=['POST']) -@api_login_required -@parse_repository_name -def create_token(namespace, repository): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - token_params = request.get_json() - - token = model.create_delegate_token(namespace, repository, - token_params['friendlyName']) - - log_action('add_repo_accesstoken', namespace, - {'repo': repository, 'token': token_params['friendlyName']}, - repo = model.get_repository(namespace, repository)) - - resp = jsonify(token_view(token)) - resp.status_code = 201 - return resp - - abort(403) # Permission denied - - -@api.route('/repository//tokens/', methods=['PUT']) -@api_login_required -@parse_repository_name -def change_token(namespace, repository, code): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - new_permission = request.get_json() - - logger.debug('Setting permission to: %s for code %s' % - (new_permission['role'], code)) - - token = model.set_repo_delegate_token_role(namespace, repository, code, - new_permission['role']) - - log_action('change_repo_permission', namespace, - {'repo': repository, 'token': token.friendly_name, 'code': code, - 'role': new_permission['role']}, - repo = model.get_repository(namespace, repository)) - - resp = jsonify(token_view(token)) - return resp - - abort(403) # Permission denied - - -@api.route('/repository//tokens/', - methods=['DELETE']) -@api_login_required -@parse_repository_name -def delete_token(namespace, repository, code): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - token = model.delete_delegate_token(namespace, repository, code) - - log_action('delete_repo_accesstoken', namespace, - {'repo': repository, 'token': token.friendly_name, - 'code': code}, - repo = model.get_repository(namespace, repository)) - - return make_response('Deleted', 204) - - abort(403) # Permission denied - - -def subscription_view(stripe_subscription, used_repos): - return { - 'currentPeriodStart': stripe_subscription.current_period_start, - 'currentPeriodEnd': stripe_subscription.current_period_end, - 'plan': stripe_subscription.plan.id, - 'usedPrivateRepos': used_repos, - } - - -@api.route('/user/card', methods=['GET']) -@api_login_required -@internal_api_call -def get_user_card(): - user = current_user.db_user() - return get_card(user) - - -@api.route('/organization//card', methods=['GET']) -@api_login_required -@internal_api_call -@org_api_call('get_user_card') -def get_org_card(orgname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - organization = model.get_organization(orgname) - return get_card(organization) - - abort(403) - - -@api.route('/user/card', methods=['POST']) -@api_login_required -@internal_api_call -def set_user_card(): - user = current_user.db_user() - token = request.get_json()['token'] - response = set_card(user, token) - log_action('account_change_cc', user.username) - return response - - -@api.route('/organization//card', methods=['POST']) -@api_login_required -@org_api_call('set_user_card') -def set_org_card(orgname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - organization = model.get_organization(orgname) - token = request.get_json()['token'] - response = set_card(organization, token) - log_action('account_change_cc', orgname) - return response - - abort(403) - - -def set_card(user, token): - if user.stripe_id: - cus = stripe.Customer.retrieve(user.stripe_id) - if cus: - try: - cus.card = token - cus.save() - except stripe.CardError as e: - return carderror_response(e) - except stripe.InvalidRequestError as e: - return carderror_response(e) - - return get_card(user) - - -def get_card(user): - card_info = { - 'is_valid': False - } - - if user.stripe_id: - cus = stripe.Customer.retrieve(user.stripe_id) - if cus and cus.default_card: - # Find the default card. - default_card = None - for card in cus.cards.data: - if card.id == cus.default_card: - default_card = card - break - - if default_card: - card_info = { - 'owner': default_card.name, - 'type': default_card.type, - 'last4': default_card.last4 - } - - return jsonify({'card': card_info}) - -@api.route('/user/plan', methods=['PUT']) -@api_login_required -@internal_api_call -def update_user_subscription(): - request_data = request.get_json() - plan = request_data['plan'] - token = request_data['token'] if 'token' in request_data else None - user = current_user.db_user() - return subscribe(user, plan, token, False) # Business features not required - - -def carderror_response(e): - resp = jsonify({ - 'carderror': e.message, - }) - resp.status_code = 402 - return resp - - -def subscribe(user, plan, token, require_business_plan): - plan_found = None - for plan_obj in PLANS: - if plan_obj['stripeId'] == plan: - plan_found = plan_obj - - if not plan_found or plan_found['deprecated']: - logger.warning('Plan not found or deprecated: %s', plan) - abort(404) - - if (require_business_plan and not plan_found['bus_features'] and not - plan_found['price'] == 0): - logger.warning('Business attempting to subscribe to personal plan: %s', - user.username) - return request_error(message='No matching plan found') - - private_repos = model.get_private_repo_count(user.username) - - # This is the default response - response_json = { - 'plan': plan, - 'usedPrivateRepos': private_repos, - } - status_code = 200 - - if not user.stripe_id: - # Check if a non-paying user is trying to subscribe to a free plan - if not plan_found['price'] == 0: - # They want a real paying plan, create the customer and plan - # simultaneously - card = token - - try: - cus = stripe.Customer.create(email=user.email, plan=plan, card=card) - user.stripe_id = cus.id - user.save() - log_action('account_change_plan', user.username, {'plan': plan}) - except stripe.CardError as e: - return carderror_response(e) - - response_json = subscription_view(cus.subscription, private_repos) - status_code = 201 - - else: - # Change the plan - cus = stripe.Customer.retrieve(user.stripe_id) - - if plan_found['price'] == 0: - if cus.subscription is not None: - # We only have to cancel the subscription if they actually have one - cus.cancel_subscription() - cus.save() - log_action('account_change_plan', user.username, {'plan': plan}) - - else: - # User may have been a previous customer who is resubscribing - if token: - cus.card = token - - cus.plan = plan - - try: - cus.save() - except stripe.CardError as e: - return carderror_response(e) - - response_json = subscription_view(cus.subscription, private_repos) - log_action('account_change_plan', user.username, {'plan': plan}) - - resp = jsonify(response_json) - resp.status_code = status_code - return resp - - -@api.route('/user/invoices', methods=['GET']) -@api_login_required -def list_user_invoices(): - user = current_user.db_user() - if not user.stripe_id: - abort(404) - - return get_invoices(user.stripe_id) - - -@api.route('/organization//invoices', methods=['GET']) -@api_login_required -@org_api_call('list_user_invoices') -def list_org_invoices(orgname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - organization = model.get_organization(orgname) - if not organization.stripe_id: - abort(404) - - return get_invoices(organization.stripe_id) - - abort(403) - - -def get_invoices(customer_id): - def invoice_view(i): - return { - 'id': i.id, - 'date': i.date, - 'period_start': i.period_start, - 'period_end': i.period_end, - 'paid': i.paid, - 'amount_due': i.amount_due, - 'next_payment_attempt': i.next_payment_attempt, - 'attempted': i.attempted, - 'closed': i.closed, - 'total': i.total, - 'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None - } - - invoices = stripe.Invoice.all(customer=customer_id, count=12) - return jsonify({ - 'invoices': [invoice_view(i) for i in invoices.data] - }) - - -@api.route('/organization//plan', methods=['PUT']) -@api_login_required -@internal_api_call -@org_api_call('update_user_subscription') -def update_org_subscription(orgname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - request_data = request.get_json() - plan = request_data['plan'] - token = request_data['token'] if 'token' in request_data else None - organization = model.get_organization(orgname) - return subscribe(organization, plan, token, True) # Business plan required - - abort(403) - - -@api.route('/user/plan', methods=['GET']) -@api_login_required -@internal_api_call -def get_user_subscription(): - user = current_user.db_user() - private_repos = model.get_private_repo_count(user.username) - - if user.stripe_id: - cus = stripe.Customer.retrieve(user.stripe_id) - - if cus.subscription: - return jsonify(subscription_view(cus.subscription, private_repos)) - - return jsonify({ - 'plan': 'free', - 'usedPrivateRepos': private_repos, - }) - - -@api.route('/organization//plan', methods=['GET']) -@api_login_required -@internal_api_call -@org_api_call('get_user_subscription') -def get_org_subscription(orgname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - private_repos = model.get_private_repo_count(orgname) - organization = model.get_organization(orgname) - if organization.stripe_id: - cus = stripe.Customer.retrieve(organization.stripe_id) - - if cus.subscription: - return jsonify(subscription_view(cus.subscription, private_repos)) - - return jsonify({ - 'plan': 'free', - 'usedPrivateRepos': private_repos, - }) - - abort(403) - - -def robot_view(name, token): - return { - 'name': name, - 'token': token, - } - - -@api.route('/user/robots', methods=['GET']) -@api_login_required -def get_user_robots(): - user = current_user.db_user() - robots = model.list_entity_robots(user.username) - return jsonify({ - 'robots': [robot_view(name, password) for name, password in robots] - }) - - -@api.route('/organization//robots', methods=['GET']) -@api_login_required -@org_api_call('get_user_robots') -def get_org_robots(orgname): - permission = OrganizationMemberPermission(orgname) - if permission.can(): - robots = model.list_entity_robots(orgname) - return jsonify({ - 'robots': [robot_view(name, password) for name, password in robots] - }) - - abort(403) - - -@api.route('/user/robots/', methods=['PUT']) -@api_login_required -def create_user_robot(robot_shortname): - parent = current_user.db_user() - robot, password = model.create_robot(robot_shortname, parent) - resp = jsonify(robot_view(robot.username, password)) - log_action('create_robot', parent.username, {'robot': robot_shortname}) - resp.status_code = 201 - return resp - - -@api.route('/organization//robots/', - methods=['PUT']) -@api_login_required -@org_api_call('create_user_robot') -def create_org_robot(orgname, robot_shortname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - parent = model.get_organization(orgname) - robot, password = model.create_robot(robot_shortname, parent) - resp = jsonify(robot_view(robot.username, password)) - log_action('create_robot', orgname, {'robot': robot_shortname}) - resp.status_code = 201 - return resp - - abort(403) - - -@api.route('/user/robots/', methods=['DELETE']) -@api_login_required -def delete_user_robot(robot_shortname): - parent = current_user.db_user() - model.delete_robot(format_robot_username(parent.username, robot_shortname)) - log_action('delete_robot', parent.username, {'robot': robot_shortname}) - return make_response('Deleted', 204) - - -@api.route('/organization//robots/', - methods=['DELETE']) -@api_login_required -@org_api_call('delete_user_robot') -def delete_org_robot(orgname, robot_shortname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - model.delete_robot(format_robot_username(orgname, robot_shortname)) - log_action('delete_robot', orgname, {'robot': robot_shortname}) - return make_response('Deleted', 204) - - abort(403) - - -def log_view(log): - view = { - 'kind': log.kind.name, - 'metadata': json.loads(log.metadata_json), - 'ip': log.ip, - 'datetime': log.datetime, - } - - if log.performer: - view['performer'] = { - 'kind': 'user', - 'name': log.performer.username, - 'is_robot': log.performer.robot, - } - - return view - - - -@api.route('/repository//logs', methods=['GET']) -@api_login_required -@parse_repository_name -def list_repo_logs(namespace, repository): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - repo = model.get_repository(namespace, repository) - if not repo: - abort(404) - - start_time = request.args.get('starttime', None) - end_time = request.args.get('endtime', None) - return get_logs(namespace, start_time, end_time, repository=repo) - - abort(403) - - -@api.route('/organization//logs', methods=['GET']) -@api_login_required -@org_api_call('list_user_logs') -def list_org_logs(orgname): - permission = AdministerOrganizationPermission(orgname) - if permission.can(): - performer_name = request.args.get('performer', None) - start_time = request.args.get('starttime', None) - end_time = request.args.get('endtime', None) - - return get_logs(orgname, start_time, end_time, - performer_name=performer_name) - - abort(403) - - -@api.route('/user/logs', methods=['GET']) -@api_login_required -def list_user_logs(): - performer_name = request.args.get('performer', None) - start_time = request.args.get('starttime', None) - end_time = request.args.get('endtime', None) - - return get_logs(current_user.db_user().username, start_time, end_time, - performer_name=performer_name) - - -def get_logs(namespace, start_time, end_time, performer_name=None, - repository=None): - performer = None - if performer_name: - performer = model.get_user(performer_name) - - if start_time: - try: - start_time = datetime.strptime(start_time + ' UTC', '%m/%d/%Y %Z') - except ValueError: - start_time = None - - if not start_time: - start_time = datetime.today() - timedelta(7) # One week - - if end_time: - try: - end_time = datetime.strptime(end_time + ' UTC', '%m/%d/%Y %Z') - end_time = end_time + timedelta(days=1) - except ValueError: - end_time = None - - if not end_time: - end_time = datetime.today() - - logs = model.list_logs(namespace, start_time, end_time, performer=performer, - repository=repository) - return jsonify({ - 'start_time': start_time, - 'end_time': end_time, - 'logs': [log_view(log) for log in logs] - }) - diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py new file mode 100644 index 000000000..60b5d7398 --- /dev/null +++ b/endpoints/api/__init__.py @@ -0,0 +1,281 @@ +import logging +import json + +from flask import Blueprint, request, make_response, jsonify +from flask.ext.restful import Resource, abort, Api, reqparse +from flask.ext.restful.utils.cors import crossdomain +from werkzeug.exceptions import HTTPException +from calendar import timegm +from email.utils import formatdate +from functools import partial, wraps +from jsonschema import validate, ValidationError + +from data import model +from util.names import parse_namespace_repository +from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, + AdministerRepositoryPermission, UserReadPermission, + UserAdminPermission) +from auth import scopes +from auth.auth_context import get_authenticated_user, get_validated_oauth_token +from auth.auth import process_oauth +from endpoints.csrf import csrf_protect + + +logger = logging.getLogger(__name__) +api_bp = Blueprint('api', __name__) +api = Api() +api.init_app(api_bp) +api.decorators = [csrf_protect, + process_oauth, + crossdomain(origin='*', headers=['Authorization', 'Content-Type'])] + + +class ApiException(Exception): + def __init__(self, error_type, status_code, error_description, payload=None): + Exception.__init__(self) + self.error_description = error_description + self.status_code = status_code + self.payload = payload + self.error_type = error_type + + def to_dict(self): + rv = dict(self.payload or ()) + if self.error_description is not None: + rv['error_description'] = self.error_description + + rv['error_type'] = self.error_type + return rv + + +class InvalidRequest(ApiException): + def __init__(self, error_description, payload=None): + ApiException.__init__(self, 'invalid_request', 400, error_description, payload) + + +class InvalidToken(ApiException): + def __init__(self, error_description, payload=None): + ApiException.__init__(self, 'invalid_token', 401, error_description, payload) + + +class Unauthorized(ApiException): + def __init__(self, payload=None): + user = get_authenticated_user() + if user is None or user.organization: + ApiException.__init__(self, 'invalid_token', 401, "Requires authentication", payload) + else: + ApiException.__init__(self, 'insufficient_scope', 403, 'Unauthorized', payload) + + + +class NotFound(ApiException): + def __init__(self, payload=None): + ApiException.__init__(self, None, 404, 'Not Found', payload) + + +@api_bp.app_errorhandler(ApiException) +@crossdomain(origin='*', headers=['Authorization', 'Content-Type']) +def handle_api_error(error): + response = jsonify(error.to_dict()) + response.status_code = error.status_code + if error.error_type is not None: + response.headers['WWW-Authenticate'] = ('Bearer error="%s" error_description="%s"' % + (error.error_type, error.error_description)) + return response + + +def resource(*urls, **kwargs): + def wrapper(api_resource): + api.add_resource(api_resource, *urls, **kwargs) + return api_resource + return wrapper + + +def truthy_bool(param): + return param not in {False, 'false', 'False', '0', 'FALSE', '', 'null'} + + +def format_date(date): + """ Output an RFC822 date format. """ + if date is None: + return None + return formatdate(timegm(date.utctimetuple())) + + +def add_method_metadata(name, value): + def modifier(func): + if '__api_metadata' not in dir(func): + func.__api_metadata = {} + func.__api_metadata[name] = value + return func + return modifier + + +def method_metadata(func, name): + if '__api_metadata' in dir(func): + return func.__api_metadata.get(name, None) + return None + + +nickname = partial(add_method_metadata, 'nickname') +related_user_resource = partial(add_method_metadata, 'related_user_resource') +internal_only = add_method_metadata('internal', True) + + +def query_param(name, help_str, type=reqparse.text_type, default=None, + choices=(), required=False): + def add_param(func): + if '__api_query_params' not in dir(func): + func.__api_query_params = [] + func.__api_query_params.append({ + 'name': name, + 'type': type, + 'help': help_str, + 'default': default, + 'choices': choices, + 'required': required, + }) + return func + return add_param + + +def parse_args(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + if '__api_query_params' not in dir(func): + abort(500) + + parser = reqparse.RequestParser() + for arg_spec in func.__api_query_params: + parser.add_argument(**arg_spec) + parsed_args = parser.parse_args() + + return func(self, parsed_args, *args, **kwargs) + return wrapper + + +def parse_repository_name(func): + @wraps(func) + def wrapper(repository, *args, **kwargs): + (namespace, repository) = parse_namespace_repository(repository) + return func(namespace, repository, *args, **kwargs) + return wrapper + + +class ApiResource(Resource): + def options(self): + return None, 200 + + +class RepositoryParamResource(ApiResource): + method_decorators = [parse_repository_name] + + +def require_repo_permission(permission_class, scope, allow_public=False): + def wrapper(func): + @add_method_metadata('oauth2_scope', scope) + @wraps(func) + def wrapped(self, namespace, repository, *args, **kwargs): + logger.debug('Checking permission %s for repo: %s/%s', permission_class, namespace, + repository) + permission = permission_class(namespace, repository) + if (permission.can() or + (allow_public and + model.repository_is_public(namespace, repository))): + return func(self, namespace, repository, *args, **kwargs) + raise Unauthorized() + return wrapped + return wrapper + + +require_repo_read = require_repo_permission(ReadRepositoryPermission, scopes.READ_REPO, True) +require_repo_write = require_repo_permission(ModifyRepositoryPermission, scopes.WRITE_REPO) +require_repo_admin = require_repo_permission(AdministerRepositoryPermission, scopes.ADMIN_REPO) + + +def require_user_permission(permission_class, scope=None): + def wrapper(func): + @add_method_metadata('oauth2_scope', scope) + @wraps(func) + def wrapped(self, *args, **kwargs): + user = get_authenticated_user() + if not user: + raise Unauthorized() + + logger.debug('Checking permission %s for user %s', permission_class, user.username) + permission = permission_class(user.username) + if permission.can(): + return func(self, *args, **kwargs) + raise Unauthorized() + return wrapped + return wrapper + + +require_user_read = require_user_permission(UserReadPermission, scopes.READ_USER) +require_user_admin = require_user_permission(UserAdminPermission, None) + + +def require_scope(scope_object): + def wrapper(func): + @add_method_metadata('oauth2_scope', scope_object) + @wraps(func) + def wrapped(*args, **kwargs): + return func(*args, **kwargs) + return wrapped + return wrapper + + +def validate_json_request(schema_name): + def wrapper(func): + @add_method_metadata('request_schema', schema_name) + @wraps(func) + def wrapped(self, *args, **kwargs): + schema = self.schemas[schema_name] + try: + validate(request.get_json(), schema) + return func(self, *args, **kwargs) + except ValidationError as ex: + raise InvalidRequest(ex.message) + return wrapped + return wrapper + + +def request_error(exception=None, **kwargs): + data = kwargs.copy() + message = 'Request error.' + if exception: + message = exception.message + raise InvalidRequest(message, data) + + +def log_action(kind, user_or_orgname, metadata=None, repo=None): + if not metadata: + metadata = {} + + oauth_token = get_validated_oauth_token() + if oauth_token: + metadata['oauth_token_id'] = oauth_token.id + metadata['oauth_token_application_id'] = oauth_token.application.client_id + metadata['oauth_token_application'] = oauth_token.application.name + + performer = get_authenticated_user() + model.log_action(kind, user_or_orgname, performer=performer, ip=request.remote_addr, + metadata=metadata, repository=repo) + + +import endpoints.api.billing +import endpoints.api.build +import endpoints.api.discovery +import endpoints.api.image +import endpoints.api.logs +import endpoints.api.organization +import endpoints.api.permission +import endpoints.api.prototype +import endpoints.api.repository +import endpoints.api.repotoken +import endpoints.api.robot +import endpoints.api.search +import endpoints.api.tag +import endpoints.api.team +import endpoints.api.trigger +import endpoints.api.user +import endpoints.api.webhook diff --git a/endpoints/api/billing.py b/endpoints/api/billing.py new file mode 100644 index 000000000..1f31aa58b --- /dev/null +++ b/endpoints/api/billing.py @@ -0,0 +1,326 @@ +import stripe + +from flask import request + +from endpoints.api import (resource, nickname, ApiResource, validate_json_request, log_action, + related_user_resource, internal_only, Unauthorized, NotFound, + require_user_admin) +from endpoints.api.subscribe import subscribe, subscription_view +from auth.permissions import AdministerOrganizationPermission +from auth.auth_context import get_authenticated_user +from data import model +from data.plans import PLANS + + +def carderror_response(e): + return {'carderror': e.message}, 402 + + +def get_card(user): + card_info = { + 'is_valid': False + } + + if user.stripe_id: + cus = stripe.Customer.retrieve(user.stripe_id) + if cus and cus.default_card: + # Find the default card. + default_card = None + for card in cus.cards.data: + if card.id == cus.default_card: + default_card = card + break + + if default_card: + card_info = { + 'owner': default_card.name, + 'type': default_card.type, + 'last4': default_card.last4 + } + + return {'card': card_info} + + +def set_card(user, token): + if user.stripe_id: + cus = stripe.Customer.retrieve(user.stripe_id) + if cus: + try: + cus.card = token + cus.save() + except stripe.CardError as exc: + return carderror_response(exc) + except stripe.InvalidRequestError as exc: + return carderror_response(exc) + + return get_card(user) + + +def get_invoices(customer_id): + def invoice_view(i): + return { + 'id': i.id, + 'date': i.date, + 'period_start': i.period_start, + 'period_end': i.period_end, + 'paid': i.paid, + 'amount_due': i.amount_due, + 'next_payment_attempt': i.next_payment_attempt, + 'attempted': i.attempted, + 'closed': i.closed, + 'total': i.total, + 'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None + } + + invoices = stripe.Invoice.all(customer=customer_id, count=12) + return { + 'invoices': [invoice_view(i) for i in invoices.data] + } + + +@resource('/v1/plans/') +class ListPlans(ApiResource): + """ Resource for listing the available plans. """ + @nickname('listPlans') + def get(self): + """ List the avaialble plans. """ + return { + 'plans': PLANS, + } + + +@resource('/v1/user/card') +@internal_only +class UserCard(ApiResource): + """ Resource for managing a user's credit card. """ + schemas = { + 'UserCard': { + 'id': 'UserCard', + 'type': 'object', + 'description': 'Description of a user card', + 'required': [ + 'token', + ], + 'properties': { + 'token': { + 'type': 'string', + 'description': 'Stripe token that is generated by stripe checkout.js', + }, + }, + }, + } + + @require_user_admin + @nickname('getUserCard') + def get(self): + """ Get the user's credit card. """ + user = get_authenticated_user() + return get_card(user) + + @require_user_admin + @nickname('setUserCard') + @validate_json_request('UserCard') + def post(self): + """ Update the user's credit card. """ + user = get_authenticated_user() + token = request.get_json()['token'] + response = set_card(user, token) + log_action('account_change_cc', user.username) + return response + + +@resource('/v1/organization//card') +@internal_only +@related_user_resource(UserCard) +class OrganizationCard(ApiResource): + """ Resource for managing an organization's credit card. """ + schemas = { + 'OrgCard': { + 'id': 'OrgCard', + 'type': 'object', + 'description': 'Description of a user card', + 'required': [ + 'token', + ], + 'properties': { + 'token': { + 'type': 'string', + 'description': 'Stripe token that is generated by stripe checkout.js', + }, + }, + }, + } + + @nickname('getOrgCard') + def get(self, orgname): + """ Get the organization's credit card. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + organization = model.get_organization(orgname) + return get_card(organization) + + raise Unauthorized() + + @nickname('setOrgCard') + @validate_json_request('OrgCard') + def post(self, orgname): + """ Update the orgnaization's credit card. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + organization = model.get_organization(orgname) + token = request.get_json()['token'] + response = set_card(organization, token) + log_action('account_change_cc', orgname) + return response + + raise Unauthorized() + + +@resource('/v1/user/plan') +@internal_only +class UserPlan(ApiResource): + """ Resource for managing a user's subscription. """ + schemas = { + 'UserSubscription': { + 'id': 'UserSubscription', + 'type': 'object', + 'description': 'Description of a user card', + 'required': [ + 'plan', + ], + 'properties': { + 'token': { + 'type': 'string', + 'description': 'Stripe token that is generated by stripe checkout.js', + }, + 'plan': { + 'type': 'string', + 'description': 'Plan name to which the user wants to subscribe', + }, + }, + }, + } + + @require_user_admin + @nickname('updateUserSubscription') + @validate_json_request('UserSubscription') + def put(self): + """ Create or update the user's subscription. """ + request_data = request.get_json() + plan = request_data['plan'] + token = request_data['token'] if 'token' in request_data else None + user = get_authenticated_user() + return subscribe(user, plan, token, False) # Business features not required + + @require_user_admin + @nickname('getUserSubscription') + def get(self): + """ Fetch any existing subscription for the user. """ + user = get_authenticated_user() + private_repos = model.get_private_repo_count(user.username) + + if user.stripe_id: + cus = stripe.Customer.retrieve(user.stripe_id) + + if cus.subscription: + return subscription_view(cus.subscription, private_repos) + + return { + 'plan': 'free', + 'usedPrivateRepos': private_repos, + } + + +@resource('/v1/organization//plan') +@internal_only +@related_user_resource(UserPlan) +class OrganizationPlan(ApiResource): + """ Resource for managing a org's subscription. """ + schemas = { + 'OrgSubscription': { + 'id': 'OrgSubscription', + 'type': 'object', + 'description': 'Description of a user card', + 'required': [ + 'plan', + ], + 'properties': { + 'token': { + 'type': 'string', + 'description': 'Stripe token that is generated by stripe checkout.js', + }, + 'plan': { + 'type': 'string', + 'description': 'Plan name to which the user wants to subscribe', + }, + }, + }, + } + + @nickname('updateOrgSubscription') + @validate_json_request('OrgSubscription') + def put(self, orgname): + """ Create or update the org's subscription. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + request_data = request.get_json() + plan = request_data['plan'] + token = request_data['token'] if 'token' in request_data else None + organization = model.get_organization(orgname) + return subscribe(organization, plan, token, True) # Business plan required + + raise Unauthorized() + + @nickname('getOrgSubscription') + def get(self, orgname): + """ Fetch any existing subscription for the org. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + private_repos = model.get_private_repo_count(orgname) + organization = model.get_organization(orgname) + if organization.stripe_id: + cus = stripe.Customer.retrieve(organization.stripe_id) + + if cus.subscription: + return subscription_view(cus.subscription, private_repos) + + return { + 'plan': 'free', + 'usedPrivateRepos': private_repos, + } + + raise Unauthorized() + + +@resource('/v1/user/invoices') +@internal_only +class UserInvoiceList(ApiResource): + """ Resource for listing a user's invoices. """ + @require_user_admin + @nickname('listUserInvoices') + def get(self): + """ List the invoices for the current user. """ + user = get_authenticated_user() + if not user.stripe_id: + raise NotFound() + + return get_invoices(user.stripe_id) + + +@resource('/v1/organization//invoices') +@internal_only +@related_user_resource(UserInvoiceList) +class OrgnaizationInvoiceList(ApiResource): + """ Resource for listing an orgnaization's invoices. """ + @nickname('listOrgInvoices') + def get(self, orgname): + """ List the invoices for the specified orgnaization. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + organization = model.get_organization(orgname) + if not organization.stripe_id: + raise NotFound() + + return get_invoices(organization.stripe_id) + + raise Unauthorized() \ No newline at end of file diff --git a/endpoints/api/build.py b/endpoints/api/build.py new file mode 100644 index 000000000..f14d097bb --- /dev/null +++ b/endpoints/api/build.py @@ -0,0 +1,213 @@ +import logging +import json + +from flask import request + +from app import app +from endpoints.api import (RepositoryParamResource, parse_args, query_param, nickname, resource, + require_repo_read, require_repo_write, validate_json_request, + ApiResource, internal_only, format_date, api, Unauthorized, NotFound) +from endpoints.common import start_build +from endpoints.trigger import BuildTrigger +from data import model +from auth.permissions import ModifyRepositoryPermission + + +logger = logging.getLogger(__name__) +user_files = app.config['USERFILES'] +build_logs = app.config['BUILDLOGS'] + + +def get_trigger_config(trigger): + try: + return json.loads(trigger.config) + except: + return {} + + +def get_job_config(build_obj): + try: + return json.loads(build_obj.job_config) + except: + return None + + +def trigger_view(trigger): + if trigger and trigger.uuid: + config_dict = get_trigger_config(trigger) + build_trigger = BuildTrigger.get_trigger_for_service(trigger.service.name) + return { + 'service': trigger.service.name, + 'config': config_dict, + 'id': trigger.uuid, + 'connected_user': trigger.connected_user.username, + 'is_active': build_trigger.is_active(config_dict) + } + + return None + + +def build_status_view(build_obj, can_write=False): + status = build_logs.get_status(build_obj.uuid) + logger.debug('Can write: %s job_config: %s', can_write, build_obj.job_config) + resp = { + 'id': build_obj.uuid, + 'phase': build_obj.phase if status else 'cannot_load', + 'started': format_date(build_obj.started), + 'display_name': build_obj.display_name, + 'status': status or {}, + 'job_config': get_job_config(build_obj) if can_write else None, + 'is_writer': can_write, + 'trigger': trigger_view(build_obj.trigger), + 'resource_key': build_obj.resource_key, + } + + if can_write: + resp['archive_url'] = user_files.get_file_url(build_obj.resource_key) + + return resp + + +@resource('/v1/repository//build/') +class RepositoryBuildList(RepositoryParamResource): + """ Resource related to creating and listing repository builds. """ + schemas = { + 'RepositoryBuildRequest': { + 'id': 'RepositoryBuildRequest', + 'type': 'object', + 'description': 'Description of a new repository build.', + 'required': [ + 'file_id', + ], + 'properties': { + 'file_id': { + 'type': 'string', + 'description': 'The file id that was generated when the build spec was uploaded', + }, + 'subdirectory': { + 'type': 'string', + 'description': 'Subdirectory in which the Dockerfile can be found', + }, + }, + }, + } + + @require_repo_read + @parse_args + @query_param('limit', 'The maximum number of builds to return', type=int, default=5) + @nickname('getRepoBuilds') + def get(self, args, namespace, repository): + """ Get the list of repository builds. """ + limit = args['limit'] + builds = list(model.list_repository_builds(namespace, repository, limit)) + + can_write = ModifyRepositoryPermission(namespace, repository).can() + return { + 'builds': [build_status_view(build, can_write) for build in builds] + } + + @require_repo_write + @nickname('requestRepoBuild') + @validate_json_request('RepositoryBuildRequest') + def post(self, namespace, repository): + """ Request that a repository be built and pushed from the specified input. """ + logger.debug('User requested repository initialization.') + request_json = request.get_json() + + dockerfile_id = request_json['file_id'] + subdir = request_json['subdirectory'] if 'subdirectory' in request_json else '' + + # Check if the dockerfile resource has already been used. If so, then it + # can only be reused if the user has access to the repository for which it + # was used. + associated_repository = model.get_repository_for_resource(dockerfile_id) + if associated_repository: + if not ModifyRepositoryPermission(associated_repository.namespace, + associated_repository.name): + raise Unauthorized() + + # Start the build. + repo = model.get_repository(namespace, repository) + display_name = user_files.get_file_checksum(dockerfile_id) + + build_request = start_build(repo, dockerfile_id, ['latest'], display_name, subdir, True) + + resp = build_status_view(build_request, True) + repo_string = '%s/%s' % (namespace, repository) + headers = { + 'Location': api.url_for(RepositoryBuildStatus, repository=repo_string, + build_uuid=build_request.uuid), + } + return resp, 201, headers + + +@resource('/v1/repository//build//status') +class RepositoryBuildStatus(RepositoryParamResource): + """ Resource for dealing with repository build status. """ + @require_repo_read + @nickname('getRepoBuildStatus') + def get(self, namespace, repository, build_uuid): + """ Return the status for the builds specified by the build uuids. """ + build = model.get_repository_build(namespace, repository, build_uuid) + if not build: + raise NotFound() + + can_write = ModifyRepositoryPermission(namespace, repository).can() + return build_status_view(build, can_write) + + +@resource('/v1/repository//build//logs') +class RepositoryBuildLogs(RepositoryParamResource): + """ Resource for loading repository build logs. """ + @require_repo_write + @nickname('getRepoBuildLogs') + def get(self, namespace, repository, build_uuid): + """ Return the build logs for the build specified by the build uuid. """ + response_obj = {} + + build = model.get_repository_build(namespace, repository, build_uuid) + + start = int(request.args.get('start', 0)) + + count, logs = build_logs.get_log_entries(build.uuid, start) + + response_obj.update({ + 'start': start, + 'total': count, + 'logs': [log for log in logs], + }) + + return response_obj + + +@resource('/v1/filedrop/') +@internal_only +class FileDropResource(ApiResource): + """ Custom verb for setting up a client side file transfer. """ + schemas = { + 'FileDropRequest': { + 'id': 'FileDropRequest', + 'type': 'object', + 'description': 'Description of the file that the user wishes to upload.', + 'required': [ + 'mimeType', + ], + 'properties': { + 'mimeType': { + 'type': 'string', + 'description': 'Type of the file which is about to be uploaded', + }, + }, + }, + } + + @nickname('getFiledropUrl') + @validate_json_request('FileDropRequest') + def post(self): + """ Request a URL to which a file may be uploaded. """ + mime_type = request.get_json()['mimeType'] + (url, file_id) = user_files.prepare_for_drop(mime_type) + return { + 'url': url, + 'file_id': str(file_id), + } diff --git a/endpoints/api/discovery.py b/endpoints/api/discovery.py new file mode 100644 index 000000000..212d6654e --- /dev/null +++ b/endpoints/api/discovery.py @@ -0,0 +1,184 @@ +import re +import logging + +from flask.ext.restful import reqparse + +from endpoints.api import (ApiResource, resource, method_metadata, nickname, truthy_bool, + parse_args, query_param) +from app import app +from auth import scopes + + +logger = logging.getLogger(__name__) + + +PARAM_REGEX = re.compile(r'<([\w]+:)?([\w]+)>') + + +TYPE_CONVERTER = { + truthy_bool: 'boolean', + str: 'string', + basestring: 'string', + reqparse.text_type: 'string', + int: 'integer', +} + +URL_SCHEME = app.config['URL_SCHEME'] +URL_HOST = app.config['URL_HOST'] + + +def fully_qualified_name(method_view_class): + inst = method_view_class() + return '%s.%s' % (inst.__module__, inst.__class__.__name__) + + +def swagger_route_data(include_internal=False, compact=False): + apis = [] + models = {} + for rule in app.url_map.iter_rules(): + endpoint_method = app.view_functions[rule.endpoint] + + if 'view_class' in dir(endpoint_method): + view_class = endpoint_method.view_class + operations = [] + + method_names = list(rule.methods.difference(['HEAD', 'OPTIONS'])) + for method_name in method_names: + method = getattr(view_class, method_name.lower(), None) + + parameters = [] + for param in rule.arguments: + parameters.append({ + 'paramType': 'path', + 'name': param, + 'dataType': 'string', + 'description': 'Param description.', + 'required': True, + }) + + if method is None: + logger.debug('Unable to find method for %s in class %s', method_name, view_class) + else: + req_schema_name = method_metadata(method, 'request_schema') + if req_schema_name: + parameters.append({ + 'paramType': 'body', + 'name': 'body', + 'description': 'Request body contents.', + 'dataType': req_schema_name, + 'required': True, + }) + + schema = view_class.schemas[req_schema_name] + models[req_schema_name] = schema + + if '__api_query_params' in dir(method): + for param_spec in method.__api_query_params: + new_param = { + 'paramType': 'query', + 'name': param_spec['name'], + 'description': param_spec['help'], + 'dataType': TYPE_CONVERTER[param_spec['type']], + 'required': param_spec['required'], + } + + if len(param_spec['choices']) > 0: + new_param['enum'] = list(param_spec['choices']) + + parameters.append(new_param) + + new_operation = { + 'method': method_name, + 'nickname': method_metadata(method, 'nickname') + } + + if not compact: + new_operation.update({ + 'type': 'void', + 'summary': method.__doc__ if method.__doc__ else '', + 'parameters': parameters, + }) + + + scope = method_metadata(method, 'oauth2_scope') + if scope and not compact: + new_operation['authorizations'] = { + 'oauth2': [ + { + 'scope': scope.scope + } + ], + } + + internal = method_metadata(method, 'internal') + if internal is not None: + new_operation['internal'] = True + + if not internal or (internal and include_internal): + operations.append(new_operation) + + swagger_path = PARAM_REGEX.sub(r'{\2}', rule.rule) + new_resource = { + 'path': swagger_path, + 'description': view_class.__doc__ if view_class.__doc__ else "", + 'operations': operations, + 'name': fully_qualified_name(view_class), + } + + related_user_res = method_metadata(view_class, 'related_user_resource') + if related_user_res is not None: + new_resource['quayUserRelated'] = fully_qualified_name(related_user_res) + + internal = method_metadata(view_class, 'internal') + if internal is not None: + new_resource['internal'] = True + + if not internal or (internal and include_internal): + apis.append(new_resource) + + # If compact form was requested, simply return the APIs. + if compact: + return {'apis': apis} + + swagger_data = { + 'apiVersion': 'v1', + 'swaggerVersion': '1.2', + 'basePath': '%s://%s' % (URL_SCHEME, URL_HOST), + 'resourcePath': '/', + 'info': { + 'title': 'Quay.io API', + 'description': ('This API allows you to perform many of the operations required to work ' + 'with Quay.io repositories, users, and organizations. You can find out more ' + 'at Quay.io.'), + 'termsOfServiceUrl': 'https://quay.io/tos', + 'contact': 'support@quay.io', + }, + 'authorizations': { + 'oauth2': { + 'scopes': [scope._asdict() for scope in scopes.ALL_SCOPES.values()], + 'grantTypes': { + "implicit": { + "tokenName": "access_token", + "loginEndpoint": { + "url": "%s://%s/oauth/authorize" % (URL_SCHEME, URL_HOST), + }, + }, + }, + }, + }, + 'apis': apis, + 'models': models, + } + + return swagger_data + + +@resource('/v1/discovery') +class DiscoveryResource(ApiResource): + """Ability to inspect the API for usage information and documentation.""" + @parse_args + @query_param('internal', 'Whether to include internal APIs.', type=truthy_bool, default=False) + @nickname('discovery') + def get(self, args): + """ List all of the API endpoints available in the swagger API format.""" + return swagger_route_data(args['internal']) diff --git a/endpoints/api/image.py b/endpoints/api/image.py new file mode 100644 index 000000000..edfae14b8 --- /dev/null +++ b/endpoints/api/image.py @@ -0,0 +1,92 @@ +import json + +from collections import defaultdict + +from app import app +from endpoints.api import (resource, nickname, require_repo_read, RepositoryParamResource, + format_date, NotFound) +from data import model +from util.cache import cache_control_flask_restful + + +store = app.config['STORAGE'] + + +def image_view(image): + extended_props = image + if image.storage and image.storage.id: + extended_props = image.storage + + command = extended_props.command + return { + 'id': image.docker_image_id, + 'created': format_date(extended_props.created), + 'comment': extended_props.comment, + 'command': json.loads(command) if command else None, + 'ancestors': image.ancestors, + 'dbid': image.id, + 'size': extended_props.image_size, + } + + +@resource('/v1/repository//image/') +class RepositoryImageList(RepositoryParamResource): + """ Resource for listing repository images. """ + @require_repo_read + @nickname('listRepositoryImages') + def get(self, namespace, repository): + """ List the images for the specified repository. """ + all_images = model.get_repository_images(namespace, repository) + all_tags = model.list_repository_tags(namespace, repository) + + tags_by_image_id = defaultdict(list) + for tag in all_tags: + tags_by_image_id[tag.image.docker_image_id].append(tag.name) + + + def add_tags(image_json): + image_json['tags'] = tags_by_image_id[image_json['id']] + return image_json + + + return { + 'images': [add_tags(image_view(image)) for image in all_images] + } + + +@resource('/v1/repository//image/') +class RepositoryImage(RepositoryParamResource): + """ Resource for handling repository images. """ + @require_repo_read + @nickname('getImage') + def get(self, namespace, repository, image_id): + """ Get the information available for the specified image. """ + image = model.get_repo_image(namespace, repository, image_id) + if not image: + raise NotFound() + + return image_view(image) + + +@resource('/v1/repository//image//changes') +class RepositoryImageChanges(RepositoryParamResource): + """ Resource for handling repository image change lists. """ + + @cache_control_flask_restful(max_age=60*60) # Cache for one hour + @require_repo_read + @nickname('getImageChanges') + def get(self, namespace, repository, image_id): + """ Get the list of changes for the specified image. """ + image = model.get_repo_image(namespace, repository, image_id) + + if not image: + raise NotFound() + + uuid = image.storage and image.storage.uuid + diffs_path = store.image_file_diffs_path(namespace, repository, image_id, uuid) + + try: + response_json = json.loads(store.get_content(diffs_path)) + return response_json + except IOError: + raise NotFound() diff --git a/endpoints/api/logs.py b/endpoints/api/logs.py new file mode 100644 index 000000000..abd2c3e03 --- /dev/null +++ b/endpoints/api/logs.py @@ -0,0 +1,126 @@ +import json + +from datetime import datetime, timedelta + +from endpoints.api import (resource, nickname, ApiResource, query_param, parse_args, + RepositoryParamResource, require_repo_admin, related_user_resource, + format_date, Unauthorized, NotFound, require_user_admin, + internal_only) +from auth.permissions import AdministerOrganizationPermission, AdministerOrganizationPermission +from auth.auth_context import get_authenticated_user +from data import model + + +def log_view(log): + view = { + 'kind': log.kind.name, + 'metadata': json.loads(log.metadata_json), + 'ip': log.ip, + 'datetime': format_date(log.datetime), + } + + if log.performer: + view['performer'] = { + 'kind': 'user', + 'name': log.performer.username, + 'is_robot': log.performer.robot, + } + + return view + + +def get_logs(namespace, start_time, end_time, performer_name=None, + repository=None): + performer = None + if performer_name: + performer = model.get_user(performer_name) + + if start_time: + try: + start_time = datetime.strptime(start_time + ' UTC', '%m/%d/%Y %Z') + except ValueError: + start_time = None + + if not start_time: + start_time = datetime.today() - timedelta(7) # One week + + if end_time: + try: + end_time = datetime.strptime(end_time + ' UTC', '%m/%d/%Y %Z') + end_time = end_time + timedelta(days=1) + except ValueError: + end_time = None + + if not end_time: + end_time = datetime.today() + + logs = model.list_logs(namespace, start_time, end_time, performer=performer, + repository=repository) + return { + 'start_time': format_date(start_time), + 'end_time': format_date(end_time), + 'logs': [log_view(log) for log in logs] + } + + +@resource('/v1/repository//logs') +@internal_only +class RepositoryLogs(RepositoryParamResource): + """ Resource for fetching logs for the specific repository. """ + @require_repo_admin + @nickname('listRepoLogs') + @parse_args + @query_param('starttime', 'Earliest time from which to get logs (%m/%d/%Y %Z)', type=str) + @query_param('endtime', 'Latest time to which to get logs (%m/%d/%Y %Z)', type=str) + def get(self, args, namespace, repository): + """ List the logs for the specified repository. """ + repo = model.get_repository(namespace, repository) + if not repo: + raise NotFound() + + start_time = args['starttime'] + end_time = args['endtime'] + return get_logs(namespace, start_time, end_time, repository=repo) + + +@resource('/v1/user/logs') +@internal_only +class UserLogs(ApiResource): + """ Resource for fetching logs for the current user. """ + @require_user_admin + @nickname('listUserLogs') + @parse_args + @query_param('starttime', 'Earliest time from which to get logs. (%m/%d/%Y %Z)', type=str) + @query_param('endtime', 'Latest time to which to get logs. (%m/%d/%Y %Z)', type=str) + @query_param('performer', 'Username for which to filter logs.', type=str) + def get(self, args): + """ List the logs for the current user. """ + performer_name = args['performer'] + start_time = args['starttime'] + end_time = args['endtime'] + + user = get_authenticated_user() + return get_logs(user.username, start_time, end_time, performer_name=performer_name) + + +@resource('/v1/organization//logs') +@internal_only +@related_user_resource(UserLogs) +class OrgLogs(ApiResource): + """ Resource for fetching logs for the entire organization. """ + @nickname('listOrgLogs') + @parse_args + @query_param('starttime', 'Earliest time from which to get logs. (%m/%d/%Y %Z)', type=str) + @query_param('endtime', 'Latest time to which to get logs. (%m/%d/%Y %Z)', type=str) + @query_param('performer', 'Username for which to filter logs.', type=str) + def get(self, args, orgname): + """ List the logs for the specified organization. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + performer_name = args['performer'] + start_time = args['starttime'] + end_time = args['endtime'] + + return get_logs(orgname, start_time, end_time, performer_name=performer_name) + + raise Unauthorized() \ No newline at end of file diff --git a/endpoints/api/organization.py b/endpoints/api/organization.py new file mode 100644 index 000000000..9cb6a267a --- /dev/null +++ b/endpoints/api/organization.py @@ -0,0 +1,520 @@ +import logging +import stripe + +from flask import request + +from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, + related_user_resource, internal_only, Unauthorized, NotFound, + require_user_admin, log_action) +from endpoints.api.team import team_view +from endpoints.api.user import User, PrivateRepositories +from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission, + CreateRepositoryPermission) +from auth.auth_context import get_authenticated_user +from data import model +from data.plans import get_plan +from util.gravatar import compute_hash + + +logger = logging.getLogger(__name__) + + + +def org_view(o, teams): + admin_org = AdministerOrganizationPermission(o.username) + is_admin = admin_org.can() + view = { + 'name': o.username, + 'email': o.email if is_admin else '', + 'gravatar': compute_hash(o.email), + 'teams': {t.name : team_view(o.username, t) for t in teams}, + 'is_admin': is_admin + } + + if is_admin: + view['invoice_email'] = o.invoice_email + + return view + + +@resource('/v1/organization/') +@internal_only +class OrganizationList(ApiResource): + """ Resource for creating organizations. """ + schemas = { + 'NewOrg': { + 'id': 'NewOrg', + 'type': 'object', + 'description': 'Description of a new organization.', + 'required': [ + 'name', + 'email', + ], + '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.get_organization(org_data['name']) + except model.InvalidOrganizationException: + pass + + if not existing: + try: + existing = model.get_user(org_data['name']) + except model.InvalidUserException: + pass + + if existing: + msg = 'A user or organization with this name already exists' + raise request_error(message=msg) + + try: + model.create_organization(org_data['name'], org_data['email'], user) + return 'Created', 201 + except model.DataModelException as ex: + raise request_error(exception=ex) + + +@resource('/v1/organization/') +@internal_only +@related_user_resource(User) +class Organization(ApiResource): + """ Resource for managing organizations. """ + schemas = { + 'UpdateOrg': { + 'id': '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', + }, + }, + }, + } + @nickname('getOrganization') + def get(self, orgname): + """ Get the details for the specified organization """ + permission = OrganizationMemberPermission(orgname) + if permission.can(): + try: + org = model.get_organization(orgname) + except model.InvalidOrganizationException: + raise NotFound() + + teams = model.get_teams_within_org(org) + return org_view(org, teams) + + raise Unauthorized() + + @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.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.change_invoice_email(org, org_data['invoice_email']) + + if 'email' in org_data and org_data['email'] != org.email: + new_email = org_data['email'] + if model.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.update_email(org, new_email) + + teams = model.get_teams_within_org(org) + return org_view(org, teams) + raise Unauthorized() + + +@resource('/v1/organization//private') +@internal_only +@related_user_resource(PrivateRepositories) +class OrgPrivateRepositories(ApiResource): + """ Custom verb to compute whether additional private repositories are available. """ + @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.get_organization(orgname) + private_repos = model.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//members') +@internal_only +class OrgnaizationMemberList(ApiResource): + """ Resource for listing the members of an organization. """ + @nickname('getOrganizationMembers') + def get(self, orgname): + """ List the members of the specified organization. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + try: + org = model.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.get_organization_members_with_teams(org) + for member in members: + if not member.user.username in members_dict: + members_dict[member.user.username] = {'name': member.user.username, + 'kind': 'user', + 'is_robot': member.user.robot, + 'teams': []} + + members_dict[member.user.username]['teams'].append(member.team.name) + + return {'members': members_dict} + + raise Unauthorized() + + +@resource('/v1/organization//members/') +@internal_only +class OrganizationMember(ApiResource): + """ Resource for managing individual organization members. """ + @nickname('getOrganizationMember') + def get(self, orgname, membername): + """ Get information on the specific orgnaization member. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + try: + org = model.get_organization(orgname) + except model.InvalidOrganizationException: + raise NotFound() + + member_dict = None + member_teams = model.get_organization_members_with_teams(org, membername=membername) + for member in member_teams: + if not member_dict: + member_dict = {'name': member.user.username, + 'kind': 'user', + 'is_robot': member.user.robot, + 'teams': []} + + member_dict['teams'].append(member.team.name) + + if not member_dict: + raise NotFound() + + return {'member': member_dict} + + raise Unauthorized() + + +@resource('/v1/app/') +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() + + org_hash = compute_hash(application.organization.email) + gravatar = compute_hash(application.gravatar_email) if application.gravatar_email else org_hash + + return { + 'name': application.name, + 'description': application.description, + 'uri': application.application_uri, + 'gravatar': gravatar, + '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, + 'gravatar_email': application.gravatar_email if is_admin else None, + } + + +@resource('/v1/organization//applications') +@internal_only +class OrganizationApplications(ApiResource): + """ Resource for managing applications defined by an organizations. """ + schemas = { + 'NewApp': { + 'id': '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', + }, + 'gravatar_email': { + 'type': 'string', + 'description': 'The e-mail address of the gravatar to use for the application', + } + }, + }, + } + + + @nickname('getOrganizationApplications') + def get(self, orgname): + """ List the applications for the specified organization """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + try: + org = model.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() + + @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.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', ''), + gravatar_email = app_data.get('gravatar_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//applications/') +@internal_only +class OrganizationApplicationResource(ApiResource): + """ Resource for managing an application defined by an organizations. """ + schemas = { + 'UpdateApp': { + 'id': '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', + }, + 'gravatar_email': { + 'type': 'string', + 'description': 'The e-mail address of the gravatar to use for the application', + } + }, + }, + } + + @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.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() + + @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.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.gravatar_email = app_data.get('gravatar_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() + + + @nickname('deleteOrganizationApplication') + def delete(self, orgname, client_id): + """ Deletes the application under this organization. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + try: + org = model.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 'Deleted', 204 + raise Unauthorized() + + +@resource('/v1/organization//applications//resetclientsecret') +@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.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() diff --git a/endpoints/api/permission.py b/endpoints/api/permission.py new file mode 100644 index 000000000..601a549e3 --- /dev/null +++ b/endpoints/api/permission.py @@ -0,0 +1,241 @@ +import logging + +from flask import request + +from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource, + log_action, request_error, validate_json_request) +from data import model + + +logger = logging.getLogger(__name__) + + +def role_view(repo_perm_obj): + return { + 'role': repo_perm_obj.role.name, + } + +def wrap_role_view_user(role_json, user): + role_json['is_robot'] = user.robot + return role_json + + +def wrap_role_view_org(role_json, user, org_members): + role_json['is_org_member'] = user.robot or user.username in org_members + return role_json + + +@resource('/v1/repository//permissions/team/') +class RepositoryTeamPermissionList(RepositoryParamResource): + """ Resource for repository team permissions. """ + @require_repo_admin + @nickname('listRepoTeamPermissions') + def get(self, namespace, repository): + """ List all team permission. """ + repo_perms = model.get_all_repo_teams(namespace, repository) + + return { + 'permissions': {repo_perm.team.name: role_view(repo_perm) + for repo_perm in repo_perms} + } + + +@resource('/v1/repository//permissions/user/') +class RepositoryUserPermissionList(RepositoryParamResource): + """ Resource for repository user permissions. """ + @require_repo_admin + @nickname('listRepoUserPermissions') + def get(self, namespace, repository): + """ List all user permissions. """ + # Lookup the organization (if any). + org = None + try: + org = model.get_organization(namespace) # Will raise an error if not org + except model.InvalidOrganizationException: + # This repository isn't under an org + pass + + # Determine how to wrap the role(s). + def wrapped_role_view(repo_perm): + return wrap_role_view_user(role_view(repo_perm), repo_perm.user) + + role_view_func = wrapped_role_view + + if org: + org_members = model.get_organization_member_set(namespace) + current_func = role_view_func + + def wrapped_role_org_view(repo_perm): + return wrap_role_view_org(current_func(repo_perm), repo_perm.user, + org_members) + + role_view_func = wrapped_role_org_view + + # Load and return the permissions. + repo_perms = model.get_all_repo_users(namespace, repository) + return { + 'permissions': {perm.user.username: role_view_func(perm) + for perm in repo_perms} + } + + +@resource('/v1/repository//permissions/user/') +class RepositoryUserPermission(RepositoryParamResource): + """ Resource for managing individual user permissions. """ + schemas = { + 'UserPermission': { + 'id': 'UserPermission', + 'type': 'object', + 'description': 'Description of a user permission.', + 'required': [ + 'role', + ], + 'properties': { + 'role': { + 'type': 'string', + 'description': 'Role to use for the user', + 'enum': [ + 'read', + 'write', + 'admin', + ], + }, + }, + }, + } + + @require_repo_admin + @nickname('getUserPermissions') + def get(self, namespace, repository, username): + """ Get the Fetch the permission for the specified user. """ + logger.debug('Get repo: %s/%s permissions for user %s' % + (namespace, repository, username)) + perm = model.get_user_reponame_permission(username, namespace, repository) + perm_view = wrap_role_view_user(role_view(perm), perm.user) + + try: + model.get_organization(namespace) + org_members = model.get_organization_member_set(namespace) + perm_view = wrap_role_view_org(perm_view, perm.user, org_members) + except model.InvalidOrganizationException: + # This repository is not part of an organization + pass + + return perm_view + + @require_repo_admin + @nickname('changeUserPermissions') + @validate_json_request('UserPermission') + def put(self, namespace, repository, username): # Also needs to respond to post + """ Update the perimssions for an existing repository. """ + new_permission = request.get_json() + + logger.debug('Setting permission to: %s for user %s' % + (new_permission['role'], username)) + + try: + perm = model.set_user_repo_permission(username, namespace, repository, + new_permission['role']) + except model.InvalidUsernameException as ex: + raise request_error(exception=ex) + + perm_view = wrap_role_view_user(role_view(perm), perm.user) + + try: + model.get_organization(namespace) + org_members = model.get_organization_member_set(namespace) + perm_view = wrap_role_view_org(perm_view, perm.user, org_members) + except model.InvalidOrganizationException: + # This repository is not part of an organization + pass + except model.DataModelException as ex: + raise request_error(exception=ex) + + log_action('change_repo_permission', namespace, + {'username': username, 'repo': repository, + 'role': new_permission['role']}, + repo=model.get_repository(namespace, repository)) + + return perm_view, 200 + + @require_repo_admin + @nickname('deleteUserPermissions') + def delete(self, namespace, repository, username): + """ Delete the permission for the user. """ + try: + model.delete_user_permission(username, namespace, repository) + except model.DataModelException as ex: + raise request_error(exception=ex) + + log_action('delete_repo_permission', namespace, + {'username': username, 'repo': repository}, + repo=model.get_repository(namespace, repository)) + + return 'Deleted', 204 + + +@resource('/v1/repository//permissions/team/') +class RepositoryTeamPermission(RepositoryParamResource): + """ Resource for managing individual team permissions. """ + schemas = { + 'TeamPermission': { + 'id': 'TeamPermission', + 'type': 'object', + 'description': 'Description of a team permission.', + 'required': [ + 'role', + ], + 'properties': { + 'role': { + 'type': 'string', + 'description': 'Role to use for the team', + 'enum': [ + 'read', + 'write', + 'admin', + ], + }, + }, + }, + } + + @require_repo_admin + @nickname('getTeamPermissions') + def get(self, namespace, repository, teamname): + """ Fetch the permission for the specified team. """ + logger.debug('Get repo: %s/%s permissions for team %s' % + (namespace, repository, teamname)) + perm = model.get_team_reponame_permission(teamname, namespace, repository) + return role_view(perm) + + @require_repo_admin + @nickname('changeTeamPermissions') + @validate_json_request('TeamPermission') + def put(self, namespace, repository, teamname): + """ Update the existing team permission. """ + new_permission = request.get_json() + + logger.debug('Setting permission to: %s for team %s' % + (new_permission['role'], teamname)) + + perm = model.set_team_repo_permission(teamname, namespace, repository, + new_permission['role']) + + log_action('change_repo_permission', namespace, + {'team': teamname, 'repo': repository, + 'role': new_permission['role']}, + repo=model.get_repository(namespace, repository)) + + return role_view(perm), 200 + + @require_repo_admin + @nickname('deleteTeamPermissions') + def delete(self, namespace, repository, teamname): + """ Delete the permission for the specified team. """ + model.delete_team_permission(teamname, namespace, repository) + + log_action('delete_repo_permission', namespace, + {'team': teamname, 'repo': repository}, + repo=model.get_repository(namespace, repository)) + + return 'Deleted', 204 diff --git a/endpoints/api/prototype.py b/endpoints/api/prototype.py new file mode 100644 index 000000000..bedc19832 --- /dev/null +++ b/endpoints/api/prototype.py @@ -0,0 +1,253 @@ +from flask import request + +from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, + log_action, Unauthorized, NotFound, internal_only) +from auth.permissions import AdministerOrganizationPermission +from auth.auth_context import get_authenticated_user +from data import model + + +def prototype_view(proto, org_members): + def prototype_user_view(user): + return { + 'name': user.username, + 'is_robot': user.robot, + 'kind': 'user', + 'is_org_member': user.robot or user.username in org_members, + } + + if proto.delegate_user: + delegate_view = prototype_user_view(proto.delegate_user) + else: + delegate_view = { + 'name': proto.delegate_team.name, + 'kind': 'team', + } + + return { + 'activating_user': (prototype_user_view(proto.activating_user) + if proto.activating_user else None), + 'delegate': delegate_view, + 'role': proto.role.name, + 'id': proto.uuid, + } + +def log_prototype_action(action_kind, orgname, prototype, **kwargs): + username = get_authenticated_user().username + log_params = { + 'prototypeid': prototype.uuid, + 'username': username, + 'activating_username': (prototype.activating_user.username + if prototype.activating_user else None), + 'role': prototype.role.name + } + + for key, value in kwargs.items(): + log_params[key] = value + + if prototype.delegate_user: + log_params['delegate_user'] = prototype.delegate_user.username + elif prototype.delegate_team: + log_params['delegate_team'] = prototype.delegate_team.name + + log_action(action_kind, orgname, log_params) + + +@resource('/v1/organization//prototypes') +@internal_only +class PermissionPrototypeList(ApiResource): + """ Resource for listing and creating permission prototypes. """ + schemas = { + 'NewPrototype': { + 'id': 'NewPrototype', + 'type': 'object', + 'description': 'Description of a new prototype', + 'required': [ + 'role', + 'delegate', + ], + 'properties': { + 'role': { + 'type': 'string', + 'description': 'Role that should be applied to the delegate', + 'enum': [ + 'read', + 'write', + 'admin', + ], + }, + 'activating_user': { + 'type': 'object', + 'description': 'Repository creating user to whom the rule should apply', + 'required': [ + 'name', + ], + 'properties': { + 'name': { + 'type': 'string', + 'description': 'The username for the activating_user', + }, + }, + }, + 'delegate': { + 'type': 'object', + 'description': 'Information about the user or team to which the rule grants access', + 'required': [ + 'name', + 'kind', + ], + 'properties': { + 'name': { + 'type': 'string', + 'description': 'The name for the delegate team or user', + }, + 'kind': { + 'type': 'string', + 'description': 'Whether the delegate is a user or a team', + 'enum': [ + 'user', + 'team', + ], + }, + }, + }, + }, + }, + } + + @nickname('getOrganizationPrototypePermissions') + def get(self, orgname): + """ List the existing prototypes for this organization. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + try: + org = model.get_organization(orgname) + except model.InvalidOrganizationException: + raise NotFound() + + permissions = model.get_prototype_permissions(org) + org_members = model.get_organization_member_set(orgname) + return {'prototypes': [prototype_view(p, org_members) for p in permissions]} + + raise Unauthorized() + + @nickname('createOrganizationPrototypePermission') + @validate_json_request('NewPrototype') + def post(self, orgname): + """ Create a new permission prototype. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + try: + org = model.get_organization(orgname) + except model.InvalidOrganizationException: + raise NotFound() + + details = request.get_json() + activating_username = None + + if ('activating_user' in details and details['activating_user'] and + 'name' in details['activating_user']): + activating_username = details['activating_user']['name'] + + delegate = details['delegate'] if 'delegate' in details else {} + delegate_kind = delegate.get('kind', None) + delegate_name = delegate.get('name', None) + + delegate_username = delegate_name if delegate_kind == 'user' else None + delegate_teamname = delegate_name if delegate_kind == 'team' else None + + activating_user = (model.get_user(activating_username) if activating_username else None) + delegate_user = (model.get_user(delegate_username) if delegate_username else None) + delegate_team = (model.get_organization_team(orgname, delegate_teamname) + if delegate_teamname else None) + + if activating_username and not activating_user: + raise request_error(message='Unknown activating user') + + if not delegate_user and not delegate_team: + raise request_error(message='Missing delegate user or team') + + role_name = details['role'] + + prototype = model.add_prototype_permission(org, role_name, activating_user, + delegate_user, delegate_team) + log_prototype_action('create_prototype_permission', orgname, prototype) + org_members = model.get_organization_member_set(orgname) + return prototype_view(prototype, org_members) + + raise Unauthorized() + + +@resource('/v1/organization//prototypes/') +@internal_only +class PermissionPrototype(ApiResource): + """ Resource for managingin individual permission prototypes. """ + schemas = { + 'PrototypeUpdate': { + 'id': 'PrototypeUpdate', + 'type': 'object', + 'description': 'Description of a the new prototype role', + 'required': [ + 'role', + ], + 'properties': { + 'role': { + 'type': 'string', + 'description': 'Role that should be applied to the permission', + 'enum': [ + 'read', + 'write', + 'admin', + ], + }, + }, + }, + } + + @nickname('deleteOrganizationPrototypePermission') + def delete(self, orgname, prototypeid): + """ Delete an existing permission prototype. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + try: + org = model.get_organization(orgname) + except model.InvalidOrganizationException: + raise NotFound() + + prototype = model.delete_prototype_permission(org, prototypeid) + if not prototype: + raise NotFound() + + log_prototype_action('delete_prototype_permission', orgname, prototype) + + return 'Deleted', 204 + + raise Unauthorized() + + @nickname('updateOrganizationPrototypePermission') + @validate_json_request('PrototypeUpdate') + def put(self, orgname, prototypeid): + """ Update the role of an existing permission prototype. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + try: + org = model.get_organization(orgname) + except model.InvalidOrganizationException: + raise NotFound() + + existing = model.get_prototype_permission(org, prototypeid) + if not existing: + raise NotFound() + + details = request.get_json() + role_name = details['role'] + prototype = model.update_prototype_permission(org, prototypeid, role_name) + if not prototype: + raise NotFound() + + log_prototype_action('modify_prototype_permission', orgname, prototype, + original_role=existing.role.name) + org_members = model.get_organization_member_set(orgname) + return prototype_view(prototype, org_members) + + raise Unauthorized() diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py new file mode 100644 index 000000000..13d32a3fc --- /dev/null +++ b/endpoints/api/repository.py @@ -0,0 +1,291 @@ +import logging +import json + +from flask import request + +from data import model +from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request, + require_repo_read, require_repo_write, require_repo_admin, + RepositoryParamResource, resource, query_param, parse_args, ApiResource, + request_error, require_scope, Unauthorized, NotFound, InvalidRequest) +from auth.permissions import (ModifyRepositoryPermission, AdministerRepositoryPermission, + CreateRepositoryPermission, ReadRepositoryPermission) +from auth.auth_context import get_authenticated_user +from auth import scopes + + +logger = logging.getLogger(__name__) + + +@resource('/v1/repository') +class RepositoryList(ApiResource): + """Operations for creating and listing repositories.""" + schemas = { + 'NewRepo': { + 'id': 'NewRepo', + 'type': 'object', + 'description': 'Description of a new repository', + 'required': [ + 'repository', + 'visibility', + 'description', + ], + 'properties': { + 'repository': { + 'type': 'string', + 'description': 'Repository name', + }, + 'visibility': { + 'type': 'string', + 'description': 'Visibility which the repository will start with', + 'enum': [ + 'public', + 'private', + ], + }, + 'namespace': { + 'type': 'string', + 'description': ('Namespace in which the repository should be created. If omitted, the ' + 'username of the caller is used'), + }, + 'description': { + 'type': 'string', + 'description': 'Markdown encoded description for the repository', + }, + }, + }, + } + + @require_scope(scopes.CREATE_REPO) + @nickname('createRepo') + @validate_json_request('NewRepo') + def post(self): + """Create a new repository.""" + owner = get_authenticated_user() + req = request.get_json() + + if owner is None and 'namespace' not in 'req': + raise InvalidRequest('Must provide a namespace or must be logged in.') + + namespace_name = req['namespace'] if 'namespace' in req else owner.username + + permission = CreateRepositoryPermission(namespace_name) + if permission.can(): + repository_name = req['repository'] + visibility = req['visibility'] + + existing = model.get_repository(namespace_name, repository_name) + if existing: + raise request_error(message='Repository already exists') + + visibility = req['visibility'] + + repo = model.create_repository(namespace_name, repository_name, owner, + visibility) + repo.description = req['description'] + repo.save() + + log_action('create_repo', namespace_name, {'repo': repository_name, + 'namespace': namespace_name}, repo=repo) + return { + 'namespace': namespace_name, + 'name': repository_name + }, 201 + + raise Unauthorized() + + @require_scope(scopes.READ_REPO) + @nickname('listRepos') + @parse_args + @query_param('page', 'Offset page number. (int)', type=int) + @query_param('limit', 'Limit on the number of results (int)', type=int) + @query_param('namespace', 'Namespace to use when querying for org repositories.', type=str) + @query_param('public', 'Whether to include public repositories.', type=truthy_bool, default=True) + @query_param('private', 'Whether to inlcude private repositories.', type=truthy_bool, + default=True) + @query_param('sort', 'Whether to sort the results.', type=truthy_bool, default=False) + @query_param('count', 'Whether to include a count of the total number of results available.', + type=truthy_bool, default=False) + def get(self, args): + """Fetch the list of repositories under a variety of situations.""" + def repo_view(repo_obj): + return { + 'namespace': repo_obj.namespace, + 'name': repo_obj.name, + 'description': repo_obj.description, + 'is_public': repo_obj.visibility.name == 'public', + } + + username = None + if get_authenticated_user() and args['private']: + username = get_authenticated_user().username + + response = {} + + repo_count = None + if args['count']: + repo_count = model.get_visible_repository_count(username, include_public=args['public'], + namespace=args['namespace']) + response['count'] = repo_count + + repo_query = model.get_visible_repositories(username, limit=args['limit'], page=args['page'], + include_public=args['public'], sort=args['sort'], + namespace=args['namespace']) + + response['repositories'] = [repo_view(repo) for repo in repo_query + if (repo.visibility.name == 'public' or + ReadRepositoryPermission(repo.namespace, repo.name).can())] + + return response + +def image_view(image): + extended_props = image + if image.storage and image.storage.id: + extended_props = image.storage + + command = extended_props.command + return { + 'id': image.docker_image_id, + 'created': format_date(extended_props.created), + 'comment': extended_props.comment, + 'command': json.loads(command) if command else None, + 'ancestors': image.ancestors, + 'dbid': image.id, + 'size': extended_props.image_size, + } + +@resource('/v1/repository/') +class Repository(RepositoryParamResource): + """Operations for managing a specific repository.""" + schemas = { + 'RepoUpdate': { + 'id': 'RepoUpdate', + 'type': 'object', + 'description': 'Fields which can be updated in a repository.', + 'required': [ + 'description', + ], + 'properties': { + 'description': { + 'type': 'string', + 'description': 'Markdown encoded description for the repository', + }, + } + } + } + + @require_repo_read + @nickname('getRepo') + def get(self, namespace, repository): + """Fetch the specified repository.""" + logger.debug('Get repo: %s/%s' % (namespace, repository)) + + def tag_view(tag): + image = model.get_tag_image(namespace, repository, tag.name) + if not image: + return {} + + return { + 'name': tag.name, + 'image': image_view(image), + } + + organization = None + try: + organization = model.get_organization(namespace) + except model.InvalidOrganizationException: + pass + + is_public = model.repository_is_public(namespace, repository) + repo = model.get_repository(namespace, repository) + if repo: + tags = model.list_repository_tags(namespace, repository) + tag_dict = {tag.name: tag_view(tag) for tag in tags} + can_write = ModifyRepositoryPermission(namespace, repository).can() + can_admin = AdministerRepositoryPermission(namespace, repository).can() + active_builds = model.list_repository_builds(namespace, repository, 1, + include_inactive=False) + + return { + 'namespace': namespace, + 'name': repository, + 'description': repo.description, + 'tags': tag_dict, + 'can_write': can_write, + 'can_admin': can_admin, + 'is_public': is_public, + 'is_building': len(list(active_builds)) > 0, + 'is_organization': bool(organization), + 'status_token': repo.badge_token if not is_public else '' + } + + raise NotFound() + + @require_repo_write + @nickname('updateRepo') + @validate_json_request('RepoUpdate') + def put(self, namespace, repository): + """ Update the description in the specified repository. """ + repo = model.get_repository(namespace, repository) + if repo: + values = request.get_json() + repo.description = values['description'] + repo.save() + + log_action('set_repo_description', namespace, + {'repo': repository, 'description': values['description']}, + repo=repo) + return { + 'success': True + } + raise NotFound() + + @require_repo_admin + @nickname('deleteRepository') + def delete(self, namespace, repository): + """ Delete a repository. """ + model.purge_repository(namespace, repository) + log_action('delete_repo', namespace, + {'repo': repository, 'namespace': namespace}) + return 'Deleted', 204 + + +@resource('/v1/repository//changevisibility') +class RepositoryVisibility(RepositoryParamResource): + """ Custom verb for changing the visibility of the repository. """ + schemas = { + 'ChangeVisibility': { + 'id': 'ChangeVisibility', + 'type': 'object', + 'description': 'Change the visibility for the repository.', + 'required': [ + 'visibility', + ], + 'properties': { + 'visibility': { + 'type': 'string', + 'description': 'Visibility which the repository will start with', + 'enum': [ + 'public', + 'private', + ], + }, + } + } + } + + @require_repo_admin + @nickname('changeRepoVisibility') + @validate_json_request('ChangeVisibility') + def post(self, namespace, repository): + """ Change the visibility of a repository. """ + repo = model.get_repository(namespace, repository) + if repo: + values = request.get_json() + model.set_repository_visibility(repo, values['visibility']) + log_action('change_repo_visibility', namespace, + {'repo': repository, 'visibility': values['visibility']}, + repo=repo) + return { + 'success': True + } diff --git a/endpoints/api/repotoken.py b/endpoints/api/repotoken.py new file mode 100644 index 000000000..1d9f6bf3b --- /dev/null +++ b/endpoints/api/repotoken.py @@ -0,0 +1,134 @@ +import logging + +from flask import request + +from endpoints.api import (resource, nickname, require_repo_admin, RepositoryParamResource, + log_action, validate_json_request, NotFound) +from data import model + + +logger = logging.getLogger(__name__) + + +def token_view(token_obj): + return { + 'friendlyName': token_obj.friendly_name, + 'code': token_obj.code, + 'role': token_obj.role.name, + } + + +@resource('/v1/repository//tokens/') +class RepositoryTokenList(RepositoryParamResource): + """ Resource for creating and listing repository tokens. """ + schemas = { + 'NewToken': { + 'id': 'NewToken', + 'type': 'object', + 'description': 'Description of a new token.', + 'required':[ + 'friendlyName', + ], + 'properties': { + 'friendlyName': { + 'type': 'string', + 'description': 'Friendly name to help identify the token', + }, + }, + }, + } + + @require_repo_admin + @nickname('listRepoTokens') + def get(self, namespace, repository): + """ List the tokens for the specified repository. """ + tokens = model.get_repository_delegate_tokens(namespace, repository) + + return { + 'tokens': {token.code: token_view(token) for token in tokens} + } + + @require_repo_admin + @nickname('createToken') + @validate_json_request('NewToken') + def post(self, namespace, repository): + """ Create a new repository token. """ + token_params = request.get_json() + + token = model.create_delegate_token(namespace, repository, + token_params['friendlyName']) + + log_action('add_repo_accesstoken', namespace, + {'repo': repository, 'token': token_params['friendlyName']}, + repo = model.get_repository(namespace, repository)) + + return token_view(token), 201 + + +@resource('/v1/repository//tokens/') +class RepositoryToken(RepositoryParamResource): + """ Resource for managing individual tokens. """ + schemas = { + 'TokenPermission': { + 'id': 'TokenPermission', + 'type': 'object', + 'description': 'Description of a token permission', + 'required': [ + 'role', + ], + 'properties': { + 'role': { + 'type': 'string', + 'description': 'Role to use for the token', + 'enum': [ + 'read', + 'write', + 'admin', + ], + }, + }, + }, + } + @require_repo_admin + @nickname('getTokens') + def get(self, namespace, repository, code): + """ Fetch the specified repository token information. """ + try: + perm = model.get_repo_delegate_token(namespace, repository, code) + except model.InvalidTokenException: + raise NotFound() + + return token_view(perm) + + @require_repo_admin + @nickname('changeToken') + @validate_json_request('TokenPermission') + def put(self, namespace, repository, code): + """ Update the permissions for the specified repository token. """ + new_permission = request.get_json() + + logger.debug('Setting permission to: %s for code %s' % + (new_permission['role'], code)) + + token = model.set_repo_delegate_token_role(namespace, repository, code, + new_permission['role']) + + log_action('change_repo_permission', namespace, + {'repo': repository, 'token': token.friendly_name, 'code': code, + 'role': new_permission['role']}, + repo = model.get_repository(namespace, repository)) + + return token_view(token) + + @require_repo_admin + @nickname('deleteToken') + def delete(self, namespace, repository, code): + """ Delete the repository token. """ + token = model.delete_delegate_token(namespace, repository, code) + + log_action('delete_repo_accesstoken', namespace, + {'repo': repository, 'token': token.friendly_name, + 'code': code}, + repo = model.get_repository(namespace, repository)) + + return 'Deleted', 204 diff --git a/endpoints/api/robot.py b/endpoints/api/robot.py new file mode 100644 index 000000000..0961d694e --- /dev/null +++ b/endpoints/api/robot.py @@ -0,0 +1,98 @@ +from endpoints.api import (resource, nickname, ApiResource, log_action, related_user_resource, + Unauthorized, require_user_admin, internal_only) +from auth.permissions import AdministerOrganizationPermission, OrganizationMemberPermission +from auth.auth_context import get_authenticated_user +from data import model +from util.names import format_robot_username + + +def robot_view(name, token): + return { + 'name': name, + 'token': token, + } + + +@resource('/v1/user/robots') +@internal_only +class UserRobotList(ApiResource): + """ Resource for listing user robots. """ + @require_user_admin + @nickname('getUserRobots') + def get(self): + """ List the available robots for the user. """ + user = get_authenticated_user() + robots = model.list_entity_robots(user.username) + return { + 'robots': [robot_view(name, password) for name, password in robots] + } + + +@resource('/v1/user/robots/') +@internal_only +class UserRobot(ApiResource): + """ Resource for managing a user's robots. """ + @require_user_admin + @nickname('createUserRobot') + def put(self, robot_shortname): + """ Create a new user robot with the specified name. """ + parent = get_authenticated_user() + robot, password = model.create_robot(robot_shortname, parent) + log_action('create_robot', parent.username, {'robot': robot_shortname}) + return robot_view(robot.username, password), 201 + + @require_user_admin + @nickname('deleteUserRobot') + def delete(self, robot_shortname): + """ Delete an existing robot. """ + parent = get_authenticated_user() + model.delete_robot(format_robot_username(parent.username, robot_shortname)) + log_action('delete_robot', parent.username, {'robot': robot_shortname}) + return 'Deleted', 204 + + +@resource('/v1/organization//robots') +@internal_only +@related_user_resource(UserRobotList) +class OrgRobotList(ApiResource): + """ Resource for listing an organization's robots. """ + @nickname('getOrgRobots') + def get(self, orgname): + """ List the organization's robots. """ + permission = OrganizationMemberPermission(orgname) + if permission.can(): + robots = model.list_entity_robots(orgname) + return { + 'robots': [robot_view(name, password) for name, password in robots] + } + + raise Unauthorized() + + +@resource('/v1/organization//robots/') +@internal_only +@related_user_resource(UserRobot) +class OrgRobot(ApiResource): + """ Resource for managing an organization's robots. """ + @nickname('createOrgRobot') + def put(self, orgname, robot_shortname): + """ Create a new robot in the organization. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + parent = model.get_organization(orgname) + robot, password = model.create_robot(robot_shortname, parent) + log_action('create_robot', orgname, {'robot': robot_shortname}) + return robot_view(robot.username, password), 201 + + raise Unauthorized() + + @nickname('deleteOrgRobot') + def delete(self, orgname, robot_shortname): + """ Delete an existing organization robot. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + model.delete_robot(format_robot_username(orgname, robot_shortname)) + log_action('delete_robot', orgname, {'robot': robot_shortname}) + return 'Deleted', 204 + + raise Unauthorized() diff --git a/endpoints/api/search.py b/endpoints/api/search.py new file mode 100644 index 000000000..2d96b0384 --- /dev/null +++ b/endpoints/api/search.py @@ -0,0 +1,116 @@ +from endpoints.api import (ApiResource, parse_args, query_param, truthy_bool, nickname, resource, + require_scope) +from data import model +from auth.permissions import (OrganizationMemberPermission, ViewTeamPermission, + ReadRepositoryPermission, UserAdminPermission) +from auth.auth_context import get_authenticated_user +from auth import scopes + + +@resource('/v1/entities/') +class EntitySearch(ApiResource): + """ Resource for searching entities. """ + @parse_args + @query_param('namespace', 'Namespace to use when querying for org entities.', type=str, + default='') + @query_param('includeTeams', 'Whether to include team names.', type=truthy_bool, default=False) + @nickname('getMatchingEntities') + def get(self, args, prefix): + """ Get a list of entities that match the specified prefix. """ + teams = [] + + namespace_name = args['namespace'] + robot_namespace = None + organization = None + + try: + organization = model.get_organization(namespace_name) + + # namespace name was an org + permission = OrganizationMemberPermission(namespace_name) + if permission.can(): + robot_namespace = namespace_name + + if args['includeTeams']: + teams = model.get_matching_teams(prefix, organization) + + except model.InvalidOrganizationException: + # namespace name was a user + user = get_authenticated_user() + if user and user.username == namespace_name: + # Check if there is admin user permissions (login only) + admin_permission = UserAdminPermission(user.username) + if admin_permission.can(): + robot_namespace = namespace_name + + users = model.get_matching_users(prefix, robot_namespace, organization) + + def entity_team_view(team): + result = { + 'name': team.name, + 'kind': 'team', + 'is_org_member': True + } + return result + + def user_view(user): + user_json = { + 'name': user.username, + 'kind': 'user', + 'is_robot': user.is_robot, + } + + if organization is not None: + user_json['is_org_member'] = user.is_robot or user.is_org_member + + return user_json + + team_data = [entity_team_view(team) for team in teams] + user_data = [user_view(user) for user in users] + + return { + 'results': team_data + user_data + } + + + def team_view(orgname, team): + view_permission = ViewTeamPermission(orgname, team.name) + role = model.get_team_org_role(team).name + return { + 'id': team.id, + 'name': team.name, + 'description': team.description, + 'can_view': view_permission.can(), + 'role': role + } + + +@resource('/v1/find/repository') +class FindRepositories(ApiResource): + """ Resource for finding repositories. """ + @parse_args + @query_param('query', 'The prefix to use when querying for repositories.', type=str, default='') + @require_scope(scopes.READ_REPO) + @nickname('findRepos') + def get(self, args): + """ Get a list of repositories that match the specified prefix query. """ + prefix = args['query'] + + def repo_view(repo): + return { + 'namespace': repo.namespace, + 'name': repo.name, + 'description': repo.description + } + + username = None + user = get_authenticated_user() + if user is not None: + username = user.username + + matching = model.get_matching_repositories(prefix, username) + return { + 'repositories': [repo_view(repo) for repo in matching + if (repo.visibility.name == 'public' or + ReadRepositoryPermission(repo.namespace, repo.name).can())] + } \ No newline at end of file diff --git a/endpoints/api/subscribe.py b/endpoints/api/subscribe.py new file mode 100644 index 000000000..f9f9d7f14 --- /dev/null +++ b/endpoints/api/subscribe.py @@ -0,0 +1,98 @@ +import logging +import stripe + +from endpoints.api import request_error, log_action, NotFound +from endpoints.common import check_repository_usage +from data import model +from data.plans import PLANS + + +logger = logging.getLogger(__name__) + + +def carderror_response(exc): + return {'carderror': exc.message}, 402 + + +def subscription_view(stripe_subscription, used_repos): + return { + 'currentPeriodStart': stripe_subscription.current_period_start, + 'currentPeriodEnd': stripe_subscription.current_period_end, + 'plan': stripe_subscription.plan.id, + 'usedPrivateRepos': used_repos, + } + + +def subscribe(user, plan, token, require_business_plan): + plan_found = None + for plan_obj in PLANS: + if plan_obj['stripeId'] == plan: + plan_found = plan_obj + + if not plan_found or plan_found['deprecated']: + logger.warning('Plan not found or deprecated: %s', plan) + raise NotFound() + + if (require_business_plan and not plan_found['bus_features'] and not + plan_found['price'] == 0): + logger.warning('Business attempting to subscribe to personal plan: %s', + user.username) + raise request_error(message='No matching plan found') + + private_repos = model.get_private_repo_count(user.username) + + # This is the default response + response_json = { + 'plan': plan, + 'usedPrivateRepos': private_repos, + } + status_code = 200 + + if not user.stripe_id: + # Check if a non-paying user is trying to subscribe to a free plan + if not plan_found['price'] == 0: + # They want a real paying plan, create the customer and plan + # simultaneously + card = token + + try: + cus = stripe.Customer.create(email=user.email, plan=plan, card=card) + user.stripe_id = cus.id + user.save() + check_repository_usage(user, plan_found) + log_action('account_change_plan', user.username, {'plan': plan}) + except stripe.CardError as e: + return carderror_response(e) + + response_json = subscription_view(cus.subscription, private_repos) + status_code = 201 + + else: + # Change the plan + cus = stripe.Customer.retrieve(user.stripe_id) + + if plan_found['price'] == 0: + if cus.subscription is not None: + # We only have to cancel the subscription if they actually have one + cus.cancel_subscription() + cus.save() + check_repository_usage(user, plan_found) + log_action('account_change_plan', user.username, {'plan': plan}) + + else: + # User may have been a previous customer who is resubscribing + if token: + cus.card = token + + cus.plan = plan + + try: + cus.save() + except stripe.CardError as e: + return carderror_response(e) + + response_json = subscription_view(cus.subscription, private_repos) + check_repository_usage(user, plan_found) + log_action('account_change_plan', user.username, {'plan': plan}) + + return response_json, status_code diff --git a/endpoints/api/tag.py b/endpoints/api/tag.py new file mode 100644 index 000000000..10d466e81 --- /dev/null +++ b/endpoints/api/tag.py @@ -0,0 +1,95 @@ +from flask import request + +from endpoints.api import (resource, nickname, require_repo_read, require_repo_write, + RepositoryParamResource, log_action, NotFound, validate_json_request) +from endpoints.api.image import image_view +from data import model +from auth.auth_context import get_authenticated_user + + +@resource('/v1/repository//tag/') +class RepositoryTag(RepositoryParamResource): + """ Resource for managing repository tags. """ + schemas = { + 'MoveTag': { + 'id': 'MoveTag', + 'type': 'object', + 'description': 'Description of to which image a new or existing tag should point', + 'required': [ + 'image', + ], + 'properties': { + 'image': { + 'type': 'string', + 'description': 'Image identifier to which the tag should point', + }, + }, + }, + } + + @require_repo_write + @nickname('changeTagImage') + @validate_json_request('MoveTag') + def put(self, namespace, repository, tag): + """ Change which image a tag points to or create a new tag.""" + image_id = request.get_json()['image'] + image = model.get_repo_image(namespace, repository, image_id) + if not image: + raise NotFound() + + original_image_id = None + try: + original_tag_image = model.get_tag_image(namespace, repository, tag) + if original_tag_image: + original_image_id = original_tag_image.docker_image_id + except model.DataModelException: + # This is a new tag. + pass + + model.create_or_update_tag(namespace, repository, tag, image_id) + model.garbage_collect_repository(namespace, repository) + + username = get_authenticated_user().username + log_action('move_tag' if original_image_id else 'create_tag', namespace, + { 'username': username, 'repo': repository, 'tag': tag, + 'image': image_id, 'original_image': original_image_id }, + repo=model.get_repository(namespace, repository)) + + return 'Updated', 201 + + @require_repo_write + @nickname('deleteFullTag') + def delete(self, namespace, repository, tag): + """ Delete the specified repository tag. """ + model.delete_tag(namespace, repository, tag) + model.garbage_collect_repository(namespace, repository) + + username = get_authenticated_user().username + log_action('delete_tag', namespace, + {'username': username, 'repo': repository, 'tag': tag}, + repo=model.get_repository(namespace, repository)) + + return 'Deleted', 204 + + +@resource('/v1/repository//tag//images') +class RepositoryTagImages(RepositoryParamResource): + """ Resource for listing the images in a specific repository tag. """ + @require_repo_read + @nickname('listTagImages') + def get(self, namespace, repository, tag): + """ List the images for the specified repository tag. """ + try: + tag_image = model.get_tag_image(namespace, repository, tag) + except model.DataModelException: + raise NotFound() + + parent_images = model.get_parent_images(tag_image) + + parents = list(parent_images) + parents.reverse() + all_images = [tag_image] + parents + + return { + 'images': [image_view(image) for image in all_images] + } diff --git a/endpoints/api/team.py b/endpoints/api/team.py new file mode 100644 index 000000000..e82a2bd69 --- /dev/null +++ b/endpoints/api/team.py @@ -0,0 +1,179 @@ +from flask import request + +from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error, + log_action, Unauthorized, NotFound, internal_only) +from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission +from auth.auth_context import get_authenticated_user +from data import model + + +def team_view(orgname, team): + view_permission = ViewTeamPermission(orgname, team.name) + role = model.get_team_org_role(team).name + return { + 'id': team.id, + 'name': team.name, + 'description': team.description, + 'can_view': view_permission.can(), + 'role': role + } + +def member_view(member): + return { + 'name': member.username, + 'kind': 'user', + 'is_robot': member.robot, + } + + +@resource('/v1/organization//team/') +@internal_only +class OrganizationTeam(ApiResource): + """ Resource for manging an organization's teams. """ + schemas = { + 'TeamDescription': { + 'id': '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', + }, + }, + }, + } + + @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(): + team = None + + details = request.get_json() + is_existing = False + try: + team = model.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.get_organization(orgname) + team = model.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 = model.get_team_org_role(team).name + if role != details['role']: + team = model.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), 200 + + raise Unauthorized() + + @nickname('deleteOrganizationTeam') + def delete(self, orgname, teamname): + """ Delete the specified team. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + model.remove_team(orgname, teamname, get_authenticated_user().username) + log_action('org_delete_team', orgname, {'team': teamname}) + return 'Deleted', 204 + + raise Unauthorized() + + +@resource('/v1/organization//team//members') +@internal_only +class TeamMemberList(ApiResource): + """ Resource for managing the list of members for a team. """ + @nickname('getOrganizationTeamMembers') + def get(self, orgname, teamname): + """ Retrieve the list of members for the specified team. """ + view_permission = ViewTeamPermission(orgname, teamname) + edit_permission = AdministerOrganizationPermission(orgname) + + if view_permission.can(): + team = None + try: + team = model.get_organization_team(orgname, teamname) + except model.InvalidTeamException: + raise NotFound() + + members = model.get_organization_team_members(team.id) + return { + 'members': {m.username : member_view(m) for m in members}, + 'can_edit': edit_permission.can() + } + + raise Unauthorized() + + +@resource('/v1/organization//team//members/') +@internal_only +class TeamMember(ApiResource): + """ Resource for managing individual members of a team. """ + @nickname('updateOrganizationTeamMember') + def put(self, orgname, teamname, membername): + """ Add a member to an existing team. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + team = None + user = None + + # Find the team. + try: + team = model.get_organization_team(orgname, teamname) + except model.InvalidTeamException: + raise NotFound() + + # Find the user. + user = model.get_user(membername) + if not user: + raise request_error(message='Unknown user') + + # Add the user to the team. + model.add_user_to_team(user, team) + log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname}) + return member_view(user) + + raise Unauthorized() + + @nickname('deleteOrganizationTeamMember') + def delete(self, orgname, teamname, membername): + """ Delete an existing member of a team. """ + permission = AdministerOrganizationPermission(orgname) + if permission.can(): + # Remote the user from the team. + invoking_user = get_authenticated_user().username + model.remove_user_from_team(orgname, teamname, membername, invoking_user) + log_action('org_remove_team_member', orgname, {'member': membername, 'team': teamname}) + return 'Deleted', 204 + + raise Unauthorized() diff --git a/endpoints/api/trigger.py b/endpoints/api/trigger.py new file mode 100644 index 000000000..1eb7cd169 --- /dev/null +++ b/endpoints/api/trigger.py @@ -0,0 +1,267 @@ +import json +import logging + +from flask import request, url_for +from urllib import quote +from urlparse import urlunparse + +from app import app +from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin, + log_action, request_error, query_param, parse_args, internal_only, + validate_json_request, api, Unauthorized, NotFound, InvalidRequest) +from endpoints.api.build import (build_status_view, trigger_view, RepositoryBuildStatus, + get_trigger_config) +from endpoints.common import start_build +from endpoints.trigger import (BuildTrigger as BuildTriggerBase, TriggerDeactivationException, + TriggerActivationException, EmptyRepositoryException) +from data import model +from auth.permissions import UserAdminPermission + + +logger = logging.getLogger(__name__) + + +def _prepare_webhook_url(scheme, username, password, hostname, path): + auth_hostname = '%s:%s@%s' % (quote(username), quote(password), hostname) + return urlunparse((scheme, auth_hostname, path, '', '', '')) + + +@resource('/v1/repository//trigger/') +class BuildTriggerList(RepositoryParamResource): + """ Resource for listing repository build triggers. """ + + @require_repo_admin + @nickname('listBuildTriggers') + def get(self, namespace, repository): + """ List the triggers for the specified repository. """ + triggers = model.list_build_triggers(namespace, repository) + return { + 'triggers': [trigger_view(trigger) for trigger in triggers] + } + + +@resource('/v1/repository//trigger/') +class BuildTrigger(RepositoryParamResource): + """ Resource for managing specific build triggers. """ + + @require_repo_admin + @nickname('getBuildTrigger') + def get(self, namespace, repository, trigger_uuid): + """ Get information for the specified build trigger. """ + try: + trigger = model.get_build_trigger(namespace, repository, trigger_uuid) + except model.InvalidBuildTriggerException: + raise NotFound() + + return trigger_view(trigger) + + @require_repo_admin + @nickname('deleteBuildTrigger') + def delete(self, namespace, repository, trigger_uuid): + """ Delete the specified build trigger. """ + try: + trigger = model.get_build_trigger(namespace, repository, trigger_uuid) + except model.InvalidBuildTriggerException: + raise NotFound() + + handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name) + config_dict = get_trigger_config(trigger) + if handler.is_active(config_dict): + try: + handler.deactivate(trigger.auth_token, config_dict) + except TriggerDeactivationException as ex: + # We are just going to eat this error + logger.warning('Trigger deactivation problem: %s', ex) + + log_action('delete_repo_trigger', namespace, + {'repo': repository, 'trigger_id': trigger_uuid, + 'service': trigger.service.name, 'config': config_dict}, + repo=model.get_repository(namespace, repository)) + + trigger.delete_instance(recursive=True) + return 'No Content', 204 + + +@resource('/v1/repository//trigger//subdir') +@internal_only +class BuildTriggerSubdirs(RepositoryParamResource): + """ Custom verb for fetching the subdirs which are buildable for a trigger. """ + schemas = { + 'BuildTriggerSubdirRequest': { + 'id': 'BuildTriggerSubdirRequest', + 'type': 'object', + 'description': 'Arbitrary json.', + }, + } + + @require_repo_admin + @nickname('listBuildTriggerSubdirs') + @validate_json_request('BuildTriggerSubdirRequest') + def post(self, namespace, repository, trigger_uuid): + """ List the subdirectories available for the specified build trigger and source. """ + try: + trigger = model.get_build_trigger(namespace, repository, trigger_uuid) + except model.InvalidBuildTriggerException: + raise NotFound() + + handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name) + user_permission = UserAdminPermission(trigger.connected_user.username) + if user_permission.can(): + new_config_dict = request.get_json() + + try: + subdirs = handler.list_build_subdirs(trigger.auth_token, new_config_dict) + return { + 'subdir': subdirs, + 'status': 'success' + } + except EmptyRepositoryException as exc: + return { + 'status': 'error', + 'message': exc.msg + } + else: + raise Unauthorized() + + +@resource('/v1/repository//trigger//activate') +@internal_only +class BuildTriggerActivate(RepositoryParamResource): + """ Custom verb for activating a build trigger once all required information has been collected. + """ + schemas = { + 'BuildTriggerActivateRequest': { + 'id': 'BuildTriggerActivateRequest', + 'type': 'object', + 'description': 'Arbitrary json.', + }, + } + + @require_repo_admin + @nickname('activateBuildTrigger') + @validate_json_request('BuildTriggerActivateRequest') + def post(self, namespace, repository, trigger_uuid): + """ Activate the specified build trigger. """ + try: + trigger = model.get_build_trigger(namespace, repository, trigger_uuid) + except model.InvalidBuildTriggerException: + raise NotFound() + + handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name) + existing_config_dict = get_trigger_config(trigger) + if handler.is_active(existing_config_dict): + raise InvalidRequest('Trigger config is not sufficient for activation.') + + user_permission = UserAdminPermission(trigger.connected_user.username) + if user_permission.can(): + new_config_dict = request.get_json() + + token_name = 'Build Trigger: %s' % trigger.service.name + token = model.create_delegate_token(namespace, repository, token_name, + 'write') + + try: + repository_path = '%s/%s' % (trigger.repository.namespace, + trigger.repository.name) + path = url_for('webhooks.build_trigger_webhook', + repository=repository_path, trigger_uuid=trigger.uuid) + authed_url = _prepare_webhook_url(app.config['URL_SCHEME'], '$token', + token.code, app.config['URL_HOST'], + path) + + final_config = handler.activate(trigger.uuid, authed_url, + trigger.auth_token, new_config_dict) + except TriggerActivationException as exc: + token.delete_instance() + raise request_error(message=exc.message) + + # Save the updated config. + trigger.config = json.dumps(final_config) + trigger.write_token = token + trigger.save() + + # Log the trigger setup. + repo = model.get_repository(namespace, repository) + log_action('setup_repo_trigger', namespace, + {'repo': repository, 'namespace': namespace, + 'trigger_id': trigger.uuid, 'service': trigger.service.name, + 'config': final_config}, repo=repo) + + return trigger_view(trigger) + else: + raise Unauthorized() + + +@resource('/v1/repository//trigger//start') +class ActivateBuildTrigger(RepositoryParamResource): + """ Custom verb to manually activate a build trigger. """ + + @require_repo_admin + @nickname('manuallyStartBuildTrigger') + def post(self, namespace, repository, trigger_uuid): + """ Manually start a build from the specified trigger. """ + try: + trigger = model.get_build_trigger(namespace, repository, trigger_uuid) + except model.InvalidBuildTriggerException: + raise NotFound() + + handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name) + config_dict = get_trigger_config(trigger) + if not handler.is_active(config_dict): + raise InvalidRequest('Trigger is not active.') + + specs = handler.manual_start(trigger.auth_token, config_dict) + dockerfile_id, tags, name, subdir = specs + + repo = model.get_repository(namespace, repository) + + build_request = start_build(repo, dockerfile_id, tags, name, subdir, True) + + resp = build_status_view(build_request, True) + repo_string = '%s/%s' % (namespace, repository) + headers = { + 'Location': api.url_for(RepositoryBuildStatus, repository=repo_string, + build_uuid=build_request.uuid), + } + return resp, 201, headers + + +@resource('/v1/repository//trigger//builds') +class TriggerBuildList(RepositoryParamResource): + """ Resource to represent builds that were activated from the specified trigger. """ + @require_repo_admin + @parse_args + @query_param('limit', 'The maximum number of builds to return', type=int, default=5) + @nickname('listTriggerRecentBuilds') + def get(self, args, namespace, repository, trigger_uuid): + """ List the builds started by the specified trigger. """ + limit = args['limit'] + builds = list(model.list_trigger_builds(namespace, repository, + trigger_uuid, limit)) + return { + 'builds': [build_status_view(build, True) for build in builds] + } + + +@resource('/v1/repository//trigger//sources') +@internal_only +class BuildTriggerSources(RepositoryParamResource): + """ Custom verb to fetch the list of build sources for the trigger config. """ + @require_repo_admin + @nickname('listTriggerBuildSources') + def get(self, namespace, repository, trigger_uuid): + """ List the build sources for the trigger configuration thus far. """ + try: + trigger = model.get_build_trigger(namespace, repository, trigger_uuid) + except model.InvalidBuildTriggerException: + raise NotFound() + + user_permission = UserAdminPermission(trigger.connected_user.username) + if user_permission.can(): + trigger_handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name) + + return { + 'sources': trigger_handler.list_build_sources(trigger.auth_token) + } + else: + raise Unauthorized() diff --git a/endpoints/api/user.py b/endpoints/api/user.py new file mode 100644 index 000000000..ba245209c --- /dev/null +++ b/endpoints/api/user.py @@ -0,0 +1,450 @@ +import logging +import stripe +import json + +from flask import request +from flask.ext.login import logout_user +from flask.ext.principal import identity_changed, AnonymousIdentity + +from app import app +from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, + log_action, internal_only, NotFound, require_user_admin, + InvalidToken, require_scope, format_date) +from endpoints.api.subscribe import subscribe +from endpoints.common import common_login +from data import model +from data.plans import get_plan +from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission, + UserAdminPermission, UserReadPermission) +from auth.auth_context import get_authenticated_user +from auth import scopes +from util.gravatar import compute_hash +from util.email import (send_confirmation_email, send_recovery_email, + send_change_email) + + +logger = logging.getLogger(__name__) + + +def user_view(user): + def org_view(o): + admin_org = AdministerOrganizationPermission(o.username) + return { + 'name': o.username, + 'gravatar': compute_hash(o.email), + 'is_org_admin': admin_org.can(), + 'can_create_repo': admin_org.can() or CreateRepositoryPermission(o.username).can(), + 'preferred_namespace': not (o.stripe_id is None) + } + + organizations = model.get_user_organizations(user.username) + + def login_view(login): + return { + 'service': login.service.name, + 'service_identifier': login.service_ident, + } + + logins = model.list_federated_logins(user) + + user_response = { + 'verified': user.verified, + 'anonymous': False, + 'username': user.username, + 'email': user.email, + 'gravatar': compute_hash(user.email), + } + + user_admin = UserAdminPermission(user.username) + if user_admin.can(): + user_response.update({ + 'organizations': [org_view(o) for o in organizations], + 'logins': [login_view(login) for login in logins], + 'can_create_repo': True, + 'invoice_email': user.invoice_email, + 'preferred_namespace': not (user.stripe_id is None), + }) + + return user_response + + +def notification_view(notification): + return { + 'organization': notification.target.username if notification.target.organization else None, + 'kind': notification.kind.name, + 'created': format_date(notification.created), + 'metadata': json.loads(notification.metadata_json), + } + + +@resource('/v1/user/') +class User(ApiResource): + """ Operations related to users. """ + schemas = { + 'NewUser': { + 'id': 'NewUser', + 'type': 'object', + 'description': 'Fields which must be specified for a new user.', + 'required': [ + 'username', + 'password', + 'email', + ], + 'properties': { + 'username': { + 'type': 'string', + 'description': 'The user\'s username', + }, + 'password': { + 'type': 'string', + 'description': 'The user\'s password', + }, + 'email': { + 'type': 'string', + 'description': 'The user\'s email address', + }, + } + }, + 'UpdateUser': { + 'id': 'UpdateUser', + 'type': 'object', + 'description': 'Fields which can be updated in a user.', + 'properties': { + 'password': { + 'type': 'string', + 'description': 'The user\'s password', + }, + 'invoice_email': { + 'type': 'boolean', + 'description': 'Whether the user desires to receive an invoice email.', + }, + 'email': { + 'type': 'string', + 'description': 'The user\'s email address', + }, + }, + }, + } + + @require_scope(scopes.READ_USER) + @nickname('getLoggedInUser') + def get(self): + """ Get user information for the authenticated user. """ + user = get_authenticated_user() + if user is None or user.organization or not UserReadPermission(user.username).can(): + raise InvalidToken("Requires authentication", payload={'session_required': False}) + + return user_view(user) + + @require_user_admin + @nickname('changeUserDetails') + @internal_only + @validate_json_request('UpdateUser') + def put(self): + """ Update a users details such as password or email. """ + user = get_authenticated_user() + user_data = request.get_json() + + try: + if 'password' in user_data: + logger.debug('Changing password for user: %s', user.username) + log_action('account_change_password', user.username) + model.change_password(user, user_data['password']) + + if 'invoice_email' in user_data: + logger.debug('Changing invoice_email for user: %s', user.username) + model.change_invoice_email(user, user_data['invoice_email']) + + if 'email' in user_data and user_data['email'] != user.email: + new_email = user_data['email'] + if model.find_user_by_email(new_email): + # Email already used. + raise request_error(message='E-mail address already used') + + logger.debug('Sending email to change email address for user: %s', + user.username) + code = model.create_confirm_email_code(user, new_email=new_email) + send_change_email(user.username, user_data['email'], code.code) + + except model.InvalidPasswordException, ex: + raise request_error(exception=ex) + + return user_view(user) + + @nickname('createNewUser') + @internal_only + @validate_json_request('NewUser') + def post(self): + """ Create a new user. """ + user_data = request.get_json() + + existing_user = model.get_user(user_data['username']) + if existing_user: + raise request_error(message='The username already exists') + + try: + new_user = model.create_user(user_data['username'], user_data['password'], + user_data['email']) + code = model.create_confirm_email_code(new_user) + send_confirmation_email(new_user.username, new_user.email, code.code) + return 'Created', 201 + except model.DataModelException as ex: + raise request_error(exception=ex) + +@resource('/v1/user/private') +@internal_only +class PrivateRepositories(ApiResource): + """ Operations dealing with the available count of private repositories. """ + @require_user_admin + @nickname('getUserPrivateAllowed') + def get(self): + """ Get the number of private repos this user has, and whether they are allowed to create more. + """ + user = get_authenticated_user() + private_repos = model.get_private_repo_count(user.username) + repos_allowed = 0 + + if user.stripe_id: + cus = stripe.Customer.retrieve(user.stripe_id) + if cus.subscription: + plan = get_plan(cus.subscription.plan.id) + if plan: + repos_allowed = plan['privateRepos'] + + return { + 'privateCount': private_repos, + 'privateAllowed': (private_repos < repos_allowed) + } + + +def conduct_signin(username_or_email, password): + needs_email_verification = False + invalid_credentials = False + + verified = model.verify_user(username_or_email, password) + if verified: + if common_login(verified): + return {'success': True} + else: + needs_email_verification = True + + else: + invalid_credentials = True + + return { + 'needsEmailVerification': needs_email_verification, + 'invalidCredentials': invalid_credentials, + }, 403 + + +@resource('/v1/user/convert') +@internal_only +class ConvertToOrganization(ApiResource): + """ Operations for converting a user to an organization. """ + schemas = { + 'ConvertUser': { + 'id': 'ConvertUser', + 'type': 'object', + 'description': 'Information required to convert a user to an organization.', + 'required': [ + 'adminUser', + 'adminPassword', + 'plan', + ], + 'properties': { + 'adminUser': { + 'type': 'string', + 'description': 'The user who will become an org admin\'s username', + }, + 'adminPassword': { + 'type': 'string', + 'description': 'The user who will become an org admin\'s password', + }, + 'plan': { + 'type': 'string', + 'description': 'The plan to which the organizatino should be subscribed', + }, + }, + }, + } + + @require_user_admin + @nickname('convertUserToOrganization') + @validate_json_request('ConvertUser') + def post(self): + """ Convert the user to an organization. """ + user = get_authenticated_user() + convert_data = request.get_json() + + # Ensure that the new admin user is the not user being converted. + admin_username = convert_data['adminUser'] + if admin_username == user.username: + raise request_error(reason='invaliduser', + message='The admin user is not valid') + + # Ensure that the sign in credentials work. + admin_password = convert_data['adminPassword'] + if not model.verify_user(admin_username, admin_password): + raise request_error(reason='invaliduser', + message='The admin user credentials are not valid') + + # Subscribe the organization to the new plan. + plan = convert_data['plan'] + subscribe(user, plan, None, True) # Require business plans + + # Convert the user to an organization. + model.convert_user_to_organization(user, model.get_user(admin_username)) + log_action('account_convert', user.username) + + # And finally login with the admin credentials. + return conduct_signin(admin_username, admin_password) + + +@resource('/v1/signin') +@internal_only +class Signin(ApiResource): + """ Operations for signing in the user. """ + schemas = { + 'SigninUser': { + 'id': 'SigninUser', + 'type': 'object', + 'description': 'Information required to sign in a user.', + 'required': [ + 'username', + 'password', + ], + 'properties': { + 'username': { + 'type': 'string', + 'description': 'The user\'s username', + }, + 'password': { + 'type': 'string', + 'description': 'The user\'s password', + }, + }, + }, + } + + @nickname('signinUser') + @validate_json_request('SigninUser') + def post(self): + """ Sign in the user with the specified credentials. """ + signin_data = request.get_json() + if not signin_data: + raise NotFound() + + username = signin_data['username'] + password = signin_data['password'] + + return conduct_signin(username, password) + + +@resource('/v1/signout') +@internal_only +class Signout(ApiResource): + """ Resource for signing out users. """ + @nickname('logout') + def post(self): + """ Request that the current user be signed out. """ + logout_user() + identity_changed.send(app, identity=AnonymousIdentity()) + return {'success': True} + + +@resource("/v1/recovery") +@internal_only +class Recovery(ApiResource): + """ Resource for requesting a password recovery email. """ + schemas = { + 'RequestRecovery': { + 'id': 'RequestRecovery', + 'type': 'object', + 'description': 'Information required to sign in a user.', + 'required': [ + 'email', + ], + 'properties': { + 'email': { + 'type': 'string', + 'description': 'The user\'s email address', + }, + }, + }, + } + + @nickname('requestRecoveryEmail') + @validate_json_request('RequestRecovery') + def post(self): + """ Request a password recovery email.""" + email = request.get_json()['email'] + code = model.create_reset_password_email_code(email) + send_recovery_email(email, code.code) + return 'Created', 201 + + +@resource('/v1/user/notifications') +@internal_only +class UserNotificationList(ApiResource): + @require_user_admin + @nickname('listUserNotifications') + def get(self): + notifications = model.list_notifications(get_authenticated_user()) + return { + 'notifications': [notification_view(notification) for notification in notifications] + } + + +def authorization_view(access_token): + oauth_app = access_token.application + return { + 'application': { + 'name': oauth_app.name, + 'description': oauth_app.description, + 'url': oauth_app.application_uri, + 'gravatar': compute_hash(oauth_app.gravatar_email or oauth_app.organization.email), + 'organization': { + 'name': oauth_app.organization.username, + 'gravatar': compute_hash(oauth_app.organization.email) + } + }, + 'scopes': scopes.get_scope_information(access_token.scope), + 'uuid': access_token.uuid + } + +@resource('/v1/user/authorizations') +@internal_only +class UserAuthorizationList(ApiResource): + @require_user_admin + @nickname('listUserAuthorizations') + def get(self): + access_tokens = model.oauth.list_access_tokens_for_user(get_authenticated_user()) + + return { + 'authorizations': [authorization_view(token) for token in access_tokens] + } + + +@resource('/v1/user/authorizations/') +@internal_only +class UserAuthorization(ApiResource): + @require_user_admin + @nickname('getUserAuthorization') + def get(self, access_token_uuid): + access_token = model.oauth.lookup_access_token_for_user(get_authenticated_user(), + access_token_uuid) + if not access_token: + raise NotFound() + + return authorization_view(access_token) + + @require_user_admin + @nickname('deleteUserAuthorization') + def delete(self, access_token_uuid): + access_token = model.oauth.lookup_access_token_for_user(get_authenticated_user(), + access_token_uuid) + if not access_token: + raise NotFound() + + access_token.delete_instance(recursive=True, delete_nullable=True) + return 'Deleted', 204 diff --git a/endpoints/api/webhook.py b/endpoints/api/webhook.py new file mode 100644 index 000000000..b38d7ec43 --- /dev/null +++ b/endpoints/api/webhook.py @@ -0,0 +1,77 @@ +import json + +from flask import request + +from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin, + log_action, validate_json_request, api, NotFound) +from data import model + + +def webhook_view(webhook): + return { + 'public_id': webhook.public_id, + 'parameters': json.loads(webhook.parameters), + } + + +@resource('/v1/repository//webhook/') +class WebhookList(RepositoryParamResource): + """ Resource for dealing with listing and creating webhooks. """ + schemas = { + 'WebhookCreateRequest': { + 'id': 'WebhookCreateRequest', + 'type': 'object', + 'description': 'Arbitrary json.', + }, + } + + @require_repo_admin + @nickname('createWebhook') + @validate_json_request('WebhookCreateRequest') + def post(self, namespace, repository): + """ Create a new webhook for the specified repository. """ + repo = model.get_repository(namespace, repository) + webhook = model.create_webhook(repo, request.get_json()) + resp = webhook_view(webhook) + repo_string = '%s/%s' % (namespace, repository) + headers = { + 'Location': api.url_for(Webhook, repository=repo_string, public_id=webhook.public_id), + } + log_action('add_repo_webhook', namespace, + {'repo': repository, 'webhook_id': webhook.public_id}, + repo=repo) + return resp, 201, headers + + @require_repo_admin + @nickname('listWebhooks') + def get(self, namespace, repository): + """ List the webhooks for the specified repository. """ + webhooks = model.list_webhooks(namespace, repository) + return { + 'webhooks': [webhook_view(webhook) for webhook in webhooks] + } + + +@resource('/v1/repository//webhook/') +class Webhook(RepositoryParamResource): + """ Resource for dealing with specific webhooks. """ + @require_repo_admin + @nickname('getWebhook') + def get(self, namespace, repository, public_id): + """ Get information for the specified webhook. """ + try: + webhook = model.get_webhook(namespace, repository, public_id) + except model.InvalidWebhookException: + raise NotFound() + + return webhook_view(webhook) + + @require_repo_admin + @nickname('deleteWebhook') + def delete(self, namespace, repository, public_id): + """ Delete the specified webhook. """ + model.delete_webhook(namespace, repository, public_id) + log_action('delete_repo_webhook', namespace, + {'repo': repository, 'webhook_id': public_id}, + repo=model.get_repository(namespace, repository)) + return 'No Content', 204 diff --git a/endpoints/callbacks.py b/endpoints/callbacks.py new file mode 100644 index 000000000..0f110c098 --- /dev/null +++ b/endpoints/callbacks.py @@ -0,0 +1,133 @@ +import logging + +from flask import request, redirect, url_for, Blueprint +from flask.ext.login import current_user + +from endpoints.common import render_page_template, common_login +from app import app, mixpanel +from data import model +from util.names import parse_repository_name +from util.http import abort +from auth.permissions import AdministerRepositoryPermission +from auth.auth import require_session_login + + +logger = logging.getLogger(__name__) + +client = app.config['HTTPCLIENT'] + + +callback = Blueprint('callback', __name__) + + +def exchange_github_code_for_token(code): + code = request.args.get('code') + payload = { + 'client_id': app.config['GITHUB_CLIENT_ID'], + 'client_secret': app.config['GITHUB_CLIENT_SECRET'], + 'code': code, + } + headers = { + 'Accept': 'application/json' + } + + get_access_token = client.post(app.config['GITHUB_TOKEN_URL'], + params=payload, headers=headers) + + token = get_access_token.json()['access_token'] + return token + + +def get_github_user(token): + token_param = { + 'access_token': token, + } + get_user = client.get(app.config['GITHUB_USER_URL'], params=token_param) + + return get_user.json() + + +@callback.route('/github/callback', methods=['GET']) +def github_oauth_callback(): + error = request.args.get('error', None) + if error: + return render_page_template('githuberror.html', error_message=error) + + token = exchange_github_code_for_token(request.args.get('code')) + user_data = get_github_user(token) + + username = user_data['login'] + github_id = user_data['id'] + + v3_media_type = { + 'Accept': 'application/vnd.github.v3' + } + + token_param = { + 'access_token': token, + } + get_email = client.get(app.config['GITHUB_USER_EMAILS'], params=token_param, + headers=v3_media_type) + + # We will accept any email, but we prefer the primary + found_email = None + for user_email in get_email.json(): + found_email = user_email['email'] + if user_email['primary']: + break + + to_login = model.verify_federated_login('github', github_id) + if not to_login: + # try to create the user + try: + to_login = model.create_federated_user(username, found_email, 'github', + github_id) + + # Success, tell mixpanel + mixpanel.track(to_login.username, 'register', {'service': 'github'}) + + state = request.args.get('state', None) + if state: + logger.debug('Aliasing with state: %s' % state) + mixpanel.alias(to_login.username, state) + + except model.DataModelException, ex: + return render_page_template('githuberror.html', error_message=ex.message) + + if common_login(to_login): + return redirect(url_for('web.index')) + + return render_page_template('githuberror.html') + + +@callback.route('/github/callback/attach', methods=['GET']) +@require_session_login +def github_oauth_attach(): + token = exchange_github_code_for_token(request.args.get('code')) + user_data = get_github_user(token) + github_id = user_data['id'] + user_obj = current_user.db_user() + model.attach_federated_login(user_obj, 'github', github_id) + return redirect(url_for('web.user')) + + +@callback.route('/github/callback/trigger/', methods=['GET']) +@require_session_login +@parse_repository_name +def attach_github_build_trigger(namespace, repository): + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + token = exchange_github_code_for_token(request.args.get('code')) + repo = model.get_repository(namespace, repository) + if not repo: + msg = 'Invalid repository: %s/%s' % (namespace, repository) + abort(404, message=msg) + + trigger = model.create_build_trigger(repo, 'github', token, current_user.db_user()) + admin_path = '%s/%s/%s' % (namespace, repository, 'admin') + full_url = '%s%s%s' % (url_for('web.repository', path=admin_path), '?tab=trigger&new_trigger=', + trigger.uuid) + logger.debug('Redirecting to full url: %s' % full_url) + return redirect(full_url) + + abort(403) diff --git a/endpoints/common.py b/endpoints/common.py index 64dfd5e4f..4a8c03eb9 100644 --- a/endpoints/common.py +++ b/endpoints/common.py @@ -1,18 +1,41 @@ import logging -import os -import base64 +import urlparse +import json +import string -from flask import request, abort, session, make_response +from flask import make_response, render_template, request from flask.ext.login import login_user, UserMixin from flask.ext.principal import identity_changed +from random import SystemRandom from data import model +from data.queue import dockerfile_build_queue from app import app, login_manager from auth.permissions import QuayDeferredPermissionUser +from auth import scopes +from endpoints.api.discovery import swagger_route_data +from werkzeug.routing import BaseConverter logger = logging.getLogger(__name__) +route_data = None + +class RepoPathConverter(BaseConverter): + regex = '[\.a-zA-Z0-9_\-]+/[\.a-zA-Z0-9_\-]+' + weight = 200 + +app.url_map.converters['repopath'] = RepoPathConverter + + +def get_route_data(): + global route_data + if route_data: + return route_data + + route_data = swagger_route_data(include_internal=True, compact=True) + return route_data + def truthy_param(param): return param not in {False, 'false', 'False', '0', 'FALSE', '', 'null'} @@ -20,9 +43,10 @@ def truthy_param(param): @login_manager.user_loader def load_user(username): - logger.debug('Loading user: %s' % username) + logger.debug('User loader loading deferred user: %s' % username) return _LoginWrappedDBUser(username) + class _LoginWrappedDBUser(UserMixin): def __init__(self, db_username, db_user=None): @@ -47,7 +71,7 @@ class _LoginWrappedDBUser(UserMixin): def common_login(db_user): if login_user(_LoginWrappedDBUser(db_user.username, db_user)): logger.debug('Successfully signed in as: %s' % db_user.username) - new_identity = QuayDeferredPermissionUser(db_user.username, 'username') + new_identity = QuayDeferredPermissionUser(db_user.username, 'username', {scopes.DIRECT_LOGIN}) identity_changed.send(app, identity=new_identity) return True else: @@ -58,19 +82,68 @@ def common_login(db_user): @app.errorhandler(model.DataModelException) def handle_dme(ex): logger.exception(ex) - return make_response(ex.message, 400) + return make_response(json.dumps({'message': ex.message}), 400) -@app.errorhandler(KeyError) -def handle_dme_key_error(ex): - logger.exception(ex) - return make_response('Invalid key: %s' % ex.message, 400) +def random_string(): + random = SystemRandom() + return ''.join([random.choice(string.ascii_uppercase + string.digits) for _ in range(8)]) + +def render_page_template(name, **kwargs): + resp = make_response(render_template(name, route_data=json.dumps(get_route_data()), + cache_buster=random_string(), **kwargs)) + resp.headers['X-FRAME-OPTIONS'] = 'DENY' + return resp -def generate_csrf_token(): - if '_csrf_token' not in session: - session['_csrf_token'] = base64.b64encode(os.urandom(48)) +def check_repository_usage(user_or_org, plan_found): + private_repos = model.get_private_repo_count(user_or_org.username) + repos_allowed = plan_found['privateRepos'] - return session['_csrf_token'] + if private_repos > repos_allowed: + model.create_notification('over_private_usage', user_or_org, {'namespace': user_or_org.username}) + else: + model.delete_notifications_by_kind(user_or_org, 'over_private_usage') -app.jinja_env.globals['csrf_token'] = generate_csrf_token + +def start_build(repository, dockerfile_id, tags, build_name, subdir, manual, + trigger=None): + host = urlparse.urlparse(request.url).netloc + repo_path = '%s/%s/%s' % (host, repository.namespace, repository.name) + + token = model.create_access_token(repository, 'write') + logger.debug('Creating build %s with repo %s tags %s and dockerfile_id %s', + build_name, repo_path, tags, dockerfile_id) + + job_config = { + 'docker_tags': tags, + 'repository': repo_path, + 'build_subdir': subdir, + } + build_request = model.create_repository_build(repository, token, job_config, + dockerfile_id, build_name, + trigger) + + dockerfile_build_queue.put(json.dumps({ + 'build_uuid': build_request.uuid, + 'namespace': repository.namespace, + 'repository': repository.name, + }), retries_remaining=1) + + metadata = { + 'repo': repository.name, + 'namespace': repository.namespace, + 'fileid': dockerfile_id, + 'manual': manual, + } + + if trigger: + metadata['trigger_id'] = trigger.uuid + metadata['config'] = json.loads(trigger.config) + metadata['service'] = trigger.service.name + + model.log_action('build_dockerfile', repository.namespace, + ip=request.remote_addr, metadata=metadata, + repository=repository) + + return build_request diff --git a/endpoints/csrf.py b/endpoints/csrf.py new file mode 100644 index 000000000..7fa8194bc --- /dev/null +++ b/endpoints/csrf.py @@ -0,0 +1,40 @@ +import logging +import os +import base64 + +from flask import session, request +from functools import wraps + +from app import app +from auth.auth_context import get_validated_oauth_token +from util.http import abort + + +logger = logging.getLogger(__name__) + + +def generate_csrf_token(): + if '_csrf_token' not in session: + session['_csrf_token'] = base64.b64encode(os.urandom(48)) + + return session['_csrf_token'] + + +def csrf_protect(func): + @wraps(func) + def wrapper(*args, **kwargs): + oauth_token = get_validated_oauth_token() + if oauth_token is None and request.method != "GET" and request.method != "HEAD": + token = session.get('_csrf_token', None) + found_token = request.values.get('_csrf_token', None) + + if not token or token != found_token: + msg = 'CSRF Failure. Session token was %s and request token was %s' + logger.error(msg, token, found_token) + abort(403, message='CSRF token was invalid or missing.') + + return func(*args, **kwargs) + return wrapper + + +app.jinja_env.globals['csrf_token'] = generate_csrf_token \ No newline at end of file diff --git a/endpoints/index.py b/endpoints/index.py index 549bcfec8..480c9e636 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -6,16 +6,16 @@ from flask import request, make_response, jsonify, session, Blueprint from functools import wraps from collections import OrderedDict -from data import model, userevent +from data import model +from data.model import oauth from data.queue import webhook_queue from app import mixpanel, app from auth.auth import process_auth -from auth.auth_context import get_authenticated_user, get_validated_token +from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token from util.names import parse_repository_name from util.email import send_confirmation_email -from auth.permissions import (ModifyRepositoryPermission, UserPermission, - ReadRepositoryPermission, - CreateRepositoryPermission) +from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission, + ReadRepositoryPermission, CreateRepositoryPermission) from util.http import abort @@ -79,6 +79,13 @@ def create_user(): except model.InvalidTokenException: abort(400, 'Invalid access token.', issue='invalid-access-token') + elif username == '$oauthtoken': + validated = oauth.validate_access_token(password) + if validated is not None: + return success + else: + abort(400, 'Invalid oauth access token.', issue='invalid-oauth-access-token') + elif '+' in username: try: model.verify_robot(username, password) @@ -115,7 +122,12 @@ def create_user(): @index.route('/users/', methods=['GET']) @process_auth def get_user(): - if get_authenticated_user(): + if get_validated_oauth_token(): + return jsonify({ + 'username': '$oauthtoken', + 'email': None, + }) + elif get_authenticated_user(): return jsonify({ 'username': get_authenticated_user().username, 'email': get_authenticated_user().email, @@ -131,7 +143,7 @@ def get_user(): @index.route('/users//', methods=['PUT']) @process_auth def update_user(username): - permission = UserPermission(username) + permission = UserAdminPermission(username) if permission.can(): update_request = request.get_json() @@ -214,7 +226,14 @@ def create_repository(namespace, repository): 'namespace': namespace } - if get_authenticated_user(): + if get_validated_oauth_token(): + mixpanel.track(username, 'push_repo', extra_params) + + oauth_token = get_validated_oauth_token() + metadata['oauth_token_id'] = oauth_token.id + metadata['oauth_token_application_id'] = oauth_token.application.client_id + metadata['oauth_token_application'] = oauth_token.application.name + elif get_authenticated_user(): username = get_authenticated_user().username mixpanel.track(username, 'push_repo', extra_params) @@ -230,7 +249,7 @@ def create_repository(namespace, repository): event = app.config['USER_EVENTS'].get_event(username) event.publish_event_data('docker-cli', user_data) - else: + elif get_validated_token(): mixpanel.track(get_validated_token().code, 'push_repo', extra_params) metadata['token'] = get_validated_token().friendly_name metadata['token_code'] = get_validated_token().code @@ -333,7 +352,13 @@ def get_repository_images(namespace, repository): 'repo': repository, 'namespace': namespace, } - if get_authenticated_user(): + + if get_validated_oauth_token(): + oauth_token = get_validated_oauth_token() + metadata['oauth_token_id'] = oauth_token.id + metadata['oauth_token_application_id'] = oauth_token.application.client_id + metadata['oauth_token_application'] = oauth_token.application.name + elif get_authenticated_user(): metadata['username'] = get_authenticated_user().username elif get_validated_token(): metadata['token'] = get_validated_token().friendly_name diff --git a/endpoints/realtime.py b/endpoints/realtime.py index a4df130aa..7dfd1768d 100644 --- a/endpoints/realtime.py +++ b/endpoints/realtime.py @@ -1,71 +1,52 @@ import logging -import redis import json -from functools import wraps -from flask import request, make_response, Blueprint, abort, Response -from flask.ext.login import current_user, logout_user -from data import model, userevent -from app import app +from flask import request, Blueprint, abort, Response +from flask.ext.login import current_user +from data import userevent +from auth.auth import require_session_login logger = logging.getLogger(__name__) realtime = Blueprint('realtime', __name__) -def api_login_required(f): - @wraps(f) - def decorated_view(*args, **kwargs): - if not current_user.is_authenticated(): - abort(401) - - if (current_user and current_user.db_user() and - current_user.db_user().organization): - abort(401) - - if (current_user and current_user.db_user() and - current_user.db_user().robot): - abort(401) - - return f(*args, **kwargs) - return decorated_view - @realtime.route("/user/") -@api_login_required +@require_session_login def index(): - debug_template = """ - - - - -

Server sent events

-
- - - + + + """ - return(debug_template) + return(debug_template) @realtime.route("/user/test") -@api_login_required +@require_session_login def user_test(): evt = userevent.UserEvent('logs.quay.io', current_user.db_user().username) evt.publish_event_data('test', {'foo': 2}) return 'OK' @realtime.route("/user/subscribe") -@api_login_required +@require_session_login def user_subscribe(): def wrapper(listener): for event_id, data in listener.event_stream(): diff --git a/endpoints/registry.py b/endpoints/registry.py index 51bb7219e..9727d5ccb 100644 --- a/endpoints/registry.py +++ b/endpoints/registry.py @@ -2,7 +2,7 @@ import logging import json from flask import (make_response, request, session, Response, redirect, - Blueprint) + Blueprint, abort as flask_abort) from functools import wraps from datetime import datetime from time import time @@ -259,7 +259,7 @@ def get_image_json(namespace, repository, image_id, headers): data = store.get_content(store.image_json_path(namespace, repository, image_id, uuid)) except IOError: - abort(404, message='Image data not found') + flask_abort(404) try: size = store.get_size(store.image_layer_path(namespace, repository, diff --git a/endpoints/trigger.py b/endpoints/trigger.py new file mode 100644 index 000000000..82a3284ab --- /dev/null +++ b/endpoints/trigger.py @@ -0,0 +1,286 @@ +import logging +import io +import os.path +import zipfile + +from github import Github, UnknownObjectException, GithubException +from tempfile import SpooledTemporaryFile + +from app import app + + +user_files = app.config['USERFILES'] +client = app.config['HTTPCLIENT'] + + +logger = logging.getLogger(__name__) + + +ZIPBALL = 'application/zip' +CHUNK_SIZE = 512 * 1024 + + +class BuildArchiveException(Exception): + pass + +class InvalidServiceException(Exception): + pass + +class TriggerActivationException(Exception): + pass + +class TriggerDeactivationException(Exception): + pass + +class ValidationRequestException(Exception): + pass + +class EmptyRepositoryException(Exception): + pass + + +class BuildTrigger(object): + def __init__(self): + pass + + def list_build_sources(self, auth_token): + """ + Take the auth information for the specific trigger type and load the + list of build sources(repositories). + """ + raise NotImplementedError + + def list_build_subdirs(self, auth_token, config): + """ + Take the auth information and the specified config so far and list all of + the possible subdirs containing dockerfiles. + """ + raise NotImplementedError + + def handle_trigger_request(self, request, auth_token, config): + """ + Transform the incoming request data into a set of actions. Returns a tuple + of usefiles resource id, docker tags, build name, and resource subdir. + """ + raise NotImplementedError + + def is_active(self, config): + """ + Returns True if the current build trigger is active. Inactive means further + setup is needed. + """ + raise NotImplementedError + + def activate(self, trigger_uuid, standard_webhook_url, auth_token, config): + """ + Activates the trigger for the service, with the given new configuration. + Returns new configuration that should be stored if successful. + """ + raise NotImplementedError + + def deactivate(self, auth_token, config): + """ + Deactivates the trigger for the service, removing any hooks installed in + the remote service. Returns the new config that should be stored if this + trigger is going to be re-activated. + """ + raise NotImplementedError + + def manual_start(self, auth_token, config): + """ + Manually creates a repository build for this trigger. + """ + raise NotImplementedError + + @classmethod + def service_name(cls): + """ + Particular service implemented by subclasses. + """ + raise NotImplementedError + + @classmethod + def get_trigger_for_service(cls, service): + for subc in cls.__subclasses__(): + if subc.service_name() == service: + return subc() + + raise InvalidServiceException('Unable to find service: %s' % service) + + +def raise_unsupported(): + raise io.UnsupportedOperation + + +class GithubBuildTrigger(BuildTrigger): + @staticmethod + def _get_client(auth_token): + return Github(auth_token, client_id=app.config['GITHUB_CLIENT_ID'], + client_secret=app.config['GITHUB_CLIENT_SECRET']) + + @classmethod + def service_name(cls): + return 'github' + + def is_active(self, config): + return 'hook_id' in config + + def activate(self, trigger_uuid, standard_webhook_url, auth_token, config): + new_build_source = config['build_source'] + gh_client = self._get_client(auth_token) + + try: + to_add_webhook = gh_client.get_repo(new_build_source) + except UnknownObjectException: + msg = 'Unable to find GitHub repository for source: %s' + raise TriggerActivationException(msg % new_build_source) + + webhook_config = { + 'url': standard_webhook_url, + 'content_type': 'json', + } + + try: + hook = to_add_webhook.create_hook('web', webhook_config) + config['hook_id'] = hook.id + config['master_branch'] = to_add_webhook.master_branch + except GithubException: + msg = 'Unable to create webhook on repository: %s' + raise TriggerActivationException(msg % new_build_source) + + return config + + def deactivate(self, auth_token, config): + gh_client = self._get_client(auth_token) + + try: + repo = gh_client.get_repo(config['build_source']) + to_delete = repo.get_hook(config['hook_id']) + to_delete.delete() + except GithubException: + msg = 'Unable to remove hook: %s' % config['hook_id'] + raise TriggerDeactivationException(msg) + + config.pop('hook_id', None) + + return config + + + def list_build_sources(self, auth_token): + gh_client = self._get_client(auth_token) + usr = gh_client.get_user() + + personal = { + 'personal': True, + 'repos': [repo.full_name for repo in usr.get_repos()], + 'info': { + 'name': usr.login, + 'avatar_url': usr.avatar_url, + } + } + + repos_by_org = [personal] + + for org in usr.get_orgs(): + repo_list = [] + for repo in org.get_repos(type='member'): + repo_list.append(repo.full_name) + + repos_by_org.append({ + 'personal': False, + 'repos': repo_list, + 'info': { + 'name': org.name, + 'avatar_url': org.avatar_url + } + }) + + return repos_by_org + + def list_build_subdirs(self, auth_token, config): + gh_client = self._get_client(auth_token) + source = config['build_source'] + + try: + repo = gh_client.get_repo(source) + default_commit = repo.get_branch(repo.master_branch or 'master').commit + commit_tree = repo.get_git_tree(default_commit.sha, recursive=True) + + return [os.path.dirname(elem.path) for elem in commit_tree.tree + if (elem.type == u'blob' and + os.path.basename(elem.path) == u'Dockerfile')] + except GithubException: + msg = 'Unable to list contents of repository: %s' % source + raise EmptyRepositoryException(msg) + + @staticmethod + def _prepare_build(config, repo, commit_sha, build_name, ref): + # Prepare the download and upload URLs + archive_link = repo.get_archive_link('zipball', commit_sha) + download_archive = client.get(archive_link, stream=True) + + zipball_subdir = '' + with SpooledTemporaryFile(CHUNK_SIZE) as zipball: + for chunk in download_archive.iter_content(CHUNK_SIZE): + zipball.write(chunk) + + # Pull out the name of the subdir that GitHub generated + with zipfile.ZipFile(zipball) as archive: + zipball_subdir = archive.namelist()[0] + + dockerfile_id = user_files.store_file(zipball, ZIPBALL) + + logger.debug('Successfully prepared job') + + # compute the tag(s) + branch = ref.split('/')[-1] + tags = {branch} + if branch == repo.master_branch: + tags.add('latest') + logger.debug('Pushing to tags: %s' % tags) + + # compute the subdir + repo_subdir = config['subdir'] + joined_subdir = os.path.join(zipball_subdir, repo_subdir) + logger.debug('Final subdir: %s' % joined_subdir) + + return dockerfile_id, list(tags), build_name, joined_subdir + + @staticmethod + def get_display_name(sha): + return sha[0:7] + + def handle_trigger_request(self, request, auth_token, config): + payload = request.get_json() + + if 'zen' in payload: + raise ValidationRequestException() + + logger.debug('Payload %s', payload) + ref = payload['ref'] + commit_sha = payload['head_commit']['id'] + short_sha = GithubBuildTrigger.get_display_name(commit_sha) + + gh_client = self._get_client(auth_token) + + repo_full_name = '%s/%s' % (payload['repository']['owner']['name'], + payload['repository']['name']) + repo = gh_client.get_repo(repo_full_name) + + logger.debug('Github repo: %s', repo) + + return GithubBuildTrigger._prepare_build(config, repo, commit_sha, + short_sha, ref) + + def manual_start(self, auth_token, config): + source = config['build_source'] + subdir = config['subdir'] + + gh_client = self._get_client(auth_token) + repo = gh_client.get_repo(source) + master = repo.get_branch(repo.master_branch) + master_sha = master.commit.sha + short_sha = GithubBuildTrigger.get_display_name(master_sha) + ref = 'refs/heads/%s' % repo.master_branch + + return self._prepare_build(config, repo, master_sha, short_sha, ref) diff --git a/endpoints/web.py b/endpoints/web.py index cc56eeac6..02d0ffd50 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -1,43 +1,44 @@ import logging -import requests import stripe +import os -from flask import (abort, redirect, request, url_for, render_template, - make_response, Response, Blueprint) -from flask.ext.login import login_required, current_user +from flask import (abort, redirect, request, url_for, make_response, Response, + Blueprint) +from flask.ext.login import current_user from urlparse import urlparse from data import model -from app import app, mixpanel +from data.model.oauth import DatabaseAuthorizationProvider +from app import app from auth.permissions import AdministerOrganizationPermission from util.invoice import renderInvoiceToPdf from util.seo import render_snapshot from util.cache import no_cache -from endpoints.api import get_route_data -from endpoints.common import common_login - +from endpoints.common import common_login, render_page_template +from endpoints.csrf import csrf_protect, generate_csrf_token +from util.names import parse_repository_name +from util.gravatar import compute_hash +from auth import scopes logger = logging.getLogger(__name__) web = Blueprint('web', __name__) - -def render_page_template(name, **kwargs): - - resp = make_response(render_template(name, route_data=get_route_data(), - **kwargs)) - resp.headers['X-FRAME-OPTIONS'] = 'DENY' - return resp +STATUS_TAGS = app.config['STATUS_TAGS'] @web.route('/', methods=['GET'], defaults={'path': ''}) -@web.route('/repository/', methods=['GET']) @web.route('/organization/', methods=['GET']) @no_cache def index(path): return render_page_template('index.html') +@web.route('/500', methods=['GET']) +def internal_error_display(): + return render_page_template('500.html') + + @web.route('/snapshot', methods=['GET']) @web.route('/snapshot/', methods=['GET']) @web.route('/snapshot/', methods=['GET']) @@ -106,9 +107,10 @@ def new(): return index('') -@web.route('/repository/') +@web.route('/repository/', defaults={'path': ''}) +@web.route('/repository/', methods=['GET']) @no_cache -def repository(): +def repository(path): return index('') @@ -179,97 +181,6 @@ def receipt(): abort(404) -def exchange_github_code_for_token(code): - code = request.args.get('code') - payload = { - 'client_id': app.config['GITHUB_CLIENT_ID'], - 'client_secret': app.config['GITHUB_CLIENT_SECRET'], - 'code': code, - } - headers = { - 'Accept': 'application/json' - } - - get_access_token = requests.post(app.config['GITHUB_TOKEN_URL'], - params=payload, headers=headers) - - token = get_access_token.json()['access_token'] - return token - - -def get_github_user(token): - token_param = { - 'access_token': token, - } - get_user = requests.get(app.config['GITHUB_USER_URL'], params=token_param) - - return get_user.json() - - -@web.route('/oauth2/github/callback', methods=['GET']) -def github_oauth_callback(): - error = request.args.get('error', None) - if error: - return render_page_template('githuberror.html', error_message=error) - - token = exchange_github_code_for_token(request.args.get('code')) - user_data = get_github_user(token) - - username = user_data['login'] - github_id = user_data['id'] - - v3_media_type = { - 'Accept': 'application/vnd.github.v3' - } - - token_param = { - 'access_token': token, - } - get_email = requests.get(app.config['GITHUB_USER_EMAILS'], - params=token_param, headers=v3_media_type) - - # We will accept any email, but we prefer the primary - found_email = None - for user_email in get_email.json(): - found_email = user_email['email'] - if user_email['primary']: - break - - to_login = model.verify_federated_login('github', github_id) - if not to_login: - # try to create the user - try: - to_login = model.create_federated_user(username, found_email, 'github', - github_id) - - # Success, tell mixpanel - mixpanel.track(to_login.username, 'register', {'service': 'github'}) - - state = request.args.get('state', None) - if state: - logger.debug('Aliasing with state: %s' % state) - mixpanel.alias(to_login.username, state) - - except model.DataModelException, ex: - return render_page_template('githuberror.html', error_message=ex.message) - - if common_login(to_login): - return redirect(url_for('web.index')) - - return render_page_template('githuberror.html') - - -@web.route('/oauth2/github/callback/attach', methods=['GET']) -@login_required -def github_oauth_attach(): - token = exchange_github_code_for_token(request.args.get('code')) - user_data = get_github_user(token) - github_id = user_data['id'] - user_obj = current_user.db_user() - model.attach_federated_login(user_obj, 'github', github_id) - return redirect(url_for('web.user')) - - @web.route('/confirm', methods=['GET']) def confirm_email(): code = request.values['code'] @@ -297,3 +208,134 @@ def confirm_recovery(): return redirect(url_for('web.user')) else: abort(403) + + +@web.route('/repository//status', methods=['GET']) +@parse_repository_name +@no_cache +def build_status_badge(namespace, repository): + token = request.args.get('token', None) + is_public = model.repository_is_public(namespace, repository) + if not is_public: + repo = model.get_repository(namespace, repository) + if not repo or token != repo.badge_token: + abort(404) + + # Lookup the tags for the repository. + tags = model.list_repository_tags(namespace, repository) + is_empty = len(list(tags)) == 0 + build = model.get_recent_repository_build(namespace, repository) + + if not is_empty and (not build or build.phase == 'complete'): + status_name = 'ready' + elif build and build.phase == 'error': + status_name = 'failed' + elif build and build.phase != 'complete': + status_name = 'building' + else: + status_name = 'none' + + response = make_response(STATUS_TAGS[status_name]) + response.content_type = 'image/svg+xml' + return response + + +class FlaskAuthorizationProvider(DatabaseAuthorizationProvider): + def get_authorized_user(self): + return current_user.db_user() + + def _make_response(self, body='', headers=None, status_code=200): + return make_response(body, status_code, headers) + + +@web.route('/oauth/authorizeapp', methods=['POST']) +@csrf_protect +def authorize_application(): + if not current_user.is_authenticated(): + abort(401) + return + + provider = FlaskAuthorizationProvider() + client_id = request.form.get('client_id', None) + redirect_uri = request.form.get('redirect_uri', None) + scope = request.form.get('scope', None) + + # Add the access token. + return provider.get_token_response('token', client_id, redirect_uri, scope=scope) + + +@web.route('/oauth/denyapp', methods=['POST']) +@csrf_protect +def deny_application(): + if not current_user.is_authenticated(): + abort(401) + return + + provider = FlaskAuthorizationProvider() + client_id = request.form.get('client_id', None) + redirect_uri = request.form.get('redirect_uri', None) + scope = request.form.get('scope', None) + + # Add the access token. + return provider.get_auth_denied_response('token', client_id, redirect_uri, scope=scope) + + +@web.route('/oauth/authorize', methods=['GET']) +@no_cache +def request_authorization_code(): + provider = FlaskAuthorizationProvider() + response_type = request.args.get('response_type', 'code') + client_id = request.args.get('client_id', None) + redirect_uri = request.args.get('redirect_uri', None) + scope = request.args.get('scope', None) + + if (not current_user.is_authenticated() or + not provider.validate_has_scopes(client_id, current_user.db_user().username, scope)): + if not provider.validate_redirect_uri(client_id, redirect_uri): + current_app = provider.get_application_for_client_id(client_id) + if not current_app: + abort(404) + + return provider._make_redirect_error_response(current_app.redirect_uri, 'redirect_uri_mismatch') + + # Load the scope information. + scope_info = scopes.get_scope_information(scope) + if not scope_info: + abort(404) + return + + # Load the application information. + oauth_app = provider.get_application_for_client_id(client_id) + oauth_app_view = { + 'name': oauth_app.name, + 'description': oauth_app.description, + 'url': oauth_app.application_uri, + 'organization': { + 'name': oauth_app.organization.username, + 'gravatar': compute_hash(oauth_app.organization.email) + } + } + + # Show the authorization page. + return render_page_template('oauthorize.html', scopes=scope_info, application=oauth_app_view, + enumerate=enumerate, client_id=client_id, redirect_uri=redirect_uri, + scope=scope, csrf_token_val=generate_csrf_token()) + + if response_type == 'token': + return provider.get_token_response(response_type, client_id, redirect_uri, scope=scope) + else: + return provider.get_authorization_code(response_type, client_id, redirect_uri, scope=scope) + + +@web.route('/oauth/access_token', methods=['POST']) +@no_cache +def exchange_code_for_token(): + grant_type = request.form.get('grant_type', None) + client_id = request.form.get('client_id', None) + client_secret = request.form.get('client_secret', None) + redirect_uri = request.form.get('redirect_uri', None) + code = request.form.get('code', None) + scope = request.form.get('scope', None) + + provider = FlaskAuthorizationProvider() + return provider.get_token(grant_type, client_id, client_secret, redirect_uri, code, scope=scope) diff --git a/endpoints/webhooks.py b/endpoints/webhooks.py index 5a6c0ad3d..d92e7095e 100644 --- a/endpoints/webhooks.py +++ b/endpoints/webhooks.py @@ -1,18 +1,26 @@ import logging import stripe +import json from flask import request, make_response, Blueprint from data import model -from app import app +from data.queue import dockerfile_build_queue +from auth.auth import process_auth +from auth.permissions import ModifyRepositoryPermission from util.invoice import renderInvoiceToHtml from util.email import send_invoice_email +from util.names import parse_repository_name +from util.http import abort +from endpoints.trigger import BuildTrigger, ValidationRequestException +from endpoints.common import start_build logger = logging.getLogger(__name__) webhooks = Blueprint('webhooks', __name__) + @webhooks.route('/stripe', methods=['POST']) def stripe_webhook(): request_data = request.get_json() @@ -36,3 +44,38 @@ def stripe_webhook(): send_invoice_email(user.email, invoice_html) return make_response('Okay') + + +@webhooks.route('/push//trigger/', + methods=['POST']) +@process_auth +@parse_repository_name +def build_trigger_webhook(namespace, repository, trigger_uuid): + logger.debug('Webhook received for %s/%s with uuid %s', namespace, + repository, trigger_uuid) + permission = ModifyRepositoryPermission(namespace, repository) + if permission.can(): + try: + trigger = model.get_build_trigger(namespace, repository, trigger_uuid) + except model.InvalidBuildTriggerException: + abort(404) + + handler = BuildTrigger.get_trigger_for_service(trigger.service.name) + + logger.debug('Passing webhook request to handler %s', handler) + config_dict = json.loads(trigger.config) + try: + specs = handler.handle_trigger_request(request, trigger.auth_token, + config_dict) + dockerfile_id, tags, name, subdir = specs + + except ValidationRequestException: + # This was just a validation request, we don't need to build anything + return make_response('Okay') + + repo = model.get_repository(namespace, repository) + start_build(repo, dockerfile_id, tags, name, subdir, False, trigger) + + return make_response('Okay') + + abort(403) diff --git a/initdb.py b/initdb.py index eba642754..52bb5833e 100644 --- a/initdb.py +++ b/initdb.py @@ -9,6 +9,7 @@ from peewee import (SqliteDatabase, create_model_tables, drop_model_tables, from data.database import * from data import model +from data.model import oauth from app import app @@ -182,6 +183,8 @@ def initialize_database(): Visibility.create(name='private') LoginService.create(name='github') LoginService.create(name='quayrobot') + + BuildTriggerService.create(name='github') LogEntryKind.create(name='account_change_plan') LogEntryKind.create(name='account_change_cc') @@ -207,6 +210,7 @@ def initialize_database(): LogEntryKind.create(name='add_repo_webhook') LogEntryKind.create(name='delete_repo_webhook') LogEntryKind.create(name='set_repo_description') + LogEntryKind.create(name='build_dockerfile') LogEntryKind.create(name='org_create_team') @@ -220,6 +224,19 @@ def initialize_database(): LogEntryKind.create(name='modify_prototype_permission') LogEntryKind.create(name='delete_prototype_permission') + LogEntryKind.create(name='setup_repo_trigger') + LogEntryKind.create(name='delete_repo_trigger') + + LogEntryKind.create(name='create_application') + LogEntryKind.create(name='update_application') + LogEntryKind.create(name='delete_application') + LogEntryKind.create(name='reset_application_client_secret') + + NotificationKind.create(name='password_required') + NotificationKind.create(name='over_private_usage') + + NotificationKind.create(name='test_notification') + def wipe_database(): logger.debug('Wiping all data from the DB.') @@ -257,6 +274,9 @@ def populate_database(): new_user_4.verified = True new_user_4.save() + new_user_5 = model.create_user('unverified', 'password', 'no5@thanks.com') + new_user_5.save() + reader = model.create_user('reader', 'password', 'no1@thanks.com') reader.verified = True reader.save() @@ -265,6 +285,8 @@ def populate_database(): outside_org.verified = True outside_org.save() + model.create_notification('test_notification', new_user_1, metadata={'some': 'value'}) + __generate_repository(new_user_4, 'randomrepo', 'Random repo repository.', False, [], (4, [], ['latest', 'prod'])) @@ -308,9 +330,24 @@ def populate_database(): False, [], (0, [], None)) token = model.create_access_token(building, 'write') - tag = 'ci.devtable.com:5000/%s/%s' % (building.namespace, building.name) - build = model.create_repository_build(building, token, '701dcc3724fb4f2ea6c31400528343cd', - tag, 'build-name') + + trigger = model.create_build_trigger(building, 'github', '123authtoken', + new_user_1) + trigger.config = json.dumps({ + 'build_source': 'jakedt/testconnect', + 'subdir': '', + }) + trigger.save() + + repo = 'ci.devtable.com:5000/%s/%s' % (building.namespace, building.name) + job_config = { + 'repository': repo, + 'docker_tags': ['latest'], + 'build_subdir': '', + } + build = model.create_repository_build(building, token, job_config, + '701dcc3724fb4f2ea6c31400528343cd', + 'build-name', trigger) build.uuid = 'deadbeef-dead-beef-dead-beefdeadbeef' build.save() @@ -319,6 +356,15 @@ def populate_database(): org.stripe_id = TEST_STRIPE_ID org.save() + oauth.create_application(org, 'Some Test App', 'http://localhost:8000', 'http://localhost:8000/o2c.html', + client_id='deadbeef') + + oauth.create_application(org, 'Some Other Test App', 'http://quay.io', 'http://localhost:8000/o2c.html', + client_id='deadpork', + description = 'This is another test application') + + model.oauth.create_access_token_for_testing(new_user_1, 'deadbeef', 'repo:admin') + model.create_robot('neworgrobot', org) owners = model.get_organization_team('buynlarge', 'owners') @@ -423,6 +469,12 @@ def populate_database(): timestamp=today, metadata={'token_code': 'somecode', 'repo': 'orgrepo'}) + model.log_action('build_dockerfile', new_user_1.username, repository=building, + timestamp=today, + metadata={'repo': 'building', 'namespace': new_user_1.username, + 'trigger_id': trigger.uuid, 'config': json.loads(trigger.config), + 'service': trigger.service.name}) + if __name__ == '__main__': app.config['LOGGING_CONFIG']() initialize_database() diff --git a/requirements-nover.txt b/requirements-nover.txt index 5b1ef8841..39142b7c5 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -22,4 +22,8 @@ logstash_formatter redis hiredis git+https://github.com/dotcloud/docker-py.git -loremipsum \ No newline at end of file +loremipsum +pygithub +flask-restful +jsonschema +git+https://github.com/NateFerrero/oauth2lib.git \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 8d9670b67..2d26ab24b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,45 +1,51 @@ APScheduler==2.1.2 Flask==0.10.1 -Flask-Login==0.2.9 +Flask-Login==0.2.10 Flask-Mail==0.9.0 Flask-Principal==0.4.0 +Flask-RESTful==0.2.12 Jinja2==2.7.2 -MarkupSafe==0.18 -Pillow==2.3.0 +MarkupSafe==0.19 +Pillow==2.3.1 +PyGithub==1.24.1 PyMySQL==0.6.1 Werkzeug==0.9.4 +aniso8601==0.82 argparse==1.2.1 beautifulsoup4==4.3.2 blinker==1.3 -boto==2.24.0 +boto==2.27.0 distribute==0.6.34 git+https://github.com/dotcloud/docker-py.git -ecdsa==0.10 +ecdsa==0.11 gevent==1.0 greenlet==0.4.2 gunicorn==18.0 hiredis==0.1.2 html5lib==1.0b3 itsdangerous==0.23 +jsonschema==2.3.0 lockfile==0.9.1 logstash-formatter==0.5.8 loremipsum==1.0.2 -marisa-trie==0.5.1 -mixpanel-py==3.1.1 +marisa-trie==0.6 +mixpanel-py==3.1.2 mock==1.0.1 -paramiko==1.12.1 -peewee==2.2.0 +git+https://github.com/NateFerrero/oauth2lib.git +paramiko==1.13.0 +peewee==2.2.2 py-bcrypt==0.4 pyPdf==1.13 pycrypto==2.6.1 python-daemon==1.6 python-dateutil==2.2 -python-digitalocean==0.6 +python-digitalocean==0.7 +pytz==2014.2 redis==2.9.1 reportlab==2.7 requests==2.2.1 -six==1.5.2 -stripe==1.12.0 +six==1.6.1 +stripe==1.12.2 websocket-client==0.11.0 wsgiref==0.1.2 xhtml2pdf==0.0.5 diff --git a/screenshots/screenshots.js b/screenshots/screenshots.js index 9999eb8a8..fbef4ac01 100644 --- a/screenshots/screenshots.js +++ b/screenshots/screenshots.js @@ -15,8 +15,9 @@ var isDebug = !!options['d']; var rootUrl = isDebug ? 'http://localhost:5000/' : 'https://quay.io/'; var repo = isDebug ? 'complex' : 'r0'; -var org = isDebug ? 'buynlarge' : 'quay' -var orgrepo = 'orgrepo' +var org = isDebug ? 'buynlarge' : 'devtable' +var orgrepo = isDebug ? 'buynlarge/orgrepo' : 'quay/testconnect2'; +var buildrepo = isDebug ? 'devtable/building' : 'quay/testconnect2'; var outputDir = "screenshots/"; @@ -32,8 +33,16 @@ casper.on("page.error", function(msg, trace) { }); casper.start(rootUrl + 'signin', function () { + this.wait(1000); +}); + +casper.thenClick('.accordion-toggle[data-target="#collapseSignin"]', function() { + this.wait(1000); +}); + +casper.then(function () { this.fill('.form-signin', { - 'username': 'devtable', + 'username': isDebug ? 'devtable' : 'quaydemo', 'password': isDebug ? 'password': 'C>K98%y"_=54x"<', }, false); }); @@ -43,6 +52,7 @@ casper.thenClick('.form-signin button[type=submit]', function() { }); casper.then(function() { + this.waitForSelector('.fa-lock'); this.log('Generating user home screenshot.'); }); @@ -150,12 +160,25 @@ casper.then(function() { this.log('Generating oganization repository admin screenshot.'); }); -casper.thenOpen(rootUrl + 'repository/' + org + '/' + orgrepo + '/admin', function() { - this.waitForText('outsideorg') +casper.thenOpen(rootUrl + 'repository/' + orgrepo + '/admin', function() { + this.waitForText('Robot Account') }); casper.then(function() { this.capture(outputDir + 'org-repo-admin.png'); }); + +casper.then(function() { + this.log('Generating build history screenshot.'); +}); + +casper.thenOpen(rootUrl + 'repository/' + buildrepo + '/build', function() { + this.waitForText('Starting'); +}); + +casper.then(function() { + this.capture(outputDir + 'build-history.png'); +}); + casper.run(); diff --git a/static/css/quay.css b/static/css/quay.css index 83627f35f..fb89ca9f1 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -9,7 +9,74 @@ } } +.notification-view-element { + cursor: pointer; + margin-bottom: 10px; + border-bottom: 1px solid #eee; + padding-bottom: 10px; + position: relative; + max-width: 320px; +} + +.notification-view-element .orginfo { + margin-top: 8px; + float: left; +} + +.notification-view-element .orginfo .orgname { + font-size: 12px; + color: #aaa; +} + +.notification-view-element .circle { + position: absolute; + top: 14px; + left: 0px; + + width: 12px; + height: 12px; + display: inline-block; + border-radius: 50%; +} + +.notification-view-element .datetime { + margin-top: 16px; + font-size: 12px; + color: #aaa; + text-align: right; +} + +.notification-view-element .message { + margin-bottom: 4px; +} + +.notification-view-element .container { + padding: 10px; + border-radius: 6px; + margin-left: 16px; +} + +.notification-view-element .container:hover { + background: rgba(66, 139, 202, 0.1); +} + +.dockerfile-path { + margin-top: 10px; + padding: 20px; + padding-bottom: 0px; + font-family: Consolas, "Lucida Console", Monaco, monospace; + font-size: 14px; +} + +.dockerfile-path:before { + content: "\f15b"; + font-family: FontAwesome; + margin-right: 8px; + font-size: 18px; +} + .dockerfile-view { + margin-top: 10px; margin: 20px; padding: 20px; background: #F7F6F6; @@ -282,21 +349,6 @@ i.toggle-icon:hover { vertical-align: middle; } -#copyClipboard { - cursor: pointer; -} - -#copyClipboard.zeroclipboard-is-hover { - background: #428bca; - color: white; -} - -#clipboardCopied.hovering { - position: absolute; - right: 0px; - top: 40px; -} - .content-container { padding-bottom: 70px; } @@ -506,7 +558,22 @@ i.toggle-icon:hover { min-width: 200px; } -.user-notification { +.notification-primary { + background: #428bca; + color: white; +} + +.notification-info { + color: black; + background: #d9edf7; +} + +.notification-warning { + color: #8a6d3b; + background: #fcf8e3; +} + +.notification-error { background: red; } @@ -775,11 +842,20 @@ i.toggle-icon:hover { margin-bottom: 16px; } +.new-repo .section-title { + float: right; + color: #aaa; +} + .new-repo .repo-option { margin: 6px; margin-top: 16px; } +.new-repo .repo-option label { + font-weight: normal; +} + .new-repo .repo-option i { font-size: 18px; padding-left: 10px; @@ -1596,10 +1672,17 @@ p.editable:hover i { } .repo .empty-description { - max-width: 600px; padding: 6px; } +.repo .empty-description pre:last-child { + margin-bottom: 0px; +} + +.repo .empty-description .panel-default { + margin-top: 20px; +} + .repo dl.dl-horizontal dt { width: 80px; padding-right: 10px; @@ -1710,7 +1793,38 @@ p.editable:hover i { margin-top: 28px; } -#clipboardCopied { +.copy-box-element { + position: relative; +} + +.global-zeroclipboard-container embed { + cursor: pointer; +} + +#copyClipboard.zeroclipboard-is-hover, .copy-box-element .zeroclipboard-is-hover { + background: #428bca; + color: white; + cursor: pointer !important; +} + +#clipboardCopied.hovering, .copy-box-element .hovering { + position: absolute; + right: 0px; + top: 40px; + pointer-events: none; + z-index: 100; +} + +.copy-box-element .id-container { + display: inline-block; + vertical-align: middle; +} + +.copy-box-element input { + background-color: white !important; +} + +#clipboardCopied, .clipboard-copied-message { font-size: 0.8em; display: inline-block; margin-right: 10px; @@ -1721,7 +1835,7 @@ p.editable:hover i { border-radius: 4px; } -#clipboardCopied.animated { +#clipboardCopied.animated, .clipboard-copied-message { -webkit-animation: fadeOut 4s ease-in-out 0s 1 forwards; -moz-animation: fadeOut 4s ease-in-out 0s 1 forwards; -ms-animation: fadeOut 4s ease-in-out 0s 1 forwards; @@ -2037,6 +2151,13 @@ p.editable:hover i { left: 4px; } +.repo-admin .right-controls { + text-align: right; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #eee; +} + .repo-admin .right-info { font-size: 11px; margin-top: 10px; @@ -2205,16 +2326,16 @@ p.editable:hover i { padding-right: 6px; } -.delete-ui { +.delete-ui-element { outline: none; } -.delete-ui i { +.delete-ui-element i { cursor: pointer; vertical-align: middle; } -.delete-ui .delete-ui-button { +.delete-ui-element .delete-ui-button { display: inline-block; vertical-align: middle; color: white; @@ -2230,15 +2351,15 @@ p.editable:hover i { transition: width 500ms ease-in-out; } -.delete-ui .delete-ui-button button { +.delete-ui-element .delete-ui-button button { padding: 4px; } -.delete-ui:focus i { +.delete-ui-element:focus i { visibility: hidden; } -.delete-ui:focus .delete-ui-button { +.delete-ui-element:focus .delete-ui-button { width: 60px; } @@ -2812,7 +2933,7 @@ p.editable:hover i { margin-bottom: 10px; } -.create-org .step-container .description { +.form-group .description { margin-top: 10px; display: block; color: #888; @@ -2820,7 +2941,7 @@ p.editable:hover i { margin-left: 10px; } -.create-org .form-group input { +.form-group.nested input { margin-top: 10px; margin-left: 10px; } @@ -2927,9 +3048,10 @@ p.editable:hover i { .tt-suggestion { display: block; padding: 3px 20px; + cursor: pointer; } -.tt-suggestion.tt-is-under-cursor { +.tt-suggestion.tt-cursor { color: #fff; background-color: #0081c2; background-image: -moz-linear-gradient(top, #0088cc, #0077b3); @@ -2941,10 +3063,17 @@ p.editable:hover i { filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0077b3', GradientType=0) } -.tt-suggestion.tt-is-under-cursor a { +.tt-suggestion.tt-cursor a { color: #fff; } +.tt-empty { + padding: 10px; + font-size: 12px; + color: #aaa; + white-space: nowrap; +} + .tt-suggestion p { margin: 0; } @@ -3380,4 +3509,216 @@ pre.command:before { .label.MAINTAINER { border-color: #aaa !important; +} + +.dropdown-select { + margin: 10px; + position: relative; +} + +.dropdown-select .dropdown-select-icon { + position: absolute; + top: 6px; + left: 6px; + z-index: 2; + display: none; +} + +.dropdown-select .dropdown-select-icon.fa { + top: 10px; + left: 8px; + font-size: 20px; +} + +.dropdown-select .dropdown-select-icon.none-icon { + color: #ccc; + display: inline; +} + +.dropdown-select.has-item .dropdown-select-icon { + display: inline; +} + +.dropdown-select.has-item .dropdown-select-icon.none-icon { + display: none; +} + +.dropdown-select .lookahead-input { + padding-left: 32px; +} + +.dropdown-select .twitter-typeahead { + display: block !important; +} + +.dropdown-select .twitter-typeahead .tt-hint { + padding-left: 32px; +} + +.dropdown-select .dropdown { + position: absolute; + right: 0px; + top: 0px; +} + +.dropdown-select .dropdown button.dropdown-toggle { + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; +} + +.trigger-setup-github-element .github-org-icon { + width: 20px; + margin-right: 8px; + vertical-align: middle; +} + +.trigger-setup-github-element li.github-repo-listing i { + margin-right: 10px; + margin-left: 6px; +} + +.trigger-setup-github-element li.github-org-header { + padding-left: 6px; +} + +.slideinout { + -webkit-transition:0.5s all; + transition:0.5s linear all; + opacity: 1; + + position: relative; + + height: 100px; + opacity: 1; +} + +.slideinout.ng-hide { + opacity: 0; + height: 0px; +} + +.slideinout.ng-hide-add, .slideinout.ng-hide-remove { + display: block !important; +} + +.auth-header > img { + float: left; + margin-top: 8px; + margin-right: 20px; +} + +.auth-header { + padding-bottom: 10px; + border-bottom: 1px solid #eee; + margin-bottom: 10px; +} + +.auth-scopes .reason { + margin-top: 20px; + margin-bottom: 20px; + font-size: 18px; +} + +.auth-scopes ul { + margin-top: 10px; + list-style: none; +} + +.auth-scopes li { + display: block; +} + +.auth-scopes .scope { + max-width: 500px; +} + +.auth-scopes .scope-container:last-child { + border-bottom: 0px; +} + +.auth-scopes .panel-default { + border: 0px; + margin-bottom: 0px; + padding-bottom: 10px; + box-shadow: none; +} + +.auth-scopes .panel-default:last-child { + border-bottom: 0px; +} + +.auth-scopes .panel-heading { + border: 0px; + background: transparent; +} + +.auth-scopes .scope .title { + min-width: 300px; + cursor: pointer; + display: inline-block; +} + +.auth-scopes .scope .title a { + color: #444; +} + +.auth-scopes .scope .description { + padding: 10px; +} + +.auth-scopes .scope i { + margin-right: 10px; + margin-top: 2px; +} + +.auth-scopes .scope i.fa-lg { + font-size: 24px; +} + +.auth-scopes .title i.arrow:before { + content: "\f0d7"; +} + +.auth-scopes .title.collapsed i.arrow:before { + content: "\f0da" !important; +} + +.auth-container .button-bar form { + display: inline-block; +} + +.auth-container .button-bar { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #eee; +} + +.auth-container .button-bar button { + margin: 6px; +} + +.manage-application #oauth td { + padding: 6px; + padding-bottom: 20px; +} + +.manage-application .button-bar { + margin-top: 10px; + padding-top: 20px; + border-top: 1px solid #eee; +} + +.auth-info .by:before { + content: "by"; + margin-right: 4px; +} + +.auth-info .by { + color: #aaa; + font-size: 12px; +} + +.auth-info .scope { + cursor: pointer; + margin-right: 4px; } \ No newline at end of file diff --git a/static/directives/application-info.html b/static/directives/application-info.html new file mode 100644 index 000000000..bbaf56454 --- /dev/null +++ b/static/directives/application-info.html @@ -0,0 +1,12 @@ +
+
+ +

{{ application.name }}

+

+ {{ application.organization.name }} +

+
+
+ {{ application.description || '(No Description)' }} +
+
diff --git a/static/directives/application-manager.html b/static/directives/application-manager.html new file mode 100644 index 000000000..df870d7a5 --- /dev/null +++ b/static/directives/application-manager.html @@ -0,0 +1,24 @@ +
+
+ +
+
+ + Create New Application + +
+ + + + + + + + + + + +
Application NameApplication URI
{{ app.name }}{{ app.application_uri }}
+
+ +
diff --git a/static/directives/application-reference-dialog.html b/static/directives/application-reference-dialog.html new file mode 100644 index 000000000..364cf6113 --- /dev/null +++ b/static/directives/application-reference-dialog.html @@ -0,0 +1,15 @@ + diff --git a/static/directives/application-reference.html b/static/directives/application-reference.html new file mode 100644 index 000000000..0f61c5dc2 --- /dev/null +++ b/static/directives/application-reference.html @@ -0,0 +1,4 @@ + + + {{ title }} + diff --git a/static/directives/copy-box.html b/static/directives/copy-box.html new file mode 100644 index 000000000..204bd3a57 --- /dev/null +++ b/static/directives/copy-box.html @@ -0,0 +1,14 @@ +
+
+
+ + + + +
+
+ + +
diff --git a/static/directives/delete-ui.html b/static/directives/delete-ui.html new file mode 100644 index 000000000..d04e840f0 --- /dev/null +++ b/static/directives/delete-ui.html @@ -0,0 +1,4 @@ + + + + diff --git a/static/directives/dropdown-select-icon.html b/static/directives/dropdown-select-icon.html new file mode 100644 index 000000000..8fc5d79b6 --- /dev/null +++ b/static/directives/dropdown-select-icon.html @@ -0,0 +1 @@ + diff --git a/static/directives/dropdown-select-menu.html b/static/directives/dropdown-select-menu.html new file mode 100644 index 000000000..4d6c453f0 --- /dev/null +++ b/static/directives/dropdown-select-menu.html @@ -0,0 +1 @@ + diff --git a/static/directives/dropdown-select.html b/static/directives/dropdown-select.html new file mode 100644 index 000000000..d24cd531e --- /dev/null +++ b/static/directives/dropdown-select.html @@ -0,0 +1,13 @@ +