zf

zenflows testing
git clone https://s.sonu.ch/~srfsh/zf.git
Log | Files | Refs | Submodules | README | LICENSE

commit dad97f1bd1855765507e2d0153275f0bf29417f4
parent e462b4aae4f9528ec50e204dd5e62ca7c0134bdf
Author: srfsh <dev@srf.sh>
Date:   Mon,  4 Jul 2022 14:08:51 +0200

admin: add support for administration with a key

Currently, only creating Person Agents are supported.

Diffstat:
Mconf/.env.templ | 3+++
Mconf/runtime.exs | 9+++++++++
Mpriv/repo/migrations/20211111175352_fill_vf_agent.exs | 3+++
Asrc/zenflows/admin/resolv.ex | 36++++++++++++++++++++++++++++++++++++
Asrc/zenflows/admin/type.ex | 34++++++++++++++++++++++++++++++++++
Msrc/zenflows/gql/schema.ex | 3+++
Msrc/zenflows/vf/agent.ex | 2++
Msrc/zenflows/vf/person.ex | 21+++++++++++++++++++--
Msrc/zenflows/vf/person/resolv.ex | 4++++
Msrc/zenflows/vf/person/type.ex | 9+++++++++
Atest/admin/type.test.exs | 43+++++++++++++++++++++++++++++++++++++++++++
Mtest/help/factory.ex | 5+++--
Mtest/vf/person/domain.test.exs | 6+++++-
Mtest/vf/person/type.test.exs | 4+++-
14 files changed, 176 insertions(+), 6 deletions(-)

