Compare commits

...

13 Commits

Author SHA1 Message Date
MaximeBajeux
57f8c77e3c feat(registries): merge back and front features 2021-03-17 04:51:53 +01:00
MaximeBajeux
bc11177ad4 Merge remote-tracking branch 'origin/feat/CE-493/update-docker-swarm-kubernetes-sidebars' into feat/CE/487-registry-access-control-merge 2021-03-16 17:13:06 +01:00
MaximeBajeux
0a7295be2f Merge branch 'feat/CE/487-registry-access-control' of github.com:portainer/portainer into feat/CE/487-registry-access-control 2021-03-16 17:08:00 +01:00
MaximeBajeux
eecb0c0c6e fix(migrations): use not deprecated fields 2021-03-16 17:05:56 +01:00
MaximeBajeux
69b07ca800 feat(registries): Registry access control 2021-03-16 17:05:56 +01:00
MaximeBajeux
c8999c743a fix(migrations): use not deprecated fields 2021-03-15 17:53:09 +01:00
MaximeBajeux
f37f0858dc feat(registries): Registry access control 2021-03-15 17:48:20 +01:00
alice groux
7f5c08ef64 feat(app/registries): implement access management view for docker and kubernetes registries 2021-03-15 16:27:47 +01:00
alice groux
85bd4f3901 feat(app/registries): improve registriesdatatable 2021-03-15 15:11:48 +01:00
Alice Groux
5a7889dd1f feat(docker/registries): remove the registry usage information panel (#4919) 2021-03-12 19:54:19 +01:00
alice groux
bd58991c2c feat(app/registries): implement access management for endpoints registries (wip) 2021-03-12 16:38:30 +01:00
alice groux
5f4b375bab feat(app/registries): improve sidebars 2021-03-12 13:47:13 +01:00
alice groux
3d937d7afc feat(app/registries): update sidebar and add endpoint registries view for Docker and Kubernetes 2021-03-11 17:22:21 +01:00
36 changed files with 650 additions and 109 deletions

View File

@@ -1,8 +1,11 @@
package migrator
import (
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/errors"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/stackutils"
)
@@ -38,3 +41,38 @@ func (m *Migrator) updateStackResourceControlToDB27() error {
return nil
}
func (m *Migrator) updateRegistriesToDB27() error {
registries, err := m.registryService.Registries()
if err != nil {
return err
}
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
for _, registry := range registries {
userIDs := []portainer.UserID{}
for id := range registry.UserAccessPolicies {
userIDs = append(userIDs, portainer.UserID(id))
}
teamIDs := []portainer.TeamID{}
for id := range registry.TeamAccessPolicies {
teamIDs = append(teamIDs, portainer.TeamID(id))
}
resourceControl := authorization.NewRestrictedResourceControl(
fmt.Sprintf("%d-%d", int(registry.ID), int(endpoint.ID)),
portainer.RegistryResourceControl,
userIDs,
teamIDs,
)
m.resourceControlService.CreateResourceControl(resourceControl)
}
}
return nil
}

View File

@@ -356,6 +356,11 @@ func (m *Migrator) Migrate() error {
if err != nil {
return err
}
err = m.updateRegistriesToDB27()
if err != nil {
return err
}
}
return m.versionService.StoreDBVersion(portainer.DBVersion)

View File

