ash-project / ash-project/ash

Proposal: hierarchical tenants via ancestor_attributes + Ash.ToAncestorTenants

Open
#2,797 2 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.