TD-421: Api key management service MVP (#1)

This commit is contained in:
Alexey S 2022-12-05 18:16:32 +04:00 committed by GitHub
parent 08369b617e
commit 70f113f3a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
83 changed files with 5879 additions and 0 deletions

206
.credo.exs Normal file
View File

@ -0,0 +1,206 @@
%{
#
# You can have as many configs as you like in the `configs:` field.
configs: [
%{
#
# Run any config using `mix credo -C <name>`. If no config name is given
# "default" is used.
#
name: "default",
#
# These are the files included in the analysis:
files: %{
#
# You can give explicit globs or simply directories.
# In the latter case `**/*.{ex,exs}` will be used.
#
included: [
"lib/",
"src/",
"test/",
"web/",
"apps/*/lib/",
"apps/*/src/",
"apps/*/test/",
"apps/*/web/"
],
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
},
#
# Load and configure plugins here:
#
plugins: [],
#
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
requires: [],
#
# If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below:
#
strict: true,
#
# To modify the timeout for parsing files, change this value:
#
parse_timeout: 5000,
#
# If you want to use uncolored output by default, you can change `color`
# to `false` below:
#
color: true,
#
# You can customize the parameters of any check by adding a second element
# to the tuple.
#
# To disable a check put `false` as second element:
#
# {Credo.Check.Design.DuplicatedCode, false}
#
checks: %{
enabled: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
#
## Design Checks
#
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage,
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
# You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
#
{Credo.Check.Design.TagTODO, [exit_status: 0]},
{Credo.Check.Design.TagFIXME, []},
#
## Readability Checks
#
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},
{Credo.Check.Readability.WithSingleClause, []},
#
## Refactoring Opportunities
#
{Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.MapJoin, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, []},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},
#
## Warnings
#
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []},
{Credo.Check.Warning.SpecWithStruct, []},
{Credo.Check.Warning.WrongTestFileExtension, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.UnsafeExec, []},
# Controversial and experimental checks
{Credo.Check.Design.DuplicatedCode, []},
{Credo.Check.Design.SkipTestWithoutComment, []},
{Credo.Check.Consistency.UnusedVariableNames, []},
{Credo.Check.Readability.BlockPipe, []},
{Credo.Check.Readability.ImplTrue, []},
{Credo.Check.Readability.NestedFunctionCalls, []},
{Credo.Check.Readability.SeparateAliasRequire, []},
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.IoPuts, []},
{Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.NegatedIsNil, []},
{Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.MixEnv, []},
{Credo.Check.Warning.UnsafeToAtom, []}
],
disabled: [
#
# Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`)
#
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks)
#
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
{Credo.Check.Readability.AliasAs, []},
{Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Refactor.PipeChainStart, []},
{Credo.Check.Refactor.ModuleDependencies, []},
{Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Warning.LazyLogging, []}
# {Credo.Check.Refactor.MapInto, []},
#
# Custom checks can be created using `mix credo.gen.check`.
#
]
}
}
]
}

3
.env Normal file
View File

@ -0,0 +1,3 @@
SERVICE_NAME=api_key_mgmt
OTP_VERSION=25
ELIXIR_VERSION=1.14

9
.formatter.exs Normal file
View File

@ -0,0 +1,9 @@
# Used by "mix format"
[
inputs: [
"{mix,.formatter,.credo}.exs",
"config/*.exs",
"priv/repository/migrations/*.exs",
"apps/**/{lib,test}/**/*.{ex,exs}"
]
]

21
.github/workflows/build-image.yml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Build and publish Docker image
on:
push:
branches:
- 'master'
- 'epic/**'
pull_request:
branches: ['**']
env:
REGISTRY: ghcr.io
jobs:
build-push:
runs-on: ubuntu-latest
steps:
- uses: valitydev/action-deploy-docker@v2
with:
registry-username: ${{ github.actor }}
registry-access-token: ${{ secrets.GITHUB_TOKEN }}

