Repository: saleor/saleor
Pull request: Create general abstraction for object metadata
Metadata is a convenient way of extending existing data model, both internally and externally. API clients, staff, plugins and integrations can use both private and public metadata fields to store useful context and not depend on service itself for data interpretation and business logic application. 
Recently _metadata_ was introduced to _User_ model for storage of payment gateway customer context. Since it appears to be crucial mechanism for future integrations, it is worth extracting into reusable components and applied to wide range of objects in _Saleor_, ex. Product, ProductType, Order, Checkout.

Metadata consists of identical public and private fields, _meta_ is available for access through standard API query, _privateMeta_ is accessible to internal service implementation and staff/admin API access.
Data model for metadata is constructed from key-value stores that are named and grouped into namespaces.

- [x] Expose private user metadata through API
- [x] Extract private metadata to mixin
- [x] Add public metadata to mixin and graphql
- [x] Generalize _metadata_ mutations
- [x] Migrate _Product_,  _ProductType_  and vatlayer logic to new metadata
- [x] Apply new base to all required models

Closes #4425 
_Order_ can't be extended due to #4337

Models extended with metadata:
- Product
- ProductType
- ProductVariant
- Category
- Collection
- Attribute
- User
- DigitalContent
- Checkout

### Pull Request Checklist

<!-- Please keep this section. It will make maintainer's life easier. -->

1. [x] Privileged views and APIs are guarded by proper permission checks.
1. [x] All visible strings are translated with proper context.
1. [x] All data-formatting is locale-aware (dates, numbers, and so on).
1. [x] Database queries are optimized and the number of queries is constant.
1. [x] Database migration files are up to date.
1. [x] The changes are tested.
1. [ ] The code is documented (docstrings, project documentation).
1. [x] GraphQL schema and type definitions are up to date.
1. [x] Changes are mentioned in the changelog.


Problem statement:
Create meta mixin for models
### What I'm trying to achieve
Products has already been extended with metadata, now we add private meta field to _User_ model. More will surely come.

### Describe a proposed solution
Generalize metadata into public and private fields introduced in dedicated mixin making to possible to access metadata for virtually all core saleor models.

Hints from issue discussion:

Commit message: Fix permissions

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b46261b9de5..2b06dd46a1a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ All notable, unreleased changes to this project will be documented in this file.
 - Fix dashboard menu styles - #4491 by @benekex2
 - Do not allow random ids to appear in snapshots - #4495 by @dominik-zeglen
 - Order is no longer created when the payment was unsuccessful in the API - #4500 by @NyanKiyoshi
+- Create general abstraction for object metadata - #4447 by @salwator
 
 ## 2.8.0
 
diff --git a/saleor/account/migrations/0029_user_meta.py b/saleor/account/migrations/0029_user_meta.py
new file mode 100644
index 00000000000..ea3a8128e47
--- /dev/null
+++ b/saleor/account/migrations/0029_user_meta.py
@@ -0,0 +1,22 @@
+# Generated by Django 2.2.3 on 2019-07-16 08:17
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+import saleor.core.utils.json_serializer
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("account", "0028_user_private_meta")]
+
+    operations = [
+        migrations.AddField(
+            model_name="user",
+            name="meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+            ),
+        )
+    ]
diff --git a/saleor/account/migrations/0030_auto_20190719_0733.py b/saleor/account/migrations/0030_auto_20190719_0733.py
new file mode 100644
index 00000000000..99b1e24472f
--- /dev/null
+++ b/saleor/account/migrations/0030_auto_20190719_0733.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.2.3 on 2019-07-19 12:33
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+import saleor.core.utils.json_serializer
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("account", "0029_user_meta")]
+
+    operations = [
+        migrations.AlterField(
+            model_name="user",
+            name="meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        )
+    ]
diff --git a/saleor/account/migrations/0031_auto_20190719_0745.py b/saleor/account/migrations/0031_auto_20190719_0745.py
new file mode 100644
index 00000000000..efc1ce6c123
--- /dev/null
+++ b/saleor/account/migrations/0031_auto_20190719_0745.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.2.3 on 2019-07-19 12:45
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+import saleor.core.utils.json_serializer
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("account", "0030_auto_20190719_0733")]
+
+    operations = [
+        migrations.AlterField(
+            model_name="user",
+            name="private_meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        )
+    ]
diff --git a/saleor/account/models.py b/saleor/account/models.py
index 8ca624fefbb..c948787c4d7 100644
--- a/saleor/account/models.py
+++ b/saleor/account/models.py
@@ -16,6 +16,7 @@
 from phonenumber_field.modelfields import PhoneNumber, PhoneNumberField
 from versatileimagefield.fields import VersatileImageField
 
+from ..core.models import ModelWithMetadata
 from ..core.utils.json_serializer import CustomJsonEncoder
 from . import CustomerEvents
 from .validators import validate_possible_number
@@ -131,7 +132,7 @@ def get_token():
     return str(uuid.uuid4())
 
 
-class User(PermissionsMixin, AbstractBaseUser):
+class User(PermissionsMixin, ModelWithMetadata, AbstractBaseUser):
     email = models.EmailField(unique=True)
     first_name = models.CharField(max_length=256, blank=True)
     last_name = models.CharField(max_length=256, blank=True)
@@ -151,8 +152,6 @@ class User(PermissionsMixin, AbstractBaseUser):
     )
     avatar = VersatileImageField(upload_to="user-avatars", blank=True, null=True)
 
-    private_meta = JSONField(blank=True, default=dict, encoder=CustomJsonEncoder)
-
     USERNAME_FIELD = "email"
 
     objects = UserManager()
@@ -189,14 +188,6 @@ def get_ajax_label(self):
             return "%s %s (%s)" % (address.first_name, address.last_name, self.email)
         return self.email
 
-    def get_private_meta(self, label):
-        return self.private_meta.get(label, {})
-
-    def store_private_meta(self, label, key, value):
-        if label not in self.private_meta:
-            self.private_meta[label] = {}
-        self.private_meta[label][str(key)] = value
-
 
 class CustomerNote(models.Model):
     user = models.ForeignKey(
diff --git a/saleor/core/models.py b/saleor/core/models.py
index 7cbbc247d37..cb20d059ae1 100644
--- a/saleor/core/models.py
+++ b/saleor/core/models.py
@@ -1,8 +1,11 @@
 import datetime
 
+from django.contrib.postgres.fields import JSONField
 from django.db import models
 from django.db.models import F, Max, Q
 
+from .utils.json_serializer import CustomJsonEncoder
+
 
 class SortableModel(models.Model):
     sort_order = models.PositiveIntegerField(editable=False, db_index=True, null=True)
@@ -64,3 +67,35 @@ def is_visible(self):
             self.publication_date is None
             or self.publication_date < datetime.date.today()
         )
+
+
+class ModelWithMetadata(models.Model):
+    private_meta = JSONField(
+        blank=True, null=True, default=dict, encoder=CustomJsonEncoder
+    )
+    meta = JSONField(blank=True, null=True, default=dict, encoder=CustomJsonEncoder)
+
+    class Meta:
+        abstract = True
+
+    def get_private_meta(self, namespace: str, client: str) -> dict:
+        return self.private_meta.get(namespace, {}).get(client, {})
+
+    def store_private_meta(self, namespace: str, client: str, item: dict):
+        if namespace not in self.private_meta:
+            self.private_meta[namespace] = {}
+        self.private_meta[namespace][str(client)] = item
+
+    def clear_stored_private_meta_for_client(self, namespace: str, client: str):
+        self.private_meta.get(namespace, {}).pop(client, None)
+
+    def get_meta(self, namespace: str, client: str) -> dict:
+        return self.meta.get(namespace, {}).get(client, {})
+
+    def store_meta(self, namespace: str, client: str, item: dict):
+        if namespace not in self.meta:
+            self.meta[namespace] = {}
+        self.meta[namespace][str(client)] = item
+
+    def clear_stored_meta_for_client(self, namespace: str, client: str):
+        self.meta.get(namespace, {}).pop(client, None)
diff --git a/saleor/core/taxes/vatlayer/interface.py b/saleor/core/taxes/vatlayer/interface.py
index c1a1ee2c478..67e62e629b8 100644
--- a/saleor/core/taxes/vatlayer/interface.py
+++ b/saleor/core/taxes/vatlayer/interface.py
@@ -22,6 +22,7 @@
 
 
 META_FIELD = "vatlayer"
