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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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
-
-
-
-
+
+