Multi-tenancy¶
Yoyaku is multi-tenant by default - but invisibly for single-site
installs. Every resource, slot, tariff and booking is stamped with a
tenant machine name; a shipped default tenant means simple sites never
think about it.
flowchart LR
REQ[Request] --> TC[Tenant context]
TC --> R1[resolver A]
TC --> R2[resolver B]
R1 -->|tenant id| TC
TC -->|first non-empty wins,<br/>else default| T[(Active tenant)]
T --> DATA[All rows filtered / stamped by tenant]
How it works¶
Tenantis a config entity (yoyaku_tenant), one shipped at install:default.TenantField::getDefinition()provides the sharedtenantbase field every partitioned entity adds, and the type names it with ayoyaku_tenantentity key ('yoyaku_tenant' => 'tenant'), the way core'sownerkey names its field. The key, not the field's name, is what the tenant lists, the tenant deletion check and the parent chain ask, so a tenant field another module adds is never taken for a yoyaku one. Core makes an entity key's column NOT NULL, so a type whose tenant may be empty, meaning shared with every tenant (an allotment, a manager grant on a shared resource type), also setsstorage_schematoNullableTenantStorageSchema, or a shared row cannot be saved. A booking inherits its tariff's tenant.- The tenant context (
yoyaku.tenant_context,TenantContextInterface) resolves the active tenant by consulting resolvers taggedyoyaku.tenant_resolverin priority order, falling back todefault. It is one service with the booking channel context (TenantAndBookingChannelContext), so insideexecuteInBookingChannel()the tenant is the entered channel's. TenantResolverInterface- implement and tag it to map your dimension (domain, group, siteā¦) to a tenant id.
Which tenant a row belongs to¶
The tenant is never asked for on a form, so nothing an operator does says which realm they meant. Two rules answer it instead, and between them they cover every way a row is created: an admin screen, a preset being applied, an import, a script.
A row belonging to another row takes that row's tenant. A slot of a
resource in one realm cannot be in another, so a type that belongs to something
extends OwnedContentEntityBase, whose preSave() settles it as the row is
saved, following the chain the entity type declares as parent_fields and
EntityParents reads. A type added by a submodule joins in by declaring what it
belongs to and extending that base, which is also where a reader sees that it
does. Nothing is dispatched for the types this does not concern: a booking, and
everything else on the site, pays nothing at all.
A row belonging to nothing but its tenant takes the tenant being served. That is the default value on the field itself, which asks the tenant context. A resource, a tariff preset, a tariff class, an allotment and a venue are created this way, from their own collection screen, and they belong to the realm their operator is working in.
Neither rule changes anything on a site running one tenant, where the realm
being served is the shipped default and every row already carried it.
The one value an operator does choose is an allotment's, because that type is the only one whose tenant may be empty, meaning the name is shared with every tenant. See allotments.
Who sees what¶
- Lists show the current tenant's records, for everyone, administrators
included: on a tenant's domain an operator works on that tenant. A list is a
View of a tenant-bearing type, or an access-checked entity query its code
tags
TenantListHooks::LIST_TAG(the collections, the overviews, the resources a filter offers). A type whose tenant is optional also lists the rows shared with every tenant. Each such type declares theyoyaku_tenantlist cache context, so a cached list is served to its own tenant only. - A record's own children follow the record. A resource's tariffs, a slot's bookings, a venue's places are listed on its tabs whichever tenant it belongs to, and code counting or checking them reads them all: only a list is narrowed, never a read.
administer yoyakuis the root of the engine. It reaches every record of every tenant, so it is given only to people trusted with all of them. A tenant-bound administrator will be a narrower permission beside it (the planned administer tenant); until then, per-tenant order management andyoyaku_manager's per-resource grants are the narrower roles.
A list builder of a tenant-bearing type uses TenantListQueryTrait, which tags
core's list query. A module listing one another way adds the tag itself:
$ids = $storage->getQuery()
->accessCheck(TRUE)
->addTag(TenantListHooks::LIST_TAG)
->execute();
Deleting a tenant¶
A tenant is deleted only once nothing belongs to it. It is known by its machine name, so records left behind would belong to whichever tenant is created next under that name: its resources, its orders, and its managers and operators with the rights they were given. Its delete form lists what is left instead of offering the delete, and the deletion refuses it in code and on a config import alike. The tenant's own booking channels are its configuration and go with it.
One tenant per domain¶
The Yoyaku Domain submodule (yoyaku_domain) is the shipped answer for the
common case, a site with several front ends on
Domain Access: each tenant names the
domains it is served on, on its Domains tab, and a request served on one of
them acts in that tenant.
- The binding lives on the tenant, not on the domain record: domain configuration keeps meaning what Domain says it means, and several domains may point at one tenant.
- A domain no tenant claims defers, so the
defaulttenant applies exactly as it does without the submodule. Installing it changes nothing until a tenant names a domain. - Which domain is being served is Domain's answer. A hostname it cannot match, as on the CLI and in cron, falls back to the default domain record, so such a run acts in the tenant naming that domain.
- Yoyaku tenants and Orchestra tenants are separate entity types: this reads yoyaku's own binding, and a site is free to line the two up differently, or to run yoyaku without Orchestra at all.
Adding a resolver¶
Any other dimension (a group, a path prefix, a consumer) is a service away:
# my_module.services.yml
services:
my_module.group_tenant_resolver:
class: Drupal\my_module\GroupTenantResolver
arguments: ['@my_module.group_context']
tags:
# Above yoyaku_domain's resolver (priority 0), so the domain decides only
# what this one leaves undecided.
- { name: yoyaku.tenant_resolver, priority: 100 }
use Drupal\yoyaku\TenantResolverInterface;
final class GroupTenantResolver implements TenantResolverInterface {
public function resolveTenantId(): ?string {
// Return a tenant id, or NULL to defer to the next resolver.
}
}
Note
Capacity is always counted per tariff id, so tariffs in different tenants are independent by construction. The tenant field additionally lets consumers scope listings and access per tenant.
Tenant-scoped resource types¶
A resource type (yoyaku_resource_type) is shared or tenant-scoped, the same
shape as a shared vocabulary with per-tenant additions. Its tenant property
is either empty or a tenant id:
- empty means the type is shared: it is offered in every tenant;
- set means the type is private to that tenant and offered only there.
ResourceTypeRepository is the single place this resolves: the types available
in a tenant are the shared types plus that tenant's own. The resource Type
selector lists only available types, and a validation constraint rejects
assigning a type from another tenant, so a resource never references a type
outside its realm.
Manage types and tenants under Booking in the admin menu (Resource types, Tenants).
Constraints are enforced per tenant¶
Order constraints (the per-booker limits) always count within the order's tenant. Even a limit spanning resources of a shared type is counted per tenant: a booker holding one of them in one tenant is not blocked from booking it in another. The tenant is the isolation boundary, so bookings in one realm never interfere with another.