PhoenixKitEntities.Managed (PhoenixKitEntities v0.4.6)

Copy Markdown View Source

Support for MANAGED entity blueprints — blueprints owned by another module (e.g. the catalogue's attribute sets), created and administered exclusively through that module's own UI on top of the entities API.

A managed blueprint carries in its settings:

"managed_by"  => "catalogue"          # owning module key
"locked_keys" => ["kind", ...]        # settings["<owner>"] keys the
                                      # generic write path must not touch

The two guarantees

  1. Hidden from the generic adminmanaged?/1 lets listings exclude these blueprints (PhoenixKitEntities.list_entities/1 accepts include_managed: false); the owning module renders its own management UI.
  2. Write-path protectionvalidate_mutation/2 and validate_delete/1 are called by the entities write path (not just the UI): mutations from anywhere but the owning module cannot rename the blueprint's slug, change its status, or touch locked settings keys; deletion requires the owner's confirmation callback to approve (e.g. the catalogue refuses while item attachments exist). UI guards without a write interceptor are theater. One write path is deliberately outside the interception: reorder_entities/2 bulk-updates position via update_all, and position is not part of any owner contract.

Owners bypass the guard by passing on_behalf_of: "<owner>" in opts — the guard is against accidental generic-admin edits, not a security boundary (all callers are admin code). It also covers only the blueprint rows themselves — data records under a managed blueprint go through the ordinary EntityData write path.

Delete approval

Owners register a delete-approval callback at runtime:

PhoenixKitEntities.Managed.register_delete_guard(
  "catalogue",
  &MyApp.Catalogue.deletion_guard/1
)

where deletion_guard/1 returns :ok or {:error, reason} (e.g. {:error, :set_in_use} while item attachments exist). The callback MUST be an external capture (&Mod.fun/1), never an anonymous fun: a local capture in :persistent_term goes stale when the registering module is purged (code reload / hot upgrade), raises on call, and every delete then fails closed with {:error, :delete_guard_error}.

Registration is process-independent (persistent_term), set up in the owning module's application start. A managed blueprint with no registered guard refuses deletion outright — fail closed.

Summary

Functions

True when the entity is managed by another module.

The owning module key, or nil.

Registers (replaces) the owner's delete-approval callback.

Validates CREATING an entity with attrs: a blueprint claiming a managed_by owner can only be provisioned by that owner (on_behalf_of matching). Without this, any generic caller could create a blueprint that masquerades as module-owned — hidden from the generic admin yet picked up by the owning module's listings (panel finding, 2026-08-18 review).

Validates deleting entity. Owner-originated calls consult nothing; generic calls are refused; the owner's registered guard arbitrates owner-side deletes.

Validates an update to entity with attrs. Returns :ok or {:error, reason}. Owner-originated calls (on_behalf_of matching the owner) pass unconditionally.

Functions

managed?(arg1)

@spec managed?(struct() | map()) :: boolean()

True when the entity is managed by another module.

owner(arg1)

@spec owner(struct() | map()) :: String.t() | nil

The owning module key, or nil.

register_delete_guard(owner, fun)

@spec register_delete_guard(String.t(), (struct() -> :ok | {:error, term()})) :: :ok

Registers (replaces) the owner's delete-approval callback.

validate_creation(attrs, opts \\ [])

@spec validate_creation(
  map(),
  keyword()
) :: :ok | {:error, :managed_blueprint}

Validates CREATING an entity with attrs: a blueprint claiming a managed_by owner can only be provisioned by that owner (on_behalf_of matching). Without this, any generic caller could create a blueprint that masquerades as module-owned — hidden from the generic admin yet picked up by the owning module's listings (panel finding, 2026-08-18 review).

validate_delete(entity, opts \\ [])

@spec validate_delete(struct(), keyword()) :: :ok | {:error, term()}

Validates deleting entity. Owner-originated calls consult nothing; generic calls are refused; the owner's registered guard arbitrates owner-side deletes.

validate_mutation(entity, attrs, opts \\ [])

@spec validate_mutation(struct(), map(), keyword()) ::
  :ok | {:error, :managed_blueprint | :locked_key}

Validates an update to entity with attrs. Returns :ok or {:error, reason}. Owner-originated calls (on_behalf_of matching the owner) pass unconditionally.