diff --git a/app.py b/app.py index 214efb3b6..1fd0bc936 100644 --- a/app.py +++ b/app.py @@ -1,47 +1,51 @@ +import json import logging import os -import json from functools import partial + +from Crypto.PublicKey import RSA from flask import Flask, request, Request, _request_ctx_stack -from flask_principal import Principal from flask_login import LoginManager, UserMixin from flask_mail import Mail -from werkzeug.routing import BaseConverter +from flask_principal import Principal from jwkest.jwk import RSAKey -from Crypto.PublicKey import RSA +from werkzeug.routing import BaseConverter import features from avatars.avatars import Avatar -from storage import Storage -from data import model from data import database -from data.userfiles import Userfiles -from data.users import UserAuthentication +from data import model +from data.archivedlogs import LogArchive from data.billing import Billing from data.buildlogs import BuildLogs -from data.archivedlogs import LogArchive -from data.userevent import UserEventsBuilderModule from data.queue import WorkQueue, BuildMetricQueueReporter +from data.userevent import UserEventsBuilderModule +from data.userfiles import Userfiles +from data.users import UserAuthentication +from storage import Storage from util import get_app_url from util.saas.analytics import Analytics from util.saas.useranalytics import UserAnalytics from util.saas.exceptionlog import Sentry from util.names import urn_generator +from util.config.configutil import generate_secret_key from util.config.oauth import (GoogleOAuthConfig, GithubOAuthConfig, GitLabOAuthConfig, DexOAuthConfig) - -from util.security.signing import Signer -from util.security.instancekeys import InstanceKeys -from util.saas.cloudwatch import start_cloudwatch_sender from util.config.provider import get_config_provider -from util.config.configutil import generate_secret_key from util.config.superusermanager import SuperUserManager -from util.secscan.api import SecurityScannerAPI +from util.label_validator import LabelValidator +from util.license import LicenseValidator, LICENSE_FILENAME from util.metrics.metricqueue import MetricQueue from util.metrics.prometheus import PrometheusPlugin -from util.label_validator import LabelValidator +from util.names import urn_generator +from util.saas.analytics import Analytics +from util.saas.cloudwatch import start_cloudwatch_sender +from util.saas.exceptionlog import Sentry +from util.secscan.api import SecurityScannerAPI +from util.security.instancekeys import InstanceKeys +from util.security.signing import Signer OVERRIDE_CONFIG_DIRECTORY = 'conf/stack/' OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml' @@ -189,6 +193,9 @@ signer = Signer(app, config_provider) instance_keys = InstanceKeys(app) label_validator = LabelValidator(app) +license_validator = LicenseValidator(os.path.join(OVERRIDE_CONFIG_DIRECTORY, LICENSE_FILENAME)) +license_validator.start() + start_cloudwatch_sender(metric_queue, app) tf = app.config['DB_TRANSACTION_FACTORY'] diff --git a/endpoints/api/suconfig.py b/endpoints/api/suconfig.py index 502c048d4..3da059290 100644 --- a/endpoints/api/suconfig.py +++ b/endpoints/api/suconfig.py @@ -6,7 +6,8 @@ import signal from flask import abort from endpoints.api import (ApiResource, nickname, resource, internal_only, show_if, - require_fresh_login, request, validate_json_request, verify_not_prod) + require_fresh_login, request, validate_json_request, verify_not_prod, + InvalidRequest) from endpoints.common import common_login from app import app, config_provider, superusers, OVERRIDE_CONFIG_DIRECTORY @@ -18,6 +19,7 @@ from data.database import User from util.config.configutil import add_enterprise_config_defaults from util.config.database import sync_database_with_config from util.config.validator import validate_service_for_config, CONFIG_FILENAMES +from util.license import decode_license, LicenseError from data.runmigration import run_alembic_migration from data.users import get_federated_service_name, get_users_handler @@ -62,6 +64,12 @@ class SuperUserRegistryStatus(ApiResource): 'status': 'missing-config-dir' } + # If there is no license file, we need to ask the user to upload it. + if not config_provider.has_license_file(): + return { + 'status': 'upload-license' + } + # If there is no config file, we need to setup the database. if not config_provider.config_exists(): return { @@ -244,6 +252,50 @@ class SuperUserConfig(ApiResource): abort(403) +@resource('/v1/superuser/config/license') +@internal_only +@show_if(features.SUPER_USERS) +class SuperUserSetAndValidateLicense(ApiResource): + """ Resource for setting and validating a license. """ + schemas = { + 'ValidateLicense': { + 'type': 'object', + 'description': 'Validates and sets a license', + 'required': [ + 'license', + ], + 'properties': { + 'license': { + 'type': 'string' + }, + }, + }, + } + + @nickname('suSetAndValidateLicense') + @verify_not_prod + @validate_json_request('ValidateLicense') + def post(self): + """ Validates the given license contents and then saves it to the config volume. """ + if config_provider.has_license_file(): + abort(403) + + license_contents = request.get_json()['license'] + try: + decoded_license = decode_license(license_contents) + except LicenseError as le: + raise InvalidRequest(le.message) + + if decoded_license.is_expired: + raise InvalidRequest('License has expired') + + config_provider.save_license(license_contents) + return { + 'decoded': decoded_license.subscription, + 'success': True + } + + @resource('/v1/superuser/config/file/') @internal_only @show_if(features.SUPER_USERS) diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index baa5b1a86..d1fe52518 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -18,11 +18,12 @@ from auth.permissions import SuperUserPermission from endpoints.api import (ApiResource, nickname, resource, validate_json_request, internal_only, require_scope, show_if, parse_args, query_param, abort, require_fresh_login, path_param, verify_not_prod, - page_support, log_action) + page_support, log_action, InvalidRequest) from endpoints.api.logs import get_logs, get_aggregate_logs from data import model from data.database import ServiceKeyApprovalType from util.useremails import send_confirmation_email, send_recovery_email +from util.license import decode_license, LicenseError logger = logging.getLogger(__name__) @@ -819,3 +820,143 @@ class SuperUserServiceKeyApproval(ApiResource): return make_response('', 201) abort(403) + + +@resource('/v1/superuser/license') +@internal_only +@show_if(features.SUPER_USERS) +class SuperUserLicense(ApiResource): + """ Resource for getting and setting a license. """ + schemas = { + 'UpdateLicense': { + 'type': 'object', + 'description': 'Updates a license', + 'required': [ + 'license', + ], + 'properties': { + 'license': { + 'type': 'string' + }, + }, + }, + } + + @nickname('getLicense') + @require_fresh_login + @require_scope(scopes.SUPERUSER) + @verify_not_prod + def get(self): + """ Returns the current decoded license. """ + if SuperUserPermission().can(): + try: + decoded_license = config_provider.get_license() + except LicenseError as le: + raise InvalidRequest(le.message) + + if decoded_license.is_expired: + raise InvalidRequest('License has expired') + + return { + 'decoded': decoded_license.subscription, + 'success': True + } + + abort(403) + + @nickname('updateLicense') + @require_fresh_login + @require_scope(scopes.SUPERUSER) + @verify_not_prod + @validate_json_request('UpdateLicense') + def put(self): + """ Validates the given license contents and then saves it to the config volume. """ + if SuperUserPermission().can(): + license_contents = request.get_json()['license'] + try: + decoded_license = decode_license(license_contents) + except LicenseError as le: + raise InvalidRequest(le.message) + + if decoded_license.is_expired: + raise InvalidRequest('License has expired') + + config_provider.save_license(license_contents) + return { + 'decoded': decoded_license.subscription, + 'success': True + } + + abort(403) + + +@resource('/v1/messages') +@show_if(features.SUPER_USERS) +class SuperUserMessages(ApiResource): + """ Resource for getting a list of super user messages """ + + schemas = { + 'GetMessage': { + 'id': 'GetMessage', + 'type': 'object', + 'description': 'Messages that a super user has saved in the past', + 'properties': { + 'message': { + 'type': 'array', + 'description': 'A list of messages', + 'itemType': { + 'type': 'object', + 'properties': { + 'id': { + 'type': 'integer', + 'description': 'The message id', + }, + 'content': { + 'type': 'string', + 'description': 'The actual message', + }, + }, + }, + }, + }, + }, + 'CreateMessage': { + 'id': 'CreateMessage', + 'type': 'object', + 'description': 'Create a new message', + 'properties': { + 'message': { + 'type': 'object', + 'description': 'A single message', + 'properties': { + 'content': { + 'type': 'string', + 'description': 'The actual message', + }, + }, + }, + }, + } + } + + @nickname('getMessages') + def get(self): + """ Return a super users messages """ + return { + 'messages': [message_view(m) for m in model.message.get_messages()], + } + + @verify_not_prod + @nickname('createMessages') + @validate_json_request('CreateMessage') + @require_scope(scopes.SUPERUSER) + def post(self): + """ Create a message """ + if SuperUserPermission().can(): + model.message.create([request.get_json()['message']]) + return make_response('', 201) + abort(403) + + +def message_view(message): + return {'id': message.id, 'content': message.content} diff --git a/endpoints/v1/__init__.py b/endpoints/v1/__init__.py index 1e9715787..18ef430c4 100644 --- a/endpoints/v1/__init__.py +++ b/endpoints/v1/__init__.py @@ -1,14 +1,15 @@ from flask import Blueprint, make_response -from app import metric_queue +from app import metric_queue, license_validator from endpoints.decorators import anon_protect, anon_allowed from util.metrics.metricqueue import time_blueprint v1_bp = Blueprint('v1', __name__) - +license_validator.enforce_license_before_request(v1_bp) time_blueprint(v1_bp, metric_queue) + # Note: This is *not* part of the Docker index spec. This is here for our own health check, # since we have nginx handle the _ping below. @v1_bp.route('/_internal_ping') @@ -29,4 +30,4 @@ def ping(): from endpoints.v1 import index from endpoints.v1 import registry -from endpoints.v1 import tag \ No newline at end of file +from endpoints.v1 import tag diff --git a/endpoints/v2/__init__.py b/endpoints/v2/__init__.py index da20b9077..26150f875 100644 --- a/endpoints/v2/__init__.py +++ b/endpoints/v2/__init__.py @@ -9,7 +9,7 @@ from semantic_version import Spec import features -from app import app, metric_queue, get_app_url +from app import app, metric_queue, get_app_url, license_validator from auth.auth_context import get_grant_context from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, AdministerRepositoryPermission) @@ -18,16 +18,32 @@ from data import model from endpoints.decorators import anon_protect, anon_allowed from endpoints.v2.errors import V2RegistryException, Unauthorized from util.http import abort -from util.registry.dockerver import docker_version from util.metrics.metricqueue import time_blueprint +from util.registry.dockerver import docker_version from util.pagination import encrypt_page_token, decrypt_page_token -logger = logging.getLogger(__name__) -v2_bp = Blueprint('v2', __name__) +logger = logging.getLogger(__name__) + + +v2_bp = Blueprint('v2', __name__) +license_validator.enforce_license_before_request(v2_bp) time_blueprint(v2_bp, metric_queue) +@v2_bp.app_errorhandler(V2RegistryException) +def handle_registry_v2_exception(error): + response = jsonify({ + 'errors': [error.as_dict()] + }) + + response.status_code = error.http_status_code + if response.status_code == 401: + response.headers.extend(get_auth_headers(repository=error.repository, scopes=error.scopes)) + logger.debug('sending response: %s', response.get_data()) + return response + + _MAX_RESULTS_PER_PAGE = 50 @@ -72,19 +88,6 @@ def paginate(limit_kwarg_name='limit', offset_kwarg_name='offset', return wrapper -@v2_bp.app_errorhandler(V2RegistryException) -def handle_registry_v2_exception(error): - response = jsonify({ - 'errors': [error.as_dict()] - }) - - response.status_code = error.http_status_code - if response.status_code == 401: - response.headers.extend(get_auth_headers(repository=error.repository, scopes=error.scopes)) - logger.debug('sending response: %s', response.get_data()) - return response - - def _require_repo_permission(permission_class, scopes=None, allow_public=False): def wrapper(func): @wraps(func) @@ -117,7 +120,6 @@ def get_input_stream(flask_request): return flask_request.stream -# TODO remove when v2 is deployed everywhere def route_show_if(value): def decorator(f): @wraps(f) @@ -129,6 +131,7 @@ def route_show_if(value): return decorated_function return decorator + @v2_bp.route('/') @route_show_if(features.ADVERTISE_V2) @process_registry_jwt_auth() diff --git a/endpoints/verbs/__init__.py b/endpoints/verbs/__init__.py index 7dddd0a8c..d7c8e248b 100644 --- a/endpoints/verbs/__init__.py +++ b/endpoints/verbs/__init__.py @@ -5,7 +5,7 @@ from flask import redirect, Blueprint, abort, send_file, make_response, request import features -from app import app, signer, storage, metric_queue +from app import app, signer, storage, metric_queue, license_validator from auth.auth_context import get_authenticated_user from auth.permissions import ReadRepositoryPermission from auth.process import process_auth @@ -25,9 +25,11 @@ from util.registry.torrent import (make_torrent, per_user_torrent_filename, publ PieceHasher) -verbs = Blueprint('verbs', __name__) logger = logging.getLogger(__name__) +verbs = Blueprint('verbs', __name__) +license_validator.enforce_license_before_request(verbs) + def _open_stream(formatter, namespace, repository, tag, derived_image_id, repo_image, handlers): """ diff --git a/static/css/core-ui.css b/static/css/core-ui.css index 26fb1b5cf..bd482b37e 100644 --- a/static/css/core-ui.css +++ b/static/css/core-ui.css @@ -567,6 +567,33 @@ a:focus { margin-right: 4px; } +.config-license-field-element textarea { + padding: 10px; + margin-bottom: 10px; + height: 250px; +} + +.config-license-field-element .license-status { + margin-bottom: 26px; +} + +.config-license-field-element table td:first-child { + width: 150px; + font-weight: bold; +} + +.config-license-field-element .fa { + margin-right: 6px; +} + +.config-license-field-element .license-valid h4 { + color: #2FC98E; +} + +.config-license-field-element .license-invalid h4 { + color: #D64456; +} + .co-checkbox { position: relative; } diff --git a/static/css/pages/setup.css b/static/css/pages/setup.css new file mode 100644 index 000000000..2ca87dbdc --- /dev/null +++ b/static/css/pages/setup.css @@ -0,0 +1,56 @@ +.initial-setup-modal .upload-license textarea { + border: 1px solid #eee !important; + transition: all ease-in-out 200ms; + resize: none; +} + +.initial-setup-modal .upload-license textarea { + padding: 10px; + margin-top: 20px; + margin-bottom: 10px; +} + +.initial-setup-modal .upload-license .validate-message { + display: inline-block; + margin-left: 10px; + margin-top: 10px; +} + +.initial-setup-modal .upload-license .license-invalid h5 { + font-size: 18px; + color: red; +} + +.initial-setup-modal .upload-license .license-invalid h6 { + margin-bottom: 10px; + font-size: 16px; +} + +.initial-setup-modal .upload-license .license-invalid .fa { + margin-right: 6px; +} + +.initial-setup-modal .license-valid h5 { + color: #2FC98E; + font-size: 16px; + margin-bottom: 16px; +} + +.initial-setup-modal .license-valid .fa { + margin-right: 6px; +} + +.initial-setup-modal .license-valid table { + margin-top: 40px; +} + +.initial-setup-modal .license-valid table td { + border: 0px; + padding: 4px; +} + +.initial-setup-modal .license-valid table td:first-child { + font-weight: bold; + max-width: 100px; + padding-right: 20px; +} diff --git a/static/directives/config/config-license-field.html b/static/directives/config/config-license-field.html new file mode 100644 index 000000000..9eb688156 --- /dev/null +++ b/static/directives/config/config-license-field.html @@ -0,0 +1,40 @@ +
+ + + +
+ +
+

