Merge pull request #2550 from coreos-inc/signing-ui
Basic signing support UI
This commit is contained in:
commit
c8136f2fe5
26 changed files with 478 additions and 22 deletions
|
@ -416,4 +416,5 @@ import endpoints.api.team
|
||||||
import endpoints.api.trigger
|
import endpoints.api.trigger
|
||||||
import endpoints.api.user
|
import endpoints.api.user
|
||||||
import endpoints.api.secscan
|
import endpoints.api.secscan
|
||||||
|
import endpoints.api.signing
|
||||||
|
|
||||||
|
|
|
@ -378,7 +378,7 @@ class Repository(RepositoryParamResource):
|
||||||
'is_organization': repo.namespace_user.organization,
|
'is_organization': repo.namespace_user.organization,
|
||||||
'is_starred': is_starred,
|
'is_starred': is_starred,
|
||||||
'status_token': repo.badge_token if not is_public else '',
|
'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:
|
if stats is not None:
|
||||||
|
|
|
@ -4,9 +4,10 @@ import logging
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from app import tuf_metadata_api
|
from app import tuf_metadata_api
|
||||||
|
from data import model
|
||||||
from endpoints.api import (require_repo_read, path_param,
|
from endpoints.api import (require_repo_read, path_param,
|
||||||
RepositoryParamResource, resource, nickname, show_if,
|
RepositoryParamResource, resource, nickname, show_if,
|
||||||
disallow_for_app_repositories)
|
disallow_for_app_repositories, NotFound)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -21,7 +22,11 @@ class RepositorySignatures(RepositoryParamResource):
|
||||||
@nickname('getRepoSignatures')
|
@nickname('getRepoSignatures')
|
||||||
@disallow_for_app_repositories
|
@disallow_for_app_repositories
|
||||||
def get(self, namespace, repository):
|
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)
|
tag_data, expiration = tuf_metadata_api.get_default_tags_with_expiration(namespace, repository)
|
||||||
return {
|
return {
|
||||||
'tags': tag_data,
|
'tags': tag_data,
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
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 test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
|
||||||
from mock import patch, ANY, MagicMock
|
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'}
|
params = {'repository': 'devtable/repo'}
|
||||||
request_body = {'trust_enabled': trust_enabled}
|
request_body = {'trust_enabled': trust_enabled}
|
||||||
assert expected_body == conduct_api_call(cl, RepositoryTrust, 'POST', params, request_body, expected_status).json
|
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']
|
||||||
|
|
|
@ -39,11 +39,11 @@ REPO_PARAMS = {'repository': 'devtable/someapp'}
|
||||||
(SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'freshuser', 403),
|
(SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'freshuser', 403),
|
||||||
(SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'reader', 403),
|
(SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'reader', 403),
|
||||||
(SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'devtable', 404),
|
(SuperUserRepositoryBuildResource, 'GET', BUILD_PARAMS, None, 'devtable', 404),
|
||||||
|
|
||||||
(RepositorySignatures, 'GET', REPO_PARAMS, {}, 'freshuser', 403),
|
(RepositorySignatures, 'GET', REPO_PARAMS, {}, 'freshuser', 403),
|
||||||
(RepositorySignatures, 'GET', REPO_PARAMS, {}, 'reader', 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}, None, 403),
|
||||||
(RepositoryTrust, 'POST', REPO_PARAMS, {'trust_enabled': True}, 'freshuser', 403),
|
(RepositoryTrust, 'POST', REPO_PARAMS, {'trust_enabled': True}, 'freshuser', 403),
|
||||||
(RepositoryTrust, 'POST', REPO_PARAMS, {'trust_enabled': True}, 'reader', 403),
|
(RepositoryTrust, 'POST', REPO_PARAMS, {'trust_enabled': True}, 'reader', 403),
|
||||||
|
|
|
@ -30,7 +30,7 @@ def tags_equal(expected, actual):
|
||||||
return expected == actual
|
return expected == actual
|
||||||
|
|
||||||
@pytest.mark.parametrize('targets,expected', [
|
@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'}),
|
({'bad': 'tags'}, {'tags': {'bad': 'tags'}, 'expiration': 'expires'}),
|
||||||
({}, {'tags': {}, 'expiration': 'expires'}),
|
({}, {'tags': {}, 'expiration': 'expires'}),
|
||||||
(None, {'tags': None, 'expiration': 'expires'}), # API returns None on exceptions
|
(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:
|
with patch('endpoints.api.signing.tuf_metadata_api') as mock_tuf:
|
||||||
mock_tuf.get_default_tags_with_expiration.return_value = (targets, 'expires')
|
mock_tuf.get_default_tags_with_expiration.return_value = (targets, 'expires')
|
||||||
with client_with_identity('devtable', client) as cl:
|
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)
|
assert tags_equal(expected, conduct_api_call(cl, RepositorySignatures, 'GET', params, None, 200).json)
|
||||||
|
|
|
@ -576,6 +576,11 @@ def populate_database(minimal=False, with_storage=False):
|
||||||
(1, [(1, [], 'v5.0'), (1, [], 'v6.0')], None)],
|
(1, [(1, [], 'v5.0'), (1, [], 'v6.0')], None)],
|
||||||
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',
|
publicrepo = __generate_repository(with_storage, new_user_2, 'publicrepo',
|
||||||
'Public repository pullable by the world.', True,
|
'Public repository pullable by the world.', True,
|
||||||
[], (10, [], 'latest'))
|
[], (10, [], 'latest'))
|
||||||
|
|
|
@ -1170,6 +1170,10 @@ a:focus {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.co-table thead td.unorderable-col:after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.co-table thead td.current:after {
|
.co-table thead td.current:after {
|
||||||
content: "\f175";
|
content: "\f175";
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
|
|
|
@ -81,6 +81,10 @@
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.repo-panel-tags-element .signing-col {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.repo-panel-tags-element .security-scan-col span {
|
.repo-panel-tags-element .security-scan-col span {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
@ -179,4 +183,9 @@
|
||||||
.repo-panel-tags-element .co-checked-actions .btn .text {
|
.repo-panel-tags-element .co-checked-actions .btn .text {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-panel-tags-element .disabled-option,
|
||||||
|
.repo-panel-tags-element .disabled-option a {
|
||||||
|
color: #ccc;
|
||||||
}
|
}
|
16
static/css/directives/ui/repository-signing-config.css
Normal file
16
static/css/directives/ui/repository-signing-config.css
Normal file
|
@ -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;
|
||||||
|
}
|
55
static/css/directives/ui/tag-signing-display.css
Normal file
55
static/css/directives/ui/tag-signing-display.css
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -96,12 +96,14 @@
|
||||||
<i class="fa ci-robot"></i> New Robot Account
|
<i class="fa ci-robot"></i> New Robot Account
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li role="presentation" class="divider" ng-if="currentPageContext.repository && currentPageContext.repository.can_write"></li>
|
<li role="presentation" class="divider" ng-if="currentPageContext.repository && currentPageContext.repository.can_write && !currentPageContext.repository.trust_enabled"></li>
|
||||||
<li role="presentation" class="dropdown-header"
|
<li role="presentation" class="dropdown-header"
|
||||||
ng-if="currentPageContext.repository && currentPageContext.repository.can_write">
|
ng-if="currentPageContext.repository && currentPageContext.repository.can_write &&
|
||||||
|
!currentPageContext.repository.trust_enabled">
|
||||||
Repository {{ currentPageContext.repository.namespace }}/{{ currentPageContext.repository.name }}
|
Repository {{ currentPageContext.repository.namespace }}/{{ currentPageContext.repository.name }}
|
||||||
</li>
|
</li>
|
||||||
<li ng-if="currentPageContext.repository && currentPageContext.repository.can_write">
|
<li ng-if="currentPageContext.repository && currentPageContext.repository.can_write &&
|
||||||
|
!currentPageContext.repository.trust_enabled">
|
||||||
<a ng-click="startBuild()">
|
<a ng-click="startBuild()">
|
||||||
<i class="fa fa-tasks"></i> New Dockerfile Build
|
<i class="fa fa-tasks"></i> New Dockerfile Build
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
<div class="repo-panel-builds-element">
|
<div class="repo-panel-builds-element">
|
||||||
<div class="feedback-bar" feedback="feedback"></div>
|
<div class="feedback-bar" feedback="feedback"></div>
|
||||||
<div class="tab-header-controls">
|
<div class="tab-header-controls">
|
||||||
<button class="btn btn-primary" ng-click="showNewBuildDialog()">
|
<button class="btn btn-primary" ng-click="showNewBuildDialog()" ng-if="!repository.trust_enabled">
|
||||||
<i class="fa fa-play"></i> Start New Build
|
<i class="fa fa-play"></i> Start New Build
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="tab-header">Repository Builds</h3>
|
<h3 class="tab-header">Repository Builds</h3>
|
||||||
|
|
||||||
|
<div class="co-alert co-alert-info" ng-if="repository.trust_enabled">
|
||||||
|
Builds cannot be performed on this repository because Quay Content Trust is
|
||||||
|
enabled, which requires that all operations be signed by a user.
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Builds -->
|
<!-- Builds -->
|
||||||
<div class="co-panel" id="repoBuilds">
|
<div class="co-panel" id="repoBuilds">
|
||||||
<!-- Builds header controls -->
|
<!-- Builds header controls -->
|
||||||
|
@ -78,7 +83,7 @@
|
||||||
</div> <!-- /Builds -->
|
</div> <!-- /Builds -->
|
||||||
|
|
||||||
<!-- Build Triggers -->
|
<!-- Build Triggers -->
|
||||||
<div class="co-panel" ng-if="repository.can_admin && TriggerService.getTypes().length" id="repoBuildTriggers">
|
<div class="co-panel" ng-if="repository.can_admin && TriggerService.getTypes().length && !repository.trust_enabled" id="repoBuildTriggers">
|
||||||
<!-- Builds header controls -->
|
<!-- Builds header controls -->
|
||||||
<div class="co-panel-heading">
|
<div class="co-panel-heading">
|
||||||
<i class="fa fa-flash"></i>
|
<i class="fa fa-flash"></i>
|
||||||
|
|
|
@ -32,7 +32,7 @@
|
||||||
<!-- No Builds -->
|
<!-- No Builds -->
|
||||||
<div class="empty" ng-if="builds && !builds.length">
|
<div class="empty" ng-if="builds && !builds.length">
|
||||||
<div class="empty-primary-msg">No builds have been run for this repository.</div>
|
<div class="empty-primary-msg">No builds have been run for this repository.</div>
|
||||||
<div class="empty-secondary-msg" ng-if="repository.can_write">
|
<div class="empty-secondary-msg" ng-if="repository.can_write && !repository.trust_enabled">
|
||||||
Click on the <i class="fa fa-tasks" style="margin-left: 6px"></i> Builds tab to start a new build.
|
Click on the <i class="fa fa-tasks" style="margin-left: 6px"></i> Builds tab to start a new build.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -18,6 +18,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Signing and Trust -->
|
||||||
|
<div ng-if="repository.kind == 'image'">
|
||||||
|
<repository-signing-config repository="repository"></repository-signing-config>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Events and Notifications -->
|
<!-- Events and Notifications -->
|
||||||
<div ng-if="repository.kind == 'image'">
|
<div ng-if="repository.kind == 'image'">
|
||||||
<div class="repository-events-table" repository="repository"
|
<div class="repository-events-table" repository="repository"
|
||||||
|
|
|
@ -52,7 +52,8 @@
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li ng-if="repository.can_write">
|
<li ng-if="repository.can_write">
|
||||||
<a ng-click="askDeleteMultipleTags(checkedTags.checked)">
|
<a ng-click="askDeleteMultipleTags(checkedTags.checked)"
|
||||||
|
ng-class="repository.trust_enabled ? 'disabled-option' : ''">
|
||||||
<i class="fa fa-times"></i><span class="text">Delete Tags</span>
|
<i class="fa fa-times"></i><span class="text">Delete Tags</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -84,13 +85,17 @@
|
||||||
<td ng-class="tablePredicateClass('name', options.predicate, options.reverse)">
|
<td ng-class="tablePredicateClass('name', options.predicate, options.reverse)">
|
||||||
<a ng-click="orderBy('name')">Tag</a>
|
<a ng-click="orderBy('name')">Tag</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="hidden-xs unorderable-col"
|
||||||
|
style="width: 46px;"
|
||||||
|
quay-require="['SIGNING']"
|
||||||
|
ng-if="repository.trust_enabled">Sign
|
||||||
|
</td>
|
||||||
<td class="hidden-xs"
|
<td class="hidden-xs"
|
||||||
ng-class="tablePredicateClass('last_modified_datetime', options.predicate, options.reverse)"
|
ng-class="tablePredicateClass('last_modified_datetime', options.predicate, options.reverse)"
|
||||||
style="width: 140px;">
|
style="width: 140px;">
|
||||||
<a ng-click="orderBy('last_modified_datetime')">Last Modified</a>
|
<a ng-click="orderBy('last_modified_datetime')">Last Modified</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden-xs"
|
<td class="hidden-xs"
|
||||||
ng-class="tablePredicateClass('security_scanned', options.predicate, options.reverse)"
|
|
||||||
style="width: 200px;"
|
style="width: 200px;"
|
||||||
quay-require="['SECURITY_SCANNER']">
|
quay-require="['SECURITY_SCANNER']">
|
||||||
Security Scan
|
Security Scan
|
||||||
|
@ -123,6 +128,11 @@
|
||||||
<tr ng-class="expandedView ? 'expanded-view': ''">
|
<tr ng-class="expandedView ? 'expanded-view': ''">
|
||||||
<td><span class="cor-checkable-item" controller="checkedTags" item="tag"></span></td>
|
<td><span class="cor-checkable-item" controller="checkedTags" item="tag"></span></td>
|
||||||
<td class="co-flowing-col"><span class="tag-span"><span bo-text="tag.name"></span></span></td>
|
<td class="co-flowing-col"><span class="tag-span"><span bo-text="tag.name"></span></span></td>
|
||||||
|
<td class="signing-col hidden-xs"
|
||||||
|
quay-require="['SIGNING']"
|
||||||
|
ng-if="repository.trust_enabled">
|
||||||
|
<tag-signing-display tag="tag" signatures="repoSignatureInfo"></tag-signing-display>
|
||||||
|
</td>
|
||||||
<td class="hidden-xs">
|
<td class="hidden-xs">
|
||||||
<span bo-if="tag.last_modified" data-title="{{ tag.last_modified | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}" bs-tooltip>
|
<span bo-if="tag.last_modified" data-title="{{ tag.last_modified | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}" bs-tooltip>
|
||||||
<span am-time-ago="tag.last_modified"></span>
|
<span am-time-ago="tag.last_modified"></span>
|
||||||
|
@ -232,14 +242,16 @@
|
||||||
<td class="options-col">
|
<td class="options-col">
|
||||||
<span bo-if="repository.can_write">
|
<span bo-if="repository.can_write">
|
||||||
<span class="cor-options-menu">
|
<span class="cor-options-menu">
|
||||||
<span class="cor-option" option-click="askAddTag(tag)">
|
<span class="cor-option" option-click="askAddTag(tag)"
|
||||||
|
ng-class="repository.trust_enabled ? 'disabled-option' : ''">
|
||||||
<i class="fa fa-plus"></i> Add New Tag
|
<i class="fa fa-plus"></i> Add New Tag
|
||||||
</span>
|
</span>
|
||||||
<span class="cor-option" option-click="showLabelEditor(tag)"
|
<span class="cor-option" option-click="showLabelEditor(tag)"
|
||||||
ng-if="tag.manifest_digest">
|
ng-if="tag.manifest_digest">
|
||||||
<i class="fa fa-tags"></i> Edit Labels
|
<i class="fa fa-tags"></i> Edit Labels
|
||||||
</span>
|
</span>
|
||||||
<span class="cor-option" option-click="askDeleteTag(tag.name)">
|
<span class="cor-option" option-click="askDeleteTag(tag.name)"
|
||||||
|
ng-class="repository.trust_enabled ? 'disabled-option' : ''">
|
||||||
<i class="fa fa-times"></i> Delete Tag
|
<i class="fa fa-times"></i> Delete Tag
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -125,7 +125,6 @@
|
||||||
The following images and any other images not referenced by a tag will be deleted:
|
The following images and any other images not referenced by a tag will be deleted:
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Restore Tag Confirm -->
|
<!-- Restore Tag Confirm -->
|
||||||
<div class="cor-confirm-dialog"
|
<div class="cor-confirm-dialog"
|
||||||
|
@ -144,4 +143,19 @@
|
||||||
image-id="restoreTagInfo.image_id"
|
image-id="restoreTagInfo.image_id"
|
||||||
manifest-digest="restoreTagInfo.manifest_digest"></span>?
|
manifest-digest="restoreTagInfo.manifest_digest"></span>?
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Trust Enabled Dialog -->
|
||||||
|
<div class="modal fade" id="trustEnabledModal">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
Cannot execute with trust enabled
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
The selected operation cannot be performed on this repository because Quay Content Trust is
|
||||||
|
enabled, which requires that all operations be signed by a user.
|
||||||
|
</div>
|
||||||
|
</div><!-- /.modal-content -->
|
||||||
|
</div><!-- /.modal-dialog -->
|
||||||
|
</div><!-- /.modal -->
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -39,9 +39,26 @@ angular.module('quay').directive('repoPanelTags', function () {
|
||||||
$scope.labelCache = {};
|
$scope.labelCache = {};
|
||||||
|
|
||||||
$scope.imageVulnerabilities = {};
|
$scope.imageVulnerabilities = {};
|
||||||
|
$scope.repoSignatureInfo = null;
|
||||||
|
|
||||||
$scope.defcon1 = {};
|
$scope.defcon1 = {};
|
||||||
$scope.hasDefcon1 = false;
|
$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() {
|
var setTagState = function() {
|
||||||
if (!$scope.repository || !$scope.selectedTags) { return; }
|
if (!$scope.repository || !$scope.selectedTags) { return; }
|
||||||
|
|
||||||
|
@ -190,6 +207,7 @@ angular.module('quay').directive('repoPanelTags', function () {
|
||||||
|
|
||||||
// Process each of the tags.
|
// Process each of the tags.
|
||||||
setTagState();
|
setTagState();
|
||||||
|
loadRepoSignatures();
|
||||||
});
|
});
|
||||||
|
|
||||||
$scope.loadImageVulnerabilities = function(image_id, imageData) {
|
$scope.loadImageVulnerabilities = function(image_id, imageData) {
|
||||||
|
@ -244,7 +262,7 @@ angular.module('quay').directive('repoPanelTags', function () {
|
||||||
|
|
||||||
$scope.getImageVulnerabilities = function(image_id) {
|
$scope.getImageVulnerabilities = function(image_id) {
|
||||||
if (!$scope.repository) {
|
if (!$scope.repository) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$scope.imageVulnerabilities[image_id]) {
|
if (!$scope.imageVulnerabilities[image_id]) {
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
<div class="repository-signing-config-element" quay-require="['SIGNING']">
|
||||||
|
<div class="repository-events-table-element">
|
||||||
|
<div class="co-panel">
|
||||||
|
<div class="co-panel-heading">
|
||||||
|
<i class="fa ci-shield-check-full"></i> Trust and Signing
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i class="fa status-icon"
|
||||||
|
ng-class="{'ci-shield-check-outline': $ctrl.repository.trust_enabled, 'ci-shield-none': !$ctrl.repository.trust_enabled}"></i>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div ng-if="$ctrl.repository.trust_enabled">
|
||||||
|
<h4>Content Trust Enabled</h4>
|
||||||
|
<p>
|
||||||
|
Content Trust and Signing is enabled on this repository and all tag operations must be signed via Docker Content Trust.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Note that due to this feature being enabled, all UI-based tag operations and all build support is <strong>disabled on this repository</strong>.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-danger" ng-click="$ctrl.askChangeTrust(false)">Disable Content Trust</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ng-if="!$ctrl.repository.trust_enabled">
|
||||||
|
<h4>Content Trust Disabled</h4>
|
||||||
|
<p>
|
||||||
|
Content Trust and Signing is disabled on this repository.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-default" ng-click="$ctrl.askChangeTrust(true)">Enable Content Trust</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change trust dialogs -->
|
||||||
|
<div class="cor-confirm-dialog"
|
||||||
|
dialog-context="$ctrl.enableTrustInfo"
|
||||||
|
dialog-action="$ctrl.changeTrust(true, callback)"
|
||||||
|
dialog-title="Enable Content Trust"
|
||||||
|
dialog-action-title="Enable Trust">
|
||||||
|
<p>Click "Enable Trust" to enable content trust on this repository.</p>
|
||||||
|
<p>Please note that at this time, having content trust will <strong>disable</strong> the following
|
||||||
|
features under the repository:
|
||||||
|
<ul>
|
||||||
|
<li>Any tag operations in the UI (Add Tag, Delete Tag, Restore Tag)
|
||||||
|
<li>All build triggers and ability to invoke builds
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cor-confirm-dialog"
|
||||||
|
dialog-context="$ctrl.disableTrustInfo"
|
||||||
|
dialog-action="$ctrl.changeTrust(false, callback)"
|
||||||
|
dialog-title="Disable Content Trust"
|
||||||
|
dialog-action-title="Disable Trust and Delete Data">
|
||||||
|
<div class="co-alert co-alert-warning">
|
||||||
|
<strong>Warning:</strong> Disabling content trust will prevent users from pushing signed
|
||||||
|
manifests to this repository and will <strong>delete all existing signing and trust data</strong>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
$scope.isAnotherImageTag = function(image, tag) {
|
||||||
if (!$scope.repository) { return; }
|
if (!$scope.repository) { return; }
|
||||||
|
|
||||||
|
@ -53,6 +62,9 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
||||||
|
|
||||||
$scope.createOrMoveTag = function(image, tag) {
|
$scope.createOrMoveTag = function(image, tag) {
|
||||||
if (!$scope.repository.can_write) { return; }
|
if (!$scope.repository.can_write) { return; }
|
||||||
|
if ($scope.alertOnTrust()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$scope.addingTag = true;
|
$scope.addingTag = true;
|
||||||
|
|
||||||
|
@ -77,6 +89,8 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.deleteMultipleTags = function(tags, callback) {
|
$scope.deleteMultipleTags = function(tags, callback) {
|
||||||
|
if (!$scope.repository.can_write) { return; }
|
||||||
|
|
||||||
var count = tags.length;
|
var count = tags.length;
|
||||||
var perform = function(index) {
|
var perform = function(index) {
|
||||||
if (index >= count) {
|
if (index >= count) {
|
||||||
|
@ -221,18 +235,30 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
||||||
|
|
||||||
$scope.actionHandler = {
|
$scope.actionHandler = {
|
||||||
'askDeleteTag': function(tag) {
|
'askDeleteTag': function(tag) {
|
||||||
|
if ($scope.alertOnTrust()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$scope.deleteTagInfo = {
|
$scope.deleteTagInfo = {
|
||||||
'tag': tag
|
'tag': tag
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
'askDeleteMultipleTags': function(tags) {
|
'askDeleteMultipleTags': function(tags) {
|
||||||
|
if ($scope.alertOnTrust()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$scope.deleteMultipleTagsInfo = {
|
$scope.deleteMultipleTagsInfo = {
|
||||||
'tags': tags
|
'tags': tags
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
'askAddTag': function(image) {
|
'askAddTag': function(image) {
|
||||||
|
if ($scope.alertOnTrust()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$scope.tagToCreate = '';
|
$scope.tagToCreate = '';
|
||||||
$scope.toTagImage = image;
|
$scope.toTagImage = image;
|
||||||
$scope.addingTag = false;
|
$scope.addingTag = false;
|
||||||
|
@ -264,6 +290,10 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
||||||
},
|
},
|
||||||
|
|
||||||
'askRestoreTag': function(tag, image_id, opt_manifest_digest) {
|
'askRestoreTag': function(tag, image_id, opt_manifest_digest) {
|
||||||
|
if ($scope.alertOnTrust()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (tag.image_id == image_id) {
|
if (tag.image_id == image_id) {
|
||||||
bootbox.alert('This is the current image for the tag');
|
bootbox.alert('This is the current image for the tag');
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
<span class="tag-signing-display-element">
|
||||||
|
<span ng-switch on="$ctrl.signingStatus($ctrl.tag, $ctrl.signatures)">
|
||||||
|
<!-- Loading -->
|
||||||
|
<span ng-switch-when="loading">
|
||||||
|
<span class="cor-loader-inline"></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<span class="signing-load-error" ng-switch-when="error"
|
||||||
|
data-title="Could not load signing information" bs-tooltip>
|
||||||
|
<i class="fa fa-question-circle"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Not Signed -->
|
||||||
|
<span class="signing-not-signed" ng-switch-when="not-signed"
|
||||||
|
data-title="This tag has not been signed" bs-tooltip>
|
||||||
|
<i class="fa shield-icon ci-shield-none"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Signature Valid -->
|
||||||
|
<span class="signing-valid" ng-switch-when="valid-signature">
|
||||||
|
<span ng-switch on="$ctrl.expirationStatus($ctrl.tag, $ctrl.signatures)">
|
||||||
|
<!-- But expired -->
|
||||||
|
<span class="expired" ng-switch-when="expired"
|
||||||
|
data-title="This tag has a matching, but expired signature" bs-tooltip>
|
||||||
|
<i class="fa shield-icon ci-shield-invalid-outline"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Expires soon -->
|
||||||
|
<span class="expires-soon" ng-switch-when="expires-soon"
|
||||||
|
data-title="This tag has a valid and matching signature, but it is expiring soon on {{ $ctrl.signatures.expiration }}" bs-tooltip>
|
||||||
|
<i class="fa shield-icon ci-shield-check-outline"></i>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Okay -->
|
||||||
|
<span class="okay" ng-switch-when="okay"
|
||||||
|
data-title="This tag has a valid and matching signature" bs-tooltip>
|
||||||
|
<i class="fa shield-icon ci-shield-check-outline"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Signature Invalid -->
|
||||||
|
<span class="signing-invalid" ng-switch-when="invalid-signature"
|
||||||
|
data-title="The signed digest for this tag does not match the one pushed.<br><br>Signed: {{ this.signedDigest.substr(0, 12) }}<br>Pushed: {{ this.pushedDigest.substr(0, 12) }}" data-html="true" bs-tooltip>
|
||||||
|
<i class="fa shield-icon ci-shield-invalid-outline"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,6 +14,8 @@ import { VisibilityIndicatorComponent } from './directives/ui/visibility-indicat
|
||||||
import { CorTableComponent } from './directives/ui/cor-table/cor-table.component';
|
import { CorTableComponent } from './directives/ui/cor-table/cor-table.component';
|
||||||
import { CorTableColumn } from './directives/ui/cor-table/cor-table-col.component';
|
import { CorTableColumn } from './directives/ui/cor-table/cor-table-col.component';
|
||||||
import { ChannelIconComponent } from './directives/ui/channel-icon/channel-icon.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 { BuildServiceImpl } from './services/build/build.service.impl';
|
||||||
import { AvatarServiceImpl } from './services/avatar/avatar.service.impl';
|
import { AvatarServiceImpl } from './services/avatar/avatar.service.impl';
|
||||||
import { DockerfileServiceImpl } from './services/dockerfile/dockerfile.service.impl';
|
import { DockerfileServiceImpl } from './services/dockerfile/dockerfile.service.impl';
|
||||||
|
@ -44,6 +46,8 @@ import { QuayRequireDirective } from './directives/structural/quay-require/quay-
|
||||||
CorTableColumn,
|
CorTableColumn,
|
||||||
ChannelIconComponent,
|
ChannelIconComponent,
|
||||||
QuayRequireDirective,
|
QuayRequireDirective,
|
||||||
|
TagSigningDisplayComponent,
|
||||||
|
RepositorySigningConfigComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ViewArrayImpl,
|
ViewArrayImpl,
|
||||||
|
|
|
@ -79,6 +79,7 @@ export type Repository = {
|
||||||
private: boolean;
|
private: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
|
trust_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -101,4 +102,29 @@ export type Namespace = {
|
||||||
export type Trigger = {
|
export type Trigger = {
|
||||||
id: number;
|
id: number;
|
||||||
service: any;
|
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}
|
||||||
};
|
};
|
Binary file not shown.
Reference in a new issue