]*>';
+ const right = '
';
+ const flags = 'g';
+ const replacement = (wholeMatch: string, match: string, leftSide: string, rightSide: string) => {
+ const language: string = leftSide.slice(leftSide.indexOf('language-') + ('language-').length,
+ leftSide.indexOf('"', leftSide.indexOf('language-')));
+ addHighlightedLanguage(language).catch(error => null);
+
+ match = htmlunencode(match);
+ return leftSide + highlightAuto(match).value + rightSide;
+ };
+
+ return {
+ type: 'output',
+ filter: (text, converter, options) => {
+ return (extra_ca_certs
is a single file and cannot be processed by this tool. If a valid and appended list of certificates, they will be installed on container startup.
+ This section lists any custom or self-signed SSL certificates that are installed in the container on startup after being read from the extra_ca_certs
directory in the configuration volume.
+
+ Custom certificates are typically used in place of publicly signed certificates for corporate-internal services. +
+Please make sure that all custom names used for downstream services (such as Clair) are listed in the certificates below.
+Upload certificates: | ++ + | +
Certificate Filename | +Status | +Names Handled | ++ + |
{{ certificate.path }} | +
+
+
+ Error: {{ certificate.error }}
+
+
+
+ Certificate is expired
+
+
+
+ Certificate is valid
+
+ |
+
+ (None)
+ {{ name }}
+ |
+ + | + +
{{ serviceName }}
exists
+ Assign New Key
+ {{ serviceName }}
+ Create Key
+ config.yaml
file found in conf/stack
could not be parsed."
+ var title = "Invalid configuration file";
+ CoreDialog.fatal(title, message);
+ };
+
+
+ $scope.showMissingConfigDialog = function() {
+ var message = "A volume should be mounted into the container at /conf/stack
: " +
+ "docker run -v /path/to/config:/conf/stack" + + "
+ |
-
- |
E-mail Address Change Requested+E-mail Address Change Requested++This email address was added to the {{ app_title }} account {{ username }}. -This email address was recently asked to become the new e-mail address for user {{ username | user_reference }}. +
+The {{ app_title }} Team -To confirm this change, please click the following link: -{{ app_link('confirm?code=' + token) }} + {% endblock %} diff --git a/emails/confirmemail.html b/emails/confirmemail.html index de94372cd..11ea31d00 100644 --- a/emails/confirmemail.html +++ b/emails/confirmemail.html @@ -2,12 +2,29 @@ {% block content %} - Please Confirm E-mail Address+Confirm email for new user: {{ username }}+-This email address was recently used to register user {{ username | user_reference }}. - - -To confirm this email address, please click the following link: -{{ app_link('confirm?code=' + token) }} +
+The {{ app_title }} Team {% endblock %} diff --git a/emails/email-template-viewer.html b/emails/email-template-viewer.html new file mode 100644 index 000000000..f3f6f9a38 --- /dev/null +++ b/emails/email-template-viewer.html @@ -0,0 +1,17 @@ + + + + Email Template Viewer+ Here is a list of the templates available: +
Account E-mail Address Changed+Account password changed: {{ username }}+-The email address for user {{ username | user_reference }} has been changed from this e-mail address to {{ new_email }}. - - -If this change was not expected, please immediately log into your {{ username | admin_reference }} and reset your email address. +
+The {{ app_title }} Team {% endblock %} diff --git a/emails/logsexported.html b/emails/logsexported.html new file mode 100644 index 000000000..945ddedcc --- /dev/null +++ b/emails/logsexported.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block content %} + + Usage Logs Export has completed+Export ID: {{ export_id }}++ +{% if status == 'success' %} +
+The {{ app_title }} Team + +{% endblock %} diff --git a/emails/orgrecovery.html b/emails/orgrecovery.html index cda1d17e0..8d5cd3072 100644 --- a/emails/orgrecovery.html +++ b/emails/orgrecovery.html @@ -2,19 +2,44 @@ {% block content %} - Organization {{ organization }} recovery+Organization recovery: {{ organization }}+-A user at {{ app_link() }} has attempted to recover organization {{ organization | user_reference }} via this email address. - - -Please login with one of the following user accounts to access this organization: -
-If you did not make this request, your organization has not been compromised and the user was -not given access. Please disregard this email. +
+The {{ app_title }} Team {% endblock %} diff --git a/emails/passwordchanged.html b/emails/passwordchanged.html index 07c6232cc..870ef7981 100644 --- a/emails/passwordchanged.html +++ b/emails/passwordchanged.html @@ -2,12 +2,29 @@ {% block content %} - Account Password Changed+Account password changed: {{ username }}+-The password for user {{ username | user_reference }} has been updated. - - -If this change was not expected, please immediately log into your account settings and reset your email address, -or contact support. +
+The {{ app_title }} Team {% endblock %} diff --git a/emails/paymentfailure.html b/emails/paymentfailure.html index 790f590b4..b901d597f 100644 --- a/emails/paymentfailure.html +++ b/emails/paymentfailure.html @@ -2,12 +2,29 @@ {% block content %} - Subscription Payment Failure+Subscription payment failure: {{ username }}+-Your recent payment for account {{ username | user_reference }} failed, which usually results in our payments processor canceling -your subscription automatically. If you would like to continue to use {{ app_title }} without interruption, -please add a new card to {{ app_title }} and re-subscribe to your plan. - -You can find the card and subscription management features under your {{ username | admin_reference }} +
+The {{ app_title }} Team {% endblock %} diff --git a/emails/recovery.html b/emails/recovery.html index 6f0267e39..27d3eaf48 100644 --- a/emails/recovery.html +++ b/emails/recovery.html @@ -2,17 +2,29 @@ {% block content %} - Account recovery+Account recovery+-A user at {{ app_link() }} has attempted to recover their account -using this email address. - - -If you made this request, please click the following link to recover your account and -change your password: -{{ app_link('recovery?code=' + token) }} - -If you did not make this request, your account has not been compromised and the user was -not given access. Please disregard this email. +
+The {{ app_title }} Team {% endblock %} diff --git a/emails/repoauthorizeemail.html b/emails/repoauthorizeemail.html index 7ae33975c..7d779d852 100644 --- a/emails/repoauthorizeemail.html +++ b/emails/repoauthorizeemail.html @@ -2,12 +2,29 @@ {% block content %} - Verify e-mail to receive repository notifications+Verify e-mail to recieve {{namespace}}/{{repository}} notifications+-A request has been made to send notifications to this email address for repository {{ (namespace, repository) | repository_reference }} +
-To verify this email address, please click the following link: -{{ app_link('authrepoemail?code=' + token) }} +
+The {{ app_title }} Team {% endblock %} diff --git a/emails/teaminvite.html b/emails/teaminvite.html index 128bbe00f..0e4f11198 100644 --- a/emails/teaminvite.html +++ b/emails/teaminvite.html @@ -2,16 +2,29 @@ {% block content %} - Invitation to join team: {{ teamname }}+Invitation to join team: {{ organization }}/{{ teamname }}+-{{ inviter | user_reference }} has invited you to join the team {{ teamname | team_reference }} under organization {{ organization | user_reference }}. +
+
-{{ app_link('confirminvite?code=' + token) }} +
-If you were not expecting this invitation, you can ignore this email. + +Thank you, +The {{ app_title }} Team {% endblock %} diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 018b5277d..8dcabe6a3 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -1,113 +1,55 @@ import logging import datetime -from app import app, metric_queue -from flask import Blueprint, request, make_response, jsonify, session -from flask.ext.restful import Resource, abort, Api, reqparse -from flask.ext.restful.utils.cors import crossdomain from calendar import timegm from email.utils import formatdate from functools import partial, wraps + +from flask import Blueprint, request, session +from flask_restful import Resource, abort, Api, reqparse +from flask_restful.utils.cors import crossdomain from jsonschema import validate, ValidationError -from data import model -from util.names import parse_namespace_repository +from app import app, metric_queue, authentication 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 auth.auth_context import (get_authenticated_context, get_authenticated_user, + get_validated_oauth_token) +from auth.decorators import process_oauth +from data import model as data_model +from data.logs_model import logs_model +from data.database import RepositoryState from endpoints.csrf import csrf_protect -from endpoints.decorators import check_anon_protection -from util.saas.metricqueue import time_decorator +from endpoints.exception import (Unauthorized, InvalidRequest, InvalidResponse, + FreshLoginRequired, NotFound) +from endpoints.decorators import check_anon_protection, require_xhr_from_browser, check_readonly +from util.metrics.metricqueue import time_decorator +from util.names import parse_namespace_repository +from util.pagination import encrypt_page_token, decrypt_page_token +from util.request import get_request_ip +from __init__models_pre_oci import pre_oci_model as model logger = logging.getLogger(__name__) api_bp = Blueprint('api', __name__) -api = Api() + + +CROSS_DOMAIN_HEADERS = ['Authorization', 'Content-Type', 'X-Requested-With'] + +class ApiExceptionHandlingApi(Api): + @crossdomain(origin='*', headers=CROSS_DOMAIN_HEADERS) + def handle_error(self, error): + return super(ApiExceptionHandlingApi, self).handle_error(error) + + +api = ApiExceptionHandlingApi() api.init_app(api_bp) -api.decorators = [csrf_protect, - crossdomain(origin='*', headers=['Authorization', 'Content-Type']), - process_oauth, time_decorator(api_bp.name, metric_queue)] - - -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 ExternalServiceTimeout(ApiException): - def __init__(self, error_description, payload=None): - ApiException.__init__(self, 'external_service_timeout', 520, error_description, payload) - - -class InvalidRequest(ApiException): - def __init__(self, error_description, payload=None): - ApiException.__init__(self, 'invalid_request', 400, error_description, payload) - - -class InvalidResponse(ApiException): - def __init__(self, error_description, payload=None): - ApiException.__init__(self, 'invalid_response', 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 FreshLoginRequired(ApiException): - def __init__(self, payload=None): - ApiException.__init__(self, 'fresh_login_required', 401, "Requires fresh login", payload) - - -class ExceedsLicenseException(ApiException): - def __init__(self, payload=None): - ApiException.__init__(self, None, 402, 'Payment Required', payload) - - -class NotFound(ApiException): - def __init__(self, payload=None): - ApiException.__init__(self, None, 404, 'Not Found', payload) - - -class DownstreamIssue(ApiException): - def __init__(self, payload=None): - ApiException.__init__(self, None, 520, 'Downstream Issue', 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 +api.decorators = [csrf_protect(), + crossdomain(origin='*', headers=CROSS_DOMAIN_HEADERS), + process_oauth, time_decorator(api_bp.name, metric_queue), + require_xhr_from_browser] def resource(*urls, **kwargs): @@ -115,6 +57,7 @@ def resource(*urls, **kwargs): if not api_resource: return None + api_resource.registered = True api.add_resource(api_resource, *urls, **kwargs) return api_resource return wrapper @@ -122,6 +65,11 @@ def resource(*urls, **kwargs): def show_if(value): def f(inner): + if hasattr(inner, 'registered') and inner.registered: + msg = ('API endpoint %s is already registered; please switch the ' + + '@show_if to be *below* the @resource decorator') + raise Exception(msg % inner) + if not value: return None @@ -131,6 +79,11 @@ def show_if(value): def hide_if(value): def f(inner): + if hasattr(inner, 'registered') and inner.registered: + msg = ('API endpoint %s is already registered; please switch the ' + + '@hide_if to be *below* the @resource decorator') + raise Exception(msg % inner) + if value: return None @@ -208,21 +161,42 @@ def query_param(name, help_str, type=reqparse.text_type, default=None, return func return add_param +def page_support(page_token_kwarg='page_token', parsed_args_kwarg='parsed_args'): + def inner(func): + """ Adds pagination support to an API endpoint. The decorated API will have an + added query parameter named 'next_page'. Works in tandem with the + modelutil paginate method. + """ + @wraps(func) + @query_param('next_page', 'The page token for the next page', type=str) + def wrapper(self, *args, **kwargs): + # Note: if page_token is None, we'll receive the first page of results back. + page_token = decrypt_page_token(kwargs[parsed_args_kwarg]['next_page']) + kwargs[page_token_kwarg] = page_token -def parse_args(func): - @wraps(func) - def wrapper(self, *args, **kwargs): - if '__api_query_params' not in dir(func): - abort(500) + (result, next_page_token) = func(self, *args, **kwargs) + if next_page_token is not None: + result['next_page'] = encrypt_page_token(next_page_token) - parser = reqparse.RequestParser() - for arg_spec in func.__api_query_params: - parser.add_argument(**arg_spec) - parsed_args = parser.parse_args() + return result + return wrapper + return inner - return func(self, parsed_args, *args, **kwargs) - return wrapper +def parse_args(kwarg_name='parsed_args'): + def inner(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) + kwargs[kwarg_name] = parser.parse_args() + + return func(self, *args, **kwargs) + return wrapper + return inner def parse_repository_name(func): @wraps(func) @@ -233,14 +207,39 @@ def parse_repository_name(func): class ApiResource(Resource): - method_decorators = [check_anon_protection] + registered = False + method_decorators = [check_anon_protection, check_readonly] def options(self): return None, 200 class RepositoryParamResource(ApiResource): - method_decorators = [check_anon_protection, parse_repository_name] + method_decorators = [check_anon_protection, parse_repository_name, check_readonly] + + +def disallow_for_app_repositories(func): + @wraps(func) + def wrapped(self, namespace_name, repository_name, *args, **kwargs): + # Lookup the repository with the given namespace and name and ensure it is not an application + # repository. + if model.is_app_repository(namespace_name, repository_name): + abort(501) + + return func(self, namespace_name, repository_name, *args, **kwargs) + + return wrapped + + +def disallow_for_non_normal_repositories(func): + @wraps(func) + def wrapped(self, namespace_name, repository_name, *args, **kwargs): + repo = data_model.repository.get_repository(namespace_name, repository_name) + if repo and repo.state != RepositoryState.NORMAL: + abort(503, message='Repository is in read only or mirror mode: %s' % repo.state) + + return func(self, namespace_name, repository_name, *args, **kwargs) + return wrapped def require_repo_permission(permission_class, scope, allow_public=False): @@ -253,7 +252,7 @@ def require_repo_permission(permission_class, scope, allow_public=False): permission = permission_class(namespace, repository) if (permission.can() or (allow_public and - model.repository.repository_is_public(namespace, repository))): + model.repository_is_public(namespace, repository))): return func(self, namespace, repository, *args, **kwargs) raise Unauthorized() return wrapped @@ -284,8 +283,7 @@ def require_user_permission(permission_class, scope=None): require_user_read = require_user_permission(UserReadPermission, scopes.READ_USER) -require_user_admin = require_user_permission(UserAdminPermission, None) -require_fresh_user_admin = require_user_permission(UserAdminPermission, None) +require_user_admin = require_user_permission(UserAdminPermission, scopes.ADMIN_USER) def verify_not_prod(func): @@ -312,8 +310,7 @@ def require_fresh_login(func): if not user: raise Unauthorized() - oauth_token = get_validated_oauth_token() - if oauth_token: + if get_validated_oauth_token(): return func(*args, **kwargs) logger.debug('Checking fresh login for user %s', user.username) @@ -321,7 +318,8 @@ def require_fresh_login(func): last_login = session.get('login_time', datetime.datetime.min) valid_span = datetime.datetime.now() - datetime.timedelta(minutes=10) - if not user.password_hash or last_login >= valid_span: + if (not user.password_hash or last_login >= valid_span or + not authentication.supports_fresh_login): return func(*args, **kwargs) raise FreshLoginRequired() @@ -338,7 +336,19 @@ def require_scope(scope_object): return wrapper -def validate_json_request(schema_name): +def max_json_size(max_size): + def wrapper(func): + @wraps(func) + def wrapped(self, *args, **kwargs): + if request.is_json and len(request.get_data()) > max_size: + raise InvalidRequest() + + return func(self, *args, **kwargs) + return wrapped + return wrapper + + +def validate_json_request(schema_name, optional=False): def wrapper(func): @add_method_metadata('request_schema', schema_name) @wraps(func) @@ -347,12 +357,13 @@ def validate_json_request(schema_name): try: json_data = request.get_json() if json_data is None: - raise InvalidRequest('Missing JSON body') - - validate(json_data, schema) + if not optional: + raise InvalidRequest('Missing JSON body') + else: + validate(json_data, schema) return func(self, *args, **kwargs) except ValidationError as ex: - raise InvalidRequest(ex.message) + raise InvalidRequest(str(ex)) return wrapped return wrapper @@ -361,15 +372,13 @@ def request_error(exception=None, **kwargs): data = kwargs.copy() message = 'Request error.' if exception: - message = exception.message + message = str(exception) + + message = data.pop('message', message) raise InvalidRequest(message, data) -def license_error(exception=None): - raise ExceedsLicenseException() - - -def log_action(kind, user_or_orgname, metadata=None, repo=None): +def log_action(kind, user_or_orgname, metadata=None, repo=None, repo_name=None): if not metadata: metadata = {} @@ -380,8 +389,15 @@ def log_action(kind, user_or_orgname, metadata=None, repo=None): metadata['oauth_token_application'] = oauth_token.application.name performer = get_authenticated_user() - model.log.log_action(kind, user_or_orgname, performer=performer, ip=request.remote_addr, - metadata=metadata, repository=repo) + + if repo_name is not None: + repo = data_model.repository.get_repository(user_or_orgname, repo_name) + + logs_model.log_action(kind, user_or_orgname, + repository=repo, + performer=performer, + ip=get_request_ip(), + metadata=metadata) def define_json_response(schema_name): @@ -396,18 +412,22 @@ def define_json_response(schema_name): try: validate(resp, schema) except ValidationError as ex: - raise InvalidResponse(ex.message) + raise InvalidResponse(str(ex)) return resp return wrapped return wrapper +import endpoints.api.appspecifictokens import endpoints.api.billing import endpoints.api.build import endpoints.api.discovery +import endpoints.api.error +import endpoints.api.globalmessages import endpoints.api.image import endpoints.api.logs +import endpoints.api.manifest import endpoints.api.organization import endpoints.api.permission import endpoints.api.prototype @@ -424,4 +444,5 @@ import endpoints.api.team import endpoints.api.trigger import endpoints.api.user import endpoints.api.secscan - +import endpoints.api.signing +import endpoints.api.mirror diff --git a/endpoints/api/__init__models_interface.py b/endpoints/api/__init__models_interface.py new file mode 100644 index 000000000..974d9e0e1 --- /dev/null +++ b/endpoints/api/__init__models_interface.py @@ -0,0 +1,54 @@ +from abc import ABCMeta, abstractmethod + +from six import add_metaclass + + +@add_metaclass(ABCMeta) +class InitDataInterface(object): + """ + Interface that represents all data store interactions required by __init__. + """ + + @abstractmethod + def is_app_repository(self, namespace_name, repository_name): + """ + + Args: + namespace_name: namespace or user + repository_name: repository + + Returns: + Boolean + """ + pass + + @abstractmethod + def repository_is_public(self, namespace_name, repository_name): + """ + + Args: + namespace_name: namespace or user + repository_name: repository + + Returns: + Boolean + """ + pass + + @abstractmethod + def log_action(self, kind, namespace_name, repository_name, performer, ip, metadata): + """ + + Args: + kind: type of log + user_or_orgname: name of user or organization + performer: user doing the action + ip: originating ip + metadata: metadata + repository: repository the action is related to + + Returns: + None + """ + pass + diff --git a/endpoints/api/__init__models_pre_oci.py b/endpoints/api/__init__models_pre_oci.py new file mode 100644 index 000000000..f14e7267c --- /dev/null +++ b/endpoints/api/__init__models_pre_oci.py @@ -0,0 +1,19 @@ +from __init__models_interface import InitDataInterface + +from data import model +from data.logs_model import logs_model + +class PreOCIModel(InitDataInterface): + def is_app_repository(self, namespace_name, repository_name): + return model.repository.get_repository(namespace_name, repository_name, + kind_filter='application') is not None + + def repository_is_public(self, namespace_name, repository_name): + return model.repository.repository_is_public(namespace_name, repository_name) + + def log_action(self, kind, namespace_name, repository_name, performer, ip, metadata): + repository = model.repository.get_repository(namespace_name, repository_name) + logs_model.log_action(kind, namespace_name, performer=performer, ip=ip, metadata=metadata, + repository=repository) + +pre_oci_model = PreOCIModel() diff --git a/endpoints/api/appspecifictokens.py b/endpoints/api/appspecifictokens.py new file mode 100644 index 000000000..1e886c385 --- /dev/null +++ b/endpoints/api/appspecifictokens.py @@ -0,0 +1,133 @@ +""" Manages app specific tokens for the current user. """ + +import logging +import math + +from datetime import timedelta +from flask import request + +import features + +from app import app +from auth.auth_context import get_authenticated_user +from data import model +from endpoints.api import (ApiResource, nickname, resource, validate_json_request, + log_action, require_user_admin, require_fresh_login, + path_param, NotFound, format_date, show_if, query_param, parse_args, + truthy_bool) +from util.timedeltastring import convert_to_timedelta + +logger = logging.getLogger(__name__) + + +def token_view(token, include_code=False): + data = { + 'uuid': token.uuid, + 'title': token.title, + 'last_accessed': format_date(token.last_accessed), + 'created': format_date(token.created), + 'expiration': format_date(token.expiration), + } + + if include_code: + data.update({ + 'token_code': model.appspecifictoken.get_full_token_string(token), + }) + + return data + + +# The default window to use when looking up tokens that will be expiring. +_DEFAULT_TOKEN_EXPIRATION_WINDOW = '4w' + + +@resource('/v1/user/apptoken') +@show_if(features.APP_SPECIFIC_TOKENS) +class AppTokens(ApiResource): + """ Lists all app specific tokens for a user """ + schemas = { + 'NewToken': { + 'type': 'object', + 'required': [ + 'title', + ], + 'properties': { + 'title': { + 'type': 'string', + 'description': 'The user-defined title for the token', + }, + } + }, + } + + @require_user_admin + @nickname('listAppTokens') + @parse_args() + @query_param('expiring', 'If true, only returns those tokens expiring soon', type=truthy_bool) + def get(self, parsed_args): + """ Lists the app specific tokens for the user. """ + expiring = parsed_args['expiring'] + if expiring: + expiration = app.config.get('APP_SPECIFIC_TOKEN_EXPIRATION') + token_expiration = convert_to_timedelta(expiration or _DEFAULT_TOKEN_EXPIRATION_WINDOW) + seconds = math.ceil(token_expiration.total_seconds() * 0.1) or 1 + soon = timedelta(seconds=seconds) + tokens = model.appspecifictoken.get_expiring_tokens(get_authenticated_user(), soon) + else: + tokens = model.appspecifictoken.list_tokens(get_authenticated_user()) + + return { + 'tokens': [token_view(token, include_code=False) for token in tokens], + 'only_expiring': expiring, + } + + @require_user_admin + @require_fresh_login + @nickname('createAppToken') + @validate_json_request('NewToken') + def post(self): + """ Create a new app specific token for user. """ + title = request.get_json()['title'] + token = model.appspecifictoken.create_token(get_authenticated_user(), title) + + log_action('create_app_specific_token', get_authenticated_user().username, + {'app_specific_token_title': token.title, + 'app_specific_token': token.uuid}) + + return { + 'token': token_view(token, include_code=True), + } + + +@resource('/v1/user/apptoken/ ')
@@ -91,46 +71,30 @@ class RepositoryToken(RepositoryParamResource):
},
},
}
+
@require_repo_admin
@nickname('getTokens')
- def get(self, namespace, repository, code):
+ def get(self, namespace_name, repo_name, code):
""" Fetch the specified repository token information. """
- try:
- perm = model.token.get_repo_delegate_token(namespace, repository, code)
- except model.InvalidTokenException:
- raise NotFound()
+ return {
+ 'message': 'Handling of access tokens is no longer supported',
+ }, 410
- return token_view(perm)
@require_repo_admin
@nickname('changeToken')
@validate_json_request('TokenPermission')
- def put(self, namespace, repository, code):
+ def put(self, namespace_name, repo_name, code):
""" Update the permissions for the specified repository token. """
- new_permission = request.get_json()
+ return {
+ 'message': 'Handling of access tokens is no longer supported',
+ }, 410
- logger.debug('Setting permission to: %s for code %s' %
- (new_permission['role'], code))
-
- token = model.token.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.repository.get_repository(namespace, repository))
-
- return token_view(token)
@require_repo_admin
@nickname('deleteToken')
- def delete(self, namespace, repository, code):
+ def delete(self, namespace_name, repo_name, code):
""" Delete the repository token. """
- token = model.token.delete_delegate_token(namespace, repository, code)
-
- log_action('delete_repo_accesstoken', namespace,
- {'repo': repository, 'token': token.friendly_name,
- 'code': code},
- repo=model.repository.get_repository(namespace, repository))
-
- return 'Deleted', 204
+ return {
+ 'message': 'Handling of access tokens is no longer supported',
+ }, 410
diff --git a/endpoints/api/robot.py b/endpoints/api/robot.py
index ed64f87cc..867329323 100644
--- a/endpoints/api/robot.py
+++ b/endpoints/api/robot.py
@@ -1,88 +1,65 @@
""" Manage user and organization robot accounts. """
from endpoints.api import (resource, nickname, ApiResource, log_action, related_user_resource,
- Unauthorized, require_user_admin, require_scope, path_param, parse_args,
- truthy_bool, query_param)
+ require_user_admin, require_scope, path_param, parse_args,
+ truthy_bool, query_param, validate_json_request, max_json_size)
+from endpoints.api.robot_models_pre_oci import pre_oci_model as model
+from endpoints.exception import Unauthorized
from auth.permissions import AdministerOrganizationPermission, OrganizationMemberPermission
from auth.auth_context import get_authenticated_user
from auth import scopes
-from data import model
-from data.database import User, Team, Repository, FederatedLogin
from util.names import format_robot_username
-from flask import abort
-from app import avatar
-
-def robot_view(name, token):
- return {
- 'name': name,
- 'token': token
- }
+from flask import abort, request
-def permission_view(permission):
- return {
- 'repository': {
- 'name': permission.repository.name,
- 'is_public': permission.repository.visibility.name == 'public'
+CREATE_ROBOT_SCHEMA = {
+ 'type': 'object',
+ 'description': 'Optional data for creating a robot',
+ 'properties': {
+ 'description': {
+ 'type': 'string',
+ 'description': 'Optional text description for the robot',
+ 'maxLength': 255,
},
- 'role': permission.role.name
- }
+ 'unstructured_metadata': {
+ 'type': 'object',
+ 'description': 'Optional unstructured metadata for the robot',
+ },
+ },
+}
+
+ROBOT_MAX_SIZE = 1024 * 1024 # 1 KB.
-def robots_list(prefix, include_permissions=False):
- tuples = model.user.list_entity_robot_permission_teams(prefix,
- include_permissions=include_permissions)
+def robots_list(prefix, include_permissions=False, include_token=False, limit=None):
+ robots = model.list_entity_robot_permission_teams(prefix, limit=limit,
+ include_token=include_token,
+ include_permissions=include_permissions)
+ return {'robots': [robot.to_dict(include_token=include_token) for robot in robots]}
- robots = {}
- robot_teams = set()
-
- for robot_tuple in tuples:
- robot_name = robot_tuple.get(User.username)
- if not robot_name in robots:
- robots[robot_name] = {
- 'name': robot_name,
- 'token': robot_tuple.get(FederatedLogin.service_ident)
- }
-
- if include_permissions:
- robots[robot_name].update({
- 'teams': [],
- 'repositories': []
- })
-
- if include_permissions:
- team_name = robot_tuple.get(Team.name)
- repository_name = robot_tuple.get(Repository.name)
-
- if team_name is not None:
- check_key = robot_name + ':' + team_name
- if not check_key in robot_teams:
- robot_teams.add(check_key)
-
- robots[robot_name]['teams'].append({
- 'name': team_name,
- 'avatar': avatar.get_data(team_name, team_name, 'team')
- })
-
- if repository_name is not None:
- if not repository_name in robots[robot_name]['repositories']:
- robots[robot_name]['repositories'].append(repository_name)
-
- return {'robots': robots.values()}
@resource('/v1/user/robots')
class UserRobotList(ApiResource):
""" Resource for listing user robots. """
+
@require_user_admin
@nickname('getUserRobots')
- @parse_args
+ @parse_args()
@query_param('permissions',
- 'Whether to include repostories and teams in which the robots have permission.',
+ 'Whether to include repositories and teams in which the robots have permission.',
type=truthy_bool, default=False)
- def get(self, args):
+ @query_param('token',
+ 'If false, the robot\'s token is not returned.',
+ type=truthy_bool, default=True)
+ @query_param('limit',
+ 'If specified, the number of robots to return.',
+ type=int, default=None)
+ def get(self, parsed_args):
""" List the available robots for the user. """
user = get_authenticated_user()
- return robots_list(user.username, include_permissions=args.get('permissions', False))
+ return robots_list(user.username, include_token=parsed_args.get('token', True),
+ include_permissions=parsed_args.get('permissions', False),
+ limit=parsed_args.get('limit'))
@resource('/v1/user/robots/ |