Merge pull request #492 from coreos-inc/nofreelunch

UI and API fixes for disallowing private repo count abuse
This commit is contained in:
josephschorr 2015-09-16 17:53:11 -04:00
commit c801965626
12 changed files with 267 additions and 112 deletions

View file

@ -12,12 +12,42 @@ from auth.permissions import AdministerOrganizationPermission
from auth.auth_context import get_authenticated_user
from auth import scopes
from data import model
from data.billing import PLANS
from data.billing import PLANS, get_plan
import features
import uuid
import json
def lookup_allowed_private_repos(namespace):
""" Returns false if the given namespace has used its allotment of private repositories. """
# Lookup the namespace and verify it has a subscription.
namespace_user = model.user.get_namespace_user(namespace)
if namespace_user is None:
return False
if not namespace_user.stripe_id:
return False
# Ask Stripe for the subscribed plan.
# TODO: Can we cache this or make it faster somehow?
try:
cus = billing.Customer.retrieve(namespace_user.stripe_id)
except stripe.APIConnectionError:
abort(503, message='Cannot contact Stripe')
if not cus.subscription:
return False
# Find the number of private repositories used by the namespace and compare it to the
# plan subscribed.
private_repos = model.user.get_private_repo_count(namespace)
current_plan = get_plan(cus.subscription.plan.id)
if current_plan is None:
return False
return private_repos < current_plan['privateRepos']
def carderror_response(e):
return {'carderror': e.message}, 402

View file

@ -2,6 +2,7 @@
import logging
import datetime
import features
from datetime import timedelta
@ -15,7 +16,8 @@ from endpoints.api import (truthy_bool, format_date, nickname, log_action, valid
require_repo_read, require_repo_write, require_repo_admin,
RepositoryParamResource, resource, query_param, parse_args, ApiResource,
request_error, require_scope, Unauthorized, NotFound, InvalidRequest,
path_param)
path_param, ExceedsLicenseException)
from endpoints.api.billing import lookup_allowed_private_repos
from auth.permissions import (ModifyRepositoryPermission, AdministerRepositoryPermission,
CreateRepositoryPermission)
@ -26,6 +28,18 @@ from auth import scopes
logger = logging.getLogger(__name__)
def check_allowed_private_repos(namespace):
""" Checks to see if the given namespace has reached its private repository limit. If so,
raises a ExceedsLicenseException.
"""
# Not enabled if billing is disabled.
if not features.BILLING:
return
if not lookup_allowed_private_repos(namespace):
raise ExceedsLicenseException()
@resource('/v1/repository')
class RepositoryList(ApiResource):
"""Operations for creating and listing repositories."""
@ -87,6 +101,8 @@ class RepositoryList(ApiResource):
raise request_error(message='Repository already exists')
visibility = req['visibility']
if visibility == 'private':
check_allowed_private_repos(namespace_name)
repo = model.repository.create_repository(namespace_name, repository_name, owner, visibility)
repo.description = req['description']
@ -339,7 +355,11 @@ class RepositoryVisibility(RepositoryParamResource):
repo = model.repository.get_repository(namespace, repository)
if repo:
values = request.get_json()
model.repository.set_repository_visibility(repo, values['visibility'])
visibility = values['visibility']
if visibility == 'private':
check_allowed_private_repos(namespace)
model.repository.set_repository_visibility(repo, visibility)
log_action('change_repo_visibility', namespace,
{'repo': repository, 'visibility': values['visibility']},
repo=repo)

View file

