defmodule Runbox.Scenario.UserAction do
@moduledoc group: :output_actions
@moduledoc """
Toolbox for working with scenario user actions.
User action is action created by scenario via `Runbox.Scenario.UIAction` and fired
by user (usually from UI). Scenario is usually also a receiver of fired action.
Assumed workflow is following:
1) Scenario creates list of actions (using `Runbox.Scenario.UIAction`) to be fired
in the future and saves it somewhere (in incident asset for example).
2) UI displays available actions.
3) User fires selected action from UI.
4) UI pushes the action to raw Kafka topic (defined in action) with extra data attached
(usually some comment).
5) Normalizer pipeline receives the action and sends it to target scenario.
"""
defmodule Token do
@moduledoc false
use Joken.Config
@impl true
def token_config do
# no default claims - UI Actions use JWT as verifiable envelope
%{}
end
end
alias __MODULE__
alias Runbox.Message
@typedoc """
Fired user action.
When action is fired by the user, it should be pushed to raw `topic` with current
timestamp to be correctly reordered by `Normalizer`. Properties `type` and `details`
should contain enough information for routing action to the target scenario. Property
`user` is ID of user who fired the action, `data` contains extra information attached
by environment where user fired the action (for example comment filled by the user on UI).
"""
@type t :: %UserAction{type: String.t(), details: map, user: String.t(), data: any}
@derive Jason.Encoder
defstruct [:type, :details, :user, :data]
# User actions do not use JWT for security reasons, so static secret is good enough.
# If secret is changed, all saved user actions (JWTs) must be regenerated!
@signer Joken.Signer.create("HS256", "Kqz0EyLw6HKcLRv222S6U3v1h6T")
@doc """
Packs user action to JWT to be used (fired) later (from UI for example).
Parameter `details` must be a map encodable to JSON. Because `details` can be (and it is!)
encoded/decoded to/from JSON during its life-cycle, atom property names of `details` are
always converted to string property names in fired action's `details`.
JWT is not used for security reasons - it is just good enough envelope protecting
action against accidental corruption on its journey.
"""
@spec pack(String.t(), String.t(), map) ::
{:ok, Joken.bearer_token()} | {:error, Joken.error_reason()}
def pack(topic, type, details) when is_binary(topic) and is_binary(type) and is_map(details) do
claims = %{"topic" => topic, "type" => type, "details" => details}
with {:ok, token, _} <- Token.generate_and_sign(claims, @signer) do
{:ok, token}
end
end
@doc """
Unpacks action and makes it fired (prepared for sending to target topic).
Routing properties (`type`, `details`) are propagated from packed action, `user` and `data`
properties are attached to fired action. Target `topic` is also extracted from packed action.
Returns error if packed action (JWT) is not valid.
"""
@spec fire(Joken.bearer_token(), String.t(), any) ::
{:ok, topic :: String.t(), String.t()} | {:error, Joken.error_reason()}
def fire(packed, user, data) do
with {:ok, claims} <- Token.verify_and_validate(packed, @signer),
props = %{type: claims["type"], details: claims["details"], user: user, data: data},
{:ok, action} <- Jason.encode(props) do
{:ok, claims["topic"], action}
end
end
@doc """
Helper for UserAction forwarding to be used in normalizer.
If helper is used in normalizer pipeline configuration in `config.ini` like
[pipelines.some_pipeline]
definition = Runbox.Scenario.UserAction.normalizer_pipeline_step
then `Runbox.Message` `body` will contain fired `Runbox.Scenario.UserAction`.
"""
@spec normalizer_pipeline_step(Message.t()) :: Message.t()
def normalizer_pipeline_step(msg) do
action = UserAction.parse_raw_action(msg.body)
%{msg | body: action, type: action.type}
end
@doc """
Parses string containing JSON to `Runbox.Scenario.UserAction`.
"""
@spec parse_raw_action(String.t()) :: UserAction.t()
def parse_raw_action(msg) do
action = Jason.decode!(msg)
%UserAction{
type: Map.fetch!(action, "type"),
user: Map.fetch!(action, "user"),
data: Map.fetch!(action, "data"),
details: Map.fetch!(action, "details")
}
end
end