keys ui WIP

This commit is contained in:
Joseph Schorr 2016-04-01 13:55:29 -04:00 committed by Jimmy Zelinskie
parent dc593c0197
commit 11ff3e9b59
25 changed files with 1154 additions and 74 deletions

View file

@ -694,9 +694,11 @@ class LogEntryKind(BaseModel):
name = CharField(index=True, unique=True)
_LogEntryAccountProxy = Proxy()
class LogEntry(BaseModel):
kind = ForeignKeyField(LogEntryKind, index=True)
account = QuayUserField(index=True, related_name='account')
account = ForeignKeyField(_LogEntryAccountProxy, index=True, null=True, related_name='account')
performer = QuayUserField(allows_robots=True, index=True, null=True,
related_name='performer', robot_null_delete=True)
repository = ForeignKeyField(Repository, index=True, null=True)
@ -715,6 +717,8 @@ class LogEntry(BaseModel):
(('repository', 'datetime', 'kind'), False),
)
_LogEntryAccountProxy.initialize(User)
class RepositoryActionCount(BaseModel):
repository = ForeignKeyField(Repository, index=True)
@ -875,12 +879,14 @@ class ServiceKeyApprovalType(Enum):
SUPERUSER = 'Super User API'
KEY_ROTATION = 'Key Rotation'
_ServiceKeyApproverProxy = Proxy()
class ServiceKeyApproval(BaseModel):
approver = ForeignKeyField(_ServiceKeyApproverProxy, null=True)
approval_type = CharField(index=True)
approved_date = DateTimeField(default=datetime.utcnow)
notes = TextField()
notes = TextField(default='')
_ServiceKeyApproverProxy.initialize(User)

View file

@ -103,4 +103,5 @@ config = Config()
# moving the minimal number of things to _basequery
# TODO document the methods and modules for each one of the submodules below.
from data.model import (blob, build, image, log, notification, oauth, organization, permission,
repository, storage, tag, team, token, user, release, modelutil)
repository, service_keys, storage, tag, team, token, user, release,
modelutil)

View file

@ -1,5 +1,6 @@
import json
from calendar import timegm
from peewee import JOIN_LEFT_OUTER, SQL, fn
from datetime import datetime, timedelta, date
from cachetools import lru_cache
@ -53,15 +54,33 @@ def get_logs_query(start_time, end_time, performer=None, repository=None, namesp
return query
def get_log_action_date(dtdata):
if dtdata is None:
return None
return
def _json_serialize(obj):
if isinstance(obj, datetime):
return timegm(obj.utctimetuple())
return obj
def log_action(kind_name, user_or_organization_name, performer=None, repository=None,
ip=None, metadata={}, timestamp=None):
if not timestamp:
timestamp = datetime.today()
account = None
if user_or_organization_name is not None:
account = User.get(User.username == user_or_organization_name).id
kind = LogEntryKind.get(LogEntryKind.name == kind_name)
account = User.get(User.username == user_or_organization_name)
metadata_json = json.dumps(metadata, default=_json_serialize)
LogEntry.create(kind=kind, account=account, performer=performer,
repository=repository, ip=ip, metadata_json=json.dumps(metadata),
repository=repository, ip=ip, metadata_json=metadata_json,
datetime=timestamp)

View file

@ -1,26 +1,21 @@
from calendar import timegm
from datetime import datetime, timedelta
from peewee import JOIN_LEFT_OUTER
from app import app
from data.database import db_for_update, User, ServiceKey, ServiceKeyApproval
from data.model import ServiceKeyDoesNotExist, ServiceKeyAlreadyApproved, db_transaction
from data.model import ServiceKeyDoesNotExist, ServiceKeyAlreadyApproved, db_transaction, config
from data.model.notification import create_notification, delete_all_notifications_by_path_prefix
# TODO ACTION_LOGS for keys
UNAPPROVED_TTL = timedelta(seconds=app.config['UNAPPROVED_SERVICE_KEY_TTL_SEC'])
def _expired_keys_clause(service):
return ((ServiceKey.service == service) &
(ServiceKey.expiration_date <= datetime.utcnow()))
def _stale_unapproved_keys_clause(service):
unapproved_ttl = timedelta(seconds=config.app_config['UNAPPROVED_SERVICE_KEY_TTL_SEC'])
return ((ServiceKey.service == service) &
(ServiceKey.approval >> None) &
(ServiceKey.created_date <= (datetime.utcnow() - UNAPPROVED_TTL)))
(ServiceKey.created_date <= (datetime.utcnow() - unapproved_ttl)))
def _gc_expired(service):
@ -36,10 +31,12 @@ def _notify_superusers(key):
'jwk': key.jwk,
'metadata': key.metadata,
'created_date': timegm(key.created_date.utctimetuple()),
'expiration_date': timegm(key.created_date.utctimetuple()),
}
superusers = User.select().where(User.username << app.config['SUPER_USERS'])
if key.expiration_date is not None:
notification_metadata['expiration_date'] = timegm(key.expiration_date.utctimetuple())
superusers = User.select().where(User.username << config.app_config['SUPER_USERS'])
for superuser in superusers:
create_notification('service_key_submitted', superuser, metadata=notification_metadata,
lookup_path='/service_key_approval/{0}'.format(key.kid))
@ -57,9 +54,11 @@ def replace_service_key(old_kid, kid, jwk, metadata, expiration_date):
try:
with db_transaction():
key = db_for_update(ServiceKey.select().where(ServiceKey.kid == old_kid)).get()
metadata = key.metadata.update(metadata)
key.metadata.update(metadata)
ServiceKey.create(name=key.name, kid=kid, service=key.service, jwk=jwk,
metadata=metadata, expiration_date=expiration_date, approval=key.approval)
metadata=key.metadata, expiration_date=expiration_date,
approval=key.approval)
key.delete_instance()
except ServiceKey.DoesNotExist:
raise ServiceKeyDoesNotExist
@ -69,13 +68,16 @@ def replace_service_key(old_kid, kid, jwk, metadata, expiration_date):
_gc_expired(key.service)
def update_service_key(name, kid, metadata, expiration_date):
def update_service_key(kid, name=None, metadata=None):
try:
with db_transaction():
key = db_for_update(ServiceKey.select().where(ServiceKey.kid == kid)).get()
key.name = name
key.metadata.update(metadata)
key.expiration_date = expiration_date
if name is not None:
key.name = name
if metadata is not None:
key.metadata.update(metadata)
key.save()
except ServiceKey.DoesNotExist:
raise ServiceKeyDoesNotExist
@ -83,15 +85,26 @@ def update_service_key(name, kid, metadata, expiration_date):
_gc_expired(key.service)
def delete_service_key(service, kid):
def delete_service_key(kid):
try:
ServiceKey.delete().where(ServiceKey.service == service,
ServiceKey.kid == kid).execute()
key = ServiceKey.get(kid=kid)
ServiceKey.delete().where(ServiceKey.kid == kid).execute()
except ServiceKey.DoesNotExist:
raise ServiceKeyDoesNotExist()
raise ServiceKeyDoesNotExist
delete_all_notifications_by_path_prefix('/service_key_approval/{0}'.format(kid))
_gc_expired(service)
_gc_expired(key.service)
return key
def set_key_expiration(kid, expiration_date):
try:
service_key = get_service_key(kid)
except ServiceKey.DoesNotExist:
raise ServiceKeyDoesNotExist
service_key.expiration_date = expiration_date
service_key.save()
def approve_service_key(kid, approver, approval_type, notes=''):
@ -129,7 +142,7 @@ def _list_service_keys_query(kid=None, service=None, approved_only=False):
return query
def list_keys():
def list_all_keys():
return list(_list_service_keys_query())