@ -29,6 +29,18 @@
margin-top: -7px !important;
}
.repo-panel-settings-element .repo-count-checker {
margin-top: 20px;
}
.repo-panel-settings-element .co-alert {
margin-bottom: 0px;
}
.repo-panel-settings-element .panel-body {
border-bottom: 0px;
}
@media (max-width: 767px) {
.repo-panel-settings-element .delete-btn {
float: none;

View file

@ -0,0 +1,26 @@
.repo-count-checker .btn {
margin-top: 0px !important;
}
.repo-count-checker .co-alert {
margin-bottom: 6px !important;
padding-right: 120px;
}
.repo-count-checker .co-alert .btn {
position: absolute;
top: 10px;
right: 10px;
}
@media (max-width: 767px) {
.repo-count-checker .co-alert {
padding-right: 10px;
}
.repo-count-checker .co-alert .btn {
position: relative;
margin-top: 20px;
margin-bottom: 10px;
}
}

View file

@ -0,0 +1,24 @@
<div class="repo-count-checker-element">
<div class="required-plan" ng-show="isEnabled && planRequired && planRequired.title">
<div class="co-alert co-alert-info">
In order to make this repository private under
<strong ng-if="isUserNamespace">your personal namespace</strong>
<strong ng-if="!isUserNamespace">organization <b>{{ repo.namespace }}</b></strong>, you will need to upgrade your plan to
<b style="border-bottom: 1px dotted black;" data-html="true"
data-title="{{ '<b>' + planRequired.title + '</b><br>' + planRequired.privateRepos + ' private repositories' }}" bs-tooltip>
{{ planRequired.title }}
</b>.
This will cost $<span>{{ planRequired.price / 100 }}</span>/month.
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a>
</div>
<span ng-if="isUserNamespace && user.organizations.length == 1" style="margin-left: 6px; display: inline-block;">or did you mean to have this repository under the <b>{{ user.organizations[0].name }}</b> namespace?</span>
<div class="cor-loader-inline" ng-show="planChanging"></div>
</div>
<div class="cor-loader-inline" ng-show="isEnabled && checkingPlan"></div>
<div class="required-plan" ng-show="isEnabled && planRequired && !isUserNamespace && !planRequired.title">
<div class="co-alert co-alert-warning">
This organization has reached its private repository limit. Please contact your administrator.
</div>
</div>
</div>
</div>

View file

@ -22,12 +22,10 @@
<div class="repository-events-table" repository="repository"
is-enabled="isEnabled"></div>
<!-- Other settings -->
<!-- Visibility settings -->
<div class="co-panel">
<div class="co-panel-heading"><i class="fa fa-gears"></i> Repository Settings</div>
<div class="co-panel-heading"><i class="fa fa-unlock-alt"></i> Repository Visibility</div>
<div class="cor-loader" ng-show="!repository"></div>
<div ng-show="repository">
<!-- Public/Private -->
<div class="panel-body panel-section lock-section" ng-if="!repository.is_public">
@ -44,12 +42,23 @@
<div>This repository is currently <b>public</b> and is visible to all users, and may be pulled by all users.</div>
<button class="btn btn-default" ng-click="askChangeAccess('private')">
<button class="btn btn-default" ng-click="askChangeAccess('private')" ng-show="!planRequired">
<i class="fa fa-lock"></i>Make Private
</button>
</div>
<!-- Delete Repository -->
<!-- Payment -->
<div class="repo-count-checker" namespace="repository.namespace" plan-required="planRequired"
is-enabled="repository.is_public">
</div>
</div>
</div>
</div>
<!-- Delete repository -->
<div class="co-panel">
<div class="co-panel-heading"><i class="fa fa-trash"></i> Delete Repository</div>
<div class="cor-loader" ng-show="!repository"></div>
<div ng-show="repository">
<div class="panel-body panel-section">
<div class="co-alert co-alert-danger">
<button class="btn btn-danger delete-btn" ng-click="askDelete()">
@ -60,10 +69,16 @@
Deleting a repository <b>cannot be undone</b>. Here be dragons!
</div>
</div>
</div>
</div>
<!-- Build Status Badge -->
<div class="panel-body panel-section hidden-xs">
<!-- Build Status Badge -->
<div class="co-panel hidden-xs">
<div class="co-panel-heading"><i class="fa fa-tasks"></i> Build Status Badge</div>
<div class="cor-loader" ng-show="!repository"></div>
<div ng-show="repository">
<div class="panel-body panel-section">
<!-- Token Info Banner -->
<div class="co-alert co-alert-info" ng-if="!repository.is_public">
Note: This badge contains a token so the badge can be seen by external users. The token does not grant any other access and is safe to share!

View file

@ -0,0 +1,81 @@
/**
* An element which displays a message when the maximum number of private repositories has been
* reached.
*/
angular.module('quay').directive('repoCountChecker', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/repo-count-checker.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'namespace': '=namespace',
'planRequired': '=planRequired',
'isEnabled': '=isEnabled'
},
controller: function($scope, $element, ApiService, UserService, PlanService, Features) {
var refresh = function() {
$scope.planRequired = null;
if (!$scope.isEnabled || !$scope.namespace || !Features.BILLING) {
return;
}
$scope.checkingPlan = true;
$scope.isUserNamespace = UserService.isUserNamespace($scope.namespace);
ApiService.getPrivateAllowed($scope.isUserNamespace ? null : $scope.namespace).then(function(resp) {
$scope.checkingPlan = false;
if (resp['privateAllowed']) {
$scope.planRequired = null;
return;
}
if (resp['privateCount'] == null) {
// Organization where we are not the admin.
$scope.planRequired = {};
return;
}
// Otherwise, lookup the matching plan.
PlanService.getMinimumPlan(resp['privateCount'] + 1, !$scope.isUserNamespace, function(minimum) {
$scope.planRequired = minimum;
});
});
};
var subscribedToPlan = function(sub) {
$scope.planChanging = false;
$scope.subscription = sub;
PlanService.getPlan(sub.plan, function(subscribedPlan) {
$scope.subscribedPlan = subscribedPlan;
refresh();
});
};
$scope.$watch('namespace', refresh);
$scope.$watch('isEnabled', refresh);
$scope.upgradePlan = function() {
var callbacks = {
'started': function() { $scope.planChanging = true; },
'opened': function() { $scope.planChanging = true; },
'closed': function() { $scope.planChanging = false; },
'success': subscribedToPlan,
'failure': function(resp) {
$('#couldnotsubscribeModal').modal();
$scope.planChanging = false;
}
};
var isUserNamespace = UserService.isUserNamespace($scope.namespace);
var namespace = isUserNamespace ? null : $scope.namespace;
PlanService.changePlan($scope, namespace, $scope.planRequired.stripeId, callbacks);
};
}
};
return directiveDefinitionObject;
});

