mirror of
https://github.com/valitydev/api-key-mgmt.git
synced 2024-11-06 08:55:16 +00:00
TD-421: Api key management service MVP (#1)
This commit is contained in:
parent
08369b617e
commit
70f113f3a3
206
.credo.exs
Normal file
206
.credo.exs
Normal 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
3
.env
Normal file
@ -0,0 +1,3 @@
|
||||
SERVICE_NAME=api_key_mgmt
|
||||
OTP_VERSION=25
|
||||
ELIXIR_VERSION=1.14
|
9
.formatter.exs
Normal file
9
.formatter.exs
Normal 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
21
.github/workflows/build-image.yml
vendored
Normal 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
34
.github/workflows/elixir-checks.yml
vendored
Normal 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
|
169
.github/workflows/elixir-parallel-build.yml
vendored
Normal file
169
.github/workflows/elixir-parallel-build.yml
vendored
Normal 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
35
.gitignore
vendored
Normal 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
39
Dockerfile
Normal 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
11
Dockerfile.dev
Normal 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
99
Makefile
Normal 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
|
5
apps/api_key_mgmt/coveralls.json
Normal file
5
apps/api_key_mgmt/coveralls.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"coverage_options": {
|
||||
"minimum_coverage": 90
|
||||
}
|
||||
}
|
23
apps/api_key_mgmt/lib/api_key_mgmt.ex
Normal file
23
apps/api_key_mgmt/lib/api_key_mgmt.ex
Normal 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
|
99
apps/api_key_mgmt/lib/api_key_mgmt/api_key.ex
Normal file
99
apps/api_key_mgmt/lib/api_key_mgmt/api_key.ex
Normal 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
|
51
apps/api_key_mgmt/lib/api_key_mgmt/api_key_repository.ex
Normal file
51
apps/api_key_mgmt/lib/api_key_mgmt/api_key_repository.ex
Normal 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
|
71
apps/api_key_mgmt/lib/api_key_mgmt/auth.ex
Normal file
71
apps/api_key_mgmt/lib/api_key_mgmt/auth.ex
Normal 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
|
@ -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
|
86
apps/api_key_mgmt/lib/api_key_mgmt/auth/context.ex
Normal file
86
apps/api_key_mgmt/lib/api_key_mgmt/auth/context.ex
Normal 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
|
198
apps/api_key_mgmt/lib/api_key_mgmt/handler.ex
Normal file
198
apps/api_key_mgmt/lib/api_key_mgmt/handler.ex
Normal 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
|
8
apps/api_key_mgmt/lib/api_key_mgmt/repository.ex
Normal file
8
apps/api_key_mgmt/lib/api_key_mgmt/repository.ex
Normal 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
|
14
apps/api_key_mgmt/lib/api_key_mgmt/router.ex
Normal file
14
apps/api_key_mgmt/lib/api_key_mgmt/router.ex
Normal 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
|
27
apps/api_key_mgmt/lib/plugger/generated/auth.ex
Normal file
27
apps/api_key_mgmt/lib/plugger/generated/auth.ex
Normal 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
|
32
apps/api_key_mgmt/lib/plugger/generated/handler.ex
Normal file
32
apps/api_key_mgmt/lib/plugger/generated/handler.ex
Normal 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
|
111
apps/api_key_mgmt/lib/plugger/generated/response.ex
Normal file
111
apps/api_key_mgmt/lib/plugger/generated/response.ex
Normal 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
|
156
apps/api_key_mgmt/lib/plugger/generated/router.ex
Normal file
156
apps/api_key_mgmt/lib/plugger/generated/router.ex
Normal 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
|
354
apps/api_key_mgmt/lib/plugger/generated/spec.ex
Normal file
354
apps/api_key_mgmt/lib/plugger/generated/spec.ex
Normal 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
|
54
apps/api_key_mgmt/lib/plugger/plug.ex
Normal file
54
apps/api_key_mgmt/lib/plugger/plug.ex
Normal 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
|
10
apps/api_key_mgmt/lib/plugger/protocol.ex
Normal file
10
apps/api_key_mgmt/lib/plugger/protocol.ex
Normal 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
84
apps/api_key_mgmt/mix.exs
Normal 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
|
@ -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
|
@ -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
|
101
apps/api_key_mgmt/test/api_key_mgmt/auth/context_test.exs
Normal file
101
apps/api_key_mgmt/test/api_key_mgmt/auth/context_test.exs
Normal 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
|
187
apps/api_key_mgmt/test/api_key_mgmt/auth_test.exs
Normal file
187
apps/api_key_mgmt/test/api_key_mgmt/auth_test.exs
Normal 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
|
27
apps/api_key_mgmt/test/api_key_mgmt/handler/context_test.exs
Normal file
27
apps/api_key_mgmt/test/api_key_mgmt/handler/context_test.exs
Normal 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
|
355
apps/api_key_mgmt/test/api_key_mgmt/handler_test.exs
Normal file
355
apps/api_key_mgmt/test/api_key_mgmt/handler_test.exs
Normal 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
|
307
apps/api_key_mgmt/test/api_key_mgmt_test.exs
Normal file
307
apps/api_key_mgmt/test/api_key_mgmt_test.exs
Normal 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
|
311
apps/api_key_mgmt/test/plugger/router_test.exs
Normal file
311
apps/api_key_mgmt/test/plugger/router_test.exs
Normal 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
|
18
apps/api_key_mgmt/test/support/auth/test_entity.ex
Normal file
18
apps/api_key_mgmt/test/support/auth/test_entity.ex
Normal 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
|
15
apps/api_key_mgmt/test/test_helper.exs
Normal file
15
apps/api_key_mgmt/test/test_helper.exs
Normal 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()
|
5
apps/bouncer/coveralls.json
Normal file
5
apps/bouncer/coveralls.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"coverage_options": {
|
||||
"minimum_coverage": 90
|
||||
}
|
||||
}
|
46
apps/bouncer/lib/bouncer.ex
Normal file
46
apps/bouncer/lib/bouncer.ex
Normal 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
|
25
apps/bouncer/lib/bouncer/client.ex
Normal file
25
apps/bouncer/lib/bouncer/client.ex
Normal 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
|
21
apps/bouncer/lib/bouncer/client/woody.ex
Normal file
21
apps/bouncer/lib/bouncer/client/woody.ex
Normal 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
|
225
apps/bouncer/lib/bouncer/context_fragment_builder.ex
Normal file
225
apps/bouncer/lib/bouncer/context_fragment_builder.ex
Normal 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
|
78
apps/bouncer/lib/bouncer/context_fragment_builder/helper.ex
Normal file
78
apps/bouncer/lib/bouncer/context_fragment_builder/helper.ex
Normal 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
65
apps/bouncer/mix.exs
Normal 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
|
82
apps/bouncer/test/bouncer/client/woody_test.exs
Normal file
82
apps/bouncer/test/bouncer/client/woody_test.exs
Normal 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
|
35
apps/bouncer/test/bouncer/context_fragment_builder_test.exs
Normal file
35
apps/bouncer/test/bouncer/context_fragment_builder_test.exs
Normal 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
|
64
apps/bouncer/test/bouncer_test.exs
Normal file
64
apps/bouncer/test/bouncer_test.exs
Normal 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
|
139
apps/bouncer/test/support/helper.ex
Normal file
139
apps/bouncer/test/support/helper.ex
Normal 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
|
4
apps/bouncer/test/test_helper.exs
Normal file
4
apps/bouncer/test/test_helper.exs
Normal file
@ -0,0 +1,4 @@
|
||||
Mox.defmock(Bouncer.MockClient, for: Bouncer.Client)
|
||||
Application.put_env(:bouncer, :client_impl, Bouncer.MockClient)
|
||||
|
||||
ExUnit.start()
|
5
apps/org_management/coveralls.json
Normal file
5
apps/org_management/coveralls.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"coverage_options": {
|
||||
"minimum_coverage": 90
|
||||
}
|
||||
}
|
19
apps/org_management/lib/org_management.ex
Normal file
19
apps/org_management/lib/org_management.ex
Normal 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
|
24
apps/org_management/lib/org_management/client.ex
Normal file
24
apps/org_management/lib/org_management/client.ex
Normal 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
|
21
apps/org_management/lib/org_management/client/woody.ex
Normal file
21
apps/org_management/lib/org_management/client/woody.ex
Normal 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
|
67
apps/org_management/mix.exs
Normal file
67
apps/org_management/mix.exs
Normal 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
|
@ -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
|
32
apps/org_management/test/org_management_test.exs
Normal file
32
apps/org_management/test/org_management_test.exs
Normal 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
|
4
apps/org_management/test/test_helper.exs
Normal file
4
apps/org_management/test/test_helper.exs
Normal file
@ -0,0 +1,4 @@
|
||||
Mox.defmock(OrgManagement.MockClient, for: OrgManagement.Client)
|
||||
Application.put_env(:org_management, :client_impl, OrgManagement.MockClient)
|
||||
|
||||
ExUnit.start()
|
5
apps/token_keeper/coveralls.json
Normal file
5
apps/token_keeper/coveralls.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"coverage_options": {
|
||||
"minimum_coverage": 90
|
||||
}
|
||||
}
|
46
apps/token_keeper/lib/token_keeper/authenticator.ex
Normal file
46
apps/token_keeper/lib/token_keeper/authenticator.ex
Normal 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
|
60
apps/token_keeper/lib/token_keeper/authenticator/client.ex
Normal file
60
apps/token_keeper/lib/token_keeper/authenticator/client.ex
Normal 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
|
@ -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
|
59
apps/token_keeper/lib/token_keeper/authority.ex
Normal file
59
apps/token_keeper/lib/token_keeper/authority.ex
Normal 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
|
63
apps/token_keeper/lib/token_keeper/authority/client.ex
Normal file
63
apps/token_keeper/lib/token_keeper/authority/client.ex
Normal 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
|
52
apps/token_keeper/lib/token_keeper/authority/client/woody.ex
Normal file
52
apps/token_keeper/lib/token_keeper/authority/client/woody.ex
Normal 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
|
110
apps/token_keeper/lib/token_keeper/identity.ex
Normal file
110
apps/token_keeper/lib/token_keeper/identity.ex
Normal 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
68
apps/token_keeper/mix.exs
Normal 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
|
70
apps/token_keeper/test/support/authenticator/woody_mock.ex
Normal file
70
apps/token_keeper/test/support/authenticator/woody_mock.ex
Normal 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
|
38
apps/token_keeper/test/support/helper.ex
Normal file
38
apps/token_keeper/test/support/helper.ex
Normal 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
|
7
apps/token_keeper/test/test_helper.exs
Normal file
7
apps/token_keeper/test/test_helper.exs
Normal 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()
|
@ -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
|
70
apps/token_keeper/test/token_keeper/authenticator_test.exs
Normal file
70
apps/token_keeper/test/token_keeper/authenticator_test.exs
Normal 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
|
@ -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
|
128
apps/token_keeper/test/token_keeper/authority_test.exs
Normal file
128
apps/token_keeper/test/token_keeper/authority_test.exs
Normal 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
|
194
apps/token_keeper/test/token_keeper/identity_test.exs
Normal file
194
apps/token_keeper/test/token_keeper/identity_test.exs
Normal 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
33
compose.yaml
Normal 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
9
config/config.exs
Normal 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
1
config/dev.exs
Normal file
@ -0,0 +1 @@
|
||||
import Config
|
6
config/prod.exs
Normal file
6
config/prod.exs
Normal 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
54
config/runtime.exs
Normal 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
10
config/test.exs
Normal 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
34
mix.exs
Normal 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
50
mix.lock
Normal 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"]},
|
||||
}
|
Loading…
Reference in New Issue
Block a user