Repository: saleor/saleor

Issue:
Access to unpublished objects in API
Some of the API fields leak access to unpublished objects. The issue affects the following fields:
- [x] `collection(id: ID!)`
- [x] `product(id: ID!)`
- [x] `productVariant(id: ID!)`

All above fields use `graphene.Node.get_node_from_global_id` in their resolvers which operates on queryset of all objects. We should add some logic that checks if the `request.user` has access to the given object. This is properly implemented for example in the [page resolver](https://github.com/mirumee/saleor/blob/7109b84e50eb46113d141dc07ab99e9f99da0e05/saleor/graphql/page/resolvers.py#L10). Access rules in summary:
- anonymous users and customers should have only access to objects that are published
- unpublished objects can be viewed by staff users with proper permissions

We can fix that by implementing simlar logic as in the page resolver (link above). This should do for now, but in the long run I think it would be nice to have the ability to determine the queryset that `get_node_from_global_id` uses underneath. There is [an open PR in graphene-django](https://github.com/graphql-python/graphene-django/pull/528) for that.




Discussion:

Resolving patch:
diff --git a/saleor/graphql/product/resolvers.py b/saleor/graphql/product/resolvers.py
--- a/saleor/graphql/product/resolvers.py
+++ b/saleor/graphql/product/resolvers.py
@@ -113,7 +113,11 @@ def resolve_product_types(info):
 
 
 def resolve_product_variants(info, ids=None):
-    qs = models.ProductVariant.objects.all()
+    user = info.context.user
+    visible_products = models.Product.objects.visible_to_user(
+        user).values_list('pk', flat=True)
+    qs = models.ProductVariant.objects.filter(
+        product__id__in=visible_products)
     if ids:
         db_ids = [
             get_database_id(info, node_id, only_type=ProductVariant)
diff --git a/saleor/graphql/product/types.py b/saleor/graphql/product/types.py
--- a/saleor/graphql/product/types.py
+++ b/saleor/graphql/product/types.py
@@ -200,6 +200,17 @@ def resolve_revenue(self, info, period):
     def resolve_images(self, info):
         return self.images.all()
 
+    @classmethod
+    def get_node(cls, info, id):
+        user = info.context.user
+        visible_products = models.Product.objects.visible_to_user(
+            user).values_list('pk', flat=True)
+        try:
+            return cls._meta.model.objects.filter(
+                product__id__in=visible_products).get(pk=id)
+        except cls._meta.model.DoesNotExist:
+            return None
+
 
 class ProductAvailability(graphene.ObjectType):
     available = graphene.Boolean()
@@ -344,6 +355,17 @@ def resolve_collections(self, info):
     def resolve_available_on(self, info):
         return self.publication_date
 
+    @classmethod
+    def get_node(cls, info, id):
+        if info.context:
+            user = info.context.user
+            try:
+                return cls._meta.model.objects.visible_to_user(
+                    user).get(pk=id)
+            except cls._meta.model.DoesNotExist:
+                return None
+        return None
+
 
 def prefetch_products(info, *args, **kwargs):
     """Prefetch products visible to the current user.
@@ -425,6 +447,17 @@ def resolve_products(self, info, **kwargs):
         qs = self.products.visible_to_user(info.context.user)
         return gql_optimizer.query(qs, info)
 
+    @classmethod
+    def get_node(cls, info, id):
+        if info.context:
+            user = info.context.user
+            try:
+                return cls._meta.model.objects.visible_to_user(
+                    user).get(pk=id)
+            except cls._meta.model.DoesNotExist:
+                return None
+        return None
+
     def resolve_published_date(self, info):
         return self.publication_date
 


Test patch:
diff --git a/tests/api/conftest.py b/tests/api/conftest.py
--- a/tests/api/conftest.py
+++ b/tests/api/conftest.py
@@ -1,5 +1,6 @@
 import json
 
+import graphene
 import pytest
 from django.contrib.auth.models import AnonymousUser
 from django.core.serializers.json import DjangoJSONEncoder
@@ -90,3 +91,9 @@ def user_api_client(customer_user):
 @pytest.fixture
 def api_client():
     return ApiClient(user=AnonymousUser())
+
+
+@pytest.fixture
+def schema_context():
+    params = {'user': AnonymousUser()}
+    return graphene.types.Context(**params)
diff --git a/tests/api/test_base_mutation.py b/tests/api/test_base_mutation.py
--- a/tests/api/test_base_mutation.py
+++ b/tests/api/test_base_mutation.py
@@ -45,7 +45,7 @@ class Arguments:
                 product_id = graphene.ID(required=True)
 
 
-def test_resolve_id(product):
+def test_resolve_id(product, schema_context):
     product_id = graphene.Node.to_global_id('Product', product.pk)
     query = """
         mutation testMutation($productId: ID!) {
@@ -59,12 +59,13 @@ def test_resolve_id(product):
         }
     """
     variables = {'productId': product_id}
-    result = schema.execute(query, variables=variables)
+    result = schema.execute(
+        query, variables=variables, context_value=schema_context)
     assert not result.errors
     assert result.data['test']['name'] == product.name
 
 
-def test_user_error_nonexistent_id():
+def test_user_error_nonexistent_id(schema_context):
     query = """
         mutation testMutation($productId: ID!) {
             test(productId: $productId) {
@@ -77,7 +78,8 @@ def test_user_error_nonexistent_id():
         }
     """
     variables = {'productId': 'not-really'}
-    result = schema.execute(query, variables=variables)
+    result = schema.execute(
+        query, variables=variables, context_value=schema_context)
     assert not result.errors
     user_errors = result.data['test']['errors']
     assert user_errors
@@ -85,7 +87,7 @@ def test_user_error_nonexistent_id():
     assert "Couldn't resolve to a node" in user_errors[0]['message']
 
 
-def test_user_error_id_of_different_type(product):
+def test_user_error_id_of_different_type(product, schema_context):
     query = """
         mutation testMutation($productId: ID!) {
             test(productId: $productId) {
@@ -105,7 +107,8 @@ def test_user_error_id_of_different_type(product):
     variant_id = graphene.Node.to_global_id('ProductVariant', variant.pk)
 
     variables = {'productId': variant_id}
-    result = schema.execute(query, variables=variables)
+    result = schema.execute(
+        query, variables=variables, context_value=schema_context)
     assert not result.errors
     user_errors = result.data['test']['errors']
     assert user_errors
diff --git a/tests/api/test_collection.py b/tests/api/test_collection.py
--- a/tests/api/test_collection.py
+++ b/tests/api/test_collection.py
@@ -434,3 +434,45 @@ def test_update_collection_mutation_remove_background_image(
     assert not data['backgroundImage']
     collection_with_image.refresh_from_db()
     assert not collection_with_image.background_image
+
+
+def _fetch_collection(client, collection, permissions=None):
+    query = """
+    query fetchCollection($collectionId: ID!){
+        collection(id: $collectionId) {
+            name,
+            isPublished
+        }
+    }
+    """
+    variables = {
+        'collectionId': graphene.Node.to_global_id(
+            'Collection', collection.id)}
+    response = client.post_graphql(
+        query, variables, permissions=permissions, check_no_permissions=False)
+    content = get_graphql_content(response)
+    return content['data']['collection']
+
+
+def test_fetch_unpublished_collection_staff_user(
+        staff_api_client, unpublished_collection, permission_manage_products):
+    collection_data = _fetch_collection(
+        staff_api_client,
+        unpublished_collection,
+        permissions=[permission_manage_products])
+    assert collection_data['name'] == unpublished_collection.name
+    assert collection_data[
+        'isPublished'] == unpublished_collection.is_published
+
+
+def test_fetch_unpublished_collection_customer(
+        user_api_client, unpublished_collection):
+    collection_data = _fetch_collection(
+        user_api_client, unpublished_collection)
+    assert collection_data is None
+
+
+def test_fetch_unpublished_collection_anonymous_user(
+        api_client, unpublished_collection):
+    collection_data = _fetch_collection(api_client, unpublished_collection)
+    assert collection_data is None
diff --git a/tests/api/test_product.py b/tests/api/test_product.py
--- a/tests/api/test_product.py
+++ b/tests/api/test_product.py
@@ -241,6 +241,47 @@ def test_fetch_product_by_id(user_api_client, product):
     assert product_data['name'] == product.name
 
 
+def _fetch_product(client, product, permissions=None):
+    query = """
+    query ($productId: ID!) {
+        node(id: $productId) {
+            ... on Product {
+                name,
+                isPublished
+            }
+        }
+    }
+    """
+    variables = {
+        'productId': graphene.Node.to_global_id('Product', product.id)}
+    response = client.post_graphql(
+        query, variables, permissions=permissions, check_no_permissions=False)
+    content = get_graphql_content(response)
+    return content['data']['node']
+
+
+def test_fetch_unpublished_product_staff_user(
+        staff_api_client, unavailable_product, permission_manage_products):
+    product_data = _fetch_product(
+        staff_api_client,
+        unavailable_product,
+        permissions=[permission_manage_products])
+    assert product_data['name'] == unavailable_product.name
+    assert product_data['isPublished'] == unavailable_product.is_published
+
+
+def test_fetch_unpublished_product_customer(
+        user_api_client, unavailable_product):
+    product_data = _fetch_product(user_api_client, unavailable_product)
+    assert product_data is None
+
+
+def test_fetch_unpublished_product_anonymous_user(
+        api_client, unavailable_product):
+    product_data = _fetch_product(api_client, unavailable_product)
+    assert product_data is None
+
+
 def test_filter_products_by_attributes(user_api_client, product):
     product_attr = product.product_type.product_attributes.first()
     attr_value = product_attr.values.first()
diff --git a/tests/api/test_variant.py b/tests/api/test_variant.py
--- a/tests/api/test_variant.py
+++ b/tests/api/test_variant.py
@@ -304,3 +304,94 @@ def test_delete_variant(staff_api_client, product, permission_manage_products):
     assert data['productVariant']['sku'] == variant.sku
     with pytest.raises(variant._meta.model.DoesNotExist):
         variant.refresh_from_db()
+
+
+def _fetch_all_variants(client, permissions=None):
+    query = """
+        query fetchAllVariants {
+            productVariants(first: 10) {
+                totalCount
+                edges {
+                    node {
+                        id
+                    }
+                }
+            }
+        }
+    """
+    response = client.post_graphql(
+        query, {}, permissions=permissions, check_no_permissions=False)
+    content = get_graphql_content(response)
+    return content['data']['productVariants']
+
+
+def test_fetch_all_variants_staff_user(
+        staff_api_client, unavailable_product_with_variant,
+        permission_manage_products):
+    data = _fetch_all_variants(
+        staff_api_client, permissions=[permission_manage_products])
+    variant = unavailable_product_with_variant.variants.first()
+    variant_id = graphene.Node.to_global_id('ProductVariant', variant.pk)
+    assert data['totalCount'] == 1
+    assert data['edges'][0]['node']['id'] == variant_id
+
+
+def test_fetch_all_variants_customer(
+        user_api_client, unavailable_product_with_variant):
+    data = _fetch_all_variants(user_api_client)
+    assert data['totalCount'] == 0
+
+
+def test_fetch_all_variants_anonymous_user(
+        api_client, unavailable_product_with_variant):
+    data = _fetch_all_variants(api_client)
+    assert data['totalCount'] == 0
+
+
+def _fetch_variant(client, variant, permissions=None):
+    query = """
+    query ProductVariantDetails($variantId: ID!) {
+        productVariant(id: $variantId) {
+            id
+            product {
+                id
+            }
+        }
+    }
+    """
+    variables = {
+        'variantId': graphene.Node.to_global_id('ProductVariant', variant.id)}
+    response = client.post_graphql(
+        query, variables, permissions=permissions, check_no_permissions=False)
+    content = get_graphql_content(response)
+    print(content)
+    return content['data']['productVariant']
+
+
+def test_fetch_unpublished_variant_staff_user(
+        staff_api_client, unavailable_product_with_variant,
+        permission_manage_products):
+    variant = unavailable_product_with_variant.variants.first()
+    data = _fetch_variant(
+        staff_api_client, variant, permissions=[permission_manage_products])
+
+    variant_id = graphene.Node.to_global_id('ProductVariant', variant.pk)
+    product_id = graphene.Node.to_global_id(
+        'Product', unavailable_product_with_variant.pk)
+
+    assert data['id'] == variant_id
+    assert data['product']['id'] == product_id
+
+
+def test_fetch_unpublished_variant_customer(
+        user_api_client, unavailable_product_with_variant):
+    variant = unavailable_product_with_variant.variants.first()
+    data = _fetch_variant(user_api_client, variant)
+    assert data is None
+
+
+def test_fetch_unpublished_variant_anonymous_user(
+        api_client, unavailable_product_with_variant):
+    variant = unavailable_product_with_variant.variants.first()
+    data = _fetch_variant(api_client, variant)
+    assert data is None
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -416,6 +416,24 @@ def unavailable_product(product_type, category):
     return product
 
 
+@pytest.fixture
+def unavailable_product_with_variant(product_type, category):
+    product = Product.objects.create(
+        name='Test product', price=Money('10.00', 'USD'),
+        product_type=product_type, is_published=False, category=category)
+
+    variant_attr = product_type.variant_attributes.first()
+    variant_attr_value = variant_attr.values.first()
+    variant_attributes = {
+        smart_text(variant_attr.pk): smart_text(variant_attr_value.pk)}
+
+    ProductVariant.objects.create(
+        product=product, sku='123', attributes=variant_attributes,
+        cost_price=Money('1.00', 'USD'), quantity=10, quantity_allocated=1)
+
+    return product
+
+
 @pytest.fixture
 def product_with_images(product_type, category):
     product = Product.objects.create(
@@ -661,6 +679,15 @@ def draft_collection(db):
     return collection
 
 
+@pytest.fixture
+def unpublished_collection():
+    collection = Collection.objects.create(
+        name='Unpublished collection',
+        slug='unpublished-collection',
+        is_published=False)
+    return collection
+
+
 @pytest.fixture
 def page(db):
     data = {