View file

@ -5,6 +5,7 @@ import logging
import os
import string
from calendar import timegm
from datetime import datetime
from hashlib import sha256
from random import SystemRandom
@ -22,7 +23,7 @@ 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)
page_support, log_action)
from endpoints.api.logs import get_logs, get_aggregate_logs
from data import model
from data.database import ServiceKeyApprovalType
@ -148,6 +149,8 @@ def org_view(org):
def user_view(user, password=None):
user_data = {
'kind': 'user',
'name': user.username,
'username': user.username,
'email': user.email,
'verified': user.verified,
@ -506,7 +509,7 @@ class SuperUserServiceKeyManagement(ApiResource):
""" Resource for managing service keys."""
schemas = {
'CreateServiceKey': {
'id': 'PutServiceKey',
'id': 'CreateServiceKey',
'type': 'object',
'description': 'Description of creation of a service key',
'required': ['service', 'expiration'],
@ -523,9 +526,13 @@ class SuperUserServiceKeyManagement(ApiResource):
'type': 'object',
'description': 'The key/value pairs of this key\'s metadata',
},
'notes': {
'type': 'string',
'description': 'If specified, the extra notes for the key',
},
'expiration': {
'description': 'The expiration date as a unix timestamp',
'anyOf': [{'type': 'string'}, {'type': 'null'}],
'anyOf': [{'type': 'number'}, {'type': 'null'}],
},
},
},
@ -536,46 +543,72 @@ class SuperUserServiceKeyManagement(ApiResource):
@require_scope(scopes.SUPERUSER)
def get(self):
if SuperUserPermission().can():
keys = model.service_keys.list_keys()
return jsonify({'keys': [key_view(key) for key in keys]})
keys = model.service_keys.list_all_keys()
return jsonify({
'keys': [key_view(key) for key in keys],
})
abort(403)
@require_fresh_login
@verify_not_prod
@nickname('createServiceKey')
@require_scope(scopes.SUPERUSER)
@validate_json_request('PutServiceKey')
@validate_json_request('CreateServiceKey')
def post(self):
if SuperUserPermission().can():
body = request.get_json()
# Ensure we have a valid expiration date if specified.
expiration_date = body.get('expiration', None)
if expiration_date == '':
expiration_date = None
if expiration_date is not None:
try:
expiration_date = datetime.utcfromtimestamp(float(expiration_date))
except ValueError:
abort(400)
# Create the metadata for the key.
user = get_authenticated_user()
metadata = body.get('metadata', {})
metadata.update({
'created_by': 'Quay SuperUser Panel',
'created_by': 'Quay Superuser Panel',
'creator': user.username,
'ip': request.remote_addr,
})
# Generate the private key but *do not save it on the server anywhere*.
private_key = RSA.generate(2048)
jwk = RSAKey(key=private_key.publickey()).serialize()
kid = sha256(json.dumps(canonicalize(jwk), separators=(',', ':'))).hexdigest()
# Create the service key.
model.service_keys.create_service_key(body.get('name', ''), kid, body['service'], jwk,
metadata, expiration_date)
model.service_keys.approve_service_key(kid, user, ServiceKeyApprovalType.SUPERUSER)
return jsonify({'public_key': private_key.publickey().exportKey('PEM'),
'private_key': private_key.exportKey('PEM')})
# Auto-approve the service key.
model.service_keys.approve_service_key(kid, user, ServiceKeyApprovalType.SUPERUSER,
notes=body.get('notes', ''))
# Log the creation and auto-approval of the service key.
key_log_metadata = {
'kid': kid,
'preshared': True,
'service': body['service'],
'name': body.get('name', ''),
'expiration_date': expiration_date,
'auto_approved': True,
}
log_action('service_key_create', user.username, key_log_metadata)
log_action('service_key_approve', user.username, key_log_metadata)
return jsonify({
'kid': kid,
'name': body.get('name', ''),
'public_key': private_key.publickey().exportKey('PEM'),
'private_key': private_key.exportKey('PEM'),
})
abort(403)
@ -590,7 +623,6 @@ class SuperUserServiceKeyUpdater(ApiResource):
'id': 'PutServiceKey',
'type': 'object',
'description': 'Description of updates for a service key',
'required': ['name', 'metadata', 'expiration'],
'properties': {
'name': {
'type': 'string',
@ -602,28 +634,78 @@ class SuperUserServiceKeyUpdater(ApiResource):
},
'expiration': {
'description': 'The expiration date as a unix timestamp',
'anyOf': [{'type': 'string'}, {'type': 'null'}],
'anyOf': [{'type': 'number'}, {'type': 'null'}],
},
},
},
}
@require_fresh_login
@verify_not_prod
@nickname('putServiceKey')
@nickname('updateServiceKey')
@require_scope(scopes.SUPERUSER)
@validate_json_request('PutServiceKey')
def put(self, kid):
if SuperUserPermission().can():
body = request.get_json()
try:
key = model.service_keys.get_service_key(kid)
except model.service_keys.ServiceKeyDoesNotExist:
abort(404)
expiration_date = body['expiration']
if expiration_date is not None and expiration_date != '':
try:
expiration_date = datetime.utcfromtimestamp(float(expiration_date))
except ValueError:
abort(400)
user = get_authenticated_user()
model.service_keys.update_service_key(body['name'], kid, body['metadata'], expiration_date)
key_log_metadata = {
'kid': key.kid,
'service': key.service,
'name': body.get('name', key.name),
'expiration_date': key.expiration_date,
}
if 'expiration' in body:
expiration_date = body['expiration']
if expiration_date is not None and expiration_date != '':
try:
expiration_date = datetime.utcfromtimestamp(float(expiration_date))
except ValueError:
abort(400)
key_log_metadata.update({
'old_expiration_date': key.expiration_date,
'expiration_date': expiration_date,
})
log_action('service_key_extend', user.username, key_log_metadata)
model.service_keys.set_key_expiration(kid, expiration_date)
if 'name' in body or 'metadata' in body:
model.service_keys.update_service_key(kid, body.get('name'), body.get('metadata'))
log_action('service_key_modify', user.username, key_log_metadata)
return jsonify(key_view(model.service_keys.get_service_key(kid)))
abort(403)
@require_fresh_login
@verify_not_prod
@nickname('deleteServiceKey')
@require_scope(scopes.SUPERUSER)
def delete(self, kid):
if SuperUserPermission().can():
key = model.service_keys.delete_service_key(kid)
key_log_metadata = {
'kid': kid,
'service': key.service,
'name': key.name,
'created_date': key.created_date,
'expiration_date': key.expiration_date,
}
user = get_authenticated_user()
log_action('service_key_delete', user.username, key_log_metadata)
return make_response('', 201)
abort(403)
@ -634,19 +716,36 @@ class SuperUserServiceKeyUpdater(ApiResource):
class SuperUserServiceKeyApproval(ApiResource):
""" Resource for approving service keys. """
schemas = {
'ApproveServiceKey': {
'id': 'ApproveServiceKey',
'type': 'object',
'description': 'Information for approving service keys',
'properties': {
'notes': {
'type': 'string',
'description': 'Optional approval notes',
},
},
},
}
@require_fresh_login
@verify_not_prod
@nickname('approveServiceKey')
@require_scope(scopes.SUPERUSER)
def put(self, kid):
if SuperUserPermission().can():
notes = request.get_json().get('notes', '')
approver = get_authenticated_user()
try:
model.service_keys.approve_service_key(kid, approver, ServiceKeyApprovalType.SUPERUSER)
model.service_keys.approve_service_key(kid, approver, ServiceKeyApprovalType.SUPERUSER,
notes=notes)
except model.ServiceKeyDoesNotExist:
abort(404)
except model.ServiceKeyAlreadyApproved:
pass
return make_response('', 200)
return make_response('', 201)
abort(403)

