neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Queries: a query only asks the owner's own source and foci
31b25cb · Gabe Morell · 2026-11-04 22:32
Message
{commit_body(@commit)}
Files changed
added
apps/room_sanctum/lib/room_sanctum/configuration/ownership.ex
+62
−0
@@ -0,0 +1,62 @@
| 1 | +defmodule RoomSanctum.Configuration.Ownership do | |
| 2 | + @moduledoc """ | |
| 3 | + A record may only point at its owner's own records. | |
| 4 | + | |
| 5 | + The forms only offer the user's own sources, queries, visions and so on, but | |
| 6 | + a form is not the only way in: whatever ids the client sends are cast as | |
| 7 | + they come. These check them against the owner of the record being saved. | |
| 8 | + | |
| 9 | + Only the ids a change *adds* are checked. Stored records can already hold | |
| 10 | + ids with nothing behind them -- deleting a record does not take its id off | |
| 11 | + everything that pointed at it -- and those have to stay editable. A record | |
| 12 | + with no owner is left alone, as `Vision`'s `query_ids` check does. | |
| 13 | + """ | |
| 14 | + import Ecto.Changeset | |
| 15 | + import Ecto.Query, only: [from: 2] | |
| 16 | + | |
| 17 | + alias RoomSanctum.Repo | |
| 18 | + | |
| 19 | + @message "must be your own" | |
| 20 | + | |
| 21 | + @doc """ | |
| 22 | + Refuse the change if `field` -- an id, or a list of them -- gains an id of a | |
| 23 | + `schema` row the record's owner does not own. | |
| 24 | + """ | |
| 25 | + def validate_owned(changeset, field, schema) do | |
| 26 | + added = List.wrap(get_change(changeset, field)) -- List.wrap(Map.get(changeset.data, field)) | |
| 27 | + check(changeset, field, added, schema) | |
| 28 | + end | |
| 29 | + | |
| 30 | + @doc """ | |
| 31 | + The same, for an id kept in an embed: `key` inside the `embed` field, as a | |
| 32 | + query keeps its `foci_id` and a packages source its `mailbox_source_id`. | |
| 33 | + """ | |
| 34 | + def validate_owned_in(changeset, embed, key, schema) do | |
| 35 | + case get_change(changeset, embed) do | |
| 36 | + nil -> | |
| 37 | + changeset | |
| 38 | + | |
| 39 | + new -> | |
| 40 | + old = embedded(Map.get(changeset.data, embed), key) | |
| 41 | + check(changeset, embed, List.wrap(embedded(new, key)) -- List.wrap(old), schema) | |
| 42 | + end | |
| 43 | + end | |
| 44 | + | |
| 45 | + defp embedded(%Ecto.Changeset{} = changeset, key), do: get_field(changeset, key) | |
| 46 | + defp embedded(%{} = struct, key), do: Map.get(struct, key) | |
| 47 | + defp embedded(nil, _key), do: nil | |
| 48 | + | |
| 49 | + defp check(changeset, field, added, schema) do | |
| 50 | + with [_ | _] = ids <- added |> Enum.reject(&is_nil/1) |> Enum.uniq(), | |
| 51 | + user_id when not is_nil(user_id) <- get_field(changeset, :user_id), | |
| 52 | + [_ | _] <- ids -- owned_ids(schema, ids, user_id) do | |
| 53 | + add_error(changeset, field, @message) | |
| 54 | + else | |
| 55 | + _ -> changeset | |
| 56 | + end | |
| 57 | + end | |
| 58 | + | |
| 59 | + defp owned_ids(schema, ids, user_id) do | |
| 60 | + Repo.all(from r in schema, where: r.id in ^ids and r.user_id == ^user_id, select: r.id) | |
| 61 | + end | |
| 62 | +end |
modified
apps/room_sanctum/lib/room_sanctum/configuration/query.ex
+6
−0
@@ -3,6 +3,8 @@ defmodule RoomSanctum.Configuration.Query do
| 3 | 3 | import Ecto.Changeset |
| 4 | 4 | import PolymorphicEmbed |
| 5 | 5 | |
| 6 | + alias RoomSanctum.Configuration.{Foci, Ownership, Source} | |
| 7 | + | |
| 6 | 8 | schema "cfg_queries" do |
| 7 | 9 | belongs_to :user, RoomSanctum.Accounts.User |
| 8 | 10 | field :name, :string |
@@ -55,6 +57,10 @@ defmodule RoomSanctum.Configuration.Query do
| 55 | 57 | |> foreign_key_constraint(:user_id) |
| 56 | 58 | |> cast_polymorphic_embed(:query, required: true) |
| 57 | 59 | |> validate_required([:name]) |
| 60 | + # The form only offers the owner's own sources and foci; anything else | |
| 61 | + # sent in their place is refused rather than queried. | |
| 62 | + |> Ownership.validate_owned(:source_id, Source) | |
| 63 | + |> Ownership.validate_owned_in(:query, :foci_id, Foci) | |
| 58 | 64 | end |
| 59 | 65 | |
| 60 | 66 | def meta_changeset(meta, attrs \\ %{}) do |
added
apps/room_sanctum/test/room_sanctum/query_owned_ids_test.exs
+59
−0
@@ -0,0 +1,59 @@
| 1 | +defmodule RoomSanctum.QueryOwnedIdsTest do | |
| 2 | + @moduledoc """ | |
| 3 | + A query asks one of its owner's sources, at one of its owner's foci. The | |
| 4 | + form only offers those; an id of someone else's, sent in their place, is | |
| 5 | + refused rather than queried. | |
| 6 | + """ | |
| 7 | + use RoomSanctum.DataCase | |
| 8 | + | |
| 9 | + import RoomSanctum.OwnershipFixtures | |
| 10 | + | |
| 11 | + alias RoomSanctum.Configuration | |
| 12 | + | |
| 13 | + defp weather(user, source, foci) do | |
| 14 | + Configuration.create_query(%{ | |
| 15 | + name: "Outside", | |
| 16 | + source_id: source.id, | |
| 17 | + user_id: user.id, | |
| 18 | + query: %{"__type__" => "weather", "foci_id" => foci.id} | |
| 19 | + }) | |
| 20 | + end | |
| 21 | + | |
| 22 | + test "the owner's own source and foci are accepted" do | |
| 23 | + me = owner() | |
| 24 | + assert {:ok, query} = weather(me, source_for(me), foci_for(me)) | |
| 25 | + assert query.user_id == me.id | |
| 26 | + end | |
| 27 | + | |
| 28 | + test "another user's source is refused" do | |
| 29 | + me = owner() | |
| 30 | + assert {:error, changeset} = weather(me, source_for(owner()), foci_for(me)) | |
| 31 | + assert %{source_id: [_]} = errors_on(changeset) | |
| 32 | + end | |
| 33 | + | |
| 34 | + test "another user's foci is refused" do | |
| 35 | + me = owner() | |
| 36 | + assert {:error, changeset} = weather(me, source_for(me), foci_for(owner())) | |
| 37 | + assert %{query: [_]} = errors_on(changeset) | |
| 38 | + end | |
| 39 | + | |
| 40 | + test "an existing query moved onto another user's source or foci is refused" do | |
| 41 | + me = owner() | |
| 42 | + {:ok, query} = weather(me, source_for(me), foci_for(me)) | |
| 43 | + them = owner() | |
| 44 | + | |
| 45 | + assert {:error, _} = Configuration.update_query(query, %{source_id: source_for(them).id}) | |
| 46 | + | |
| 47 | + assert {:error, _} = | |
| 48 | + Configuration.update_query(query, %{ | |
| 49 | + query: %{"__type__" => "weather", "foci_id" => foci_for(them).id} | |
| 50 | + }) | |
| 51 | + | |
| 52 | + assert {:ok, _} = Configuration.update_query(query, %{name: "Renamed"}) | |
| 53 | + | |
| 54 | + assert {:ok, _} = | |
| 55 | + Configuration.update_query(query, %{ | |
| 56 | + query: %{"__type__" => "weather", "foci_id" => foci_for(me).id} | |
| 57 | + }) | |
| 58 | + end | |
| 59 | +end |
added
apps/room_sanctum/test/support/fixtures/ownership_fixtures.ex
+109
−0
@@ -0,0 +1,109 @@
| 1 | +defmodule RoomSanctum.OwnershipFixtures do | |
| 2 | + @moduledoc """ | |
| 3 | + Records belonging to a given user, for the tests that check one user's | |
| 4 | + record cannot point at another's. | |
| 5 | + """ | |
| 6 | + | |
| 7 | + alias RoomSanctum.{Accounts, Configuration, Repo} | |
| 8 | + | |
| 9 | + def owner do | |
| 10 | + {:ok, user} = | |
| 11 | + Accounts.register_user(%{ | |
| 12 | + email: "owner#{System.unique_integer([:positive])}@example.com", | |
| 13 | + password: "hello world!hello world!" | |
| 14 | + }) | |
| 15 | + | |
| 16 | + user | |
| 17 | + end | |
| 18 | + | |
| 19 | + def source_for(user, attrs \\ %{}) do | |
| 20 | + {:ok, source} = | |
| 21 | + %{ | |
| 22 | + name: "Markets", | |
| 23 | + notes: "", | |
| 24 | + type: :bourse, | |
| 25 | + enabled: true, | |
| 26 | + user_id: user.id, | |
| 27 | + config: %{"__type__" => "bourse"} | |
| 28 | + } | |
| 29 | + |> Map.merge(attrs) | |
| 30 | + |> Configuration.create_source() | |
| 31 | + | |
| 32 | + source | |
| 33 | + end | |
| 34 | + | |
| 35 | + def query_for(user) do | |
| 36 | + {:ok, query} = | |
| 37 | + Configuration.create_query(%{ | |
| 38 | + name: "AAPL", | |
| 39 | + notes: "", | |
| 40 | + source_id: source_for(user).id, | |
| 41 | + user_id: user.id, | |
| 42 | + query: %{"__type__" => "bourse", "symbol" => "AAPL"} | |
| 43 | + }) | |
| 44 | + | |
| 45 | + query | |
| 46 | + end | |
| 47 | + | |
| 48 | + def foci_for(user) do | |
| 49 | + {:ok, foci} = | |
| 50 | + Configuration.create_foci(%{ | |
| 51 | + name: "Home", | |
| 52 | + user_id: user.id, | |
| 53 | + place: %Geo.Point{coordinates: {-71.0589, 42.3601}, srid: 4326} | |
| 54 | + }) | |
| 55 | + | |
| 56 | + foci | |
| 57 | + end | |
| 58 | + | |
| 59 | + def vision_for(user) do | |
| 60 | + {:ok, vision} = Configuration.create_vision(%{name: "Dash", user_id: user.id}) | |
| 61 | + vision | |
| 62 | + end | |
| 63 | + | |
| 64 | + def plani_for(user) do | |
| 65 | + {:ok, plani} = | |
| 66 | + Configuration.create_plani(%{ | |
| 67 | + name: "Pocket", | |
| 68 | + user_id: user.id, | |
| 69 | + home_foci_id: foci_for(user).id | |
| 70 | + }) | |
| 71 | + | |
| 72 | + plani | |
| 73 | + end | |
| 74 | + | |
| 75 | + def ankyra_for(user) do | |
| 76 | + {:ok, ankyra} = | |
| 77 | + Accounts.create_rabbit_user(%{ | |
| 78 | + username: Ecto.UUID.generate(), | |
| 79 | + password: "hunter2", | |
| 80 | + topic: "Some-Topic", | |
| 81 | + user_id: user.id | |
| 82 | + }) | |
| 83 | + | |
| 84 | + ankyra | |
| 85 | + end | |
| 86 | + | |
| 87 | + @doc """ | |
| 88 | + users_rabbit comes from the hermes repo's migrations and is not in the | |
| 89 | + sanctum test database. Made inside the test's sandbox transaction, so it goes | |
| 90 | + when the test does. | |
| 91 | + """ | |
| 92 | + def users_rabbit!(_ctx \\ %{}) do | |
| 93 | + Repo.query!(""" | |
| 94 | + CREATE TABLE IF NOT EXISTS users_rabbit ( | |
| 95 | + id bigserial PRIMARY KEY, | |
| 96 | + password varchar, | |
| 97 | + username uuid, | |
| 98 | + topic varchar, | |
| 99 | + client_ids varchar[] DEFAULT '{}', | |
| 100 | + auto_register_clients boolean DEFAULT true, | |
| 101 | + user_id bigint, | |
| 102 | + inserted_at timestamp, | |
| 103 | + updated_at timestamp | |
| 104 | + ) | |
| 105 | + """) | |
| 106 | + | |
| 107 | + :ok | |
| 108 | + end | |
| 109 | +end |
Parents: b7e3cd6