@@ -1,6 +1,7 @@
package registries
import (
"fmt"
"net/http"
"github.com/gorilla/mux"
@@ -8,6 +9,7 @@ import (
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
)
func hideFields(registry *portainer.Registry) {
@@ -47,3 +49,69 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithoutRegistry)))
return h
}
func (handler *Handler) userIsAdmin(userID portainer.UserID) (bool, error) {
user, err := handler.DataStore.User().User(userID)
if err != nil {
return false, err
}
isAdmin := user.Role == portainer.AdministratorRole
return isAdmin, nil
}
func (handler *Handler) userIsAdminOrEndpointAdmin(user *portainer.User, endpointID portainer.EndpointID) (bool, error) {
isAdmin := user.Role == portainer.AdministratorRole
return isAdmin, nil
}
func (handler *Handler) userCanCreateRegistry(securityContext *security.RestrictedRequestContext, endpointID portainer.EndpointID) (bool, error) {
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return false, err
}
return handler.userIsAdminOrEndpointAdmin(user, endpointID)
}
func (handler *Handler) userCanAccessRegistry(securityContext *security.RestrictedRequestContext, endpointID portainer.EndpointID, resourceControl *portainer.ResourceControl) (bool, error) {
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return false, err
}
userTeamIDs := make([]portainer.TeamID, 0)
for _, membership := range securityContext.UserMemberships {
userTeamIDs = append(userTeamIDs, membership.TeamID)
}
if resourceControl != nil && authorization.UserCanAccessResource(securityContext.UserID, userTeamIDs, resourceControl) {
return true, nil
}
return handler.userIsAdminOrEndpointAdmin(user, endpointID)
}
func (handler *Handler) computeRegistryResourceControlID(registryID portainer.RegistryID, endpointID portainer.EndpointID) string {
return fmt.Sprintf("%d-%d", int(registryID), int(endpointID))
}
func (handler *Handler) filterAndDecorateRegistries(registries []portainer.Registry, resourceControls []portainer.ResourceControl, endpointID portainer.EndpointID) []portainer.Registry {
if endpointID == 0 {
return registries
}
filteredRegistries := make([]portainer.Registry, 0, len(registries))
for _, registry := range registries {
for _, resourceControl := range resourceControls {
if resourceControl.ResourceID == handler.computeRegistryResourceControlID(registry.ID, endpointID) {
registry.ResourceControl = &resourceControl
filteredRegistries = append(filteredRegistries, registry)
}
}
}
return filteredRegistries
}

View File

@@ -93,17 +93,17 @@ func (payload *registryConfigurePayload) Validate(r *http.Request) error {
// @failure 500 "Server error"
// @router /registries/{id}/configure [post]
func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
payload := &registryConfigurePayload{}
err := payload.Validate(r)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
}
payload := &registryConfigurePayload{}
err = payload.Validate(r)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}

View File

@@ -9,6 +9,7 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/authorization"
)
type registryCreatePayload struct {
@@ -78,7 +79,20 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *
err = handler.DataStore.Registry().CreateRegistry(registry)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the registry inside the database", err}
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create registry", err}
}
endpoints, err := handler.DataStore.Endpoint().Endpoints()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints information inside the database", err}
}
for _, endpoint := range endpoints {
resourceControl := authorization.NewAdministratorsOnlyResourceControl(handler.computeRegistryResourceControlID(registry.ID, endpoint.ID), portainer.RegistryResourceControl)
err = handler.DataStore.ResourceControl().CreateResourceControl(resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control inside the database", err}
}
}
hideFields(registry)

View File

@@ -28,7 +28,7 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
}
_, err = handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == errors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}
} else if err != nil {
@@ -40,5 +40,17 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the registry from the database", err}
}
endpoints, err := handler.DataStore.Endpoint().Endpoints()
for _, endpoint := range endpoints {
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(handler.computeRegistryResourceControlID(registry.ID, endpoint.ID), portainer.RegistryResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the registry", err}
}
err = handler.DataStore.ResourceControl().DeleteResourceControl(resourceControl.ID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the resource control associated to the registry", err}
}
}
return response.Empty(w)
}

View File

@@ -10,6 +10,7 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api/http/security"
)
// @id RegistryInspect
@@ -44,6 +45,35 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", errors.ErrEndpointAccessDenied}
}
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err}
}
if endpointID != 0 || !securityContext.IsAdmin {
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(handler.computeRegistryResourceControlID(registry.ID, portainer.EndpointID(endpointID)), portainer.RegistryResourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the registry", err}
}
access, err := handler.userCanAccessRegistry(securityContext, portainer.EndpointID(endpointID), resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate registry access", err}
}
if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
}
if resourceControl != nil {
registry.ResourceControl = resourceControl
}
}
hideFields(registry)
return response.JSON(w, registry)
}

View File

