Merge branch 'tokenauth'
Conflicts: test.db
This commit is contained in:
commit
4f0dced8e7
12 changed files with 507 additions and 118 deletions
78
auth/auth.py
78
auth/auth.py
|
@ -32,19 +32,34 @@ def process_basic_auth(auth):
|
||||||
credentials = b64decode(normalized[1]).split(':')
|
credentials = b64decode(normalized[1]).split(':')
|
||||||
|
|
||||||
if len(credentials) != 2:
|
if len(credentials) != 2:
|
||||||
logger.debug('Invalid basic auth credential formet.')
|
logger.debug('Invalid basic auth credential format.')
|
||||||
|
|
||||||
authenticated = model.verify_user(credentials[0], credentials[1])
|
if credentials[0] == '$token':
|
||||||
|
# Use as token 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
|
||||||
|
|
||||||
if authenticated:
|
identity_changed.send(app, identity=Identity(token.code, 'token'))
|
||||||
logger.debug('Successfully validated user: %s' % authenticated.username)
|
return
|
||||||
ctx = _request_ctx_stack.top
|
|
||||||
ctx.authenticated_user = authenticated
|
|
||||||
|
|
||||||
new_identity = QuayDeferredPermissionUser(authenticated.username,
|
except model.DataModelException:
|
||||||
'username')
|
logger.debug('Invalid token: %s' % credentials[1])
|
||||||
identity_changed.send(app, identity=new_identity)
|
|
||||||
return
|
else:
|
||||||
|
authenticated = model.verify_user(credentials[0], credentials[1])
|
||||||
|
|
||||||
|
if authenticated:
|
||||||
|
logger.debug('Successfully validated user: %s' % authenticated.username)
|
||||||
|
ctx = _request_ctx_stack.top
|
||||||
|
ctx.authenticated_user = authenticated
|
||||||
|
|
||||||
|
new_identity = QuayDeferredPermissionUser(authenticated.username,
|
||||||
|
'username')
|
||||||
|
identity_changed.send(app, identity=new_identity)
|
||||||
|
return
|
||||||
|
|
||||||
# We weren't able to authenticate via basic auth.
|
# We weren't able to authenticate via basic auth.
|
||||||
logger.debug('Basic auth present but could not be validated.')
|
logger.debug('Basic auth present but could not be validated.')
|
||||||
|
@ -54,42 +69,37 @@ def process_basic_auth(auth):
|
||||||
def process_token(auth):
|
def process_token(auth):
|
||||||
normalized = [part.strip() for part in auth.split(' ') if part]
|
normalized = [part.strip() for part in auth.split(' ') if part]
|
||||||
if normalized[0].lower() != 'token' or len(normalized) != 2:
|
if normalized[0].lower() != 'token' or len(normalized) != 2:
|
||||||
logger.debug('Invalid token format.')
|
logger.debug('Not an auth token: %s' % auth)
|
||||||
return
|
return
|
||||||
|
|
||||||
token_details = normalized[1].split(',')
|
token_details = normalized[1].split(',')
|
||||||
|
|
||||||
if len(token_details) != 2:
|
if len(token_details) != 1:
|
||||||
logger.debug('Invalid token format.')
|
logger.warning('Invalid token format: %s' % auth)
|
||||||
return
|
abort(401)
|
||||||
|
|
||||||
token_vals = {val[0]: val[1] for val in
|
token_vals = {val[0]: val[1] for val in
|
||||||
(detail.split('=') for detail in token_details)}
|
(detail.split('=') for detail in token_details)}
|
||||||
if ('signature' not in token_vals or 'repository' not in token_vals):
|
if 'signature' not in token_vals:
|
||||||
logger.debug('Invalid token components.')
|
logger.warning('Token does not contain signature: %s' % auth)
|
||||||
return
|
abort(401)
|
||||||
|
|
||||||
unquoted = token_vals['repository'][1:-1]
|
try:
|
||||||
namespace, repository = parse_namespace_repository(unquoted)
|
token_data = model.load_token_data(token_vals['signature'])
|
||||||
logger.debug('Validing signature: %s' % token_vals['signature'])
|
|
||||||
validated = model.verify_token(token_vals['signature'], namespace,
|
|
||||||
repository)
|
|
||||||
|
|
||||||
if validated:
|
except model.InvalidTokenException:
|
||||||
session['repository'] = repository
|
logger.warning('Token could not be validated: %s' %
|
||||||
session['namespace'] = namespace
|
token_vals['signature'])
|
||||||
|
abort(401)
|
||||||
|
|
||||||
logger.debug('Successfully validated token: %s' % validated.code)
|
session['repository'] = token_data.repository.name
|
||||||
ctx = _request_ctx_stack.top
|
session['namespace'] = token_data.repository.namespace
|
||||||
ctx.validated_token = validated
|
|
||||||
|
|
||||||
identity_changed.send(app, identity=Identity(validated.code, 'token'))
|
logger.debug('Successfully validated token: %s' % token_data.code)
|
||||||
|
ctx = _request_ctx_stack.top
|
||||||
|
ctx.validated_token = token_data
|
||||||
|
|
||||||
return
|
identity_changed.send(app, identity=Identity(token_data.code, 'token'))
|
||||||
|
|
||||||
# WE weren't able to authenticate the token
|
|
||||||
logger.debug('Token present but could not be validated.')
|
|
||||||
abort(401)
|
|
||||||
|
|
||||||
|
|
||||||
def process_auth(f):
|
def process_auth(f):
|
||||||
|
|
|
@ -80,19 +80,14 @@ def on_identity_loaded(sender, identity):
|
||||||
identity_changed.send(app, identity=switch_to_deferred)
|
identity_changed.send(app, identity=switch_to_deferred)
|
||||||
|
|
||||||
elif identity.auth_type == 'token':
|
elif identity.auth_type == 'token':
|
||||||
logger.debug('Computing permissions for token: %s' % identity.id)
|
logger.debug('Loading permissions for token: %s' % identity.id)
|
||||||
|
token_data = model.load_token_data(identity.id)
|
||||||
|
|
||||||
token = model.get_token(identity.id)
|
repo_grant = _RepositoryNeed(token_data.repository.namespace,
|
||||||
|
token_data.repository.name,
|
||||||
if token.user:
|
token_data.role.name)
|
||||||
query = model.get_user_repo_permissions(token.user, token.repository)
|
logger.debug('Delegate token added permission: {0}'.format(repo_grant))
|
||||||
for permission in query:
|
identity.provides.add(repo_grant)
|
||||||
t_grant = _RepositoryNeed(token.repository.namespace,
|
|
||||||
token.repository.name, permission.role.name)
|
|
||||||
logger.debug('Token added permission: {0}'.format(t_grant))
|
|
||||||
identity.provides.add(t_grant)
|
|
||||||
else:
|
|
||||||
logger.debug('Token was anonymous.')
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.error('Unknown identity auth type: %s' % identity.auth_type)
|
logger.error('Unknown identity auth type: %s' % identity.auth_type)
|
||||||
|
|
|
@ -99,10 +99,13 @@ def random_string_generator(length=16):
|
||||||
|
|
||||||
|
|
||||||
class AccessToken(BaseModel):
|
class AccessToken(BaseModel):
|
||||||
code = CharField(default=random_string_generator(), unique=True, index=True)
|
friendly_name = CharField(null=True)
|
||||||
user = ForeignKeyField(User, null=True)
|
code = CharField(default=random_string_generator(length=64), unique=True,
|
||||||
|
index=True)
|
||||||
repository = ForeignKeyField(Repository)
|
repository = ForeignKeyField(Repository)
|
||||||
created = DateTimeField(default=datetime.now)
|
created = DateTimeField(default=datetime.now)
|
||||||
|
role = ForeignKeyField(Role)
|
||||||
|
temporary = BooleanField(default=True)
|
||||||
|
|
||||||
|
|
||||||
class EmailConfirmation(BaseModel):
|
class EmailConfirmation(BaseModel):
|
||||||
|
|
|
@ -26,6 +26,10 @@ class InvalidPasswordException(DataModelException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTokenException(DataModelException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def create_user(username, password, email):
|
def create_user(username, password, email):
|
||||||
if not validate_email(email):
|
if not validate_email(email):
|
||||||
raise InvalidEmailAddressException('Invalid email address: %s' % email)
|
raise InvalidEmailAddressException('Invalid email address: %s' % email)
|
||||||
|
@ -159,25 +163,6 @@ def verify_user(username, password):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(user, repository):
|
|
||||||
new_token = AccessToken.create(user=user, repository=repository)
|
|
||||||
return new_token
|
|
||||||
|
|
||||||
|
|
||||||
def verify_token(code, namespace_name, repository_name):
|
|
||||||
joined = AccessToken.select(AccessToken, Repository).join(Repository)
|
|
||||||
tokens = list(joined.where(AccessToken.code == code,
|
|
||||||
Repository.namespace == namespace_name,
|
|
||||||
Repository.name == repository_name))
|
|
||||||
if tokens:
|
|
||||||
return tokens[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_token(code):
|
|
||||||
return AccessToken.get(AccessToken.code == code)
|
|
||||||
|
|
||||||
|
|
||||||
def get_visible_repositories(username=None, include_public=True, limit=None,
|
def get_visible_repositories(username=None, include_public=True, limit=None,
|
||||||
sort=False):
|
sort=False):
|
||||||
if not username and not include_public:
|
if not username and not include_public:
|
||||||
|
@ -485,3 +470,69 @@ def get_private_repo_count(username):
|
||||||
joined = Repository.select().join(Visibility)
|
joined = Repository.select().join(Visibility)
|
||||||
return joined.where(Repository.namespace == username,
|
return joined.where(Repository.namespace == username,
|
||||||
Visibility.name == 'private').count()
|
Visibility.name == 'private').count()
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(repository, role):
|
||||||
|
role = Role.get(Role.name == role)
|
||||||
|
new_token = AccessToken.create(repository=repository, temporary=True,
|
||||||
|
role=role)
|
||||||
|
return new_token
|
||||||
|
|
||||||
|
|
||||||
|
def create_delegate_token(namespace_name, repository_name, friendly_name):
|
||||||
|
read_only = Role.get(name='read')
|
||||||
|
repo = Repository.get(Repository.name == repository_name,
|
||||||
|
Repository.namespace == namespace_name)
|
||||||
|
new_token = AccessToken.create(repository=repo, role=read_only,
|
||||||
|
friendly_name=friendly_name, temporary=False)
|
||||||
|
return new_token
|
||||||
|
|
||||||
|
|
||||||
|
def get_repository_delegate_tokens(namespace_name, repository_name):
|
||||||
|
selected = AccessToken.select(AccessToken, Role)
|
||||||
|
with_repo = selected.join(Repository)
|
||||||
|
with_role = with_repo.switch(AccessToken).join(Role)
|
||||||
|
return with_role.where(Repository.name == repository_name,
|
||||||
|
Repository.namespace == namespace_name,
|
||||||
|
AccessToken.temporary == False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_repo_delegate_token(namespace_name, repository_name, code):
|
||||||
|
repo_query = get_repository_delegate_tokens(namespace_name, repository_name)
|
||||||
|
found = list(repo_query.where(AccessToken.code == code))
|
||||||
|
|
||||||
|
if found:
|
||||||
|
return found[0]
|
||||||
|
else:
|
||||||
|
raise InvalidTokenException('Unable to find token with code: %s' % code)
|
||||||
|
|
||||||
|
|
||||||
|
def set_repo_delegate_token_role(namespace_name, repository_name, code, role):
|
||||||
|
token = get_repo_delegate_token(namespace_name, repository_name, code)
|
||||||
|
|
||||||
|
if role != 'read' and role != 'write':
|
||||||
|
raise DataModelException('Invalid role for delegate token: %s' % role)
|
||||||
|
|
||||||
|
new_role = Role.get(Role.name == role)
|
||||||
|
token.role = new_role
|
||||||
|
token.save()
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def delete_delegate_token(namespace_name, repository_name, code):
|
||||||
|
token = get_repo_delegate_token(namespace_name, repository_name, code)
|
||||||
|
token.delete_instance()
|
||||||
|
|
||||||
|
|
||||||
|
def load_token_data(code):
|
||||||
|
""" Load the permissions for any token by code. """
|
||||||
|
selected = AccessToken.select(AccessToken, Repository, Role)
|
||||||
|
with_role = selected.join(Role)
|
||||||
|
with_repo = with_role.switch(AccessToken).join(Repository)
|
||||||
|
fetched = list(with_repo.where(AccessToken.code == code))
|
||||||
|
|
||||||
|
if fetched:
|
||||||
|
return fetched[0]
|
||||||
|
else:
|
||||||
|
raise InvalidTokenException('Invalid delegate token code: %s' % code)
|
||||||
|
|
|
@ -456,6 +456,92 @@ def delete_permissions(namespace, repository, username):
|
||||||
abort(403) # Permission denied
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
def token_view(token_obj):
|
||||||
|
return {
|
||||||
|
'friendlyName': token_obj.friendly_name,
|
||||||
|
'code': token_obj.code,
|
||||||
|
'role': token_obj.role.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/repository/<path: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
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/repository/<path:repository>/tokens/<code>', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
@parse_repository_name
|
||||||
|
def get_tokens(namespace, repository, code):
|
||||||
|
permission = AdministerRepositoryPermission(namespace, repository)
|
||||||
|
if permission.can():
|
||||||
|
perm = model.get_repo_delegate_token(namespace, repository, code)
|
||||||
|
return jsonify(token_view(perm))
|
||||||
|
|
||||||
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/repository/<path: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'])
|
||||||
|
|
||||||
|
resp = jsonify(token_view(token))
|
||||||
|
resp.status_code = 201
|
||||||
|
return resp
|
||||||
|
|
||||||
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/repository/<path:repository>/tokens/<code>', 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'])
|
||||||
|
|
||||||
|
resp = jsonify(token_view(token))
|
||||||
|
return resp
|
||||||
|
|
||||||
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/repository/<path:repository>/tokens/<code>',
|
||||||
|
methods=['DELETE'])
|
||||||
|
@api_login_required
|
||||||
|
@parse_repository_name
|
||||||
|
def delete_token(namespace, repository, code):
|
||||||
|
permission = AdministerRepositoryPermission(namespace, repository)
|
||||||
|
if permission.can():
|
||||||
|
model.delete_delegate_token(namespace, repository, code)
|
||||||
|
return make_response('Deleted', 204)
|
||||||
|
|
||||||
|
abort(403) # Permission denied
|
||||||
|
|
||||||
|
|
||||||
def subscription_view(stripe_subscription, used_repos):
|
def subscription_view(stripe_subscription, used_repos):
|
||||||
return {
|
return {
|
||||||
'currentPeriodStart': stripe_subscription.current_period_start,
|
'currentPeriodStart': stripe_subscription.current_period_start,
|
||||||
|
|
|
@ -19,25 +19,26 @@ from auth.permissions import (ModifyRepositoryPermission,
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def generate_headers(f):
|
def generate_headers(role='read'):
|
||||||
@wraps(f)
|
def decorator_method(f):
|
||||||
def wrapper(namespace, repository, *args, **kwargs):
|
@wraps(f)
|
||||||
response = f(namespace, repository, *args, **kwargs)
|
def wrapper(namespace, repository, *args, **kwargs):
|
||||||
|
response = f(namespace, repository, *args, **kwargs)
|
||||||
|
|
||||||
response.headers['X-Docker-Endpoints'] = app.config['REGISTRY_SERVER']
|
response.headers['X-Docker-Endpoints'] = app.config['REGISTRY_SERVER']
|
||||||
|
|
||||||
has_token_request = request.headers.get('X-Docker-Token', '')
|
has_token_request = request.headers.get('X-Docker-Token', '')
|
||||||
|
|
||||||
if has_token_request:
|
if has_token_request:
|
||||||
repo = model.get_repository(namespace, repository)
|
repo = model.get_repository(namespace, repository)
|
||||||
token = model.create_access_token(get_authenticated_user(), repo)
|
token = model.create_access_token(repo, role)
|
||||||
token_str = 'signature=%s,repository="%s/%s"' % (token.code, namespace,
|
token_str = 'signature=%s' % token.code
|
||||||
repository)
|
response.headers['WWW-Authenticate'] = token_str
|
||||||
response.headers['WWW-Authenticate'] = token_str
|
response.headers['X-Docker-Token'] = token_str
|
||||||
response.headers['X-Docker-Token'] = token_str
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
return wrapper
|
return wrapper
|
||||||
|
return decorator_method
|
||||||
|
|
||||||
|
|
||||||
@app.route('/v1/users', methods=['POST'])
|
@app.route('/v1/users', methods=['POST'])
|
||||||
|
@ -47,6 +48,13 @@ def create_user():
|
||||||
username = user_data['username']
|
username = user_data['username']
|
||||||
password = user_data['password']
|
password = user_data['password']
|
||||||
|
|
||||||
|
if username == '$token':
|
||||||
|
try:
|
||||||
|
token = model.load_token_data(password)
|
||||||
|
return make_response('Verified', 201)
|
||||||
|
except model.InvalidTokenException:
|
||||||
|
abort(401)
|
||||||
|
|
||||||
existing_user = model.get_user(username)
|
existing_user = model.get_user(username)
|
||||||
if existing_user:
|
if existing_user:
|
||||||
verified = model.verify_user(username, password)
|
verified = model.verify_user(username, password)
|
||||||
|
@ -100,13 +108,17 @@ def update_user(username):
|
||||||
@app.route('/v1/repositories/<path:repository>', methods=['PUT'])
|
@app.route('/v1/repositories/<path:repository>', methods=['PUT'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
@generate_headers
|
@generate_headers(role='write')
|
||||||
def create_repository(namespace, repository):
|
def create_repository(namespace, repository):
|
||||||
image_descriptions = json.loads(request.data)
|
image_descriptions = json.loads(request.data)
|
||||||
|
|
||||||
repo = model.get_repository(namespace, repository)
|
repo = model.get_repository(namespace, repository)
|
||||||
|
|
||||||
if repo:
|
if not repo and get_authenticated_user() is None:
|
||||||
|
logger.debug('Attempt to create new repository with token auth.')
|
||||||
|
abort(400)
|
||||||
|
|
||||||
|
elif repo:
|
||||||
permission = ModifyRepositoryPermission(namespace, repository)
|
permission = ModifyRepositoryPermission(namespace, repository)
|
||||||
if not permission.can():
|
if not permission.can():
|
||||||
abort(403)
|
abort(403)
|
||||||
|
@ -135,7 +147,10 @@ def create_repository(namespace, repository):
|
||||||
|
|
||||||
response = make_response('Created', 201)
|
response = make_response('Created', 201)
|
||||||
|
|
||||||
mixpanel.track(get_authenticated_user().username, 'push_repo')
|
if get_authenticated_user():
|
||||||
|
mixpanel.track(get_authenticated_user().username, 'push_repo')
|
||||||
|
else:
|
||||||
|
mixpanel.track(get_validated_token().code, 'push_repo')
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -143,7 +158,7 @@ def create_repository(namespace, repository):
|
||||||
@app.route('/v1/repositories/<path:repository>/images', methods=['PUT'])
|
@app.route('/v1/repositories/<path:repository>/images', methods=['PUT'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
@generate_headers
|
@generate_headers(role='write')
|
||||||
def update_images(namespace, repository):
|
def update_images(namespace, repository):
|
||||||
permission = ModifyRepositoryPermission(namespace, repository)
|
permission = ModifyRepositoryPermission(namespace, repository)
|
||||||
|
|
||||||
|
@ -164,7 +179,7 @@ def update_images(namespace, repository):
|
||||||
@app.route('/v1/repositories/<path:repository>/images', methods=['GET'])
|
@app.route('/v1/repositories/<path:repository>/images', methods=['GET'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
@generate_headers
|
@generate_headers(role='read')
|
||||||
def get_repository_images(namespace, repository):
|
def get_repository_images(namespace, repository):
|
||||||
permission = ReadRepositoryPermission(namespace, repository)
|
permission = ReadRepositoryPermission(namespace, repository)
|
||||||
|
|
||||||
|
@ -196,7 +211,7 @@ def get_repository_images(namespace, repository):
|
||||||
@app.route('/v1/repositories/<path:repository>/images', methods=['DELETE'])
|
@app.route('/v1/repositories/<path:repository>/images', methods=['DELETE'])
|
||||||
@process_auth
|
@process_auth
|
||||||
@parse_repository_name
|
@parse_repository_name
|
||||||
@generate_headers
|
@generate_headers(role='write')
|
||||||
def delete_repository_images(namespace, repository):
|
def delete_repository_images(namespace, repository):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,48 @@
|
||||||
font-family: 'Droid Sans', sans-serif;
|
font-family: 'Droid Sans', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.description-overview {
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-list {
|
||||||
|
margin: 10px;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-list li:before {
|
||||||
|
content: "\00BB";
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-list li {
|
||||||
|
list-style-type: none;
|
||||||
|
margin: 0px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
display: inline-block;
|
||||||
|
float: right;
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.accordion-toggle {
|
.accordion-toggle {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none !important;
|
text-decoration: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-guide h3 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-guide h3 .label {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
.plans .callout {
|
.plans .callout {
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
@ -441,7 +478,8 @@ p.editable:hover i {
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo dl.dl-horizontal dt {
|
.repo dl.dl-horizontal dt {
|
||||||
width: 60px;
|
width: 80px;
|
||||||
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo dl.dl-horizontal dd {
|
.repo dl.dl-horizontal dd {
|
||||||
|
@ -485,19 +523,22 @@ p.editable:hover i {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo #clipboardCopied {
|
.repo #clipboardCopied.hovering {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0px;
|
right: 0px;
|
||||||
top: 40px;
|
top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo #clipboardCopied {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
background: black;
|
background: black;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
|
|
||||||
-webkit-animation: fadeOut 4s ease-in-out 0s 1 forwards;
|
-webkit-animation: fadeOut 4s ease-in-out 0s 1 forwards;
|
||||||
-moz-animation: fadeOut 4s ease-in-out 0s 1 forwards;
|
-moz-animation: fadeOut 4s ease-in-out 0s 1 forwards;
|
||||||
-ms-animation: fadeOut 4s ease-in-out 0s 1 forwards;
|
-ms-animation: fadeOut 4s ease-in-out 0s 1 forwards;
|
||||||
|
@ -557,6 +598,18 @@ p.editable:hover i {
|
||||||
padding-left: 36px;
|
padding-left: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.repo-admin .token-dialog-body .well {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-admin .token-view {
|
||||||
|
background: transparent;
|
||||||
|
display: block;
|
||||||
|
border: 0px transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.repo-admin .panel {
|
.repo-admin .panel {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 620px;
|
width: 620px;
|
||||||
|
@ -571,6 +624,10 @@ p.editable:hover i {
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.repo-admin .token a {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.repo .description p {
|
.repo .description p {
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,17 @@
|
||||||
|
$.fn.clipboardCopy = function() {
|
||||||
|
var clip = new ZeroClipboard($(this), { 'moviePath': 'static/lib/ZeroClipboard.swf' });
|
||||||
|
clip.on('complete', function() {
|
||||||
|
// Resets the animation.
|
||||||
|
var elem = $('#clipboardCopied')[0];
|
||||||
|
elem.style.display = 'none';
|
||||||
|
|
||||||
|
// Show the notification.
|
||||||
|
setTimeout(function() {
|
||||||
|
elem.style.display = 'inline-block';
|
||||||
|
}, 1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function getFirstTextLine(commentString) {
|
function getFirstTextLine(commentString) {
|
||||||
if (!commentString) { return; }
|
if (!commentString) { return; }
|
||||||
|
|
||||||
|
@ -398,18 +412,7 @@ function RepoCtrl($scope, Restangular, $routeParams, $rootScope, $location) {
|
||||||
|
|
||||||
$scope.setTag($routeParams.tag);
|
$scope.setTag($routeParams.tag);
|
||||||
|
|
||||||
var clip = new ZeroClipboard($('#copyClipboard'), { 'moviePath': 'static/lib/ZeroClipboard.swf' });
|
$('#copyClipboard').clipboardCopy();
|
||||||
clip.on('complete', function() {
|
|
||||||
// Resets the animation.
|
|
||||||
var elem = $('#clipboardCopied')[0];
|
|
||||||
elem.style.display = 'none';
|
|
||||||
|
|
||||||
// Show the notification.
|
|
||||||
setTimeout(function() {
|
|
||||||
elem.style.display = 'block';
|
|
||||||
}, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
$scope.loading = false;
|
$scope.loading = false;
|
||||||
}, function() {
|
}, function() {
|
||||||
$scope.repo = null;
|
$scope.repo = null;
|
||||||
|
@ -422,6 +425,13 @@ function RepoCtrl($scope, Restangular, $routeParams, $rootScope, $location) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
|
function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
|
||||||
|
$('.info-icon').popover({
|
||||||
|
'trigger': 'hover',
|
||||||
|
'html': true
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#copyClipboard').clipboardCopy();
|
||||||
|
|
||||||
var namespace = $routeParams.namespace;
|
var namespace = $routeParams.namespace;
|
||||||
var name = $routeParams.name;
|
var name = $routeParams.name;
|
||||||
|
|
||||||
|
@ -513,6 +523,42 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$scope.createToken = function() {
|
||||||
|
var friendlyName = {
|
||||||
|
'friendlyName': $scope.newToken.friendlyName
|
||||||
|
};
|
||||||
|
|
||||||
|
var permissionPost = Restangular.one('repository/' + namespace + '/' + name + '/tokens/');
|
||||||
|
permissionPost.customPOST(friendlyName).then(function(newToken) {
|
||||||
|
$scope.newToken.friendlyName = '';
|
||||||
|
$scope.createTokenForm.$setPristine();
|
||||||
|
$scope.tokens[newToken.code] = newToken;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.deleteToken = function(tokenCode) {
|
||||||
|
var deleteAction = Restangular.one('repository/' + namespace + '/' + name + '/tokens/' + tokenCode);
|
||||||
|
deleteAction.customDELETE().then(function() {
|
||||||
|
delete $scope.tokens[tokenCode];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.changeTokenAccess = function(tokenCode, newAccess) {
|
||||||
|
var role = {
|
||||||
|
'role': newAccess
|
||||||
|
};
|
||||||
|
|
||||||
|
var deleteAction = Restangular.one('repository/' + namespace + '/' + name + '/tokens/' + tokenCode);
|
||||||
|
deleteAction.customPUT(role).then(function(updated) {
|
||||||
|
$scope.tokens[updated.code] = updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.showToken = function(tokenCode) {
|
||||||
|
$scope.shownToken = $scope.tokens[tokenCode];
|
||||||
|
$('#tokenmodal').modal({});
|
||||||
|
};
|
||||||
|
|
||||||
$scope.askChangeAccess = function(newAccess) {
|
$scope.askChangeAccess = function(newAccess) {
|
||||||
$('#make' + newAccess + 'Modal').modal({});
|
$('#make' + newAccess + 'Modal').modal({});
|
||||||
};
|
};
|
||||||
|
@ -556,7 +602,7 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
|
||||||
var repositoryFetch = Restangular.one('repository/' + namespace + '/' + name);
|
var repositoryFetch = Restangular.one('repository/' + namespace + '/' + name);
|
||||||
repositoryFetch.get().then(function(repo) {
|
repositoryFetch.get().then(function(repo) {
|
||||||
$scope.repo = repo;
|
$scope.repo = repo;
|
||||||
$scope.loading = !($scope.permissions && $scope.repo);
|
$scope.loading = !($scope.permissions && $scope.repo && $scope.tokens);
|
||||||
}, function() {
|
}, function() {
|
||||||
$scope.permissions = null;
|
$scope.permissions = null;
|
||||||
$rootScope.title = 'Unknown Repository';
|
$rootScope.title = 'Unknown Repository';
|
||||||
|
@ -568,12 +614,23 @@ function RepoAdminCtrl($scope, Restangular, $routeParams, $rootScope) {
|
||||||
permissionsFetch.get().then(function(resp) {
|
permissionsFetch.get().then(function(resp) {
|
||||||
$rootScope.title = 'Settings - ' + namespace + '/' + name;
|
$rootScope.title = 'Settings - ' + namespace + '/' + name;
|
||||||
$scope.permissions = resp.permissions;
|
$scope.permissions = resp.permissions;
|
||||||
$scope.loading = !($scope.permissions && $scope.repo);
|
$scope.loading = !($scope.permissions && $scope.repo && $scope.tokens);
|
||||||
}, function() {
|
}, function() {
|
||||||
$scope.permissions = null;
|
$scope.permissions = null;
|
||||||
$rootScope.title = 'Unknown Repository';
|
$rootScope.title = 'Unknown Repository';
|
||||||
$scope.loading = false;
|
$scope.loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch the tokens.
|
||||||
|
var tokensFetch = Restangular.one('repository/' + namespace + '/' + name + '/tokens/');
|
||||||
|
tokensFetch.get().then(function(resp) {
|
||||||
|
$scope.tokens = resp.tokens;
|
||||||
|
$scope.loading = !($scope.permissions && $scope.repo && $scope.tokens);
|
||||||
|
}, function() {
|
||||||
|
$scope.tokens = null;
|
||||||
|
$scope.loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, KeyService, $routeParams) {
|
function UserAdminCtrl($scope, $timeout, Restangular, PlanService, UserService, KeyService, $routeParams) {
|
||||||
|
|
|
@ -1,10 +1,20 @@
|
||||||
<div class="container ready-indicator" data-status="{{ status }}">
|
<div class="container ready-indicator" data-status="{{ status }}">
|
||||||
<div class="alert alert-warning">Warning: Quay requires docker version 0.6.2 or higher to work</div>
|
<div class="alert alert-warning">Warning: Quay requires docker version 0.6.2 or higher to work</div>
|
||||||
|
|
||||||
<h2>Getting started guide</h2>
|
<h2>User guide</h2>
|
||||||
<div class="container">
|
<div class="user-guide container">
|
||||||
|
|
||||||
<h3>Pushing a repository to Quay</h3>
|
<h3>Pulling a repository from Quay</h3>
|
||||||
|
<div class="container">
|
||||||
|
<div class="alert alert-info">Note: <b>Private</b> repositories require you to be <b>logged in</b> or the pull will fail. See below for how to sign into Quay if you have never done so before. </div>
|
||||||
|
To pull a repository from Quay, run the following command:
|
||||||
|
<br><br>
|
||||||
|
<pre>docker pull quay.io/<i>username/repo_name</i></pre>
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
|
||||||
|
<h3>Pushing a repository to Quay <span class="label label-success">Requires Write Access</span></h3>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
First, tag the image with your repository name:<br><br>
|
First, tag the image with your repository name:<br><br>
|
||||||
<pre>docker tag <i>0u123imageid</i> quay.io/<i>username/repo_name</i></pre>
|
<pre>docker tag <i>0u123imageid</i> quay.io/<i>username/repo_name</i></pre>
|
||||||
|
@ -14,12 +24,41 @@
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
<h3>Pulling a repository from Quay</h3>
|
<h3>Granting and managing permissions to users <span class="label label-info">Requires Admin Access</span></h3>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="alert alert-info">Note: <b>Private</b> repositories require you to be <b>logged in</b> or the pull will fail. See below for how to sign into Quay if you have never done so before. </div>
|
<div class="description-overview">Quay allows a repository to be shared any number of users and to grant those users any level of permissions for a repository</div>
|
||||||
To pull a repository from Quay, run the following command:
|
|
||||||
<br><br>
|
<ul class="description-list">
|
||||||
<pre>docker pull quay.io/<i>username/repo_name</i></pre>
|
<li>Permissions for a repository can be granted and managed in the repository's admin interface
|
||||||
|
<li><b>Adding a user:</b> Type that user's username in the "Add New User..." field, and select the user
|
||||||
|
<li><b>Changing permissions:</b> A user's permissions (read, read/write or admin) can be changed by clicking the field to the right of the user
|
||||||
|
<li><b>Removing a user:</b> A user can be removed from the list by clicking the <b>X</b> and then clicking <b>Delete</b>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<h3>Using access tokens in place of users <span class="label label-info">Requires Admin Access</span></h3>
|
||||||
|
<div class="container">
|
||||||
|
<div class="description-overview">
|
||||||
|
There are many circumstances where it makes sense to <b>not</b> use a user's username and password (deployment scripts, etc).
|
||||||
|
To support this case, Quay allows the use of <b>access tokens</b> which can be created on a repository and have read and/or write
|
||||||
|
permissions, without any passwords.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="description-list">
|
||||||
|
<li>Tokens can be managed in the repository's admin interface
|
||||||
|
<li><b>Adding a token:</b> Enter a user-readable description in the "New token description" field
|
||||||
|
<li><b>Changing permissions:</b> A token's permissions (read or read/write) can be changed by clicking the field to the right of the token
|
||||||
|
<li><b>Deleting a token:</b> A token can be deleted by clicking the <b>X</b> and then clicking <b>Delete</b>
|
||||||
|
<li><b>Using a token:</b> To use the token, the following credentials can be used:
|
||||||
|
<dl class="dl-horizontal">
|
||||||
|
<dt>Username</dt><dd>$token</dd>
|
||||||
|
<dt>Password</dt><dd>(token value can be found by clicking on the token)</dd>
|
||||||
|
<dt>Email</dt><dd>This value is ignored, any value may be used.</dd>
|
||||||
|
</dl>
|
||||||
|
</ul>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
<div class="collapse navbar-collapse navbar-ex1-collapse">
|
<div class="collapse navbar-collapse navbar-ex1-collapse">
|
||||||
<ul class="nav navbar-nav">
|
<ul class="nav navbar-nav">
|
||||||
<li><a ng-href="/repository/">Repositories</a></li>
|
<li><a ng-href="/repository/">Repositories</a></li>
|
||||||
<li><a ng-href="/guide/">Getting Started</a></li>
|
<li><a ng-href="/guide/">User Guide</a></li>
|
||||||
<li><a ng-href="/plans/">Plans & Pricing</a></li>
|
<li><a ng-href="/plans/">Plans & Pricing</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,10 @@
|
||||||
|
|
||||||
<!-- User Access Permissions -->
|
<!-- User Access Permissions -->
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading">User Access Permissions</div>
|
<div class="panel-heading">User Access Permissions
|
||||||
|
|
||||||
|
<i class="info-icon icon-info-sign" data-placement="left" data-content="Allow any number of users to read, write or administer this repository"></i>
|
||||||
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
|
|
||||||
<table class="permissions">
|
<table class="permissions">
|
||||||
|
@ -56,7 +59,55 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<br>
|
|
||||||
|
<!-- Token Permissions -->
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">Access Token Permissions
|
||||||
|
|
||||||
|
<i class="info-icon icon-info-sign" data-placement="left" data-content="Grant permissions to this repository by creating unique tokens that can be used without entering account passwords<br><br>To use in docker:<br><dl class='dl-horizontal'><dt>Username</dt><dd>$token</dd><dt>Password</dt><dd>(token value)</dd><dt>Email</dt><dd>(any value)</dd></dl>"></i>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form name="createTokenForm" ng-submit="createToken()">
|
||||||
|
<table class="permissions">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td>Token Description</td>
|
||||||
|
<td>Permissions</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tr ng-repeat="(code, token) in tokens">
|
||||||
|
<td class="user token">
|
||||||
|
<i class="icon-key"></i>
|
||||||
|
<a ng-click="showToken(token.code)">{{ token.friendlyName }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="user-permissions">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button type="button" class="btn btn-default" ng-click="changeTokenAccess(token.code, 'read')" ng-class="{read: 'active', write: ''}[token.role]">Read only</button>
|
||||||
|
<button type="button" class="btn btn-default" ng-click="changeTokenAccess(token.code, 'write')" ng-class="{read: '', write: 'active'}[token.role]">Write</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="delete-ui" tabindex="0" title="Delete Token">
|
||||||
|
<span class="delete-ui-button" ng-click="deleteToken(token.code)"><button class="btn btn-danger" type="button">Delete</button></span>
|
||||||
|
<i class="icon-remove"></i>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control" placeholder="New token description" ng-model="newToken.friendlyName"required>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="submit" ng-disabled="createTokenForm.$invalid" class="btn btn-sm btn-default">Create</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Public/Private -->
|
<!-- Public/Private -->
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
|
@ -114,6 +165,31 @@
|
||||||
</div><!-- /.modal-dialog -->
|
</div><!-- /.modal-dialog -->
|
||||||
</div><!-- /.modal -->
|
</div><!-- /.modal -->
|
||||||
|
|
||||||
|
<!-- Modal message dialog -->
|
||||||
|
<div class="modal fade" id="tokenmodal">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||||
|
<h4 class="modal-title"><i class="icon-key"></i> {{ shownToken.friendlyName }}</h4>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body token-dialog-body">
|
||||||
|
<div class="alert alert-info">The docker <u>username</u> is <b>$token</b> and the <u>password</u> is the token. You may use any value for email.</div>
|
||||||
|
<div class="well well-sm">
|
||||||
|
<input id="token-view" class="token-view" type="text" value="{{ shownToken.code }}" onClick="this.select();" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div id="clipboardCopied" style="display: none">
|
||||||
|
Copied to clipboard
|
||||||
|
</div>
|
||||||
|
<button id="copyClipboard" type="button" class="btn btn-primary" data-clipboard-target="token-view">Copy to clipboard</button>
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div><!-- /.modal-content -->
|
||||||
|
</div><!-- /.modal-dialog -->
|
||||||
|
</div><!-- /.modal -->
|
||||||
|
|
||||||
|
|
||||||
<!-- Modal message dialog -->
|
<!-- Modal message dialog -->
|
||||||
<div class="modal fade" id="makepublicModal">
|
<div class="modal fade" id="makepublicModal">
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="clipboardCopied" style="display: none">
|
<div id="clipboardCopied" class="hovering" style="display: none">
|
||||||
Copied to clipboard
|
Copied to clipboard
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Reference in a new issue