keys ui WIP
This commit is contained in:
parent
dc593c0197
commit
11ff3e9b59
25 changed files with 1154 additions and 74 deletions
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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 = [
|
||||
|
|
49
initdb.py
49
initdb.py
|
@ -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'})
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
31
static/css/directives/ui/markdown-editor.css
Normal file
31
static/css/directives/ui/markdown-editor.css
Normal 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;
|
||||
}
|
89
static/css/directives/ui/service-keys-manager.css
Normal file
89
static/css/directives/ui/service-keys-manager.css
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
3
static/directives/datetime-picker.html
Normal file
3
static/directives/datetime-picker.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
<span class="datetime-picker-element">
|
||||
<input class="form-control" type="text" ng-model="entered_datetime"/>
|
||||
</span>
|
11
static/directives/markdown-editor.html
Normal file
11
static/directives/markdown-editor.html
Normal 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>
|
260
static/directives/service-keys-manager.html
Normal file
260
static/directives/service-keys-manager.html
Normal 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">×</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">×</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>
|
51
static/js/directives/ui/datetime-picker.js
Normal file
51
static/js/directives/ui/datetime-picker.js
Normal 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;
|
||||
});
|
|
@ -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',
|
||||
|
|
32
static/js/directives/ui/markdown-editor.js
Normal file
32
static/js/directives/ui/markdown-editor.js
Normal 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;
|
||||
});
|
229
static/js/directives/ui/service-keys-manager.js
Normal file
229
static/js/directives/ui/service-keys-manager.js
Normal 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;
|
||||
});
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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.
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
|
|
Reference in a new issue