License Valid

+ + + +
Product:{{ licenseDecoded.publicProductName || licenseDecoded.productName }}
Plan:{{ licenseDecoded.publicPlanName || licenseDecoded.planName }}
+
+ +
+

Validation Failed

+
{{ licenseError }}
+
+ + + +
+

+ Your license can be found under the "Raw Format" tab of your Quay Enterprise + subscription in the Tectonic Account. +

+ + + + + +
+ Validating License +
+
+
\ No newline at end of file diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html index c86187488..d810ad1a7 100644 --- a/static/directives/config/config-setup-tool.html +++ b/static/directives/config/config-setup-tool.html @@ -3,6 +3,16 @@
+ +
+
+ License +
+
+
+
+
+
diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js index c47ba2f77..19977d359 100644 --- a/static/js/core-config-setup.js +++ b/static/js/core-config-setup.js @@ -1246,5 +1246,73 @@ angular.module("core-config-setup", ['angularFileUpload']) } }; return directiveDefinitionObject; + }) + + .directive('configLicenseField', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/config/config-license-field.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + }, + controller: function($scope, $element, ApiService, UserService) { + $scope.state = 'loading-license'; + $scope.showingEditor = false; + $scope.requiredBox = ''; + + var loadLicense = function() { + ApiService.getLicense().then(function(resp) { + $scope.state = 'license-valid'; + $scope.showingEditor = false; + $scope.licenseDecoded = resp['decoded']; + $scope.requiredBox = 'filled'; + }, function(resp) { + $scope.licenseError = ApiService.getErrorMessage(resp); + $scope.state = 'license-error'; + $scope.showingEditor = true; + $scope.requiredBox = ''; + }); + }; + + UserService.updateUserIn($scope, function(user) { + if (!user.anonymous) { + loadLicense(); + } + }); + + $scope.showEditor = function($event) { + $event.preventDefault(); + $event.stopPropagation(); + + $scope.showingEditor = true; + }; + + $scope.validateAndUpdate = function($event) { + $event.preventDefault(); + $event.stopPropagation(); + + $scope.state = 'validating-license'; + + var data = { + 'license': $scope.licenseContents + }; + + ApiService.updateLicense(data).then(function(resp) { + $scope.state = 'license-valid'; + $scope.showingEditor = false; + $scope.licenseDecoded = resp['decoded']; + $scope.requiredBox = 'filled'; + }, function(resp) { + $scope.licenseError = ApiService.getErrorMessage(resp); + $scope.state = 'license-error'; + $scope.showingEditor = true; + $scope.requiredBox = ''; + }); + }; + } + }; + return directiveDefinitionObject; }); diff --git a/static/js/pages/setup.js b/static/js/pages/setup.js index cbd539c16..3759a9bf2 100644 --- a/static/js/pages/setup.js +++ b/static/js/pages/setup.js @@ -37,6 +37,15 @@ // The config.yaml exists but it is invalid. 'INVALID_CONFIG': 'config-invalid', + // License is being uploaded. + 'UPLOAD_LICENSE': 'upload-license', + + // License is being validated. + 'VALIDATING_LICENSE': 'upload-license-validating', + + // License is validated. + 'VALIDATED_LICENSE': 'upload-license-validated', + // DB is being configured. 'CONFIG_DB': 'config-db', @@ -95,7 +104,10 @@ $scope.currentConfig = null; $scope.currentState = { - 'hasDatabaseSSLCert': false + 'hasDatabaseSSLCert': false, + 'licenseContents': '', + 'licenseError': null, + 'licenseDecoded': null }; $scope.$watch('currentStep', function(currentStep) { @@ -121,6 +133,7 @@ case $scope.States.CREATE_SUPERUSER: case $scope.States.DB_RESTARTING: case $scope.States.CONFIG_DB: + case $scope.States.UPLOAD_LICENSE: case $scope.States.VALID_CONFIG: case $scope.States.READY: $('#setupModal').modal({ @@ -131,6 +144,27 @@ } }); + $scope.validateLicense = function() { + $scope.currentStep = $scope.States.VALIDATING_LICENSE; + + var data = { + 'license': $scope.currentState.licenseContents + }; + + ApiService.suSetAndValidateLicense(data).then(function(resp) { + $scope.currentStep = $scope.States.VALIDATED_LICENSE; + + $scope.currentState.licenseError = null; + $scope.currentState.licenseDecoded = resp['decoded']; + }, function(resp) { + $scope.currentStep = $scope.States.UPLOAD_LICENSE; + + $scope.currentState.licenseError = ApiService.getErrorMessage(resp); + $scope.currentState.licenseContents = ''; + $scope.currentState.licenseDecoded = null; + }); + }; + $scope.restartContainer = function(state) { $scope.currentStep = state; ContainerService.restartContainer(function() { @@ -166,6 +200,7 @@ var States = $scope.States; return [ + isStepFamily(step, States.UPLOAD_LICENSE), isStepFamily(step, States.CONFIG_DB), isStepFamily(step, States.DB_SETUP), isStep(step, States.DB_RESTARTING), @@ -191,6 +226,10 @@ return false; }; + $scope.beginSetup = function() { + $scope.currentStep = $scope.States.CONFIG_DB; + }; + $scope.showInvalidConfigDialog = function() { var message = "The config.yaml file found in conf/stack could not be parsed." var title = "Invalid configuration file"; diff --git a/static/partials/setup.html b/static/partials/setup.html index e28837b6e..e238ab846 100644 --- a/static/partials/setup.html +++ b/static/partials/setup.html @@ -9,12 +9,13 @@
- + + - - - + + + @@ -36,12 +37,13 @@ + + + + + + + + + + + +