@@ -4,8 +4,11 @@ import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
)
// @id RegistryList
@@ -21,21 +24,45 @@ import (
// @failure 500 "Server error"
// @router /registries [get]
func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
}
registries, err := handler.DataStore.Registry().Registries()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
}
resourceControls, err := handler.DataStore.ResourceControl().ResourceControls()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
}
filteredRegistries := security.FilterRegistries(registries, securityContext)
registries = handler.filterAndDecorateRegistries(registries, resourceControls, portainer.EndpointID(endpointID))
for idx := range filteredRegistries {
hideFields(&filteredRegistries[idx])
if !securityContext.IsAdmin {
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user information from the database", err}
}
userTeamIDs := make([]portainer.TeamID, 0)
for _, membership := range securityContext.UserMemberships {
userTeamIDs = append(userTeamIDs, membership.TeamID)
}
registries = authorization.FilterAuthorizedRegistries(registries, user, userTeamIDs)
}
return response.JSON(w, filteredRegistries)
for idx := range registries {
hideFields(&registries[idx])
}
return response.JSON(w, registries)
}

View File

@@ -52,12 +52,6 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
}
var payload registryUpdatePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}
@@ -65,6 +59,12 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err}
}
var payload registryUpdatePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
if payload.Name != nil {
registry.Name = *payload.Name
}
@@ -102,14 +102,6 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
}
}
if payload.UserAccessPolicies != nil {
registry.UserAccessPolicies = payload.UserAccessPolicies
}
if payload.TeamAccessPolicies != nil {
registry.TeamAccessPolicies = payload.TeamAccessPolicies
}
err = handler.DataStore.Registry().UpdateRegistry(registry.ID, registry)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err}

View File