View file

@ -22,22 +22,6 @@
'initialize': ''
};
// Watch the namespace on the repo. If it changes, we update the plan and the public/private
// accordingly.
$scope.isUserNamespace = true;
$scope.$watch('repo.namespace', function(namespace) {
// Note: Can initially be undefined.
if (!namespace) { return; }
var isUserNamespace = (namespace == $scope.user.username);
$scope.planRequired = null;
$scope.isUserNamespace = isUserNamespace;
// Determine whether private repositories are allowed for the namespace.
checkPrivateAllowed();
});
$scope.changeNamespace = function(namespace) {
$scope.repo.namespace = namespace;
};
@ -108,65 +92,5 @@
});
});
};
$scope.upgradePlan = function() {
var callbacks = {
'started': function() { $scope.planChanging = true; },
'opened': function() { $scope.planChanging = true; },
'closed': function() { $scope.planChanging = false; },
'success': subscribedToPlan,
'failure': function(resp) {
$('#couldnotsubscribeModal').modal();
$scope.planChanging = false;
}
};
var namespace = $scope.isUserNamespace ? null : $scope.repo.namespace;
PlanService.changePlan($scope, namespace, $scope.planRequired.stripeId, callbacks);
};
var checkPrivateAllowed = function() {
if (!$scope.repo || !$scope.repo.namespace) { return; }
if (!Features.BILLING) {
$scope.checkingPlan = false;
$scope.planRequired = null;
return;
}
$scope.checkingPlan = true;
var isUserNamespace = $scope.isUserNamespace;
ApiService.getPrivateAllowed(isUserNamespace ? null : $scope.repo.namespace).then(function(resp) {
$scope.checkingPlan = false;
if (resp['privateAllowed']) {
$scope.planRequired = null;
return;
}
if (resp['privateCount'] == null) {
// Organization where we are not the admin.
$scope.planRequired = {};
return;
}
// Otherwise, lookup the matching plan.
PlanService.getMinimumPlan(resp['privateCount'] + 1, !isUserNamespace, function(minimum) {
$scope.planRequired = minimum;
});
});
};
var subscribedToPlan = function(sub) {
$scope.planChanging = false;
$scope.subscription = sub;
PlanService.getPlan(sub.plan, function(subscribedPlan) {
$scope.subscribedPlan = subscribedPlan;
$scope.planRequired = null;
checkPrivateAllowed();
});
};
}
})();

View file

@ -126,6 +126,10 @@ function(ApiService, CookieService, $rootScope, Config) {
return userResponse;
};
userService.isUserNamespace = function(namespace) {
return namespace == userResponse.username;
};
// Update the user in the root scope.
userService.updateUserIn($rootScope);

View file

@ -88,31 +88,9 @@
</div>
<!-- Payment -->
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && planRequired.title">
<div class="co-alert co-alert-warning">
In order to make this repository private under
<strong ng-if="isUserNamespace">your personal namespace</strong>
<strong ng-if="!isUserNamespace">organization <b>{{ repo.namespace }}</b></strong>, you will need to upgrade your plan to
<b style="border-bottom: 1px dotted black;" data-html="true"
data-title="{{ '<b>' + planRequired.title + '</b><br>' + planRequired.privateRepos + ' private repositories' }}" bs-tooltip>
{{ planRequired.title }}
</b>.
This will cost $<span>{{ planRequired.price / 100 }}</span>/month.
</div>
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a>
<span ng-if="isUserNamespace && user.organizations.length == 1" style="margin-left: 6px; display: inline-block;">or did you mean to create this repository
under <a href="javascript:void(0)" ng-click="changeNamespace(user.organizations[0].name)"><b>{{ user.organizations[0].name }}</b></a>?</span>
<div class="cor-loader-inline" ng-show="planChanging"></div>
<div class="repo-count-checker" namespace="repo.namespace" plan-required="planRequired"
is-enabled="repo.is_public == '0'">
</div>
<div class="cor-loader-inline" ng-show="repo.is_public == '0' && checkingPlan"></div>
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && !isUserNamespace && !planRequired.title">
<div class="co-alert co-alert-warning">
This organization has reached its private repository limit. Please contact your administrator.
</div>
</div>
</div>
</div>
</div>

View file

@ -317,8 +317,18 @@ class TestGetUserPrivateAllowed(ApiTestCase):
def test_allowed(self):
self.login(ADMIN_ACCESS_USER)
# Change the subscription of the namespace.
self.putJsonResponse(UserPlan, data=dict(plan='personal-30'))
json = self.getJsonResponse(PrivateRepositories)
assert json['privateCount'] >= 6
assert not json['privateAllowed']
# Change the subscription of the namespace.
self.putJsonResponse(UserPlan, data=dict(plan='bus-large-30'))
json = self.getJsonResponse(PrivateRepositories)
assert json['privateAllowed']
@ -1437,6 +1447,36 @@ class TestUpdateRepo(ApiTestCase):
class TestChangeRepoVisibility(ApiTestCase):
SIMPLE_REPO = ADMIN_ACCESS_USER + '/simple'
def test_trychangevisibility(self):
self.login(ADMIN_ACCESS_USER)
# Make public.
self.postJsonResponse(RepositoryVisibility,
params=dict(repository=self.SIMPLE_REPO),
data=dict(visibility='public'))
# Verify the visibility.
json = self.getJsonResponse(Repository,
params=dict(repository=self.SIMPLE_REPO))
self.assertEquals(True, json['is_public'])
# Change the subscription of the namespace.
self.putJsonResponse(UserPlan, data=dict(plan='personal-30'))
# Try to make private.
self.postJsonResponse(RepositoryVisibility,
params=dict(repository=self.SIMPLE_REPO),
data=dict(visibility='private'),
expected_code=402)
# Verify the visibility.
json = self.getJsonResponse(Repository,
params=dict(repository=self.SIMPLE_REPO))
self.assertEquals(True, json['is_public'])
def test_changevisibility(self):
self.login(ADMIN_ACCESS_USER)

View file

@ -20,6 +20,7 @@ TEST_DB_FILE = NamedTemporaryFile(delete=True)
class TestConfig(DefaultConfig):
TESTING = True
SECRET_KEY = 'a36c9d7d-25a9-4d3f-a586-3d2f8dc40a83'
BILLING_TYPE = 'FakeStripe'
TEST_DB_FILE = TEST_DB_FILE
DB_URI = os.environ.get('TEST_DATABASE_URI', 'sqlite:///{0}'.format(TEST_DB_FILE.name))