diff --git a/data/database.py b/data/database.py index 6a06d0996..ed1d248ee 100644 --- a/data/database.py +++ b/data/database.py @@ -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) diff --git a/data/model/__init__.py b/data/model/__init__.py index 36a33d0c7..d8f772f45 100644 --- a/data/model/__init__.py +++ b/data/model/__init__.py @@ -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) diff --git a/data/model/log.py b/data/model/log.py index 56c029a1e..f13719855 100644 --- a/data/model/log.py +++ b/data/model/log.py @@ -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) diff --git a/data/model/service_keys.py b/data/model/service_keys.py index 5286c0199..7da5bf8d6 100644 --- a/data/model/service_keys.py +++ b/data/model/service_keys.py @@ -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()) diff --git a/endpoints/api/superuser.py b/endpoints/api/superuser.py index a0248f68c..0312a27ea 100644 --- a/endpoints/api/superuser.py +++ b/endpoints/api/superuser.py @@ -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) diff --git a/endpoints/key_server.py b/endpoints/key_server.py index de029c489..8b7ca1596 100644 --- a/endpoints/key_server.py +++ b/endpoints/key_server.py @@ -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) diff --git a/external_libraries.py b/external_libraries.py index 5cbf53f99..45d7f7059 100644 --- a/external_libraries.py +++ b/external_libraries.py @@ -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 = [ diff --git a/initdb.py b/initdb.py index abc06d866..0f032fe45 100644 --- a/initdb.py +++ b/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'}) diff --git a/static/css/core-ui.css b/static/css/core-ui.css index 3fd80f895..8278eb438 100644 --- a/static/css/core-ui.css +++ b/static/css/core-ui.css @@ -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; diff --git a/static/css/directives/ui/markdown-editor.css b/static/css/directives/ui/markdown-editor.css new file mode 100644 index 000000000..bf5602db7 --- /dev/null +++ b/static/css/directives/ui/markdown-editor.css @@ -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; +} \ No newline at end of file diff --git a/static/css/directives/ui/service-keys-manager.css b/static/css/directives/ui/service-keys-manager.css new file mode 100644 index 000000000..2574cb1ba --- /dev/null +++ b/static/css/directives/ui/service-keys-manager.css @@ -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; +} \ No newline at end of file diff --git a/static/css/quay.css b/static/css/quay.css index f2095b9e7..e6c8b8c66 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -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; diff --git a/static/directives/datetime-picker.html b/static/directives/datetime-picker.html new file mode 100644 index 000000000..25f57d701 --- /dev/null +++ b/static/directives/datetime-picker.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/static/directives/markdown-editor.html b/static/directives/markdown-editor.html new file mode 100644 index 000000000..4f838dffe --- /dev/null +++ b/static/directives/markdown-editor.html @@ -0,0 +1,11 @@ +
\ No newline at end of file diff --git a/static/directives/service-keys-manager.html b/static/directives/service-keys-manager.html new file mode 100644 index 000000000..5f2c836c5 --- /dev/null +++ b/static/directives/service-keys-manager.html @@ -0,0 +1,260 @@ ++ | + Name + | ++ Service Name + | ++ Created + | ++ Expires + | ++ Approval Status + | ++ + + |
+ + + + | ++ + (Unnamed) + | ++ | + + | ++ + + Automatically rotated + + + + + + Expires + + + + + Does not expire + + | ++ + Approved by + + + Approved via key rotation + + + + Awaiting Approval Approve Now + + | ++ | + +
+ Full Key ID
+
+
+
+
+ Approval notes
+
+ |
+