mirror of https://github.com/portainer/portainer
feat(secrets): add UAC (#1200)
parent
a2b4cd8050
commit
61f652da04
|
@ -80,6 +80,8 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht
|
||||||
resourceControlType = portainer.VolumeResourceControl
|
resourceControlType = portainer.VolumeResourceControl
|
||||||
case "network":
|
case "network":
|
||||||
resourceControlType = portainer.NetworkResourceControl
|
resourceControlType = portainer.NetworkResourceControl
|
||||||
|
case "secret":
|
||||||
|
resourceControlType = portainer.SecretResourceControl
|
||||||
default:
|
default:
|
||||||
httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger)
|
httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger)
|
||||||
return
|
return
|
||||||
|
|
|
@ -106,6 +106,30 @@ func decorateNetworkList(networkData []interface{}, resourceControls []portainer
|
||||||
return decoratedNetworkData, nil
|
return decoratedNetworkData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decorateSecretList loops through all secrets and will decorate any secret with an existing resource control.
|
||||||
|
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
|
||||||
|
func decorateSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||||
|
decoratedSecretData := make([]interface{}, 0)
|
||||||
|
|
||||||
|
for _, secret := range secretData {
|
||||||
|
|
||||||
|
secretObject := secret.(map[string]interface{})
|
||||||
|
if secretObject[secretIdentifier] == nil {
|
||||||
|
return nil, ErrDockerSecretIdentifierNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
secretID := secretObject[secretIdentifier].(string)
|
||||||
|
resourceControl := getResourceControlByResourceID(secretID, resourceControls)
|
||||||
|
if resourceControl != nil {
|
||||||
|
secretObject = decorateObject(secretObject, resourceControl)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoratedSecretData = append(decoratedSecretData, secretObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoratedSecretData, nil
|
||||||
|
}
|
||||||
|
|
||||||
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
|
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
|
||||||
metadata := make(map[string]interface{})
|
metadata := make(map[string]interface{})
|
||||||
metadata["ResourceControl"] = resourceControl
|
metadata["ResourceControl"] = resourceControl
|
||||||
|
|
|
@ -135,3 +135,28 @@ func filterNetworkList(networkData []interface{}, resourceControls []portainer.R
|
||||||
|
|
||||||
return filteredNetworkData, nil
|
return filteredNetworkData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterSecretList loops through all secrets, filters secrets without any resource control (public resources) or with
|
||||||
|
// any resource control giving access to the user (these secrets will be decorated).
|
||||||
|
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
|
||||||
|
func filterSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
|
||||||
|
filteredSecretData := make([]interface{}, 0)
|
||||||
|
|
||||||
|
for _, secret := range secretData {
|
||||||
|
secretObject := secret.(map[string]interface{})
|
||||||
|
if secretObject[secretIdentifier] == nil {
|
||||||
|
return nil, ErrDockerSecretIdentifierNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
secretID := secretObject[secretIdentifier].(string)
|
||||||
|
resourceControl := getResourceControlByResourceID(secretID, resourceControls)
|
||||||
|
if resourceControl == nil {
|
||||||
|
filteredSecretData = append(filteredSecretData, secretObject)
|
||||||
|
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
|
||||||
|
secretObject = decorateObject(secretObject, resourceControl)
|
||||||
|
filteredSecretData = append(filteredSecretData, secretObject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredSecretData, nil
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ErrDockerSecretIdentifierNotFound defines an error raised when Portainer is unable to find a secret identifier
|
||||||
|
ErrDockerSecretIdentifierNotFound = portainer.Error("Docker secret identifier not found")
|
||||||
|
secretIdentifier = "ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
// secretListOperation extracts the response as a JSON object, loop through the secrets array
|
||||||
|
// decorate and/or filter the secrets based on resource controls before rewriting the response
|
||||||
|
func secretListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// SecretList response is a JSON array
|
||||||
|
// https://docs.docker.com/engine/api/v1.28/#operation/SecretList
|
||||||
|
responseArray, err := getResponseAsJSONArray(response)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if executor.operationContext.isAdmin {
|
||||||
|
responseArray, err = decorateSecretList(responseArray, executor.operationContext.resourceControls)
|
||||||
|
} else {
|
||||||
|
responseArray, err = filterSecretList(responseArray, executor.operationContext.resourceControls,
|
||||||
|
executor.operationContext.userID, executor.operationContext.userTeamIDs)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewriteResponse(response, responseArray, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretInspectOperation extracts the response as a JSON object, verify that the user
|
||||||
|
// has access to the secret based on resource control (check are done based on the secretID and optional Swarm service ID)
|
||||||
|
// and either rewrite an access denied response or a decorated secret.
|
||||||
|
func secretInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||||
|
// SecretInspect response is a JSON object
|
||||||
|
// https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect
|
||||||
|
responseObject, err := getResponseAsJSONOBject(response)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if responseObject[secretIdentifier] == nil {
|
||||||
|
return ErrDockerSecretIdentifierNotFound
|
||||||
|
}
|
||||||
|
secretID := responseObject[secretIdentifier].(string)
|
||||||
|
|
||||||
|
resourceControl := getResourceControlByResourceID(secretID, executor.operationContext.resourceControls)
|
||||||
|
if resourceControl != nil {
|
||||||
|
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
|
||||||
|
executor.operationContext.userTeamIDs, resourceControl) {
|
||||||
|
responseObject = decorateObject(responseObject, resourceControl)
|
||||||
|
} else {
|
||||||
|
return rewriteAccessDeniedResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||||
|
}
|
|
@ -62,6 +62,8 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
|
||||||
return p.proxyVolumeRequest(request)
|
return p.proxyVolumeRequest(request)
|
||||||
case strings.HasPrefix(path, "/networks"):
|
case strings.HasPrefix(path, "/networks"):
|
||||||
return p.proxyNetworkRequest(request)
|
return p.proxyNetworkRequest(request)
|
||||||
|
case strings.HasPrefix(path, "/secrets"):
|
||||||
|
return p.proxySecretRequest(request)
|
||||||
case strings.HasPrefix(path, "/swarm"):
|
case strings.HasPrefix(path, "/swarm"):
|
||||||
return p.proxySwarmRequest(request)
|
return p.proxySwarmRequest(request)
|
||||||
default:
|
default:
|
||||||
|
@ -157,12 +159,30 @@ func (p *proxyTransport) proxyNetworkRequest(request *http.Request) (*http.Respo
|
||||||
return p.rewriteOperation(request, networkListOperation)
|
return p.rewriteOperation(request, networkListOperation)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// assume /networks/{name}
|
// assume /networks/{id}
|
||||||
if request.Method == http.MethodGet {
|
if request.Method == http.MethodGet {
|
||||||
return p.rewriteOperation(request, networkInspectOperation)
|
return p.rewriteOperation(request, networkInspectOperation)
|
||||||
}
|
}
|
||||||
volumeID := path.Base(requestPath)
|
networkID := path.Base(requestPath)
|
||||||
return p.restrictedOperation(request, volumeID)
|
return p.restrictedOperation(request, networkID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *proxyTransport) proxySecretRequest(request *http.Request) (*http.Response, error) {
|
||||||
|
switch requestPath := request.URL.Path; requestPath {
|
||||||
|
case "/secrets/create":
|
||||||
|
return p.executeDockerRequest(request)
|
||||||
|
|
||||||
|
case "/secrets":
|
||||||
|
return p.rewriteOperation(request, secretListOperation)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// assume /secrets/{id}
|
||||||
|
if request.Method == http.MethodGet {
|
||||||
|
return p.rewriteOperation(request, secretInspectOperation)
|
||||||
|
}
|
||||||
|
secretID := path.Base(requestPath)
|
||||||
|
return p.restrictedOperation(request, secretID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -401,4 +401,6 @@ const (
|
||||||
VolumeResourceControl
|
VolumeResourceControl
|
||||||
// NetworkResourceControl represents a resource control associated to a Docker network
|
// NetworkResourceControl represents a resource control associated to a Docker network
|
||||||
NetworkResourceControl
|
NetworkResourceControl
|
||||||
|
// SecretResourceControl represents a resource control associated to a Docker secret
|
||||||
|
SecretResourceControl
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,11 +1,17 @@
|
||||||
angular.module('createSecret', [])
|
angular.module('createSecret', [])
|
||||||
.controller('CreateSecretController', ['$scope', '$state', 'Notifications', 'SecretService', 'LabelHelper',
|
.controller('CreateSecretController', ['$scope', '$state', 'Notifications', 'SecretService', 'LabelHelper', 'Authentication', 'ResourceControlService', 'FormValidator',
|
||||||
function ($scope, $state, Notifications, SecretService, LabelHelper) {
|
function ($scope, $state, Notifications, SecretService, LabelHelper, Authentication, ResourceControlService, FormValidator) {
|
||||||
|
|
||||||
$scope.formValues = {
|
$scope.formValues = {
|
||||||
Name: '',
|
Name: '',
|
||||||
Data: '',
|
Data: '',
|
||||||
Labels: [],
|
Labels: [],
|
||||||
encodeSecret: true
|
encodeSecret: true,
|
||||||
|
AccessControlData: new AccessControlFormData()
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.state = {
|
||||||
|
formValidationError: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.addLabel = function() {
|
$scope.addLabel = function() {
|
||||||
|
@ -36,10 +42,38 @@ function ($scope, $state, Notifications, SecretService, LabelHelper) {
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSecret(config) {
|
function validateForm(accessControlData, isAdmin) {
|
||||||
$('#createSecretSpinner').show();
|
$scope.state.formValidationError = '';
|
||||||
SecretService.create(config)
|
var error = '';
|
||||||
|
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
$scope.state.formValidationError = error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.create = function () {
|
||||||
|
$('#createResourceSpinner').show();
|
||||||
|
|
||||||
|
var accessControlData = $scope.formValues.AccessControlData;
|
||||||
|
var userDetails = Authentication.getUserDetails();
|
||||||
|
var isAdmin = userDetails.role === 1 ? true : false;
|
||||||
|
|
||||||
|
if (!validateForm(accessControlData, isAdmin)) {
|
||||||
|
$('#createResourceSpinner').hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var secretConfiguration = prepareConfiguration();
|
||||||
|
SecretService.create(secretConfiguration)
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
|
var secretIdentifier = data.ID;
|
||||||
|
var userId = userDetails.ID;
|
||||||
|
return ResourceControlService.applyResourceControl('secret', secretIdentifier, userId, accessControlData, []);
|
||||||
|
})
|
||||||
|
.then(function success() {
|
||||||
Notifications.success('Secret successfully created');
|
Notifications.success('Secret successfully created');
|
||||||
$state.go('secrets', {}, {reload: true});
|
$state.go('secrets', {}, {reload: true});
|
||||||
})
|
})
|
||||||
|
@ -47,12 +81,7 @@ function ($scope, $state, Notifications, SecretService, LabelHelper) {
|
||||||
Notifications.error('Failure', err, 'Unable to create secret');
|
Notifications.error('Failure', err, 'Unable to create secret');
|
||||||
})
|
})
|
||||||
.finally(function final() {
|
.finally(function final() {
|
||||||
$('#createSecretSpinner').hide();
|
$('#createResourceSpinner').hide();
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
$scope.create = function () {
|
|
||||||
var config = prepareConfiguration();
|
|
||||||
createSecret(config);
|
|
||||||
};
|
};
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -66,6 +66,9 @@
|
||||||
<!-- !labels-input-list -->
|
<!-- !labels-input-list -->
|
||||||
</div>
|
</div>
|
||||||
<!-- !labels-->
|
<!-- !labels-->
|
||||||
|
<!-- access-control -->
|
||||||
|
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
|
||||||
|
<!-- !access-control -->
|
||||||
<!-- actions -->
|
<!-- actions -->
|
||||||
<div class="col-sm-12 form-section-title">
|
<div class="col-sm-12 form-section-title">
|
||||||
Actions
|
Actions
|
||||||
|
@ -74,7 +77,8 @@
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Name || !formValues.Data" ng-click="create()">Create secret</button>
|
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Name || !formValues.Data" ng-click="create()">Create secret</button>
|
||||||
<a type="button" class="btn btn-default btn-sm" ui-sref="secrets">Cancel</a>
|
<a type="button" class="btn btn-default btn-sm" ui-sref="secrets">Cancel</a>
|
||||||
<i id="createSecretSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
<i id="createResourceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||||
|
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- !actions -->
|
<!-- !actions -->
|
||||||
|
|
|
@ -53,3 +53,12 @@
|
||||||
</rd-widget>
|
</rd-widget>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- access-control-panel -->
|
||||||
|
<por-access-control-panel
|
||||||
|
ng-if="secret && applicationState.application.authentication"
|
||||||
|
resource-id="secret.Id"
|
||||||
|
resource-control="secret.ResourceControl"
|
||||||
|
resource-type="'secret'">
|
||||||
|
</por-access-control-panel>
|
||||||
|
<!-- !access-control-panel -->
|
||||||
|
|
|
@ -30,31 +30,44 @@
|
||||||
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
|
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th>
|
||||||
<a ui-sref="secrets" ng-click="order('Name')">
|
<a ng-click="order('Name')">
|
||||||
Name
|
Name
|
||||||
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||||
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th>
|
||||||
<a ui-sref="secrets" ng-click="order('CreatedAt')">
|
<a ng-click="order('CreatedAt')">
|
||||||
Created at
|
Created at
|
||||||
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||||
<span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
<span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
|
<th ng-if="applicationState.application.authentication">
|
||||||
|
<a ng-click="order('ResourceControl.Ownership')">
|
||||||
|
Ownership
|
||||||
|
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||||
|
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr dir-paginate="secret in (state.filteredSecrets = ( secrets | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: pagination_count))">
|
<tr dir-paginate="secret in (state.filteredSecrets = ( secrets | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: pagination_count))">
|
||||||
<td><input type="checkbox" ng-model="secret.Checked" ng-change="selectItem(secret)"/></td>
|
<td><input type="checkbox" ng-model="secret.Checked" ng-change="selectItem(secret)"/></td>
|
||||||
<td><a ui-sref="secret({id: secret.Id})">{{ secret.Name }}</a></td>
|
<td><a ui-sref="secret({id: secret.Id})">{{ secret.Name }}</a></td>
|
||||||
<td>{{ secret.CreatedAt | getisodate }}</td>
|
<td>{{ secret.CreatedAt | getisodate }}</td>
|
||||||
|
<td ng-if="applicationState.application.authentication">
|
||||||
|
<span>
|
||||||
|
<i ng-class="secret.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
|
||||||
|
{{ secret.ResourceControl.Ownership ? secret.ResourceControl.Ownership : secret.ResourceControl.Ownership = 'public' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr ng-if="!secrets">
|
<tr ng-if="!secrets">
|
||||||
<td colspan="3" class="text-center text-muted">Loading...</td>
|
<td colspan="4" class="text-center text-muted">Loading...</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr ng-if="secrets.length == 0">
|
<tr ng-if="secrets.length == 0">
|
||||||
<td colspan="3" class="text-center text-muted">No secrets available.</td>
|
<td colspan="4" class="text-center text-muted">No secrets available.</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -5,4 +5,10 @@ function SecretViewModel(data) {
|
||||||
this.Version = data.Version.Index;
|
this.Version = data.Version.Index;
|
||||||
this.Name = data.Spec.Name;
|
this.Name = data.Spec.Name;
|
||||||
this.Labels = data.Spec.Labels;
|
this.Labels = data.Spec.Labels;
|
||||||
|
|
||||||
|
if (data.Portainer) {
|
||||||
|
if (data.Portainer.ResourceControl) {
|
||||||
|
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue