--- title: Current Domains Development tags: Pulp, Domains, Multitenancy description: Current state of Domain development as of 10-3-2022 --- # Domains Current Development State Oct 3, 2022 pulpcore: https://github.com/pulp/pulpcore/pull/3190 pulp_file: https://github.com/pulp/pulp_file/pull/810/ discourse: https://discourse.pulpproject.org/t/new-multi-tenancy-feature-domains/635/3 --- ### Domain Model ```python class Domain(BaseModel, AutoAddObjPermsMixin): name = models.TextField(null=False, unique=True) description = models.TextField(null=True) # Storage class is required, optional settings are validated by serializer storage_class = models.TextField(null=False) storage_settings = models.JSONField(default=dict) def get_storage(self): """Returns this domain's instantiated storage class.""" if self.storage_class == "default": return default_storage storage_class = get_storage_class(self.storage_class) return storage_class(**self.storage_settings) ``` --- ### Pulp w/ Domains ```python domain = models.ForeignKey("Domain", default=get_default_domain, on_delete=PROTECT) # From pulpcore.app.utils def get_default_domain(): global default_domain_pk # This can be run in a migration, and once after if default_domain_pk is None: try: Domain = models.Domain except AttributeError: return None try: domain = Domain.objects.get(name="default", storage_class="default") except Domain.DoesNotExist: domain = Domain(name="default", storage_class="default") domain.save(skip_hooks=True) default_domain_pk = domain.pk return default_domain_pk ``` * [Most objects will now have a domain relation](https://hackmd.io/X8IWzy95SCWnuTV4RPK9JA) * Default domain is needed to allow domain to become apart of objects `unique_together` constraint * Notable objects without domains: `User`, `Group`, `AccessPolicy`, `Role` --- ### Enabling Domains in Pulp While the `Domain` object will always be present in Pulp, the features enabled by domains will be off by default. Turning on domains will be controlled through settings `DOMAIN_ENABLED`. * Pulp will only start if each plugin is compatible with domains. Done through a new attribute on the `PulpPluginConfig` ```python class PulpFilePluginAppConfig(PulpPluginAppConfig): ... domain_compatible = True ``` * API Url routing will change to use new settings `V3_DOMAIN_API_ROOT` * `API_ROOT/<domain_path>/api/v3/` - e.g. `/pulp/<domain_path>/api/v3/` * Content app routing will now have `{domain_path}` added to the end of `CONTENT_ORIGIN` * `CONTENT_ORIGIN/{domain_path}/{path}` -e.g. `/pulp/content/{domain_path}/ * All current objects in Pulp will be found under the `default` domain * This includes objects that are not apart of domains --- ### Files in Domains To have files uploaded to their correct domain `FieldFile` used for `Artifact` and `TemporaryFile`'s `FileField` has been customized ```python class FieldFile(BaseFileField.attr_class): @property def storage(self): if domain := getattr(self.instance, "domain", None): return domain.get_storage() return self._storage @storage.setter def storage(self, value): self._storage = value def get_artifact_path(sha256digest, domain=None): args = ["artifact", sha256digest[:2], sha256digest[2:]] # Prevent collisions if two domains have the same backend settings if domain is not None: if domain.storage_class != "default": args.insert(1, str(domain.pulp_id)) return os.path.join(*args) with artifact.file.open() as f: ... ``` --- ### Backwards Compatibility and Hidden Helpers Adding domain to the URL would change the signatures for all our viewsets, so a new middleware has been added to intercept and remove the `domain_path`, setting it on the request object before calling the viewset's handler. ```python def process_view(self, request, view_func, view_args, view_kwargs): domain = view_kwargs.pop("domain", None) or "default" if not Domain.objects.filter(name=domain).exists(): raise Http404() setattr(request, "domain", domain) return None ``` To avoid having to modify every serializer to handle the new domain parameter for object creation a new hidden field has been added to the `ModelSerializer` ```python class ModelSerializer(serializers.ModelSerializer): ... def _get_domain(self): context = self.root.context if domain := context.get("domain", None): if isinstance(domain, Domain): return domain name = domain elif request := context.get("request", None): name = getattr(request, "domain", "default") else: name = "default" return Domain.objects.get(name=name) domain = serializers.HiddenField(default=_get_domain) def __init__subclass(): ... if "domain" in meta.fields and model: if not hasattr(model, "domain"): meta.fields = tuple(set(meta.fields) - {"domain"}) ``` --- ### RBAC w/ Domains There is a new level for permissions: Domain-level, and with it comes new Global Access Conditions. ```python class UserRole(BaseModel): # Same change on GroupRole ... domain = models.ForeignKey("Domain", null=True, on_delete=CASCADE) def has_domain_perms(request, view, action, permission): if settings.DOMAIN_ENABLED: domain_name = request.domain domain = Domain.objects.get(name=domain_name) return request.user.has_perm(permission, obj=domain) return False def has_model_domain_or_obj_perms(...): return has_model_perms(...) or has_domain_perms(...) or has_object_perms(...) ``` * Roles and AccessPolicies will remain system wide and will be up to the System Admin to determine what is appropriate for users and their domains. * Objects will be scoped to their domain on top of RBAC scoping * Currently endpoints/objects without domains are interactable in every domain (to users with correct permissions), but their href will always show as being in the 'defualt' domain --- ### Adding Domain Compatibility to a Plugin 1. Add `domain` relation to all models without it * This includes their `Content` models as base `Content` does not have it. 2. Update custom model serializers to include `domain` field and have correct uniqueness constraint 4. Ensure any custom serializer prevents cross-domain parameters * `ValidateFieldsMixin` has a method for this 2. Update each task that uses objects to include the `domain` field. * Basic syncs and publishes will probably need no changes. 3. Update each viewset that creates tasks to include the `domain` in the `dispatch` call. 6. Add the appropiate `has_domain_perms` checks to the viewset's AccessPolicies. 7. Update any extra URL routes to include `{domain_path}` if `DOMAIN_ENABLED` 8. Add `domain_compatiable = True` to `PluginAppConfig` 9. Add tests & documentation ---