@@ -134,6 +134,19 @@ func DecorateCustomTemplates(templates []portainer.CustomTemplate, resourceContr
return templates
}
// FilterAuthorizedRegistries returns a list of decorated registries filtered through resource control access checks.
func FilterAuthorizedRegistries(registries []portainer.Registry, user *portainer.User, userTeamIDs []portainer.TeamID) []portainer.Registry {
authorizedRegistries := make([]portainer.Registry, 0)
for _, registry := range registries {
if registry.ResourceControl != nil && UserCanAccessResource(user.ID, userTeamIDs, registry.ResourceControl) {
authorizedRegistries = append(authorizedRegistries, registry)
}
}
return authorizedRegistries
}
// FilterAuthorizedStacks returns a list of decorated stacks filtered through resource control access checks.
func FilterAuthorizedStacks(stacks []portainer.Stack, user *portainer.User, userTeamIDs []portainer.TeamID) []portainer.Stack {
authorizedStacks := make([]portainer.Stack, 0)

View File

@@ -508,8 +508,12 @@ type (
Password string `json:"Password,omitempty" example:"registry_password"`
ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"`
Gitlab GitlabRegistryData `json:"Gitlab"`
UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
ResourceControl *ResourceControl `json:"ResourceControl"`
// Deprecated fields
// Deprecated in DBVersion == 26
UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
// Deprecated fields
// Deprecated in DBVersion == 18
@@ -546,7 +550,7 @@ type (
// List of Docker resources that will inherit this access control
SubResourceIDs []string `json:"SubResourceIds" example:"617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08"`
// Type of Docker resource. Valid values are: 1- container, 2 -service
// 3 - volume, 4 - secret, 5 - stack, 6 - config or 7 - custom template
// 3 - volume, 4 - secret, 5 - stack, 6 - config, 7 - custom template or 8 - registry
Type ResourceControlType `json:"Type" example:"1"`
UserAccesses []UserResourceAccess `json:"UserAccesses" example:""`
TeamAccesses []TeamResourceAccess `json:"TeamAccesses" example:""`
@@ -1487,6 +1491,8 @@ const (
ConfigResourceControl
// CustomTemplateResourceControl represents a resource control associated to a custom template
CustomTemplateResourceControl
// RegistryResourceControl represents a resource control associated to a registry
RegistryResourceControl
)
const (

View File

@@ -591,6 +591,26 @@ angular.module('portainer.docker', ['portainer.app']).config([
},
};
const registries = {
name: 'docker.registries',
url: '/registries',
views: {
'content@': {
component: 'endpointRegistriesView',
},
},
};
const registryAccess = {
name: 'docker.registries.access',
url: '/:id/access',
views: {
'content@': {
component: 'dockerRegistryAccessView',
},
},
};
$stateRegistryProvider.register(configs);
$stateRegistryProvider.register(config);
$stateRegistryProvider.register(configCreation);
@@ -641,5 +661,7 @@ angular.module('portainer.docker', ['portainer.app']).config([
$stateRegistryProvider.register(volumeBrowse);
$stateRegistryProvider.register(volumeCreation);
$stateRegistryProvider.register(dockerFeaturesConfiguration);
$stateRegistryProvider.register(registries);
$stateRegistryProvider.register(registryAccess);
},
]);

View File

@@ -38,14 +38,34 @@
<li class="sidebar-list" ng-if="$ctrl.swarmManagement">
<a ui-sref="docker.swarm({endpointId: $ctrl.endpointId})" ui-sref-active="active">Swarm <span class="menu-icon fa fa-object-group fa-fw"></span></a>
<div class="sidebar-sublist" ng-if="$ctrl.adminAccess && ['docker.featuresConfiguration', 'docker.swarm'].includes($ctrl.currentRouteName)">
<div
class="sidebar-sublist"
ng-if="$ctrl.adminAccess && ['docker.featuresConfiguration', 'docker.registries', 'docker.registries.access', 'docker.swarm'].includes($ctrl.currentRouteName)"
>
<a ui-sref="docker.featuresConfiguration({endpointId: $ctrl.endpointId})" ui-sref-active="active">Setup</a>
</div>
<div
class="sidebar-sublist"
ng-if="$ctrl.adminAccess && ['docker.registries', 'docker.registries.access', 'docker.featuresConfiguration', 'docker.swarm'].includes($ctrl.currentRouteName)"
>
<a ui-sref="docker.registries({endpointId: $ctrl.endpointId})" ui-sref-active="active">Registries</a>
</div>
</li>
<li class="sidebar-list" ng-if="$ctrl.standaloneManagement">
<a ui-sref="docker.host({endpointId: $ctrl.endpointId})" ui-sref-active="active">Host <span class="menu-icon fa fa-th fa-fw"></span></a>
<div class="sidebar-sublist" ng-if="$ctrl.adminAccess && ['docker.featuresConfiguration', 'docker.host'].includes($ctrl.currentRouteName)">
<div
class="sidebar-sublist"
ng-if="$ctrl.adminAccess && ['docker.featuresConfiguration', 'docker.registries', 'docker.registries.access', 'docker.host'].includes($ctrl.currentRouteName)"
>
<a ui-sref="docker.featuresConfiguration({endpointId: $ctrl.endpointId})" ui-sref-active="active">Setup</a>
</div>
<div
class="sidebar-sublist"
ng-if="$ctrl.adminAccess && ['docker.registries', 'docker.registries.access', 'docker.featuresConfiguration', 'docker.host'].includes($ctrl.currentRouteName)"
>
<a ui-sref="docker.registries({endpointId: $ctrl.endpointId})" ui-sref-active="active">Registries</a>
</div>
</li>

View File

@@ -0,0 +1,42 @@
<rd-header>
<rd-header-title title-text="Registry access"></rd-header-title>
<rd-header-content> <a ui-sref="portainer.registries">Registries</a> &gt; {{ ctrl.registry.Name }} &gt; Access management </rd-header-content>
</rd-header>
<div class="row" ng-if="ctrl.registry">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-plug" title-text="Registry"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Name</td>
<td>
{{ ctrl.registry.Name }}
</td>
</tr>
<tr>
<td>URL</td>
<td>
{{ ctrl.registry.URL }}
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<!-- access-control-panel -->
<por-access-control-panel
ng-if="ctrl.registry"
resource-id="ctrl.registry.ResourceControl.ResourceId"
resource-control="ctrl.registry.ResourceControl"
resource-type="'registry'"
disable-ownership-change="false"
>
</por-access-control-panel>
<!-- !access-control-panel -->

View File

@@ -0,0 +1,8 @@
angular.module('portainer.docker').component('dockerRegistryAccessView', {
templateUrl: './registryAccess.html',
controller: 'DockerRegistryAccessController',
controllerAs: 'ctrl',
bindings: {
$transition$: '<',
},
});

View File

@@ -0,0 +1,32 @@
import { RegistryViewModel } from '../../../../portainer/models/registry';
class DockerRegistryAccessController {
/* @ngInject */
constructor($async, Notifications, EndpointProvider, RegistryService) {
this.$async = $async;
this.Notifications = Notifications;
this.EndpointProvider = EndpointProvider;
this.RegistryService = RegistryService;
}
$onInit() {
return this.$async(async () => {
try {
this.state = {
actionInProgress: false,
};
const endpointId = this.EndpointProvider.currentEndpoint().Id;
const registry = await this.RegistryService.registry(endpointId, this.$transition$.params().id);
this.registry = new RegistryViewModel(registry);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve registry details');
} finally {
this.state.viewReady = true;
}
});
}
}
export default DockerRegistryAccessController;
angular.module('portainer.docker').controller('DockerRegistryAccessController', DockerRegistryAccessController);

View File

@@ -247,6 +247,26 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([
},
};
const registries = {
name: 'kubernetes.registries',
url: '/registries',
views: {
'content@': {
component: 'endpointRegistriesView',
},
},
};
const registriesAccess = {
name: 'kubernetes.registries.access',
url: '/:id/access',
views: {
'content@': {
component: 'kubernetesRegistryAccessView',
},
},
};
$stateRegistryProvider.register(kubernetes);
$stateRegistryProvider.register(applications);
$stateRegistryProvider.register(applicationCreation);
@@ -270,5 +290,7 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([
$stateRegistryProvider.register(resourcePoolAccess);
$stateRegistryProvider.register(volumes);
$stateRegistryProvider.register(volume);
$stateRegistryProvider.register(registries);
$stateRegistryProvider.register(registriesAccess);
},
]);

View File

@@ -15,7 +15,30 @@
</li>
<li class="sidebar-list">
<a ui-sref="kubernetes.cluster({endpointId: $ctrl.endpointId})" ui-sref-active="active">Cluster <span class="menu-icon fa fa-server fa-fw"></span></a>
<div class="sidebar-sublist" ng-if="$ctrl.adminAccess && ($ctrl.currentState === 'kubernetes.cluster' || $ctrl.currentState === 'portainer.endpoints.endpoint.kubernetesConfig')">
<div
class="sidebar-sublist"
ng-if="
$ctrl.adminAccess &&
($ctrl.currentState === 'kubernetes.cluster' ||
$ctrl.currentState === 'portainer.endpoints.endpoint.kubernetesConfig' ||
$ctrl.currentState === 'kubernetes.registries' ||
$ctrl.currentState === 'kubernetes.registries.access')
"
>
<a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: $ctrl.endpointId})" ui-sref-active="active">Setup</a>
</div>
<div
class="sidebar-sublist"
ng-if="
$ctrl.adminAccess &&
($ctrl.currentState === 'kubernetes.cluster' ||
$ctrl.currentState === 'kubernetes.registries' ||
$ctrl.currentState === 'kubernetes.registries.access' ||
$ctrl.currentState === 'portainer.endpoints.endpoint.kubernetesConfig')
"
>
<a ui-sref="kubernetes.registries({endpointId: $ctrl.endpointId})" ui-sref-active="active">Registries</a>
</div>
</li>

View File

@@ -1,11 +1,9 @@
<rd-header>
<rd-header-title title-text="Registry access"></rd-header-title>
<rd-header-content>
<a ui-sref="portainer.registries">Registries</a> &gt; <a ui-sref="portainer.registries.registry({id: registry.Id})">{{ registry.Name }}</a> &gt; Access management
</rd-header-content>
<rd-header-content> <a ui-sref="portainer.registries">Registries</a> &gt; {{ ctrl.registry.Name }} &gt; Access management </rd-header-content>
</rd-header>
<div class="row" ng-if="registry">
<div class="row" ng-if="ctrl.registry">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-plug" title-text="Registry"></rd-widget-header>
@@ -15,13 +13,13 @@
<tr>
<td>Name</td>
<td>
{{ registry.Name }}
{{ ctrl.registry.Name }}
</td>
</tr>
<tr>
<td>URL</td>
<td>
{{ registry.URL }}
{{ ctrl.registry.URL }}
</td>
</tr>
</tbody>
@@ -31,5 +29,11 @@
</div>
</div>
<por-access-management ng-if="registry" access-controlled-entity="registry" entity-type="registry" action-in-progress="state.actionInProgress" update-access="updateAccess">
<por-access-management
ng-if="ctrl.registry"
access-controlled-entity="registry"
entity-type="registry"
action-in-progress="ctrl.state.actionInProgress"
update-access="ctrl.updateAccess"
>
</por-access-management>

View File

@@ -0,0 +1,8 @@
angular.module('portainer.kubernetes').component('kubernetesRegistryAccessView', {
templateUrl: './registryAccess.html',
controller: 'KubernetesRegistryAccessController',
controllerAs: 'ctrl',
bindings: {
$transition$: '<',
},
});

View File

@@ -0,0 +1,42 @@
import { RegistryViewModel } from '../../../../portainer/models/registry';
class KubernetesRegistryAccessController {
/* @ngInject */
constructor($async, Notifications, RegistryService) {
this.$async = $async;
this.Notifications = Notifications;
this.RegistryService = RegistryService;
}
updateAccess() {
this.state.actionInProgress = true;
this.RegistryService.updateRegistry(this.registry)
.then(() => {
this.Notifications.success('Access successfully updated');
this.reload();
})
.catch((err) => {
this.state.actionInProgress = false;
this.Notifications.error('Failure', err, 'Unable to update accesses');
});
}
$onInit() {
return this.$async(async () => {
try {
this.state = {
actionInProgress: false,
};
const registry = await this.RegistryService.registry(this.$transition$.params().id);
this.registry = new RegistryViewModel(registry);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve registry details');
} finally {
this.state.viewReady = true;
}
});
}
}
export default KubernetesRegistryAccessController;
angular.module('portainer.kubernetes').controller('KubernetesRegistryAccessController', KubernetesRegistryAccessController);

View File

@@ -304,17 +304,6 @@ angular.module('portainer.app', ['portainer.oauth']).config([
},
};
var registryAccess = {
name: 'portainer.registries.registry.access',
url: '/access',
views: {
'content@': {
templateUrl: './views/registries/access/registryAccess.html',
controller: 'RegistryAccessController',
},
},
};
var settings = {
name: 'portainer.settings',
url: '/settings',
@@ -423,7 +412,6 @@ angular.module('portainer.app', ['portainer.oauth']).config([
$stateRegistryProvider.register(initAdmin);
$stateRegistryProvider.register(registries);
$stateRegistryProvider.register(registry);
$stateRegistryProvider.register(registryAccess);
$stateRegistryProvider.register(registryCreation);
$stateRegistryProvider.register(settings);
$stateRegistryProvider.register(settingsAuthentication);

View File

@@ -4,7 +4,7 @@
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="actionBar" ng-if="$ctrl.accessManagement">
<div class="actionBar" ng-if="$ctrl.accessManagement && !$ctrl.endpointType">
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
@@ -27,7 +27,7 @@
<thead>
<tr>
<th>
<span class="md-checkbox" ng-if="$ctrl.accessManagement">
<span class="md-checkbox" ng-if="$ctrl.accessManagement && !$ctrl.endpointType">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
</span>
@@ -53,19 +53,22 @@
ng-class="{ active: item.Checked }"
>
<td>
<span class="md-checkbox" ng-if="$ctrl.accessManagement">
<span class="md-checkbox" ng-if="$ctrl.accessManagement && !$ctrl.endpointType">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" />
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="portainer.registries.registry({id: item.Id})" ng-if="$ctrl.accessManagement">{{ item.Name }}</a>
<span ng-if="!$ctrl.accessManagement">{{ item.Name }}</span>
<a ui-sref="portainer.registries.registry({id: item.Id})" ng-if="$ctrl.accessManagement && !$ctrl.endpointType">{{ item.Name }}</a>
<span ng-if="!$ctrl.accessManagement && $ctrl.endpointType">{{ item.Name }}</span>
<span ng-if="$ctrl.accessManagement && $ctrl.endpointType">{{ item.Name }}</span>
<span ng-if="item.Authentication" style="margin-left: 5px;" class="label label-info image-tag">authentication-enabled</span>
</td>
<td>
{{ item.URL }}
</td>
<td>
<a ui-sref="portainer.registries.registry.access({id: item.Id})" ng-if="$ctrl.accessManagement"> <i class="fa fa-users" aria-hidden="true"></i> Manage access </a>
<a ng-click="$ctrl.redirectToManageAccess(item)" ng-if="$ctrl.accessManagement && $ctrl.endpointType">
<i class="fa fa-users" aria-hidden="true"></i> Manage access
</a>
<span class="text-muted space-left" style="cursor: pointer;" data-toggle="tooltip" title="This feature is available in Portainer Business Edition">
<i class="fa fa-search" aria-hidden="true"></i> Browse</span
>

View File

@@ -1,6 +1,6 @@
angular.module('portainer.app').component('registriesDatatable', {
templateUrl: './registriesDatatable.html',
controller: 'GenericDatatableController',
controller: 'RegistriesDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
@@ -11,5 +11,6 @@ angular.module('portainer.app').component('registriesDatatable', {
accessManagement: '<',
removeAction: '<',
canBrowse: '<',
endpointType: '<',
},
});

View File

@@ -0,0 +1,83 @@
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
angular.module('portainer.docker').controller('RegistriesDatatableController', [
'$scope',
'$controller',
'$state',
'DatatableService',
function ($scope, $controller, $state, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
this.allowSelection = function (item) {
return item.Id;
};
this.goToRegistry = function (item) {
if (
this.endpointType === PortainerEndpointTypes.KubernetesLocalEnvironment ||
this.endpointType === PortainerEndpointTypes.AgentOnKubernetesEnvironment ||
this.endpointType === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment
) {
$state.go('kubernetes.registries.registry', { id: item.Id });
} else if (
this.endpointType === PortainerEndpointTypes.DockerEnvironment ||
this.endpointType === PortainerEndpointTypes.AgentOnDockerEnvironment ||
this.endpointType === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment
) {
$state.go('docker.registries.registry', { id: item.Id });
} else {
$state.go('portainer.registries.registry', { id: item.Id });
}
};
this.redirectToManageAccess = function (item) {
if (
this.endpointType === PortainerEndpointTypes.KubernetesLocalEnvironment ||
this.endpointType === PortainerEndpointTypes.AgentOnKubernetesEnvironment ||
this.endpointType === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment
) {
$state.go('kubernetes.registries.access', { id: item.Id });
} else {
$state.go('docker.registries.access', { id: item.Id });
}
};
this.$onInit = function () {
this.setDefaults();
this.prepareTableFromDataset();
this.state.orderBy = this.orderBy;
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) {
this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy;
}
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
}
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) {
this.filters = storedFilters;
}
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
var storedColumnVisibility = DatatableService.getColumnVisibilitySettings(this.tableKey);
if (storedColumnVisibility !== null) {
this.columnVisibility = storedColumnVisibility;
}
};
},
]);

View File

@@ -13,6 +13,7 @@ export function RegistryViewModel(data) {
this.AuthorizedTeams = data.AuthorizedTeams;
this.UserAccessPolicies = data.UserAccessPolicies;
this.TeamAccessPolicies = data.TeamAccessPolicies;
this.ResourceControl = data.ResourceControl;
this.Checked = false;
this.Gitlab = data.Gitlab;
}

View File

@@ -7,6 +7,7 @@ export const ResourceControlTypeString = Object.freeze({
STACK: 'stack',
VOLUME: 'volume',
CUSTOM_TEMPLATE: 'custom-template',
REGISTRY: 'registry',
});
/**
@@ -21,4 +22,5 @@ export const ResourceControlTypeInt = Object.freeze({
STACK: 6,
CONFIG: 7,
CUSTOM_TEMPLATE: 8,
REGISTRY: 9,
});

View File

@@ -8,8 +8,8 @@ angular.module('portainer.app').factory('Registries', [
{},
{
create: { method: 'POST', ignoreLoadingBar: true },
query: { method: 'GET', isArray: true },
get: { method: 'GET', params: { id: '@id' } },
query: { method: 'GET', params: { endpointId: '@endpointId' }, isArray: true },
get: { method: 'GET', params: { id: '@id', action: '', endpointId: '@endpointId' } },
update: { method: 'PUT', params: { id: '@id' } },
updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } },
remove: { method: 'DELETE', params: { id: '@id' } },

View File

@@ -14,10 +14,10 @@ angular.module('portainer.app').factory('RegistryService', [
'use strict';
var service = {};
service.registries = function () {
service.registries = function (endpointId) {
var deferred = $q.defer();
Registries.query()
Registries.query({ endpointId: endpointId })
.$promise.then(function success(data) {
var registries = data.map(function (item) {
return new RegistryViewModel(item);
@@ -31,10 +31,10 @@ angular.module('portainer.app').factory('RegistryService', [
return deferred.promise;
};
service.registry = function (id) {
service.registry = function (endpointId, id) {
var deferred = $q.defer();
Registries.get({ id: id })
Registries.get({ id: id, endpointId: endpointId })
.$promise.then(function success(data) {
var registry = new RegistryViewModel(data);
deferred.resolve(registry);

View File

@@ -0,0 +1,23 @@
<rd-header>
<rd-header-title title-text="Environment registries">
<a data-toggle="tooltip" title="Refresh" ui-sref="docker.registries" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Manage registry access inside this environment</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<registries-datatable
title-text="Registries"
title-icon="fa-database"
dataset="ctrl.registries"
table-key="registries"
order-by="Name"
access-management="ctrl.isAdmin"
remove-action="removeAction"
can-browse="canBrowse"
endpoint-type="ctrl.endpointType"
></registries-datatable>
</div>
</div>

View File

@@ -0,0 +1,8 @@
angular.module('portainer.app').component('endpointRegistriesView', {
templateUrl: './registries.html',
controller: 'EndpointRegistriesController',
controllerAs: 'ctrl',
bindings: {
$transition$: '<',
},
});

View File

@@ -0,0 +1,47 @@
class EndpointRegistriesController {
/* @ngInject */
constructor($async, Notifications, EndpointProvider, Authentication, RegistryService) {
this.$async = $async;
this.Notifications = Notifications;
this.EndpointProvider = EndpointProvider;
this.Authentication = Authentication;
this.RegistryService = RegistryService;
this.getRegistriesAsync = this.getRegistriesAsync.bind(this);
}
async getRegistriesAsync() {
try {
this.registries = await this.RegistryService.registries(this.endpointId);
} catch (err) {
this.Notifications.Error('Failure', err, 'Unable to retrieve registries');
}
}
getRegistries() {
return this.$async(this.getRegistriesAsync);
}
$onInit() {
return this.$async(async () => {
this.state = {
viewReady: false,
};
try {
const endpoint = this.EndpointProvider.currentEndpoint();
this.endpointType = endpoint.Type;
this.endpointId = endpoint.Id;
await this.getRegistries();
this.isAdmin = this.Authentication.isAdmin();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve registries');
} finally {
this.state.viewReady = true;
}
});
}
}
export default EndpointRegistriesController;
angular.module('portainer.app').controller('EndpointRegistriesController', EndpointRegistriesController);

