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:
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