Try   HackMD

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

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

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

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
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

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.

    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

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.

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
  3. Ensure any custom serializer prevents cross-domain parameters
    • ValidateFieldsMixin has a method for this
  4. Update each task that uses objects to include the domain field.
    • Basic syncs and publishes will probably need no changes.
  5. 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