You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
This issue description was drafted with AI assistance and reviewed by the submitter.
Code of Conduct
AI Policy
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:organization_idon create (a custom change on every resource)[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 indexesA 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:
For existing single-level users this is fully transparent:
ancestor_attributesdefaults to[], and a resource that doesn't opt in behaves exactly as today — no code or migration changes.Behavior when
ancestor_attributesis configured:ancestors ++ [attribute] ++ identity keys, matching the generated unique indexesancestor_attributes(wrong count or nils), the operation raises rather than silently dropping the filters[organization_id, department_id, ...]; resource snapshots store the new option so codegen diffs it correctly, and older snapshots without it load as[]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
InfoAPIs, so it merges first.Describe alternatives you've considered
Making the multitenancy
attributeaccept 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'stenant_from_record, ash_paper_trail, the manifest, and everyAsh.ToTenantimpl assume scalar. I threw that version away. In the proposed designmultitenancy_attribute/1is unchanged and existing code is untouched; ancestors are derived from the original tenant via a new protocol with@fallback_to_anyreturning[], so no existingAsh.ToTenantsetup needs to change.Additional context