diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index 12584e70e..873e21a9a 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -416,4 +416,5 @@ import endpoints.api.team import endpoints.api.trigger import endpoints.api.user import endpoints.api.secscan +import endpoints.api.signing diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py index 8cea8a602..37f22b856 100644 --- a/endpoints/api/repository.py +++ b/endpoints/api/repository.py @@ -378,7 +378,7 @@ class Repository(RepositoryParamResource): 'is_organization': repo.namespace_user.organization, 'is_starred': is_starred, 'status_token': repo.badge_token if not is_public else '', - 'trust_enabled': repo.trust_enabled, + 'trust_enabled': bool(features.SIGNING) and repo.trust_enabled, } if stats is not None: diff --git a/endpoints/api/signing.py b/endpoints/api/signing.py index aa23b7062..161f87760 100644 --- a/endpoints/api/signing.py +++ b/endpoints/api/signing.py @@ -4,9 +4,10 @@ import logging import features from app import tuf_metadata_api +from data import model from endpoints.api import (require_repo_read, path_param, - RepositoryParamResource, resource, nickname, show_if, - disallow_for_app_repositories) + RepositoryParamResource, resource, nickname, show_if, + disallow_for_app_repositories, NotFound) logger = logging.getLogger(__name__) @@ -21,7 +22,11 @@ class RepositorySignatures(RepositoryParamResource): @nickname('getRepoSignatures') @disallow_for_app_repositories def get(self, namespace, repository): - """ Fetches the list of signed tags for the repository""" + """ Fetches the list of signed tags for the repository. """ + repo = model.repository.get_repository(namespace, repository) + if repo is None or not repo.trust_enabled: + raise NotFound() + tag_data, expiration = tuf_metadata_api.get_default_tags_with_expiration(namespace, repository) return { 'tags': tag_data, diff --git a/endpoints/api/test/test_repository.py b/endpoints/api/test/test_repository.py index f686fd9e4..e3b3050b8 100644 --- a/endpoints/api/test/test_repository.py +++ b/endpoints/api/test/test_repository.py @@ -1,7 +1,8 @@ import pytest from endpoints.api.test.shared import client_with_identity, conduct_api_call -from endpoints.api.repository import RepositoryTrust +from endpoints.api.repository import RepositoryTrust, Repository +from features import FeatureNameValue from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file from mock import patch, ANY, MagicMock @@ -40,3 +41,11 @@ def test_post_changetrust(trust_enabled, repo_found, expected_body, expected_sta params = {'repository': 'devtable/repo'} request_body = {'trust_enabled': trust_enabled} assert expected_body == conduct_api_call(cl, RepositoryTrust, 'POST', params, request_body, expected_status).json + + +def test_signing_disabled(client): + with patch('features.SIGNING', FeatureNameValue('SIGNING', False)): + with client_with_identity('devtable', client) as cl: + params = {'repository': 'devtable/simple'} + response = conduct_api_call(cl, Repository, 'GET', params).json + assert not response['trust_enabled'] diff --git a/endpoints/api/test/test_security.py b/endpoints/api/test/test_security.py index bfcae8b99..a65ca7b0e 100644 --- a/endpoints/api/test/test_security.py +++ b/endpoints/api/test/test_security.py @@ -39,11 +39,11 @@ REPO_PARAMS = {'repository': 'devtable/someapp'} (SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'freshuser', 403), (SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'reader', 403), (SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'devtable', 404), - + (RepositorySignatures, 'GET', REPO_PARAMS, {}, 'freshuser', 403), (RepositorySignatures, 'GET', REPO_PARAMS, {}, 'reader', 403), - (RepositorySignatures, 'GET', REPO_PARAMS, {}, 'devtable', 200), - + (RepositorySignatures, 'GET', REPO_PARAMS, {}, 'devtable', 404), + (RepositoryTrust, 'POST', REPO_PARAMS, {'trust_enabled': True}, None, 403), (RepositoryTrust, 'POST', REPO_PARAMS, {'trust_enabled': True}, 'freshuser', 403), (RepositoryTrust, 'POST', REPO_PARAMS, {'trust_enabled': True}, 'reader', 403), diff --git a/endpoints/api/test/test_signing.py b/endpoints/api/test/test_signing.py index 93ac94be6..056fdad7f 100644 --- a/endpoints/api/test/test_signing.py +++ b/endpoints/api/test/test_signing.py @@ -30,7 +30,7 @@ def tags_equal(expected, actual): return expected == actual @pytest.mark.parametrize('targets,expected', [ - (VALID_TARGETS, {'tags': VALID_TARGETS, 'expiration': 'expires'}), + (VALID_TARGETS, {'tags': VALID_TARGETS, 'expiration': 'expires'}), ({'bad': 'tags'}, {'tags': {'bad': 'tags'}, 'expiration': 'expires'}), ({}, {'tags': {}, 'expiration': 'expires'}), (None, {'tags': None, 'expiration': 'expires'}), # API returns None on exceptions @@ -39,5 +39,5 @@ def test_get_signatures(targets, expected, client): with patch('endpoints.api.signing.tuf_metadata_api') as mock_tuf: mock_tuf.get_default_tags_with_expiration.return_value = (targets, 'expires') with client_with_identity('devtable', client) as cl: - params = {'repository': 'devtable/repo'} + params = {'repository': 'devtable/trusted'} assert tags_equal(expected, conduct_api_call(cl, RepositorySignatures, 'GET', params, None, 200).json) diff --git a/initdb.py b/initdb.py index efcb99da8..28093dcfd 100644 --- a/initdb.py +++ b/initdb.py @@ -576,6 +576,11 @@ def populate_database(minimal=False, with_storage=False): (1, [(1, [], 'v5.0'), (1, [], 'v6.0')], None)], None)) + trusted_repo = __generate_repository(with_storage, new_user_1, 'trusted', 'Trusted repository.', + False, [], (4, [], ['latest', 'prod'])) + trusted_repo.trust_enabled = True + trusted_repo.save() + publicrepo = __generate_repository(with_storage, new_user_2, 'publicrepo', 'Public repository pullable by the world.', True, [], (10, [], 'latest')) diff --git a/static/css/core-ui.css b/static/css/core-ui.css index 36b2ce342..1c4672081 100644 --- a/static/css/core-ui.css +++ b/static/css/core-ui.css @@ -1170,6 +1170,10 @@ a:focus { visibility: hidden; } +.co-table thead td.unorderable-col:after { + display: none; +} + .co-table thead td.current:after { content: "\f175"; visibility: visible; diff --git a/static/css/directives/repo-view/repo-panel-tags.css b/static/css/directives/repo-view/repo-panel-tags.css index 0cb53a633..7e03596bf 100644 --- a/static/css/directives/repo-view/repo-panel-tags.css +++ b/static/css/directives/repo-view/repo-panel-tags.css @@ -81,6 +81,10 @@ margin-right: 2px; } +.repo-panel-tags-element .signing-col { + text-align: center; +} + .repo-panel-tags-element .security-scan-col span { cursor: pointer; } @@ -179,4 +183,9 @@ .repo-panel-tags-element .co-checked-actions .btn .text { display: none; } +} + +.repo-panel-tags-element .disabled-option, +.repo-panel-tags-element .disabled-option a { + color: #ccc; } \ No newline at end of file diff --git a/static/css/directives/ui/repository-signing-config.css b/static/css/directives/ui/repository-signing-config.css new file mode 100644 index 000000000..98fafb4f3 --- /dev/null +++ b/static/css/directives/ui/repository-signing-config.css @@ -0,0 +1,16 @@ +.repository-signing-config-element td { + vertical-align: top; +} + +.repository-signing-config-element .status-icon { + font-size: 48px; + margin-right: 10px; +} + +.repository-signing-config-element .status-icon.ci-shield-check-outline { + color: #2FC98E; +} + +.repository-signing-config-element .status-icon.ci-shield-none { + color: #9B9B9B; +} \ No newline at end of file diff --git a/static/css/directives/ui/tag-signing-display.css b/static/css/directives/ui/tag-signing-display.css new file mode 100644 index 000000000..d05d4608b --- /dev/null +++ b/static/css/directives/ui/tag-signing-display.css @@ -0,0 +1,55 @@ +.tag-signing-display-element { + text-align: center; + display: inline-block; + cursor: default; +} + +.tag-signing-display-element .fa { + font-size: 18px; +} + +.tag-signing-display-element .fa.fa-question-circle { + font-size: 14px; + line-height: 18px; + text-align: center; +} + +.tag-signing-display-element .signing-load-error { + color: #CCC; +} + +.tag-signing-display-element .signing-not-signed { + color: #9B9B9B; +} + +.tag-signing-display-element .signing-valid .okay, +.tag-signing-display-element .signing-valid .expires-soon { + color: #2FC98E; +} + + +.tag-signing-display-element .signing-valid .expires-soon { + position: relative; +} + +.tag-signing-display-element .signing-valid .expires-soon:after { + border-radius: 50%; + width: 6px; + height: 6px; + position: absolute; + bottom: 0px; + right: 0px; + z-index: 1; + display: inline-block; + content: " "; + background-color: #FCA657; +} + + +.tag-signing-display-element .signing-valid .expired { + color: #FCA657; +} + +.tag-signing-display-element .signing-invalid { + color: #D64456; +} \ No newline at end of file diff --git a/static/directives/header-bar.html b/static/directives/header-bar.html index 63c72c91a..713f2bcbb 100644 --- a/static/directives/header-bar.html +++ b/static/directives/header-bar.html @@ -96,12 +96,14 @@ New Robot Account - + -
  • +
  • New Dockerfile Build diff --git a/static/directives/repo-view/repo-panel-builds.html b/static/directives/repo-view/repo-panel-builds.html index f59fb8361..554ae3c54 100644 --- a/static/directives/repo-view/repo-panel-builds.html +++ b/static/directives/repo-view/repo-panel-builds.html @@ -1,12 +1,17 @@
    -

    Repository Builds

    +
    + Builds cannot be performed on this repository because Quay Content Trust is + enabled, which requires that all operations be signed by a user. +
    +
    @@ -78,7 +83,7 @@
    -
    +
    diff --git a/static/directives/repo-view/repo-panel-info.html b/static/directives/repo-view/repo-panel-info.html index ac5c0711f..a0f77fa71 100644 --- a/static/directives/repo-view/repo-panel-info.html +++ b/static/directives/repo-view/repo-panel-info.html @@ -32,7 +32,7 @@
    No builds have been run for this repository.
    -
    +
    Click on the Builds tab to start a new build.
    diff --git a/static/directives/repo-view/repo-panel-settings.html b/static/directives/repo-view/repo-panel-settings.html index 4fb97f37c..1a34cbdae 100644 --- a/static/directives/repo-view/repo-panel-settings.html +++ b/static/directives/repo-view/repo-panel-settings.html @@ -18,6 +18,11 @@
    + +
    + +
    +
  • - + Delete Tags
  • @@ -84,13 +85,17 @@ Tag + Sign + Last Modified Security Scan @@ -123,6 +128,11 @@ + + + @@ -232,14 +242,16 @@ - + Add New Tag Edit Labels - + Delete Tag diff --git a/static/directives/tag-operations-dialog.html b/static/directives/tag-operations-dialog.html index 5febe8702..dcce8f971 100644 --- a/static/directives/tag-operations-dialog.html +++ b/static/directives/tag-operations-dialog.html @@ -125,7 +125,6 @@ The following images and any other images not referenced by a tag will be deleted: -
    ?
    + + + diff --git a/static/js/directives/repo-view/repo-panel-tags.js b/static/js/directives/repo-view/repo-panel-tags.js index 159a61d9a..7bb43555e 100644 --- a/static/js/directives/repo-view/repo-panel-tags.js +++ b/static/js/directives/repo-view/repo-panel-tags.js @@ -39,9 +39,26 @@ angular.module('quay').directive('repoPanelTags', function () { $scope.labelCache = {}; $scope.imageVulnerabilities = {}; + $scope.repoSignatureInfo = null; + $scope.defcon1 = {}; $scope.hasDefcon1 = false; + var loadRepoSignatures = function() { + $scope.repoSignatureError = false; + $scope.repoSignatureInfo = null; + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name + }; + + ApiService.getRepoSignatures(null, params).then(function(resp) { + $scope.repoSignatureInfo = resp; + }, function() { + $scope.repoSignatureInfo = {'error': true}; + }); + }; + var setTagState = function() { if (!$scope.repository || !$scope.selectedTags) { return; } @@ -190,6 +207,7 @@ angular.module('quay').directive('repoPanelTags', function () { // Process each of the tags. setTagState(); + loadRepoSignatures(); }); $scope.loadImageVulnerabilities = function(image_id, imageData) { @@ -244,7 +262,7 @@ angular.module('quay').directive('repoPanelTags', function () { $scope.getImageVulnerabilities = function(image_id) { if (!$scope.repository) { - return + return; } if (!$scope.imageVulnerabilities[image_id]) { diff --git a/static/js/directives/ui/repository-signing-config/repository-signing-config.component.html b/static/js/directives/ui/repository-signing-config/repository-signing-config.component.html new file mode 100644 index 000000000..e041fc27c --- /dev/null +++ b/static/js/directives/ui/repository-signing-config/repository-signing-config.component.html @@ -0,0 +1,65 @@ +
    +
    +
    +
    + Trust and Signing +
    +
    + + + + + +
    + + +
    +

    Content Trust Enabled

    +

    + Content Trust and Signing is enabled on this repository and all tag operations must be signed via Docker Content Trust. +

    +

    + Note that due to this feature being enabled, all UI-based tag operations and all build support is disabled on this repository. +

    + +
    + +
    +

    Content Trust Disabled

    +

    + Content Trust and Signing is disabled on this repository. +

    + +
    +
    +
    +
    + + +
    +

    Click "Enable Trust" to enable content trust on this repository.

    +

    Please note that at this time, having content trust will disable the following + features under the repository: +

      +
    • Any tag operations in the UI (Add Tag, Delete Tag, Restore Tag) +
    • All build triggers and ability to invoke builds +
    +

    +
    + +
    +
    + Warning: Disabling content trust will prevent users from pushing signed + manifests to this repository and will delete all existing signing and trust data. +
    +
    +
    \ No newline at end of file diff --git a/static/js/directives/ui/repository-signing-config/repository-signing-config.component.ts b/static/js/directives/ui/repository-signing-config/repository-signing-config.component.ts new file mode 100644 index 000000000..4c68fd0ef --- /dev/null +++ b/static/js/directives/ui/repository-signing-config/repository-signing-config.component.ts @@ -0,0 +1,44 @@ +import { Input, Component, Inject } from 'ng-metadata/core'; +import { Repository } from '../../../types/common.types'; + +/** + * A component that displays the configuration and options for repository signing. + */ +@Component({ + selector: 'repository-signing-config', + templateUrl: '/static/js/directives/ui/repository-signing-config/repository-signing-config.component.html', +}) +export class RepositorySigningConfigComponent { + @Input('<') public repository: Repository; + + private enableTrustInfo: {[key: string]: string} = null; + private disableTrustInfo: {[key: string]: string} = null; + + constructor (@Inject("ApiService") private ApiService: any) { + + } + + private askChangeTrust(newState: boolean) { + if (newState) { + this.enableTrustInfo = {}; + } else { + this.disableTrustInfo = {}; + } + } + + private changeTrust(newState: boolean, callback: (success: boolean) => void) { + var params = { + 'repository': this.repository.namespace + '/' + this.repository.name, + }; + + var data = { + 'trust_enabled': newState, + }; + + var errorDisplay = this.ApiService.errorDisplay('Could not just change trust', callback); + this.ApiService.changeRepoTrust(data, params).then((resp) => { + this.repository.trust_enabled = newState; + callback(true); + }, errorDisplay); + } +} \ No newline at end of file diff --git a/static/js/directives/ui/tag-operations-dialog.js b/static/js/directives/ui/tag-operations-dialog.js index 3dce5a9ee..4001334e4 100644 --- a/static/js/directives/ui/tag-operations-dialog.js +++ b/static/js/directives/ui/tag-operations-dialog.js @@ -35,6 +35,15 @@ angular.module('quay').directive('tagOperationsDialog', function () { }); }; + $scope.alertOnTrust = function() { + if ($scope.repository.trust_enabled) { + $('#trustEnabledModal').modal('show'); + return true; + } + + return false; + }; + $scope.isAnotherImageTag = function(image, tag) { if (!$scope.repository) { return; } @@ -53,6 +62,9 @@ angular.module('quay').directive('tagOperationsDialog', function () { $scope.createOrMoveTag = function(image, tag) { if (!$scope.repository.can_write) { return; } + if ($scope.alertOnTrust()) { + return; + } $scope.addingTag = true; @@ -77,6 +89,8 @@ angular.module('quay').directive('tagOperationsDialog', function () { }; $scope.deleteMultipleTags = function(tags, callback) { + if (!$scope.repository.can_write) { return; } + var count = tags.length; var perform = function(index) { if (index >= count) { @@ -221,18 +235,30 @@ angular.module('quay').directive('tagOperationsDialog', function () { $scope.actionHandler = { 'askDeleteTag': function(tag) { + if ($scope.alertOnTrust()) { + return; + } + $scope.deleteTagInfo = { 'tag': tag }; }, 'askDeleteMultipleTags': function(tags) { + if ($scope.alertOnTrust()) { + return; + } + $scope.deleteMultipleTagsInfo = { 'tags': tags }; }, 'askAddTag': function(image) { + if ($scope.alertOnTrust()) { + return; + } + $scope.tagToCreate = ''; $scope.toTagImage = image; $scope.addingTag = false; @@ -264,6 +290,10 @@ angular.module('quay').directive('tagOperationsDialog', function () { }, 'askRestoreTag': function(tag, image_id, opt_manifest_digest) { + if ($scope.alertOnTrust()) { + return; + } + if (tag.image_id == image_id) { bootbox.alert('This is the current image for the tag'); return; diff --git a/static/js/directives/ui/tag-signing-display/tag-signing-display.component.html b/static/js/directives/ui/tag-signing-display/tag-signing-display.component.html new file mode 100644 index 000000000..be57ff502 --- /dev/null +++ b/static/js/directives/ui/tag-signing-display/tag-signing-display.component.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/static/js/directives/ui/tag-signing-display/tag-signing-display.component.ts b/static/js/directives/ui/tag-signing-display/tag-signing-display.component.ts new file mode 100644 index 000000000..67a7bc02d --- /dev/null +++ b/static/js/directives/ui/tag-signing-display/tag-signing-display.component.ts @@ -0,0 +1,78 @@ +import { Input, Component, Inject } from 'ng-metadata/core'; +import { ApostilleSignatureDocument, ApostilleTagDocument } from '../../../types/common.types'; +import * as moment from "moment"; + +/** + * A component that displays the signing status of a tag in the repository view. + */ +@Component({ + selector: 'tag-signing-display', + templateUrl: '/static/js/directives/ui/tag-signing-display/tag-signing-display.component.html', +}) +export class TagSigningDisplayComponent { + @Input('<') public tag: any; + @Input('<') public signatures: ApostilleSignatureDocument; + + private signedDigest: string; + private pushedDigest: string; + + constructor(@Inject("$sanitize") private $sanitize: ng.sanitize.ISanitizeService) {} + + private base64ToHex(base64String: string): string { + // Based on: http://stackoverflow.com/questions/39460182/decode-base64-to-hexadecimal-string-with-javascript + var raw = atob(base64String); + var hexString = ''; + for (var i = 0; i < raw.length; ++i) { + var char = raw.charCodeAt(i); + var hex = char.toString(16) + hexString += (hex.length == 2 ? hex : '0' + hex); + } + return hexString; + } + + private expirationStatus(tag: any, signatures: ApostilleSignatureDocument): string { + if (!signatures || !signatures.expiration) { + return 'unknown'; + } + + var expires = moment(signatures.expiration); + var now = moment(); + + if (expires.isSameOrBefore(now)) { + return 'expired'; + } + + var withOneWeek = moment().add('1', 'w'); + if (expires.isSameOrBefore(withOneWeek)) { + return 'expires-soon'; + } + + return 'okay'; + } + + private signingStatus(tag: any, signatures: ApostilleSignatureDocument): string { + if (!tag || !signatures) { + return 'loading'; + } + + if (signatures.error || !signatures.tags) { + return 'error'; + } + + var tag_info = signatures.tags[tag.name]; + if (!tag_info || !tag.manifest_digest) { + return 'not-signed'; + } + + var digest_without_prefix = tag.manifest_digest.substr('sha256:'.length); + var hex_signature = this.base64ToHex(tag_info.hashes['sha256']); + + if (hex_signature == digest_without_prefix) { + return 'valid-signature'; + } else { + this.signedDigest = this.$sanitize(hex_signature); + this.pushedDigest = this.$sanitize(digest_without_prefix); + return 'invaid-signature'; + } + } +} \ No newline at end of file diff --git a/static/js/quay.module.ts b/static/js/quay.module.ts index 91139a7a4..416429622 100644 --- a/static/js/quay.module.ts +++ b/static/js/quay.module.ts @@ -14,6 +14,8 @@ import { VisibilityIndicatorComponent } from './directives/ui/visibility-indicat import { CorTableComponent } from './directives/ui/cor-table/cor-table.component'; import { CorTableColumn } from './directives/ui/cor-table/cor-table-col.component'; import { ChannelIconComponent } from './directives/ui/channel-icon/channel-icon.component'; +import { TagSigningDisplayComponent } from './directives/ui/tag-signing-display/tag-signing-display.component'; +import { RepositorySigningConfigComponent } from './directives/ui/repository-signing-config/repository-signing-config.component'; import { BuildServiceImpl } from './services/build/build.service.impl'; import { AvatarServiceImpl } from './services/avatar/avatar.service.impl'; import { DockerfileServiceImpl } from './services/dockerfile/dockerfile.service.impl'; @@ -44,6 +46,8 @@ import { QuayRequireDirective } from './directives/structural/quay-require/quay- CorTableColumn, ChannelIconComponent, QuayRequireDirective, + TagSigningDisplayComponent, + RepositorySigningConfigComponent, ], providers: [ ViewArrayImpl, diff --git a/static/js/types/common.types.ts b/static/js/types/common.types.ts index 316176a34..803f43595 100644 --- a/static/js/types/common.types.ts +++ b/static/js/types/common.types.ts @@ -79,6 +79,7 @@ export type Repository = { private: boolean; url: string; namespace?: string; + trust_enabled: boolean; } @@ -101,4 +102,29 @@ export type Namespace = { export type Trigger = { id: number; service: any; +}; + +/** + * Represents an apostille signature document, with extra expiration information. + */ +export type ApostilleSignatureDocument = { + // When the signed document expires. + expiration: string + + // Object of information for each tag. + tags: {string: ApostilleTagDocument} + + // If true, an error occurred while trying to load this document. + error: boolean +}; + +/** + * An apostille document containing signatures for a tag. + */ +export type ApostilleTagDocument = { + // The length of the document. + length: number + + // The hashes for the tag. + hashes: {string: string} }; \ No newline at end of file diff --git a/test/data/test.db b/test/data/test.db index c8f6dc912..9cd43ae05 100644 Binary files a/test/data/test.db and b/test/data/test.db differ