Proposal: hierarchical tenants via ancestor_attributes + Ash.ToAncestorTenants
- Dominant language
- Elixir
- Stars
- 2.5k
- Forks
- 422
- Avg merge
- 23h 26m
- Merged PRs (30d)
- 46
Description
This issue description was drafted with AI assistance and reviewed by the submitter.
### Code of Conduct
- [x] I agree to follow this project's Code of Conduct
### AI Policy
- [x] 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:
```elixir
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:
- ash: ash-project/ash#2796
- ash_sql: ash-project/ash_sql#240 (one change in join filters)
- ash_postgres: ash-project/ash_postgres#804
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 ash-project/ash_postgres#798.
Contributor guide
Research direction
Start with the self-contained ash-project/ash#2796 PR and its new Info APIs, then review the dependent ash_sql#240 and ash_postgres#804 changes. Check the referenced runtime, migration-generator, snapshot round-trip, and three-level hierarchy tests; the work is done when the full suites and generated hierarchical indexes, filters, and references are covered.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- elixir
- Domain
- backend, databases
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 15/100