diff --git a/api/http/handler/resourcecontrols/resourcecontrol_create.go b/api/http/handler/resourcecontrols/resourcecontrol_create.go index cf943b678..e57476ed5 100644 --- a/api/http/handler/resourcecontrols/resourcecontrol_create.go +++ b/api/http/handler/resourcecontrols/resourcecontrol_create.go @@ -78,6 +78,8 @@ func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Req switch payload.Type { case "container": resourceControlType = portainer.ContainerResourceControl + case "container-group": + resourceControlType = portainer.ContainerGroupResourceControl case "service": resourceControlType = portainer.ServiceResourceControl case "volume": diff --git a/api/http/proxy/factory/azure.go b/api/http/proxy/factory/azure.go index 27b8a26f8..84c9c6495 100644 --- a/api/http/proxy/factory/azure.go +++ b/api/http/proxy/factory/azure.go @@ -8,13 +8,13 @@ import ( "github.com/portainer/portainer/api/http/proxy/factory/azure" ) -func newAzureProxy(endpoint *portainer.Endpoint) (http.Handler, error) { +func newAzureProxy(endpoint *portainer.Endpoint, dataStore portainer.DataStore) (http.Handler, error) { remoteURL, err := url.Parse(azureAPIBaseURL) if err != nil { return nil, err } proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) - proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials) + proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials, dataStore, endpoint) return proxy, nil } diff --git a/api/http/proxy/factory/azure/access_control.go b/api/http/proxy/factory/azure/access_control.go new file mode 100644 index 000000000..9974bfca1 --- /dev/null +++ b/api/http/proxy/factory/azure/access_control.go @@ -0,0 +1,149 @@ +package azure + +import ( + "log" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" +) + +func (transport *Transport) createAzureRequestContext(request *http.Request) (*azureRequestContext, error) { + var err error + + tokenData, err := security.RetrieveTokenData(request) + if err != nil { + return nil, err + } + + resourceControls, err := transport.dataStore.ResourceControl().ResourceControls() + if err != nil { + return nil, err + } + + context := &azureRequestContext{ + isAdmin: true, + userID: tokenData.ID, + resourceControls: resourceControls, + } + + if tokenData.Role != portainer.AdministratorRole { + context.isAdmin = false + + teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return nil, err + } + + userTeamIDs := make([]portainer.TeamID, 0) + for _, membership := range teamMemberships { + userTeamIDs = append(userTeamIDs, membership.TeamID) + } + context.userTeamIDs = userTeamIDs + } + + return context, nil +} + +func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} { + if object["Portainer"] == nil { + object["Portainer"] = make(map[string]interface{}) + } + + portainerMetadata := object["Portainer"].(map[string]interface{}) + portainerMetadata["ResourceControl"] = resourceControl + return object +} + +func (transport *Transport) createPrivateResourceControl( + resourceIdentifier string, + resourceType portainer.ResourceControlType, + userID portainer.UserID) (*portainer.ResourceControl, error) { + + resourceControl := authorization.NewPrivateResourceControl(resourceIdentifier, resourceType, userID) + + err := transport.dataStore.ResourceControl().CreateResourceControl(resourceControl) + if err != nil { + log.Printf("[ERROR] [http,proxy,azure,transport] [message: unable to persist resource control] [resource: %s] [err: %s]", resourceIdentifier, err) + return nil, err + } + + return resourceControl, nil +} + +func (transport *Transport) userCanDeleteContainerGroup(request *http.Request, context *azureRequestContext) bool { + if context.isAdmin { + return true + } + resourceIdentifier := request.URL.Path + resourceControl := transport.findResourceControl(resourceIdentifier, context) + return authorization.UserCanAccessResource(context.userID, context.userTeamIDs, resourceControl) +} + +func (transport *Transport) decorateContainerGroups(containerGroups []interface{}, context *azureRequestContext) []interface{} { + decoratedContainerGroups := make([]interface{}, 0) + + for _, containerGroup := range containerGroups { + containerGroup = transport.decorateContainerGroup(containerGroup.(map[string]interface{}), context) + decoratedContainerGroups = append(decoratedContainerGroups, containerGroup) + } + + return decoratedContainerGroups +} + +func (transport *Transport) decorateContainerGroup(containerGroup map[string]interface{}, context *azureRequestContext) map[string]interface{} { + containerGroupId, ok := containerGroup["id"].(string) + if ok { + resourceControl := transport.findResourceControl(containerGroupId, context) + if resourceControl != nil { + containerGroup = decorateObject(containerGroup, resourceControl) + } + } else { + log.Printf("[WARN] [http,proxy,azure,decorate] [message: unable to find resource id property in container group]") + } + + return containerGroup +} + +func (transport *Transport) filterContainerGroups(containerGroups []interface{}, context *azureRequestContext) []interface{} { + filteredContainerGroups := make([]interface{}, 0) + + for _, containerGroup := range containerGroups { + userCanAccessResource := false + containerGroup := containerGroup.(map[string]interface{}) + portainerObject, ok := containerGroup["Portainer"].(map[string]interface{}) + if ok { + resourceControl, ok := portainerObject["ResourceControl"].(*portainer.ResourceControl) + if ok { + userCanAccessResource = authorization.UserCanAccessResource(context.userID, context.userTeamIDs, resourceControl) + } + } + + if context.isAdmin || userCanAccessResource { + filteredContainerGroups = append(filteredContainerGroups, containerGroup) + } + } + + return filteredContainerGroups +} + +func (transport *Transport) removeResourceControl(containerGroup map[string]interface{}, context *azureRequestContext) error { + containerGroupID, ok := containerGroup["id"].(string) + if ok { + resourceControl := transport.findResourceControl(containerGroupID, context) + if resourceControl != nil { + err := transport.dataStore.ResourceControl().DeleteResourceControl(resourceControl.ID) + return err + } + } else { + log.Printf("[WARN] [http,proxy,azure] [message: missign ID in container group]") + } + + return nil +} + +func (transport *Transport) findResourceControl(containerGroupId string, context *azureRequestContext) *portainer.ResourceControl { + resourceControl := authorization.GetResourceControlByResourceIDAndType(containerGroupId, portainer.ContainerGroupResourceControl, context.resourceControls) + return resourceControl +} diff --git a/api/http/proxy/factory/azure/containergroup.go b/api/http/proxy/factory/azure/containergroup.go new file mode 100644 index 000000000..c3383035c --- /dev/null +++ b/api/http/proxy/factory/azure/containergroup.go @@ -0,0 +1,109 @@ +package azure + +import ( + "errors" + "net/http" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/responseutils" +) + +// proxy for /subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/* +func (transport *Transport) proxyContainerGroupRequest(request *http.Request) (*http.Response, error) { + switch request.Method { + case http.MethodPut: + return transport.proxyContainerGroupPutRequest(request) + case http.MethodGet: + return transport.proxyContainerGroupGetRequest(request) + case http.MethodDelete: + return transport.proxyContainerGroupDeleteRequest(request) + default: + return http.DefaultTransport.RoundTrip(request) + } +} + +func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request) (*http.Response, error) { + response, err := http.DefaultTransport.RoundTrip(request) + if err != nil { + return response, err + } + + responseObject, err := responseutils.GetResponseAsJSONObject(response) + if err != nil { + return response, err + } + + containerGroupID, ok := responseObject["id"].(string) + if !ok { + return response, errors.New("Missing container group ID") + } + + context, err := transport.createAzureRequestContext(request) + if err != nil { + return response, err + } + + resourceControl, err := transport.createPrivateResourceControl(containerGroupID, portainer.ContainerGroupResourceControl, context.userID) + if err != nil { + return response, err + } + + responseObject = decorateObject(responseObject, resourceControl) + + err = responseutils.RewriteResponse(response, responseObject, http.StatusOK) + if err != nil { + return response, err + } + + return response, nil +} + +func (transport *Transport) proxyContainerGroupGetRequest(request *http.Request) (*http.Response, error) { + response, err := http.DefaultTransport.RoundTrip(request) + if err != nil { + return response, err + } + + responseObject, err := responseutils.GetResponseAsJSONObject(response) + if err != nil { + return nil, err + } + + context, err := transport.createAzureRequestContext(request) + if err != nil { + return nil, err + } + + responseObject = transport.decorateContainerGroup(responseObject, context) + + responseutils.RewriteResponse(response, responseObject, http.StatusOK) + + return response, nil +} + +func (transport *Transport) proxyContainerGroupDeleteRequest(request *http.Request) (*http.Response, error) { + context, err := transport.createAzureRequestContext(request) + if err != nil { + return nil, err + } + + if !transport.userCanDeleteContainerGroup(request, context) { + return responseutils.WriteAccessDeniedResponse() + } + + response, err := http.DefaultTransport.RoundTrip(request) + if err != nil { + return response, err + } + + responseObject, err := responseutils.GetResponseAsJSONObject(response) + if err != nil { + return nil, err + } + + transport.removeResourceControl(responseObject, context) + + responseutils.RewriteResponse(response, responseObject, http.StatusOK) + + return response, nil +} diff --git a/api/http/proxy/factory/azure/containergroups.go b/api/http/proxy/factory/azure/containergroups.go new file mode 100644 index 000000000..ccb441b3b --- /dev/null +++ b/api/http/proxy/factory/azure/containergroups.go @@ -0,0 +1,48 @@ +package azure + +import ( + "fmt" + "net/http" + + "github.com/portainer/portainer/api/http/proxy/factory/responseutils" +) + +// proxy for /subscriptions/*/providers/Microsoft.ContainerInstance/containerGroups +func (transport *Transport) proxyContainerGroupsRequest(request *http.Request) (*http.Response, error) { + switch request.Method { + case http.MethodGet: + return transport.proxyContainerGroupsGetRequest(request) + default: + return http.DefaultTransport.RoundTrip(request) + } +} + +func (transport *Transport) proxyContainerGroupsGetRequest(request *http.Request) (*http.Response, error) { + response, err := http.DefaultTransport.RoundTrip(request) + if err != nil { + return nil, err + } + + responseObject, err := responseutils.GetResponseAsJSONObject(response) + if err != nil { + return nil, err + } + + value, ok := responseObject["value"].([]interface{}) + if ok { + context, err := transport.createAzureRequestContext(request) + if err != nil { + return response, err + } + + decoratedValue := transport.decorateContainerGroups(value, context) + filteredValue := transport.filterContainerGroups(decoratedValue, context) + responseObject["value"] = filteredValue + + responseutils.RewriteResponse(response, responseObject, http.StatusOK) + } else { + return nil, fmt.Errorf("The container groups response has no value property") + } + + return response, nil +} \ No newline at end of file diff --git a/api/http/proxy/factory/azure/transport.go b/api/http/proxy/factory/azure/transport.go index 0c8505c8b..d6ed7de7a 100644 --- a/api/http/proxy/factory/azure/transport.go +++ b/api/http/proxy/factory/azure/transport.go @@ -5,7 +5,7 @@ import ( "strconv" "sync" "time" - + "path" "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/client" ) @@ -21,26 +21,50 @@ type ( client *client.HTTPClient token *azureAPIToken mutex sync.Mutex + dataStore portainer.DataStore + endpoint *portainer.Endpoint + } + + azureRequestContext struct { + isAdmin bool + userID portainer.UserID + userTeamIDs []portainer.TeamID + resourceControls []portainer.ResourceControl } ) // NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport // interface for proxying requests to the Azure API. -func NewTransport(credentials *portainer.AzureCredentials) *Transport { +func NewTransport(credentials *portainer.AzureCredentials, dataStore portainer.DataStore, endpoint *portainer.Endpoint) *Transport { return &Transport{ credentials: credentials, client: client.NewHTTPClient(), + dataStore: dataStore, + endpoint: endpoint, } } // RoundTrip is the implementation of the the http.RoundTripper interface func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport.proxyAzureRequest(request) +} + +func (transport *Transport) proxyAzureRequest(request *http.Request) (*http.Response, error) { + requestPath := request.URL.Path + err := transport.retrieveAuthenticationToken() if err != nil { return nil, err } request.Header.Set("Authorization", "Bearer "+transport.token.value) + + if match, _ := path.Match(portainer.AzurePathContainerGroups, requestPath); match { + return transport.proxyContainerGroupsRequest(request) + } else if match, _ := path.Match(portainer.AzurePathContainerGroup, requestPath); match { + return transport.proxyContainerGroupRequest(request) + } + return http.DefaultTransport.RoundTrip(request) } diff --git a/api/http/proxy/factory/factory.go b/api/http/proxy/factory/factory.go index dd4e62440..1e6f83d40 100644 --- a/api/http/proxy/factory/factory.go +++ b/api/http/proxy/factory/factory.go @@ -55,7 +55,7 @@ func (factory *ProxyFactory) NewLegacyExtensionProxy(extensionAPIURL string) (ht func (factory *ProxyFactory) NewEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { switch endpoint.Type { case portainer.AzureEnvironment: - return newAzureProxy(endpoint) + return newAzureProxy(endpoint, factory.dataStore) case portainer.EdgeAgentOnKubernetesEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.KubernetesLocalEnvironment: return factory.newKubernetesProxy(endpoint) } diff --git a/api/http/proxy/factory/responseutils/response.go b/api/http/proxy/factory/responseutils/response.go index 9f5870810..a32cd3252 100644 --- a/api/http/proxy/factory/responseutils/response.go +++ b/api/http/proxy/factory/responseutils/response.go @@ -2,6 +2,7 @@ package responseutils import ( "bytes" + "compress/gzip" "encoding/json" "errors" "io/ioutil" @@ -48,13 +49,21 @@ func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) return nil, errors.New("unable to parse response: empty response body") } - var data interface{} - body, err := ioutil.ReadAll(response.Body) - if err != nil { - return nil, err + reader := response.Body + + if response.Header.Get("Content-Encoding") == "gzip" { + response.Header.Del("Content-Encoding") + gzipReader, err := gzip.NewReader(response.Body) + if err != nil { + return nil, err + } + reader = gzipReader } - err = response.Body.Close() + defer reader.Close() + + var data interface{} + body, err := ioutil.ReadAll(reader) if err != nil { return nil, err } diff --git a/api/internal/authorization/access_control.go b/api/internal/authorization/access_control.go index 4f533bffa..5004088db 100644 --- a/api/internal/authorization/access_control.go +++ b/api/internal/authorization/access_control.go @@ -160,9 +160,13 @@ func FilterAuthorizedCustomTemplates(customTemplates []portainer.CustomTemplate, return authorizedTemplates } -// UserCanAccessResource will valide that a user has permissions defined in the specified resource control +// UserCanAccessResource will valid that a user has permissions defined in the specified resource control // based on its identifier and the team(s) he is part of. func UserCanAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool { + if resourceControl == nil { + return false + } + for _, authorizedUserAccess := range resourceControl.UserAccesses { if userID == authorizedUserAccess.UserID { return true diff --git a/api/portainer.go b/api/portainer.go index ea0711372..bd0fc0015 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1497,6 +1497,8 @@ const ( ConfigResourceControl // CustomTemplateResourceControl represents a resource control associated to a custom template CustomTemplateResourceControl + // ContainerGroupResourceControl represents a resource control associated to an Azure container group + ContainerGroupResourceControl ) const ( @@ -1773,3 +1775,8 @@ const ( EndpointResourcesAccess Authorization = "EndpointResourcesAccess" ) + +const ( + AzurePathContainerGroups = "/subscriptions/*/providers/Microsoft.ContainerInstance/containerGroups" + AzurePathContainerGroup = "/subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/*" +) diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html index da68fd7a0..62fa95acf 100644 --- a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html @@ -49,6 +49,13 @@