Queries: a query only asks the owner's own source and foci

31b25cb · Gabe Morell · 2026-11-04 22:32

13 files +236 -0
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