View file

@ -12,6 +12,7 @@ from jwkest.jwk import keyrep, RSAKey, ECKey
import data.model
import data.model.service_keys
from data.model.log import log_action
from app import app
from auth.registry_jwt_auth import TOKEN_REGEX
@ -59,13 +60,14 @@ def _validate_jwt(encoded_jwt, jwk, service):
strictjwt.decode(encoded_jwt, public_key, algorithms=['RS256'],
audience=JWT_AUDIENCE, issuer=service)
except strictjwt.InvalidTokenError:
logger.exception('JWT validation failure')
abort(400)
def _signer_kid(encoded_jwt):
decoded_jwt = jwt.decode(encoded_jwt, verify=False)
logger.debug(decoded_jwt)
return decoded_jwt.get('signer_kid', None)
return decoded_jwt.get('kid', None)
def _signer_key(service, signer_kid):
@ -104,6 +106,7 @@ def put_service_key(service, kid):
try:
expiration_date = datetime.utcfromtimestamp(float(expiration_date))
except ValueError:
logger.exception('Error parsing expiration date on key')
abort(400)
rotation_ttl = request.args.get('rotation', None)
@ -113,6 +116,7 @@ def put_service_key(service, kid):
try:
jwk = request.get_json()
except ValueError:
logger.exception('Error parsing JWK')
abort(400)
logger.debug(jwk)
@ -120,7 +124,9 @@ def put_service_key(service, kid):
jwt_header = request.headers.get(JWT_HEADER_NAME, '')
match = TOKEN_REGEX.match(jwt_header)
if match is None:
logger.error('Could not find matching bearer token')
abort(400)
encoded_jwt = match.group(1)
_validate_jwk(jwk, kid)
@ -131,6 +137,18 @@ def put_service_key(service, kid):
# The key is self-signed. Create a new instance and await approval.
_validate_jwt(encoded_jwt, jwk, service)
data.model.service_keys.create_service_key('', kid, service, jwk, metadata, expiration_date)
key_log_metadata = {
'kid': kid,
'preshared': False,
'service': service,
'name': '',
'expiration_date': expiration_date,
'user_agent': request.headers.get('User-Agent'),
'ip': request.remote_addr,
}
log_action('service_key_create', None, metadata=key_log_metadata, ip=request.remote_addr)
return make_response('', 202)
metadata.update({'created_by': 'Key Rotation'})
@ -146,6 +164,17 @@ def put_service_key(service, kid):
except data.model.ServiceKeyDoesNotExist:
abort(404)
key_log_metadata = {
'kid': kid,
'signer_kid': signer_key.kid,
'service': service,
'name': signer_key.name,
'expiration_date': expiration_date,
'user_agent': request.headers.get('User-Agent'),
'ip': request.remote_addr,
}
log_action('service_key_rotate', None, metadata=key_log_metadata, ip=request.remote_addr)
return make_response('', 200)

View file

