Background
ValidateRelationshipPermitted currently enforces source-side permissions only — it calls permitted_source_roles/0 on the changeset's own resource and rejects the action if the role isn't allowed.
Target-side enforcement is missing: a resource with target :none can still be related to by any source, because the change only runs on the source's update action and has no way to resolve the target's resource module from its record ID alone.
The gap
When relate_shelf runs with a CardInstance as the target, we call ShelfInstance.permitted_source_roles/0 ✓. We do not call CardInstance.permitted_target_roles/0 ✗. A CardInstance with target :none would silently accept incoming relationships.
Implementation plan — runtime introspection, no registry needed
All the information we need is available at runtime. No compile-time registry required.
How it works
At the point ValidateRelationshipPermitted runs, we have changeset.domain. The approach:
- Call
Ash.Domain.Info.resources(changeset.domain) to enumerate all resource modules in the domain.
- Filter to those that export
permitted_target_roles/0 (via function_exported?(module, :permitted_target_roles, 0)) — these are all Instance resources using the provider extension.
- For each relationship in the
:relationships argument, try Ash.get(module, rel.id, domain: domain, authorize?: false) across candidate modules until one returns {:ok, record}.
- Call
module.permitted_target_roles/0 on the resolved module and check the relationship alias against it.
- If no module resolves the ID, fail with a clear error.
Cost
O(N queries) in the number of Instance resource types in the domain per relationship — in practice a small, bounded number. Only fires on update actions that carry argument :relationships, {:array, :struct}.
Future optimisation (not required)
If the O(N) cost becomes a concern, build a %{spec_uuid => module} lookup map at startup from all modules that export specification_id/0. The Specification instance data is the natural registry key since it is already the stable identifier for a resource kind. This would make resolution O(1). Defer until benchmarking shows it is needed.
Acceptance criteria
- A resource with
target :none rejects incoming relationships at the source's relate action.
- A resource with
target :all accepts them.
- A resource with
target [:consumes] only accepts relationships whose alias matches.
- Unit test:
relate_shelf targeting a CardInstance with target :none fails with "not permitted as target".
- Unit test: same action targeting a
CardInstance with target :all succeeds.
- Existing source-side tests continue to pass unchanged.
Background
ValidateRelationshipPermittedcurrently enforces source-side permissions only — it callspermitted_source_roles/0on the changeset's own resource and rejects the action if the role isn't allowed.Target-side enforcement is missing: a resource with
target :nonecan still be related to by any source, because the change only runs on the source's update action and has no way to resolve the target's resource module from its record ID alone.The gap
When
relate_shelfruns with aCardInstanceas the target, we callShelfInstance.permitted_source_roles/0✓. We do not callCardInstance.permitted_target_roles/0✗. ACardInstancewithtarget :nonewould silently accept incoming relationships.Implementation plan — runtime introspection, no registry needed
All the information we need is available at runtime. No compile-time registry required.
How it works
At the point
ValidateRelationshipPermittedruns, we havechangeset.domain. The approach:Ash.Domain.Info.resources(changeset.domain)to enumerate all resource modules in the domain.permitted_target_roles/0(viafunction_exported?(module, :permitted_target_roles, 0)) — these are all Instance resources using the provider extension.:relationshipsargument, tryAsh.get(module, rel.id, domain: domain, authorize?: false)across candidate modules until one returns{:ok, record}.module.permitted_target_roles/0on the resolved module and check the relationship alias against it.Cost
O(N queries) in the number of Instance resource types in the domain per relationship — in practice a small, bounded number. Only fires on update actions that carry
argument :relationships, {:array, :struct}.Future optimisation (not required)
If the O(N) cost becomes a concern, build a
%{spec_uuid => module}lookup map at startup from all modules that exportspecification_id/0. The Specification instance data is the natural registry key since it is already the stable identifier for a resource kind. This would make resolution O(1). Defer until benchmarking shows it is needed.Acceptance criteria
target :nonerejects incoming relationships at the source'srelateaction.target :allaccepts them.target [:consumes]only accepts relationships whose alias matches.relate_shelftargeting aCardInstancewithtarget :nonefails with "not permitted as target".CardInstancewithtarget :allsucceeds.