+META_NAMESPACE = "taxes"
 
 
 def calculate_checkout_total(
@@ -132,16 +133,20 @@ def apply_taxes_to_shipping_price_range(
 def assign_tax_to_object_meta(obj: Union["Product", "ProductType"], tax_code: str):
     if tax_code not in dict(TaxRateType.CHOICES):
         return
-    if not hasattr(obj, "meta"):
+    if not hasattr(obj, "private_meta"):
         return
-    if "taxes" not in obj.meta:
-        obj.meta["taxes"] = {}
-    obj.meta["taxes"][META_FIELD] = {"code": tax_code, "description": tax_code}
+    tax_item = {"code": tax_code, "description": tax_code}
+    stored_tax_meta = obj.get_private_meta(namespace=META_NAMESPACE, client=META_FIELD)
+    stored_tax_meta.update(tax_item)
+    obj.store_private_meta(
+        namespace=META_NAMESPACE, client=META_FIELD, item=stored_tax_meta
+    )
+    obj.save()
 
 
 def get_tax_from_object_meta(obj: Union["Product", "ProductType"]) -> TaxType:
-    if not hasattr(obj, "meta"):
+    if not hasattr(obj, "private_meta"):
         return TaxType(code="", description="")
-    tax = obj.meta.get("taxes", {}).get(META_FIELD, {})
+    tax = obj.get_private_meta(namespace=META_NAMESPACE, client=META_FIELD)
 
     return TaxType(code=tax.get("code", ""), description=tax.get("description", ""))
diff --git a/saleor/graphql/account/mutations.py b/saleor/graphql/account/mutations.py
index 430e30792cd..0923b572970 100644
--- a/saleor/graphql/account/mutations.py
+++ b/saleor/graphql/account/mutations.py
@@ -23,7 +23,13 @@
 from ..account.i18n import I18nMixin
 from ..account.types import Address, AddressInput, User
 from ..core.enums import PermissionEnum
-from ..core.mutations import BaseMutation, ModelDeleteMutation, ModelMutation
+from ..core.mutations import (
+    BaseMutation,
+    ClearMetaBaseMutation,
+    ModelDeleteMutation,
+    ModelMutation,
+    UpdateMetaBaseMutation,
+)
 from ..core.types import Upload
 from ..core.utils import validate_image_file
 from .utils import CustomerDeleteMixin, StaffDeleteMixin, UserDeleteMixin
@@ -783,3 +789,31 @@ def perform_mutation(cls, _root, info):
         user.avatar.delete_sized_images()
         user.avatar.delete()
         return UserAvatarDelete(user=user)
+
+
+class UserUpdateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        description = "Updates metadata for user."
+        model = models.User
+
+
+class UserUpdatePrivateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        description = "Updates private metadata for user."
+        permissions = ("account.manage_users",)
+        model = models.User
+        public = False
+
+
+class UserClearStoredMeta(ClearMetaBaseMutation):
+    class Meta:
+        description = "Clear stored metadata value."
+        model = models.User
+
+
+class UserClearStoredPrivateMeta(ClearMetaBaseMutation):
+    class Meta:
+        description = "Clear stored metadata value."
+        model = models.User
+        permissions = ("account.manage_users",)
+        public = False
diff --git a/saleor/graphql/account/schema.py b/saleor/graphql/account/schema.py
index 881da54b4cd..efa0cf1d3ef 100644
--- a/saleor/graphql/account/schema.py
+++ b/saleor/graphql/account/schema.py
@@ -29,6 +29,10 @@
     StaffUpdate,
     UserAvatarDelete,
     UserAvatarUpdate,
+    UserClearStoredMeta,
+    UserClearStoredPrivateMeta,
+    UserUpdateMeta,
+    UserUpdatePrivateMeta,
 )
 from .resolvers import resolve_address_validator, resolve_customers, resolve_staff_users
 from .types import AddressValidationData, User
@@ -127,3 +131,9 @@ class AccountMutations(graphene.ObjectType):
     user_avatar_update = UserAvatarUpdate.Field()
     user_avatar_delete = UserAvatarDelete.Field()
     user_bulk_set_active = UserBulkSetActive.Field()
+
+    user_update_metadata = UserUpdateMeta.Field()
+    user_clear_stored_metadata = UserClearStoredMeta.Field()
+
+    user_update_private_metadata = UserUpdatePrivateMeta.Field()
+    user_clear_stored_private_metadata = UserClearStoredPrivateMeta.Field()
diff --git a/saleor/graphql/account/types.py b/saleor/graphql/account/types.py
index 97e319d9579..de6158bcfa3 100644
--- a/saleor/graphql/account/types.py
+++ b/saleor/graphql/account/types.py
@@ -11,7 +11,8 @@
 from ..checkout.types import Checkout
 from ..core.connection import CountableDjangoObjectType
 from ..core.fields import PrefetchingConnectionField
-from ..core.types import CountryDisplay, Image, PermissionDisplay
+from ..core.resolvers import resolve_meta, resolve_private_meta
+from ..core.types import CountryDisplay, Image, MetadataObjectType, PermissionDisplay
 from ..core.utils import get_node_optimized
 from ..utils import format_permissions_for_display
 from .enums import CustomerEventsEnum
@@ -150,7 +151,7 @@ def resolve_order_line(root: models.CustomerEvent, info):
         return None
 
 
-class User(CountableDjangoObjectType):
+class User(MetadataObjectType, CountableDjangoObjectType):
     addresses = gql_optimizer.field(
         graphene.List(Address, description="List of all user's addresses."),
         model_field="addresses",
@@ -263,6 +264,15 @@ def resolve_stored_payment_sources(root: models.User, _info):
 
         return resolve_payment_sources(root)
 
+    @staticmethod
+    @permission_required("account.manage_users")
+    def resolve_private_meta(root, _info):
+        return resolve_private_meta(root, _info)
+
+    @staticmethod
+    def resolve_meta(root, _info):
+        return resolve_meta(root, _info)
+
 
 class ChoiceValue(graphene.ObjectType):
     raw = graphene.String()
diff --git a/saleor/graphql/core/mutations.py b/saleor/graphql/core/mutations.py
index 85aa946f8e3..f793c0f78fa 100644
--- a/saleor/graphql/core/mutations.py
+++ b/saleor/graphql/core/mutations.py
@@ -18,7 +18,7 @@
 from ...account import models
 from ..account.types import User
 from ..utils import get_nodes
-from .types import Error, Upload
+from .types import Error, MetaInput, MetaPath, Upload
 from .utils import snake_to_camel_case
 
 registry = get_global_registry()
@@ -521,3 +521,142 @@ def mutate(cls, root, info, token, **kwargs):
             return super().mutate(root, info, token, **kwargs)
         except JSONWebTokenError:
             return None
+
+
+class BaseMetadataMutation(BaseMutation):
+    class Meta:
+        abstract = True
+
+    @classmethod
+    def __init_subclass_with_meta__(
+        cls,
+        arguments=None,
+        model=None,
+        public=True,
+        return_field_name=None,
+        _meta=None,
+        **kwargs,
+    ):
+        if not model:
+            raise ImproperlyConfigured("model is required for update meta mutation")
+        if not _meta:
+            _meta = MetaUpdateOptions(cls)
+        if not arguments:
+            arguments = {}
+        if not return_field_name:
+            return_field_name = get_model_name(model)
+        fields = get_output_fields(model, return_field_name)
+
+        _meta.model = model
+        _meta.public = public
+        _meta.return_field_name = return_field_name
+
+        super().__init_subclass_with_meta__(_meta=_meta, **kwargs)
+        cls._update_mutation_arguments_and_fields(arguments=arguments, fields=fields)
+
+    @classmethod
+    def get_store_method(cls, instance):
+        return (
+            getattr(instance, "store_meta")
+            if cls._meta.public
+            else getattr(instance, "store_private_meta")
+        )
+
+    @classmethod
+    def get_meta_method(cls, instance):
+        return (
+            getattr(instance, "get_meta")
+            if cls._meta.public
+            else getattr(instance, "get_private_meta")
+        )
+
+    @classmethod
+    def get_clear_method(cls, instance):
+        return (
+            getattr(instance, "clear_stored_meta_for_client")
+            if cls._meta.public
+            else getattr(instance, "clear_stored_private_meta_for_client")
+        )
+
+    @classmethod
+    def get_instance(cls, info, **data):
+        object_id = data.get("id")
+        if object_id:
+            model_type = registry.get_type_for_model(cls._meta.model)
+            instance = cls.get_node_or_error(info, object_id, only_type=model_type)
+        else:
+            instance = cls._meta.model()
+        return instance
+
+    @classmethod
+    def success_response(cls, instance):
+        """Return a success response."""
+        return cls(**{cls._meta.return_field_name: instance, "errors": []})
+
+
+class MetaUpdateOptions(MutationOptions):
+    model = None
+    return_field_name = None
+    public = True
+
+
+class UpdateMetaBaseMutation(BaseMetadataMutation):
+    class Meta:
+        abstract = True
+
+    class Arguments:
+        id = graphene.ID(description="ID of an object to update.", required=True)
+        input = MetaInput(
+            description="Fields required to update new or stored metadata item.",
+            required=True,
+        )
+
+    @classmethod
+    def perform_mutation(cls, root, info, **data):
+        instance = cls.get_instance(info, **data)
+        get_meta = cls.get_meta_method(instance)
+        store_meta = cls.get_store_method(instance)
+
+        metadata = data.pop("input")
+        stored_data = get_meta(metadata.namespace, metadata.client_name)
+        stored_data[metadata.key] = metadata.value
+        store_meta(
+            namespace=metadata.namespace, client=metadata.client_name, item=stored_data
+        )
+        instance.save()
+        return cls.success_response(instance)
+
+
+class ClearMetaBaseMutation(BaseMetadataMutation):
+    class Meta:
+        abstract = True
+
+    class Arguments:
+        id = graphene.ID(description="ID of a customer to update.", required=True)
+        input = MetaPath(
+            description="Fields required to identify stored metadata item.",
+            required=True,
+        )
+
+    @classmethod
+    def perform_mutation(cls, root, info, **data):
+        instance = cls.get_instance(info, **data)
+        get_meta = cls.get_meta_method(instance)
+        store_meta = cls.get_store_method(instance)
+        clear_meta = cls.get_clear_method(instance)
+
+        metadata = data.pop("input")
+        stored_data = get_meta(metadata.namespace, metadata.client_name)
+
+        cleared_value = stored_data.pop(metadata.key, None)
+        if not stored_data:
+            clear_meta(metadata.namespace, metadata.client_name)
+            instance.save()
+        elif cleared_value is not None:
+            store_meta(
+                namespace=metadata.namespace,
+                client=metadata.client_name,
+                item=stored_data,
+            )
+            instance.save()
+        return cls.success_response(instance)
diff --git a/saleor/graphql/core/resolvers.py b/saleor/graphql/core/resolvers.py
new file mode 100644
index 00000000000..878aa39dc38
--- /dev/null
+++ b/saleor/graphql/core/resolvers.py
@@ -0,0 +1,21 @@
+from operator import itemgetter
+
+
+def resolve_private_meta(root, _info):
+    return sorted(
+        [
+            {"namespace": namespace, "metadata": data}
+            for namespace, data in root.private_meta.items()
+        ],
+        key=itemgetter("namespace"),
+    )
+
+
+def resolve_meta(root, _info):
+    return sorted(
+        [
+            {"namespace": namespace, "metadata": data}
+            for namespace, data in root.meta.items()
+        ],
+        key=itemgetter("namespace"),
+    )
diff --git a/saleor/graphql/core/types/__init__.py b/saleor/graphql/core/types/__init__.py
index 92efe4e307d..cbce3b480f0 100644
--- a/saleor/graphql/core/types/__init__.py
+++ b/saleor/graphql/core/types/__init__.py
@@ -10,5 +10,6 @@
     Weight,
 )
 from .filter_input import FilterInputObjectType
+from .meta import MetadataObjectType, MetaInput, MetaPath
 from .money import VAT, Money, MoneyRange, ReducedRate, TaxedMoney, TaxedMoneyRange
 from .upload import Upload
diff --git a/saleor/graphql/core/types/meta.py b/saleor/graphql/core/types/meta.py
new file mode 100644
index 00000000000..4a0ef59812a
--- /dev/null
+++ b/saleor/graphql/core/types/meta.py
@@ -0,0 +1,68 @@
+from operator import itemgetter
+
+import graphene
+
+
+class MetaItem(graphene.ObjectType):
+    key = graphene.String(required=True, description="Key for stored data.")
+    value = graphene.String(required=True, description="Stored metadata value.")
+
+
+class MetaClientStore(graphene.ObjectType):
+    name = graphene.String(required=True, description="Metadata clients name.")
+    metadata = graphene.List(
+        MetaItem, required=True, description="Metadata stored for a client."
+    )
+
+    @staticmethod
+    def resolve_metadata(root, _info):
+        return sorted(
+            [{"key": k, "value": v} for k, v in root["metadata"].items()],
+            key=itemgetter("key"),
+        )
+
+
+class MetaStore(graphene.ObjectType):
+    namespace = graphene.String(
+        required=True, description="Name of metadata client group."
+    )
+    clients = graphene.List(
+        MetaClientStore,
+        required=True,
+        description="List of clients that stored metadata in a group.",
+    )
+
+    @staticmethod
+    def resolve_clients(root: dict, _info):
+        return sorted(
+            [
+                {"name": key, "metadata": value}
+                for key, value in root["metadata"].items()
+            ],
+            key=itemgetter("name"),
+        )
+
+
+class MetaPath(graphene.InputObjectType):
+    namespace = graphene.String(
+        required=True, description="Name of metadata client group."
+    )
+    client_name = graphene.String(required=True, description="Metadata clients name.")
+    key = graphene.String(required=True, description="Key for stored data.")
+
+
+class MetaInput(MetaPath):
+    value = graphene.String(required=True, description="Stored metadata value.")
+
+
+class MetadataObjectType(graphene.ObjectType):
+    private_meta = graphene.List(
+        MetaStore,
+        required=True,
+        description="List of privately stored metadata namespaces.",
+    )
+    meta = graphene.List(
+        MetaStore,
+        required=True,
+        description="List of publicly stored metadata namespaces.",
+    )
diff --git a/saleor/graphql/product/mutations/attributes.py b/saleor/graphql/product/mutations/attributes.py
index a5b5338fd15..39fa90ac8f3 100644
--- a/saleor/graphql/product/mutations/attributes.py
+++ b/saleor/graphql/product/mutations/attributes.py
@@ -4,7 +4,12 @@
 from django.template.defaultfilters import slugify
 
 from ....product import models
-from ...core.mutations import ModelDeleteMutation, ModelMutation
+from ...core.mutations import (
+    ClearMetaBaseMutation,
+    ModelDeleteMutation,
+    ModelMutation,
+    UpdateMetaBaseMutation,
+)
 from ...product.types import ProductType
 from ..descriptions import AttributeDescriptions, AttributeValueDescriptions
 from ..enums import AttributeTypeEnum
@@ -230,6 +235,36 @@ def success_response(cls, instance):
         return response
 
 
+class AttributeUpdateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        model = models.Attribute
+        description = "Update public metadata for Attribute "
+        permissions = ("product.manage_products",)
+
+
+class AttributeClearMeta(ClearMetaBaseMutation):
+    class Meta:
+        description = "Clears public metadata item for Attribute"
+        model = models.Attribute
+        permissions = ("product.manage_products",)
+
+
+class AttributeUpdatePrivateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        description = "Update public metadata for Attribute"
+        model = models.Attribute
+        public = False
+        permissions = ("product.manage_products",)
+
+
+class AttributeClearPrivateMeta(ClearMetaBaseMutation):
+    class Meta:
+        description = "Clears public metadata item for Attribute"
+        model = models.Attribute
+        public = False
+        permissions = ("product.manage_products",)
+
+
 class AttributeValueCreate(ModelMutation):
     attribute = graphene.Field(Attribute, description="A related Attribute.")
 
diff --git a/saleor/graphql/product/mutations/products.py b/saleor/graphql/product/mutations/products.py
index 39f1f22473d..c6dd1c2a072 100644
--- a/saleor/graphql/product/mutations/products.py
+++ b/saleor/graphql/product/mutations/products.py
@@ -17,7 +17,13 @@
 )
 from ....product.utils.attributes import get_name_from_attributes
 from ...core.enums import TaxRateType