@ -11,16 +11,19 @@ EXTERNAL_JS = [
'ajax.googleapis.com/ajax/libs/angularjs/1.2.9/angular-route.min.js',
'ajax.googleapis.com/ajax/libs/angularjs/1.2.9/angular-sanitize.min.js',
'ajax.googleapis.com/ajax/libs/angularjs/1.2.9/angular-animate.min.js',
'cdn.jsdelivr.net/g/momentjs',
'cdnjs.cloudflare.com/ajax/libs/bootstrap-datepicker/1.2.0/js/bootstrap-datepicker.min.js',
'cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0,d3js@3.3.3,momentjs',
'cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.37/js/bootstrap-datetimepicker.min.js',
'cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0,d3js@3.3.3',
'cdn.ravenjs.com/1.1.14/jquery,native/raven.min.js',
]
EXTERNAL_CSS = [
'netdna.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.css',
'netdna.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css',
'fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,600,700',
's3.amazonaws.com/cdn.core-os.net/icons/core-icons.css'
'fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,700',
's3.amazonaws.com/cdn.core-os.net/icons/core-icons.css',
'cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.37/css/bootstrap-datetimepicker.min.css',
]
EXTERNAL_FONTS = [

View file

@ -12,18 +12,24 @@ from peewee import (SqliteDatabase, create_model_tables, drop_model_tables, save
from itertools import count
from uuid import UUID, uuid4
from threading import Event
from hashlib import sha256
from Crypto.PublicKey import RSA
from jwkest.jwk import RSAKey
from email.utils import formatdate
from data.database import (db, all_models, Role, TeamRole, Visibility, LoginService,
BuildTriggerService, AccessTokenKind, LogEntryKind, ImageStorageLocation,
ImageStorageTransformation, ImageStorageSignatureKind,
ExternalNotificationEvent, ExternalNotificationMethod, NotificationKind,
QuayRegion, QuayService, UserRegion, OAuthAuthorizationCode)
QuayRegion, QuayService, UserRegion, OAuthAuthorizationCode,
ServiceKeyApprovalType)
from data import model
from data.queue import WorkQueue
from app import app, storage as store, tf
from storage.basestorage import StoragePaths
from endpoints.v2.manifest import _generate_and_store_manifest
from util import canonicalize
from workers import repositoryactioncounter
@ -150,6 +156,32 @@ def __create_subtree(with_storage, repo, structure, creator_username, parent, ta
__create_subtree(with_storage, repo, subtree, creator_username, new_image, tag_map)
def __generate_service_key(name, user, timestamp, approval_type, expiration=None, metadata=None):
private_key = RSA.generate(1024)
jwk = RSAKey(key=private_key.publickey()).serialize()
kid = sha256(json.dumps(canonicalize(jwk), separators=(',', ':'))).hexdigest()
metadata = metadata or {}
model.service_keys.create_service_key(name, kid, 'sample_service', jwk, metadata, expiration)
model.service_keys.approve_service_key(kid, user, approval_type,
notes='The **test** apporval')
key_metadata = {
'kid': kid,
'preshared': True,
'service': 'sample_service',
'name': name,
'expiration_date': expiration,
'auto_approved': True
}
model.log.log_action('service_key_approve', None, performer=user,
timestamp=timestamp, metadata=key_metadata)
model.log.log_action('service_key_create', None, performer=user,
timestamp=timestamp, metadata=key_metadata)
def __generate_repository(with_storage, user_obj, name, description, is_public, permissions, structure):
repo = model.repository.create_repository(user_obj.username, name, user_obj)
@ -305,6 +337,13 @@ def initialize_database():
LogEntryKind.create(name='repo_verb')
LogEntryKind.create(name='service_key_create')
LogEntryKind.create(name='service_key_approve')
LogEntryKind.create(name='service_key_delete')
LogEntryKind.create(name='service_key_modify')
LogEntryKind.create(name='service_key_extend')
LogEntryKind.create(name='service_key_rotate')
ImageStorageLocation.create(name='local_eu')
ImageStorageLocation.create(name='local_us')
@ -614,6 +653,14 @@ def populate_database(minimal=False, with_storage=False):
six_ago = today - timedelta(5)
four_ago = today - timedelta(4)
__generate_service_key('somesamplekey', new_user_1, today, ServiceKeyApprovalType.SUPERUSER)
__generate_service_key('someexpiringkey', new_user_1, week_ago, ServiceKeyApprovalType.SUPERUSER,
today + timedelta(14))
__generate_service_key('autorotatingkey', new_user_1, six_ago,
ServiceKeyApprovalType.KEY_ROTATION, today + timedelta(1),
dict(rotation_ttl=timedelta(hours=12).total_seconds()))
model.log.log_action('org_create_team', org.username, performer=new_user_1,
timestamp=week_ago, metadata={'team': 'readers'})

View file

@ -55,6 +55,30 @@ a:focus {
outline: none !important;
}
.co-form-table label {
white-space: nowrap;
}
.co-form-table td {
padding: 8px;
}
.co-form-table td:first-child {
vertical-align: top;
padding-top: 14px;
}
.co-form-table td .co-help-text {
margin-top: 10px;
margin-bottom: 4px;
}
.co-help-text {
margin-top: 6px;
color: #aaa;
display: inline-block;
}
.co-options-menu .fa-gear {
color: #999;
cursor: pointer;

View file

@ -0,0 +1,31 @@
.markdown-editor-element .wmd-panel .btn {
background-color: #ddd;
}
.markdown-editor-element .wmd-panel .btn:hover {
background-color: #eee;
}
.markdown-editor-element .wmd-panel .btn:active {
background-color: #ccc;
}
.markdown-editor-element .preview-btn {
float: right;
}
.markdown-editor-element .preview-btn.active {
box-shadow: inset 0 3px 5px rgba(0,0,0,.125);
}
.markdown-editor-element .preview-panel .markdown-view {
border: 1px solid #eee;
padding: 4px;
min-height: 150px;
}
.markdown-editor-element .preview-top-bar {
height: 43px;
line-height: 43px;
color: #ddd;
}

View file

@ -0,0 +1,89 @@
.service-keys-manager-element .co-filter-box {
float: right;
}
.service-keys-manager-element .manager-header {
margin-bottom: 20px;
}
@media (max-width: 767px) {
.service-keys-manager-element .co-filter-box {
float: none;
display: block;
}
}
.service-keys-manager-element .approval-user .pretext {
vertical-align: middle;
margin-right: 4px;
font-size: 12px;
color: #777;
}
.service-keys-manager-element .expired a {
color: #D64456;
}
.service-keys-manager-element .critical a {
color: #F77454;
}
.service-keys-manager-element .warning a {
color: #FCA657;
}
.service-keys-manager-element .info a {
color: #2FC98E;
}
.service-keys-manager-element .rotation {
color: #777;
}
.service-keys-manager-element .no-expiration {
color: #145884;
}
.service-keys-manager-element i.fa {
margin-right: 4px;
}
.service-keys-manager-element .approval-rotation {
font-size: 12px;
color: #777;
}
.service-keys-manager-element .approval-rotation i.fa {
margin-right: 6px;
}
.service-keys-manager-element .subtitle {
color: #999;
font-size: 90%;
text-transform: uppercase;
font-weight: 300;
padding-top: 0!important;
text-align: left;
margin-bottom: 6px;
margin-top: 10px;
}
.service-keys-manager-element .approval-required i.fa {
margin-right: 4px;
}
.service-keys-manager-element .approval-required a {
margin-left: 10px;
}
.service-keys-manager-element .unnamed {
color: #ddd;
}
.service-keys-manager-element .key-display {
margin-top: 10px;
font-size: 12px;
font-family: Menlo,Monaco,Consolas,"Courier New",monospace;
background: white;
min-height: 500px;
}

View file

@ -1382,7 +1382,6 @@ p.editable:hover i {
.modal-body textarea {
width: 100%;
height: 150px;
border: 0px;
}
.tag-specific-images-view .image-listings {
@ -4034,13 +4033,28 @@ i.rocket-icon {
text-align: center;
}
.section-description-header {
.section-description-header {
position: relative;
margin-bottom: 10px;
min-height: 50px;
padding-bottom: 10px;
}
.section-description-header.twenty {
margin-top: -20px;
}
.section-description-header:before {
font-family: FontAwesome;
content: "\f05a";
position: absolute;
top: 50%;
left: 10px;
font-size: 22px;
color: #D2D2D2;
transform: translateY(-50%);
}
.nvtooltip h3 {
margin: 0;
padding: 4px 14px;

View file

@ -0,0 +1,3 @@
<span class="datetime-picker-element">
<input class="form-control" type="text" ng-model="entered_datetime"/>
</span>

View file

@ -0,0 +1,11 @@
<div class="markdown-editor-element">
<a class="btn btn-default preview-btn" ng-click="togglePreview()" ng-class="{'active': previewing}">Preview</a>
<div class="wmd-panel" ng-show="!previewing">
<div id="wmd-button-bar-{{id}}"></div>
<textarea class="wmd-input form-control" id="wmd-input-{{id}}" ng-model="content"></textarea>
</div>
<div class="preview-panel" ng-show="previewing">
<div class="preview-top-bar">Viewing preview</div>
<div class="markdown-view" content="content || '(Nothing entered)'"></div>
</div>
</div>

View file

@ -0,0 +1,260 @@
<div class="service-keys-manager-element">
<div class="resource-view" resource="keysResource" error-message="'Could not load service keys'">
<div class="manager-header" header-title="Service Keys">
<button class="btn btn-primary" ng-click="showCreateKey()">
Create Preshareable Key
</button>
</div>
<div class="section-description-header twenty">
Service keys provide a recognized means of authentication between Quay Enterprise and external services, as well as between external services. <br>Example services include Quay Security Scanner speaking to a <a href="https://github.com/coreos/clair" target="_blank">Clair</a> cluster, or Quay Enterprise speaking to its
<a href="https://tectonic.com/quay-enterprise/docs/latest/build-support.html" target="_blank">build workers</a>.
</div>
<span class="co-filter-box" ng-if="keys.length">
<span class="filter-message" ng-if="options.filter">
Showing {{ orderedKeys.entries.length }} of {{ keys.length }} keys
</span>
<input class="form-control" type="text" ng-model="options.filter" placeholder="Filter Keys...">
</span>
<!-- Table -->
<div class="empty" ng-if="!keys.length" style="margin-top: 20px;">
<div class="empty-primary-msg">No service keys defined</div>
<div class="empty-secondary-msg">There are no keys defined for working with external services</div>
</div>
<table class="co-table" ng-show="keys.length">
<thead>
<td class="caret-col"></td>
<td ng-class="TableService.tablePredicateClass('name', options.predicate, options.reverse)">
<a href="javascript:void(0)" ng-click="TableService.orderBy('name', options)">Name</a>
</td>
<td ng-class="TableService.tablePredicateClass('service', options.predicate, options.reverse)">
<a href="javascript:void(0)" ng-click="TableService.orderBy('service', options)">Service Name</a>
</td>
<td ng-class="TableService.tablePredicateClass('creation_datetime', options.predicate, options.reverse)">
<a href="javascript:void(0)" ng-click="TableService.orderBy('creation_datetime', options)">Created</a>
</td>
<td ng-class="TableService.tablePredicateClass('expiration_datetime', options.predicate, options.reverse)">
<a href="javascript:void(0)" ng-click="TableService.orderBy('expiration_datetime', options)">Expires</a>
</td>
<td>
Approval Status
</td>
<td class="hidden-xs options-col"></td>
</thead>
<tbody ng-repeat="key in orderedKeys.visibleEntries" bindonce>
<tr>
<td class="caret-col">
<span ng-click="toggleDetails(key)">
<i class="fa"
ng-class="key.expanded ? 'fa-caret-down' : 'fa-caret-right'"
data-title="View Details" bs-tooltip></i>
</span>
</td>
<td>
<a ng-click="toggleDetails(key)" bo-if="key.name"><span bo-text="key.name"></span></a>
<a ng-click="toggleDetails(key)" bo-if="!key.name" class="unnamed">(Unnamed)</a>
</td>
<td><span bo-text="key.service"></span></td>
<td>
<span am-time-ago="key.created_date"></span>
</td>
<td>
<span class="rotation" bo-if="key.expiration_date && getExpirationInfo(key).rotateDate">
<i class="fa" ng-class="getExpirationInfo(key).icon"></i>
Automatically rotated <span am-time-ago="getExpirationInfo(key).rotateDate"></span>
</span>
<span bo-if="key.expiration_date && !getExpirationInfo(key).rotateDate">
<span ng-class="getExpirationInfo(key).className">
<a ng-click="showChangeExpiration(key)">
<i class="fa" ng-class="getExpirationInfo(key).icon"></i>
Expires <span am-time-ago="key.expiration_date"></span>
</a>
</span>
</span>
<span class="no-expiration" bo-if="!key.expiration_date">
<i class="fa fa-info-circle"></i> Does not expire
</span>
</td>
<td>
<span class="approval-user" bo-if="key.approval && key.approval.approval_type == 'ServiceKeyApprovalType.SUPERUSER'">
<span class="pretext">Approved by</span><span class="entity-reference" entity="key.approval.approver"></span>
</span>
<span class="approval-rotation" bo-if="key.approval && key.approval.approval_type == 'ServiceKeyApprovalType.KEY_ROTATION'">
<i class="fa fa-refresh"></i>Approved via key rotation
</span>
<span class="approval-required" bo-if="!key.approval">
<i class="fa fa-warning"></i>
Awaiting Approval <a ng-click="showApproveKey(key)">Approve Now</a>
</span>
</td>
<td class="options-col">
<span class="cor-options-menu">
<span class="cor-option" option-click="showChangeName(key)">
<i class="fa fa-tag"></i> Set Friendly Name
</span>
<span class="cor-option" option-click="showChangeExpiration(key)">
<i class="fa fa-clock-o"></i> Change Expiration Time
</span>
<span class="cor-option" option-click="showApproveKey(key)" ng-show="!key.approval">
<i class="fa fa-check-circle"></i> Approve Key
</span>
<span class="cor-option" option-click="showDeleteKey(key)">
<i class="fa fa-times"></i> Delete Key
</span>
</span>
</td>
</tr>
<tr ng-if="key.expanded">
<td colspan="7">
<div class="subtitle">Full Key ID</div>
<span bo-text="key.kid"></span>
<div bo-if="key.approval.notes">
<div class="subtitle">Approval notes</div>
<div class="markdown-view" content="key.approval.notes"></div>
</div>
</td>
</tr>
</tbody>
</table>
<div class="empty" ng-if="keys.length && !orderedKeys.entries.length"
style="margin-top: 20px;">
<div class="empty-primary-msg">No matching keys found.</div>
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
</div>
</div>
<!-- Change Key Expiration Confirm -->
<div class="cor-confirm-dialog"
dialog-context="context.expirationChangeInfo"
dialog-action="changeKeyExpiration(context.expirationChangeInfo, callback)"
dialog-title="Change Service Key Expiration"
dialog-action-title="Change Expiration">
<form>
<span class="datetime-picker" datetime="context.expirationChangeInfo.expiration_date"></span>
<span class="co-help-text">
If specified, the date and time that the key expires. It is highly recommended to have an expiration date.
</span>
</form>
</div>
<!-- Delete Key Confirm -->
<div class="cor-confirm-dialog"
dialog-context="deleteKeyInfo"
dialog-action="deleteKey(deleteKeyInfo, callback)"
dialog-title="Delete Service Key"
dialog-action-title="Delete Key">
Are you <strong>sure</strong> you want to delete service key <strong>{{ getKeyTitle(deleteKeyInfo.key) }}</strong>?<br><br>
All external services that use this key for authentication will fail.
</div>
<!-- Approve Key Confirm -->
<div class="cor-confirm-dialog"
dialog-context="approvalKeyInfo"
dialog-action="approveKey(approvalKeyInfo, callback)"
dialog-title="Approve Service Key"
dialog-action-title="Approve Key">
<form>
<div style="margin-bottom: 10px;">
Approve service key <strong>{{ getKeyTitle(approvalKeyInfo.key) }}</strong>?
</div>
<div class="markdown-editor" content="approvalKeyInfo.notes"></div>
<span class="co-help-text">
Enter optional notes for additional human-readable information about why the key was approved.
</span>
</form>
</div>
<!-- Created key modal -->
<div id="createdKeyModal" class="modal fade co-dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" ng-show="!creatingKey" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Created Preshareable Service Key <strong>{{ getKeyTitle(createdKey) }}</strong></h4>
</div>
<div class="modal-body">
<div class="co-alert co-alert-warning">
Please copy or download the following private key. <strong>Once this dialog is closed the key will not be accessible anywhere else</strong>.
</div>
<textarea class="key-display form-control" onclick="this.focus();this.select()" readonly>{{ createdKey.private_key }}</textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" ng-click="downloadPrivateKey(createdKey)" ng-if="isDownloadSupported()">
<i class="fa fa-download"></i> Download Private Key
</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- Create key modal -->
<div id="createKeyModal" class="modal fade co-dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" ng-show="!creatingKey" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Create Preshareable Service Key</h4>
</div>
<div class="modal-body" ng-show="creatingKey">
<div class="cor-loader"></div>
</div>
<div class="modal-body" ng-show="!creatingKey">
<form name="createForm" ng-submit="createServiceKey()">
<table class="co-form-table">
<tr>
<td><label for="create-key-name">Key Name:</label></td>
<td>
<input class="form-control" name="create-key-name" type="text" ng-model="newKey.name" placeholder="Friendly Key Name" required>
<span class="co-help-text">
A friendly name for the key for later reference.
</span>
</td>
</tr>
<tr>
<td><label for="create-servce-name">Service Name:</label></td>
<td>
<input class="form-control" name="create-servce-name" type="text" ng-model="newKey.service" placeholder="Service Name" ng-pattern="/^[a-z0-9_]+$/" required>
<span class="co-help-text">
The name of the service for the key. Keys within the same cluster should share service names, representing
a single logical service. Must match [a-z0-9_]+.
</span>
</td>
</tr>
<tr>
<td><label for="create-key-expiration">Expires:</label></td>
<td>
<span class="datetime-picker" datetime="newKey.expiration"></span>
<span class="co-help-text">
If specified, the date and time that the key expires. It is highly recommended to have an expiration date.
</span>
</td>
</tr>
<tr>
<td><label for="create-key-notes">Approval Notes:</label></td>
<td>
<div class="markdown-editor" content="newKey.notes"></div>
<span class="co-help-text">
Optional notes for additional human-readable information about why the key was added.
</span>
</td>
</tr>
</table>
</form>
</div>
<div class="modal-footer" ng-show="!creatingKey">
<button type="button" class="btn btn-primary" ng-click="createServiceKey()" ng-disabled="createForm.$invalid">
Create Key
</button>
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>

View file

@ -0,0 +1,51 @@
/**
* An element which displays a datetime picker.
*/
angular.module('quay').directive('datetimePicker', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/datetime-picker.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'datetime': '=datetime',
},
controller: function($scope, $element) {
$scope.entered_datetime = null;
$(function() {
$element.find('input').datetimepicker({
'format': 'LLL',
'sideBySide': true,
'showClear': true
});
$element.find('input').on("dp.change", function (e) {
$scope.datetime = e.date ? e.date.unix() : null;
});
});
$scope.$watch('entered_datetime', function(value) {
if (!value) {
if ($scope.datetime) {
$scope.datetime = null;
}
return;
}
$scope.datetime = (new Date(value)).getTime()/1000;
});
$scope.$watch('datetime', function(value) {
if (!value) {
$scope.entered_datetime = null;
return;
}
$scope.entered_datetime = moment.unix(value).format('LLL');
});
}
};
return directiveDefinitionObject;
});

View file

@ -37,6 +37,14 @@ angular.module('quay').directive('logsView', function () {
return '';
};
var getServiceKeyTitle = function(metadata) {
if (metadata.name) {
return metadata.name;
}
return metadata.kind.substr(0, 12);
};
var logDescriptions = {
'account_change_plan': 'Change plan',
'account_change_cc': 'Update credit card',
@ -195,6 +203,20 @@ angular.module('quay').directive('logsView', function () {
'regenerate_robot_token': 'Regenerated token for robot {robot}',
'service_key_create': function(metadata) {
if (metadata.preshared) {
return 'Manual creation of preshared service key {kid} for service {service}';
} else {
return 'Creation of service key {kid} for service {service} by {user_agent}';
}
},
'service_key_approve': 'Approval of service key {kid}',
'service_key_modify': 'Modification of service key {kid}',
'service_key_delete': 'Deletion of service key {kid}',
'service_key_extend': 'Change of expiration of service key {kid} from {old_expiration_date} to {expiration_date}',
'service_key_rotate': 'Automatic rotation of service key {kid} by {user_agent}',
// Note: These are deprecated.
'add_repo_webhook': 'Add webhook in repository {repo}',
'delete_repo_webhook': 'Delete webhook in repository {repo}'
@ -245,6 +267,12 @@ angular.module('quay').directive('logsView', function () {
'add_repo_notification': 'Add repository notification',
'delete_repo_notification': 'Delete repository notification',
'regenerate_robot_token': 'Regenerate Robot Token',
'service_key_create': 'Create Service Key',
'service_key_approve': 'Approve Service Key',
'service_key_modify': 'Modify Service Key',
'service_key_delete': 'Delete Service Key',
'service_key_extend': 'Extend Service Key Expiration',
'service_key_rotate': 'Automatic rotation of Service Key',
// Note: these are deprecated.
'add_repo_webhook': 'Add webhook',

View file

@ -0,0 +1,32 @@
/**
* An element which display an inline editor for writing and previewing markdown text.
*/
angular.module('quay').directive('markdownEditor', function () {
var counter = 0;
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/markdown-editor.html',
replace: false,
transclude: false,
restrict: 'C',
scope: {
'content': '=content',
},
controller: function($scope, $element, $timeout) {
$scope.id = (counter++);
$scope.previewing = false;
$timeout(function() {
var converter = Markdown.getSanitizingConverter();
var editor = new Markdown.Editor(converter, '-' + $scope.id);
editor.run();
});
$scope.togglePreview = function() {
$scope.previewing = !$scope.previewing;
};
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,229 @@
/**
* An element which displays a panel for managing keys for external services.
*/
angular.module('quay').directive('serviceKeysManager', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/service-keys-manager.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'isEnabled': '=isEnabled'
},
controller: function($scope, $element, ApiService, TableService) {
$scope.options = {
'filter': null,
'predicate': 'expiration_datetime',
'reverse': false,
};
$scope.TableService = TableService;
$scope.newKey = null;
$scope.creatingKey = false;
$scope.context = {
'expirationChangeInfo': null
};
var buildOrderedKeys = function() {
if (!$scope.keys) {
return;
}
var keys = $scope.keys.map(function(key) {
var expiration_datetime = -Number.MAX_VALUE;
if (key.expiration_date) {
expiration_datetime = new Date(key.expiration_date).valueOf() * (-1);
}
return $.extend(key, {
'creation_datetime': new Date(key.creation_date).valueOf() * (-1),
'expiration_datetime': expiration_datetime,
'expanded': false
});
});
$scope.orderedKeys = TableService.buildOrderedItems(keys, $scope.options,
['name', 'kid', 'service'],
['creation_datetime', 'expiration_datetime'])
};
var loadServiceKeys = function() {
$scope.options.filter = null;
$scope.now = new Date();
$scope.keysResource = ApiService.getServiceKeysAsResource().get(function(resp) {
$scope.keys = resp['keys'];
buildOrderedKeys();
});
};
$scope.getKeyTitle = function(key) {
if (!key) { return ''; }
return key.name || key.kid.substr(0, 12);
};
$scope.toggleDetails = function(key) {
key.expanded = !key.expanded;
};
$scope.getExpirationInfo = function(key) {
if (!key.expiration_date) {
return '';
}
if (key.metadata.rotation_ttl) {
var rotate_date = moment(key.created_date).add(key.metadata.rotation_ttl, 's')
if (moment().isBefore(rotate_date)) {
return {'className': 'rotation', 'icon': 'fa-refresh', 'rotateDate': rotate_date};
}
}
expiration_date = moment(key.expiration_date);
if (moment().isAfter(expiration_date)) {
return {'className': 'expired', 'icon': 'fa-warning'};
}
if (moment().add(1, 'week').isAfter(expiration_date)) {
return {'className': 'critical', 'icon': 'fa-warning'};
}
if (moment().add(1, 'month').isAfter(expiration_date)) {
return {'className': 'warning', 'icon': 'fa-warning'};
}
return {'className': 'info', 'icon': 'fa-info-circle'};
};
$scope.showChangeName = function(key) {
bootbox.prompt('Enter a friendly name for key ' + $scope.getKeyTitle(key), function(value) {
if (value) {
var data = {
'name': value
};
var params = {
'kid': key.kid
};
ApiService.updateServiceKey(data, params).then(function(resp) {
loadServiceKeys();
}, ApiService.errorDisplay('Could not update service key'));
}
});
};
$scope.showChangeExpiration = function(key) {
$scope.context.expirationChangeInfo = {
'key': key,
'expiration_date': key.expiration_date ? (new Date(key.expiration_date).getTime() / 1000) : null
};
};
$scope.changeKeyExpiration = function(changeInfo, callback) {
var errorHandler = ApiService.errorDisplay('Could not change expiration on service key', callback);
var data = {
'expiration': changeInfo.expiration_date
};
var params = {
'kid': changeInfo.key.kid
};
ApiService.updateServiceKey(data, params).then(function(resp) {
loadServiceKeys();
callback(true);
}, errorHandler);
};
$scope.createServiceKey = function() {
$scope.creatingKey = true;
ApiService.createServiceKey($scope.newKey).then(function(resp) {
$scope.creatingKey = false;
$('#createKeyModal').modal('hide');
$scope.createdKey = resp;
$('#createdKeyModal').modal('show');
loadServiceKeys();
}, ApiService.errorDisplay('Could not create service key'));
};
$scope.showApproveKey = function(key) {
$scope.approvalKeyInfo = {
'key': key,
'notes': ''
};
};
$scope.approveKey = function(approvalKeyInfo, callback) {
var errorHandler = ApiService.errorDisplay('Could not approve service key', callback);
var data = {
'notes': approvalKeyInfo.notes
};
var params = {
'kid': approvalKeyInfo.key.kid
};
ApiService.approveServiceKey(data, params).then(function(resp) {
loadServiceKeys();
callback(true);
}, errorHandler);
};
$scope.showCreateKey = function() {
$scope.newKey = {
'expiration': null
};
$('#createKeyModal').modal('show');
};
$scope.showDeleteKey = function(key) {
$scope.deleteKeyInfo = {
'key': key
};
};
$scope.deleteKey = function(deleteKeyInfo, callback) {
var errorHandler = ApiService.errorDisplay('Could not delete service key', callback);
var params = {
'kid': deleteKeyInfo.key.kid
};
ApiService.deleteServiceKey(null, params).then(function(resp) {
loadServiceKeys();
callback(true);
}, errorHandler);
};
$scope.isDownloadSupported = function() {
var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent);
if (isSafari) {
// Doesn't work properly in Safari, sadly.
return false;
}
try { return !!new Blob(); } catch(e) {}
return false;
};
$scope.downloadPrivateKey = function(key) {
var blob = new Blob([key.private_key]);
saveAs(blob, $scope.getKeyTitle(key) + '.pem');
};
$scope.$watch('options.filter', buildOrderedKeys);
$scope.$watch('options.predicate', buildOrderedKeys);
$scope.$watch('options.reverse', buildOrderedKeys);
$scope.$watch('isEnabled', function(value) {
if (value) {
loadServiceKeys();
}
});
}
};
return directiveDefinitionObject;
});

View file

@ -31,6 +31,7 @@
$scope.csrf_token = encodeURIComponent(window.__token);
$scope.dashboardActive = false;
$scope.currentConfig = null;
$scope.serviceKeysActive = false;
$scope.setDashboardActive = function(active) {
$scope.dashboardActive = active;
@ -46,6 +47,10 @@
$('#createUserModal').modal('show');
};
$scope.loadServiceKeys = function() {
$scope.serviceKeysActive = true;
};
$scope.viewSystemLogs = function(service) {
if ($scope.pollChannel) {
$scope.pollChannel.stop();

View file

@ -25,6 +25,34 @@ angular.module('quay').factory('StringBuilderService', ['$sce', 'UtilService', f
'client_id': 'chain'
};
var filters = {
'obj': function(value) {
if (!value) { return []; }
return Object.getOwnPropertyNames(value);
},
'updated_tags': function(value) {
if (!value) { return []; }
return Object.getOwnPropertyNames(value);
},
'kid': function(kid, metadata) {
if (metadata.name) {
return metadata.name;
}
return metadata.kid.substr(0, 12);
},
'expiration_date': function(value) {
return moment.unix(value).format('LLL');
},
'old_expiration_date': function(value) {
return moment.unix(value).format('LLL');
}
};
stringBuilderService.buildUrl = function(value_or_func, metadata) {
var url = value_or_func;
if (typeof url != 'string') {
@ -105,18 +133,6 @@ angular.module('quay').factory('StringBuilderService', ['$sce', 'UtilService', f
}
stringBuilderService.buildString = function(value_or_func, metadata, opt_codetag) {
var filters = {
'obj': function(value) {
if (!value) { return []; }
return Object.getOwnPropertyNames(value);
},
'updated_tags': function(value) {
if (!value) { return []; }
return Object.getOwnPropertyNames(value);
}
};
var description = value_or_func;
if (typeof description != 'string') {
description = description(metadata);
@ -126,7 +142,7 @@ angular.module('quay').factory('StringBuilderService', ['$sce', 'UtilService', f
if (metadata.hasOwnProperty(key)) {
var value = metadata[key] != null ? metadata[key] : '(Unknown)';
if (filters[key]) {
value = filters[key](value);
value = filters[key](value, metadata);
}
description = stringBuilderService.replaceField(description, '', key, value, opt_codetag);

View file

@ -24,6 +24,10 @@
tab-target="#organizations" tab-init="loadOrganizations()">
<i class="fa fa-sitemap"></i>
</span>
<span class="cor-tab" tab-title="Manage Service Keys"
tab-target="#servicekeys" tab-init="loadServiceKeys()">
<i class="fa fa-key"></i>
</span>
<span class="cor-tab" tab-title="Dashboard" tab-target="#dashboard"
tab-shown="setDashboardActive(true)" tab-hidden="setDashboardActive(false)">
<i class="fa fa-tachometer"></i>
@ -50,6 +54,11 @@
configuration-saved="configurationSaved(config)"></div>
</div>
<!-- Service keys tab -->
<div id="servicekeys" class="tab-pane">
<div class="service-keys-manager" is-enabled="serviceKeysActive"></div>
</div>
<!-- Dashboard tab -->
<div id="dashboard" class="tab-pane">
<div class="ps-usage-graph" is-enabled="dashboardActive"></div>

Binary file not shown.

View file

@ -48,7 +48,8 @@ from endpoints.api.permission import (RepositoryUserPermission, RepositoryTeamPe
from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserManagement,
SuperUserSendRecoveryEmail, ChangeLog,
SuperUserOrganizationManagement, SuperUserOrganizationList,
SuperUserAggregateLogs)
SuperUserAggregateLogs, SuperUserServiceKeyManagement,
SuperUserServiceKeyUpdater, SuperUserServiceKeyApproval)
from endpoints.api.secscan import RepositoryImageSecurity
@ -3911,6 +3912,66 @@ class TestSuperUserSendRecoveryEmail(ApiTestCase):
self._run_test('POST', 404, 'devtable', None)
class TestSuperUserServiceKeyManagement(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(SuperUserServiceKeyManagement)
def test_get_anonymous(self):
self._run_test('GET', 403, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 403, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 200, 'devtable', None)
def test_post_anonymous(self):
self._run_test('POST', 401, None, dict(service='someservice', expiration=None))
def test_post_freshuser(self):
self._run_test('POST', 403, 'freshuser', dict(service='someservice', expiration=None))
def test_post_reader(self):
self._run_test('POST', 403, 'reader', dict(service='someservice', expiration=None))
def test_post_devtable(self):
self._run_test('POST', 200, 'devtable', dict(service='someservice', expiration=None))
class TestSuperUserServiceKeyUpdater(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(SuperUserServiceKeyUpdater, kid=1234)
def test_delete_anonymous(self):
self._run_test('DELETE', 401, None, None)
def test_delete_freshuser(self):
self._run_test('DELETE', 403, 'freshuser', None)
def test_delete_reader(self):
self._run_test('DELETE', 403, 'reader', None)
def test_delete_devtable(self):
self._run_test('DELETE', 400, 'devtable', None)
def test_put_anonymous(self):
self._run_test('PUT', 401, None, {})
def test_put_freshuser(self):
self._run_test('PUT', 403, 'freshuser', {})
def test_put_reader(self):
self._run_test('PUT', 403, 'reader', {})
def test_put_devtable(self):
self._run_test('PUT', 404, 'devtable', {})
class TestTeamMemberInvite(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)

View file

@ -24,7 +24,7 @@ def slash_join(*args):
def canonicalize(json_obj):
""" Returns a JSON object sorted by key. """
if isinstance(json_obj, collections.MutableMapping):
sorted_obj = sorted({key: canonicalize(val) for key, val in json_obj}.items())
sorted_obj = sorted({key: canonicalize(val) for key, val in json_obj.items()}.items())
return collections.OrderedDict(sorted_obj)
elif isinstance(json_obj, (list, tuple)):
return [canonicalize(val) for val in json_obj]