View File

@@ -1,34 +0,0 @@
angular.module('portainer.app').controller('RegistryAccessController', [
'$scope',
'$state',
'$transition$',
'RegistryService',
'Notifications',
function ($scope, $state, $transition$, RegistryService, Notifications) {
$scope.updateAccess = function () {
$scope.state.actionInProgress = true;
RegistryService.updateRegistry($scope.registry)
.then(() => {
Notifications.success('Access successfully updated');
$state.reload();
})
.catch((err) => {
$scope.state.actionInProgress = false;
Notifications.error('Failure', err, 'Unable to update accesses');
});
};
function initView() {
$scope.state = { actionInProgress: false };
RegistryService.registry($transition$.params().id)
.then(function success(data) {
$scope.registry = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve registry details');
});
}
initView();
},
]);

View File

@@ -33,7 +33,7 @@ angular.module('portainer.app').controller('RegistryController', [
function initView() {
var registryID = $transition$.params().id;
RegistryService.registry(registryID)
RegistryService.registry(0, registryID)
.then(function success(data) {
$scope.registry = data;
})

View File

@@ -7,15 +7,6 @@
<rd-header-content>Registry management</rd-header-content>
</rd-header>
<information-panel title-text="Registry usage">
<span class="small">
<p class="text-muted">
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
DockerHub credentials and registries can only be used with Docker endpoints at the time.
</p>
</span>
</information-panel>
<div class="row" ng-if="dockerhub && isAdmin">
<div class="col-sm-12">
<rd-widget>

View File

@@ -72,7 +72,7 @@ angular.module('portainer.app').controller('RegistriesController', [
function initView() {
$q.all({
registries: RegistryService.registries(),
registries: RegistryService.registries(0),
dockerhub: DockerHubService.dockerhub(),
})
.then(function success(data) {