Skip to content

feat: support hierarchical tenants via multitenancy ancestor_attributes#2796

Open
jechol wants to merge 1 commit into
ash-project:mainfrom
jechol:hierarchical-tenants
Open

feat: support hierarchical tenants via multitenancy ancestor_attributes#2796
jechol wants to merge 1 commit into
ash-project:mainfrom
jechol:hierarchical-tenants

Conversation

@jechol

@jechol jechol commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Adds first-class support for hierarchical tenancy (organization → department → team) to attribute multitenancy, while keeping the tenant itself scalar.

multitenancy do
  strategy :attribute
  attribute :department_id
  ancestor_attributes [:organization_id]   # broadest-first; default []
end

defimpl Ash.ToAncestorTenants, for: MyApp.Department do
  def to_ancestor_tenants(department, _resource), do: [department.organization_id]
end

Without this, the ancestor level has to be reimplemented by hand on every department-scoped resource: a custom change stamping organization_id on create, plus hand-rolled index prefixes downstream.

What it does (when ancestor_attributes is configured):

  • creates stamp the ancestor attributes from the tenant
  • reads/updates/destroys filter on the ancestor attributes in addition to the tenant attribute
  • upsert conflict targets become ancestors ++ [attribute] ++ identity keys
  • if the ancestors derived from the tenant don't line up with the configuration (wrong count or nils), the operation raises rather than silently dropping the filters
  • a verifier rejects misconfiguration at compile time (unknown ancestor, listing the tenant attribute itself, non-:attribute strategy)

Backward compatibility

The tenant stays a single value — multitenancy_attribute/1 and every Ash.ToTenant contract are unchanged, so ash_oban/ash_paper_trail/manifest consumers are unaffected. Ancestors are derived from the original tenant via the new Ash.ToAncestorTenants protocol with @fallback_to_any returning []: resources that don't opt in (ancestor_attributes defaults to []) behave exactly as today, and no existing tenant value needs an implementation.

(I first tried making attribute accept a list; it broke the scalar-tenant contract across the ecosystem, so I discarded that design in favor of this one.)

Implementation

  • Ash.ToAncestorTenants protocol (new), ancestor_attributes DSL option, verifier rules
  • Ash.Resource.Info.multitenancy_ancestor_attributes/1, multitenancy_attributes/1, and multitenancy_attribute_values/3 — the single entry point the six action paths (read/create/bulk create/update/destroy/seed) reduce over

Validation

Full suite green. Also dogfooded on our production app (~55 department-scoped resources): the hand-written organization-stamping change is deleted outright, and the strict raise immediately caught a call site passing a bare integer id as tenant for a department-scoped load.

Companion PRs, opened alongside this one: ash-project/ash_sql#240 (join filters) and ash-project/ash_postgres#804 (index prefixes, composite FK with_match, snapshots). Proposal issue with the full picture: #2797.

Contributor checklist

Leave anything that you believe does not apply unchecked.

  • I accept the AI Policy, or AI was not used in the creation of this PR.
  • Bug fixes include regression tests
  • Chores
  • Documentation changes
  • Features include unit/acceptance tests
  • Refactoring
  • Update dependencies

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant