Issue #3626929: Lists show every tenant's records, while an administrator is refused another tenant's records
Closes #3626929. Replaces !397 (closed) (#3626905, closed as works as designed).
RELEASE NOTE: administer yoyaku is site-wide. Give it only to people trusted with every tenant.
administer yoyaku is the root of the booking engine (first commit). It reaches every record of every tenant. #3625422 had made YoyakuAccessControlHandler refuse an administrator any record outside the current tenant, and OrderAccessControlHandler limited deleting an order to the current tenant, administrators included. Both checks go:
YoyakuAccessControlHandleris theadminister yoyakugate and nothing else;AllotmentAccessControlHandler's shared-allotment permission is unchanged.- An order is still deleted only once archived or canceled. A person managing one tenant's orders holds a permission naming that tenant, which reaches no other.
- A tenant-bound administrator is a later, narrower permission (administer tenant).
Lists show the current tenant's records (second commit). TenantListHooks narrows a list of any type carrying the yoyaku_tenant key to the current tenant's rows, for everyone. A type whose tenant is optional also keeps the rows shared with every tenant. A list is:
- a View of such a type: the shipped
yoyaku_bookingslisted every tenant's bookings; - an access-checked entity query its code tags
TenantListHooks::LIST_TAG: every collection page (the list builders building their own query tag it, and the six keeping core's list query useTenantListQueryTrait), the slot, booking and order overviews, and the resources a filter offers.
It is not every access-checked query. A record's own children follow the record: a resource's Slots and Tariffs tabs, a slot's bookings. Code counting or checking rows must be handed all of them; a filter on every query made slot generation for another tenant's resource silently create no slot tariffs. A picker scopes itself to the record being edited, as AllotmentSelection already does, since that record may be another tenant's. Every tenant-bearing type also declares the yoyaku_tenant list cache context (third commit), which list builders and Views read, so a cached list is served to its own tenant only: a tenant may be resolved from more than the host, and the dynamic page cache does not key on the host. docs/multi-tenancy.md has a Who sees what section with the tag for other modules' lists.
Cost: one tag lookup per tenant-bearing type on each tagged or Views query, from type definitions read once per request. For a translatable type listed by an entity query, one id subquery on the data table, which is where its tenant is stored.
Tests, each checked against its part reverted:
TenantAccessTest::testAdministratorReachesEveryTenant().TenantAccessTest::testListsShowTheCurrentTenantOnly(): a tagged list shows this tenant's; an untagged read and a read without access check see both.SlotOverviewFilterTest::testTheResourceCollectionListsTheCurrentTenantsResources().SlotOverviewFilterTest::testTheOverviewListsTheCurrentTenantsSlots(): the overview lists this tenant's slot, and another tenant's resource tab still lists that resource's.BookingsViewStateFilterTest::testTheViewListsTheCurrentTenantsBookings(), which also checks the View varies by tenant.AllotmentScopeTest::testSharedAllotmentIsListedInEveryRealm().OrderAccessControlHandlerTest::testDeleteAcrossTenantsFollowsThePermission().- #3625422's assertions that an administrator is refused another tenant's record are rewritten to the new rule.
testAnEmptyTenantIsNotSharedWhereTheFieldRequiresOne()goes: opening a record no longer depends on its tenant, and a required tenant's list is a plain equality.
AI-Generated: Yes (Claude Code was used to write this change, its tests and this description. I reviewed them before posting.)