-from ...core.mutations import BaseMutation, ModelDeleteMutation, ModelMutation
+from ...core.mutations import (
+    BaseMutation,
+    ClearMetaBaseMutation,
+    ModelDeleteMutation,
+    ModelMutation,
+    UpdateMetaBaseMutation,
+)
 from ...core.scalars import Decimal, WeightScalar
 from ...core.types import SeoInput, Upload
 from ...core.utils import (
@@ -324,6 +330,66 @@ def perform_mutation(cls, _root, info, collection_id, products):
         return CollectionRemoveProducts(collection=collection)
 
 
+class CollectionUpdateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        model = models.Collection
+        description = "Update public metadata for Collection"
+        permissions = ("product.manage_products",)
+
+
+class CollectionClearMeta(ClearMetaBaseMutation):
+    class Meta:
+        model = models.Collection
+        description = "Clears public metadata item for Collection"
+        permissions = ("product.manage_products",)
+
+
+class CollectionUpdatePrivateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        model = models.Collection
+        public = False
+        description = "Update public metadata for Collection"
+        permissions = ("product.manage_products",)
+
+
+class CollectionClearPrivateMeta(ClearMetaBaseMutation):
+    class Meta:
+        model = models.Collection
+        public = False
+        description = "Clears public metadata item for Collection"
+        permissions = ("product.manage_products",)
+
+
+class CategoryUpdateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        model = models.Category
+        description = "Update public metadata for category"
+        permissions = ("product.manage_products",)
+
+
+class CategoryClearMeta(ClearMetaBaseMutation):
+    class Meta:
+        model = models.Category
+        description = "Clears public metadata item for category"
+        permissions = ("product.manage_products",)
+
+
+class CategoryUpdatePrivateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        model = models.Category
+        public = False
+        description = "Update public metadata for category"
+        permissions = ("product.manage_products",)
+
+
+class CategoryClearPrivateMeta(ClearMetaBaseMutation):
+    class Meta:
+        model = models.Category
+        public = False
+        description = "Clears public metadata item for category"
+        permissions = ("product.manage_products",)
+
+
 class AttributeValueInput(InputObjectType):
     slug = graphene.String(required=True, description="Slug of an attribute.")
     value = graphene.String(required=True, description="Value of an attribute.")
@@ -530,6 +596,36 @@ class Meta:
         permissions = ("product.manage_products",)
 
 
+class ProductUpdateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        model = models.Product
+        description = "Update public metadata for product"
+        permissions = ("product.manage_products",)
+
+
+class ProductClearMeta(ClearMetaBaseMutation):
+    class Meta:
+        description = "Clears public metadata item for product"
+        model = models.Product
+        permissions = ("product.manage_products",)
+
+
+class ProductUpdatePrivateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        description = "Update public metadata for product"
+        model = models.Product
+        public = False
+        permissions = ("product.manage_products",)
+
+
+class ProductClearPrivateMeta(ClearMetaBaseMutation):
+    class Meta:
+        description = "Clears public metadata item for product"
+        model = models.Product
+        public = False
+        permissions = ("product.manage_products",)
+
+
 class ProductVariantInput(graphene.InputObjectType):
     attributes = graphene.List(
         AttributeValueInput,
@@ -644,6 +740,36 @@ class Meta:
         permissions = ("product.manage_products",)
 
 
+class ProductVariantUpdateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        model = models.ProductVariant
+        description = "Update public metadata for product variant"
+        permissions = ("product.manage_products",)
+
+
+class ProductVariantClearMeta(ClearMetaBaseMutation):
+    class Meta:
+        model = models.ProductVariant
+        description = "Clears public metadata item for product variant"
+        permissions = ("product.manage_products",)
+
+
+class ProductVariantUpdatePrivateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        model = models.ProductVariant
+        public = False
+        description = "Update public metadata for product variant"
+        permissions = ("product.manage_products",)
+
+
+class ProductVariantClearPrivateMeta(ClearMetaBaseMutation):
+    class Meta:
+        model = models.ProductVariant
+        public = False
+        description = "Clears public metadata item for product variant"
+        permissions = ("product.manage_products",)
+
+
 class ProductTypeInput(graphene.InputObjectType):
     name = graphene.String(description="Name of the product type.")
     has_variants = graphene.Boolean(
@@ -745,6 +871,36 @@ class Meta:
         permissions = ("product.manage_products",)
 
 
+class ProductTypeUpdateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        model = models.ProductType
+        description = "Update public metadata for product type"
+        permissions = ("product.manage_products",)
+
+
+class ProductTypeClearMeta(ClearMetaBaseMutation):
+    class Meta:
+        description = "Clears public metadata item for product type"
+        model = models.ProductType
+        permissions = ("product.manage_products",)
+
+
+class ProductTypeUpdatePrivateMeta(UpdateMetaBaseMutation):
+    class Meta:
+        description = "Update public metadata for product type"
+        model = models.ProductType
+        public = False
+        permissions = ("product.manage_products",)
+
+
+class ProductTypeClearPrivateMeta(ClearMetaBaseMutation):
+    class Meta:
+        description = "Clears public metadata item for product type"
+        model = models.ProductType
+        public = False
+        permissions = ("product.manage_products",)
+
+
 class ProductImageCreateInput(graphene.InputObjectType):
     alt = graphene.String(description="Alt text for an image.")
     image = Upload(
diff --git a/saleor/graphql/product/schema.py b/saleor/graphql/product/schema.py
index ab75ccd633d..8635eb5653a 100644
--- a/saleor/graphql/product/schema.py
+++ b/saleor/graphql/product/schema.py
@@ -27,9 +27,13 @@
 from .enums import StockAvailability
 from .filters import CollectionFilter, ProductFilter, ProductTypeFilter
 from .mutations.attributes import (
+    AttributeClearMeta,
+    AttributeClearPrivateMeta,
     AttributeCreate,
     AttributeDelete,
     AttributeUpdate,
+    AttributeUpdateMeta,
+    AttributeUpdatePrivateMeta,
     AttributeValueCreate,
     AttributeValueDelete,
     AttributeValueUpdate,
@@ -41,28 +45,48 @@
     DigitalContentUrlCreate,
 )
 from .mutations.products import (
+    CategoryClearMeta,
+    CategoryClearPrivateMeta,
     CategoryCreate,
     CategoryDelete,
     CategoryUpdate,
+    CategoryUpdateMeta,
+    CategoryUpdatePrivateMeta,
     CollectionAddProducts,
+    CollectionClearMeta,
+    CollectionClearPrivateMeta,
     CollectionCreate,
     CollectionDelete,
     CollectionRemoveProducts,
     CollectionReorderProducts,
     CollectionUpdate,
+    CollectionUpdateMeta,
+    CollectionUpdatePrivateMeta,
+    ProductClearMeta,
+    ProductClearPrivateMeta,
     ProductCreate,
     ProductDelete,
     ProductImageCreate,
     ProductImageDelete,
     ProductImageReorder,
     ProductImageUpdate,
+    ProductTypeClearMeta,
+    ProductTypeClearPrivateMeta,
     ProductTypeCreate,
     ProductTypeDelete,
     ProductTypeUpdate,
+    ProductTypeUpdateMeta,
+    ProductTypeUpdatePrivateMeta,
     ProductUpdate,
+    ProductUpdateMeta,
+    ProductUpdatePrivateMeta,
+    ProductVariantClearMeta,
+    ProductVariantClearPrivateMeta,
     ProductVariantCreate,
     ProductVariantDelete,
     ProductVariantUpdate,
+    ProductVariantUpdateMeta,
+    ProductVariantUpdatePrivateMeta,
     VariantImageAssign,
     VariantImageUnassign,
 )
@@ -263,6 +287,10 @@ class ProductMutations(graphene.ObjectType):
     attribute_bulk_delete = AttributeBulkDelete.Field()
     attribute_update = AttributeUpdate.Field()
     attribute_translate = AttributeTranslate.Field()
+    attribute_update_metadata = AttributeUpdateMeta.Field()
+    attribute_clear_metadata = AttributeClearMeta.Field()
+    attribute_update_private_metadata = AttributeUpdatePrivateMeta.Field()
+    attribute_clear_private_metadata = AttributeClearPrivateMeta.Field()
 
     attribute_value_create = AttributeValueCreate.Field()
     attribute_value_delete = AttributeValueDelete.Field()
@@ -275,6 +303,10 @@ class ProductMutations(graphene.ObjectType):
     category_bulk_delete = CategoryBulkDelete.Field()
     category_update = CategoryUpdate.Field()
     category_translate = CategoryTranslate.Field()
+    category_update_metadata = CategoryUpdateMeta.Field()
+    category_clear_metadata = CategoryClearMeta.Field()
+    category_update_private_metadata = CategoryUpdatePrivateMeta.Field()
+    category_clear_private_metadata = CategoryClearPrivateMeta.Field()
 
     collection_add_products = CollectionAddProducts.Field()
     collection_create = CollectionCreate.Field()
@@ -285,6 +317,10 @@ class ProductMutations(graphene.ObjectType):
     collection_remove_products = CollectionRemoveProducts.Field()
     collection_update = CollectionUpdate.Field()
     collection_translate = CollectionTranslate.Field()
+    collection_update_metadata = CollectionUpdateMeta.Field()
+    collection_clear_metadata = CollectionClearMeta.Field()
+    collection_update_private_metadata = CollectionUpdatePrivateMeta.Field()
+    collection_clear_private_metadata = CollectionClearPrivateMeta.Field()
 
     product_create = ProductCreate.Field()
     product_delete = ProductDelete.Field()
@@ -292,6 +328,10 @@ class ProductMutations(graphene.ObjectType):
     product_bulk_publish = ProductBulkPublish.Field()
     product_update = ProductUpdate.Field()
     product_translate = ProductTranslate.Field()
+    product_update_metadata = ProductUpdateMeta.Field()
+    product_clear_metadata = ProductClearMeta.Field()
+    product_update_private_metadata = ProductUpdatePrivateMeta.Field()
+    product_clear_private_metadata = ProductClearPrivateMeta.Field()
 
     product_image_create = ProductImageCreate.Field()
     product_image_delete = ProductImageDelete.Field()
@@ -303,6 +343,10 @@ class ProductMutations(graphene.ObjectType):
     product_type_delete = ProductTypeDelete.Field()
     product_type_bulk_delete = ProductTypeBulkDelete.Field()
     product_type_update = ProductTypeUpdate.Field()
+    product_type_update_metadata = ProductTypeUpdateMeta.Field()
+    product_type_clear_metadata = ProductTypeClearMeta.Field()
+    product_type_update_private_metadata = ProductTypeUpdatePrivateMeta.Field()
+    product_type_clear_private_metadata = ProductTypeClearPrivateMeta.Field()
 
     digital_content_create = DigitalContentCreate.Field()
     digital_content_delete = DigitalContentDelete.Field()
@@ -315,6 +359,10 @@ class ProductMutations(graphene.ObjectType):
     product_variant_bulk_delete = ProductVariantBulkDelete.Field()
     product_variant_update = ProductVariantUpdate.Field()
     product_variant_translate = ProductVariantTranslate.Field()
+    product_variant_update_metadata = ProductVariantUpdateMeta.Field()
+    product_variant_clear_metadata = ProductVariantClearMeta.Field()
+    product_variant_update_private_metadata = ProductVariantUpdatePrivateMeta.Field()
+    product_variant_clear_private_metadata = ProductVariantClearPrivateMeta.Field()
 
     variant_image_assign = VariantImageAssign.Field()
     variant_image_unassign = VariantImageUnassign.Field()
diff --git a/saleor/graphql/product/types/attributes.py b/saleor/graphql/product/types/attributes.py
index 0c223ab682e..3a14fd1f626 100644
--- a/saleor/graphql/product/types/attributes.py
+++ b/saleor/graphql/product/types/attributes.py
@@ -3,9 +3,12 @@
 import graphene
 import graphene_django_optimizer as gql_optimizer
 from graphene import relay
+from graphql_jwt.decorators import permission_required
 
 from ....product import models
 from ...core.connection import CountableDjangoObjectType
+from ...core.resolvers import resolve_meta, resolve_private_meta
+from ...core.types import MetadataObjectType
 from ...translations.enums import LanguageCodeEnum
 from ...translations.resolvers import resolve_translation
 from ...translations.types import AttributeTranslation, AttributeValueTranslation
@@ -55,7 +58,7 @@ def resolve_type(root: models.AttributeValue, *_args):
         return resolve_attribute_value_type(root.value)
 
 
-class Attribute(CountableDjangoObjectType):
+class Attribute(CountableDjangoObjectType, MetadataObjectType):
     name = graphene.String(description=AttributeDescriptions.NAME)
     slug = graphene.String(description=AttributeDescriptions.SLUG)
     values = gql_optimizer.field(
@@ -87,6 +90,15 @@ class Meta:
     def resolve_values(root: models.Attribute, *_args):
         return root.values.all()
 
+    @staticmethod
+    @permission_required("product.manage_products")
+    def resolve_private_meta(root, _info):
+        return resolve_private_meta(root, _info)
+
+    @staticmethod
+    def resolve_meta(root, _info):
+        return resolve_meta(root, _info)
+
 
 class SelectedAttribute(graphene.ObjectType):
     attribute = graphene.Field(
diff --git a/saleor/graphql/product/types/digital_contents.py b/saleor/graphql/product/types/digital_contents.py
index 525fe4380b6..e24f3e737a8 100644
--- a/saleor/graphql/product/types/digital_contents.py
+++ b/saleor/graphql/product/types/digital_contents.py
@@ -1,9 +1,12 @@
 import graphene
 import graphene_django_optimizer as gql_optimizer
 from graphene import relay
+from graphql_jwt.decorators import permission_required
 
 from ....product import models
 from ...core.connection import CountableDjangoObjectType
+from ...core.resolvers import resolve_meta, resolve_private_meta
+from ...core.types import MetadataObjectType
 
 
 class DigitalContentUrl(CountableDjangoObjectType):
@@ -19,7 +22,7 @@ def resolve_url(root: models.DigitalContentUrl, *_args):
         return root.get_absolute_url()
 
 
-class DigitalContent(CountableDjangoObjectType):
+class DigitalContent(CountableDjangoObjectType, MetadataObjectType):
     urls = gql_optimizer.field(
         graphene.List(
             lambda: DigitalContentUrl,
@@ -45,3 +48,12 @@ class Meta:
     def resolve_urls(root: models.DigitalContent, info, **_kwargs):
         qs = root.urls.all()
         return gql_optimizer.query(qs, info)
+
+    @staticmethod
+    @permission_required("product.manage_products")
+    def resolve_private_meta(root, _info):
+        return resolve_private_meta(root, _info)
+
+    @staticmethod
+    def resolve_meta(root, _info):
+        return resolve_meta(root, _info)
diff --git a/saleor/graphql/product/types/products.py b/saleor/graphql/product/types/products.py
index c353c67f54e..b8d606e52b0 100644
--- a/saleor/graphql/product/types/products.py
+++ b/saleor/graphql/product/types/products.py
@@ -21,7 +21,16 @@
 from ...core.connection import CountableDjangoObjectType
 from ...core.enums import ReportingPeriod, TaxRateType
 from ...core.fields import PrefetchingConnectionField
-from ...core.types import Image, Money, MoneyRange, TaxedMoney, TaxedMoneyRange, TaxType
+from ...core.resolvers import resolve_meta, resolve_private_meta
+from ...core.types import (
+    Image,
+    MetadataObjectType,
+    Money,
+    MoneyRange,
+    TaxedMoney,
+    TaxedMoneyRange,
+    TaxType,
+)
 from ...translations.enums import LanguageCodeEnum
 from ...translations.resolvers import resolve_translation
 from ...translations.types import (
@@ -159,7 +168,7 @@ class Meta:
         description = "Represents availability of a product in the storefront."
 
 
-class ProductVariant(CountableDjangoObjectType):
+class ProductVariant(CountableDjangoObjectType, MetadataObjectType):
     stock_quantity = graphene.Int(
         required=True, description="Quantity of a product available for sale."
     )
@@ -340,8 +349,17 @@ def get_node(cls, info, id):
         qs = cls._meta.model.objects.filter(product__id__in=visible_products)
         return cls.maybe_optimize(info, qs, id)
 
+    @staticmethod
+    @permission_required("product.manage_products")
+    def resolve_private_meta(root, _info):
+        return resolve_private_meta(root, _info)
 
-class Product(CountableDjangoObjectType):
+    @staticmethod
+    def resolve_meta(root, _info):
+        return resolve_meta(root, _info)
+
+
+class Product(CountableDjangoObjectType, MetadataObjectType):
     url = graphene.String(
         description="The storefront URL for the product.", required=True
     )
@@ -574,8 +592,17 @@ def get_node(cls, info, pk):
             return cls.maybe_optimize(info, qs, pk)
         return None
 
+    @staticmethod
+    @permission_required("product.manage_products")
+    def resolve_private_meta(root, _info):
+        return resolve_private_meta(root, _info)
+
+    @staticmethod
+    def resolve_meta(root, _info):
+        return resolve_meta(root, _info)
 
-class ProductType(CountableDjangoObjectType):
+
+class ProductType(CountableDjangoObjectType, MetadataObjectType):
     products = gql_optimizer.field(
         PrefetchingConnectionField(
             Product, description="List of products of this type."
@@ -636,8 +663,17 @@ def resolve_products(root: models.ProductType, info, **_kwargs):
         qs = root.products.visible_to_user(info.context.user)
         return gql_optimizer.query(qs, info)
 
+    @staticmethod
+    @permission_required("account.manage_products")
+    def resolve_private_meta(root, _info):
+        return resolve_private_meta(root, _info)
 
-class Collection(CountableDjangoObjectType):
+    @staticmethod
+    def resolve_meta(root, _info):
+        return resolve_meta(root, _info)
+
+
+class Collection(CountableDjangoObjectType, MetadataObjectType):
     products = gql_optimizer.field(
         PrefetchingConnectionField(
             Product, description="List of products in this collection."
@@ -709,8 +745,17 @@ def get_node(cls, info, id):
             return cls.maybe_optimize(info, qs, id)
         return None
 
+    @staticmethod
+    @permission_required("product.manage_products")
+    def resolve_private_meta(root, _info):
+        return resolve_private_meta(root, _info)
+
+    @staticmethod
+    def resolve_meta(root, _info):
+        return resolve_meta(root, _info)
+
 
-class Category(CountableDjangoObjectType):
+class Category(CountableDjangoObjectType, MetadataObjectType):
     ancestors = PrefetchingConnectionField(
         lambda: Category, description="List of ancestors of the category."
     )
@@ -795,6 +840,15 @@ def resolve_products(root: models.Category, info, **_kwargs):
         qs = qs.filter(category__in=tree)
         return gql_optimizer.query(qs, info)
 
+    @staticmethod
+    @permission_required("product.manage_products")
+    def resolve_private_meta(root, _info):
+        return resolve_private_meta(root, _info)
+
+    @staticmethod
+    def resolve_meta(root, _info):
+        return resolve_meta(root, _info)
+
 
 class ProductImage(CountableDjangoObjectType):
     url = graphene.String(
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index b00f24179c7..0448dafece5 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -107,6 +107,8 @@ type Attribute implements Node {
   id: ID!
   productType: ProductType
   productVariantType: ProductType
+  privateMeta: [MetaStore]!
+  meta: [MetaStore]!
   name: String
   slug: String
   values: [AttributeValue]
@@ -118,6 +120,16 @@ type AttributeBulkDelete {
   count: Int!
 }
 
+type AttributeClearMeta {
+  errors: [Error!]
+  attribute: Attribute
+}
+
+type AttributeClearPrivateMeta {
+  errors: [Error!]
+  attribute: Attribute
+}
+
 type AttributeCountableConnection {
   pageInfo: PageInfo!
   edges: [AttributeCountableEdge!]!
@@ -181,6 +193,16 @@ input AttributeUpdateInput {
   addValues: [AttributeValueCreateInput]
 }
 
+type AttributeUpdateMeta {
+  errors: [Error!]
+  attribute: Attribute
+}
+
+type AttributeUpdatePrivateMeta {
+  errors: [Error!]
+  attribute: Attribute
+}
+
 type AttributeValue implements Node {
   sortOrder: Int
   id: ID!
@@ -285,6 +307,8 @@ type Category implements Node {
   descriptionJson: JSONString!
   parent: Category
   level: Int!
+  privateMeta: [MetaStore]!
+  meta: [MetaStore]!
   ancestors(before: String, after: String, first: Int, last: Int): CategoryCountableConnection
   products(before: String, after: String, first: Int, last: Int): ProductCountableConnection
   url: String
@@ -298,6 +322,16 @@ type CategoryBulkDelete {
   count: Int!
 }
 
+type CategoryClearMeta {
+  errors: [Error!]
+  category: Category
+}
+
+type CategoryClearPrivateMeta {
+  errors: [Error!]
+  category: Category
+}
+
 type CategoryCountableConnection {
   pageInfo: PageInfo!
   edges: [CategoryCountableEdge!]!
@@ -349,6 +383,16 @@ type CategoryUpdate {
   category: Category
 }
 
+type CategoryUpdateMeta {
+  errors: [Error!]
+  category: Category
+}
+
+type CategoryUpdatePrivateMeta {
+  errors: [Error!]
+  category: Category
+}
+
 type Checkout implements Node {
   created: DateTime!
   lastChange: DateTime!
@@ -508,6 +552,8 @@ type Collection implements Node {
   slug: String!
   description: String!
   descriptionJson: JSONString!
+  privateMeta: [MetaStore]!
+  meta: [MetaStore]!
   products(before: String, after: String, first: Int, last: Int): ProductCountableConnection
   backgroundImage(size: Int): Image
   publishedDate: Date @deprecated(reason: "publishedDate is deprecated, use publicationDate instead")
@@ -529,6 +575,16 @@ type CollectionBulkPublish {
   count: Int!
 }
 
+type CollectionClearMeta {
+  errors: [Error!]
+  collection: Collection
+}
+
+type CollectionClearPrivateMeta {
+  errors: [Error!]
+  collection: Collection
+}
+
 type CollectionCountableConnection {
   pageInfo: PageInfo!
   edges: [CollectionCountableEdge!]!
@@ -615,6 +671,16 @@ type CollectionUpdate {
   collection: Collection
 }
 
+type CollectionUpdateMeta {
+  errors: [Error!]
+  collection: Collection
+}
+
+type CollectionUpdatePrivateMeta {
+  errors: [Error!]
+  collection: Collection
+}
+
 enum CountryCode {
   AF
   AX
@@ -1003,6 +1069,8 @@ type DigitalContent implements Node {
   urlValidDays: Int
   urls: [DigitalContentUrl]
   id: ID!
+  privateMeta: [MetaStore]!
+  meta: [MetaStore]!
 }
 
 type DigitalContentCountableConnection {
@@ -1499,6 +1567,34 @@ type MenuUpdate {
   menu: Menu
 }
 
+type MetaClientStore {
+  name: String!
+  metadata: [MetaItem]!
+}
+
+input MetaInput {
+  namespace: String!
+  clientName: String!
+  key: String!
+  value: String!
+}
+
+type MetaItem {
+  key: String!
+  value: String!
+}
+
+input MetaPath {
+  namespace: String!
+  clientName: String!
+  key: String!
+}
+
+type MetaStore {
+  namespace: String!
+  clients: [MetaClientStore]!
+}
+
 type Money {
   currency: String!
   amount: Float!
@@ -1538,6 +1634,10 @@ type Mutations {
   attributeBulkDelete(ids: [ID]!): AttributeBulkDelete
   attributeUpdate(id: ID!, input: AttributeUpdateInput!): AttributeUpdate
   attributeTranslate(id: ID!, input: NameTranslationInput!, languageCode: LanguageCodeEnum!): AttributeTranslate
+  attributeUpdateMetadata(id: ID!, input: MetaInput!): AttributeUpdateMeta
+  attributeClearMetadata(id: ID!, input: MetaPath!): AttributeClearMeta
+  attributeUpdatePrivateMetadata(id: ID!, input: MetaInput!): AttributeUpdatePrivateMeta
+  attributeClearPrivateMetadata(id: ID!, input: MetaPath!): AttributeClearPrivateMeta
   attributeValueCreate(attribute: ID!, input: AttributeValueCreateInput!): AttributeValueCreate
   attributeValueDelete(id: ID!): AttributeValueDelete
   attributeValueBulkDelete(ids: [ID]!): AttributeValueBulkDelete
@@ -1548,6 +1648,10 @@ type Mutations {
   categoryBulkDelete(ids: [ID]!): CategoryBulkDelete
   categoryUpdate(id: ID!, input: CategoryInput!): CategoryUpdate
   categoryTranslate(id: ID!, input: TranslationInput!, languageCode: LanguageCodeEnum!): CategoryTranslate
+  categoryUpdateMetadata(id: ID!, input: MetaInput!): CategoryUpdateMeta
+  categoryClearMetadata(id: ID!, input: MetaPath!): CategoryClearMeta
+  categoryUpdatePrivateMetadata(id: ID!, input: MetaInput!): CategoryUpdatePrivateMeta
+  categoryClearPrivateMetadata(id: ID!, input: MetaPath!): CategoryClearPrivateMeta
   collectionAddProducts(collectionId: ID!, products: [ID]!): CollectionAddProducts
   collectionCreate(input: CollectionCreateInput!): CollectionCreate
   collectionDelete(id: ID!): CollectionDelete
@@ -1557,12 +1661,20 @@ type Mutations {
   collectionRemoveProducts(collectionId: ID!, products: [ID]!): CollectionRemoveProducts
   collectionUpdate(id: ID!, input: CollectionInput!): CollectionUpdate
   collectionTranslate(id: ID!, input: TranslationInput!, languageCode: LanguageCodeEnum!): CollectionTranslate
+  collectionUpdateMetadata(id: ID!, input: MetaInput!): CollectionUpdateMeta
+  collectionClearMetadata(id: ID!, input: MetaPath!): CollectionClearMeta
+  collectionUpdatePrivateMetadata(id: ID!, input: MetaInput!): CollectionUpdatePrivateMeta
+  collectionClearPrivateMetadata(id: ID!, input: MetaPath!): CollectionClearPrivateMeta
   productCreate(input: ProductCreateInput!): ProductCreate
   productDelete(id: ID!): ProductDelete
   productBulkDelete(ids: [ID]!): ProductBulkDelete
   productBulkPublish(ids: [ID]!, isPublished: Boolean!): ProductBulkPublish
   productUpdate(id: ID!, input: ProductInput!): ProductUpdate
   productTranslate(id: ID!, input: TranslationInput!, languageCode: LanguageCodeEnum!): ProductTranslate
+  productUpdateMetadata(id: ID!, input: MetaInput!): ProductUpdateMeta
+  productClearMetadata(id: ID!, input: MetaPath!): ProductClearMeta
+  productUpdatePrivateMetadata(id: ID!, input: MetaInput!): ProductUpdatePrivateMeta
+  productClearPrivateMetadata(id: ID!, input: MetaPath!): ProductClearPrivateMeta
   productImageCreate(input: ProductImageCreateInput!): ProductImageCreate
   productImageDelete(id: ID!): ProductImageDelete
   productImageBulkDelete(ids: [ID]!): ProductImageBulkDelete
@@ -1572,6 +1684,10 @@ type Mutations {
   productTypeDelete(id: ID!): ProductTypeDelete
   productTypeBulkDelete(ids: [ID]!): ProductTypeBulkDelete
   productTypeUpdate(id: ID!, input: ProductTypeInput!): ProductTypeUpdate
+  productTypeUpdateMetadata(id: ID!, input: MetaInput!): ProductTypeUpdateMeta
+  productTypeClearMetadata(id: ID!, input: MetaPath!): ProductTypeClearMeta
+  productTypeUpdatePrivateMetadata(id: ID!, input: MetaInput!): ProductTypeUpdatePrivateMeta
+  productTypeClearPrivateMetadata(id: ID!, input: MetaPath!): ProductTypeClearPrivateMeta
   digitalContentCreate(input: DigitalContentUploadInput!, variantId: ID!): DigitalContentCreate
   digitalContentDelete(variantId: ID!): DigitalContentDelete
   digitalContentUpdate(input: DigitalContentInput!, variantId: ID!): DigitalContentUpdate
@@ -1581,6 +1697,10 @@ type Mutations {
   productVariantBulkDelete(ids: [ID]!): ProductVariantBulkDelete
   productVariantUpdate(id: ID!, input: ProductVariantInput!): ProductVariantUpdate
   productVariantTranslate(id: ID!, input: NameTranslationInput!, languageCode: LanguageCodeEnum!): ProductVariantTranslate
+  productVariantUpdateMetadata(id: ID!, input: MetaInput!): ProductVariantUpdateMeta
+  productVariantClearMetadata(id: ID!, input: MetaPath!): ProductVariantClearMeta
+  productVariantUpdatePrivateMetadata(id: ID!, input: MetaInput!): ProductVariantUpdatePrivateMeta
+  productVariantClearPrivateMetadata(id: ID!, input: MetaPath!): ProductVariantClearPrivateMeta
   variantImageAssign(imageId: ID!, variantId: ID!): VariantImageAssign
   variantImageUnassign(imageId: ID!, variantId: ID!): VariantImageUnassign
   paymentCapture(amount: Decimal, paymentId: ID!): PaymentCapture
@@ -1684,6 +1804,10 @@ type Mutations {
   userAvatarUpdate(image: Upload!): UserAvatarUpdate
   userAvatarDelete: UserAvatarDelete
   userBulkSetActive(ids: [ID]!, isActive: Boolean!): UserBulkSetActive
+  userUpdateMetadata(id: ID!, input: MetaInput!): UserUpdateMeta
+  userClearStoredMetadata(id: ID!, input: MetaPath!): UserClearStoredMeta
+  userUpdatePrivateMetadata(id: ID!, input: MetaInput!): UserUpdatePrivateMeta
+  userClearStoredPrivateMetadata(id: ID!, input: MetaPath!): UserClearStoredPrivateMeta
 }
 
 input NameTranslationInput {
@@ -2156,6 +2280,8 @@ type Product implements Node {
   updatedAt: DateTime
   chargeTaxes: Boolean!
   weight: Weight
+  privateMeta: [MetaStore]!
+  meta: [MetaStore]!
   url: String!
   thumbnailUrl(size: Int): String @deprecated(reason: "thumbnailUrl is deprecated, use\\n         thumbnail instead")
   thumbnail(size: Int): Image
@@ -2187,6 +2313,16 @@ type ProductBulkPublish {
   count: Int!
 }
 
+type ProductClearMeta {
+  errors: [Error!]
+  product: Product
+}
+
+type ProductClearPrivateMeta {
+  errors: [Error!]
+  product: Product
+}
+
 type ProductCountableConnection {
   pageInfo: PageInfo!
   edges: [ProductCountableEdge!]!
@@ -2351,6 +2487,8 @@ type ProductType implements Node {
   isShippingRequired: Boolean!
   isDigital: Boolean!
   weight: Weight
+  privateMeta: [MetaStore]!
+  meta: [MetaStore]!
   products(before: String, after: String, first: Int, last: Int): ProductCountableConnection
   taxRate: TaxRateType
   taxType: TaxType
@@ -2363,6 +2501,16 @@ type ProductTypeBulkDelete {
   count: Int!
 }
 
+type ProductTypeClearMeta {
+  errors: [Error!]
+  productType: ProductType
+}
+
+type ProductTypeClearPrivateMeta {
+  errors: [Error!]
+  productType: ProductType
+}
+
 enum ProductTypeConfigurable {
   CONFIGURABLE
   SIMPLE
@@ -2416,11 +2564,31 @@ type ProductTypeUpdate {
   productType: ProductType
 }
 
+type ProductTypeUpdateMeta {
+  errors: [Error!]
+  productType: ProductType
+}
+
+type ProductTypeUpdatePrivateMeta {
+  errors: [Error!]
+  productType: ProductType
+}
+
 type ProductUpdate {
   errors: [Error!]
   product: Product
 }
 
+type ProductUpdateMeta {
+  errors: [Error!]
+  product: Product
+}
+
+type ProductUpdatePrivateMeta {
+  errors: [Error!]
+  product: Product
+}
+
 type ProductVariant implements Node {
   id: ID!
   sku: String!
@@ -2430,6 +2598,8 @@ type ProductVariant implements Node {
   quantity: Int!
   quantityAllocated: Int!
   weight: Weight
+  privateMeta: [MetaStore]!
+  meta: [MetaStore]!
   stockQuantity: Int!
   priceOverride: Money
   price: Money @deprecated(reason: "Has been replaced by 'pricing.price_undiscounted'")
@@ -2451,6 +2621,16 @@ type ProductVariantBulkDelete {
   count: Int!
 }
 
+type ProductVariantClearMeta {
+  errors: [Error!]
+  productVariant: ProductVariant
+}
+
+type ProductVariantClearPrivateMeta {
+  errors: [Error!]
+  productVariant: ProductVariant
+}
+
 type ProductVariantCountableConnection {
   pageInfo: PageInfo!
   edges: [ProductVariantCountableEdge!]!
@@ -2509,6 +2689,16 @@ type ProductVariantUpdate {
   productVariant: ProductVariant
 }
 
+type ProductVariantUpdateMeta {
+  errors: [Error!]
+  productVariant: ProductVariant
+}
+
+type ProductVariantUpdatePrivateMeta {
+  errors: [Error!]
+  productVariant: ProductVariant
+}
+
 type Query {
   translations(kind: TranslatableKinds!, before: String, after: String, first: Int, last: Int): TranslatableItemConnection
   shop: Shop
@@ -3059,6 +3249,8 @@ type User implements Node {
   dateJoined: DateTime!
   defaultShippingAddress: Address
   defaultBillingAddress: Address
+  privateMeta: [MetaStore]!
+  meta: [MetaStore]!
   addresses: [Address]
   checkout: Checkout
   giftCards(before: String, after: String, first: Int, last: Int): GiftCardCountableConnection
@@ -3089,6 +3281,16 @@ type UserBulkSetActive {
   count: Int!
 }
 
+type UserClearStoredMeta {
+  errors: [Error!]
+  user: User
+}
+
+type UserClearStoredPrivateMeta {
+  errors: [Error!]
+  user: User
+}
+
 type UserCountableConnection {
   pageInfo: PageInfo!
   edges: [UserCountableEdge!]!
@@ -3111,6 +3313,16 @@ input UserCreateInput {
   sendPasswordEmail: Boolean
 }
 
+type UserUpdateMeta {
+  errors: [Error!]
+  user: User
+}
+
+type UserUpdatePrivateMeta {
+  errors: [Error!]
+  user: User
+}
+
 type VAT {
   countryCode: String!
   standardRate: Float
diff --git a/saleor/payment/utils.py b/saleor/payment/utils.py
index c0586c63d49..7fb2582e297 100644
--- a/saleor/payment/utils.py
+++ b/saleor/payment/utils.py
@@ -1,6 +1,5 @@
 import json
 import logging
-
 from decimal import Decimal
 from functools import wraps
 from typing import Dict, List
@@ -25,7 +24,6 @@
     TransactionKind,
     get_payment_gateway,
 )
-
 from .interface import AddressData, GatewayResponse, PaymentData, TokenConfig
 from .models import Payment, Transaction
 
@@ -41,7 +39,7 @@
     "currency",
 }
 ALLOWED_GATEWAY_KINDS = {choices[0] for choices in TransactionKind.CHOICES}
-GATEWAYS_META_LABEL = "gateways"
+GATEWAYS_META_NAMESPACE = "payment-gateways"
 
 
 def list_enabled_gateways() -> List[str]:
@@ -515,22 +513,24 @@ def gateway_refund(payment, amount: Decimal = None) -> Transaction:
 
 def fetch_customer_id(user, gateway):
     """Retrieves users customer_id stored for desired gateway"""
-    key = prepare_label_name(gateway)
-    gateway_config = user.get_private_meta(label=GATEWAYS_META_LABEL).get(key, {})
+    key = prepare_namespace_name(gateway)
+    gateway_config = user.get_private_meta(
+        namespace=GATEWAYS_META_NAMESPACE, client=key
+    )
     return gateway_config.get("customer_id", None)
 
 
 def store_customer_id(user, gateway, customer_id):
     """Stores customer_id in users private meta for desired gateway"""
     user.store_private_meta(
-        label=GATEWAYS_META_LABEL,
-        key=prepare_label_name(gateway),
-        value={"customer_id": customer_id},
+        namespace=GATEWAYS_META_NAMESPACE,
+        client=prepare_namespace_name(gateway),
+        item={"customer_id": customer_id},
     )
     user.save(update_fields=["private_meta"])
 
 
-def prepare_label_name(s):
+def prepare_namespace_name(s):
     return s.strip().upper()
 
 
diff --git a/saleor/product/migrations/0096_auto_20190719_0339.py b/saleor/product/migrations/0096_auto_20190719_0339.py
new file mode 100644
index 00000000000..266aa6caae8
--- /dev/null
+++ b/saleor/product/migrations/0096_auto_20190719_0339.py
@@ -0,0 +1,31 @@
+# Generated by Django 2.2.3 on 2019-07-19 08:39
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+import saleor.core.utils.json_serializer
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("product", "0095_auto_20190618_0842")]
+
+    operations = [
+        migrations.AddField(
+            model_name="producttype",
+            name="private_meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="producttype",
+            name="meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+            ),
+        ),
+    ]
diff --git a/saleor/product/migrations/0097_auto_20190719_0458.py b/saleor/product/migrations/0097_auto_20190719_0458.py
new file mode 100644
index 00000000000..f2cb4f0cbfc
--- /dev/null
+++ b/saleor/product/migrations/0097_auto_20190719_0458.py
@@ -0,0 +1,31 @@
+# Generated by Django 2.2.3 on 2019-07-19 09:58
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+import saleor.core.utils.json_serializer
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("product", "0096_auto_20190719_0339")]
+
+    operations = [
+        migrations.AddField(
+            model_name="product",
+            name="private_meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="product",
+            name="meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+            ),
+        ),
+    ]
diff --git a/saleor/product/migrations/0098_auto_20190719_0733.py b/saleor/product/migrations/0098_auto_20190719_0733.py
new file mode 100644
index 00000000000..2219435a9d9
--- /dev/null
+++ b/saleor/product/migrations/0098_auto_20190719_0733.py
@@ -0,0 +1,33 @@
+# Generated by Django 2.2.3 on 2019-07-19 12:33
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+import saleor.core.utils.json_serializer
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("product", "0097_auto_20190719_0458")]
+
+    operations = [
+        migrations.AlterField(
+            model_name="product",
+            name="meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="producttype",
+            name="meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+    ]
diff --git a/saleor/product/migrations/0099_auto_20190719_0745.py b/saleor/product/migrations/0099_auto_20190719_0745.py
new file mode 100644
index 00000000000..e39567cb544
--- /dev/null
+++ b/saleor/product/migrations/0099_auto_20190719_0745.py
@@ -0,0 +1,33 @@
+# Generated by Django 2.2.3 on 2019-07-19 12:45
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+import saleor.core.utils.json_serializer
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("product", "0098_auto_20190719_0733")]
+
+    operations = [
+        migrations.AlterField(
+            model_name="product",
+            name="private_meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="producttype",
+            name="private_meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+    ]
diff --git a/saleor/product/migrations/0100_merge_20190719_0803.py b/saleor/product/migrations/0100_merge_20190719_0803.py
new file mode 100644
index 00000000000..630d97126be
--- /dev/null
+++ b/saleor/product/migrations/0100_merge_20190719_0803.py
@@ -0,0 +1,13 @@
+# Generated by Django 2.2.3 on 2019-07-19 13:03
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("product", "0096_raw_html_to_json"),
+        ("product", "0099_auto_20190719_0745"),
+    ]
+
+    operations = []
diff --git a/saleor/product/migrations/0101_auto_20190719_0839.py b/saleor/product/migrations/0101_auto_20190719_0839.py
new file mode 100644
index 00000000000..ddb95d6f1c3
--- /dev/null
+++ b/saleor/product/migrations/0101_auto_20190719_0839.py
@@ -0,0 +1,113 @@
+# Generated by Django 2.2.3 on 2019-07-19 13:39
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+import saleor.core.utils.json_serializer
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("product", "0100_merge_20190719_0803")]
+
+    operations = [
+        migrations.AddField(
+            model_name="attribute",
+            name="meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="attribute",
+            name="private_meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="category",
+            name="meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="category",
+            name="private_meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="collection",
+            name="meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="collection",
+            name="private_meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="digitalcontent",
+            name="meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="digitalcontent",
+            name="private_meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="productvariant",
+            name="meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="productvariant",
+            name="private_meta",
+            field=django.contrib.postgres.fields.jsonb.JSONField(
+                blank=True,
+                default=dict,
+                encoder=saleor.core.utils.json_serializer.CustomJsonEncoder,
+                null=True,
+            ),
+        ),
+    ]
diff --git a/saleor/product/models.py b/saleor/product/models.py
index de9ab58a8c4..a308c049576 100644
--- a/saleor/product/models.py
+++ b/saleor/product/models.py
@@ -25,10 +25,14 @@
 
 from ..core.exceptions import InsufficientStock
 from ..core.fields import SanitizedJSONField
-from ..core.models import PublishableModel, PublishedQuerySet, SortableModel
+from ..core.models import (
+    ModelWithMetadata,
+    PublishableModel,
+    PublishedQuerySet,
+    SortableModel,
+)
 from ..core.utils import build_absolute_uri
 from ..core.utils.draftjs import json_content_to_raw_text
-from ..core.utils.json_serializer import CustomJsonEncoder
 from ..core.utils.translations import TranslationProxy
 from ..core.weight import WeightUnits, zero_weight
 from ..discount import DiscountInfo
@@ -36,7 +40,7 @@
 from ..seo.models import SeoModel, SeoModelTranslation
 
 
-class Category(MPTTModel, SeoModel):
+class Category(MPTTModel, ModelWithMetadata, SeoModel):
     name = models.CharField(max_length=128)
     slug = models.SlugField(max_length=128)
     description = models.TextField(blank=True)
@@ -87,7 +91,7 @@ def __repr__(self):
         )
 
 
-class ProductType(models.Model):
+class ProductType(ModelWithMetadata):
     name = models.CharField(max_length=128)
     has_variants = models.BooleanField(default=True)
     is_shipping_required = models.BooleanField(default=True)
@@ -95,7 +99,6 @@ class ProductType(models.Model):
     weight = MeasurementField(
         measurement=Weight, unit_choices=WeightUnits.CHOICES, default=zero_weight
     )
-    meta = JSONField(blank=True, null=True, default=dict, encoder=CustomJsonEncoder)
 
     class Meta:
         app_label = "product"
@@ -122,7 +125,7 @@ def collection_sorted(self, user):
         return qs
 
 
-class Product(SeoModel, PublishableModel):
+class Product(SeoModel, ModelWithMetadata, PublishableModel):
     product_type = models.ForeignKey(
         ProductType, related_name="products", on_delete=models.CASCADE
     )
@@ -145,8 +148,6 @@ class Product(SeoModel, PublishableModel):
     weight = MeasurementField(
         measurement=Weight, unit_choices=WeightUnits.CHOICES, blank=True, null=True
     )
-    meta = JSONField(blank=True, null=True, default=dict, encoder=CustomJsonEncoder)
-
     objects = ProductsQueryset.as_manager()
     translated = TranslationProxy()
 
@@ -237,7 +238,7 @@ def __repr__(self):
         )
 
 
-class ProductVariant(models.Model):
+class ProductVariant(ModelWithMetadata):
     sku = models.CharField(max_length=32, unique=True)
     name = models.CharField(max_length=255, blank=True)
     price_override = MoneyField(
@@ -377,7 +378,7 @@ def __str__(self):
         return self.name or str(self.product_variant)
 
 
-class DigitalContent(models.Model):
+class DigitalContent(ModelWithMetadata):
     FILE = "file"
     TYPE_CHOICES = (
         (FILE, pgettext_lazy("File as a digital product", "digital_product")),
@@ -423,7 +424,7 @@ def get_absolute_url(self) -> str:
         return build_absolute_uri(url)
 
 
-class Attribute(models.Model):
+class Attribute(ModelWithMetadata):
     slug = models.SlugField(max_length=50)
     name = models.CharField(max_length=50)
     product_type = models.ForeignKey(
@@ -560,7 +561,7 @@ def get_ordering_queryset(self):
         return self.product.collectionproduct.all()
 
 
-class Collection(SeoModel, PublishableModel):
+class Collection(SeoModel, ModelWithMetadata, PublishableModel):
     name = models.CharField(max_length=128, unique=True)
     slug = models.SlugField(max_length=128)
     products = models.ManyToManyField(
diff --git a/tests/api/test_account_meta.py b/tests/api/test_account_meta.py
new file mode 100644
index 00000000000..ab2d7951826
--- /dev/null
+++ b/tests/api/test_account_meta.py
@@ -0,0 +1,495 @@
+import json
+
+import graphene
+import pytest
+
+from tests.api.utils import get_graphql_content
+
+PRIVATE_META_NAMESPACE = "TEST_NAMESPACE"
+PUBLIC_META_NAMESPACE = "PUBLIC_NAMESPACE"
+
+
+META_CLIENT = "TEST_PLUGIN"
+
+PRIVATE_KEY = "name"
+PRIVATE_VALUE = "Bond"
+
+PUBLIC_KEY = "purpose"
+PUBLIC_VALUE = "42"
+
+
+@pytest.fixture
+def customer_with_meta(customer_user):
+    customer_user.store_private_meta(
+        namespace=PRIVATE_META_NAMESPACE,
+        client=META_CLIENT,
+        item={PRIVATE_KEY: PRIVATE_VALUE},
+    )
+    customer_user.store_meta(
+        namespace=PUBLIC_META_NAMESPACE,
+        client=META_CLIENT,
+        item={PUBLIC_KEY: PUBLIC_VALUE},
+    )
+    customer_user.save()
+    return customer_user
+
+
+GET_PRIVATE_META_QUERY = """
+    query UserMeta($id: ID!) {
+        user(id: $id) {
+            email
+            privateMeta {
+                namespace
+                clients {
+                    name
+                    metadata {
+                        key
+                        value
+                    }
+                }
+            }
+        }
+    }
+"""
+
+
+def test_get_private_meta(
+    staff_api_client, permission_manage_users, customer_with_meta
+):
+    user_id = graphene.Node.to_global_id("User", customer_with_meta.id)
+    variables = {"id": user_id}
+    response = staff_api_client.post_graphql(
+        GET_PRIVATE_META_QUERY, variables, permissions=[permission_manage_users]
+    )
+    meta = get_graphql_content(response)["data"]["user"]["privateMeta"][0]
+
+    assert meta["namespace"] == PRIVATE_META_NAMESPACE
+    assert meta["clients"] == [
+        {
+            "metadata": [{"key": PRIVATE_KEY, "value": PRIVATE_VALUE}],
+            "name": META_CLIENT,
+        }
+    ]
+
+
+MY_PRIVATE_META_QUERY = """
+    {
+        me {
+            email
+            privateMeta {
+                namespace
+                clients {
+                    name
+                    metadata {
+                        key
+                        value
+                    }
+                }
+            }
+        }
+    }
+"""
+
+
+def test_user_has_no_access_to_private_meta(user_api_client, customer_with_meta):
+    response = user_api_client.post_graphql(MY_PRIVATE_META_QUERY)
+    data = json.loads(response.content.decode("utf8"))
+    assert data["errors"] is not None
+    assert data["data"]["me"] is None
+
+
+UPDATE_PRIVATE_METADATA_MUTATION = """
+    mutation TokenCreate($id: ID!, $input: MetaInput!) {
+      userUpdatePrivateMetadata(
+        id: $id
+        input: $input
+      ) {
+        user {
+          privateMeta {
+            namespace
+            clients {
+              name
+              metadata {
+                key
+                value
+              }
+            }
+          }
+        }
+      }
+    }
+"""
+
+
+def test_update_private_metadata_through_mutation(
+    staff_api_client, permission_manage_users, customer_with_meta
+):
+    NEW_VALUE = "NEW_VALUE"
+    user_id = graphene.Node.to_global_id("User", customer_with_meta.id)
+    variables = {
+        "id": user_id,
+        "input": {
+            "namespace": PRIVATE_META_NAMESPACE,
+            "clientName": META_CLIENT,
+            "key": PRIVATE_KEY,
+            "value": NEW_VALUE,
+        },
+    }
+    response = staff_api_client.post_graphql(
+        UPDATE_PRIVATE_METADATA_MUTATION,
+        variables,
+        permissions=[permission_manage_users],
+    )
+    meta = get_graphql_content(response)["data"]["userUpdatePrivateMetadata"]["user"][
+        "privateMeta"
+    ][0]
+
+    assert meta["namespace"] == PRIVATE_META_NAMESPACE
+    assert meta["clients"] == [
+        {"metadata": [{"key": PRIVATE_KEY, "value": NEW_VALUE}], "name": META_CLIENT}
+    ]
+
+
+def test_add_new_key_value_pair_to_private_metadata_using_mutation(
+    staff_api_client, permission_manage_users, customer_with_meta
+):
+    NEW_KEY = "NEW_KEY"
+    NEW_VALUE = "NEW_VALUE"
+    user_id = graphene.Node.to_global_id("User", customer_with_meta.id)
+    variables = {
+        "id": user_id,
+        "input": {
+            "namespace": PRIVATE_META_NAMESPACE,
+            "clientName": META_CLIENT,
+            "key": NEW_KEY,
+            "value": NEW_VALUE,
+        },
+    }
+    response = staff_api_client.post_graphql(
+        UPDATE_PRIVATE_METADATA_MUTATION,
+        variables,
+        permissions=[permission_manage_users],
+    )
+    meta = get_graphql_content(response)["data"]["userUpdatePrivateMetadata"]["user"][
+        "privateMeta"
+    ][0]
+
+    expected_metadata = [
+        {"key": NEW_KEY, "value": NEW_VALUE},
+        {"key": PRIVATE_KEY, "value": PRIVATE_VALUE},
+    ]
+    assert meta["namespace"] == PRIVATE_META_NAMESPACE
+    assert meta["clients"] == [{"metadata": expected_metadata, "name": META_CLIENT}]
+
+
+CLEAR_PRIVATE_METADATA_MUTATION = """
+    mutation UserClearStoredPrivateMetadata($id: ID!, $input: MetaPath!) {
+      userClearStoredPrivateMetadata(
+        id: $id
+        input: $input
+      ) {
+        user {
+          privateMeta {
+            namespace
+            clients {
+              name
+              metadata {
+                key
+                value
+              }
+            }
+          }
+        }
+      }
+    }
+"""
+
+
+def test_clear_private_metadata_through_mutation(
+    staff_api_client, permission_manage_users, customer_with_meta
+):
+    user_id = graphene.Node.to_global_id("User", customer_with_meta.id)
+    variables = {
+        "id": user_id,
+        "input": {
+            "namespace": PRIVATE_META_NAMESPACE,
+            "clientName": META_CLIENT,
+            "key": PRIVATE_KEY,
+        },
+    }
+    response = staff_api_client.post_graphql(
+        CLEAR_PRIVATE_METADATA_MUTATION,
+        variables,
+        permissions=[permission_manage_users],
+    )
+    meta = get_graphql_content(response)["data"]["userClearStoredPrivateMetadata"][
+        "user"
+    ]["privateMeta"][0]
+
+    assert meta["namespace"] == PRIVATE_META_NAMESPACE
+    assert meta["clients"] == []
+
+
+def test_clear_silently_private_metadata_from_nonexistent_client(
+    staff_api_client, permission_manage_users, customer_with_meta
+):
+    user_id = graphene.Node.to_global_id("User", customer_with_meta.id)
+    WRONG_CLIENT = "WONG"
+    variables = {
+        "id": user_id,
+        "input": {
+            "namespace": PRIVATE_META_NAMESPACE,
+            "clientName": WRONG_CLIENT,
+            "key": PRIVATE_KEY,
+        },
+    }
+    response = staff_api_client.post_graphql(
+        CLEAR_PRIVATE_METADATA_MUTATION,
+        variables,
+        permissions=[permission_manage_users],
+    )
+    meta = get_graphql_content(response)["data"]["userClearStoredPrivateMetadata"][
+        "user"
+    ]["privateMeta"][0]
+
+    assert meta["namespace"] == PRIVATE_META_NAMESPACE
+    assert meta["clients"] == [
+        {
+            "metadata": [{"key": PRIVATE_KEY, "value": PRIVATE_VALUE}],
+            "name": META_CLIENT,
+        }
+    ]
+
+
+MY_PUBLIC_META_QUERY = """
+    {
+        me {
+            email
+            meta {
+                namespace
+                clients {
+                    name
+                    metadata {
+                        key
+                        value
+                    }
+                }
+            }
+        }
+    }
+"""
+
+
+def test_access_users_public_metadata(user_api_client, customer_with_meta):
+    response = user_api_client.post_graphql(MY_PUBLIC_META_QUERY)
+    data = json.loads(response.content.decode("utf8"))
+    assert "errors" not in data
+
+    meta = get_graphql_content(response)["data"]["me"]["meta"][0]
+    assert meta["namespace"] == PUBLIC_META_NAMESPACE
+    assert meta["clients"] == [
+        {"metadata": [{"key": PUBLIC_KEY, "value": PUBLIC_VALUE}], "name": META_CLIENT}
+    ]
+
+
+GET_META_QUERY = """
+    query UserMeta($id: ID!) {
+        user(id: $id) {
+            email
+            meta {
+                namespace
+                clients {
+                    name
+                    metadata {
+                        key
+                        value
+                    }
+                }
+            }
+        }
+    }
+"""
+
+
+def test_staff_access_to_public_metadata(
+    staff_api_client, permission_manage_users, customer_with_meta
+):
+    user_id = graphene.Node.to_global_id("User", customer_with_meta.id)
+    variables = {"id": user_id}
+    response = staff_api_client.post_graphql(
+        GET_META_QUERY, variables, permissions=[permission_manage_users]
+    )
+    meta = get_graphql_content(response)["data"]["user"]["meta"][0]
+
+    assert meta["namespace"] == PUBLIC_META_NAMESPACE
+    assert meta["clients"] == [
+        {"metadata": [{"key": PUBLIC_KEY, "value": PUBLIC_VALUE}], "name": META_CLIENT}
+    ]
+
+
+UPDATE_METADATA_MUTATION = """
+    mutation TokenCreate($id: ID!, $input: MetaInput!) {
+      userUpdateMetadata(
+        id: $id
+        input: $input
+      ) {
+        user {
+          meta {
+            namespace
+            clients {
+              name
+              metadata {
+                key
+                value
+              }
+            }
+          }
+        }
+      }
+    }
+"""
+
+
+def test_update_metadata_through_mutation(user_api_client, customer_with_meta):
+    NEW_VALUE = "NEW_VALUE"
+    user_id = graphene.Node.to_global_id("User", customer_with_meta.id)
+    variables = {
+        "id": user_id,
+        "input": {
+            "namespace": PUBLIC_META_NAMESPACE,
+            "clientName": META_CLIENT,
+            "key": PUBLIC_KEY,
+            "value": NEW_VALUE,
+        },
+    }
+    resp = user_api_client.post_graphql(UPDATE_METADATA_MUTATION, variables)
+    meta = get_graphql_content(resp)["data"]["userUpdateMetadata"]["user"]["meta"][0]
+
+    assert meta["namespace"] == PUBLIC_META_NAMESPACE
+    assert meta["clients"] == [
+        {"metadata": [{"key": PUBLIC_KEY, "value": NEW_VALUE}], "name": META_CLIENT}
+    ]
+
+
+def test_add_new_key_value_pair_to_metadata_using_mutation(
+    user_api_client, customer_with_meta
+):
+    NEW_KEY = "NEW_KEY"
+    NEW_VALUE = "NEW_VALUE"
+    user_id = graphene.Node.to_global_id("User", customer_with_meta.id)
+    variables = {
+        "id": user_id,
+        "input": {
+            "namespace": PUBLIC_META_NAMESPACE,
+            "clientName": META_CLIENT,
+            "key": NEW_KEY,
+            "value": NEW_VALUE,
+        },
+    }
+    response = user_api_client.post_graphql(UPDATE_METADATA_MUTATION, variables)
+    meta = get_graphql_content(response)["data"]["userUpdateMetadata"]["user"]["meta"][
+        0
+    ]
+
+    expected_metadata = [
+        {"key": NEW_KEY, "value": NEW_VALUE},
+        {"key": PUBLIC_KEY, "value": PUBLIC_VALUE},
+    ]
+    assert meta["namespace"] == PUBLIC_META_NAMESPACE
+    assert meta["clients"] == [{"metadata": expected_metadata, "name": META_CLIENT}]
+
+
+def test_add_new_namespace_metadata_using_mutation(user_api_client, customer_with_meta):
+    NEW_KEY = "NEW_KEY"
+    NEW_VALUE = "NEW_VALUE"
+    NEW_NAMESPACE = "NEW_NAMESPACE"
+    NEW_CLIENT = "NEW_CLIENT"
+
+    user_id = graphene.Node.to_global_id("User", customer_with_meta.id)
+    variables = {
+        "id": user_id,
+        "input": {
+            "namespace": NEW_NAMESPACE,
+            "clientName": NEW_CLIENT,
+            "key": NEW_KEY,
+            "value": NEW_VALUE,
+        },
+    }
+    response = user_api_client.post_graphql(UPDATE_METADATA_MUTATION, variables)
+    meta = get_graphql_content(response)["data"]["userUpdateMetadata"]["user"]["meta"]
+
+    assert meta[0]["namespace"] == NEW_NAMESPACE
+    assert meta[0]["clients"] == [
+        {"metadata": [{"key": NEW_KEY, "value": NEW_VALUE}], "name": NEW_CLIENT}
+    ]
+
+    assert meta[1]["namespace"] == PUBLIC_META_NAMESPACE
+    assert meta[1]["clients"] == [
+        {"metadata": [{"key": PUBLIC_KEY, "value": PUBLIC_VALUE}], "name": META_CLIENT}
+    ]
+
+
+CLEAR_METADATA_MUTATION = """
+    mutation UserClearStoredPrivateMetadata($id: ID!, $input: MetaPath!) {
+      userClearStoredMetadata(
+        id: $id
+        input: $input
+      ) {
+        user {
+          meta {
+            namespace
+            clients {
+              name
+              metadata {
+                key
+                value
+              }
+            }
+          }
+        }
+      }
+    }
+"""
+
+
+def test_clear_metadata_through_mutation(user_api_client, customer_with_meta):
+    user_id = graphene.Node.to_global_id("User", customer_with_meta.id)
+    variables = {
+        "id": user_id,
+        "input": {
+            "namespace": PUBLIC_META_NAMESPACE,
+            "clientName": META_CLIENT,
+            "key": PUBLIC_KEY,
+        },
+    }
+    response = user_api_client.post_graphql(CLEAR_METADATA_MUTATION, variables)
+    meta = get_graphql_content(response)["data"]["userClearStoredMetadata"]["user"][
+        "meta"
+    ][0]
+
+    assert meta["namespace"] == PUBLIC_META_NAMESPACE
+    assert meta["clients"] == []
+
+
+def test_clear_silently_metadata_from_nonexistent_client(
+    staff_api_client, permission_manage_users, customer_with_meta
+):
+    user_id = graphene.Node.to_global_id("User", customer_with_meta.id)
+    WRONG_CLIENT = "WONG"
+    variables = {
+        "id": user_id,
+        "input": {
+            "namespace": PUBLIC_META_NAMESPACE,
+            "clientName": WRONG_CLIENT,
+            "key": PUBLIC_KEY,
+        },
+    }
+    response = staff_api_client.post_graphql(CLEAR_METADATA_MUTATION, variables)
+    meta = get_graphql_content(response)["data"]["userClearStoredMetadata"]["user"][
+        "meta"
+    ][0]
+
+    assert meta["namespace"] == PUBLIC_META_NAMESPACE
+    assert meta["clients"] == [
+        {"metadata": [{"key": PUBLIC_KEY, "value": PUBLIC_VALUE}], "name": META_CLIENT}
+    ]
diff --git a/tests/api/test_payment.py b/tests/api/test_payment.py
index ea84f7e6806..b4f1f28f8c4 100644
--- a/tests/api/test_payment.py
+++ b/tests/api/test_payment.py
@@ -468,7 +468,11 @@ def braintree_customer_id():
 
 def test_store_payment_gateway_meta(customer_user, braintree_customer_id):
     gateway_name = PaymentGatewayEnum.BRAINTREE.name
-    META = {"gateways": {gateway_name.upper(): {"customer_id": braintree_customer_id}}}
+    META = {
+        "payment-gateways": {
+            gateway_name.upper(): {"customer_id": braintree_customer_id}
+        }
+    }
     store_customer_id(customer_user, gateway_name, braintree_customer_id)
     assert customer_user.private_meta == META
     customer_user.refresh_from_db()
diff --git a/tests/taxes/test_vatlayer.py b/tests/taxes/test_vatlayer.py
index 90a81daec43..fb1d8cf9d72 100644
--- a/tests/taxes/test_vatlayer.py
+++ b/tests/taxes/test_vatlayer.py
@@ -63,7 +63,9 @@ def test_view_checkout_with_taxes(
     checkout.shipping_address = address
     checkout.save()
     product = checkout.lines.first().variant.product
-    product.meta = {"taxes": {"vatlayer": {"code": "standard", "description": ""}}}
+    product.private_meta = {
+        "taxes": {"vatlayer": {"code": "standard", "description": ""}}
+    }
     product.save()
     response = client.get(reverse("checkout:index"))
     response_checkout_line = response.context[0]["checkout_lines"][0]


Code review comments:
diff_hunk: @@ -132,16 +133,20 @@ def apply_taxes_to_shipping_price_range(
 def assign_tax_to_object_meta(obj: Union["Product", "ProductType"], tax_code: str):
     if tax_code not in dict(TaxRateType.CHOICES):
         return
-    if not hasattr(obj, "meta"):
+    if not hasattr(obj, "private_meta"):
path: saleor/core/taxes/vatlayer/interface.py
text: @user1:
No need to put taxes in private_meta. Tax object just stores a category type of product which can be visible

@author:
👍