Skip to content

Proposal: hierarchical tenants via ancestor_attributes + Ash.ToAncestorTenants #2797

Description

@jechol

This issue description was drafted with AI assistance and reviewed by the submitter.

Code of Conduct

  • I agree to follow this project's Code of Conduct

AI Policy

  • I agree to follow this project's AI Policy, or I agree that AI was not used while creating this issue.

Is your feature request related to a problem? Please describe.

We run a B2B SaaS where tenancy is hierarchical: an organization contains departments. Some resources are organization-scoped (attribute :organization_id); department-scoped resources (attribute :department_id) also carry their organization id. Attribute multitenancy handles each single level well, but the ancestor level of the department-scoped resources has to be reimplemented by hand today:

  • stamping organization_id on create (a custom change on every resource)
  • prefixing identity unique indexes with the full hierarchy ([organization_id, department_id, ...]) while keeping upsert conflict targets matching them — Postgres rejects an upsert whose conflict target has no exactly-matching unique index — and letting organization-level queries (admin, reporting) use the same indexes

A deeper hierarchy (organization → department → team) multiplies the boilerplate.

Describe the solution you'd like

One DSL option and one protocol, leaving 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

For existing single-level users this is fully transparent: ancestor_attributes defaults to [], and a resource that doesn't opt in behaves exactly as today — no code or migration changes.

Behavior 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, consistent with the index layout
  • upsert conflict targets become ancestors ++ [attribute] ++ identity keys, matching the generated unique indexes
  • if the ancestors derived from the tenant don't line up with ancestor_attributes (wrong count or nils), the operation raises rather than silently dropping the filters
  • (ash_sql) relationship joins apply the same expanded filter set
  • (ash_postgres) identity/reference index tenant prefixes become [organization_id, department_id, ...]; resource snapshots store the new option so codegen diffs it correctly, and older snapshots without it load as []
  • (ash_postgres) references that already match on the tenant attribute now match on the full chain — the generated composite foreign keys enforce, at the DB level, that referencing and referenced rows agree on every level of the hierarchy; hierarchies of different depths pair their shared prefix

A deeper hierarchy is just a longer list — for example, if teams were nested inside departments, a team-scoped resource would use ancestor_attributes [:organization_id, :department_id].

PRs implementing this, opened alongside this issue:

The ash PR is self-contained and reviewable on its own; the other two build on its new Info APIs, so it merges first.

Describe alternatives you've considered

Making the multitenancy attribute accept a list. I implemented it first and it worked, but it broke the ecosystem's contract that a tenant is a single value — ash_oban's tenant_from_record, ash_paper_trail, the manifest, and every Ash.ToTenant impl assume scalar. I threw that version away. In the proposed design multitenancy_attribute/1 is unchanged and existing code is untouched; ancestors are derived from the original tenant via a new protocol with @fallback_to_any returning [], so no existing Ash.ToTenant setup needs to change.

Additional context

  • Full suites green on all three PRs: ash (3676), ash_postgres (885, including runtime multitenancy tests against Postgres and migration-generator coverage for index prefixes, snapshot round-trip, and 3-level chains).
  • Applied to our production-scale app (~55 department-scoped resources): the generated migration rewrote ~1,250 indexes to the hierarchical prefix, and our full suite of 1,507 tests passes with the hand-written organization-stamping change deleted — the feature replaces it outright.
  • Side note: dogfooding this work is also what surfaced the migration-ordering issues that became improvement: replace pairwise operation ordering with a dependency-graph topological sort ash_postgres#798.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions