diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go index ac25a7b7a..69dc5c670 100644 --- a/api/http/proxy/factory/docker/access_control.go +++ b/api/http/proxy/factory/docker/access_control.go @@ -5,11 +5,13 @@ import ( "strings" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/docker/consts" "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/slicesx" "github.com/portainer/portainer/api/stacks/stackutils" + "github.com/docker/docker/client" "github.com/rs/zerolog/log" ) @@ -17,9 +19,6 @@ const ( resourceLabelForPortainerTeamResourceControl = "io.portainer.accesscontrol.teams" resourceLabelForPortainerUserResourceControl = "io.portainer.accesscontrol.users" resourceLabelForPortainerPublicResourceControl = "io.portainer.accesscontrol.public" - resourceLabelForDockerSwarmStackName = "com.docker.stack.namespace" - resourceLabelForDockerServiceID = "com.docker.swarm.service.id" - resourceLabelForDockerComposeStackName = "com.docker.compose.project" ) type ( @@ -123,13 +122,7 @@ func (transport *Transport) createPrivateResourceControl(resourceIdentifier stri return resourceControl, nil } -func (transport *Transport) getInheritedResourceControlFromServiceOrStack(resourceIdentifier, nodeName string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { - client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName, nil) - if err != nil { - return nil, err - } - defer client.Close() - +func (transport *Transport) getInheritedResourceControlFromServiceOrStack(client *client.Client, resourceIdentifier string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { switch resourceType { case portainer.ContainerResourceControl: return getInheritedResourceControlFromContainerLabels(client, transport.endpoint.ID, resourceIdentifier, resourceControls) @@ -295,8 +288,8 @@ func (transport *Transport) findResourceControl(resourceIdentifier string, resou return nil, nil } - if resourceLabelsObject[resourceLabelForDockerServiceID] != nil { - inheritedServiceIdentifier := resourceLabelsObject[resourceLabelForDockerServiceID].(string) + if resourceLabelsObject[consts.SwarmServiceIDLabel] != nil { + inheritedServiceIdentifier := resourceLabelsObject[consts.SwarmServiceIDLabel].(string) resourceControl = authorization.GetResourceControlByResourceIDAndType(inheritedServiceIdentifier, portainer.ServiceResourceControl, resourceControls) if resourceControl != nil { @@ -304,8 +297,8 @@ func (transport *Transport) findResourceControl(resourceIdentifier string, resou } } - if resourceLabelsObject[resourceLabelForDockerSwarmStackName] != nil { - stackName := resourceLabelsObject[resourceLabelForDockerSwarmStackName].(string) + if resourceLabelsObject[consts.SwarmStackNameLabel] != nil { + stackName := resourceLabelsObject[consts.SwarmStackNameLabel].(string) stackResourceID := stackutils.ResourceControlID(transport.endpoint.ID, stackName) resourceControl = authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls) @@ -314,8 +307,8 @@ func (transport *Transport) findResourceControl(resourceIdentifier string, resou } } - if resourceLabelsObject[resourceLabelForDockerComposeStackName] != nil { - stackName := resourceLabelsObject[resourceLabelForDockerComposeStackName].(string) + if resourceLabelsObject[consts.ComposeStackNameLabel] != nil { + stackName := resourceLabelsObject[consts.ComposeStackNameLabel].(string) stackResourceID := stackutils.ResourceControlID(transport.endpoint.ID, stackName) resourceControl = authorization.GetResourceControlByResourceIDAndType(stackResourceID, portainer.StackResourceControl, resourceControls) @@ -328,14 +321,14 @@ func (transport *Transport) findResourceControl(resourceIdentifier string, resou } func getStackResourceIDFromLabels(resourceLabelsObject map[string]string, endpointID portainer.EndpointID) string { - if resourceLabelsObject[resourceLabelForDockerSwarmStackName] != "" { - stackName := resourceLabelsObject[resourceLabelForDockerSwarmStackName] + if resourceLabelsObject[consts.SwarmStackNameLabel] != "" { + stackName := resourceLabelsObject[consts.SwarmStackNameLabel] return stackutils.ResourceControlID(endpointID, stackName) } - if resourceLabelsObject[resourceLabelForDockerComposeStackName] != "" { - stackName := resourceLabelsObject[resourceLabelForDockerComposeStackName] + if resourceLabelsObject[consts.ComposeStackNameLabel] != "" { + stackName := resourceLabelsObject[consts.ComposeStackNameLabel] return stackutils.ResourceControlID(endpointID, stackName) } diff --git a/api/http/proxy/factory/docker/containers.go b/api/http/proxy/factory/docker/containers.go index ffd745699..765f5f47b 100644 --- a/api/http/proxy/factory/docker/containers.go +++ b/api/http/proxy/factory/docker/containers.go @@ -9,6 +9,7 @@ import ( "strings" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/docker/consts" "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" @@ -34,7 +35,7 @@ func getInheritedResourceControlFromContainerLabels(dockerClient *client.Client, return nil, err } - serviceName := container.Config.Labels[resourceLabelForDockerServiceID] + serviceName := container.Config.Labels[consts.SwarmServiceIDLabel] if serviceName != "" { serviceResourceControl := authorization.GetResourceControlByResourceIDAndType(serviceName, portainer.ServiceResourceControl, resourceControls) if serviceResourceControl != nil { diff --git a/api/http/proxy/factory/docker/services.go b/api/http/proxy/factory/docker/services.go index 11eb7fdee..56e978dee 100644 --- a/api/http/proxy/factory/docker/services.go +++ b/api/http/proxy/factory/docker/services.go @@ -11,7 +11,7 @@ import ( "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" - "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/client" "github.com/segmentio/encoding/json" ) @@ -19,7 +19,7 @@ import ( const serviceObjectIdentifier = "ID" func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, endpointID portainer.EndpointID, serviceID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) { - service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, types.ServiceInspectOptions{}) + service, _, err := dockerClient.ServiceInspectWithRaw(context.Background(), serviceID, swarm.ServiceInspectOptions{}) if err != nil { return nil, err } diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index dae72ecc1..d25610f1c 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -2,6 +2,7 @@ package docker import ( "bytes" + "context" "encoding/base64" "errors" "fmt" @@ -15,12 +16,15 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/docker/client" gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" - dockerclient "github.com/portainer/portainer/api/docker/client" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/swarm" + dockerclient "github.com/docker/docker/client" "github.com/rs/zerolog/log" "github.com/segmentio/encoding/json" ) @@ -36,7 +40,7 @@ type ( dataStore dataservices.DataStore signatureService portainer.DigitalSignatureService reverseTunnelService portainer.ReverseTunnelService - dockerClientFactory *dockerclient.ClientFactory + dockerClientFactory *client.ClientFactory gitService portainer.GitService snapshotService portainer.SnapshotService dockerID string @@ -49,7 +53,7 @@ type ( DataStore dataservices.DataStore SignatureService portainer.DigitalSignatureService ReverseTunnelService portainer.ReverseTunnelService - DockerClientFactory *dockerclient.ClientFactory + DockerClientFactory *client.ClientFactory } restrictedDockerOperationContext struct { @@ -107,6 +111,9 @@ var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*ht // ProxyDockerRequest intercepts a Docker API request and apply logic based // on the requested operation. func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) { + // from : /v1.41/containers/{id}/json + // or : /containers/{id}/json + // to : /containers/{id}/json unversionedPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") if transport.endpoint.Type == portainer.AgentOnDockerEnvironment || transport.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { @@ -119,6 +126,10 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res request.Header.Set(portainer.PortainerAgentSignatureHeader, signature) } + // from : /containers/{id}/json + // trim to : containers/{id}/json + // pick : [ containers, {id}, json ][0] + // prefix : containers prefix := strings.Split(strings.TrimPrefix(unversionedPath, "/"), "/")[0] if proxyFunc := prefixProxyFuncMap[prefix]; proxyFunc != nil { @@ -215,9 +226,10 @@ func (transport *Transport) proxyConfigRequest(request *http.Request, unversione // Assume /configs/{id} configID := path.Base(requestPath) - if request.Method == http.MethodGet { + switch request.Method { + case http.MethodGet: return transport.rewriteOperation(request, transport.configInspectOperation) - } else if request.Method == http.MethodDelete { + case http.MethodDelete: return transport.executeGenericResourceDeletionOperation(request, configID, configID, portainer.ConfigResourceControl) } @@ -248,7 +260,6 @@ func (transport *Transport) proxyContainerRequest(request *http.Request, unversi if action == "json" { return transport.rewriteOperation(request, transport.containerInspectOperation) } - return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false) } else if match, _ := path.Match("/containers/*", requestPath); match { // Handle /containers/{id} requests @@ -280,7 +291,10 @@ func (transport *Transport) proxyServiceRequest(request *http.Request, unversion if match, _ := path.Match("/services/*/*", requestPath); match { // Handle /services/{id}/{action} requests serviceID := path.Base(path.Dir(requestPath)) - transport.decorateRegistryAuthenticationHeader(request) + + if err := transport.decorateRegistryAuthenticationHeader(request); err != nil { + return nil, err + } return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false) } else if match, _ := path.Match("/services/*", requestPath); match { @@ -320,28 +334,38 @@ func (transport *Transport) proxyVolumeRequest(request *http.Request, unversione } } +func match(requestPath string, pattern string) bool { + ok, err := path.Match(pattern, requestPath) + return err == nil && ok +} + func (transport *Transport) proxyNetworkRequest(request *http.Request, unversionedPath string) (*http.Response, error) { requestPath := unversionedPath - switch requestPath { - case "/networks/create": + switch { + case requestPath == "/networks/create": return transport.decorateGenericResourceCreationOperation(request, networkObjectIdentifier, portainer.NetworkResourceControl) - case "/networks": + case requestPath == "/networks": return transport.rewriteOperation(request, transport.networkListOperation) - default: - // Assume /networks/{id} - networkID := path.Base(requestPath) - - if request.Method == http.MethodGet { - return transport.rewriteOperation(request, transport.networkInspectOperation) - } else if request.Method == http.MethodDelete { - return transport.executeGenericResourceDeletionOperation(request, networkID, networkID, portainer.NetworkResourceControl) - } + case request.Method == http.MethodPost && match(requestPath, "/networks/*/connect"), + request.Method == http.MethodPost && match(requestPath, "/networks/*/disconnect"): + networkID := path.Base(path.Dir(requestPath)) return transport.restrictedResourceOperation(request, networkID, networkID, portainer.NetworkResourceControl, false) + + case request.Method == http.MethodGet && match(requestPath, "/networks/*"): + return transport.rewriteOperation(request, transport.networkInspectOperation) + + case request.Method == http.MethodDelete && match(requestPath, "/networks/*"): + networkID := path.Base(requestPath) + return transport.executeGenericResourceDeletionOperation(request, networkID, networkID, portainer.NetworkResourceControl) } + + // Assume /networks/{id} + networkID := path.Base(requestPath) + return transport.restrictedResourceOperation(request, networkID, networkID, portainer.NetworkResourceControl, false) } func (transport *Transport) proxySecretRequest(request *http.Request, unversionedPath string) (*http.Response, error) { @@ -358,9 +382,10 @@ func (transport *Transport) proxySecretRequest(request *http.Request, unversione // Assume /secrets/{id} secretID := path.Base(requestPath) - if request.Method == http.MethodGet { + switch request.Method { + case http.MethodGet: return transport.rewriteOperation(request, transport.secretInspectOperation) - } else if request.Method == http.MethodDelete { + case http.MethodDelete: return transport.executeGenericResourceDeletionOperation(request, secretID, secretID, portainer.SecretResourceControl) } @@ -413,7 +438,6 @@ func (transport *Transport) proxyBuildRequest(request *http.Request, _ string) ( func (transport *Transport) updateDefaultGitBranch(request *http.Request) error { remote := request.URL.Query().Get("remote") - if !strings.HasSuffix(remote, ".git") { return nil } @@ -549,32 +573,101 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r } resourceControl := authorization.GetResourceControlByResourceIDAndType(resourceID, resourceType, resourceControls) - if resourceControl == nil { - agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader) - - if dockerResourceID == "" { - dockerResourceID = resourceID - } - - // This resource was created outside of portainer, - // is part of a Docker service or part of a Docker Swarm/Compose stack. - inheritedResourceControl, err := transport.getInheritedResourceControlFromServiceOrStack(dockerResourceID, agentTargetHeader, resourceType, resourceControls) - if err != nil { - return nil, err - } - - if inheritedResourceControl == nil || !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) { + if resourceControl != nil { + if !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) { return utils.WriteAccessDeniedResponse() } + return transport.executeDockerRequest(request) } - if resourceControl != nil && !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) { + client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, request.Header.Get(portainer.PortainerAgentTargetHeader), nil) + if err != nil { + return nil, err + } + defer client.Close() + + // the resourceID may be the resource name (as it's a valid proxy call to use the name and not the UUID) + // so get the real resource ID and retry with it + resourceID, err = getRealResourceID(client, resourceType, resourceID) + if err != nil { + return nil, err + } + + resourceControl = authorization.GetResourceControlByResourceIDAndType(resourceID, resourceType, resourceControls) + if resourceControl != nil { + if !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) { + return utils.WriteAccessDeniedResponse() + } + return transport.executeDockerRequest(request) + } + + // If we still can't find the RC by provided ID or "real" (docker-extracted) ID + // it means this resource was created outside of portainer, + // is part of a Docker service or part of a Docker Swarm/Compose stack. + if dockerResourceID == "" { + dockerResourceID = resourceID + } + inheritedResourceControl, err := transport.getInheritedResourceControlFromServiceOrStack(client, dockerResourceID, resourceType, resourceControls) + if err != nil { + return nil, err + } + + if inheritedResourceControl == nil || !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) { return utils.WriteAccessDeniedResponse() } return transport.executeDockerRequest(request) } +func getRealResourceID(client *dockerclient.Client, resourceType portainer.ResourceControlType, resourceId string) (string, error) { + switch resourceType { + case portainer.NetworkResourceControl: + network, err := client.NetworkInspect(context.Background(), resourceId, network.InspectOptions{}) + if err != nil { + return "", err + } + return network.ID, nil + + case portainer.ContainerResourceControl: + container, err := client.ContainerInspect(context.Background(), resourceId) + if err != nil { + return "", err + } + return container.ID, nil + + case portainer.VolumeResourceControl: + // volumes don't have an UUID and their UACresourceID has a particular construct that makes them unique + // e.g. fmt.Sprintf("%s_%s", volumeName, dockerID) + // see transport.getVolumeResourceID() / FetchDockerID() + // FetchDockerID fetches info.Swarm.Cluster.ID if environment(endpoint) is swarm and info.ID otherwise + // So: return empty ID but without error + return "", nil + + case portainer.ServiceResourceControl: + service, _, err := client.ServiceInspectWithRaw(context.Background(), resourceId, swarm.ServiceInspectOptions{}) + if err != nil { + return "", err + } + return service.ID, nil + + case portainer.ConfigResourceControl: + config, _, err := client.ConfigInspectWithRaw(context.Background(), resourceId) + if err != nil { + return "", err + } + return config.ID, nil + + case portainer.SecretResourceControl: + secret, _, err := client.SecretInspectWithRaw(context.Background(), resourceId) + if err != nil { + return "", err + } + return secret.ID, nil + + } + return "", fmt.Errorf("Unknown resource type %v", resourceType) +} + // rewriteOperationWithLabelFiltering will create a new operation context with data that will be used // to decorate the original request's response as well as retrieve all the black listed labels // to filter the resources. diff --git a/api/http/proxy/factory/docker/transport_test.go b/api/http/proxy/factory/docker/transport_test.go index bc42d5e5a..578a3c449 100644 --- a/api/http/proxy/factory/docker/transport_test.go +++ b/api/http/proxy/factory/docker/transport_test.go @@ -6,9 +6,19 @@ import ( "net/http/httptest" "testing" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/swarm" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/segmentio/encoding/json" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestTransport_updateDefaultGitBranch(t *testing.T) { @@ -21,7 +31,6 @@ func TestTransport_updateDefaultGitBranch(t *testing.T) { } commitId := "my-latest-commit-id" - defaultFields := fields{ gitService: testhelpers.NewGitService(nil, commitId), } @@ -67,3 +76,331 @@ func TestTransport_updateDefaultGitBranch(t *testing.T) { }) } } + +type RoutesDefinition map[[2]string]any + +func mockDockerAPIServer(t *testing.T, routes RoutesDefinition) (*httptest.Server, string) { + version := "1.51" + + v := func(path string) string { + return "/v" + version + path + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead && r.URL.Path == "/_ping" { + w.Header().Add("Api-Version", version) + _, _ = w.Write([]byte{}) + return + } + + for defs, rValue := range routes { + method, path := defs[0], defs[1] + if r.Method == method && r.URL.Path == v(path) { + _ = response.JSON(w, rValue) + return + } + } + + http.NotFound(w, r) + })) + require.NotNil(t, srv) + + return srv, version +} + +func TestTransport_getRealResourceID(t *testing.T) { + srv, _ := mockDockerAPIServer(t, RoutesDefinition{ + {http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}}, + {http.MethodGet, "/networks/mynetwork"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}, + {http.MethodGet, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}, + {http.MethodGet, "/containers/mycontainer/json"}: container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ID: "545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6", Name: "mycontainer"}}, + {http.MethodGet, "/containers/545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6/json"}: container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ID: "545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6", Name: "mycontainer"}}, + {http.MethodGet, "/services/myservice"}: swarm.Service{ID: "ibt43uf5awhg06bxp8rkd7bhi", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "myservice"}}}, + {http.MethodGet, "/services/ibt43uf5awhg06bxp8rkd7bhi"}: swarm.Service{ID: "ibt43uf5awhg06bxp8rkd7bhi", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "myservice"}}}, + {http.MethodGet, "/configs/myconfig"}: swarm.Config{ID: "3mlqqza0k413ecebk0mfa11em", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "myconfig"}}}, + {http.MethodGet, "/configs/3mlqqza0k413ecebk0mfa11em"}: swarm.Config{ID: "3mlqqza0k413ecebk0mfa11em", Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "myconfig"}}}, + {http.MethodGet, "/secrets/mysecret"}: swarm.Secret{ID: "v9i7o4ivg33u4z3jfyxto162d", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Name: "mysecret"}}}, + {http.MethodGet, "/secrets/v9i7o4ivg33u4z3jfyxto162d"}: swarm.Secret{ID: "v9i7o4ivg33u4z3jfyxto162d", Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Name: "mysecret"}}}, + }) + defer srv.Close() + + transport := &Transport{ + endpoint: &portainer.Endpoint{URL: srv.URL}, + } + + client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil) + require.NoError(t, err) + require.NotNil(t, client) + + test := func(rctype portainer.ResourceControlType, name string, id string, errOnUnknown bool) { + // by id + got, err := getRealResourceID(client, rctype, id) + require.NoError(t, err) + require.Equal(t, id, got) + + // by name + got, err = getRealResourceID(client, rctype, name) + require.NoError(t, err) + require.Equal(t, id, got) + + // unknown for this type + _, err = getRealResourceID(client, rctype, "unknown") + if errOnUnknown { + require.Error(t, err) + } else { + require.NoError(t, err) + } + } + + test(portainer.NetworkResourceControl, "mynetwork", "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", true) + test(portainer.ContainerResourceControl, "mycontainer", "545fc03ed1fd5008c3bfa2441209ff024e21e396acbeb58b2355930ad1295aa6", true) + test(portainer.VolumeResourceControl, "anything", "", false) + test(portainer.ServiceResourceControl, "myservice", "ibt43uf5awhg06bxp8rkd7bhi", true) + test(portainer.ConfigResourceControl, "myconfig", "3mlqqza0k413ecebk0mfa11em", true) + test(portainer.SecretResourceControl, "mysecret", "v9i7o4ivg33u4z3jfyxto162d", true) + + // validate that other types are not supported + _, err = getRealResourceID(client, portainer.ContainerGroupResourceControl, "") + require.Error(t, err) +} + +func TestTransport_proxyNetworkRequest(t *testing.T) { + admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + std1 := portainer.User{ID: 2, Username: "std1", Role: portainer.StandardUserRole} + std2 := portainer.User{ID: 3, Username: "std2", Role: portainer.StandardUserRole} + + _, ds := datastore.MustNewTestStore(t, true, false) + + require.NoError(t, ds.UpdateTx(func(tx dataservices.DataStoreTx) error { + require.NoError(t, tx.User().Create(&admin)) + require.NoError(t, tx.User().Create(&std1)) + require.NoError(t, tx.User().Create(&std2)) + require.NoError(t, tx.Endpoint().Create(&portainer.Endpoint{ID: 1, Name: "env", + UserAccessPolicies: portainer.UserAccessPolicies{std1.ID: portainer.AccessPolicy{RoleID: 1}}, + })) + + require.NoError(t, tx.ResourceControl().Create(authorization.NewPrivateResourceControl("16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", portainer.NetworkResourceControl, std1.ID))) + + return nil + })) + + srv, version := mockDockerAPIServer(t, RoutesDefinition{ + {http.MethodGet, "/networks"}: []network.Summary{{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}}, + {http.MethodGet, "/networks/mynetwork"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}, + {http.MethodGet, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}: network.Inspect{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4", Name: "mynetwork"}, + {http.MethodPost, "/networks/mynetwork/connect"}: struct{}{}, + {http.MethodPost, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4/connect"}: struct{}{}, + {http.MethodPost, "/networks/mynetwork/disconnect"}: struct{}{}, + {http.MethodPost, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4/disconnect"}: struct{}{}, + {http.MethodDelete, "/networks/mynetwork"}: struct{}{}, + {http.MethodDelete, "/networks/16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}: struct{}{}, + {http.MethodPost, "/networks/create"}: network.CreateResponse{ID: "16e37c629e88694663791dc738fd37affb908d7b85ce00a20680675d10554fd4"}, + {http.MethodPost, "/networks/prune"}: struct{}{}, + }) + defer srv.Close() + + transport := &Transport{ + endpoint: &portainer.Endpoint{URL: srv.URL}, + dataStore: ds, + HTTPTransport: &http.Transport{}, + } + + test := func(method string, url string, token portainer.TokenData) (*http.Response, error) { + req := httptest.NewRequest(method, srv.URL+"/v"+version+url, nil) + req = req.WithContext(security.StoreTokenData(req, &token)) + require.NotNil(t, req) + + return transport.proxyNetworkRequest(req, url) + } + + adminToken := portainer.TokenData{ID: admin.ID, Username: admin.Username, Role: admin.Role} + std1Token := portainer.TokenData{ID: std1.ID, Username: std1.Username, Role: std1.Role} + std2Token := portainer.TokenData{ID: std2.ID, Username: std2.Username, Role: std2.Role} + + { + r, err := test(http.MethodGet, "/networks", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + var resp []network.Summary + require.NoError(t, json.NewDecoder(r.Body).Decode(&resp)) + require.Len(t, resp, 1) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/networks", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + var resp []network.Summary + require.NoError(t, json.NewDecoder(r.Body).Decode(&resp)) + require.Len(t, resp, 1) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/networks", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + var resp []network.Summary + require.NoError(t, json.NewDecoder(r.Body).Decode(&resp)) + require.Empty(t, resp) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/networks/mynetwork", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/networks/mynetwork", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/networks/mynetwork", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodGet, "/networks/unknown", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusNotFound, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/mynetwork/connect", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/mynetwork/connect", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.NoError(t, r.Body.Close()) + require.Equal(t, http.StatusOK, r.StatusCode) + } + + { + r, err := test(http.MethodPost, "/networks/mynetwork/connect", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.NoError(t, r.Body.Close()) + require.Equal(t, http.StatusForbidden, r.StatusCode) + } + + { + r, err := test(http.MethodPost, "/networks/mynetwork/disconnect", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/mynetwork/disconnect", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/mynetwork/disconnect", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodDelete, "/networks/mynetwork", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodDelete, "/networks/mynetwork", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodDelete, "/networks/mynetwork", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/create", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/create", std1Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/create", std2Token) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/prune", adminToken) + require.NoError(t, err) + require.NotNil(t, r) + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, r.Body.Close()) + } + + { + r, err := test(http.MethodPost, "/networks/prune", std1Token) + require.Error(t, err) + require.Nil(t, r) + if r != nil { + r.Body.Close() + } + } + + { + r, err := test(http.MethodPost, "/networks/prune", std2Token) + require.Error(t, err) + require.Nil(t, r) + if r != nil { + r.Body.Close() + } + } +} diff --git a/api/http/proxy/factory/utils/response.go b/api/http/proxy/factory/utils/response.go index 71caf7a54..4e8aac8fa 100644 --- a/api/http/proxy/factory/utils/response.go +++ b/api/http/proxy/factory/utils/response.go @@ -63,7 +63,10 @@ type errorResponse struct { // WriteAccessDeniedResponse will create a new access denied response func WriteAccessDeniedResponse() (*http.Response, error) { - response := &http.Response{} + header := http.Header{} + header.Add("Content-Type", "application/json") + + response := &http.Response{Header: header} err := RewriteResponse(response, errorResponse{Message: "access denied to resource"}, http.StatusForbidden) return response, err diff --git a/api/http/proxy/factory/utils/response_test.go b/api/http/proxy/factory/utils/response_test.go new file mode 100644 index 000000000..41e9cf66f --- /dev/null +++ b/api/http/proxy/factory/utils/response_test.go @@ -0,0 +1,18 @@ +package utils + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWriteAccessDeniedResponse(t *testing.T) { + r, err := WriteAccessDeniedResponse() + require.NoError(t, err) + defer r.Body.Close() + + require.NotNil(t, r) + require.Equal(t, "application/json", r.Header.Get("content-type")) + require.Equal(t, http.StatusForbidden, r.StatusCode) +}