From c33ed8f597ca1453822455aee4b3c5f0527ed946 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 16 May 2017 17:07:09 -0400 Subject: [PATCH] Implement updated UI for displaying the signing status of a tag, now that we support multiple delegations The icon now represents the status of the multiple delegations, and we show each delegation in the "Expanded" view. --- endpoints/api/signing.py | 7 +- .../directives/repo-view/repo-panel-tags.css | 6 +- .../css/directives/ui/manifest-label-list.css | 12 ++ .../css/directives/ui/tag-signing-display.css | 156 +++++++++++++++--- .../directives/repo-view/repo-panel-tags.html | 10 +- .../directives/repo-view/repo-panel-tags.js | 8 +- .../tag-signing-display.component.html | 97 ++++++++--- .../tag-signing-display.component.ts | 156 ++++++++++++++---- static/js/types/common.types.ts | 15 +- 9 files changed, 368 insertions(+), 99 deletions(-) diff --git a/endpoints/api/signing.py b/endpoints/api/signing.py index 2758a999d..aa426ff7c 100644 --- a/endpoints/api/signing.py +++ b/endpoints/api/signing.py @@ -27,9 +27,4 @@ class RepositorySignatures(RepositoryParamResource): 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, - 'expiration': expiration - } - + return {'delegations': tuf_metadata_api.get_all_tags_with_expiration(namespace, repository)} diff --git a/static/css/directives/repo-view/repo-panel-tags.css b/static/css/directives/repo-view/repo-panel-tags.css index 7e03596bf..c5ca2543a 100644 --- a/static/css/directives/repo-view/repo-panel-tags.css +++ b/static/css/directives/repo-view/repo-panel-tags.css @@ -43,7 +43,7 @@ .repo-panel-tags-element .image-track-line.start { top: 18px; - height: 25px; + height: 28px; display: block; } @@ -144,6 +144,10 @@ padding-top: 0px; } +.repo-panel-tags-element .signing-delegations-list { + margin-top: 8px; +} + @media (max-width: 1000px) { .repo-panel-tags-element .image-track { display: none; diff --git a/static/css/directives/ui/manifest-label-list.css b/static/css/directives/ui/manifest-label-list.css index 840f1b737..8cf247d42 100644 --- a/static/css/directives/ui/manifest-label-list.css +++ b/static/css/directives/ui/manifest-label-list.css @@ -1,5 +1,17 @@ .manifest-label-list-element { padding-left: 6px; + display: inline-block; + position: relative; +} + +.manifest-label-list-element:before { + content: "\f02c"; + font-family: FontAwesome; + position: absolute; + left: -22px; + top: 0px; + font-size: 15px; + color: #888; } .manifest-label-list-element .none { diff --git a/static/css/directives/ui/tag-signing-display.css b/static/css/directives/ui/tag-signing-display.css index d05d4608b..d51866977 100644 --- a/static/css/directives/ui/tag-signing-display.css +++ b/static/css/directives/ui/tag-signing-display.css @@ -2,10 +2,11 @@ text-align: center; display: inline-block; cursor: default; + position: relative; } .tag-signing-display-element .fa { - font-size: 18px; + font-size: 24px; } .tag-signing-display-element .fa.fa-question-circle { @@ -22,34 +23,145 @@ color: #9B9B9B; } -.tag-signing-display-element .signing-valid .okay, -.tag-signing-display-element .signing-valid .expires-soon { +.tag-signing-display-element .signing-valid.okay-release { color: #2FC98E; } - -.tag-signing-display-element .signing-valid .expires-soon { - position: relative; +.tag-signing-display-element .signing-valid.okay { + color: #5f9dd0; } -.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 { +.tag-signing-display-element .signing-valid.partial-okay { color: #FCA657; } .tag-signing-display-element .signing-invalid { color: #D64456; -} \ No newline at end of file +} + +.tag-signing-display-element .indicator { + position: relative; +} + +.tag-signing-display-element .expiring-soon { + border-radius: 100%; + background-color: #ffe0c4; + + position: absolute; + right: 0px; + bottom: 0px; + width: 8px; + height: 8px; + z-index: 2; +} + +.tag-signing-display-element .expired { + border-radius: 100%; + background-color: #ffcad1; + + position: absolute; + right: 0px; + bottom: 0px; + width: 8px; + height: 8px; + z-index: 3; +} + +.tag-signing-display-element .invalid { + border-radius: 100%; + background-color: #ffcad1; + + position: absolute; + right: 0px; + bottom: 0px; + width: 8px; + height: 8px; + z-index: 4; +} + +.tag-signing-display-element.extended { + display: block; + position: relative; +} + +.tag-signing-display-element.extended .fa { + color: #888 !important; + font-size: 16px; +} + +.tag-signing-display-element.extended .indicator { + font-size: 16px; + position: absolute; + left: -22px; + top: 4px; +} + +.tag-signing-display-element.extended .delegations { + margin: 0px; + padding: 0px; + text-align: left; + padding-left: 6px; + padding-top: 6px; +} + +.tag-signing-display-element.extended .delegations td { + padding: 4px; + border: 0px; +} + +.tag-signing-display-element.extended .delegations .delegation { + padding: 4px; + background-color: #eee; + border-radius: 4px; + font-size: 13px; + padding-right: 6px; + display: inline-block; +} + +.tag-signing-display-element.extended .delegations .delegation:before { + content: "\f00c"; + font-family: FontAwesome; + margin-right: 2px; + margin-left: 2px; + display: inline-block; + font-size: 10px; +} + +.tag-signing-display-element.extended .delegations .delegation.okay { + background-color: #bdf1dd; +} + +.tag-signing-display-element.extended .delegations .delegation.okay:before { + color: #2FC98E; +} + +.tag-signing-display-element.extended .delegations .delegation.warning { + background-color: #ffe0c4; +} + +.tag-signing-display-element.extended .delegations .delegation.warning:before { + content: "\f12a"; + color: #FCA657; +} + +.tag-signing-display-element.extended .delegations .delegation.error { + background-color: #ffcad1; +} + +.tag-signing-display-element.extended .delegations .delegation.error:before { + content: "\f00d"; + color: #D64456; +} + +.tag-signing-display-element.extended .delegations .delegation-name { + font-size: 14px; +} + +.tag-signing-display-element.extended .delegations .delegation-info { + display: inline-block; + white-space: nowrap; + vertical-align: middle; + margin-left: 4px; + font-size: 12px; +} + diff --git a/static/directives/repo-view/repo-panel-tags.html b/static/directives/repo-view/repo-panel-tags.html index 154dcfd97..a3b1d3e5a 100644 --- a/static/directives/repo-view/repo-panel-tags.html +++ b/static/directives/repo-view/repo-panel-tags.html @@ -131,7 +131,7 @@ - + @@ -261,9 +261,15 @@ - + +
+ + +
+ +
diff --git a/static/js/directives/repo-view/repo-panel-tags.js b/static/js/directives/repo-view/repo-panel-tags.js index bcd4c6a15..55d544575 100644 --- a/static/js/directives/repo-view/repo-panel-tags.js +++ b/static/js/directives/repo-view/repo-panel-tags.js @@ -39,7 +39,7 @@ angular.module('quay').directive('repoPanelTags', function () { $scope.labelCache = {}; $scope.imageVulnerabilities = {}; - $scope.repoSignatureInfo = null; + $scope.repoDelegationsInfo = null; $scope.defcon1 = {}; $scope.hasDefcon1 = false; @@ -50,16 +50,16 @@ angular.module('quay').directive('repoPanelTags', function () { } $scope.repoSignatureError = false; - $scope.repoSignatureInfo = null; + $scope.repoDelegationsInfo = null; var params = { 'repository': $scope.repository.namespace + '/' + $scope.repository.name }; ApiService.getRepoSignatures(null, params).then(function(resp) { - $scope.repoSignatureInfo = resp; + $scope.repoDelegationsInfo = resp; }, function() { - $scope.repoSignatureInfo = {'error': true}; + $scope.repoDelegationsInfo = {'error': true}; }); }; 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 index be57ff502..a0d2c761e 100644 --- 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 @@ -1,5 +1,5 @@ - - + + @@ -17,33 +17,80 @@ - - - - - - - + + + - - - - - - - - - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ This tag has not been signed +
+ + + + + +
+ + {{ delegation.delegationName.substr('targets/'.length) }} + +
+ + Expiring soon: {{ delegation.expiration.format("YYYY-MM-DD HH:mm:ss") }} + + Signature expired + + Signature has different hash: {{ delegation.delegationHash.substr(0, 12) }} + +
+
\ 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 index 67a7bc02d..20bc1b41c 100644 --- 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 @@ -1,7 +1,27 @@ import { Input, Component, Inject } from 'ng-metadata/core'; -import { ApostilleSignatureDocument, ApostilleTagDocument } from '../../../types/common.types'; +import { ApostilleDelegationsSet, ApostilleSignatureDocument, ApostilleTagDocument } from '../../../types/common.types'; import * as moment from "moment"; +type tagSigningInfo = { + delegations: delegationInfo[]; + delegationsByName: {[delegationName: string]: delegationInfo}; + + hasExpiringSoon: boolean; + hasExpired: boolean; + hasInvalid: boolean; +} + +type delegationInfo = { + delegationName: string; + delegationHash: string; + expiration: moment.Moment; + hasMatchingHash: boolean; + isExpired: boolean; + isExpiringSoon: boolean +}; + +var RELEASES = 'targets/releases'; + /** * A component that displays the signing status of a tag in the repository view. */ @@ -10,11 +30,11 @@ import * as moment from "moment"; templateUrl: '/static/js/directives/ui/tag-signing-display/tag-signing-display.component.html', }) export class TagSigningDisplayComponent { + @Input('<') public compact: boolean; @Input('<') public tag: any; - @Input('<') public signatures: ApostilleSignatureDocument; + @Input('<') public delegations: ApostilleDelegationsSet; - private signedDigest: string; - private pushedDigest: string; + private cachedSigningInfo: tagSigningInfo | null = null; constructor(@Inject("$sanitize") private $sanitize: ng.sanitize.ISanitizeService) {} @@ -30,49 +50,115 @@ export class TagSigningDisplayComponent { return hexString; } - private expirationStatus(tag: any, signatures: ApostilleSignatureDocument): string { - if (!signatures || !signatures.expiration) { - return 'unknown'; - } + private buildDelegationInfo(tag: any, delegationName: string, delegation: ApostilleSignatureDocument): delegationInfo { + var digest_without_prefix = tag.manifest_digest.substr('sha256:'.length); + var hex_signature = this.base64ToHex(delegation.targets[tag.name].hashes['sha256']); - var expires = moment(signatures.expiration); + var expires = moment(delegation.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'; + return { + 'delegationName': delegationName, + 'hasMatchingHash': digest_without_prefix == hex_signature, + 'expiration': expires, + 'delegationHash': hex_signature, + 'isExpired': expires.isSameOrBefore(now), + 'isExpiringSoon': !expires.isSameOrBefore(now) && expires.isSameOrBefore(withOneWeek), + } } - private signingStatus(tag: any, signatures: ApostilleSignatureDocument): string { - if (!tag || !signatures) { + private buildTagSigningInfo(tag: any, delegationSet: ApostilleDelegationsSet): tagSigningInfo { + var info = { + 'delegations': [], + 'delegationsByName': {}, + 'hasExpired': false, + 'hasExpiringSoon': false, + 'hasInvalid': false, + } + + // Find all delegations containing the tag as a target. + Object.keys(delegationSet.delegations).forEach((delegationName) => { + var delegation = delegationSet.delegations[delegationName]; + if (delegation.targets[tag.name]) { + var delegationInfo = this.buildDelegationInfo(tag, delegationName, delegation) + info.delegations.push(delegationInfo); + info.delegationsByName[delegationName] = delegationInfo; + + if (delegationInfo.isExpired) { + info.hasExpired = true; + } + + if (delegationInfo.isExpiringSoon) { + info.hasExpiringSoon = true; + } + + if (!delegationInfo.hasMatchingHash) { + info.hasInvalid = true; + } + } + }); + + return info; + } + + private getSigningInfo(tag: any, delegationSet: ApostilleDelegationsSet): tagSigningInfo { + if (!this.cachedSigningInfo) { + this.cachedSigningInfo = this.buildTagSigningInfo(tag, delegationSet); + } + return this.cachedSigningInfo; + } + + private signingStatus(tag: any, delegationSet: ApostilleDelegationsSet): string { + if (!tag || !delegationSet) { return 'loading'; } - if (signatures.error || !signatures.tags) { - return 'error'; - } - - var tag_info = signatures.tags[tag.name]; - if (!tag_info || !tag.manifest_digest) { + if (!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'; + if (delegationSet.error) { + return 'error'; } + + // Check if signed at all. + var signingInfo = this.getSigningInfo(tag, delegationSet); + if (!signingInfo.delegations.length) { + return 'not-signed'; + } + + // Check if all delegations are signed and valid. + var allReleasesValid = true; + var oneReleaseValid = false; + + this.cachedSigningInfo.delegations.forEach(function(delegation) { + var isValid = delegation.hasMatchingHash && !delegation.isExpired; + if (isValid) { + oneReleaseValid = true; + } + + allReleasesValid = allReleasesValid && isValid; + }); + + // Check if the special RELEASES target is signed and valid. + var releasesDelegation = this.cachedSigningInfo.delegationsByName[RELEASES]; + if (releasesDelegation && releasesDelegation.hasMatchingHash && !releasesDelegation.isExpired) { + if (allReleasesValid && this.cachedSigningInfo.delegations.length > 1) { + return 'all-signed'; + } else { + return 'release-signed'; + } + } + + if (allReleasesValid) { + return 'non-release-signed'; + } + + if (oneReleaseValid) { + return 'one-valid-signed'; + } + + return 'invalid-signed'; } } \ No newline at end of file diff --git a/static/js/types/common.types.ts b/static/js/types/common.types.ts index f3331cc71..c1cff8c90 100644 --- a/static/js/types/common.types.ts +++ b/static/js/types/common.types.ts @@ -105,6 +105,16 @@ export type Trigger = { }; +/** + * Represents a set of apostille delegations. + */ +export type ApostilleDelegationsSet = { + delegations: {[delegationName: string]: ApostilleSignatureDocument}; + + // The error that occurred, if any. + error: string | null; +}; + /** * Represents an apostille signature document, with extra expiration information. */ @@ -113,10 +123,7 @@ export type ApostilleSignatureDocument = { expiration: string // Object of information for each tag. - tags: {string: ApostilleTagDocument} - - // If true, an error occurred while trying to load this document. - error: boolean + targets: {string: ApostilleTagDocument} };