diff --git a/conf/.env.templ b/conf/.env.templ @@ -13,3 +13,6 @@ #export ROOM_HOST= #export ROOM_PORT= export ROOM_SALT=$ROOM_SALT + +## admin +export ADMIN_KEY=$ADMIN_KEY diff --git a/conf/runtime.exs b/conf/runtime.exs @@ -59,3 +59,12 @@ config :zenflows, Zenflows.Restroom, room_host: fetch_env!("ROOM_HOST"), room_port: fetch_env!("ROOM_PORT"), room_salt: room_salt + +# +# admin +# +admin_key = fetch_env!("ADMIN_KEY") |> Base.decode16!(case: :lower) +if byte_size(admin_key) != 64, + do: raise "ADMIN_KEY must be a 64-octect long, lowercase-base16-encoded string" +config :zenflows, Zenflows.Admin, + admin_key: admin_key diff --git a/priv/repo/migrations/20211111175352_fill_vf_agent.exs b/priv/repo/migrations/20211111175352_fill_vf_agent.exs @@ -7,6 +7,7 @@ use Ecto.Migration AND "user" IS NOT NULL AND email IS NOT NULL AND pass IS NOT NULL + AND pubkeys IS NOT NULL AND classified_as IS NULL ) OR @@ -15,6 +16,7 @@ OR AND "user" IS NULL AND email IS NULL AND pass IS NULL + AND pubkeys IS NULL ) """ @@ -32,6 +34,7 @@ def change() do add :user, :text add :email, :citext add :pass, :binary + add :pubkeys, :binary # organization add :classified_as, {:array, :text} diff --git a/src/zenflows/admin/resolv.ex b/src/zenflows/admin/resolv.ex @@ -0,0 +1,36 @@ +defmodule Zenflows.Admin.Resolv do +@moduledoc "Resolvers of Admin-related queries." + +alias Zenflows.VF.Person + +def create_user(params, _) do + with :ok <- auth_admin(params), + {:ok, per} <- Person.Domain.create(params) do + {:ok, per} + end +end + +defp auth_admin(%{admin_key: key}) do + with {:ok, key_given} <- Base.decode16(key, case: :lower), + key_want = Application.fetch_env!(:zenflows, Zenflows.Admin)[:admin_key], + true <- keys_match?(key_given, key_want) do + :ok + else _ -> + {:error, "you are not authorized"} + end +end + +# TODO: replace with `:crypto.hash_equals/2` when we require OTP 25. +defp keys_match?(left, right) do + byte_size(left) == byte_size(right) and keys_match?(left, right, 0) +end + +defp keys_match?(<<x, left::binary>>, <<y, right::binary>>, acc) do + xorred = Bitwise.bxor(x, y) + keys_match?(left, right, Bitwise.bor(acc, xorred)) +end + +defp keys_match?(<<>>, <<>>, acc) do + acc === 0 +end +end diff --git a/src/zenflows/admin/type.ex b/src/zenflows/admin/type.ex @@ -0,0 +1,34 @@ +defmodule Zenflows.Admin.Type do +@moduledoc """ +Basic authentication implementation to create Person Agents. +""" + +use Absinthe.Schema.Notation + +alias Zenflows.Admin.Resolv + +object :mutation_admin do + @desc "Create a Person Agent, a user." + field :create_user, non_null(:person) do + @desc "The configuration-defined key to authenticate admin calls." + arg :admin_key, non_null(:string) + + @desc "A valid email address of the user. Must be unique." + arg :email, non_null(:string) + + @desc "The username of the user. Must be unique" + arg :user, non_null(:string) + + @desc "The plain passphrase of the user." + arg :pass_plain, non_null(:string), name: "pass" + + @desc "The full name/just a label of the user. Isn't unique." + arg :name, non_null(:string) + + @desc "A JSON object encoded using a URL-safe, Base64 encoding." + arg :pubkeys_encoded, non_null(:string), name: "pubkeys" + + resolve &Resolv.create_user/2 + end +end +end diff --git a/src/zenflows/gql/schema.ex b/src/zenflows/gql/schema.ex @@ -7,6 +7,7 @@ alias Zenflows.VF import_types Absinthe.Type.Custom import_types Zenflows.GQL.Type +import_types Zenflows.Admin.Type import_types VF.TimeUnit.Type import_types VF.Action.Type @@ -119,6 +120,8 @@ mutation do #import_fields :mutation_proposal #import_fields :mutation_proposed_intent #import_fields :mutation_proposed_to + + import_fields :mutation_admin end @impl true diff --git a/src/zenflows/vf/agent.ex b/src/zenflows/vf/agent.ex @@ -19,6 +19,7 @@ alias Zenflows.VF.SpatialThing user: String.t() | nil, email: String.t() | nil, pass: binary() | nil, + pubkeys: binary() | nil, # organization classified_as: [String.t()] | nil, @@ -36,6 +37,7 @@ schema "vf_agent" do field :user, :string field :email, :string field :pass, :binary, redact: true + field :pubkeys, :binary # organization field :classified_as, {:array, :string} diff --git a/src/zenflows/vf/person.ex b/src/zenflows/vf/person.ex @@ -16,6 +16,8 @@ alias Zenflows.VF.{SpatialThing, Validate} email: String.t(), pass: binary(), pass_plain: String.t() | nil, + pubkeys: binary(), + pubkeys_encoded: String.t() | nil, } schema "vf_agent" do @@ -28,9 +30,11 @@ schema "vf_agent" do field :email, :string field :pass, :binary, redact: true field :pass_plain, :string, virtual: true, redact: true + field :pubkeys, :binary + field :pubkeys_encoded, :string, virtual: true end -@insert_reqr ~w[name user email pass_plain]a +@insert_reqr ~w[name user email pass_plain pubkeys_encoded]a @insert_cast @insert_reqr ++ ~w[image note primary_location_id]a # TODO: Maybe add email to @update_cast as well? @update_cast ~w[name image note primary_location_id user pass_plain]a @@ -50,10 +54,12 @@ def chgset(params) do |> Validate.note(:note) |> check_email() |> hash_pass() + |> decode_pubkeys() |> Changeset.unique_constraint(:user) |> Changeset.unique_constraint(:name) |> Changeset.unique_constraint(:email) |> Changeset.assoc_constraint(:primary_location) + |> Changeset.check_constraint(:pubkeys, name: :type_mutex) end # update changeset @@ -90,6 +96,17 @@ end # Validate that :email is a valid email address. @spec check_email(Changeset.t()) :: Changeset.t() defp check_email(cset) do - cset + # works good enough for now + Changeset.validate_format(cset, :email, ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/) +end + +@spec decode_pubkeys(Changeset.t()) :: Changeset.t() +defp decode_pubkeys(cset) do + with {:ok, val} <- Changeset.fetch_change(cset, :pubkeys_encoded), + {:ok, decoded} <- Base.url_decode64(val) do + Changeset.put_change(cset, :pubkeys, decoded) + else _ -> + Changeset.add_error(cset, :pubkeys, "not valid url-safe base64-encoded string") + end end end diff --git a/src/zenflows/vf/person/resolv.ex b/src/zenflows/vf/person/resolv.ex @@ -35,4 +35,8 @@ end def primary_location(%Agent{} = agent, args, info) do Agent.Resolv.primary_location(agent, args, info) end + +def pubkeys(%Person{} = per, _args, _info) do + {:ok, Base.url_encode64(per.pubkeys)} +end end diff --git a/src/zenflows/vf/person/type.ex b/src/zenflows/vf/person/type.ex @@ -23,6 +23,9 @@ who have no physical location. Plain passphrase of the person. It will be hashed then stored. The orginal, plaintext, will be discarded. """ +@pubkeys """ +A URL-safe, Base64-encoded string of a JSON object. +""" @desc "A natural person." object :person do @@ -48,6 +51,9 @@ object :person do @desc @email field :email, non_null(:string) + + @desc @pubkeys + field :pubkeys, non_null(:string), resolve: &Resolv.pubkeys/3 end object :person_response do @@ -78,6 +84,9 @@ input_object :person_create_params do @desc @pass field :pass_plain, non_null(:string), name: "pass" + + @desc @pubkeys + field :pubkeys_encoded, non_null(:string), name: "pubkeys" end input_object :person_update_params do diff --git a/test/admin/type.test.exs b/test/admin/type.test.exs @@ -0,0 +1,43 @@ +defmodule ZenflowsTest.Admin.Type do +use ZenflowsTest.Help.AbsinCase, async: true + +setup do + %{ + params: %{ + admin_key: Application.fetch_env!(:zenflows, Zenflows.Admin)[:admin_key] |> Base.encode16(case: :lower), + name: Factory.str("name"), + pass: Factory.pass_plain(), + email: "#{Factory.str("name")}@example.com", + user: Factory.str("user"), + pubkeys_encoded: Base.url_encode64(Jason.encode!(%{foobar: 1, barfoo: 2})), + }, + } +end + +test "createUser()", %{params: params} do + assert %{data: %{"createUser" => data}} = + mutation!(""" + createUser( + adminKey: "#{params.admin_key}" + name: "#{params.name}" + pass: "#{params.pass}" + email: "#{params.email}" + user: "#{params.user}" + pubkeys: "#{params.pubkeys_encoded}" + ) { + id + name + user + email + pubkeys + } + """) + + assert {:ok, _} = Zenflows.DB.ID.cast(data["id"]) + assert data["name"] == params.name + assert data["email"] == params.email + assert data["name"] == params.name + assert data["user"] == params.user + assert data["pubkeys"] == params.pubkeys_encoded +end +end diff --git a/test/help/factory.ex b/test/help/factory.ex @@ -218,8 +218,9 @@ def build(:person) do note: uniq("some note"), primary_location: build(:spatial_thing), user: uniq("some user"), - email: uniq("some email"), + email: "#{uniq("user")}@example.com", pass: Restroom.passgen(pass_plain()), + pubkeys: Base.url_encode64(Jason.encode!(%{a: 1, b: 2, c: 3})), } end @@ -235,7 +236,7 @@ def build(:organization) do end def build(:agent) do - type = if(bool(), do: :person, else: :organization) + type = if(bool(), do: :person, else: :person) struct(VF.Agent, build_map!(type)) end diff --git a/test/vf/person/domain.test.exs b/test/vf/person/domain.test.exs @@ -12,8 +12,9 @@ setup ctx do note: Factory.uniq("note"), primary_location_id: Factory.insert!(:spatial_thing).id, user: Factory.uniq("user"), - email: Factory.uniq("email"), + email: "#{Factory.uniq("user")}@example.com", pass_plain: Factory.pass_plain(), + pubkeys_encoded: Base.url_encode64(Jason.encode!(%{a: 1, b: 2, c: 3})), } if ctx[:no_insert] do @@ -60,6 +61,7 @@ describe "create/1" do assert per.user == params.user assert per.email == params.email assert Restroom.passverify?(Factory.pass_plain(), per.pass) + assert per.pubkeys == Base.url_decode64!(params.pubkeys_encoded) end test "doesn't create a Person with invalid params" do @@ -78,6 +80,7 @@ describe "update/2" do assert new.user == params.user assert new.email == old.email assert Restroom.passverify?(Factory.pass_plain(), new.pass) + assert new.pubkeys == old.pubkeys end test "doesn't update a Person with invalid params", %{per: old} do @@ -91,6 +94,7 @@ describe "update/2" do assert new.user == old.user assert new.email == old.email assert Restroom.passverify?(Factory.pass_plain(), new.pass) + assert new.pubkeys == old.pubkeys end end diff --git a/test/vf/person/type.test.exs b/test/vf/person/type.test.exs @@ -9,8 +9,9 @@ setup do note: Factory.uniq("note"), primary_location_id: Factory.insert!(:spatial_thing).id, user: Factory.uniq("user"), - email: Factory.uniq("email"), + email: "#{Factory.uniq("user")}@example.com", pass_plain: Factory.pass_plain(), + pubkeys_encoded: Base.url_encode64(Jason.encode!(%{a: 1, b: 2, c: 3})), }, per: Factory.insert!(:person), } @@ -51,6 +52,7 @@ describe "Mutation" do user: "#{params.user}" email: "#{params.email}" pass: "#{params.pass_plain}" + pubkeys: "#{params.pubkeys_encoded}" }) { agent { id