34
.github/workflows/elixir-checks.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Elixir CI Checks
on:
push:
branches:
- 'master'
- 'epic/**'
pull_request:
branches: ['**']
jobs:
setup:
name: Load .env
runs-on: ubuntu-latest
outputs:
otp-version: ${{ steps.otp-version.outputs.version }}
elixir-version: ${{ steps.elixir-version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v2
- run: grep -v '^#' .env >> $GITHUB_ENV
- id: otp-version
run: echo "::set-output name=version::$OTP_VERSION"
- id: elixir-version
run: echo "::set-output name=version::$ELIXIR_VERSION"
run:
name: Run checks
needs: setup
uses: ./.github/workflows/elixir-parallel-build.yml
with:
otp-version: ${{ needs.setup.outputs.otp-version }}
elixir-version: ${{ needs.setup.outputs.elixir-version }}
run-tests-with-compose: true

View File

@ -0,0 +1,169 @@
name: Elixir Parallel Build
on:
workflow_call:
inputs:
# Beam env
otp-version:
description: 'Erlang/OTP version to use.'
required: true
type: string
elixir-version:
description: 'Elixir version to use.'
required: true
type: string
# Test env
run-tests-with-compose:
description: 'Run tests in a docker-compose environment, requires a compose.yml file.'
required: false
default: false
type: boolean
run-tests-compose-container-name:
description: 'Service name, as in docker-compose.yml (default: testrunner).'
required: false
default: "testrunner"
type: string
# Coverage env
use-coveralls:
description: 'Use coveralls for code coverage analysis.'
required: false
default: false
type: boolean
# Workflow env
cache-version:
description: 'Cache version. Only change this if you *need* to reset build caches.'
required: false
default: "v1"
type: string
jobs:
build:
name: Build
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup BEAM
uses: erlef/setup-beam@v1.10
with:
otp-version: ${{ inputs.otp-version }}
elixir-version: ${{ inputs.elixir-version }}
- name: Cache deps
uses: actions/cache@v3
with:
path: deps
key: ${{ inputs.cache-version }}-otp-${{ inputs.otp-version }}-deps-${{ hashFiles('mix.lock') }}
restore-keys: |
${{ inputs.cache-version }}-otp-${{ inputs.otp-version }}-deps-
- name: Get dependencies
run: |
mix local.hex --force
mix local.rebar --force
mix deps.get
- name: Cache _build
uses: actions/cache@v3
with:
path: _build/dev/lib
key: ${{ inputs.cache-version }}-otp-${{ inputs.otp-version }}-build-${{ hashFiles('mix.lock') }}
restore-keys: |
${{ inputs.cache-version }}-otp-${{ inputs.otp-version }}-build-
- name: Compile
run: mix compile
check:
name: Check
runs-on: ubuntu-20.04
needs: build
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup BEAM
uses: erlef/setup-beam@v1.10
with:
otp-version: ${{ inputs.otp-version }}
elixir-version: ${{ inputs.elixir-version }}
- name: Cache deps
uses: actions/cache@v3
with:
path: deps
key: ${{ inputs.cache-version }}-otp-${{ inputs.otp-version }}-deps-${{ hashFiles('mix.lock') }}
restore-keys: |
${{ inputs.cache-version }}-otp-${{ inputs.otp-version }}-deps-
- name: Cache _build
uses: actions/cache@v3
with:
path: _build/dev/lib
key: ${{ inputs.cache-version }}-otp-${{ inputs.otp-version }}-build-${{ hashFiles('mix.lock') }}
restore-keys: |
${{ inputs.cache-version }}-otp-${{ inputs.otp-version }}-build-
- name: Check dependencies
run: mix deps.unlock --check-unused
- name: Check format
run: mix format --check-formatted
- name: Run credo
run: mix credo --strict
- name: Cache PLTs
uses: actions/cache@v3
with:
path: |
_build/*/*.plt
_build/*/*.plt.hash
key: ${{ inputs.cache-version }}-otp-${{ inputs.otp-version }}-plt-${{ hashFiles('mix.lock') }}
restore-keys: |
${{ inputs.cache-version }}-otp-${{ inputs.otp-version }}-plt-
- name: Run dialyzer
run: mix dialyzer
test:
name: Test
needs: build
runs-on: ubuntu-20.04
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup BEAM
uses: erlef/setup-beam@v1.10
with:
otp-version: ${{ inputs.otp-version }}
elixir-version: ${{ inputs.elixir-version }}
- name: Cache deps
uses: actions/cache@v3
with:
path: deps
key: ${{ inputs.cache-version }}-otp-${{ inputs.otp-version }}-deps-${{ hashFiles('mix.lock') }}
restore-keys: |
${{ inputs.cache-version }}-otp-${{ inputs.otp-version }}-deps-
- name: Run ExUnit
id: run-tests
if: ${{ inputs.run-tests-with-compose == false }}
run: mix test --cover
- name: Run ExUnit (/w docker-compose)
id: run-tests-w-compose
if: ${{ inputs.run-tests-with-compose == true }}
env:
# Pass workflow params to use in docker-compose.yml
DEV_IMAGE_TAG: ${{ inputs.run-tests-compose-container-name }}-dev
ELIXIR_VERSION: ${{ inputs.elixir-version }}
# Enable buildkit extensions in docker compose
COMPOSE_DOCKER_CLI_BUILD: true
DOCKER_BUILDKIT: true
run: |
docker-compose run --use-aliases --rm ${{ inputs.run-tests-compose-container-name }} \
mix do local.hex --force, local.rebar --force, test --cover

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
cover
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
api_key_mgmt-*.tar
# Temporary files, for example, from tests.
/tmp/
# Makefile artifact
.image.dev
# compose.yml artifact
.mix
# ElixirLS
.elixir_ls

39
Dockerfile Normal file
View File

@ -0,0 +1,39 @@
ARG ELIXIR_VERSION
FROM docker.io/library/elixir:${ELIXIR_VERSION} AS builder
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Copy sources
RUN mkdir /build
COPY . /build/
# Build the release
WORKDIR /build
RUN mix local.hex --force && \
mix local.rebar --force && \
mix deps.get && \
MIX_ENV=prod mix release
FROM docker.io/library/elixir:${ELIXIR_VERSION}-slim
ARG SERVICE_NAME
# Set env
ENV CHARSET=UTF-8
ENV LANG=C.UTF-8
# Expose SERVICE_NAME as env so CMD expands properly on start
ENV SERVICE_NAME=${SERVICE_NAME}
# Set runtime
WORKDIR /opt/${SERVICE_NAME}
COPY --from=builder /build/_build/prod/rel/${SERVICE_NAME} /opt/${SERVICE_NAME}
RUN echo "#!/bin/sh" >> /entrypoint.sh && \
echo "exec /opt/${SERVICE_NAME}/bin/${SERVICE_NAME} foreground" >> /entrypoint.sh && \
chmod +x /entrypoint.sh
ENTRYPOINT []
CMD ["/entrypoint.sh"]
EXPOSE 8080

11
Dockerfile.dev Normal file
View File

@ -0,0 +1,11 @@
ARG ELIXIR_VERSION
FROM docker.io/library/elixir:${ELIXIR_VERSION}
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Set env
ENV CHARSET=UTF-8
ENV LANG=C.UTF-8
# Set runtime
CMD ["/bin/bash"]

99
Makefile Normal file
View File

@ -0,0 +1,99 @@
# HINT
# Use this file to override variables here.
# For example, to run with podman put `DOCKER=podman` there.
-include Makefile.env
# NOTE
# Variables specified in `.env` file are used to pick and setup specific
# component versions, both when building a development image and when running
# CI workflows on GH Actions. This ensures that tasks run with `wc-` prefix
# (like `wc-dialyze`) are reproducible between local machine and CI runners.
DOTENV := $(shell grep -v '^\#' .env)
# Development images
DEV_IMAGE_TAG = $(TEST_CONTAINER_NAME)-dev
DEV_IMAGE_ID = $(file < .image.dev)
DOCKER ?= docker
DOCKERCOMPOSE ?= docker-compose
DOCKERCOMPOSE_W_ENV = DEV_IMAGE_TAG=$(DEV_IMAGE_TAG) $(DOCKERCOMPOSE)
MIX ?= mix
IEX ?= iex
TEST_CONTAINER_NAME ?= testrunner
all: compile
.PHONY: dev-image clean-dev-image wc-shell test
dev-image: .image.dev
.image.dev: Dockerfile.dev .env
env $(DOTENV) $(DOCKERCOMPOSE_W_ENV) build $(TEST_CONTAINER_NAME)
$(DOCKER) image ls -q -f "reference=$(DEV_IMAGE_ID)" | head -n1 > $@
clean-dev-image:
ifneq ($(DEV_IMAGE_ID),)
$(DOCKER) image rm -f $(DEV_IMAGE_TAG)
rm .image.dev
endif
DOCKER_WC_OPTIONS := -v $(PWD):$(PWD) --workdir $(PWD)
DOCKER_WC_EXTRA_OPTIONS ?= --rm
DOCKER_RUN = $(DOCKER) run -t $(DOCKER_WC_OPTIONS) $(DOCKER_WC_EXTRA_OPTIONS)
DOCKERCOMPOSE_RUN = $(DOCKERCOMPOSE_W_ENV) run --rm $(DOCKER_WC_OPTIONS)
# Utility tasks
wc-shell: dev-image
$(DOCKER_RUN) --interactive --tty $(DEV_IMAGE_TAG)
wc-%: dev-image
$(DOCKER_RUN) --tty $(DEV_IMAGE_TAG) make $*
wdeps-shell: dev-image
$(DOCKERCOMPOSE_RUN) $(TEST_CONTAINER_NAME) su; \
$(DOCKERCOMPOSE_W_ENV) down
wdeps-%: dev-image
$(DOCKERCOMPOSE_RUN) $(TEST_CONTAINER_NAME) make $(if $(MAKE_ARGS),$(MAKE_ARGS) $*,$*); \
res=$$?; \
$(DOCKERCOMPOSE_W_ENV) down; \
exit $$res
# Mix tasks
mix-hex:
$(MIX) local.hex --force
mix-rebar:
$(MIX) local.rebar rebar3 $(shell which rebar3) --force
mix-support: mix-hex mix-rebar
mix-deps: mix-support
$(MIX) deps.get
mix-shell:
$(IEX) -S mix
compile: mix-support
$(MIX) compile
credo: mix-support
$(MIX) credo --strict
check-format: mix-support
$(MIX) format --check-formatted
dialyze: mix-support
$(MIX) dialyzer
test: mix-support
$(MIX) test
format: mix-support
$(MIX) format
test-cover: mix-support
$(MIX) test --cover

View File

@ -0,0 +1,5 @@
{
"coverage_options": {
"minimum_coverage": 90
}
}

View File

@ -0,0 +1,23 @@
defmodule ApiKeyMgmt do
@moduledoc """
Main application module.
"""
use Application
@impl Application
def start(_type, _args) do
children = [
ApiKeyMgmt.Repository,
{Plug.Cowboy, scheme: :http, plug: ApiKeyMgmt.Router, options: get_cowboy_opts()}
]
opts = [strategy: :one_for_one, name: ApiKeyMgmt.Supervisor]
Supervisor.start_link(children, opts)
end
defp get_cowboy_opts do
Application.get_env(:api_key_mgmt, Plug.Cowboy, default_cowboy_opts())
end
defp default_cowboy_opts, do: [port: 8080]
end

View File

@ -0,0 +1,99 @@
defmodule ApiKeyMgmt.ApiKey do
@moduledoc """
A schema struct describing an ApiKey.
Please note how access_token is required for issue changeset,
but the field itself is virtual.
"""
use Ecto.Schema
@type(status() :: :active, :revoked)
@type t() :: %__MODULE__{
__meta__: term(),
access_token: String.t(),
id: String.t(),
inserted_at: DateTime.t(),
metadata: map(),
name: String.t(),
party_id: String.t(),
status: status(),
updated_at: DateTime.t()
}
@primary_key {:id, :string, autogenerate: false}
@foreign_key_type :string
@timestamps_opts [type: :utc_datetime]
schema "api_keys" do
field(:access_token, :string, virtual: true)
field(:metadata, :map)
field(:name, :string)
field(:party_id, :string)
field(:status, Ecto.Enum, values: [:active, :revoked], default: :active)
timestamps()
end
@spec issue_changeset(
id :: String.t(),
party_id :: String.t(),
name :: String.t(),
access_token :: String.t(),
metadata :: map() | nil
) :: Ecto.Changeset.t()
def issue_changeset(id, party_id, name, access_token, metadata \\ nil) do
# Requiring access_token to be present here feels like both a bad and a good idea
# Good because it forces an understanding that authdata has to be issued first
# Bad because unless you know the field is virtual it might seem like it's saved to db
%__MODULE__{}
|> changeset(%{
id: id,
party_id: party_id,
access_token: access_token,
name: name,
metadata: metadata
})
end
@spec revoke_changeset(t()) :: Ecto.Changeset.t()
def revoke_changeset(api_key) do
import Ecto.Changeset
change(api_key, status: :revoked)
end
# TODO: This is a good candidate for protocol use, but Plugger validation mechanics need to be fleshed out first
@spec encode(t()) :: map()
def encode(%__MODULE__{} = api_key) do
%{
"id" => api_key.id,
"name" => api_key.name,
"status" => api_key.status |> to_string() |> String.capitalize(),
"createdAt" => DateTime.to_iso8601(api_key.inserted_at),
"accessToken" => api_key.access_token,
"metadata" => api_key.metadata
}
|> Map.reject(fn {_, v} -> v == nil end)
end
defp changeset(api_key, attrs) do
import Ecto.Changeset
api_key
|> cast(attrs, [:id, :party_id, :status, :name, :access_token])
|> validate_required([:id, :party_id, :status, :name, :access_token])
|> unique_constraint(:id, name: "api_keys_pkey")
end
end
defimpl ApiKeyMgmt.Auth.BouncerEntity, for: ApiKeyMgmt.ApiKey do
alias ApiKeyMgmt.ApiKey
alias Bouncer.Base.Entity
@spec to_bouncer_entity(ApiKey.t()) :: Entity.t()
def to_bouncer_entity(api_key) do
%Entity{
id: api_key.id,
party: api_key.party_id,
type: "ApiKey"
}
end
end

View File

@ -0,0 +1,51 @@
defmodule ApiKeyMgmt.ApiKeyRepository do
@moduledoc """
A combo of a Repository and ApiKey schema struct
"""
alias ApiKeyMgmt.{ApiKey, Repository}
@spec get(id :: String.t()) :: {:ok, ApiKey.t()} | {:error, :not_found}
def get(id) do
case Repository.get(ApiKey, id) do
nil -> {:error, :not_found}
found -> {:ok, found}
end
end
@spec list(party_id :: String.t(), opts :: Keyword.t()) ::
[ApiKey.t()]
def list(party_id, opts \\ []) do
require Ecto.Query
query =
ApiKey
|> Ecto.Query.where(party_id: ^party_id)
query =
case opts[:status_filter] do
nil -> query
status_filter -> query |> Ecto.Query.where(status: ^status_filter)
end
Repository.all(query)
end
@spec issue(
id :: String.t(),
party_id :: String.t(),
name :: String.t(),
token :: String.t()
) ::
{:ok, ApiKey.t()} | {:error, any()}
def issue(id, party_id, name, token) do
ApiKey.issue_changeset(id, party_id, name, token)
|> Repository.insert()
end
@spec revoke(ApiKey.t()) :: {:ok, ApiKey.t()} | {:error, any()}
def revoke(api_key) do
api_key
|> ApiKey.revoke_changeset()
|> Repository.update()
end
end

View File

@ -0,0 +1,71 @@
defmodule ApiKeyMgmt.Auth do
@moduledoc """
Module that acts upon an Auth.Context to perform authentication and authorization.
Performs necessary RPC requests to TokenKeeper, OrgManagement and Bouncer.
"""
alias ApiKeyMgmt.Auth.Context
alias Plugger.Generated.Auth.{SecurityScheme, SecurityScheme.Bearer}
alias TokenKeeper.{Authenticator, Identity}
@spec authenticate(Context.t(), SecurityScheme.t(), opts :: Keyword.t()) ::
{:allowed, Context.t()} | {:forbidden, reason :: any}
def authenticate(context, %Bearer{token: token}, opts) do
with {:ok, identity} <- get_bearer_identity(token, context.request_origin, opts),
{:ok, context_fragments} <- get_identity_fragments(identity, opts) do
{:allowed,
%{context | external_fragments: Map.merge(context.external_fragments, context_fragments)}}
else
{:error, reason} -> {:forbidden, reason}
end
end
@spec authorize(Context.t(), opts :: Keyword.t()) ::
{:allowed, Context.t()}
| :forbidden
def authorize(context, opts) do
resolution =
context
|> Context.get_fragments()
|> Bouncer.judge(opts[:rpc_context])
case resolution do
{:ok, :allowed} -> {:allowed, context}
{:ok, :forbidden} -> :forbidden
end
end
##
@spec get_bearer_identity(token :: String.t(), request_origin :: String.t(), Keyword.t()) ::
{:ok, Identity.t()}
| {:error, Authenticator.error()}
defp get_bearer_identity(token, request_origin, opts) do
client = Authenticator.client(opts[:rpc_context])
Authenticator.authenticate(client, token, request_origin)
end
defp get_identity_fragments(identity, opts) do
with {:ok, additional_fragments} <- get_identity_type_fragments(identity.type, opts) do
{:ok, Map.merge(%{"token-keeper" => identity.bouncer_fragment}, additional_fragments)}
end
end
defp get_identity_type_fragments(%TokenKeeper.Identity.User{id: user_id}, opts) do
fragment =
case get_user_org_fragment(user_id, opts) do
{:ok, context_fragment} -> %{"org-management" => context_fragment}
{:error, {:user, :not_found}} -> %{}
end
{:ok, fragment}
end
defp get_identity_type_fragments(_identity, _opts) do
{:ok, %{}}
end
defp get_user_org_fragment(user_id, opts) do
OrgManagement.get_user_context(user_id, opts[:rpc_context])
end
end

View File

@ -0,0 +1,7 @@
defprotocol ApiKeyMgmt.Auth.BouncerEntity do
@moduledoc """
Protocol used to convert a struct into a Bouncer entity.
"""
@spec to_bouncer_entity(t) :: Bouncer.Base.Entity.t()
def to_bouncer_entity(term)
end

View File

@ -0,0 +1,86 @@
defmodule ApiKeyMgmt.Auth.Context do
@moduledoc """
A struct containing information about the current authentication and authorization context.
In handler code, please use `put_operation/4` and `add_operation_entity/2` to
add information needed to perform authorization.
"""
alias ApiKeyMgmt.Auth.BouncerEntity
alias Bouncer.Context.V1.ContextFragment
@fragment_id "api-key-mgmt"
@enforce_keys [:request_origin, :app_fragment]
defstruct external_fragments: %{}, app_fragment: nil, request_origin: nil
@type t() :: %__MODULE__{
request_origin: String.t() | nil,
external_fragments: Bouncer.fragments(),
app_fragment: ContextFragment.t()
}
@spec new(
request_origin :: String.t() | nil,
requester_ip :: :inet.ip_address(),
deployment_id :: String.t(),
ts_now :: DateTime.t() | nil
) :: t()
def new(request_origin, requester_ip, deployment_id, ts_now \\ nil) do
%__MODULE__{
request_origin: request_origin,
app_fragment: build_fragment_base(requester_ip, ts_now, deployment_id)
}
end
@spec put_operation(
t(),
operation_id :: String.t(),
party_id :: String.t() | nil,
api_key_id :: String.t() | nil
) :: t()
def put_operation(context, operation_id, party_id \\ nil, api_key_id \\ nil) do
alias Bouncer.Base.Entity
import Bouncer.ContextFragmentBuilder
party = if(party_id, do: %Entity{id: party_id})
api_key = if(api_key_id, do: %Entity{id: api_key_id})
app_fragment = apikeymgmt(context.app_fragment, operation_id, party, api_key)
%{context | app_fragment: app_fragment}
end
@spec add_operation_entity(t(), BouncerEntity.t()) :: t()
def add_operation_entity(context, entity) do
import Bouncer.ContextFragmentBuilder
app_fragment = add_entity(context.app_fragment, BouncerEntity.to_bouncer_entity(entity))
%{context | app_fragment: app_fragment}
end
@spec get_fragments(t()) :: Bouncer.fragments()
def get_fragments(context) do
import Bouncer.ContextFragmentBuilder
baked_app_fragment = bake(context.app_fragment)
Map.merge(context.external_fragments, %{@fragment_id => baked_app_fragment})
end
##
defp build_fragment_base(requester_ip, ts_now, deployment_id) do
import Bouncer.ContextFragmentBuilder
requester_ip =
requester_ip
|> :inet.ntoa()
|> List.to_string()
build()
|> environment(ts_now, deployment_id)
|> requester(requester_ip)
end
end

View File

@ -0,0 +1,198 @@
defmodule ApiKeyMgmt.Handler do
@moduledoc """
Core logic of the service.
"""
@behaviour Plugger.Generated.Handler
alias ApiKeyMgmt.{ApiKey, ApiKeyRepository, Auth}
alias Plugger.Generated.Auth.SecurityScheme
alias Plugger.Generated.Response.{
Forbidden,
GetApiKeyOk,
IssueApiKeyOk,
ListApiKeysOk,
NotFound,
RevokeApiKeyNoContent
}
alias TokenKeeper.Authority
@default_deployment_id "Production"
defmodule Context do
@moduledoc """
Context for the currently handled operation
"""
alias ApiKeyMgmt.Auth.Context, as: AuthContext
alias Woody.Context, as: RpcContext
@enforce_keys [:rpc, :auth]
defstruct [:rpc, :auth]
@type t :: %__MODULE__{
rpc: RpcContext.t(),
auth: AuthContext.t()
}
@spec new(conn :: Plug.Conn.t(), deployment_id :: String.t(), ts_now :: DateTime.t() | nil) ::
t()
def new(conn, deployment_id, ts_now \\ nil) do
request_origin =
case List.keyfind(conn.req_headers, "origin", 0) do
{"origin", origin} -> origin
_notfound -> nil
end
%__MODULE__{
rpc: RpcContext.new(),
auth: AuthContext.new(request_origin, conn.remote_ip, deployment_id, ts_now)
}
end
end
@spec __init__(conn :: Plug.Conn.t()) :: Context.t()
def __init__(conn) do
Context.new(conn, get_deployment_id())
end
@spec __authenticate__(SecurityScheme.t(), Context.t()) ::
{:allow, Context.t()} | :deny
def __authenticate__(security_scheme, ctx) do
case Auth.authenticate(ctx.auth, security_scheme, rpc_context: ctx.rpc) do
{:allowed, auth_context} ->
{:allow, %{ctx | auth: auth_context}}
{:forbidden, _reason} ->
:deny
end
end
@spec get_api_key(party_id :: String.t(), api_key_id :: String.t(), Context.t()) ::
GetApiKeyOk.t() | NotFound.t() | Forbidden.t()
def get_api_key(party_id, api_key_id, ctx) do
with {:ok, api_key} <- ApiKeyRepository.get(api_key_id),
{:allowed, _} <-
ctx.auth
|> Auth.Context.put_operation("GetApiKey", party_id, api_key_id)
|> Auth.Context.add_operation_entity(api_key)
|> Auth.authorize(rpc_context: ctx.rpc) do
%GetApiKeyOk{content: encode_api_key(api_key)}
else
{:error, :not_found} -> %NotFound{}
:forbidden -> %Forbidden{}
end
end
@spec issue_api_key(party_id :: String.t(), api_key :: map(), Context.t()) ::
IssueApiKeyOk.t() | NotFound.t() | Forbidden.t()
def issue_api_key(party_id, api_key, ctx) do
authdata_id = Base.url_encode64(:snowflake.new(), padding: false)
identity = %TokenKeeper.Identity{
type: %TokenKeeper.Identity.Party{
id: party_id
}
}
case ctx.auth
|> Auth.Context.put_operation("IssueApiKey", party_id)
|> Auth.authorize(rpc_context: ctx.rpc) do
{:allowed, _} ->
{:ok, authdata} =
get_authority_id()
|> Authority.client(ctx.rpc)
|> Authority.create(authdata_id, identity)
{:ok, api_key} =
ApiKeyRepository.issue(authdata_id, party_id, api_key.name, authdata.token)
%IssueApiKeyOk{content: encode_api_key(api_key)}
:forbidden ->
%Forbidden{}
end
end
@spec list_api_keys(party_id :: String.t(), query :: Keyword.t(), Context.t()) ::
ListApiKeysOk.t() | Forbidden.t()
def list_api_keys(party_id, query, ctx) do
list_opts = if(query[:status], do: [status_filter: query[:status]], else: [])
case ctx.auth
|> Auth.Context.put_operation("ListApiKeys", party_id)
|> Auth.authorize(rpc_context: ctx.rpc) do
{:allowed, _} ->
results = ApiKeyRepository.list(party_id, list_opts)
results = results |> Enum.map(&encode_api_key/1) |> Enum.sort()
%ListApiKeysOk{content: %{"results" => results}}
:forbidden ->
%Forbidden{}
end
end
@spec revoke_api_key(
party_id :: String.t(),
api_key_id :: String.t(),
body :: String.t(),
Context.t()
) :: RevokeApiKeyNoContent.t() | NotFound.t() | Forbidden.t()
def revoke_api_key(party_id, api_key_id, "Revoked", ctx) do
with {:ok, api_key} <- ApiKeyRepository.get(api_key_id),
{:allowed, _} <-
ctx.auth
|> Auth.Context.put_operation("RevokeApiKey", party_id, api_key_id)
|> Auth.Context.add_operation_entity(api_key)
|> Auth.authorize(rpc_context: ctx.rpc) do
# TODO: Repository and Authority updates are not run atomically,
# which means descrepancies are possible between the state reported by the API (active),
# and the ability to authenticate with such key (none), in the event one or the other fails
# when running this operation.
# Temporary fix: manually fix the database with an SQL query.
# Permanent fix: TD-460
:ok =
get_authority_id()
|> Authority.client(ctx.rpc)
|> Authority.revoke(api_key.id)
try do
{:ok, _} = ApiKeyRepository.revoke(api_key)
rescue
ex ->
require Logger
Logger.error(
"API key id #{api_key_id} was revoked by authority " <>
"but I failed to update the database!"
)
reraise ex, __STACKTRACE__
end
%RevokeApiKeyNoContent{}
else
{:error, :not_found} -> %NotFound{}
:forbidden -> %Forbidden{}
end
end
defp get_authority_id do
# TODO: Research ways to make it a code option at this level, rather than doing an env fetch
get_conf(:authority_id) || raise "No authority_id configured for #{__MODULE__}!"
end
defp get_deployment_id do
get_conf(:deployment_id) || @default_deployment_id
end
defp get_conf(key) do
conf = Application.fetch_env!(:api_key_mgmt, __MODULE__)
conf[key]
end
defp encode_api_key(api_key) do
alias ApiKeyMgmt.ApiKey
ApiKey.encode(api_key)
end
end

View File

@ -0,0 +1,8 @@
defmodule ApiKeyMgmt.Repository do
@moduledoc """
Ecto repository for the application.
"""
use Ecto.Repo,
otp_app: :api_key_mgmt,
adapter: Ecto.Adapters.Postgres
end

View File

@ -0,0 +1,14 @@
defmodule ApiKeyMgmt.Router do
@moduledoc """
Plug router for the application. It's only job is to forward
requests to the codegenned Plugger router.
"""
use Plug.Router
plug(Plug.Logger)
plug(:match)
plug(:dispatch)
forward("/", to: Plugger.Generated.Router, assigns: %{handler: ApiKeyMgmt.Handler})
end

View File

@ -0,0 +1,27 @@
defmodule Plugger.Generated.Auth do
@moduledoc false
defmodule SecurityScheme do
@moduledoc false
defmodule Bearer do
@moduledoc false
@enforce_keys [:token]
defstruct [:token]
@type t :: %__MODULE__{
token: String.t()
}
end
@type t() :: Bearer.t()
@spec parse(Plug.Conn.t()) :: {:ok, t()} | {:error, :undefined_security_scheme}
def parse(conn) do
authorization = List.keyfind(conn.req_headers, "authorization", 0)
case authorization do
{"authorization", "Bearer" <> rest} -> {:ok, %Bearer{token: String.trim(rest)}}
_notfound -> {:error, :undefined_security_scheme}
end
end
end
end

View File

@ -0,0 +1,32 @@
defmodule Plugger.Generated.Handler do
@moduledoc false
alias Plugger.Generated.Auth.SecurityScheme
alias Plugger.Generated.Response.{
Forbidden,
GetApiKeyOk,
IssueApiKeyOk,
ListApiKeysOk,
NotFound,
RevokeApiKeyNoContent
}
@type ctx :: any
@callback __init__(conn :: Plug.Conn.t()) :: ctx()
@callback __authenticate__(SecurityScheme.t(), ctx()) ::
{:allow, ctx()} | :deny
@callback get_api_key(party_id :: String.t(), api_key_id :: String.t(), ctx()) ::
GetApiKeyOk.t() | NotFound.t() | Forbidden.t()
@callback issue_api_key(party_id :: String.t(), api_key :: map(), ctx()) ::
IssueApiKeyOk.t() | NotFound.t() | Forbidden.t()
@callback list_api_keys(party_id :: String.t(), query :: Keyword.t(), ctx()) ::
ListApiKeysOk.t() | Forbidden.t()
@callback revoke_api_key(
party_id :: String.t(),
api_key_id :: String.t(),
body :: String.t(),
ctx()
) :: RevokeApiKeyNoContent.t() | NotFound.t() | Forbidden.t()
end

View File

@ -0,0 +1,111 @@
defmodule Plugger.Generated.Response do
@moduledoc false
alias Plugger.Protocol.Response, as: ResponseProtocol
defmodule GetApiKeyOk do
@moduledoc false
@enforce_keys [:content]
defstruct [:content]
@type t :: %__MODULE__{
content: map()
}
end
defimpl ResponseProtocol, for: GetApiKeyOk do
@spec put_response(GetApiKeyOk.t(), Plug.Conn.t()) :: Plug.Conn.t()
def put_response(%GetApiKeyOk{content: content}, conn) do
import Plug.Conn
conn
|> put_resp_content_type("application/json")
|> resp(200, Jason.encode!(content))
end
end
defmodule IssueApiKeyOk do
@moduledoc false
@enforce_keys [:content]
defstruct [:content]
@type t :: %__MODULE__{
content: map()
}
end
defimpl ResponseProtocol, for: IssueApiKeyOk do
@spec put_response(IssueApiKeyOk.t(), Plug.Conn.t()) :: Plug.Conn.t()
def put_response(%IssueApiKeyOk{content: content}, conn) do
import Plug.Conn
conn
|> put_resp_content_type("application/json")
|> resp(200, Jason.encode!(content))
end
end
defmodule ListApiKeysOk do
@moduledoc false
@enforce_keys [:content]
defstruct [:content]
@type t :: %__MODULE__{
content: map()
}
end
defimpl ResponseProtocol, for: ListApiKeysOk do
@spec put_response(ListApiKeysOk.t(), Plug.Conn.t()) :: Plug.Conn.t()
def put_response(%ListApiKeysOk{content: content}, conn) do
import Plug.Conn
conn
|> put_resp_content_type("application/json")
|> resp(200, Jason.encode!(content))
end
end
defmodule RevokeApiKeyNoContent do
@moduledoc false
@enforce_keys []
defstruct []
@type t :: %__MODULE__{}
end
defimpl ResponseProtocol, for: RevokeApiKeyNoContent do
@spec put_response(RevokeApiKeyNoContent.t(), Plug.Conn.t()) :: Plug.Conn.t()
def put_response(%RevokeApiKeyNoContent{}, conn) do
import Plug.Conn
resp(conn, 204, "")
end
end
defmodule NotFound do
@moduledoc false
defstruct []
@type t :: %__MODULE__{}
end
defimpl ResponseProtocol, for: NotFound do
@spec put_response(NotFound.t(), Plug.Conn.t()) :: Plug.Conn.t()
def put_response(%NotFound{}, conn) do
import Plug.Conn
resp(conn, 404, "")
end
end
defmodule Forbidden do
@moduledoc false
defstruct []
@type t :: %__MODULE__{}
end
defimpl ResponseProtocol, for: Forbidden do
@spec put_response(Forbidden.t(), Plug.Conn.t()) :: Plug.Conn.t()
def put_response(%Forbidden{}, conn) do
import Plug.Conn
resp(conn, 403, "")
end
end
end

View File

@ -0,0 +1,156 @@
defmodule Plugger.Generated.Router do
@moduledoc false
use Plug.Router
alias Plugger.Generated.Auth.SecurityScheme
alias Plugger.Generated.Spec
alias Plugger.Protocol.Response, as: ResponseProtocol
require Logger
plug(Plugger.Plug.ContentType,
allowed_types: ["application/json"]
)
plug(Plug.Parsers,
parsers: [:json],
json_decoder: Jason
)
plug(:match)
plug(:dispatch)
get "/parties/:partyId/api-keys/:apiKeyId" do
with {:ok, conn} <- Spec.cast_and_validate(conn, :get_api_key),
{:ok, security_scheme} <- SecurityScheme.parse(conn) do
handler = conn.assigns[:handler]
handler_ctx = handler.__init__(conn)
case handler.__authenticate__(security_scheme, handler_ctx) do
{:allow, handler_ctx} ->
response = handler.get_api_key(partyId, apiKeyId, handler_ctx)
response
|> ResponseProtocol.put_response(conn)
|> send_resp()
:deny ->
send_resp(conn, :forbidden, "")
end
else
{:error, :undefined_security_scheme} ->
send_resp(conn, :forbidden, "")
{:error, {:invalid_request, errors}} ->
Logger.info("Request validation failed. Reason: #{inspect(errors)}")
send_resp(conn, :bad_request, make_request_validation_error(errors))
end
end
post "/parties/:partyId/api-keys" do
with {:ok, conn} <- Spec.cast_and_validate(conn, :issue_api_key),
{:ok, security_scheme} <- SecurityScheme.parse(conn) do
handler = conn.assigns[:handler]
handler_ctx = handler.__init__(conn)
case handler.__authenticate__(security_scheme, handler_ctx) do
{:allow, handler_ctx} ->
api_key = conn.body_params
response = handler.issue_api_key(partyId, api_key, handler_ctx)
response
|> ResponseProtocol.put_response(conn)
|> send_resp()
:deny ->
send_resp(conn, :forbidden, "")
end
else
{:error, :undefined_security_scheme} ->
send_resp(conn, :forbidden, "")
{:error, {:invalid_request, errors}} ->
Logger.info("Request validation failed. Reason: #{inspect(errors)}")
send_resp(conn, :bad_request, make_request_validation_error(errors))
end
end
get "/parties/:partyId/api-keys" do
with {:ok, conn} <- Spec.cast_and_validate(conn, :list_api_keys),
{:ok, security_scheme} <- SecurityScheme.parse(conn) do
conn = Plug.Conn.fetch_query_params(conn)
handler = conn.assigns[:handler]
handler_ctx = handler.__init__(conn)
case handler.__authenticate__(security_scheme, handler_ctx) do
{:allow, handler_ctx} ->
query =
Enum.into(conn.query_params, Keyword.new(), fn {k, v} ->
{k |> Macro.underscore() |> String.to_existing_atom(),
v |> Macro.underscore() |> String.to_existing_atom()}
end)
response = handler.list_api_keys(partyId, query, handler_ctx)
response
|> ResponseProtocol.put_response(conn)
|> send_resp()
:deny ->
send_resp(conn, :forbidden, "")
end
else
{:error, :undefined_security_scheme} ->
send_resp(conn, :forbidden, "")
{:error, {:invalid_request, errors}} ->
Logger.info("Request validation failed. Reason: #{inspect(errors)}")
send_resp(conn, :bad_request, make_request_validation_error(errors))
end
end
put "/parties/:partyId/api-keys/:apiKeyId/status" do
with {:ok, conn} <- Spec.cast_and_validate(conn, :revoke_api_key),
{:ok, security_scheme} <- SecurityScheme.parse(conn) do
handler = conn.assigns[:handler]
handler_ctx = handler.__init__(conn)
case handler.__authenticate__(security_scheme, handler_ctx) do
{:allow, handler_ctx} ->
status = Map.get(conn.body_params, "_json")
response = handler.revoke_api_key(partyId, apiKeyId, status, handler_ctx)
response
|> ResponseProtocol.put_response(conn)
|> send_resp()
:deny ->
send_resp(conn, :forbidden, "")
end
else
{:error, :undefined_security_scheme} ->
send_resp(conn, :forbidden, "")
{:error, {:invalid_request, errors}} ->
Logger.info("Request validation failed. Reason: #{inspect(errors)}")
send_resp(conn, :bad_request, make_request_validation_error(errors))
end
end
match _ do
send_resp(conn, :not_found, "")
end
defp make_request_validation_error(errors) do
alias OpenApiSpex.Cast.Error
reasons = errors |> Enum.map(&Error.message_with_path/1)
response = %{
"code" => "invalidRequest",
"message" => "Request validation failed. Reason: #{reasons}"
}
Jason.encode!(response)
end
end

View File

@ -0,0 +1,354 @@
defmodule Plugger.Generated.Spec do
@moduledoc false
@openapi_spec %OpenApiSpex.OpenApi{
openapi: "3.0.3",
info: %OpenApiSpex.Info{
title: "Vality API Keys Management API",
description:
"Vality API Keys Management API является интерфейсом для управления набором\nAPI-ключей, используемых для авторизации запросов к основному API с ваших\nбэкенд-сервисов. Любые сторонние приложения, включая ваш личный кабинет,\nявляются внешними приложениями-клиентами данного API.\n\nМы предоставляем REST API поверх HTTP-протокола, схема которого описывается в\nсоответствии со стандартом [OpenAPI 3][OAS3].\nКоды возврата описываются соответствующими HTTP-статусами. Платформа принимает и\nвозвращает значения JSON в теле запросов и ответов.\n\n[OAS3]: https://swagger.io/specification/\n\n## Формат содержимого\n\nЛюбой запрос к API должен выполняться в кодировке UTF-8 и с указанием\nсодержимого в формате JSON.\n\n```\nContent-Type: application/json; charset=utf-8\n```\n",
termsOfService: "https://vality.dev/",
license: %OpenApiSpex.License{
name: "Apache 2.0",
url: "https://www.apache.org/licenses/LICENSE-2.0.html"
},
version: "1.0.0"
},
servers: [],
paths: %{
"/parties/{partyId}/api-keys" => %OpenApiSpex.PathItem{
get: %OpenApiSpex.Operation{
tags: ["apiKeys"],
summary: "Перечислить ключи организации",
operationId: "listApiKeys",
parameters: [
%OpenApiSpex.Reference{"$ref": "#/components/parameters/partyId"},
%OpenApiSpex.Parameter{
name: :status,
in: :query,
description: "Фильтр по статусу ключа. По умолчанию `active`.\n",
required: false,
schema: %OpenApiSpex.Reference{
"$ref": "#/components/schemas/ApiKeyStatus"
}
}
],
responses: %{
"200" => %OpenApiSpex.Response{
description: "Ключи найдены",
content: %{
"application/json" => %OpenApiSpex.MediaType{
schema: %OpenApiSpex.Schema{
properties: %{
results: %OpenApiSpex.Schema{
items: %OpenApiSpex.Reference{
"$ref": "#/components/schemas/ApiKey"
},
type: :array
}
},
required: [:results],
type: :object
}
}
}
},
"400" => %OpenApiSpex.Reference{
"$ref": "#/components/responses/BadRequest"
},
"403" => %OpenApiSpex.Response{
description: "Операция недоступна"
}
},
callbacks: %{},
deprecated: false
},
post: %OpenApiSpex.Operation{
tags: ["apiKeys"],
summary: "Выпустить новый ключ",
operationId: "issueApiKey",
parameters: [
%OpenApiSpex.Reference{"$ref": "#/components/parameters/partyId"}
],
requestBody: %OpenApiSpex.RequestBody{
content: %{
"application/json" => %OpenApiSpex.MediaType{
schema: %OpenApiSpex.Reference{
"$ref": "#/components/schemas/ApiKey"
}
}
},
required: false
},
responses: %{
"200" => %OpenApiSpex.Response{
description: "Ключ выпущен",
content: %{
"application/json" => %OpenApiSpex.MediaType{
schema: %OpenApiSpex.Schema{
allOf: [
%OpenApiSpex.Reference{
"$ref": "#/components/schemas/ApiKey"
},
%OpenApiSpex.Reference{
"$ref": "#/components/schemas/AccessToken"
}
]
}
}
}
},
"400" => %OpenApiSpex.Reference{
"$ref": "#/components/responses/BadRequest"
},
"403" => %OpenApiSpex.Response{
description: "Операция недоступна"
}
},
callbacks: %{},
deprecated: false
}
},
"/parties/{partyId}/api-keys/{apiKeyId}" => %OpenApiSpex.PathItem{
get: %OpenApiSpex.Operation{
tags: ["apiKeys"],
summary: "Получить данные ключа",
operationId: "getApiKey",
parameters: [
%OpenApiSpex.Reference{"$ref": "#/components/parameters/partyId"},
%OpenApiSpex.Reference{"$ref": "#/components/parameters/apiKeyId"}
],
responses: %{
"200" => %OpenApiSpex.Response{
description: "Ключ найден",
content: %{
"application/json" => %OpenApiSpex.MediaType{
schema: %OpenApiSpex.Reference{
"$ref": "#/components/schemas/ApiKey"
}
}
}
},
"400" => %OpenApiSpex.Reference{
"$ref": "#/components/responses/BadRequest"
},
"403" => %OpenApiSpex.Response{
description: "Операция недоступна"
}
},
callbacks: %{},
deprecated: false
}
},
"/parties/{partyId}/api-keys/{apiKeyId}/status" => %OpenApiSpex.PathItem{
put: %OpenApiSpex.Operation{
tags: ["apiKeys"],
summary: "Отозвать ключ",
operationId: "revokeApiKey",
parameters: [
%OpenApiSpex.Reference{"$ref": "#/components/parameters/partyId"},
%OpenApiSpex.Reference{"$ref": "#/components/parameters/apiKeyId"}
],
requestBody: %OpenApiSpex.RequestBody{
content: %{
"application/json" => %OpenApiSpex.MediaType{
schema: %OpenApiSpex.Schema{enum: ["Revoked"], type: :string}
}
},
required: false
},
responses: %{
"204" => %OpenApiSpex.Response{
description: "Ключ отозван"
},
"400" => %OpenApiSpex.Reference{
"$ref": "#/components/responses/BadRequest"
},
"403" => %OpenApiSpex.Response{
description: "Операция недоступна"
},
"404" => %OpenApiSpex.Response{
description: "Ключ не найден"
}
},
callbacks: %{},
deprecated: false
}
}
},
components: %OpenApiSpex.Components{
schemas: %{
"AccessToken" => %OpenApiSpex.Schema{
properties: %{
accessToken: %OpenApiSpex.Schema{
description: "Токен доступа, ассоциированный с данным ключом",
example:
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0a2kiOiIxS2dJWUJHc0NncSIsImlhdCI6MTUxNjIzOTAyMn0.6YsaZQC9A7BjxXHwRbJfUO6VujOb4rHTKrqmMt64TbQ\n",
maxLength: 4000,
minLength: 1,
type: :string
}
},
required: [:accessToken],
type: :object
},
"ApiKey" => %OpenApiSpex.Schema{
description: "Ключ для авторизации запросов к API",
properties: %{
createdAt: %OpenApiSpex.Schema{
description: "Дата и время создания",
format: :"date-time",
readOnly: true,
type: :string
},
id: %OpenApiSpex.Reference{"$ref": "#/components/schemas/ApiKeyID"},
metadata: %OpenApiSpex.Schema{
description:
"Произвольный набор данных, специфичный для клиента API и\nнепрозрачный для системы\n",
properties: %{},
type: :object
},
name: %OpenApiSpex.Schema{
description: "Запоминающееся название ключа",
example: "live-site-integration",
maxLength: 40,
minLength: 1,
type: :string
},
status: %OpenApiSpex.Reference{
"$ref": "#/components/schemas/ApiKeyStatus"
}
},
required: [:id, :createdAt, :name, :status],
type: :object
},
"ApiKeyID" => %OpenApiSpex.Schema{
description: "Идентификатор ключа",
example: "1KgIYBGsCgq",
maxLength: 40,
minLength: 1,
readOnly: true,
type: :string
},
"ApiKeyStatus" => %OpenApiSpex.Schema{
description: "Статус ключа",
enum: ["Active", "Revoked"],
readOnly: true,
type: :string
}
},
responses: %{
"BadRequest" => %OpenApiSpex.Response{
description: "Переданы ошибочные данные",
content: %{
"application/json" => %OpenApiSpex.MediaType{
schema: %OpenApiSpex.Schema{
description: "Ошибка в переданных данных",
properties: %{
code: %OpenApiSpex.Schema{
enum: ["invalidRequest"],
type: :string
},
message: %OpenApiSpex.Schema{type: :string}
},
required: [:code],
type: :object
}
}
}
}
},
parameters: %{
"apiKeyId" => %OpenApiSpex.Parameter{
name: :apiKeyId,
in: :path,
description: "Идентификатор ключа",
required: true,
schema: %OpenApiSpex.Reference{"$ref": "#/components/schemas/ApiKeyID"}
},
"partyId" => %OpenApiSpex.Parameter{
name: :partyId,
in: :path,
description: "Идентификатор организации",
required: true,
schema: %OpenApiSpex.Schema{
description: "Идентификатор организации",
example: "bdaf9e76-1c5b-4798-b154-19b87a61dc94",
maxLength: 40,
minLength: 1,
type: :string
}
}
},
securitySchemes: %{
"bearer" => %OpenApiSpex.SecurityScheme{
type: "http",
description:
"Для аутентификации вызовов мы используем [JWT](https://jwt.io). Токен доступа передается в заголовке.\n```shell\n Authorization: Bearer {JWT}\n```\nЗапросы к данному API авторизуются сессионным токеном доступа, который вы получаете в результате аутентификации в личном кабинете.\n",
scheme: "bearer",
bearerFormat: "JWT"
}
}
},
security: [%{"bearer" => []}],
tags: [
%OpenApiSpex.Tag{
name: "apiKeys",
extensions: %{"x-displayName" => "API-ключи"}
},
%OpenApiSpex.Tag{
name: "errorCodes",
description:
"## Общие ошибки\n\nОшибки возникающие при попытках совершения недопустимых операций, операций с невалидными объектами или несуществующими ресурсами. Имеют следующий вид:\n\n```json\n{\n \"code\": \"string\",\n \"message\": \"string\"\n}\n```\n\nВ поле `message` содержится информация по произошедшей ошибке. Например:\n\n```json\n{\n \"code\": \"invalidRequest\",\n \"message\": \"Property 'name' is required.\"\n}\n```\n\n## Ошибки обработки запросов\n\nВ процессе обработки запросов силами нашей платформы могут происходить различные непредвиденные ситуации. Об их появлении платформа сигнализирует по протоколу HTTP соответствующими [статусами][5xx], обозначающими ошибки сервера.\n\n| Код | Описание |\n| ------- | ---------- |\n| **500** | В процессе обработки платформой запроса возникла непредвиденная ситуация. При получении подобного кода ответа мы рекомендуем обратиться в техническую поддержку. |\n| **503** | Платформа временно недоступна и не готова обслуживать данный запрос. Запрос гарантированно не выполнен, при получении подобного кода ответа попробуйте выполнить его позднее, когда доступность платформы будет восстановлена. |\n| **504** | Платформа превысила допустимое время обработки запроса, результат запроса не определён. Попробуйте отправить запрос повторно или выяснить результат выполнения исходного запроса, если повторное исполнение запроса нежелательно. |\n\n[5xx]: https://tools.ietf.org/html/rfc7231#section-6.6\n\n\nЕсли вы получили ошибку, которой нет в данном описании, обратитесь в техническую поддержку.\n",
extensions: %{"x-displayName" => "Коды ошибок"}
}
]
}
@spec get :: OpenApiSpex.OpenApi.t()
def get do
@openapi_spec
end
@spec cast_and_validate(Plug.Conn.t(), atom()) ::
{:ok, Plug.Conn.t()} | {:error, {:invalid_request, [OpenApiSpex.Cast.Error.t()]}}
def cast_and_validate(conn, :get_api_key) do
do_cast_and_validate(
conn,
@openapi_spec.paths["/parties/{partyId}/api-keys/{apiKeyId}"].get
)
end
def cast_and_validate(conn, :issue_api_key) do
do_cast_and_validate(
conn,
@openapi_spec.paths["/parties/{partyId}/api-keys"].post
)
end
def cast_and_validate(conn, :list_api_keys) do
do_cast_and_validate(
conn,
@openapi_spec.paths["/parties/{partyId}/api-keys"].get
)
end
def cast_and_validate(conn, :revoke_api_key) do
do_cast_and_validate(
conn,
@openapi_spec.paths["/parties/{partyId}/api-keys/{apiKeyId}/status"].put
)
end
defp do_cast_and_validate(conn, operation) do
case OpenApiSpex.cast_and_validate(@openapi_spec, operation, strip_glob(conn)) do
{:ok, _} = ok -> ok
{:error, reasons} -> {:error, {:invalid_request, reasons}}
end
end
defp strip_glob(conn) do
# Router forwarding introduces a "glob" path parameter
# TODO: this is probably not a good way to fix this in a generic way
%{conn | path_params: Map.drop(conn.path_params, ["glob"])}
end
end

View File

@ -0,0 +1,54 @@
defmodule Plugger.Plug do
@moduledoc """
Custom plugs.
"""
defmodule ContentType do
@moduledoc """
A plug that forces the content-type to be present for HTTP methods that can use it.
"""
@behaviour Plug
import Plug.Conn
alias Plug.{Conn, Conn.Unfetched}
@methods ~w(POST PUT PATCH DELETE)
@typep options :: %{allowed_types: [String.t()]}
@spec init(Keyword.t()) :: options
@impl Plug
def init(opts) do
{allowed_types, _opts} = Keyword.pop(opts, :allowed_types)
%{allowed_types: allowed_types}
end
@spec call(Conn.t(), options) :: Conn.t()
@impl Plug
def call(%{method: method, body_params: %Unfetched{}} = conn, options)
when method in @methods do
%{allowed_types: allowed_types} = options
%{req_headers: req_headers} = conn
case List.keyfind(req_headers, "content-type", 0) do
{"content-type", ct} ->
if ct in allowed_types do
conn
else
refute(conn)
end
_notfound ->
refute(conn)
end
end
def call(%{body_params: %Unfetched{}} = conn, _options) do
conn
end
defp refute(conn) do
conn
|> send_resp(:unsupported_media_type, "")
|> halt()
end
end
end

View File

@ -0,0 +1,10 @@
defmodule Plugger.Protocol do
@moduledoc false
defprotocol Response do
@moduledoc """
A protocol that puts response information into a Plug.Conn.
"""
@spec put_response(t(), Plug.Conn.t()) :: Plug.Conn.t()
def put_response(response, conn)
end
end

84
apps/api_key_mgmt/mix.exs Normal file
View File

@ -0,0 +1,84 @@
defmodule ApiKeyMgmt.MixProject do
use Mix.Project
def project do
[
app: :api_key_mgmt,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps(),
elixirc_paths: elixirc_paths(Mix.env()),
aliases: aliases(),
dialyzer: dialyzer(),
preferred_cli_env: preferred_cli_env(),
test_coverage: [tool: ExCoveralls]
] |> umbrella()
end
defp umbrella(project) do
project ++ [
build_path: "../../_build",
config_path: "../../config/config.exs",
deps_path: "../../deps",
lockfile: "../../mix.lock",
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
defp aliases do
[
test: ["ecto.create --quiet", "ecto.migrate", "test"]
]
end
defp dialyzer do
[
plt_add_apps: [:ex_unit]
]
end
defp preferred_cli_env do
[
dialyzer: :test,
coveralls: :test,
"coveralls.github": :test,
"coveralls.html": :test
]
end
def application do
[
extra_applications: [:logger],
mod: {ApiKeyMgmt, []}
]
end
defp deps do
[
# REST API
{:plug, "~> 1.13"},
{:plug_cowboy, "~> 2.5"},
{:jason, "~> 1.4"},
{:open_api_spex, git: "https://github.com/kehitt/open_api_spex.git", branch: "fix-cast-and-validate-read-only"},
# Database
{:ecto_sql, "~> 3.9"},
{:postgrex, "~> 0.16.5"},
# RPC Clients
{:bouncer, in_umbrella: true},
{:token_keeper, in_umbrella: true},
{:org_management, in_umbrella: true},
# Utility
{:snowflake, git: "https://github.com/valitydev/snowflake.git", branch: "master"},
# Test deps
{:finch, "~> 0.13", only: [:dev, :test]},
{:mox, "~> 1.0", only: [:dev, :test]},
{:excoveralls, "~> 0.15", only: :test},
# Dev deps
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
{:credo, "~> 1.6", only: [:dev, :test], runtime: false}
]
end
end

View File

@ -0,0 +1,14 @@
defmodule ApiKeyMgmt.Repository.Migrations.CreateApiKeys do
use Ecto.Migration
def change do
create table(:api_keys) do
add(:metadata, :map)
add(:name, :string)
add(:party_id, :string)
add(:status, :string)
timestamps()
end
end
end

View File

@ -0,0 +1,79 @@
defmodule ApiKeyMgmt.ApiKeyRepositoryTest do
@moduledoc """
Tests for ApiKeyRepository module.
"""
use ExUnit.Case, async: true
alias ApiKeyMgmt.ApiKey
alias ApiKeyMgmt.ApiKeyRepository
setup do
alias Ecto.Adapters.SQL.Sandbox
:ok = Sandbox.checkout(ApiKeyMgmt.Repository)
end
test "should fail getting by random id" do
assert {:error, :not_found} == ApiKeyRepository.get("42")
end
test "should issue and get" do
{:ok, apikey1} = issue()
{:ok, apikey2} = ApiKeyRepository.get(apikey1.id)
assert %{apikey1 | access_token: nil} == apikey2
end
test "should fail issuing with the same id" do
assert match?({:ok, _}, issue())
assert match?({:error, _}, issue())
end
test "should fail issuing with invalid access token" do
assert match?({:error, _}, issue("test_id1", "test_party1", "test_name1", ""))
end
test "should issue multiple and list" do
{:ok, apikey1} = issue("test_id1", "test_party1", "test_name1")
{:ok, apikey2} = issue("test_id2", "test_party1", "test_name2")
{:ok, apikey3} = issue("test_id3", "test_party2", "test_name3")
## Remove access tokens because list ops dont return them
apikey1 = %{apikey1 | access_token: nil}
apikey2 = %{apikey2 | access_token: nil}
apikey3 = %{apikey3 | access_token: nil}
assert [apikey1, apikey2] == ApiKeyRepository.list("test_party1")
assert [apikey3] == ApiKeyRepository.list("test_party2")
assert [] == ApiKeyRepository.list("test_party3")
end
test "should issue and revoke" do
{:ok, apikey} = issue()
{:ok, apikey} = ApiKeyRepository.revoke(apikey)
assert match?(%ApiKey{status: :revoked}, apikey)
end
test "should issue multiple, revoke and list with a filter" do
{:ok, apikey1} = issue("test_id1", "test_party1")
{:ok, apikey2} = issue("test_id2", "test_party1")
{:ok, apikey2} = ApiKeyRepository.revoke(apikey2)
## Remove access tokens because list ops dont return them
apikey1 = %{apikey1 | access_token: nil}
apikey2 = %{apikey2 | access_token: nil}
assert [apikey1] == ApiKeyRepository.list("test_party1", status_filter: :active)
assert [apikey2] == ApiKeyRepository.list("test_party1", status_filter: :revoked)
end
defp issue(
id \\ "test_id",
party_id \\ "test_party",
key_name \\ "test_name",
access_token \\ "test_token"
) do
ApiKeyRepository.issue(id, party_id, key_name, access_token)
end
end

View File

@ -0,0 +1,101 @@
defmodule ApiKeyMgmt.Auth.ContextTest do
@moduledoc """
Tests for Auth.Context module.
"""
use ExUnit.Case, async: true
alias ApiKeyMgmt.Auth.Context
test "should construct a context with a base fragment" do
origin = "http://localhost"
remote_ip = {127, 0, 0, 1}
ts_now = ~U[2022-10-26T17:02:28.339227Z]
deployment_id = "production"
assert match?(
%Context{
request_origin: ^origin,
app_fragment: %Bouncer.Context.V1.ContextFragment{
env: %Bouncer.Context.V1.Environment{
now: "2022-10-26T17:02:28.339227Z",
deployment: %Bouncer.Context.V1.Deployment{
id: ^deployment_id
}
},
requester: %Bouncer.Context.V1.Requester{
ip: "127.0.0.1"
}
}
},
Context.new(origin, remote_ip, deployment_id, ts_now)
)
end
test "should put operation context" do
context = Context.new("", {127, 0, 0, 1}, "production")
operation_id = "TestOperation"
party_id = "party_id"
api_key_id = "api_key_id"
assert match?(
%Context{
app_fragment: %Bouncer.Context.V1.ContextFragment{
apikeymgmt: %Bouncer.Context.V1.ContextApiKeyMgmt{
op: %Bouncer.Context.V1.ApiKeyMgmtOperation{
id: ^operation_id,
party: %Bouncer.Base.Entity{id: ^party_id},
api_key: %Bouncer.Base.Entity{id: ^api_key_id}
}
}
}
},
Context.put_operation(context, operation_id, party_id, api_key_id)
)
end
test "should add entites to context" do
alias TestSupport.ApiKeyManagement.Auth.TestEntity
context = Context.new("", {127, 0, 0, 1}, "production")
ent_id_1 = "ent_id_1"
ent_id_2 = "ent_id_2"
ent_1 = %TestEntity{id: ent_id_1}
ent_2 = %TestEntity{id: ent_id_2}
ent_set =
MapSet.new([
%Bouncer.Base.Entity{id: ent_id_1, type: "TestEntity"},
%Bouncer.Base.Entity{id: ent_id_2, type: "TestEntity"}
])
assert match?(
%Context{
app_fragment: %Bouncer.Context.V1.ContextFragment{
entities: ^ent_set
}
},
context
|> Context.add_operation_entity(ent_1)
|> Context.add_operation_entity(ent_2)
)
end
test "should return combined contexts with app fragment encoded" do
context = %Context{
request_origin: "",
external_fragments: %{
"token-keeper" => %Bouncer.Context.ContextFragment{type: 1},
"org-management" => %Bouncer.Context.ContextFragment{type: 2}
},
app_fragment: %Bouncer.Context.V1.ContextFragment{vsn: 1}
}
assert match?(
%{
"token-keeper" => %Bouncer.Context.ContextFragment{type: 1},
"org-management" => %Bouncer.Context.ContextFragment{type: 2},
"api-key-mgmt" => %Bouncer.Context.ContextFragment{type: 0}
},
Context.get_fragments(context)
)
end
end

View File

@ -0,0 +1,187 @@
defmodule ApiKeyMgmt.AuthTest do
@moduledoc """
Tests for Auth module.
"""
use ExUnit.Case, async: true
import Mox
alias ApiKeyMgmt.Auth
alias Bouncer.Context.V1.ContextFragment
alias OrgManagement.AuthContextProvider.UserNotFound
alias Plugger.Generated.Auth.SecurityScheme.Bearer
alias TokenKeeper.Authenticator
alias TokenKeeper.Keeper.{AuthData, AuthDataNotFound}
setup :verify_on_exit!
test "should authenticate a bearer token and gather user metadata context" do
Authenticator.MockClient
|> expect(:new, fn ctx -> ctx end)
|> expect(:authenticate, fn _client, "42", _origin ->
{:ok,
%AuthData{
context: %ContextFragment{vsn: 1},
metadata: %{
"user.id" => "my_user"
}
}}
end)
OrgManagement.MockClient
|> expect(:get_user_context, fn "my_user", _ctx ->
{:ok, %ContextFragment{vsn: 2}}
end)
auth_result =
Auth.Context.new("", {0, 0, 0, 0}, "deployment")
|> Auth.authenticate(%Bearer{token: "42"}, rpc_context: %{})
assert {:allowed, context} = auth_result
assert match?(
%{
"token-keeper" => %ContextFragment{vsn: 1},
"org-management" => %ContextFragment{vsn: 2}
},
context.external_fragments
)
end
test "should authenticate a bearer token when user data is not found" do
Authenticator.MockClient
|> expect(:new, fn ctx -> ctx end)
|> expect(:authenticate, fn _client, "42", _origin ->
{:ok,
%AuthData{
context: %ContextFragment{vsn: 1},
metadata: %{
"user.id" => "my_user"
}
}}
end)
OrgManagement.MockClient
|> expect(:get_user_context, fn "my_user", _ctx ->
{:exception, %UserNotFound{}}
end)
auth_result =
Auth.Context.new("", {0, 0, 0, 0}, "deployment")
|> Auth.authenticate(%Bearer{token: "42"}, rpc_context: %{})
assert {:allowed, context} = auth_result
assert match?(
%{
"token-keeper" => %ContextFragment{vsn: 1}
},
context.external_fragments
)
end
test "should authenticate a bearer token and gather party metadata context" do
Authenticator.MockClient
|> expect(:new, fn ctx -> ctx end)
|> expect(:authenticate, fn _client, "42", _origin ->
{:ok,
%AuthData{
context: %ContextFragment{vsn: 1},
metadata: %{
"party.id" => "my_party"
}
}}
end)
auth_result =
Auth.Context.new("", {0, 0, 0, 0}, "deployment")
|> Auth.authenticate(%Bearer{token: "42"}, rpc_context: %{})
assert match?({:allowed, _}, auth_result)
{:allowed, context} = auth_result
assert match?(
%{
"token-keeper" => %ContextFragment{vsn: 1}
},
context.external_fragments
)
end
test "should fail to authenticate a bearer token" do
Authenticator.MockClient
|> expect(:new, fn ctx -> ctx end)
|> expect(:authenticate, fn _client, "42", _origin ->
{:exception, %AuthDataNotFound{}}
end)
assert {:forbidden, {:auth_data, :not_found}} =
Auth.Context.new("", {0, 0, 0, 0}, "deployment")
|> Auth.authenticate(%Bearer{token: "42"}, rpc_context: %{})
end
test "should authorize an operation and allow it" do
Bouncer.MockClient
|> expect(:judge, fn context, _ctx ->
assert match?(
%Bouncer.Decisions.Context{
fragments: %{
"token-keeper" => _,
"api-key-mgmt" => _
}
},
context
)
{:ok,
%Bouncer.Decisions.Judgement{
resolution: %Bouncer.Decisions.Resolution{
allowed: %Bouncer.Decisions.ResolutionAllowed{}
}
}}
end)
context = %{
Auth.Context.new("", {0, 0, 0, 0}, "deployment")
| external_fragments: %{
"token-keeper" => %Bouncer.Context.ContextFragment{}
}
}
assert match?(
{:allowed, _},
context
|> Auth.Context.put_operation("TestOperation")
|> Auth.authorize(rpc_context: %{})
)
end
test "should authorize an operation and forbit it" do
Bouncer.MockClient
|> expect(:judge, fn _context, _ctx ->
{:ok,
%Bouncer.Decisions.Judgement{
resolution: %Bouncer.Decisions.Resolution{
forbidden: %Bouncer.Decisions.ResolutionForbidden{}
}
}}
end)
assert :forbidden ==
Auth.Context.new("", {0, 0, 0, 0}, "deployment")
|> Auth.authorize(rpc_context: %{})
end
test "should fail to authorize an operation" do
Bouncer.MockClient
|> expect(:judge, fn _context, _ctx ->
{:exception, %Bouncer.Decisions.InvalidRuleset{}}
end)
assert_raise CaseClauseError, fn ->
Auth.Context.new("", {0, 0, 0, 0}, "deployment")
|> Auth.authorize(rpc_context: %{})
end
end
end

View File

@ -0,0 +1,27 @@
defmodule ApiKeyMgmt.Handler.ContextTest do
@moduledoc """
Tests for service handler context.
"""
use ExUnit.Case, async: true
alias ApiKeyMgmt.Auth.Context, as: AuthContext
alias ApiKeyMgmt.Handler.Context
test "should correctly create new context from connection data" do
use Plug.Test
import Plug.Conn
origin = "http://localhost"
remote_ip = {1, 3, 3, 7}
ts_now = ~U[2022-10-26T17:02:28.339227Z]
deployment = "deployment"
conn =
conn(:get, "/")
|> put_req_header("origin", origin)
|> Map.replace!(:remote_ip, remote_ip)
target_context = AuthContext.new(origin, remote_ip, deployment, ts_now)
assert match?(%Context{auth: ^target_context}, Context.new(conn, deployment, ts_now))
end
end

View File

@ -0,0 +1,355 @@
defmodule ApiKeyMgmt.HandlerTest do
@moduledoc """
Tests for service handler.
"""
use ExUnit.Case, async: true
import Mox
alias ApiKeyMgmt.ApiKeyRepository
alias ApiKeyMgmt.Auth.BouncerEntity
alias ApiKeyMgmt.Handler
alias Plugger.Generated.Auth.SecurityScheme.Bearer
alias Plugger.Generated.Response.{
Forbidden,
GetApiKeyOk,
IssueApiKeyOk,
ListApiKeysOk,
NotFound,
RevokeApiKeyNoContent
}
alias TokenKeeper.{Authenticator, Authority}
alias TokenKeeper.Keeper.{AuthData, AuthDataNotFound}
@test_authority_id "test_authority"
setup_all do
Application.put_env(:api_key_mgmt, Handler, authority_id: @test_authority_id)
end
setup do
alias Ecto.Adapters.SQL.Sandbox
:ok = Sandbox.checkout(ApiKeyMgmt.Repository)
end
setup :verify_on_exit!
setup :make_test_handler_context
setup :authenticate_test_handler_context!
describe "__authenticate__" do
test "should return deny when auth fails", ctx do
Authenticator.MockClient
|> expect(:new, fn ctx -> ctx end)
|> expect(:authenticate, fn _client, "42", _origin ->
{:exception, AuthDataNotFound.new()}
end)
assert :deny == Handler.__authenticate__(%Bearer{token: "42"}, ctx.raw_handler_ctx)
end
end
describe "get_api_key" do
test "should return an Ok response with an ApiKey", ctx do
party_id = "test_party"
key_id = "test_id"
name = "test_name"
{:ok, apikey} = repo_issue(key_id, party_id, name)
Bouncer.MockClient
|> expect(:judge, fn context, _ctx ->
import TestSupport.Bouncer.Helper
assert_context(context, fn %{"api-key-mgmt" => context_fragment} ->
context_fragment
|> assert_apikeymgmt("GetApiKey", party_id, key_id)
|> assert_entity(BouncerEntity.to_bouncer_entity(apikey))
end)
allowed()
end)
result = Handler.get_api_key(party_id, key_id, ctx.handler_ctx)
assert match?(
%GetApiKeyOk{
content: %{
"id" => ^key_id,
"name" => ^name,
"status" => "Active"
}
},
result
)
assert ["createdAt", "id", "name", "status"] == Map.keys(result.content)
end
test "should return a Forbidden response when operation was forbidden", ctx do
{:ok, apikey} = repo_issue()
Bouncer.MockClient
|> expect(:judge, fn _context, _ctx ->
import TestSupport.Bouncer.Helper
forbidden()
end)
assert %Forbidden{} ==
Handler.get_api_key("test_party", apikey.id, ctx.handler_ctx)
end
test "should return a NotFound response when key was not found", ctx do
assert %NotFound{} == Handler.get_api_key("party_id", "test_id", ctx.handler_ctx)
end
end
describe "issue_api_key" do
test "should return an Ok response", ctx do
party_id = "party_id"
name = "My Key"
access_token = "42"
Bouncer.MockClient
|> expect(:judge, fn context, _ctx ->
import TestSupport.Bouncer.Helper
assert_context(context, fn %{"api-key-mgmt" => context_fragment} ->
assert_apikeymgmt(context_fragment, "IssueApiKey", party_id)
end)
allowed()
end)
Authority.MockClient
|> expect(:new, fn @test_authority_id, ctx -> ctx end)
|> expect(:create, fn _client, id, context_fragment, metadata ->
import TestSupport.Bouncer.Helper
context_fragment
|> assert_fragment(fn fragment ->
fragment
|> assert_auth("ApiKeyToken", nil, id, party: party_id)
end)
assert %{"party.id" => party_id} == metadata
{:ok,
%TokenKeeper.Keeper.AuthData{
id: id,
token: access_token,
context: context_fragment,
metadata: metadata
}}
end)
result = Handler.issue_api_key(party_id, %{name: name}, ctx.handler_ctx)
assert match?(
%IssueApiKeyOk{
content: %{
"name" => ^name,
"status" => "Active",
"accessToken" => ^access_token
}
},
result
)
assert ["accessToken", "createdAt", "id", "name", "status"] == Map.keys(result.content)
end
test "should return a Forbidden response when operation is forbidden", ctx do
Bouncer.MockClient
|> expect(:judge, fn _context, _ctx ->
import TestSupport.Bouncer.Helper
forbidden()
end)
assert %Forbidden{} ==
Handler.issue_api_key("party_id", %{name: "My Key"}, ctx.handler_ctx)
end
end
describe "list_api_keys" do
test "should return an Ok response", ctx do
party_id = "test_party"
{:ok, apikey1} = repo_issue("test_id1", party_id, "test_name")
{:ok, apikey2} = repo_issue("test_id2", party_id, "test_name")
{:ok, apikey2} = ApiKeyRepository.revoke(apikey2)
Bouncer.MockClient
|> expect(:judge, fn context, _ctx ->
import TestSupport.Bouncer.Helper
assert_context(context, fn %{"api-key-mgmt" => context_fragment} ->
assert_apikeymgmt(context_fragment, "ListApiKeys", party_id)
end)
allowed()
end)
assert %ListApiKeysOk{
content: %{
"results" => [
encode_api_key(%{apikey1 | access_token: nil}),
encode_api_key(%{apikey2 | access_token: nil})
]
}
} ==
Handler.list_api_keys(party_id, [], ctx.handler_ctx)
end
test "should return an Ok response and a filtered list of api keys", ctx do
party_id = "test_party"
{:ok, _apikey1} = repo_issue("test_id1", party_id, "test_name")
{:ok, apikey2} = repo_issue("test_id2", party_id, "test_name")
{:ok, apikey2} = ApiKeyRepository.revoke(apikey2)
Bouncer.MockClient
|> expect(:judge, fn context, _ctx ->
import TestSupport.Bouncer.Helper
assert_context(context, fn %{"api-key-mgmt" => context_fragment} ->
assert_apikeymgmt(context_fragment, "ListApiKeys", party_id)
end)
allowed()
end)
assert %ListApiKeysOk{
content: %{
"results" => [
encode_api_key(%{apikey2 | access_token: nil})
]
}
} ==
Handler.list_api_keys(party_id, [status: :revoked], ctx.handler_ctx)
end
test "should return a Forbidden response when operation was forbiden", ctx do
party_id = "test_party"
{:ok, _apikey} = repo_issue("test_id", party_id, "test_name")
Bouncer.MockClient
|> expect(:judge, fn _context, _ctx ->
import TestSupport.Bouncer.Helper
forbidden()
end)
assert %Forbidden{} == Handler.list_api_keys(party_id, [], ctx.handler_ctx)
end
test "should return an empty list of results when no keys are found", ctx do
party_id = "test_party"
Bouncer.MockClient
|> expect(:judge, fn context, _ctx ->
import TestSupport.Bouncer.Helper
assert_context(context, fn %{"api-key-mgmt" => context_fragment} ->
assert_apikeymgmt(context_fragment, "ListApiKeys", party_id)
end)
allowed()
end)
assert %ListApiKeysOk{
content: %{
"results" => []
}
} ==
Handler.list_api_keys(party_id, [], ctx.handler_ctx)
end
end
describe "revoke_api_key" do
test "should return a NoContent response", ctx do
party_id = "test_party"
key_id = "test_id"
{:ok, apikey} = repo_issue(key_id, party_id, "test_name")
Bouncer.MockClient
|> expect(:judge, fn context, _ctx ->
import TestSupport.Bouncer.Helper
assert_context(context, fn %{"api-key-mgmt" => context_fragment} ->
context_fragment
|> assert_apikeymgmt("RevokeApiKey", party_id, key_id)
|> assert_entity(BouncerEntity.to_bouncer_entity(apikey))
end)
allowed()
end)
Authority.MockClient
|> expect(:new, fn @test_authority_id, ctx -> ctx end)
|> expect(:revoke, fn _client, ^key_id ->
{:ok, nil}
end)
assert %RevokeApiKeyNoContent{} ==
Handler.revoke_api_key(party_id, key_id, "Revoked", ctx.handler_ctx)
end
test "should return a Forbidden response when operation was forbidden", ctx do
party_id = "test_party"
key_id = "test_id"
{:ok, _apikey} = repo_issue(key_id, party_id, "test_name")
Bouncer.MockClient
|> expect(:judge, fn _context, _ctx ->
import TestSupport.Bouncer.Helper
forbidden()
end)
assert %Forbidden{} ==
Handler.revoke_api_key(party_id, key_id, "Revoked", ctx.handler_ctx)
end
test "should return a NotFound response when api key is not found", ctx do
assert %NotFound{} ==
Handler.revoke_api_key("party_id", "api_key_id", "Revoked", ctx.handler_ctx)
end
end
##
defp make_test_handler_context(_testctx) do
use Plug.Test
conn = conn(:get, "/")
ctx = Handler.__init__(conn)
%{raw_handler_ctx: ctx}
end
defp authenticate_test_handler_context!(%{raw_handler_ctx: ctx}) do
Authenticator.MockClient
|> expect(:new, fn ctx -> ctx end)
|> expect(:authenticate, fn _client, "42", _origin ->
import Bouncer.ContextFragmentBuilder
{:ok, %AuthData{context: build() |> bake()}}
end)
{:allow, ctx} = Handler.__authenticate__(%Bearer{token: "42"}, ctx)
%{handler_ctx: ctx}
end
defp repo_issue(
id \\ "test_id",
party_id \\ "test_party",
key_name \\ "test_name",
access_token \\ "test_token"
) do
ApiKeyRepository.issue(id, party_id, key_name, access_token)
end
defp encode_api_key(api_key) do
alias ApiKeyMgmt.ApiKey
ApiKey.encode(api_key)
end
end

View File

@ -0,0 +1,307 @@
defmodule ApiKeyMgmtTest do
@moduledoc """
External app tests
TODO: I never settled on a concrete scope for this case,
so currently it acts like a combined integration,
response validation and header handling test case. I probably can
be split up and/or improved considerably.
"""
use ExUnit.Case, async: true
use Plug.Test
import Mox
alias ApiKeyMgmt.Router
setup do
alias Ecto.Adapters.SQL.Sandbox
:ok = Sandbox.checkout(ApiKeyMgmt.Repository)
:ok
end
describe "handler response encoding" do
setup :verify_on_exit!
test "should follow schema when successfull" do
TokenKeeper.Authenticator.MockClient
|> stub(:new, fn ctx -> ctx end)
|> expect(:authenticate, 4, fn _client, _token, _origin ->
import TestSupport.TokenKeeper.Helper
{:ok, make_authdata("42", %{"user.id" => "42"})}
end)
TokenKeeper.Authority.MockClient
|> stub(:new, fn _authority, ctx -> ctx end)
|> expect(:create, 1, fn _client, id, context_fragment, metadata ->
import TestSupport.TokenKeeper.Helper
authdata = make_authdata(id, :active, context_fragment, metadata)
{:ok, %{authdata | token: "42"}}
end)
|> expect(:revoke, 1, fn _client, _id ->
{:ok, nil}
end)
Bouncer.MockClient
|> expect(:judge, 4, fn _context, _ctx ->
import TestSupport.Bouncer.Helper
allowed()
end)
OrgManagement.MockClient
|> expect(:get_user_context, 4, fn _user_id, _ctx ->
import Bouncer.ContextFragmentBuilder
{:ok, build() |> bake()}
end)
issue_body = %{
"name" => "my_cool_api_key"
}
assert {200, issue_api_key_response} =
test_call(
:post,
"http://doesnotresolve:8080/parties/mypartyid/api-keys",
issue_body |> Jason.encode!()
)
assert {200, get_api_key_response} =
test_call(
:get,
"http://doesnotresolve:8080/parties/mypartyid/api-keys/#{issue_api_key_response.id}"
)
assert {200, list_api_keys_response} =
test_call(:get, "http://doesnotresolve:8080/parties/mypartyid/api-keys")
assert {204, nil} =
test_call(
:put,
"http://doesnotresolve:8080/parties/mypartyid/api-keys/#{issue_api_key_response.id}/status",
"Revoked" |> Jason.encode!()
)
assert :ok == cast_response(200, :issue_api_key, issue_api_key_response)
assert :ok == cast_response(200, :get_api_key, get_api_key_response)
assert :ok == cast_response(200, :list_api_keys, list_api_keys_response)
end
test "should follow schema on business errors" do
TokenKeeper.Authenticator.MockClient
|> stub(:new, fn ctx -> ctx end)
|> expect(:authenticate, 1, fn _client, _token, _origin ->
import TestSupport.TokenKeeper.Helper
{:ok, make_authdata("42", %{"user.id" => "42"})}
end)
OrgManagement.MockClient
|> expect(:get_user_context, 1, fn _user_id, _ctx ->
import Bouncer.ContextFragmentBuilder
{:ok, build() |> bake()}
end)
assert {404, nil} =
test_call(
:put,
"http://doesnotresolve:8080/parties/mypartyid/api-keys/blah/status",
"Revoked" |> Jason.encode!()
)
end
test "should follow schema when validation fails" do
api_key_id = "test_key_that_is_way_longer_than_maximim_allowed"
issue_body = %{}
assert {400, issue_api_key_response} =
test_call(
:post,
"http://doesnotresolve:8080/parties/mypartyid/api-keys",
issue_body |> Jason.encode!()
)
assert {400, get_api_key_response} =
test_call(
:get,
"http://doesnotresolve:8080/parties/mypartyid/api-keys/#{api_key_id}"
)
assert {400, list_api_keys_response} =
test_call(
:get,
"http://doesnotresolve:8080/parties/mypartyid/api-keys?status=dontcare"
)
assert {400, revoke_api_key_response} =
test_call(
:put,
"http://doesnotresolve:8080/parties/mypartyid/api-keys/blah/status",
"Stuff" |> Jason.encode!()
)
assert :ok == cast_response(400, :issue_api_key, issue_api_key_response)
assert :ok == cast_response(400, :get_api_key, get_api_key_response)
assert :ok == cast_response(400, :list_api_keys, list_api_keys_response)
assert :ok == cast_response(400, :revoke_api_key, revoke_api_key_response)
end
test "should follow schema when authorization fails" do
TokenKeeper.Authenticator.MockClient
|> stub(:new, fn ctx -> ctx end)
|> expect(:authenticate, 4, fn _client, _token, _origin ->
import TestSupport.TokenKeeper.Helper
{:ok, make_authdata("42", %{"user.id" => "42"})}
end)
OrgManagement.MockClient
|> expect(:get_user_context, 4, fn _user_id, _ctx ->
import Bouncer.ContextFragmentBuilder
{:ok, build() |> bake()}
end)
Bouncer.MockClient
|> expect(:judge, 4, fn _context, _ctx ->
import TestSupport.Bouncer.Helper
forbidden()
end)
issue_body = %{
"name" => "my_cool_api_key"
}
{:ok, _} = ApiKeyMgmt.ApiKeyRepository.issue("blah", "notmypartyid", "tokenname", "token")
assert {403, nil} =
test_call(
:post,
"http://doesnotresolve:8080/parties/mypartyid/api-keys",
issue_body |> Jason.encode!()
)
assert {403, nil} =
test_call(
:get,
"http://doesnotresolve:8080/parties/mypartyid/api-keys/blah"
)
assert {403, nil} = test_call(:get, "http://doesnotresolve:8080/parties/mypartyid/api-keys")
assert {403, nil} =
test_call(
:put,
"http://doesnotresolve:8080/parties/mypartyid/api-keys/blah/status",
"Revoked" |> Jason.encode!()
)
end
end
describe "requests with invalid headers" do
test "should fail when auth headers missing" do
issue_body = %{
"name" => "my_cool_api_key"
}
assert {403, _} =
test_call(
:post,
"http://doesnotresolve:8080/parties/mypartyid/api-keys",
issue_body |> Jason.encode!(),
[{"content-type", "application/json"}]
)
assert {403, _} =
test_call(
:get,
"http://doesnotresolve:8080/parties/mypartyid/api-keys/1",
nil,
[]
)
assert {403, _} =
test_call(:get, "http://doesnotresolve:8080/parties/mypartyid/api-keys", nil, [])
assert {403, _} =
test_call(
:put,
"http://doesnotresolve:8080//parties/mypartyid/api-keys/mykeyid/status",
"\"Revoked\"",
[{"content-type", "application/json"}]
)
end
test "should fail when content-type header missing" do
issue_body = %{
"name" => "my_cool_api_key"
}
assert {415, _} =
test_call(
:post,
"http://doesnotresolve:8080/parties/mypartyid/api-keys",
issue_body |> Jason.encode!(),
[]
)
end
end
###
defp test_call(method, path, params_or_body \\ nil, headers \\ default_headers()) do
conn = conn(method, path, params_or_body)
conn =
Enum.reduce(headers, conn, fn {k, v}, conn ->
put_req_header(conn, k, v)
end)
conn = router_call(conn)
resp_body =
case conn.resp_body do
"" -> nil
body -> body |> Jason.decode!(keys: :atoms!)
end
{conn.status, resp_body}
end
defp router_call(conn) do
Router.call(conn, Router.init([]))
end
defp default_headers do
[
{"content-type", "application/json"},
{"authorization", "Bearer 42"}
]
end
defp cast_response(http_code, operation_id, value) do
alias Plugger.Generated.Spec
spec = Spec.get()
response_spec = get_response_spec(spec, http_code, operation_id)
case OpenApiSpex.cast_value(value, response_spec, spec) do
{:ok, _castvalue} -> :ok
err -> err
end
end
defp get_response_spec(spec, 200, :issue_api_key),
do:
spec.paths["/parties/{partyId}/api-keys"].post.responses["200"].content["application/json"].schema
defp get_response_spec(spec, 200, :list_api_keys),
do:
spec.paths["/parties/{partyId}/api-keys"].get.responses["200"].content["application/json"].schema
defp get_response_spec(spec, 200, :get_api_key),
do:
spec.paths["/parties/{partyId}/api-keys/{apiKeyId}"].get.responses["200"].content[
"application/json"
].schema
defp get_response_spec(spec, 400, _anyop),
do: spec.components.responses["BadRequest"].content["application/json"].schema
end

View File

@ -0,0 +1,311 @@
defmodule Plugger.RouterTest do
@moduledoc """
Tests for Plugger.Generated.Router
"""
use ExUnit.Case, async: true
use Plug.Test
import Mox
alias Plugger.Generated.Auth.SecurityScheme.Bearer
alias Plugger.Generated.MockHandler
alias Plugger.Generated.Response.{
GetApiKeyOk,
IssueApiKeyOk,
ListApiKeysOk,
RevokeApiKeyNoContent
}
alias Plugger.Generated.Router
setup do
MockHandler
|> stub(:__init__, fn _conn ->
%{}
end)
|> stub(:__authenticate__, fn
%Bearer{token: "42"}, ctx ->
{:allow, ctx}
end)
:ok
end
test "sould get a 404" do
assert {404, nil} = test_call(:get, "/404")
end
describe "request with authorization header" do
test "should fail without it being defined" do
conn =
conn(:get, "/parties/1/api-keys")
|> assign(:handler, MockHandler)
|> router_call()
assert 403 == conn.status
end
end
describe "request with content-type header" do
test "should fail when its not defined" do
conn =
conn(:post, "/parties/1/api-keys")
|> put_req_header("authorization", "Bearer 42")
|> assign(:handler, MockHandler)
|> router_call()
assert 415 == conn.status
end
test "should fail with invalid header provided when body is expected" do
conn =
conn(:post, "/parties/1/api-keys")
|> put_req_header("content-type", "text/html")
|> put_req_header("authorization", "Bearer 42")
|> assign(:handler, MockHandler)
|> router_call()
assert 415 == conn.status
end
test "shoud be ok when no body is expected" do
MockHandler
|> expect(:list_api_keys, fn _party_id, [status: :active], _ctx ->
%ListApiKeysOk{content: []}
end)
conn =
conn(:get, "/parties/1/api-keys?status=Active")
|> put_req_header("content-type", "text/html")
|> put_req_header("authorization", "Bearer 42")
|> assign(:handler, MockHandler)
|> router_call()
assert 200 == conn.status
end
end
describe "getApiKey operation" do
test "should return 200 with correct input" do
MockHandler
|> expect(:get_api_key, fn _party_id, api_key_id, _ctx ->
%GetApiKeyOk{
content: %{
createdAt: "2022-10-18T14:21:42+00:00",
id: api_key_id,
name: "test_key",
status: "Active"
}
}
end)
api_key_id = "test_key"
assert {200,
%{
createdAt: "2022-10-18T14:21:42+00:00",
id: ^api_key_id,
name: "test_key",
status: "Active"
}} = test_call(:get, "/parties/1/api-keys/#{api_key_id}")
end
test "should return 400 with incorrect input" do
api_key_id = "test_key_that_is_way_longer_than_maximim_allowed"
assert {400,
%{
code: "invalidRequest",
message:
"Request validation failed. Reason: #/apiKeyId: String length is larger than maxLength: 40"
}} = test_call(:get, "/parties/1/api-keys/#{api_key_id}")
end
test "should return 403 when authentication fails" do
MockHandler
|> stub(:__authenticate__, fn _securityscheme, _ctx ->
:deny
end)
assert {403, nil} == test_call(:get, "/parties/1/api-keys/1")
end
end
describe "issueApiKey operation" do
test "should return 200 with correct input" do
key_name = "Test Key"
key = %{
name: key_name
}
MockHandler
|> expect(:issue_api_key, fn _party_id, api_key, _ctx ->
%IssueApiKeyOk{
content: %{
createdAt: "2022-10-18T14:21:42+00:00",
id: "42",
accessToken: "42",
name: api_key.name,
status: "Active"
}
}
end)
assert {200,
%{
createdAt: "2022-10-18T14:21:42+00:00",
id: "42",
accessToken: "42",
name: key_name,
status: "Active"
}} ==
test_call(:post, "/parties/1/api-keys", key |> Jason.encode!())
end
test "should return 400 with incorrect input" do
key = %{}
assert {400,
%{
code: "invalidRequest",
message: "Request validation failed. Reason: #/name: Missing field: name"
}} ==
test_call(:post, "/parties/1/api-keys", key |> Jason.encode!())
key = %{
name: "test",
metadata: "test"
}
assert {400,
%{
code: "invalidRequest",
message:
"Request validation failed. Reason: #/metadata: Invalid object. Got: string"
}} ==
test_call(:post, "/parties/1/api-keys", key |> Jason.encode!())
end
test "should return 403 when authentication fails" do
key = %{
name: "Test Key"
}
MockHandler
|> stub(:__authenticate__, fn _securityscheme, _ctx ->
:deny
end)
assert {403, nil} ==
test_call(:post, "/parties/1/api-keys", key |> Jason.encode!())
end
end
describe "listApiKeys operation" do
test "should return 200 with correct input" do
test_results = %{
results: [
%{
createdAt: "2022-10-18T14:21:42+00:00",
id: "42",
name: "42",
status: "Active"
}
]
}
MockHandler
|> expect(:list_api_keys, fn _party_id, [status: :active], _ctx ->
%ListApiKeysOk{content: test_results}
end)
assert {200, test_results} == test_call(:get, "/parties/1/api-keys?status=Active")
end
test "should return 400 with incorrect input" do
assert {400,
%{
code: "invalidRequest",
message: "Request validation failed. Reason: #/status: Invalid value for enum"
}} == test_call(:get, "/parties/1/api-keys?status=Dontcare")
end
test "should return 403 when authentication fails" do
MockHandler
|> stub(:__authenticate__, fn _securityscheme, _ctx ->
:deny
end)
assert {403, nil} == test_call(:get, "/parties/1/api-keys?status=Active")
end
end
describe "revokeApiKey operation" do
test "should return 204 with correct input" do
party_id = "party_id"
api_key_id = "api_key_id"
MockHandler
|> expect(:revoke_api_key, fn ^party_id, ^api_key_id, "Revoked", _ctx ->
%RevokeApiKeyNoContent{}
end)
assert {204, nil} =
test_call(
:put,
"/parties/#{party_id}/api-keys/#{api_key_id}/status",
"\"Revoked\""
)
end
test "should return 400 with incorrect input" do
assert {400,
%{
code: "invalidRequest",
message: "Request validation failed. Reason: #: Invalid value for enum"
}} ==
test_call(
:put,
"/parties/1/api-keys/1/status",
"\"Blah\""
)
end
test "should return 403 when authentication fails" do
MockHandler
|> stub(:__authenticate__, fn _securityscheme, _ctx ->
:deny
end)
assert {403, nil} =
test_call(
:put,
"/parties/1/api-keys/1/status",
"\"Revoked\""
)
end
end
defp test_call(method, path, params_or_body \\ nil) do
conn =
conn(method, path, params_or_body)
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", "Bearer 42")
|> assign(:handler, MockHandler)
|> router_call()
resp_body =
case conn.resp_body do
"" -> nil
body -> body |> Jason.decode!(keys: :atoms!)
end
{conn.status, resp_body}
end
defp router_call(conn) do
Router.call(conn, Router.init([]))
end
end

View File

@ -0,0 +1,18 @@
defmodule TestSupport.ApiKeyManagement.Auth.TestEntity do
@moduledoc false
defstruct id: nil
@type t() :: %__MODULE__{id: String.t()}
end
defimpl ApiKeyMgmt.Auth.BouncerEntity, for: TestSupport.ApiKeyManagement.Auth.TestEntity do
alias TestSupport.ApiKeyManagement.Auth.TestEntity
alias Bouncer.Base.Entity
@spec to_bouncer_entity(TestEntity.t()) :: Entity.t()
def to_bouncer_entity(entity) do
%Entity{
id: entity.id,
type: "TestEntity"
}
end
end

View File

@ -0,0 +1,15 @@
Mox.defmock(Plugger.Generated.MockHandler, for: Plugger.Generated.Handler)
Mox.defmock(Bouncer.MockClient, for: Bouncer.Client)
Mox.defmock(OrgManagement.MockClient, for: OrgManagement.Client)
Mox.defmock(TokenKeeper.Authenticator.MockClient, for: TokenKeeper.Authenticator.Client)
Mox.defmock(TokenKeeper.Authority.MockClient, for: TokenKeeper.Authority.Client)
Application.put_env(:bouncer, :client_impl, Bouncer.MockClient)
Application.put_env(:org_management, :client_impl, OrgManagement.MockClient)
Application.put_env(:token_keeper, :authenticator_impl, TokenKeeper.Authenticator.MockClient)
Application.put_env(:token_keeper, :authority_impl, TokenKeeper.Authority.MockClient)
Ecto.Adapters.SQL.Sandbox.mode(ApiKeyMgmt.Repository, :manual)
ExUnit.start()

View File

@ -0,0 +1,5 @@
{
"coverage_options": {
"minimum_coverage": 90
}
}

View File

@ -0,0 +1,46 @@
defmodule Bouncer do
@moduledoc """
Bouncer service client.
"""
alias Bouncer.Client
alias Bouncer.Context.ContextFragment, as: EncodedContextFragment
alias Bouncer.Decisions.{
Context,
InvalidContext,
InvalidRuleset,
Resolution,
ResolutionAllowed,
ResolutionForbidden,
RulesetNotFound
}
@type fragment() :: EncodedContextFragment.t()
@type fragments() :: %{fragment_id() => fragment()}
@type ctx() :: any()
@type resolution() :: :allowed | :forbidden
@type error() :: :ruleset_not_found | :invalid_ruleset | :invalid_context
@typep fragment_id() :: String.t()
@spec judge(fragments(), ctx()) :: {:ok, resolution()} | {:error, error()}
def judge(fragments, ctx) do
case Client.judge(fragments_to_context(fragments), ctx) do
{:ok, judgement} -> {:ok, decode_resolution(judgement.resolution)}
{:exception, %RulesetNotFound{}} -> {:error, :ruleset_not_found}
{:exception, %InvalidRuleset{}} -> {:error, :invalid_ruleset}
{:exception, %InvalidContext{}} -> {:error, :invalid_context}
end
end
defp fragments_to_context(fragments) do
fragments
|> wrap_fragments()
end
defp wrap_fragments(fragments), do: %Context{fragments: fragments}
defp decode_resolution(%Resolution{allowed: %ResolutionAllowed{}}), do: :allowed
defp decode_resolution(%Resolution{forbidden: %ResolutionForbidden{}}), do: :forbidden
end

View File

@ -0,0 +1,25 @@
defmodule Bouncer.Client do
@moduledoc """
A client behaviour for the Bouncer service client.
"""
alias Bouncer.Client.Woody
alias Bouncer.Decisions.{Context, InvalidContext, InvalidRuleset, Judgement, RulesetNotFound}
@callback judge(Context.t(), rpc_context :: any()) ::
{:ok, Judgement.t()}
| {:exception, RulesetNotFound.t() | InvalidRuleset.t() | InvalidContext.t()}
@spec judge(Context.t(), rpc_context :: any()) ::
{:ok, Judgement.t()}
| {:exception, RulesetNotFound.t() | InvalidRuleset.t() | InvalidContext.t()}
def judge(bouncer_context, ctx) do
impl().judge(bouncer_context, ctx)
end
if Mix.env() == :test do
defp impl, do: Application.get_env(:bouncer, :client_impl, Woody)
else
@client_mod Application.compile_env(:bouncer, :client_impl, Woody)
defp impl, do: @client_mod
end
end

View File

@ -0,0 +1,21 @@
defmodule Bouncer.Client.Woody do
@moduledoc """
Woody implementation of Bouncer.Client
"""
@behaviour Bouncer.Client
alias Woody.Generated.Bouncer.Decisions.Arbiter.Client
alias Bouncer.Decisions.{Context, InvalidContext, InvalidRuleset, Judgement, RulesetNotFound}
alias Woody.Context, as: WoodyContext
@spec judge(Context.t(), WoodyContext.t()) ::
{:ok, Judgement.t()}
| {:exception, RulesetNotFound.t() | InvalidRuleset.t() | InvalidContext.t()}
def judge(context, woody_ctx) do
config = Application.fetch_env!(:bouncer, __MODULE__)
woody_ctx
|> Client.new(config[:url], config[:opts] || [])
|> Client.judge(config[:ruleset_id], context)
end
end

View File

@ -0,0 +1,225 @@
defmodule Bouncer.ContextFragmentBuilder do
@moduledoc """
A ContextFragment builder. Feel free to import where needed.
"""
alias Bouncer.Base.Entity
alias Bouncer.Context.ContextFragment, as: BakedContextFragment
alias Bouncer.Context.ContextFragmentType
alias Bouncer.Context.V1.ContextFragment
alias Bouncer.ContextFragmentBuilder.Helper
@doc ~S"""
Starts building a new context fragment. Utilize the ther functions in this module to shape it to your liking.
## Examples
iex> build()
%ContextFragment{}
"""
@spec build :: ContextFragment.t()
def build do
ContextFragment.new()
end
@doc ~S"""
Sets environment data of a context. If `iso8601_datetime` is missing it gets automatically populated with current time.
## Examples
iex> build() |> environment(~U[2022-10-26T17:02:28.339227Z])
%ContextFragment{
env: %Environment{
now: "2022-10-26T17:02:28.339227Z",
deployment: nil
}
}
iex> build() |> environment(~U[2022-10-26T17:02:28.339227Z], "my_deployment")
%ContextFragment{
env: %Environment{
now: "2022-10-26T17:02:28.339227Z",
deployment: %Deployment{
id: "my_deployment"
}
}
}
"""
@spec environment(
ContextFragment.t(),
datetime_now :: DateTime.t() | nil,
deployment_id :: String.t() | nil
) :: ContextFragment.t()
def environment(context_fragment, datetime_now \\ nil, deployment_id \\ nil) do
%{context_fragment | env: Helper.environment(datetime_now, deployment_id)}
end
@doc ~S"""
Sets auth data of a context.
## Examples
iex> build() |> auth("ApiKeyToken", "mytokenid")
%ContextFragment{
auth: %Auth{
method: "ApiKeyToken",
expiration: nil,
scope: MapSet.new(),
token: %Token{id: "mytokenid"}
}
}
iex> build() |> auth("ApiKeyToken", "2022-10-26T17:02:28.339227Z", "mytokenid")
%ContextFragment{
auth: %Auth{
method: "ApiKeyToken",
expiration: "2022-10-26T17:02:28.339227Z",
scope: MapSet.new(),
token: %Token{id: "mytokenid"}
}
}
iex> build() |> auth("ApiKeyToken", "2022-10-26T17:02:28.339227Z", "mytokenid", party: "mypartyid")
%ContextFragment{
auth: %Auth{
method: "ApiKeyToken",
expiration: "2022-10-26T17:02:28.339227Z",
scope: MapSet.new([
%AuthScope{
party: %Entity{id: "mypartyid"}
}
]),
token: %Token{id: "mytokenid"}
}
}
"""
@spec auth(
ContextFragment.t(),
method :: String.t(),
expiration :: String.t() | nil,
token_id :: String.t(),
scopes :: Keyword.t()
) :: ContextFragment.t()
def auth(context_fragment, method, expiration \\ nil, token_id, scopes \\ []) do
%{context_fragment | auth: Helper.auth(method, expiration, token_id, scopes)}
end
@doc ~S"""
Sets requester data of a context.
## Examples
iex> build() |> requester("localhost")
%ContextFragment{
requester: %Requester{
ip: "localhost"
}
}
"""
@spec requester(
ContextFragment.t(),
ip_address :: String.t()
) :: ContextFragment.t()
def requester(context_fragment, ip_address) do
%{context_fragment | requester: Helper.requester(ip_address)}
end
@doc ~S"""
Sets apikeymgmt operation data of a context.
## Examples
iex> build() |> apikeymgmt("MyOperation", %Entity{id: "42"}, %Entity{id: "24"})
%ContextFragment{
apikeymgmt: %ContextApiKeyMgmt{
op: %ApiKeyMgmtOperation{
id: "MyOperation",
party: %Entity{id: "42"},
api_key: %Entity{id: "24"}
}
}
}
iex> build() |> apikeymgmt("MyOperation", %Entity{id: "42"})
%ContextFragment{
apikeymgmt: %ContextApiKeyMgmt{
op: %ApiKeyMgmtOperation{
id: "MyOperation",
party: %Entity{id: "42"},
api_key: nil
}
}
}
iex> build() |> apikeymgmt("MyOperation")
%ContextFragment{
apikeymgmt: %ContextApiKeyMgmt{
op: %ApiKeyMgmtOperation{
id: "MyOperation",
party: nil,
api_key: nil
}
}
}
"""
@spec apikeymgmt(
ContextFragment.t(),
operation_id :: String.t(),
party :: Entity.t() | nil,
api_key :: Entity.t() | nil
) :: ContextFragment.t()
def apikeymgmt(context_fragment, operation_id, party \\ nil, api_key \\ nil) do
%{
context_fragment
| apikeymgmt: Helper.apikeymgmt(operation_id, party, api_key)
}
end
@doc ~S"""
Adds an entity data to a context.
## Examples
iex> build() |> add_entity(%Entity{id: "42"})
%ContextFragment{
entities: MapSet.new([%Entity{id: "42"}])
}
iex> build() |> add_entity(%Entity{id: "42"}) |> add_entity(%Entity{id: "42"})
%ContextFragment{
entities: MapSet.new([%Entity{id: "42"}])
}
iex> build() |> add_entity(%Entity{id: "42"}) |> add_entity(%Entity{id: "24"})
%ContextFragment{
entities: MapSet.new([%Entity{id: "42"}, %Entity{id: "24"}])
}
"""
@spec add_entity(
ContextFragment.t(),
Entity.t()
) :: ContextFragment.t()
def add_entity(context_fragment, entity) do
entities = context_fragment.entities || MapSet.new()
%{context_fragment | entities: entities |> MapSet.put(entity)}
end
@doc ~S"""
Finalizes a context fragment by serializing it to a binary format.
## Examples
iex> build() |> bake()
%BakedContextFragment{
type: ContextFragmentType.v1_thrift_binary(),
content: :erlang.iolist_to_binary(ContextFragment.serialize(build()))
}
"""
@spec bake(ContextFragment.t()) :: BakedContextFragment.t()
def bake(context_fragment) do
require ContextFragmentType
%BakedContextFragment{
type: ContextFragmentType.v1_thrift_binary(),
content: context_fragment |> ContextFragment.serialize() |> :erlang.iolist_to_binary()
}
end
end

View File

@ -0,0 +1,78 @@
defmodule Bouncer.ContextFragmentBuilder.Helper do
@moduledoc """
Helper functions to construct parts of a Bouncer context
"""
alias Bouncer.Base.Entity
alias Bouncer.Context.V1.{
ApiKeyMgmtOperation,
Auth,
AuthScope,
ContextApiKeyMgmt,
Deployment,
Environment,
Requester,
Token
}
@spec environment(datetime_now :: DateTime.t() | nil, deployment_id :: String.t() | nil) ::
Environment.t()
def environment(datetime_now, deployment_id) do
%Environment{
now: DateTime.to_iso8601(datetime_now || now()),
deployment: if(deployment_id, do: %Deployment{id: deployment_id})
}
end
@spec requester(ip_address :: String.t()) :: Requester.t()
def requester(ip_address) do
%Requester{
ip: ip_address
}
end
@spec apikeymgmt(
operation_id :: String.t(),
party :: Entity.t() | nil,
api_key :: Entity.t() | nil
) ::
ContextApiKeyMgmt.t()
def apikeymgmt(operation_id, party, api_key) do
%ContextApiKeyMgmt{
op: %ApiKeyMgmtOperation{
id: operation_id,
party: party,
api_key: api_key
}
}
end
@spec auth(
method :: String.t(),
expiration :: String.t() | nil,
token_id :: String.t(),
scopes :: Keyword.t()
) ::
Auth.t()
def auth(method, expiration, token_id, scopes) do
scopes = Enum.into(scopes, MapSet.new(), &auth_scope_from_keyword/1)
%Auth{
method: method,
expiration: expiration,
scope: scopes,
token: %Token{id: token_id}
}
end
defp now do
{:ok, now} = DateTime.now("Etc/UTC")
now
end
defp auth_scope_from_keyword({:party, party_id}) do
%AuthScope{
party: %Entity{id: party_id}
}
end
end

65
apps/bouncer/mix.exs Normal file
View File

@ -0,0 +1,65 @@
defmodule Bouncer.MixProject do
use Mix.Project
def project do
[
app: :bouncer,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps(),
elixirc_paths: elixirc_paths(Mix.env()),
dialyzer: dialyzer(),
preferred_cli_env: preferred_cli_env(),
test_coverage: [tool: ExCoveralls]
] |> umbrella()
end
defp umbrella(project) do
project ++ [
build_path: "../../_build",
config_path: "../../config/config.exs",
deps_path: "../../deps",
lockfile: "../../mix.lock",
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
defp dialyzer do
[
plt_add_apps: [:ex_unit]
]
end
defp preferred_cli_env do
[
dialyzer: :test,
coveralls: :test,
"coveralls.github": :test,
"coveralls.html": :test
]
end
def application do
[
extra_applications: [:logger]
]
end
defp deps do
[
# RPC
{:woody_ex, git: "https://github.com/valitydev/woody_ex.git", branch: "master"},
# Protocols
{:bouncer_proto, git: "https://github.com/valitydev/bouncer-proto.git", branch: "master"},
# Test deps
{:mox, "~> 1.0", only: [:dev, :test]},
{:excoveralls, "~> 0.15", only: :test},
# Dev deps
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
{:credo, "~> 1.6", only: [:dev, :test], runtime: false}
]
end
end

View File

@ -0,0 +1,82 @@
defmodule Bouncer.Client.WoodyTest do
@moduledoc """
Tests for Woody implementation of Bouncer.Client behaviour.
"""
# Can't run async mode when relying on app env
use ExUnit.Case, async: false
alias Bouncer.Client.Woody, as: Client
alias Woody.Generated.Bouncer.Decisions.Arbiter, as: Service
alias Woody.Server.Http, as: Server
defmodule MockHandler do
@moduledoc false
@behaviour Service.Handler
@spec new(http_path :: String.t(), fun(), options :: Keyword.t()) :: Service.Handler.t()
def new(http_path, fun, options \\ []) do
Service.Handler.new({__MODULE__, handler_fun: fun}, http_path, options)
end
@spec judge(
ruleset :: String.t(),
ctx :: Bouncer.Decisions.Context.t(),
ctx :: Woody.Context.t(),
hdlopts :: Handler.hdlopts()
) ::
{:ok, Bouncer.Decisions.Judgement.t()}
| {:error, Bouncer.Decisions.RulesetNotFound.t()}
| {:error, Bouncer.Decisions.InvalidRuleset.t()}
| {:error, Bouncer.Decisions.InvalidContext.t()}
@impl Service.Handler
def judge(ruleset, context, _ctx, hdlopts) do
hdlopts[:handler_fun].(ruleset, context)
end
end
test "should reply ok" do
alias TestSupport.Bouncer.Helper
mock_woody(fn %Bouncer.Decisions.Context{fragments: %{}} ->
Helper.allowed()
end)
assert Helper.allowed() ==
Client.judge(%Bouncer.Decisions.Context{fragments: %{}}, Woody.Context.new())
end
test "should reply with an exception" do
mock_woody(fn %Bouncer.Decisions.Context{fragments: %{}} ->
{:error, %Bouncer.Decisions.RulesetNotFound{}}
end)
assert {:exception, %Bouncer.Decisions.RulesetNotFound{}} ==
Client.judge(%Bouncer.Decisions.Context{fragments: %{}}, Woody.Context.new())
end
defp mock_woody(handler_fn) do
ruleset_id = "test_ruleset"
handler_fn = fn ^ruleset_id, context ->
handler_fn.(context)
end
start_supervised!(
Server.child_spec(
__MODULE__,
Server.Endpoint.loopback(),
MockHandler.new("/arbiter", handler_fn, event_handler: Woody.EventHandler.Default)
)
)
endpoint = Server.endpoint(__MODULE__)
Application.put_env(:bouncer, Bouncer.Client.Woody,
url: "http://#{endpoint}/arbiter",
ruleset_id: ruleset_id
)
:ok
end
end

View File

@ -0,0 +1,35 @@
defmodule Bouncer.ContextFragmentBuilderTest do
@moduledoc """
Tests for Bouncer.ContextFragmentBuilder helper module.
Most tests are defined as doctests here.
"""
use ExUnit.Case, async: true
import Bouncer.ContextFragmentBuilder
alias Bouncer.Context.V1.{
ApiKeyMgmtOperation,
Auth,
AuthScope,
ContextApiKeyMgmt,
ContextFragment,
Deployment,
Environment,
Requester,
Token
}
alias Bouncer.Base.Entity
alias Bouncer.Context.ContextFragment, as: BakedContextFragment
alias Bouncer.Context.ContextFragmentType
require ContextFragmentType
doctest Bouncer.ContextFragmentBuilder
test "environment should automatically populate the time" do
%ContextFragment{env: %Environment{now: now}} = build() |> environment()
refute now == nil
assert match?({:ok, _, _}, DateTime.from_iso8601(now))
end
end

View File

@ -0,0 +1,64 @@
defmodule BouncerTest do
@moduledoc """
Contains tests for Bouncer client library. Keep it client implementation agnostic.
"""
use ExUnit.Case, async: true
import Mox
alias Bouncer.Context.V1.ContextFragment
alias Bouncer.Decisions.{InvalidContext, InvalidRuleset, RulesetNotFound}
alias TestSupport.Bouncer.Helper
setup :verify_on_exit!
test "should resolve to allowed" do
Bouncer.MockClient
|> expect(:judge, fn _context, _ctx ->
Helper.allowed()
end)
assert Bouncer.judge(test_fragments(), %{}) == {:ok, :allowed}
end
test "should resolve to forbidden" do
Bouncer.MockClient
|> expect(:judge, fn _context, _ctx ->
Helper.forbidden()
end)
assert Bouncer.judge(test_fragments(), %{}) == {:ok, :forbidden}
end
test "should return an error with :ruleset_not_found reason" do
Bouncer.MockClient
|> expect(:judge, fn _context, _ctx ->
{:exception, RulesetNotFound.new()}
end)
assert Bouncer.judge(test_fragments(), %{}) == {:error, :ruleset_not_found}
end
test "should return an error with :invalid_ruleset reason" do
Bouncer.MockClient
|> expect(:judge, fn _context, _ctx ->
{:exception, InvalidRuleset.new()}
end)
assert Bouncer.judge(test_fragments(), %{}) == {:error, :invalid_ruleset}
end
test "should return an error with :invalid_context reason" do
Bouncer.MockClient
|> expect(:judge, fn _context, _ctx ->
{:exception, InvalidContext.new()}
end)
assert Bouncer.judge(test_fragments(), %{}) == {:error, :invalid_context}
end
defp test_fragments do
%{
"test" => ContextFragment.new()
}
end
end

View File

@ -0,0 +1,139 @@
defmodule TestSupport.Bouncer.Helper do
@moduledoc """
Helper functions to use with tests related to Bouncer
"""
alias Bouncer.Base.Entity
alias Bouncer.Context.ContextFragment, as: EncodedContextFragment
alias Bouncer.Context.V1.{
ApiKeyMgmtOperation,
Auth,
AuthScope,
ContextApiKeyMgmt,
ContextFragment
}
alias Bouncer.Decisions.{Context, Judgement, Resolution, ResolutionAllowed, ResolutionForbidden}
# TODO: Fix coverage ignore (this module is used mainly in tests of other apps)
# coveralls-ignore-start
@spec assert_context(Context.t(), (map() -> any)) ::
Context.t()
def assert_context(%Context{fragments: fragments} = context, assert_fun) do
_result = assert_fun.(decode_fragments(fragments))
context
end
@spec assert_fragment(EncodedContextFragment.t(), (ContextFragment.t() -> any)) ::
EncodedContextFragment.t()
def assert_fragment(%EncodedContextFragment{} = fragment, assert_fun) do
_result = assert_fun.(decode_fragment(fragment))
fragment
end
@spec assert_apikeymgmt(
ContextFragment.t(),
operation_id :: String.t(),
party_id :: String.t() | nil,
api_key_id :: String.t() | nil
) ::
ContextFragment.t() | no_return()
def assert_apikeymgmt(fragment, operation_id, party_id \\ nil, api_key_id \\ nil) do
api_key = if(api_key_id, do: %Entity{id: api_key_id})
party = if(party_id, do: %Entity{id: party_id})
case fragment do
%ContextFragment{
apikeymgmt: %ContextApiKeyMgmt{
op: %ApiKeyMgmtOperation{
id: ^operation_id,
party: ^party,
api_key: ^api_key
}
}
} ->
fragment
_mistmatch ->
raise "`apikeymgmt` assertion failed, fragment #{inspect(fragment)} does not match"
end
end
@spec assert_entity(ContextFragment.t(), Entity.t()) ::
ContextFragment.t()
def assert_entity(%ContextFragment{entities: entities} = fragment, entity) do
unless Enum.member?(entities, entity) do
raise "`entity` assertion failed, no #{inspect(entity)} in #{inspect(entities)}"
end
fragment
end
@spec assert_auth(
ContextFragment.t(),
method :: String.t(),
expiration :: String.t(),
token_id :: String.t(),
scopes :: Keyword.t()
) ::
ContextFragment.t()
def assert_auth(fragment, method, expiration, token_id, scopes) do
scopes = Enum.into(scopes, MapSet.new(), &auth_scope_from_keyword/1)
case fragment do
%ContextFragment{
auth: %Auth{
method: ^method,
expiration: ^expiration,
scope: ^scopes,
token: %Bouncer.Context.V1.Token{id: ^token_id}
}
} ->
fragment
_mistmatch ->
raise "`auth` assertion failed, fragment #{inspect(fragment)} does not match"
end
end
@spec allowed() :: {:ok, Judgement.t()}
def allowed do
{:ok,
%Judgement{
resolution: %Resolution{
allowed: %ResolutionAllowed{}
}
}}
end
@spec forbidden() :: {:ok, Judgement.t()}
def forbidden do
{:ok,
%Judgement{
resolution: %Resolution{
forbidden: %ResolutionForbidden{}
}
}}
end
# coveralls-ignore-end
defp decode_fragments(fragments) do
fragments
|> Enum.into(%{}, fn {k, v} -> {k, decode_fragment(v)} end)
end
defp decode_fragment(%EncodedContextFragment{
content: content
}) do
## ...
{struct, ""} = content |> :erlang.iolist_to_binary() |> ContextFragment.deserialize()
struct
end
defp auth_scope_from_keyword({:party, party_id}) do
%AuthScope{
party: %Entity{id: party_id}
}
end
end

View File

@ -0,0 +1,4 @@
Mox.defmock(Bouncer.MockClient, for: Bouncer.Client)
Application.put_env(:bouncer, :client_impl, Bouncer.MockClient)
ExUnit.start()

View File

@ -0,0 +1,5 @@
{
"coverage_options": {
"minimum_coverage": 90
}
}

View File

@ -0,0 +1,19 @@
defmodule OrgManagement do
@moduledoc """
OrgManagement service client.
"""
alias Bouncer.Context.ContextFragment
alias OrgManagement.AuthContextProvider.UserNotFound
alias OrgManagement.Client
@type error() :: {:user, :not_found}
@spec get_user_context(user_id :: String.t(), ctx :: any()) ::
{:ok, ContextFragment.t()} | {:error, error()}
def get_user_context(user_id, ctx) do
case Client.get_user_context(user_id, ctx) do
{:ok, _} = ok -> ok
{:exception, %UserNotFound{}} -> {:error, {:user, :not_found}}
end
end
end

View File

@ -0,0 +1,24 @@
defmodule OrgManagement.Client do
@moduledoc """
A client behaviour for the OrgManagement service client.
"""
alias Bouncer.Context.ContextFragment
alias OrgManagement.AuthContextProvider.UserNotFound
alias OrgManagement.Client.Woody
@callback get_user_context(user_id :: String.t(), rpc_context :: any()) ::
{:ok, ContextFragment.t()} | {:exception, UserNotFound.t()}
@spec get_user_context(user_id :: String.t(), rpc_context :: any()) ::
{:ok, ContextFragment.t()} | {:exception, UserNotFound.t()}
def get_user_context(user_id, ctx) do
impl().get_user_context(user_id, ctx)
end
if Mix.env() == :test do
defp impl, do: Application.get_env(:org_management, :client_impl, Woody)
else
@client_mod Application.compile_env(:org_management, :client_impl, Woody)
defp impl, do: @client_mod
end
end

View File

@ -0,0 +1,21 @@
defmodule OrgManagement.Client.Woody do
@moduledoc """
Woody implementation of Bouncer.Client
"""
@behaviour OrgManagement.Client
alias Bouncer.Context.ContextFragment
alias OrgManagement.AuthContextProvider.UserNotFound
alias Woody.Context
alias Woody.Generated.OrgManagement.AuthContextProvider.AuthContextProvider.Client
@spec get_user_context(user_id :: String.t(), Context.t()) ::
{:ok, ContextFragment.t()} | {:exception, UserNotFound.t()}
def get_user_context(user_id, woody_ctx) do
config = Application.fetch_env!(:org_management, __MODULE__)
woody_ctx
|> Client.new(config[:url], config[:opts] || [])
|> Client.get_user_context(user_id)
end
end

View File

@ -0,0 +1,67 @@
defmodule OrgManagement.MixProject do
use Mix.Project
def project do
[
app: :org_management,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps(),
elixirc_paths: elixirc_paths(Mix.env()),
dialyzer: dialyzer(),
preferred_cli_env: preferred_cli_env(),
test_coverage: [tool: ExCoveralls]
] |> umbrella()
end
defp umbrella(project) do
project ++ [
build_path: "../../_build",
config_path: "../../config/config.exs",
deps_path: "../../deps",
lockfile: "../../mix.lock",
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
defp dialyzer do
[
plt_add_apps: [:ex_unit]
]
end
defp preferred_cli_env do
[
dialyzer: :test,
coveralls: :test,
"coveralls.github": :test,
"coveralls.html": :test
]
end
def application do
[
extra_applications: [:logger]
]
end
defp deps do
[
# RPC
{:woody_ex, git: "https://github.com/valitydev/woody_ex.git", branch: "master"},
# Protocols
{:bouncer_proto, git: "https://github.com/valitydev/bouncer-proto.git", branch: "master"},
{:org_management_proto,
git: "https://github.com/valitydev/org-management-proto.git", branch: "master"},
# Test deps
{:mox, "~> 1.0", only: [:dev, :test]},
{:excoveralls, "~> 0.15", only: :test},
# Dev deps
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
{:credo, "~> 1.6", only: [:dev, :test], runtime: false}
]
end
end

View File

@ -0,0 +1,85 @@
defmodule OrgManagement.Client.WoodyTest do
@moduledoc """
Tests for Woody implementation of OrgManagement.Client behaviour.
"""
# Can't run async mode when relying on app env
use ExUnit.Case, async: false
alias OrgManagement.Client.Woody, as: Client
alias Woody.Generated.OrgManagement.AuthContextProvider.AuthContextProvider, as: Service
alias Woody.Server.Http, as: Server
defmodule MockHandler do
@moduledoc false
@behaviour Service.Handler
@spec new(http_path :: String.t(), fun(), options :: Keyword.t()) :: Service.Handler.t()
def new(http_path, fun, options \\ []) do
Service.Handler.new({__MODULE__, handler_fun: fun}, http_path, options)
end
@spec get_user_context(
id :: String.t(),
ctx :: Woody.Context.t(),
hdlops :: Handler.hdlopts()
) ::
{:ok, Bouncer.Context.ContextFragment.t()}
| {:error, OrgManagement.AuthContextProvider.UserNotFound.t()}
@impl Service.Handler
def get_user_context(id, _ctx, hdlopts) do
hdlopts[:handler_fun].(id)
end
end
test "should reply ok" do
alias Bouncer.Context.ContextFragmentType
require ContextFragmentType
user_id = "test_user"
mock_woody(fn ^user_id ->
{:ok,
%Bouncer.Context.ContextFragment{
type: ContextFragmentType.v1_thrift_binary(),
content: <<>>
}}
end)
assert {:ok,
%Bouncer.Context.ContextFragment{
type: ContextFragmentType.v1_thrift_binary(),
content: <<>>
}} ==
Client.get_user_context(user_id, Woody.Context.new())
end
test "should reply with an exception" do
user_id = "test_user"
mock_woody(fn ^user_id ->
{:error, %OrgManagement.AuthContextProvider.UserNotFound{}}
end)
assert {:exception, %OrgManagement.AuthContextProvider.UserNotFound{}} ==
Client.get_user_context(user_id, Woody.Context.new())
end
defp mock_woody(handler_fn) do
start_supervised!(
Server.child_spec(
__MODULE__,
Server.Endpoint.loopback(),
MockHandler.new("/user_context", handler_fn, event_handler: Woody.EventHandler.Default)
)
)
endpoint = Server.endpoint(__MODULE__)
Application.put_env(:org_management, OrgManagement.Client.Woody,
url: "http://#{endpoint}/user_context"
)
:ok
end
end

View File

@ -0,0 +1,32 @@
defmodule OrgManagementClientTest do
@moduledoc """
Contains tests for OrgManagement client library. Keep it client implementation agnostic.
"""
use ExUnit.Case, async: true
import Mox
alias Bouncer.Context.ContextFragment
alias OrgManagement.AuthContextProvider.UserNotFound
setup :verify_on_exit!
test "should return context fragment" do
OrgManagement.MockClient
|> expect(:get_user_context, fn _user_id, _ctx ->
{:ok, ContextFragment.new()}
end)
assert OrgManagement.get_user_context("user_id", %{}) ==
{:ok, %ContextFragment{}}
end
test "should return an error with {:user, :not_found} reason" do
OrgManagement.MockClient
|> expect(:get_user_context, fn _user_id, _ctx ->
{:exception, UserNotFound.new()}
end)
assert OrgManagement.get_user_context("user_id", %{}) ==
{:error, {:user, :not_found}}
end
end

View File

@ -0,0 +1,4 @@
Mox.defmock(OrgManagement.MockClient, for: OrgManagement.Client)
Application.put_env(:org_management, :client_impl, OrgManagement.MockClient)
ExUnit.start()

View File

@ -0,0 +1,5 @@
{
"coverage_options": {
"minimum_coverage": 90
}
}

View File

@ -0,0 +1,46 @@
defmodule TokenKeeper.Authenticator do
@moduledoc """
TokenKeeper.Authenticator service client.
"""
alias TokenKeeper.{Authenticator.Client, Identity}
alias TokenKeeper.Keeper.{
AuthDataNotFound,
AuthDataRevoked,
InvalidToken,
TokenSourceContext
}
@type error() :: :invalid_token | {:auth_data, :not_found | :revoked}
@spec client(context :: any()) :: Client.t()
def client(ctx) do
Client.new(ctx)
end
@spec authenticate(
Client.t(),
token :: String.t(),
request_origin :: String.t()
) ::
{:ok, Identity.t()} | {:error, error()}
def authenticate(client, token, request_origin) do
case Client.authenticate(
client,
token,
%TokenSourceContext{request_origin: request_origin}
) do
{:ok, authdata} ->
{:ok, Identity.from_authdata(authdata)}
{:exception, %InvalidToken{}} ->
{:error, :invalid_token}
{:exception, %AuthDataNotFound{}} ->
{:error, {:auth_data, :not_found}}
{:exception, %AuthDataRevoked{}} ->
{:error, {:auth_data, :revoked}}
end
end
end

View File

@ -0,0 +1,60 @@
defmodule TokenKeeper.Authenticator.Client do
@moduledoc """
A client behaviour for the TokenKeeper.Authenticator service client.
"""
alias TokenKeeper.Authenticator.Client.Woody
alias TokenKeeper.Keeper.{
AuthData,
AuthDataAlreadyExists,
AuthDataNotFound,
AuthDataRevoked,
InvalidToken,
TokenSourceContext
}
@type t() :: any
@callback new(context :: any()) :: t()
@callback authenticate(
client :: t(),
token :: String.t(),
TokenSourceContext.t()
) ::
{:ok, AuthData.t()}
| {:exception, InvalidToken.t() | AuthDataNotFound.t() | AuthDataRevoked.t()}
@callback add_existing_token(
client :: t(),
id :: String.t(),
context :: Bouncer.Context.ContextFragment.t(),
metadata :: %{String.t() => String.t()},
authority :: String.t()
) ::
{:ok, AuthData.t()}
| {:exception, AuthDataAlreadyExists.t()}
@spec new(context :: any()) :: t()
def new(ctx) do
impl().new(ctx)
end
@spec authenticate(
client :: t(),
token :: String.t(),
TokenSourceContext.t()
) ::
{:ok, AuthData.t()}
| {:exception, InvalidToken.t() | AuthDataNotFound.t() | AuthDataRevoked.t()}
def authenticate(client, token, source_context) do
impl().authenticate(client, token, source_context)
end
if Mix.env() == :test do
defp impl,
do: Application.get_env(:token_keeper, :authenticator_impl, Woody)
else
@client_mod Application.compile_env(:token_keeper, :authenticator_impl, Woody)
defp impl,
do: @client_mod
end
end

View File

@ -0,0 +1,54 @@
defmodule TokenKeeper.Authenticator.Client.Woody do
@moduledoc """
Woody implementation of TokenKeeper.Authenticator.Client
"""
@behaviour TokenKeeper.Authenticator.Client
alias TokenKeeper.Keeper.{
AuthData,
AuthDataAlreadyExists,
AuthDataNotFound,
AuthDataRevoked,
InvalidToken,
TokenSourceContext
}
alias Woody.Context
alias Woody.Generated.TokenKeeper.Keeper.TokenAuthenticator.Client
@type t() :: Woody.Client.Http.t()
@spec new(context :: Context.t()) :: t()
@impl TokenKeeper.Authenticator.Client
def new(context) do
config = Application.fetch_env!(:token_keeper, __MODULE__)
Client.new(context, config[:url], config[:opts] || [])
end
@spec authenticate(
client :: t(),
token :: String.t(),
TokenSourceContext.t()
) ::
{:ok, AuthData.t()}
| {:exception, InvalidToken.t() | AuthDataNotFound.t() | AuthDataRevoked.t()}
@impl TokenKeeper.Authenticator.Client
def authenticate(client, token, source_context) do
Client.authenticate(client, token, source_context)
end
@spec add_existing_token(
client :: t(),
id :: String.t(),
context :: Bouncer.Context.ContextFragment.t(),
metadata :: %{String.t() => String.t()},
authority :: String.t()
) ::
{:ok, AuthData.t()}
| {:exception, AuthDataAlreadyExists.t()}
@impl TokenKeeper.Authenticator.Client
def add_existing_token(client, id, context, metadata, authority) do
Client.add_existing_token(client, id, context, metadata, authority)
end
end

View File

@ -0,0 +1,59 @@
defmodule TokenKeeper.Authority do
@moduledoc """
TokenKeeper.Authority service client.
"""
alias TokenKeeper.{Authority.Client, Identity}
alias TokenKeeper.Keeper.{
AuthData,
AuthDataAlreadyExists,
AuthDataNotFound
}
@spec client(authority_id :: atom(), context :: any()) :: Client.t()
def client(authority_id, ctx) do
Client.new(authority_id, ctx)
end
@spec create(Client.t(), id :: String.t(), Identity.t()) ::
{:ok, AuthData.t()} | {:error, {:auth_data, :exists}}
def create(client, id, identity) do
{context, metadata} = Identity.to_context_metadata(identity)
context = context || build_context_for_identity(identity, id)
case Client.create(client, id, context, metadata) do
{:ok, _} = ok -> ok
{:exception, %AuthDataAlreadyExists{}} -> {:error, {:auth_data, :exists}}
end
end
@spec get(Client.t(), id :: String.t()) ::
{:ok, AuthData.t()} | {:error, {:auth_data, :not_found}}
def get(client, id) do
case Client.get(client, id) do
{:ok, _} = ok -> ok
{:exception, %AuthDataNotFound{}} -> {:error, {:auth_data, :not_found}}
end
end
@spec revoke(Client.t(), id :: String.t()) :: :ok | {:error, {:auth_data, :not_found}}
def revoke(client, id) do
case Client.revoke(client, id) do
{:ok, nil} -> :ok
{:exception, %AuthDataNotFound{}} -> {:error, {:auth_data, :not_found}}
end
end
defp build_context_for_identity(identity, authdata_id) do
context_for_identity_type(identity.type, authdata_id)
end
defp context_for_identity_type(%Identity.Party{id: party_id}, authdata_id) do
import Bouncer.ContextFragmentBuilder
build()
|> auth("ApiKeyToken", nil, authdata_id, party: party_id)
|> bake()
end
end

View File

@ -0,0 +1,63 @@
defmodule TokenKeeper.Authority.Client do
@moduledoc """
A client behaviour for the TokenKeeper.Authority service client.
"""
alias TokenKeeper.Authority.Client.Woody
alias TokenKeeper.Keeper.{
AuthData,
AuthDataAlreadyExists,
AuthDataNotFound
}
alias Bouncer.Context.ContextFragment
@type t() :: any
@callback new(authority_id :: atom(), context :: any()) :: t()
@callback create(
client :: t(),
id :: String.t(),
context_fragment :: ContextFragment.t(),
metadata :: map()
) :: {:ok, AuthData.t()} | {:exception, AuthDataAlreadyExists.t()}
@callback get(client :: t(), id :: String.t()) ::
{:ok, AuthData.t()} | {:exception, AuthDataNotFound.t()}
@callback revoke(client :: t(), id :: String.t()) ::
{:ok, nil} | {:exception, AuthDataNotFound.t()}
@spec new(authority_id :: atom(), context :: any()) :: t()
def new(authority_id, ctx) do
impl().new(authority_id, ctx)
end
@spec create(
client :: t(),
id :: String.t(),
context_fragment :: ContextFragment.t(),
metadata :: map()
) :: {:ok, AuthData.t()} | {:exception, AuthDataAlreadyExists.t()}
def create(client, id, context_fragment, metadata) do
impl().create(client, id, context_fragment, metadata)
end
@spec get(client :: t(), id :: String.t()) ::
{:ok, AuthData.t()} | {:exception, AuthDataNotFound.t()}
def get(client, id) do
impl().get(client, id)
end
@spec revoke(client :: t(), id :: String.t()) :: {:ok, nil} | {:exception, AuthDataNotFound.t()}
def revoke(client, id) do
impl().revoke(client, id)
end
if Mix.env() == :test do
defp impl,
do: Application.get_env(:token_keeper, :authority_impl, Woody)
else
@client_mod Application.compile_env(:token_keeper, :authority_impl, Woody)
defp impl,
do: @client_mod
end
end

View File

@ -0,0 +1,52 @@
defmodule TokenKeeper.Authority.Client.Woody do
@moduledoc """
Woody implementation of TokenKeeper.Authenticator.Client
"""
@behaviour TokenKeeper.Authority.Client
alias Bouncer.Context.ContextFragment
alias TokenKeeper.Keeper.{
AuthData,
AuthDataAlreadyExists,
AuthDataNotFound
}
alias Woody.Context
alias Woody.Generated.TokenKeeper.Keeper.TokenAuthority.Client
@type t() :: Woody.Client.Http.t()
@spec new(authority_id :: atom(), context :: Context.t()) :: t()
@impl TokenKeeper.Authority.Client
def new(authority_id, context) do
config = Application.fetch_env!(:token_keeper, __MODULE__)
config = Keyword.get(config, authority_id)
Client.new(context, config[:url], config[:opts] || [])
end
@spec create(
client :: t(),
id :: String.t(),
context_fragment :: ContextFragment.t(),
metadata :: map()
) :: {:ok, AuthData.t()} | {:exception, AuthDataAlreadyExists.t()}
@impl TokenKeeper.Authority.Client
def create(client, id, context_fragment, metadata) do
Client.create(client, id, context_fragment, metadata)
end
@spec get(client :: t(), id :: String.t()) ::
{:ok, AuthData.t()} | {:exception, AuthDataNotFound.t()}
@impl TokenKeeper.Authority.Client
def get(client, id) do
Client.get(client, id)
end
@spec revoke(client :: t(), id :: String.t()) :: {:ok, nil} | {:exception, AuthDataNotFound.t()}
@impl TokenKeeper.Authority.Client
def revoke(client, id) do
Client.revoke(client, id)
end
end

View File

@ -0,0 +1,110 @@
defmodule TokenKeeper.Identity do
@moduledoc """
An abstraction for an identity of authenticated user interpeted from AuthData.
"""
alias Bouncer.Context.ContextFragment
alias TokenKeeper.Keeper.AuthData
defmodule User do
@moduledoc """
User identity
"""
@enforce_keys [:id]
defstruct [:id, :email, :realm]
@type t() :: %__MODULE__{
id: String.t(),
email: String.t() | nil,
realm: String.t() | nil
}
end
defmodule Party do
@moduledoc """
Party identity
"""
@enforce_keys [:id]
defstruct [:id]
@type t() :: %__MODULE__{
id: String.t()
}
end
@enforce_keys [:type]
defstruct [:bouncer_fragment, :type]
@type type() :: User.t() | Party.t()
@type t() :: %__MODULE__{
type: type(),
bouncer_fragment: ContextFragment.t() | nil
}
@spec from_authdata(AuthData.t()) :: t()
def from_authdata(authdata) do
%__MODULE__{
type: type_from_metadata(authdata.metadata, get_metadata_mapping()),
bouncer_fragment: authdata.context
}
end
@spec to_context_metadata(t()) :: {ContextFragment.t() | nil, metadata :: map()}
def to_context_metadata(identity) do
{identity.bouncer_fragment, type_to_metadata(identity.type, get_metadata_mapping())}
end
##
defp type_to_metadata(type, mapping) do
mapping = mapping |> Enum.into(%{}, fn {k, v} -> {v, k} end)
metadata =
case type do
%Party{id: id} ->
%{party_id: id}
%User{id: id, email: email, realm: realm} ->
%{user_id: id, user_email: email, user_realm: realm}
end
map_metadata(metadata, mapping)
end
defp type_from_metadata(metadata, mapping) do
metadata = map_metadata(metadata || %{}, mapping)
case metadata do
%{party_id: _, user_id: _} ->
:unknown
%{party_id: id} ->
%Party{id: id}
%{user_id: id} = user ->
%User{id: id, email: Map.get(user, :user_email), realm: Map.get(user, :user_realm)}
_unknown ->
:unknown
end
end
defp map_metadata(metadata, mapping) do
mapping
|> Enum.into(%{}, fn {k, v} -> {k, Map.get(metadata, v)} end)
|> Map.reject(fn {_, v} -> v == nil end)
end
defp get_metadata_mapping do
conf = Application.get_env(:token_keeper, __MODULE__, nil)
conf[:metadata_mapping] || default_mapping()
end
defp default_mapping do
%{
party_id: "party.id",
user_id: "user.id",
user_email: "user.email",
user_realm: "user.realm"
}
end
end

68
apps/token_keeper/mix.exs Normal file
View File

@ -0,0 +1,68 @@
defmodule TokenKeeper.MixProject do
use Mix.Project
def project do
[
app: :token_keeper,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps(),
elixirc_paths: elixirc_paths(Mix.env()),
dialyzer: dialyzer(),
preferred_cli_env: preferred_cli_env(),
test_coverage: [tool: ExCoveralls]
] |> umbrella()
end
defp umbrella(project) do
project ++ [
build_path: "../../_build",
config_path: "../../config/config.exs",
deps_path: "../../deps",
lockfile: "../../mix.lock",
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
defp dialyzer do
[
plt_add_apps: [:ex_unit]
]
end
defp preferred_cli_env do
[
dialyzer: :test,
coveralls: :test,
"coveralls.github": :test,
"coveralls.html": :test
]
end
def application do
[
extra_applications: [:logger]
]
end
defp deps do
[
# RPC
{:woody_ex, git: "https://github.com/valitydev/woody_ex.git", branch: "master"},
# Protocols
{:bouncer_proto, git: "https://github.com/valitydev/bouncer-proto.git", branch: "master"},
{:token_keeper_proto,
git: "https://github.com/valitydev/token-keeper-proto.git", branch: "master"},
# Test deps
{:bouncer, in_umbrella: true, only: [:test]},
{:mox, "~> 1.0", only: [:dev, :test]},
{:excoveralls, "~> 0.15", only: :test},
# Dev deps
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
{:credo, "~> 1.6", only: [:dev, :test], runtime: false}
]
end
end

View File

@ -0,0 +1,70 @@
defmodule TestSupport.TokenKeeper.Autheticator.WoodyMock do
@moduledoc """
Helper functions to mock a woody token keeper service in tests
"""
alias Woody.Generated.TokenKeeper.Keeper.TokenAuthenticator, as: Service
alias Woody.Server.Http, as: Server
defmodule MockHandler do
@moduledoc false
@behaviour Service.Handler
alias Woody.Server.Http
@spec new(http_path :: String.t(), fun(), options :: Keyword.t()) :: Http.Handler.t()
def new(http_path, fun, options) do
Service.Handler.new({__MODULE__, handler_fun: fun}, http_path, options)
end
@spec add_existing_token(
id :: String.t(),
context :: Bouncer.Context.ContextFragment.t(),
metadata :: %{String.t() => String.t()},
authority :: String.t(),
ctx :: Woody.Context.t(),
hdlops :: Http.Handler.hdlopts()
) ::
{:ok, TokenKeeper.Keeper.AuthData.t()}
| {:error, TokenKeeper.Keeper.AuthDataAlreadyExists.t()}
@impl Service.Handler
def add_existing_token(id, context, metadata, authority, _ctx, hdlopts) do
hdlopts[:handler_fun][:add_existing_token].(id, context, metadata, authority)
end
@spec authenticate(
token :: String.t(),
source_context :: TokenKeeper.Keeper.TokenSourceContext.t(),
ctx :: Woody.Context.t(),
hdlops :: Http.Handler.hdlopts()
) ::
{:ok, TokenKeeper.Keeper.AuthData.t()}
| {:error, TokenKeeper.Keeper.InvalidToken.t()}
| {:error, TokenKeeper.Keeper.AuthDataNotFound.t()}
| {:error, TokenKeeper.Keeper.AuthDataRevoked.t()}
@impl Service.Handler
def authenticate(token, source_context, _ctx, hdlopts) do
hdlopts[:handler_fun][:authenticate].(token, source_context)
end
end
@spec mock(any) :: :ok
def mock(handler_fn) do
import ExUnit.Callbacks
start_supervised!(
Server.child_spec(
__MODULE__,
Server.Endpoint.loopback(),
MockHandler.new("/authenticator", handler_fn, event_handler: Woody.EventHandler.Default)
)
)
endpoint = Server.endpoint(__MODULE__)
Application.put_env(:token_keeper, TokenKeeper.Authenticator.Client.Woody,
url: "http://#{endpoint}/authenticator"
)
:ok
end
end

View File

@ -0,0 +1,38 @@
defmodule TestSupport.TokenKeeper.Helper do
@moduledoc """
Helper functions to use with tests related to TokenKeeper
"""
alias Bouncer.Context.ContextFragment
alias TokenKeeper.Keeper.{AuthData, AuthDataStatus}
@spec make_authdata(
id :: String.t() | nil,
metadata :: map(),
ContextFragment.t(),
status :: non_neg_integer()
) :: AuthData.t()
def make_authdata(
id \\ nil,
metadata \\ %{},
context \\ test_context(),
status \\ status_active()
) do
%AuthData{
id: id,
status: status,
context: context,
metadata: metadata
}
end
defp status_active do
require AuthDataStatus
AuthDataStatus.active()
end
defp test_context do
import Bouncer.ContextFragmentBuilder
build() |> bake()
end
end

View File

@ -0,0 +1,7 @@
Mox.defmock(TokenKeeper.Authenticator.MockClient, for: TokenKeeper.Authenticator.Client)
Mox.defmock(TokenKeeper.Authority.MockClient, for: TokenKeeper.Authority.Client)
Application.put_env(:token_keeper, :authenticator_impl, TokenKeeper.Authenticator.MockClient)
Application.put_env(:token_keeper, :authority_impl, TokenKeeper.Authority.MockClient)
ExUnit.start()

View File

@ -0,0 +1,85 @@
defmodule TokenKeeper.Authenticator.Client.WoodyTest do
@moduledoc """
Tests for Woody implementation of TokenKeeper.Authenticator.Client behaviour.
"""
# Can't run async mode when relying on app env
use ExUnit.Case, async: false
alias TestSupport.TokenKeeper.Autheticator.WoodyMock
alias TestSupport.TokenKeeper.Helper, as: TestHelper
alias TokenKeeper.Authenticator.Client.Woody, as: Client
describe "authenticate call" do
test "should reply ok" do
token = "42"
source_context = %TokenKeeper.Keeper.TokenSourceContext{request_origin: "localhost"}
authdata = TestHelper.make_authdata()
WoodyMock.mock(
authenticate: fn ^token, ^source_context ->
{:ok, authdata}
end
)
assert {:ok, authdata} ==
Client.new(Woody.Context.new()) |> Client.authenticate(token, source_context)
end
test "should reply with an exception" do
token = "42"
source_context = %TokenKeeper.Keeper.TokenSourceContext{request_origin: "localhost"}
WoodyMock.mock(
authenticate: fn ^token, ^source_context ->
{:error, %TokenKeeper.Keeper.InvalidToken{}}
end
)
assert {:exception, %TokenKeeper.Keeper.InvalidToken{}} ==
Client.new(Woody.Context.new()) |> Client.authenticate(token, source_context)
end
end
describe "add_existing_token call" do
test "should reply ok" do
id = "42"
metadata = %{"test" => "test"}
authority = "test"
authdata = TestHelper.make_authdata(id, metadata)
context = authdata.context
WoodyMock.mock(
add_existing_token: fn ^id, ^context, ^metadata, ^authority ->
{:ok, authdata}
end
)
assert {:ok, authdata} ==
Client.new(Woody.Context.new())
|> Client.add_existing_token(id, context, metadata, authority)
end
test "should reply with an exception" do
id = "42"
metadata = %{"test" => "test"}
authority = "test"
authdata = TestHelper.make_authdata(id, metadata)
context = authdata.context
WoodyMock.mock(
add_existing_token: fn ^id, ^context, ^metadata, ^authority ->
{:error, %TokenKeeper.Keeper.AuthDataAlreadyExists{}}
end
)
assert {:exception, %TokenKeeper.Keeper.AuthDataAlreadyExists{}} ==
Client.new(Woody.Context.new())
|> Client.add_existing_token(id, context, metadata, authority)
end
end
end

View File

@ -0,0 +1,70 @@
defmodule TokenKeeper.AuthenticatorTest do
@moduledoc """
Contains tests for TokenKeeper.Authenticator client library. Keep it client implementation agnostic.
"""
use ExUnit.Case, async: true
import Mox
alias TokenKeeper.Authenticator
alias TokenKeeper.Identity
alias TokenKeeper.Keeper.{
AuthData,
AuthDataNotFound,
AuthDataRevoked,
InvalidToken,
TokenSourceContext
}
setup :verify_on_exit!
setup_all %{} do
Authenticator.MockClient
|> Mox.expect(:new, fn ctx -> ctx end)
%{client: Authenticator.client(%{})}
end
test "should return unknown identity type", %{client: client} do
Authenticator.MockClient
|> expect(:authenticate, fn ^client,
"token",
%TokenSourceContext{request_origin: "http://origin"} ->
{:ok, AuthData.new()}
end)
assert Authenticator.authenticate(client, "token", "http://origin") ==
{:ok, %Identity{type: :unknown}}
end
test "should return an error with :invalid_token reason", %{client: client} do
Authenticator.MockClient
|> expect(:authenticate, fn ^client, _token, _origin ->
{:exception, InvalidToken.new()}
end)
assert Authenticator.authenticate(client, "token", "http://origin") ==
{:error, :invalid_token}
end
test "should return an error with {:auth_data, :not_found} reason", %{client: client} do
Authenticator.MockClient
|> expect(:authenticate, fn ^client, _token, _origin ->
{:exception, AuthDataNotFound.new()}
end)
assert Authenticator.authenticate(client, "token", "http://origin") ==
{:error, {:auth_data, :not_found}}
end
test "should return an error with {:auth_data, :revoked} reason", %{client: client} do
Authenticator.MockClient
|> expect(:authenticate, fn ^client, _token, _origin ->
{:exception, AuthDataRevoked.new()}
end)
assert Authenticator.authenticate(client, "token", "http://origin") ==
{:error, {:auth_data, :revoked}}
end
end

View File

@ -0,0 +1,97 @@
defmodule TokenKeeper.Authority.Client.WoodyTest do
@moduledoc """
Tests for Woody implementation of TokenKeeper.Authority.Client behaviour.
"""
# Can't run async mode when relying on app env
use ExUnit.Case, async: false
alias TokenKeeper.Authority.Client.Woody, as: Client
alias Woody.Generated.TokenKeeper.Keeper.TokenAuthority, as: Service
alias Woody.Server.Http, as: Server
defmodule MockHandler do
@moduledoc false
@behaviour Service.Handler
@spec new(http_path :: String.t(), fun(), options :: Keyword.t()) :: Service.Handler.t()
def new(http_path, fun, options \\ []) do
Service.Handler.new({__MODULE__, handler_fun: fun}, http_path, options)
end
@spec create(
id :: String.t(),
context :: Bouncer.Context.ContextFragment.t(),
metadata :: %{String.t() => String.t()},
ctx :: Woody.Context.t(),
hdlops :: Handler.hdlopts()
) ::
{:ok, TokenKeeper.Keeper.AuthData.t()}
| {:error, TokenKeeper.Keeper.AuthDataAlreadyExists.t()}
@impl Service.Handler
def create(id, context, metadata, _ctx, hdlopts) do
hdlopts[:handler_fun][:create].(id, context, metadata)
end
@spec get(id :: String.t(), ctx :: Woody.Context.t(), hdlops :: Handler.hdlopts()) ::
{:ok, TokenKeeper.Keeper.AuthData.t()}
| {:error, TokenKeeper.Keeper.AuthDataNotFound.t()}
@impl Service.Handler
def get(id, _ctx, hdlopts) do
hdlopts[:handler_fun][:get].(id)
end
@spec revoke(id :: String.t(), ctx :: Woody.Context.t(), hdlops :: Handler.hdlopts()) ::
:ok | {:error, TokenKeeper.Keeper.AuthDataNotFound.t()}
@impl Service.Handler
def revoke(id, _ctx, hdlopts) do
hdlopts[:handler_fun][:revoke].(id)
end
end
test "should reply ok" do
id = "42"
mock_woody(:test_authority,
revoke: fn ^id ->
{:ok, nil}
end
)
assert {:ok, nil} ==
Client.new(:test_authority, Woody.Context.new()) |> Client.revoke(id)
end
test "should reply with an exception" do
id = "42"
mock_woody(:test_authority,
revoke: fn ^id ->
{:error, %TokenKeeper.Keeper.AuthDataNotFound{}}
end
)
assert {:exception, %TokenKeeper.Keeper.AuthDataNotFound{}} ==
Client.new(:test_authority, Woody.Context.new()) |> Client.revoke(id)
end
defp mock_woody(authority_id, handler_fn) do
start_supervised!(
Server.child_spec(
__MODULE__,
Server.Endpoint.loopback(),
MockHandler.new("/authority/#{authority_id}", handler_fn,
event_handler: Woody.EventHandler.Default
)
)
)
endpoint = Server.endpoint(__MODULE__)
Application.put_env(:token_keeper, TokenKeeper.Authority.Client.Woody, [
{authority_id, url: "http://#{endpoint}/authority/#{authority_id}"}
])
:ok
end
end

View File

@ -0,0 +1,128 @@
defmodule TokenKeeper.AuthorityTest do
@moduledoc """
Contains tests for TokenKeeper.Authority client library. Keep it client implementation agnostic.
"""
use ExUnit.Case, async: true
import Mox
alias TokenKeeper.Authority
alias TokenKeeper.{Identity, Identity.Party, Identity.User}
alias TokenKeeper.Keeper.{AuthData, AuthDataAlreadyExists, AuthDataNotFound}
setup :verify_on_exit!
setup_all %{} do
Authority.MockClient
|> Mox.expect(:new, fn _id, ctx -> ctx end)
%{client: Authority.client("test", %{})}
end
test "should create authdata successfully", %{client: client} do
authdata_id = "42"
party_id = "party_id"
context = test_context(authdata_id, party_id)
Authority.MockClient
|> expect(:create, fn ^client, ^authdata_id, ^context, metadata ->
{:ok,
%AuthData{
id: authdata_id,
context: context,
metadata: metadata
}}
end)
identity = %Identity{
type: %Party{
id: party_id
}
}
assert {:ok,
%AuthData{
id: ^authdata_id,
context: ^context,
metadata: %{"party.id" => ^party_id}
}} = Authority.create(client, authdata_id, identity)
end
test "should fail creating authdata for User identity (not supported)", %{client: client} do
authdata_id = "42"
identity = %Identity{
type: %User{
id: "test"
}
}
assert_raise FunctionClauseError, fn -> Authority.create(client, authdata_id, identity) end
end
test "should fail to create authdata with error code {:auth_data, :exists}", %{client: client} do
party_id = "party_id"
Authority.MockClient
|> expect(:create, fn ^client, "authdata", _cf, %{"party.id" => ^party_id} ->
{:exception, AuthDataAlreadyExists.new()}
end)
identity = %Identity{
type: %Party{
id: party_id
}
}
assert Authority.create(client, "authdata", identity) ==
{:error, {:auth_data, :exists}}
end
test "should get authdata sucessfully", %{client: client} do
Authority.MockClient
|> expect(:get, fn ^client, "authdata" ->
{:ok, AuthData.new()}
end)
assert Authority.get(client, "authdata") ==
{:ok, AuthData.new()}
end
test "should fail to get authdata with error code {:authdata, :not_found}", %{client: client} do
Authority.MockClient
|> expect(:get, fn ^client, "authdata" ->
{:exception, AuthDataNotFound.new()}
end)
assert Authority.get(client, "authdata") ==
{:error, {:auth_data, :not_found}}
end
test "should revoke authdata sucessfully", %{client: client} do
Authority.MockClient
|> expect(:revoke, fn ^client, "authdata" ->
{:ok, nil}
end)
assert Authority.revoke(client, "authdata") ==
:ok
end
test "should fail to revoke authdata with error code {:authdata, :not_found}", %{client: client} do
Authority.MockClient
|> expect(:revoke, fn ^client, "authdata" ->
{:exception, AuthDataNotFound.new()}
end)
assert Authority.revoke(client, "authdata") ==
{:error, {:auth_data, :not_found}}
end
defp test_context(authdata_id, party_id) do
import Bouncer.ContextFragmentBuilder
build()
|> auth("ApiKeyToken", nil, authdata_id, party: party_id)
|> bake()
end
end

View File

@ -0,0 +1,194 @@
defmodule TokenKeeper.IdentityTest do
@moduledoc """
Tests for TokenKeeper.Identity module
"""
use ExUnit.Case, async: false
alias Bouncer.Context.ContextFragment
alias TokenKeeper.Identity
alias TokenKeeper.Identity.{Party, User}
alias TokenKeeper.Keeper.AuthData
test "should fail to interpret any identity type" do
authdata = %AuthData{
context: %ContextFragment{},
metadata: %{
"random" => "lmao"
}
}
assert match?(%Identity{type: :unknown}, Identity.from_authdata(authdata))
end
test "should fail to decide on identity type with conflicting data" do
authdata = %AuthData{
context: %ContextFragment{},
metadata: %{
"party.id" => "42",
"user.id" => "walter"
}
}
assert match?(%Identity{type: :unknown}, Identity.from_authdata(authdata))
end
test "should interpret User identity type" do
user_id = "walter"
authdata = %AuthData{
context: %ContextFragment{},
metadata: %{
"user.id" => user_id
}
}
assert match?(%Identity{type: %User{id: ^user_id}}, Identity.from_authdata(authdata))
end
test "should interpret Party identity type" do
party_id = "42"
authdata = %AuthData{
context: %ContextFragment{},
metadata: %{
"party.id" => party_id
}
}
assert match?(%Identity{type: %Party{id: ^party_id}}, Identity.from_authdata(authdata))
end
test "should produce correct metadata for Party identity" do
party_id = "42"
identity = %Identity{
bouncer_fragment: %ContextFragment{},
type: %Party{id: party_id}
}
assert Identity.to_context_metadata(identity) ==
{%ContextFragment{},
%{
"party.id" => party_id
}}
end
describe "with metadata mapping" do
@mapping %{
user_id: "my.user.id",
user_email: "my.user.email",
user_realm: "my.user.realm",
party_id: "my.party.id"
}
setup do
env_before = Application.get_env(:token_keeper, TokenKeeper.Identity)
:ok = Application.put_env(:token_keeper, TokenKeeper.Identity, metadata_mapping: @mapping)
on_exit(fn ->
:ok = Application.put_env(:token_keeper, TokenKeeper.Identity, env_before)
end)
end
test "should interpret User identity type" do
user_id = "walter"
authdata = %AuthData{
context: %ContextFragment{},
metadata: %{
@mapping[:user_id] => user_id
}
}
assert match?(
%Identity{type: %User{id: ^user_id}},
Identity.from_authdata(authdata)
)
end
test "should interpret User identity type with all the additional fields" do
user_id = "walter"
user_email = "example@test"
user_realm = "otherworldly"
authdata = %AuthData{
context: %ContextFragment{},
metadata: %{
@mapping[:user_id] => user_id,
@mapping[:user_email] => user_email,
@mapping[:user_realm] => user_realm
}
}
assert Identity.from_authdata(authdata) == %Identity{
bouncer_fragment: %ContextFragment{},
type: %User{id: user_id, email: user_email, realm: user_realm}
}
end
test "should interpret Party identity type" do
party_id = "42"
authdata = %AuthData{
context: %ContextFragment{},
metadata: %{
@mapping[:party_id] => party_id
}
}
assert match?(
%Identity{type: %Party{id: ^party_id}},
Identity.from_authdata(authdata)
)
end
test "should produce correct metadata for User identity" do
user_id = "walter"
user_email = "example@test"
user_realm = "otherworldly"
identity = %Identity{
bouncer_fragment: %ContextFragment{},
type: %User{id: user_id, email: user_email, realm: user_realm}
}
assert Identity.to_context_metadata(identity) ==
{%ContextFragment{},
%{
@mapping[:user_id] => user_id,
@mapping[:user_email] => user_email,
@mapping[:user_realm] => user_realm
}}
end
test "should not produce more metadata then exists" do
user_id = "walter"
identity = %Identity{
bouncer_fragment: %ContextFragment{},
type: %User{id: user_id}
}
assert Identity.to_context_metadata(identity) ==
{%ContextFragment{},
%{
@mapping[:user_id] => user_id
}}
end
test "should produce correct metadata for Party identity" do
party_id = "42"
identity = %Identity{
bouncer_fragment: %ContextFragment{},
type: %Party{id: party_id}
}
assert Identity.to_context_metadata(identity) ==
{%ContextFragment{},
%{
@mapping[:party_id] => party_id
}}
end
end
end

33
compose.yaml Normal file
View File

@ -0,0 +1,33 @@
version: '3.1'
services:
testrunner:
image: $DEV_IMAGE_TAG
build:
dockerfile: Dockerfile.dev
context: .
args:
ELIXIR_VERSION: $ELIXIR_VERSION
volumes:
- .:$PWD
environment:
- MIX_ENV=test
depends_on:
db:
condition: service_healthy
working_dir: $PWD
db:
image: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: apikeymgmt
ports:
- 5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5

9
config/config.exs Normal file
View File

@ -0,0 +1,9 @@
import Config
config :api_key_mgmt,
ecto_repos: [ApiKeyMgmt.Repository]
config :api_key_mgmt, ApiKeyMgmt.Repository, migration_primary_key: [name: :id, type: :string]
config :api_key_mgmt, ApiKeyMgmt.Repository, migration_foreign_key: [name: :id, type: :string]
import_config "#{Mix.env()}.exs"

1
config/dev.exs Normal file
View File

@ -0,0 +1 @@
import Config

6
config/prod.exs Normal file
View File

@ -0,0 +1,6 @@
import Config
config :bouncer, client_impl: Bouncer.Client.Woody
config :org_management, client_impl: OrgManagement.Client.Woody
config :token_keeper, authenticator_impl: TokenKeeper.Authenticator.Client.Woody
config :token_keeper, authority_impl: TokenKeeper.Authority.Client.Woody

54
config/runtime.exs Normal file
View File

@ -0,0 +1,54 @@
import Config
# Configure release environment here
config :api_key_mgmt, Plug.Cowboy,
# Refer to Plug.Cowboy moduledoc for available options
ip: {0, 0, 0, 0},
port: 8080
config :api_key_mgmt, ApiKeyMgmt.Handler,
# * `:deployment_id` - ID of the current deployment used for authorization
# "Production" by default
deployment_id: "Production",
# * `:authority_id` - ID of the authority that issues api keys
# Must be configured in token keeper client
authority_id: "my_authority_id"
config :api_key_mgmt, ApiKeyMgmt.Repository,
username: System.get_env("DB_USERNAME") || "postgres",
password: System.get_env("DB_PASSWORD") || "postgres",
database: System.get_env("DB_DATABASE") || "apikeymgmt",
hostname: System.get_env("DB_HOSTNAME") || "db"
config :bouncer, Bouncer.Client.Woody,
url: "http://bouncer:8022/v1/arbiter",
ruleset_id: "bouncer_ruleset",
# WoodyClient.options()
opts: []
config :org_management, OrgManagement.Client.Woody,
url: "http://org_management:8022/v1/user_context",
# WoodyClient.options()
opts: []
config :token_keeper, TokenKeeper.Authenticator.Client.Woody,
url: "http://token_keeper:8022/v2/authenticator",
# WoodyClient.options()
opts: []
config :token_keeper, TokenKeeper.Authority.Client.Woody, %{
"my_authority_id" => [
url: "http://token_keeper:8022/v2/authority/my_authority_id",
# WoodyClient.options()
opts: []
]
}
config :token_keeper, TokenKeeper.Identity,
metadata_mapping: %{
party_id: "party.id",
user_id: "user.id",
user_email: "user.email",
user_realm: "user.realm"
}

10
config/test.exs Normal file
View File

@ -0,0 +1,10 @@
import Config
config :logger, level: :warn
config :api_key_mgmt, ApiKeyMgmt.Repository,
username: System.get_env("DB_USERNAME") || "postgres",
password: System.get_env("DB_PASSWORD") || "postgres",
database: System.get_env("DB_DATABASE") || "apikeymgmt",
hostname: System.get_env("DB_HOSTNAME") || "db",
pool: Ecto.Adapters.SQL.Sandbox

34
mix.exs Normal file
View File

@ -0,0 +1,34 @@
defmodule ApiKeyMgmtUmbrella.MixProject do
use Mix.Project
def project do
[
apps_path: "apps",
start_permanent: Mix.env() == :prod,
deps: deps(),
aliases: aliases(),
releases: releases()
]
end
defp aliases do
[test: ["compile", "cmd mix test"]]
end
defp deps do
[]
end
defp releases do
[
api_key_mgmt: [
version: "0.1.0",
applications: [
api_key_mgmt: :permanent
],
include_executables_for: [:unix],
include_erts: false
]
]
end
end

50
mix.lock Normal file
View File

@ -0,0 +1,50 @@
%{
"bouncer_proto": {:git, "https://github.com/valitydev/bouncer-proto.git", "fa8fb0fe8b517f8dfce7c586c7381892fdab2149", [branch: "master"]},
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
"cache": {:hex, :cache, "2.3.3", "b23a5fe7095445a88412a6e614c933377e0137b44ffed77c9b3fef1a731a20b2", [:rebar3], [], "hexpm", "44516ce6fa03594d3a2af025dd3a87bfe711000eb730219e1ddefc816e0aa2f4"},
"castore": {:hex, :castore, "0.1.19", "a2c3e46d62b7f3aa2e6f88541c21d7400381e53704394462b9fd4f06f6d42bb6", [:mix], [], "hexpm", "e96e0161a5dc82ef441da24d5fa74aefc40d920f3a6645d15e1f9f3e66bb2109"},
"certifi": {:hex, :certifi, "2.8.0", "d4fb0a6bb20b7c9c3643e22507e42f356ac090a1dcea9ab99e27e0376d695eba", [:rebar3], [], "hexpm", "6ac7efc1c6f8600b08d625292d4bbf584e14847ce1b6b5c44d983d273e1097ea"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"},
"db_connection": {:hex, :db_connection, "2.4.3", "3b9aac9f27347ec65b271847e6baeb4443d8474289bd18c1d6f4de655b70c94d", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c127c15b0fa6cfb32eed07465e05da6c815b032508d4ed7c116122871df73c12"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"},
"ecto": {:hex, :ecto, "3.9.2", "017db3bc786ff64271108522c01a5d3f6ba0aea5c84912cfb0dd73bf13684108", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "21466d5177e09e55289ac7eade579a642578242c7a3a9f91ad5c6583337a9d15"},
"ecto_sql": {:hex, :ecto_sql, "3.9.1", "9bd5894eecc53d5b39d0c95180d4466aff00e10679e13a5cfa725f6f85c03c22", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fd470a4fff2e829bbf9dcceb7f3f9f6d1e49b4241e802f614de6b8b67c51118"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"excoveralls": {:hex, :excoveralls, "0.15.1", "83c8cf7973dd9d1d853dce37a2fb98aaf29b564bf7d01866e409abf59dac2c0e", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f8416bd90c0082d56a2178cf46c837595a06575f70a5624f164a1ffe37de07e7"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"finch": {:hex, :finch, "0.14.0", "619bfdee18fc135190bf590356c4bf5d5f71f916adb12aec94caa3fa9267a4bc", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5459acaf18c4fdb47a8c22fb3baff5d8173106217c8e56c5ba0b93e66501a8dd"},
"genlib": {:git, "https://github.com/valitydev/genlib.git", "82c5ff3866e3019eb347c7f1d8f1f847bed28c10", [branch: "master"]},
"gproc": {:hex, :gproc, "0.9.0", "853ccb7805e9ada25d227a157ba966f7b34508f386a3e7e21992b1b484230699", [:rebar3], [], "hexpm", "587e8af698ccd3504cf4ba8d90f893ede2b0f58cabb8a916e2bf9321de3cf10b"},
"hackney": {:hex, :hackney, "1.18.0", "c4443d960bb9fba6d01161d01cd81173089686717d9490e5d3606644c48d121f", [:rebar3], [{:certifi, "~> 2.8.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "9afcda620704d720db8c6a3123e9848d09c87586dc1c10479c42627b905b5c5e"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"},
"mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"},
"nimble_options": {:hex, :nimble_options, "0.5.1", "5c166f7669e40333191bea38e3bd3811cc13f459f1e4be49e89128a21b5d8c4d", [:mix], [], "hexpm", "d176cf7baa4fef0ceb301ca3eb8b55bd7de3e45f489c4f8b4f2849f1f114ef3e"},
"nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"},
"open_api_spex": {:git, "https://github.com/kehitt/open_api_spex.git", "b395aa4fba9e00a10a796b87c1e36984822b08d8", [branch: "fix-cast-and-validate-read-only"]},
"org_management_proto": {:git, "https://github.com/valitydev/org-management-proto.git", "04de2f4ad697430c75f8efa04716d30753bd7c4b", [branch: "master"]},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"},
"plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"},
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
"postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"snowflake": {:git, "https://github.com/valitydev/snowflake.git", "de159486ef40cec67074afe71882bdc7f7deab72", [branch: "master"]},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
"thrift": {:git, "https://github.com/valitydev/elixir-thrift", "d3dd6cc089772a065e6ea2dc0e098338d7431cef", [branch: "master"]},
"token_keeper_proto": {:git, "https://github.com/valitydev/token-keeper-proto.git", "8b8bb4333828350301ae2fe801c0c8de61c6529c", [branch: "master"]},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"woody": {:git, "https://github.com/valitydev/woody_erlang.git", "7358ceac9cc8f9d44e18b4ef06937b2056e6ba0c", [branch: "compat/woody_ex"]},
"woody_ex": {:git, "https://github.com/valitydev/woody_ex.git", "c84ec433e21f993ef0155e19ed48e23300c1a626", [branch: "master"]},
}