From 8aed3646dea454c066c6e464ade7d593866c710c Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Sat, 12 Sep 2020 05:01:45 -0700 Subject: [PATCH 1/2] Initial KeyVaultProxy solution Ported with permission from @heaths from https://github.com/heaths/KeyVaultProxy. Builds both within and without the Azure/azure-sdk-for-net repo. --- .gitattributes | 1 + .../keyvaultproxy/.devcontainer/Dockerfile | 49 +++++ .../.devcontainer/devcontainer.json | 16 ++ .../library-scripts/common-debian.sh | 171 +++++++++++++++++ .../library-scripts/node-debian.sh | 102 ++++++++++ .../samples/keyvaultproxy/.editorconfig | 25 +++ sdk/keyvault/samples/keyvaultproxy/.gitignore | 4 + .../keyvaultproxy/.vscode/extensions.json | 6 + .../samples/keyvaultproxy/.vscode/launch.json | 24 +++ .../samples/keyvaultproxy/.vscode/tasks.json | 28 +++ .../keyvaultproxy/Directory.Build.props | 23 +++ .../keyvaultproxy/Directory.Build.targets | 13 ++ .../samples/keyvaultproxy/KeyVaultProxy.sln | 51 +++++ sdk/keyvault/samples/keyvaultproxy/README.md | 61 ++++++ ...zureSamples.Security.KeyVault.Proxy.csproj | 19 ++ .../samples/keyvaultproxy/src/Cache.cs | 100 ++++++++++ .../keyvaultproxy/src/CachedResponse.cs | 124 +++++++++++++ .../keyvaultproxy/src/KeyVaultProxy.cs | 110 +++++++++++ .../src/Properties/AssemblyInfo.cs | 10 + ...mples.Security.KeyVault.Proxy.Tests.csproj | 22 +++ .../tests/KeyVaultProxyTests.Live.cs | 139 ++++++++++++++ .../keyvaultproxy/tests/KeyVaultProxyTests.cs | 57 ++++++ .../keyvaultproxy/tests/LiveFactAttribute.cs | 30 +++ .../keyvaultproxy/tests/LiveFactDiscoverer.cs | 82 +++++++++ .../keyvaultproxy/tests/SecretsFixture.cs | 174 ++++++++++++++++++ .../tests/TestFramework/AsyncGate.cs | 75 ++++++++ .../TestFramework/AsyncValidatingStream.cs | 98 ++++++++++ .../tests/TestFramework/MockRequest.cs | 95 ++++++++++ .../tests/TestFramework/MockResponse.cs | 88 +++++++++ .../tests/TestFramework/MockTransport.cs | 87 +++++++++ .../tests/TestFramework/TaskExtensions.cs | 83 +++++++++ 31 files changed, 1967 insertions(+) create mode 100644 sdk/keyvault/samples/keyvaultproxy/.devcontainer/Dockerfile create mode 100644 sdk/keyvault/samples/keyvaultproxy/.devcontainer/devcontainer.json create mode 100644 sdk/keyvault/samples/keyvaultproxy/.devcontainer/library-scripts/common-debian.sh create mode 100644 sdk/keyvault/samples/keyvaultproxy/.devcontainer/library-scripts/node-debian.sh create mode 100644 sdk/keyvault/samples/keyvaultproxy/.editorconfig create mode 100644 sdk/keyvault/samples/keyvaultproxy/.gitignore create mode 100644 sdk/keyvault/samples/keyvaultproxy/.vscode/extensions.json create mode 100644 sdk/keyvault/samples/keyvaultproxy/.vscode/launch.json create mode 100644 sdk/keyvault/samples/keyvaultproxy/.vscode/tasks.json create mode 100644 sdk/keyvault/samples/keyvaultproxy/Directory.Build.props create mode 100644 sdk/keyvault/samples/keyvaultproxy/Directory.Build.targets create mode 100644 sdk/keyvault/samples/keyvaultproxy/KeyVaultProxy.sln create mode 100644 sdk/keyvault/samples/keyvaultproxy/README.md create mode 100644 sdk/keyvault/samples/keyvaultproxy/src/AzureSamples.Security.KeyVault.Proxy.csproj create mode 100644 sdk/keyvault/samples/keyvaultproxy/src/Cache.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/src/CachedResponse.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/src/KeyVaultProxy.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/src/Properties/AssemblyInfo.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/tests/AzureSamples.Security.KeyVault.Proxy.Tests.csproj create mode 100644 sdk/keyvault/samples/keyvaultproxy/tests/KeyVaultProxyTests.Live.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/tests/KeyVaultProxyTests.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/tests/LiveFactAttribute.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/tests/LiveFactDiscoverer.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/tests/SecretsFixture.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/AsyncGate.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/AsyncValidatingStream.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/MockRequest.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/MockResponse.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/MockTransport.cs create mode 100644 sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/TaskExtensions.cs diff --git a/.gitattributes b/.gitattributes index 457b38066ff4..048f34790b7c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,6 +15,7 @@ *.cs text diff=csharp *.csproj text=auto *.sln text=auto eol=crlf +*.sh text=auto eol=lf # Automatically collapse Track 2 test recordings in github PRs **/SessionRecords/**/*.json linguist-generated=true diff --git a/sdk/keyvault/samples/keyvaultproxy/.devcontainer/Dockerfile b/sdk/keyvault/samples/keyvaultproxy/.devcontainer/Dockerfile new file mode 100644 index 000000000000..a2706ad4788e --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/.devcontainer/Dockerfile @@ -0,0 +1,49 @@ +# Update the VARIANT arg in devcontainer.json to pick a .NET Core version: 3.1-bionic, 2.1-bionic +ARG VARIANT="3.1-bionic" +FROM mcr.microsoft.com/dotnet/core/sdk:${VARIANT} + +# Options for setup script +ARG INSTALL_ZSH="false" +ARG UPGRADE_PACKAGES="false" +ARG USERNAME=vscode +ARG USER_UID=1000 +ARG USER_GID=$USER_UID + +# Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies. +COPY library-scripts/common-debian.sh /tmp/library-scripts/ +RUN apt-get update \ + && /bin/bash /tmp/library-scripts/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" \ + && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* && rm -rf /tmp/library-scripts + +# [Optional] Install Node.js for use with web applications - update the INSTALL_NODE arg in devcontainer.json to enable. +ARG INSTALL_NODE="false" +ARG NODE_VERSION="lts/*" +ENV NVM_DIR=/usr/local/share/nvm +ENV NVM_SYMLINK_CURRENT=true \ + PATH=${NVM_DIR}/current/bin:${PATH} +COPY library-scripts/node-debian.sh /tmp/library-scripts/ +RUN if [ "$INSTALL_NODE" = "true" ]; then /bin/bash /tmp/library-scripts/node-debian.sh "${NVM_DIR}" "${NODE_VERSION}" "${USERNAME}"; fi \ + && rm -rf /var/lib/apt/lists/* /tmp/library-scripts + +# [Optional] Install the Azure CLI - update the INSTALL_AZURE_CLI arg in devcontainer.json to enable. +ARG INSTALL_AZURE_CLI="false" +RUN if [ "$INSTALL_AZURE_CLI" = "true" ]; then \ + echo "deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/azure-cli.list \ + && curl -sL https://packages.microsoft.com/keys/microsoft.asc | apt-key add - 2>/dev/null \ + && apt-get update \ + && apt-get install -y azure-cli \ + && rm -rf /var/lib/apt/lists/*; \ + fi + +# Install .NET 2.1 runtime and clean up +RUN export DEBIAN_FRONTEND=noninteractive \ + && wget https://packages.microsoft.com/config/ubuntu/18.04/packages-microsoft-prod.deb -O /tmp/packages-microsoft-prod.deb \ + && dpkg -i /tmp/packages-microsoft-prod.deb \ + && rm -f /tmp/packages-microsoft-prod.deb \ + && apt-get update \ + && apt-get install -y apt-transport-https \ + && apt-get update \ + && apt-get install -y dotnet-runtime-2.1 \ + && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* diff --git a/sdk/keyvault/samples/keyvaultproxy/.devcontainer/devcontainer.json b/sdk/keyvault/samples/keyvaultproxy/.devcontainer/devcontainer.json new file mode 100644 index 000000000000..58b43f941d63 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/.devcontainer/devcontainer.json @@ -0,0 +1,16 @@ +{ + "name": "Azure SDK Samples for .NET", + "build": { + "dockerfile": "Dockerfile", + "args": { + "VARIANT": "3.1-bionic" + } + }, + "settings": { + "terminal.integrated.shell.linux": "/bin/bash" + }, + "extensions": [ + "editorconfig.editorconfig", + "ms-dotnettools.csharp" + ] +} diff --git a/sdk/keyvault/samples/keyvaultproxy/.devcontainer/library-scripts/common-debian.sh b/sdk/keyvault/samples/keyvaultproxy/.devcontainer/library-scripts/common-debian.sh new file mode 100644 index 000000000000..fe6423ced0a3 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/.devcontainer/library-scripts/common-debian.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +# Syntax: ./common-debian.sh [install zsh flag] [username] [user UID] [user GID] [upgrade packages flag] + +INSTALL_ZSH=${1:-"true"} +USERNAME=${2:-"vscode"} +USER_UID=${3:-1000} +USER_GID=${4:-1000} +UPGRADE_PACKAGES=${5:-"true"} + +set -e + +if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run a root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.' + exit 1 +fi + +# Treat a user name of "none" as root +if [ "${USERNAME}" = "none" ] || [ "${USERNAME}" = "root" ]; then + USERNAME=root + USER_UID=0 + USER_GID=0 +fi + +# Load markers to see which steps have already run +MARKER_FILE="/usr/local/etc/vscode-dev-containers/common" +if [ -f "${MARKER_FILE}" ]; then + echo "Marker file found:" + cat "${MARKER_FILE}" + source "${MARKER_FILE}" +fi + +# Ensure apt is in non-interactive to avoid prompts +export DEBIAN_FRONTEND=noninteractive + +# Function to call apt-get if needed +apt-get-update-if-needed() +{ + if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then + echo "Running apt-get update..." + apt-get update + else + echo "Skipping apt-get update." + fi +} + +# Run install apt-utils to avoid debconf warning then verify presence of other common developer tools and dependencies +if [ "${PACKAGES_ALREADY_INSTALLED}" != "true" ]; then + apt-get-update-if-needed + + PACKAGE_LIST="apt-utils \ + git \ + openssh-client \ + less \ + iproute2 \ + procps \ + curl \ + wget \ + unzip \ + nano \ + jq \ + lsb-release \ + ca-certificates \ + apt-transport-https \ + dialog \ + gnupg2 \ + libc6 \ + libgcc1 \ + libgssapi-krb5-2 \ + libicu[0-9][0-9] \ + liblttng-ust0 \ + libstdc++6 \ + zlib1g \ + locales \ + sudo" + + # Install libssl1.1 if available + if [[ ! -z $(apt-cache --names-only search ^libssl1.1$) ]]; then + PACKAGE_LIST="${PACKAGE_LIST} libssl1.1" + fi + + # Install appropriate version of libssl1.0.x if available + LIBSSL=$(dpkg-query -f '${db:Status-Abbrev}\t${binary:Package}\n' -W 'libssl1\.0\.?' 2>&1 || echo '') + if [ "$(echo "$LIBSSL" | grep -o 'libssl1\.0\.[0-9]:' | uniq | sort | wc -l)" -eq 0 ]; then + if [[ ! -z $(apt-cache --names-only search ^libssl1.0.2$) ]]; then + # Debian 9 + PACKAGE_LIST="${PACKAGE_LIST} libssl1.0.2" + elif [[ ! -z $(apt-cache --names-only search ^libssl1.0.0$) ]]; then + # Ubuntu 18.04, 16.04, earlier + PACKAGE_LIST="${PACKAGE_LIST} libssl1.0.0" + fi + fi + + echo "Packages to verify are installed: ${PACKAGE_LIST}" + apt-get -y install --no-install-recommends ${PACKAGE_LIST} 2> >( grep -v 'debconf: delaying package configuration, since apt-utils is not installed' >&2 ) + + PACKAGES_ALREADY_INSTALLED="true" +fi + +# Get to latest versions of all packages +if [ "${UPGRADE_PACKAGES}" = "true" ]; then + apt-get-update-if-needed + apt-get -y upgrade --no-install-recommends + apt-get autoremove -y +fi + +# Ensure at least the en_US.UTF-8 UTF-8 locale is available. +# Common need for both applications and things like the agnoster ZSH theme. +if [ "${LOCALE_ALREADY_SET}" != "true" ]; then + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen + locale-gen + LOCALE_ALREADY_SET="true" +fi + +# Create or update a non-root user to match UID/GID - see https://aka.ms/vscode-remote/containers/non-root-user. +if id -u $USERNAME > /dev/null 2>&1; then + # User exists, update if needed + if [ "$USER_GID" != "$(id -G $USERNAME)" ]; then + groupmod --gid $USER_GID $USERNAME + usermod --gid $USER_GID $USERNAME + fi + if [ "$USER_UID" != "$(id -u $USERNAME)" ]; then + usermod --uid $USER_UID $USERNAME + fi +else + # Create user + groupadd --gid $USER_GID $USERNAME + useradd -s /bin/bash --uid $USER_UID --gid $USER_GID -m $USERNAME +fi + +# Add add sudo support for non-root user +if [ "${EXISTING_NON_ROOT_USER}" != "${USERNAME}" ]; then + echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME + chmod 0440 /etc/sudoers.d/$USERNAME + EXISTING_NON_ROOT_USER="${USERNAME}" +fi + +# Ensure ~/.local/bin is in the PATH for root and non-root users for bash. (zsh is later) +if [ "${DOT_LOCAL_ALREADY_ADDED}" != "true" ]; then + echo "export PATH=\$PATH:\$HOME/.local/bin" | tee -a /root/.bashrc >> /home/$USERNAME/.bashrc + chown $USER_UID:$USER_GID /home/$USERNAME/.bashrc + DOT_LOCAL_ALREADY_ADDED="true" +fi + +# Optionally install and configure zsh +if [ "${INSTALL_ZSH}" = "true" ] && [ ! -d "/root/.oh-my-zsh" ] && [ "${ZSH_ALREADY_INSTALLED}" != "true" ]; then + apt-get-update-if-needed + apt-get install -y zsh + curl -fsSLo- https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh | bash 2>&1 + echo "export PATH=\$PATH:\$HOME/.local/bin" >> /root/.zshrc + if [ "${USERNAME}" != "root" ]; then + cp -fR /root/.oh-my-zsh /home/$USERNAME + cp -f /root/.zshrc /home/$USERNAME + sed -i -e "s/\/root\/.oh-my-zsh/\/home\/$USERNAME\/.oh-my-zsh/g" /home/$USERNAME/.zshrc + chown -R $USER_UID:$USER_GID /home/$USERNAME/.oh-my-zsh /home/$USERNAME/.zshrc + fi + ZSH_ALREADY_INSTALLED="true" +fi + +# Write marker file +mkdir -p "$(dirname "${MARKER_FILE}")" +echo -e "\ + PACKAGES_ALREADY_INSTALLED=${PACKAGES_ALREADY_INSTALLED}\n\ + LOCALE_ALREADY_SET=${LOCALE_ALREADY_SET}\n\ + EXISTING_NON_ROOT_USER=${EXISTING_NON_ROOT_USER}\n\ + DOT_LOCAL_ALREADY_ADDED=${DOT_LOCAL_ALREADY_ADDED}\n\ + ZSH_ALREADY_INSTALLED=${ZSH_ALREADY_INSTALLED}" > "${MARKER_FILE}" diff --git a/sdk/keyvault/samples/keyvaultproxy/.devcontainer/library-scripts/node-debian.sh b/sdk/keyvault/samples/keyvaultproxy/.devcontainer/library-scripts/node-debian.sh new file mode 100644 index 000000000000..73b454add7ce --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/.devcontainer/library-scripts/node-debian.sh @@ -0,0 +1,102 @@ +#!/bin/bash +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +# Syntax: ./node-debian.sh + +export NVM_DIR=${1:-"/usr/local/share/nvm"} +export NODE_VERSION=${2:-"lts/*"} +USERNAME=${3:-"vscode"} + +set -e + +if [ "$(id -u)" -ne 0 ]; then + echo -e 'Script must be run a root. Use sudo, su, or add "USER root" to\nyour Dockerfile before running this script.' + exit 1 +fi + +# Ensure apt is in non-interactive to avoid prompts +export DEBIAN_FRONTEND=noninteractive + +# Install curl, apt-get dependencies if missing +if ! type curl > /dev/null 2>&1; then + if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then + apt-get update + fi + apt-get -y install --no-install-recommends apt-transport-https ca-certificates curl gnupg2 +fi + +# Treat a user name of "none" as root +if [ "${USERNAME}" = "none" ]; then + USERNAME=root +fi + +if [ "${NODE_VERSION}" = "none" ]; then + export NODE_VERSION= +fi + +# Install yarn +if type yarn > /dev/null 2>&1; then + echo "Yarn already installed." +else + curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - 2>/dev/null + echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list + apt-get update + apt-get -y install --no-install-recommends yarn +fi + +# Install the specified node version if NVM directory already exists, then exit +if [ -d "${NVM_DIR}" ]; then + echo "NVM already installed." + if [ "${NODE_VERSION}" != "" ]; then + suIf "nvm install ${NODE_VERSION}" + fi + exit 0 +fi + +mkdir -p ${NVM_DIR} + +# Set up non-root user if applicable +if [ "${USERNAME}" != "root" ] && id -u $USERNAME > /dev/null 2>&1; then + tee -a /home/${USERNAME}/.bashrc /home/${USERNAME}/.zshrc >> /root/.zshrc \ +<< EOF +EOF + + # Add NVM init and add code to update NVM ownership if UID/GID changes + tee -a /root/.bashrc /root/.zshrc /home/${USERNAME}/.bashrc >> /home/${USERNAME}/.zshrc \ +< /dev/null 2>&1; then + su ${USERNAME} -c "$@" + else + "$@" + fi + +} + +# Run NVM installer as non-root if needed +suIf "$(cat \ +<< EOF + curl -so- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash + if [ "${NODE_VERSION}" != "" ]; then + source $NVM_DIR/nvm.sh + nvm alias default ${NODE_VERSION} + fi +EOF +)" 2>&1 + diff --git a/sdk/keyvault/samples/keyvaultproxy/.editorconfig b/sdk/keyvault/samples/keyvaultproxy/.editorconfig new file mode 100644 index 000000000000..94fe19752e21 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/.editorconfig @@ -0,0 +1,25 @@ +root = true + +[*] +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.cs] +# Require explicit types for samples. +csharp_style_var_for_built_in_types = false:error +csharp_style_var_when_type_is_apparent = false:error +csharp_style_var_elsewhere = false:error + +[*.{csproj,props,targets}] +indent_size = 2 + +[*.json] +indent_size = 2 + +[*.sh] +end_of_line = lf + +[*.{yml,yaml}] +indent_size = 2 diff --git a/sdk/keyvault/samples/keyvaultproxy/.gitignore b/sdk/keyvault/samples/keyvaultproxy/.gitignore new file mode 100644 index 000000000000..f16d9705507b --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +!.vscode/ +!.vscode/launch.json diff --git a/sdk/keyvault/samples/keyvaultproxy/.vscode/extensions.json b/sdk/keyvault/samples/keyvaultproxy/.vscode/extensions.json new file mode 100644 index 000000000000..5bae4cdfa6a5 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "editorconfig.editorconfig", + "ms-dotnettools.csharp" + ] +} diff --git a/sdk/keyvault/samples/keyvaultproxy/.vscode/launch.json b/sdk/keyvault/samples/keyvaultproxy/.vscode/launch.json new file mode 100644 index 000000000000..dd984bd6d756 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Tests (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "dotnet", + "args": [ + "test" + ], + "cwd": "${workspaceFolder}/tests", + "console": "internalConsole", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" + } + ] +} diff --git a/sdk/keyvault/samples/keyvaultproxy/.vscode/tasks.json b/sdk/keyvault/samples/keyvaultproxy/.vscode/tasks.json new file mode 100644 index 000000000000..262a03b69741 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/.vscode/tasks.json @@ -0,0 +1,28 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/sdk/keyvault/samples/keyvaultproxy/Directory.Build.props b/sdk/keyvault/samples/keyvaultproxy/Directory.Build.props new file mode 100644 index 000000000000..6657546dd532 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/Directory.Build.props @@ -0,0 +1,23 @@ + + + true + + + + + + AzureSamples.Security.KeyVault.Proxy + latest + + $(NoWarn); + AZC0001 + + false + + + + + STRONGNAME_SIGNED + + diff --git a/sdk/keyvault/samples/keyvaultproxy/Directory.Build.targets b/sdk/keyvault/samples/keyvaultproxy/Directory.Build.targets new file mode 100644 index 000000000000..b5c8eca2cb47 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/Directory.Build.targets @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/sdk/keyvault/samples/keyvaultproxy/KeyVaultProxy.sln b/sdk/keyvault/samples/keyvaultproxy/KeyVaultProxy.sln new file mode 100644 index 000000000000..3fcd0acf0578 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/KeyVaultProxy.sln @@ -0,0 +1,51 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30011.22 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureSamples.Security.KeyVault.Proxy", "src\AzureSamples.Security.KeyVault.Proxy.csproj", "{FA43C911-4EAB-4D84-AA0D-128A8242E8CD}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureSamples.Security.KeyVault.Proxy.Tests", "tests\AzureSamples.Security.KeyVault.Proxy.Tests.csproj", "{B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FA43C911-4EAB-4D84-AA0D-128A8242E8CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FA43C911-4EAB-4D84-AA0D-128A8242E8CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FA43C911-4EAB-4D84-AA0D-128A8242E8CD}.Debug|x64.ActiveCfg = Debug|Any CPU + {FA43C911-4EAB-4D84-AA0D-128A8242E8CD}.Debug|x64.Build.0 = Debug|Any CPU + {FA43C911-4EAB-4D84-AA0D-128A8242E8CD}.Debug|x86.ActiveCfg = Debug|Any CPU + {FA43C911-4EAB-4D84-AA0D-128A8242E8CD}.Debug|x86.Build.0 = Debug|Any CPU + {FA43C911-4EAB-4D84-AA0D-128A8242E8CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FA43C911-4EAB-4D84-AA0D-128A8242E8CD}.Release|Any CPU.Build.0 = Release|Any CPU + {FA43C911-4EAB-4D84-AA0D-128A8242E8CD}.Release|x64.ActiveCfg = Release|Any CPU + {FA43C911-4EAB-4D84-AA0D-128A8242E8CD}.Release|x64.Build.0 = Release|Any CPU + {FA43C911-4EAB-4D84-AA0D-128A8242E8CD}.Release|x86.ActiveCfg = Release|Any CPU + {FA43C911-4EAB-4D84-AA0D-128A8242E8CD}.Release|x86.Build.0 = Release|Any CPU + {B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}.Debug|x64.Build.0 = Debug|Any CPU + {B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}.Debug|x86.Build.0 = Debug|Any CPU + {B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}.Release|Any CPU.Build.0 = Release|Any CPU + {B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}.Release|x64.ActiveCfg = Release|Any CPU + {B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}.Release|x64.Build.0 = Release|Any CPU + {B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}.Release|x86.ActiveCfg = Release|Any CPU + {B1DBCC52-CD7E-44B4-9CE2-CC04379AB9B0}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {EE12D0F6-D523-4BCB-B622-2E3AD3AF305F} + EndGlobalSection +EndGlobal diff --git a/sdk/keyvault/samples/keyvaultproxy/README.md b/sdk/keyvault/samples/keyvaultproxy/README.md new file mode 100644 index 000000000000..55dbcd01ca2b --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/README.md @@ -0,0 +1,61 @@ +--- +page_type: sample +languages: +- csharp +products: +- azure +- azure-key-vault +urlFragment: keyvaultproxy +name: Cache certain responses from Key Vault +description: Shows how to implement a pipeline policy to cache certain responses from Key Vault to mitigate rate limiting. +--- + +# Azure Key Vault Proxy + +This is a sample showing how to use an `HttpPipelinePolicy` to cache and proxy secrets, keys, and certificates from Azure Key Vault. The [Azure.Core](https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/README.md) packages provides a number of useful HTTP pipeline policies like configurable retries, logging, and more; and, you can add your own policies. + +## Getting started + +To use this sample, you will need to install the [Azure.Core](https://nuget.org/packages/Azure.Core) package, which is installed automatically when installing any of the Azure Key Vault packages: + +* [Azure.Security.KeyVault.Certificates](https://nuget.org/packages/Azure.Security.KeyVault.Certificates) +* [Azure.Security.KeyVault.Keys](https://nuget.org/packages/Azure.Security.KeyVault.Keys) +* [Azure.Security.KeyVault.Secrets](https://nuget.org/packages/Azure.Security.KeyVault.Secrets) + +Once you build this project, you can reference this sample in your own project by either: + +* Adding a `` to this sample project in your own project, or +* Running `dotnet pack` on this sample project, publish it to a private NuGet source, and add a `` to `AzureSamples.Security.KeyVault.Proxy`. + +After you reference this sample, in your own project source, add the following: + +```csharp +using AzureSamples.Security.KeyVault.Proxy; +``` + +## Examples + +All HTTP clients for Azure.* packages allow you to customize the HTTP pipeline using their respective client options classes, such as the `SecretClientOptions` class below: + +```csharp +SecretClientOptions options = new SecretClientOptions(); +options.AddPolicy(new KeyVaultProxy(), HttpPipelinePosition.PerCall); + +SecretClient client = new SecretClient( + new Uri("https://myvault.vault.azure.net"), + new DefaultAzureCredential(), + options); +``` + +Whenever you make a call to a resource with given a unique URI, it will be cached, by default, for 1 hour. You can change the default time-to-live (TTL) like so: + +```csharp +SecretClientOptions options = new SecretClientOptions(); +options.AddPolicy(new KeyVaultProxy(TimeSpan.FromSeconds(30)), HttpPipelinePosition.PerCall); +``` + +When the resource has expired, the next request will go to the server and a successful `GET` response for certificates, keys, or secrets will be cached. + +## License + +This project is licensed under the [MIT license](https://github.com/Azure/azure-sdk-for-net/blob/master/LICENSE.txt). diff --git a/sdk/keyvault/samples/keyvaultproxy/src/AzureSamples.Security.KeyVault.Proxy.csproj b/sdk/keyvault/samples/keyvaultproxy/src/AzureSamples.Security.KeyVault.Proxy.csproj new file mode 100644 index 000000000000..5f1915bdadbf --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/src/AzureSamples.Security.KeyVault.Proxy.csproj @@ -0,0 +1,19 @@ + + + + 1.0.0 + netstandard2.0 + enable + Microsoft;azure-sdk + A sample pipeline policy to cache secrets, keys, and certificates in-memory. + MIT + https://github.com/Azure/azure-sdk-for-net/tree/master/sdk/keyvault/samples/keyvaultproxy + true + true + + + + + + + diff --git a/sdk/keyvault/samples/keyvaultproxy/src/Cache.cs b/sdk/keyvault/samples/keyvaultproxy/src/Cache.cs new file mode 100644 index 000000000000..f4e212368a9f --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/src/Cache.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Azure; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace AzureSamples.Security.KeyVault.Proxy +{ + /// + /// Maintains a cache of items. + /// + internal class Cache : IDisposable + { + private readonly Dictionary _cache = new Dictionary(StringComparer.OrdinalIgnoreCase); + private SemaphoreSlim? _lock = new SemaphoreSlim(1, 1); + + /// + public void Dispose() + { + if (_lock is {}) + { + _lock.Dispose(); + _lock = null; + } + } + + /// + /// Gets a valid or requests and caches a . + /// + /// Whether certain operations should be performed asynchronously. + /// The URI sans query parameters to cache. + /// The amount of time for which the cached item is valid. + /// The action to request a response. + /// A new . + internal async ValueTask GetOrAddAsync(bool isAsync, string uri, TimeSpan ttl, Func> action) + { + ThrowIfDisposed(); + + if (isAsync) + { + await _lock!.WaitAsync().ConfigureAwait(false); + } + else + { + _lock!.Wait(); + } + + try + { + // Try to get a valid cached response inside the lock before fetching. + if (_cache.TryGetValue(uri, out CachedResponse cachedResponse) && cachedResponse.IsValid) + { + return await cachedResponse.CloneAsync(isAsync).ConfigureAwait(false); + } + + Response response = await action().ConfigureAwait(false); + if (response.Status == 200 && response.ContentStream is { }) + { + cachedResponse = await CachedResponse.CreateAsync(isAsync, response, ttl).ConfigureAwait(false); + _cache[uri] = cachedResponse; + } + + return response; + } + finally + { + _lock.Release(); + } + } + + /// + /// Clears the cache. + /// + internal void Clear() + { + ThrowIfDisposed(); + + _lock!.Wait(); + try + { + _cache.Clear(); + } + finally + { + _lock.Release(); + } + } + + private void ThrowIfDisposed() + { + if (_lock is null) + { + throw new ObjectDisposedException(nameof(_lock)); + } + } + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/src/CachedResponse.cs b/sdk/keyvault/samples/keyvaultproxy/src/CachedResponse.cs new file mode 100644 index 000000000000..4b84fbb42210 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/src/CachedResponse.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable disable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Azure; +using Azure.Core; + +namespace AzureSamples.Security.KeyVault.Proxy +{ + /// + /// A cached that is cloned and returned for subsequent requests. + /// + internal class CachedResponse : Response + { + private readonly ResponseHeaders _headers; + private DateTimeOffset _expires; + + private CachedResponse(int status, string reasonPhrase, ResponseHeaders headers) + { + Status = status; + ReasonPhrase = reasonPhrase; + + _headers = headers; + } + + /// + public override int Status { get; } + + /// + public override string ReasonPhrase { get; } + + /// + public override Stream ContentStream { get; set; } + + /// + public override string ClientRequestId { get; set; } + + /// + /// Gets a value indicating whether this is still valid (has not expired). + /// + internal bool IsValid => DateTimeOffset.Now <= _expires; + + /// + public override void Dispose() => ContentStream?.Dispose(); + + /// + /// Creates a new . + /// + /// Whether to copy the asynchronously. + /// The to copy. + /// The time to live. + /// A copied from the . + internal static async ValueTask CreateAsync(bool isAsync, Response response, TimeSpan ttl) + { + CachedResponse cachedResponse = await CloneAsync(isAsync, response).ConfigureAwait(false); + cachedResponse._expires = DateTimeOffset.Now + ttl; + + return cachedResponse; + } + + /// + /// Clones this into a new . + /// + /// Whether to copy the asynchronously. + /// A cloned . + internal async ValueTask CloneAsync(bool isAsync) => + await CloneAsync(isAsync, this).ConfigureAwait(false); + + /// + protected override bool ContainsHeader(string name) => _headers.Contains(name); + + /// + protected override IEnumerable EnumerateHeaders() => _headers; + + /// + protected override bool TryGetHeader(string name, out string value) => _headers.TryGetValue(name, out value); + + /// + protected override bool TryGetHeaderValues(string name, out IEnumerable values) => _headers.TryGetValues(name, out values); + + private static async ValueTask CloneAsync(bool isAsync, Response response) + { + CachedResponse cachedResponse = new CachedResponse(response.Status, response.ReasonPhrase, response.Headers) + { + ClientRequestId = response.ClientRequestId, + }; + + if (response.ContentStream is { }) + { + MemoryStream ms = new MemoryStream(); + cachedResponse.ContentStream = ms; + + if (isAsync) + { + await response.ContentStream.CopyToAsync(cachedResponse.ContentStream).ConfigureAwait(false); + } + else + { + response.ContentStream.CopyTo(cachedResponse.ContentStream); + } + + ms.Position = 0; + + // Reset the position if we can; otherwise, copy the buffer. + if (response.ContentStream.CanSeek) + { + response.ContentStream.Position = 0; + } + else + { + response.ContentStream = new MemoryStream(ms.ToArray()); + } + } + + return cachedResponse; + } + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/src/KeyVaultProxy.cs b/sdk/keyvault/samples/keyvaultproxy/src/KeyVaultProxy.cs new file mode 100644 index 000000000000..ddb19ccead5e --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/src/KeyVaultProxy.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Core.Pipeline; + +namespace AzureSamples.Security.KeyVault.Proxy +{ + /// + /// Cache GET requests for secrets, keys, or certificates for Azure Key Vault clients. + /// + public class KeyVaultProxy : HttpPipelinePolicy, IDisposable + { + private readonly Cache _cache; + + /// + /// Creates a new instance of the class. + /// + /// Optional time to live for cached responses. The default is 1 hour. + /// is less than 0. + public KeyVaultProxy(TimeSpan? ttl = null) + { + ttl ??= TimeSpan.FromHours(1); + if (ttl < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(ttl)); + } + + Ttl = ttl.Value; + _cache = new Cache(); + } + + /// + /// Gets the time to live for cached responses. + /// + public TimeSpan Ttl { get; internal set; } + + /// + /// Clears the in-memory cache. + /// + public void Clear() => _cache.Clear(); + + /// + public override void Process(HttpMessage message, ReadOnlyMemory pipeline) => +#pragma warning disable AZC0102 // TaskExtensions.EnsureCompleted() is not in scope + ProcessAsync(false, message, pipeline).GetAwaiter().GetResult(); +#pragma warning restore AZC0102 + + /// + public override async ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory pipeline) => + await ProcessAsync(true, message, pipeline).ConfigureAwait(false); + + internal static bool IsSupported(string uri) + { + // Find the beginning of the path component after the scheme. + int pos = uri.IndexOf('/', 8); + if (pos > 0) + { + uri = uri.Substring(pos); + return uri.StartsWith("/secrets/", StringComparison.OrdinalIgnoreCase) + || uri.StartsWith("/keys/", StringComparison.OrdinalIgnoreCase) + || uri.StartsWith("/certificates/", StringComparison.OrdinalIgnoreCase); + } + + return false; + } + + private async ValueTask ProcessAsync(bool isAsync, HttpMessage message, ReadOnlyMemory pipeline) + { + Request request = message.Request; + if (request.Method == RequestMethod.Get) + { + string uri = request.Uri.ToUri().GetLeftPart(UriPartial.Path); + if (IsSupported(uri)) + { + message.Response = await _cache.GetOrAddAsync(isAsync, uri, Ttl, async () => + { + await ProcessNextAsync(isAsync, message, pipeline).ConfigureAwait(false); + return message.Response; + }).ConfigureAwait(false); + + return; + } + } + + await ProcessNextAsync(isAsync, message, pipeline).ConfigureAwait(false); + } + + private static async ValueTask ProcessNextAsync(bool isAsync, HttpMessage message, ReadOnlyMemory pipeline) + { + if (isAsync) + { + await ProcessNextAsync(message, pipeline).ConfigureAwait(false); + } + else + { + ProcessNext(message, pipeline); + } + } + + /// + void IDisposable.Dispose() + { + _cache.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/src/Properties/AssemblyInfo.cs b/sdk/keyvault/samples/keyvaultproxy/src/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..286ffb28c801 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/src/Properties/AssemblyInfo.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +#if STRONGNAME_SIGNED +[assembly: InternalsVisibleTo("AzureSamples.Security.KeyVault.Proxy.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100d15ddcb29688295338af4b7686603fe614abd555e09efba8fb88ee09e1f7b1ccaeed2e8f823fa9eef3fdd60217fc012ea67d2479751a0b8c087a4185541b851bd8b16f8d91b840e51b1cb0ba6fe647997e57429265e85ef62d565db50a69ae1647d54d7bd855e4db3d8a91510e5bcbd0edfbbecaa20a7bd9ae74593daa7b11b4")] +#else +[assembly: InternalsVisibleTo("AzureSamples.Security.KeyVault.Proxy.Tests")] +#endif diff --git a/sdk/keyvault/samples/keyvaultproxy/tests/AzureSamples.Security.KeyVault.Proxy.Tests.csproj b/sdk/keyvault/samples/keyvaultproxy/tests/AzureSamples.Security.KeyVault.Proxy.Tests.csproj new file mode 100644 index 000000000000..3ffa6df4569b --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/tests/AzureSamples.Security.KeyVault.Proxy.Tests.csproj @@ -0,0 +1,22 @@ + + + + netcoreapp2.1 + $(TargetFrameworks);net461 + false + + + + + + + + + + + + + + + + diff --git a/sdk/keyvault/samples/keyvaultproxy/tests/KeyVaultProxyTests.Live.cs b/sdk/keyvault/samples/keyvaultproxy/tests/KeyVaultProxyTests.Live.cs new file mode 100644 index 000000000000..e6752b8c6e75 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/tests/KeyVaultProxyTests.Live.cs @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using System.Linq; +using System.Threading.Tasks; +using Azure; +using Azure.Core.Diagnostics; +using Azure.Security.KeyVault.Secrets; +using Xunit; + +namespace AzureSamples.Security.KeyVault.Proxy +{ + public partial class KeyVaultProxyTests : IClassFixture, IDisposable + { + private readonly SecretsFixture _fixture; + private readonly AzureEventSourceListener _logger; + + public KeyVaultProxyTests(SecretsFixture fixture) + { + _fixture = fixture ?? throw new ArgumentNullException(nameof(fixture)); + _logger = AzureEventSourceListener.CreateConsoleLogger(EventLevel.Verbose); + } + + public void Dispose() => _logger.Dispose(); + + [LiveFact] + public async Task CachesSameResponse(bool isAsync) + { + Response response; + if (isAsync) + { + response = await _fixture.Client.GetSecretAsync(_fixture.SecretName); + } + else + { + response = _fixture.Client.GetSecret(_fixture.SecretName); + } + + string clientRequestId = response.GetRawResponse().ClientRequestId; + if (isAsync) + { + response = await _fixture.Client.GetSecretAsync(_fixture.SecretName); + } + else + { + response = _fixture.Client.GetSecret(_fixture.SecretName); + } + + Assert.Equal(clientRequestId, response.GetRawResponse().ClientRequestId); + } + + [LiveFact] + public async Task CachesDifferentResponse(bool isAsync) + { + string getClientRequestId; + if (isAsync) + { + Response response = await _fixture.Client.GetSecretAsync(_fixture.SecretName); + getClientRequestId = response.GetRawResponse().ClientRequestId; + } + else + { + Response response = _fixture.Client.GetSecret(_fixture.SecretName); + getClientRequestId = response.GetRawResponse().ClientRequestId; + } + + string listClientRequestId = null; + if (isAsync) + { + AsyncPageable response = _fixture.Client.GetPropertiesOfSecretsAsync(); + await foreach (Page page in response.AsPages()) + { + listClientRequestId = page.GetRawResponse().ClientRequestId; + break; + } + } + else + { + Pageable response = _fixture.Client.GetPropertiesOfSecrets(); + foreach (Page page in response.AsPages()) + { + listClientRequestId = page.GetRawResponse().ClientRequestId; + break; + } + } + + Assert.NotEqual(getClientRequestId, listClientRequestId); + } + + [LiveFact] + public async Task RequestsWhenExpired(bool isAsync) + { + _fixture.Ttl = TimeSpan.FromMilliseconds(10); + + Response response; + if (isAsync) + { + response = await _fixture.Client.GetSecretAsync(_fixture.SecretName); + } + else + { + response = _fixture.Client.GetSecret(_fixture.SecretName); + } + + string clientRequestId = response.GetRawResponse().ClientRequestId; + await Task.Delay(100); + + if (isAsync) + { + response = await _fixture.Client.GetSecretAsync(_fixture.SecretName); + } + else + { + response = _fixture.Client.GetSecret(_fixture.SecretName); + } + + Assert.NotEqual(clientRequestId, response.GetRawResponse().ClientRequestId); + } + + [LiveFact(Synchronicity = Synchronicity.Asynchronous)] + public async Task ConcurrentRequests(bool isAsync) + { + List>> tasks = new List>>(10); + for (int i = 0; i < tasks.Capacity; ++i) + { + Task> task = _fixture.Client.GetSecretAsync(_fixture.SecretName); + tasks.Add(task); + } + + await Task.WhenAll(tasks); + + string[] clientRequestIds = tasks.Select(task => task.Result.GetRawResponse().ClientRequestId).ToArray(); + Assert.All(clientRequestIds, value => Assert.Equal(clientRequestIds[0], value)); + } + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/tests/KeyVaultProxyTests.cs b/sdk/keyvault/samples/keyvaultproxy/tests/KeyVaultProxyTests.cs new file mode 100644 index 000000000000..0701c260f960 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/tests/KeyVaultProxyTests.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Xunit; + +namespace AzureSamples.Security.KeyVault.Proxy +{ + public partial class KeyVaultProxyTests + { + [Fact] + public void DefaultTtl() + { + KeyVaultProxy proxy = new KeyVaultProxy(); + Assert.Equal(TimeSpan.FromHours(1), proxy.Ttl); + } + + [Theory] + [InlineData("https://test.vault.azure.net/secrets/", true)] + [InlineData("https://test.vault.azure.net/secrets/?api-version=7.0", true)] + [InlineData("https://test.vault.azure.net/secrets/test-secret", true)] + [InlineData("https://test.vault.azure.net/secrets/test-secret?api-version=7.0", true)] + [InlineData("https://test.vault.azure.net/secrets/test-secret/version", true)] + [InlineData("https://test.vault.azure.net/secrets/test-secret/version?api-version=7.0", true)] + [InlineData("https://test.vault.azure.net/keys/", true)] + [InlineData("https://test.vault.azure.net/keys/?api-version=7.0", true)] + [InlineData("https://test.vault.azure.net/keys/test-key", true)] + [InlineData("https://test.vault.azure.net/keys/test-key?api-version=7.0", true)] + [InlineData("https://test.vault.azure.net/keys/test-key/version", true)] + [InlineData("https://test.vault.azure.net/keys/test-key/version?api-version=7.0", true)] + [InlineData("https://test.vault.azure.net/certificates/", true)] + [InlineData("https://test.vault.azure.net/certificates/?api-version=7.0", true)] + [InlineData("https://test.vault.azure.net/certificates/test-certificate", true)] + [InlineData("https://test.vault.azure.net/certificates/test-certificate?api-version=7.0", true)] + [InlineData("https://test.vault.azure.net/certificates/test-certificate/version", true)] + [InlineData("https://test.vault.azure.net/certificates/test-certificate/version?api-version=7.0", true)] + [InlineData("https://test.vault.azure.net/deletedsecrets/", false)] + [InlineData("https://test.vault.azure.net/deletedsecrets/?api-version=7.0", false)] + [InlineData("https://test.vault.azure.net/deletedsecrets/test-secret", false)] + [InlineData("https://test.vault.azure.net/deletedsecrets/test-secret?api-version=7.0", false)] + [InlineData("https://test.vault.azure.net/deletedsecrets/test-secret/version", false)] + [InlineData("https://test.vault.azure.net/deletedsecrets/test-secret/version?api-version=7.0", false)] + [InlineData("https://test.vault.azure.net/deletedkeys/", false)] + [InlineData("https://test.vault.azure.net/deletedkeys?/api-version=7.0", false)] + [InlineData("https://test.vault.azure.net/deletedkeys/test-key", false)] + [InlineData("https://test.vault.azure.net/deletedkeys/test-key?api-version=7.0", false)] + [InlineData("https://test.vault.azure.net/deletedkeys/test-key/version", false)] + [InlineData("https://test.vault.azure.net/deletedkeys/test-key/version?api-version=7.0", false)] + [InlineData("https://test.vault.azure.net/deletedcertificates/", false)] + [InlineData("https://test.vault.azure.net/deletedcertificates/?api-version=7.0", false)] + [InlineData("https://test.vault.azure.net/deletedcertificates/test-certificate", false)] + [InlineData("https://test.vault.azure.net/deletedcertificates/test-certificate?api-version=7.0", false)] + [InlineData("https://test.vault.azure.net/deletedcertificates/test-certificate/version", false)] + [InlineData("https://test.vault.azure.net/deletedcertificates/test-certificate/version?api-version=7.0", false)] + public void IsSupported(string uri, bool expected) => Assert.Equal(expected, KeyVaultProxy.IsSupported(uri)); + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/tests/LiveFactAttribute.cs b/sdk/keyvault/samples/keyvaultproxy/tests/LiveFactAttribute.cs new file mode 100644 index 000000000000..dfbbee6e0f4a --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/tests/LiveFactAttribute.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Xunit; +using Xunit.Sdk; + +namespace AzureSamples.Security.KeyVault.Proxy +{ + /// + /// Test attribute to run tests synchronously or asynchronously in conjunction with a . + /// + [XunitTestCaseDiscoverer("AzureSamples.Security.KeyVault.Proxy.LiveFactDiscoverer", "AzureSamples.Security.KeyVault.Proxy.Tests")] + public class LiveFactAttribute : FactAttribute + { + /// + /// Gets or sets whether to run only synchronously, asynchronously, or both. + /// + public Synchronicity Synchronicity { get; set; } + } + + /// + /// Options to run methods synchronously, asynchronously, or both (default). + /// + public enum Synchronicity + { + Both, + Synchronous, + Asynchronous, + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/tests/LiveFactDiscoverer.cs b/sdk/keyvault/samples/keyvaultproxy/tests/LiveFactDiscoverer.cs new file mode 100644 index 000000000000..6dc7395bb16b --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/tests/LiveFactDiscoverer.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace AzureSamples.Security.KeyVault.Proxy +{ + public class LiveFactDiscoverer : IXunitTestCaseDiscoverer + { + private readonly IMessageSink _diagnosticMessageSink; + + public LiveFactDiscoverer(IMessageSink diagnosticMessageSink) + { + _diagnosticMessageSink = diagnosticMessageSink ?? throw new ArgumentNullException(nameof(diagnosticMessageSink)); + } + + public IEnumerable Discover(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) + { + Synchronicity options = factAttribute.GetNamedArgument(nameof(LiveFactAttribute.Synchronicity)); + + if ((options & Synchronicity.Asynchronous) == 0) + { + yield return new LiveTestCase(_diagnosticMessageSink, discoveryOptions, testMethod, false); + } + + if ((options & Synchronicity.Synchronous) == 0) + { + yield return new LiveTestCase(_diagnosticMessageSink, discoveryOptions, testMethod, true); + } + } + + private class LiveTestCase : XunitTestCase + { + [Obsolete] + public LiveTestCase() + { + } + + public LiveTestCase(IMessageSink diagnosticMessageSink, ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, bool isAsync) + : base(diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod, new object[] { isAsync }) + { + IsAsync = isAsync; + } + + public bool IsAsync { get; private set; } + + public override async Task RunAsync(IMessageSink diagnosticMessageSink, IMessageBus messageBus, object[] constructorArguments, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) + { + // Clear the cache if SecretsFixture was passed to the test class constructor. + if (constructorArguments is { } && constructorArguments.Length == 1 && constructorArguments[0] is SecretsFixture fixture) + { + fixture.Reset(); + } + else + { + aggregator.Add(new InvalidOperationException($"A single test class constructor argument of type {nameof(SecretsFixture)} was expected.")); + } + + return await base.RunAsync(diagnosticMessageSink, messageBus, constructorArguments, aggregator, cancellationTokenSource).ConfigureAwait(false); + } + + public override void Serialize(IXunitSerializationInfo data) + { + base.Serialize(data); + + data.AddValue(nameof(IsAsync), IsAsync); + } + + public override void Deserialize(IXunitSerializationInfo data) + { + base.Deserialize(data); + + IsAsync = data.GetValue(nameof(IsAsync)); + } + } + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/tests/SecretsFixture.cs b/sdk/keyvault/samples/keyvaultproxy/tests/SecretsFixture.cs new file mode 100644 index 000000000000..8f44a6fd7a00 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/tests/SecretsFixture.cs @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Core.TestFramework; +using Azure.Security.KeyVault.Secrets; + +namespace AzureSamples.Security.KeyVault.Proxy +{ + /// + /// A common test fixture to request secrets through the . + /// + public class SecretsFixture + { + private readonly KeyVaultProxy _proxy; + private readonly TimeSpan _originalTtl; + + /// + /// Creates a new instance of the class. + /// + public SecretsFixture() + { + SecretClientOptions options = new SecretClientOptions + { + Diagnostics = + { + IsLoggingContentEnabled = true, + }, + Transport = new MockTransport(CreateResponse), + }; + + options.AddPolicy(_proxy = new KeyVaultProxy(), HttpPipelinePosition.PerCall); + _originalTtl = _proxy.Ttl; + + Client = new SecretClient(new Uri("https://test.vault.azure.net"), new NullCredential(), options); + } + + /// + /// Gets the for tests. + /// + public SecretClient Client { get; } + + /// + /// Gets the name of the test secret. + /// + public string SecretName { get; } = "test-secret"; + + /// + /// Gets or sets the time to live on the . + /// + public TimeSpan Ttl + { + get => _proxy.Ttl; + set => _proxy.Ttl = value; + } + + /// + /// Clears the proxy cache and resets the time to live to its default. + /// + public void Reset() + { + _proxy.Clear(); + _proxy.Ttl = _originalTtl; + } + + private MockResponse CreateResponse(MockRequest request) + { + if (request.Method == RequestMethod.Get) + { + string path = request.Uri.Path; + if (path.StartsWith($"/secrets/{SecretName}/275486a5f6cc41349e5fb480d068927c", StringComparison.OrdinalIgnoreCase)) + { + return CreateResponse($@"{{ +""value"": ""secret-value-0"", +""id"": ""https://test.vault.azure.net/secrets/{SecretName}/275486a5f6cc41349e5fb480d068927c"", +""attributes"": {{ + ""enabled"": true, + ""created"": 1588832393, + ""updated"": 1588832393, + ""recoveryLevel"": ""Recoverable+Purgeable"" +}} +}}"); + } + + if (path.StartsWith($"/secrets/{SecretName}", StringComparison.OrdinalIgnoreCase)) + { + return CreateResponse($@"{{ +""value"": ""secret-value-1"", +""id"": ""https://test.vault.azure.net/secrets/{SecretName}/275486a5f6cc41349e5fb480d068927c"", +""attributes"": {{ + ""enabled"": true, + ""created"": 1588832473, + ""updated"": 1588832473, + ""recoveryLevel"": ""Recoverable+Purgeable"" +}} +}}"); + } + + if (path.StartsWith($"/secrets", StringComparison.OrdinalIgnoreCase)) + { + return CreateResponse($@"{{ +""value"": [ + {{ + ""id"": ""https://test.vault.azure.net/secrets/{SecretName}/275486a5f6cc41349e5fb480d068927c"", + ""attributes"": {{ + ""enabled"": true, + ""created"": 1588832473, + ""updated"": 1588832473, + ""recoveryLevel"": ""Recoverable+Purgeable"" + }} + }}, + {{ + ""id"": ""https://test.vault.azure.net/secrets/other-secret/3a398dae0e7a4ccdbf32e5f3c306cc03"", + ""attributes"": {{ + ""enabled"": true, + ""created"": 1588832617, + ""updated"": 1588832617, + ""recoveryLevel"": ""Recoverable+Purgeable"" + }} + }} +], +""nextLink"": null +}}"); + } + } + + throw new NotImplementedException(); + } + + private MockResponse CreateResponse(string content) + { + byte[] buffer = Encoding.UTF8.GetBytes(content); + MockResponse response = new MockResponse(200) + { + ClientRequestId = Guid.NewGuid().ToString(), + ContentStream = new MemoryStream(buffer), + }; + + // Add headers matching current response headers from Key Vault. + response.AddHeader(new HttpHeader("Cache-Control", "no-cache")); + response.AddHeader(new HttpHeader("Content-Length", buffer.Length.ToString(CultureInfo.InvariantCulture))); + response.AddHeader(new HttpHeader("Content-Type", "application/json; charset=utf-8")); + response.AddHeader(new HttpHeader("Date", DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture))); + response.AddHeader(new HttpHeader("Expires", "-1")); + response.AddHeader(new HttpHeader("Pragma", "no-cache")); + response.AddHeader(new HttpHeader("Server", "Microsoft-IIS/10.0")); + response.AddHeader(new HttpHeader("Strict-Transport-Security", "max-age=31536000;includeSubDomains")); + response.AddHeader(new HttpHeader("X-AspNet-Version", "4.0.30319")); + response.AddHeader(new HttpHeader("X-Content-Type-Options", "nosniff")); + response.AddHeader(new HttpHeader("x-ms-keyvault-network-info", "addr=131.107.160.97;act_addr_fam=InterNetwork;")); + response.AddHeader(new HttpHeader("x-ms-keyvault-region", "westus")); + response.AddHeader(new HttpHeader("x-ms-keyvault-service-version", "1.1.0.875")); + response.AddHeader(new HttpHeader("x-ms-request-id", response.ClientRequestId)); + response.AddHeader(new HttpHeader("X-Powered-By", "ASP.NET")); + + return response; + } + + private class NullCredential : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) => + new AccessToken("Sanitized", DateTimeOffset.MaxValue); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) => + new ValueTask(GetToken(requestContext, cancellationToken)); + } + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/AsyncGate.cs b/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/AsyncGate.cs new file mode 100644 index 000000000000..53f2c0445388 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/AsyncGate.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading.Tasks; + +namespace Azure.Core.TestFramework +{ + public class AsyncGate + { + private readonly object _sync = new object(); + private TaskCompletionSource _signalTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + private TaskCompletionSource _releaseTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task WaitForSignal() + { + return await _signalTaskCompletionSource.Task.TimeoutAfterDefault(); + } + + public async Task Cycle(TOut value = default) + { + TIn signal = await WaitForSignal(); + Release(value); + return signal; + } + + public async Task CycleWithException(Exception exception) + { + TIn signal = await WaitForSignal(); + ReleaseWithException(exception); + return signal; + } + + public void Release(TOut value = default) + { + lock (_sync) + { + Reset().SetResult(value); + } + } + + public void ReleaseWithException(Exception exception) + { + lock (_sync) + { + Reset().SetException(exception); + } + } + + private TaskCompletionSource Reset() + { + lock (_sync) + { + if (!_signalTaskCompletionSource.Task.IsCompleted) + { + throw new InvalidOperationException("No await call to release"); + } + + TaskCompletionSource releaseTaskCompletionSource = _releaseTaskCompletionSource; + _releaseTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _signalTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return releaseTaskCompletionSource; + } + } + + public Task WaitForRelease(TIn value = default) + { + lock (_sync) + { + _signalTaskCompletionSource.SetResult(value); + return _releaseTaskCompletionSource.Task.TimeoutAfterDefault(); + } + } + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/AsyncValidatingStream.cs b/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/AsyncValidatingStream.cs new file mode 100644 index 000000000000..f1144a3b05d4 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/AsyncValidatingStream.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Azure.Core.TestFramework +{ + internal class AsyncValidatingStream : Stream + { + private readonly bool _isAsync; + + private readonly Stream _innerStream; + + public AsyncValidatingStream(bool isAsync, Stream innerStream) + { + _isAsync = isAsync; + _innerStream = innerStream; + } + + public override void Flush() + { + Validate(false); + _innerStream.Flush(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + Validate(true); + return _innerStream.FlushAsync(cancellationToken); + } + + private void Validate(bool isAsync) + { + if (isAsync != _isAsync) + { + throw new InvalidOperationException("All stream calls were expected to be " + (_isAsync ? "async" : "sync") + " but were " + (isAsync ? "async" : "sync")); + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + Validate(false); + return _innerStream.Read(buffer, offset, count); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = new CancellationToken()) + { + Validate(true); + return _innerStream.ReadAsync(buffer, offset, count, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) + { + return _innerStream.Seek(offset, origin); + } + + public override void SetLength(long value) + { + _innerStream.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + Validate(false); + _innerStream.Write(buffer, offset, count); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken = new CancellationToken()) + { + Validate(true); + return _innerStream.WriteAsync(buffer, offset, count, cancellationToken); + } + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + Validate(true); + return _innerStream.CopyToAsync(destination, bufferSize, cancellationToken); + } + + public override void Close() + { + _innerStream.Close(); + } + + public override bool CanRead => _innerStream.CanRead; + public override bool CanSeek => _innerStream.CanSeek; + public override bool CanWrite => _innerStream.CanWrite; + public override long Length => _innerStream.Length; + public override long Position + { + get => _innerStream.Position; + set => _innerStream.Position = value; + } + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/MockRequest.cs b/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/MockRequest.cs new file mode 100644 index 000000000000..6b975bcc9a26 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/MockRequest.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Azure.Core.TestFramework +{ + public class MockRequest : Request + { + public MockRequest() + { + ClientRequestId = Guid.NewGuid().ToString(); + } + + private readonly Dictionary> _headers = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + public bool IsDisposed { get; private set; } + + public override RequestContent Content + { + get { return base.Content; } + set + { + if (value != null && value.TryComputeLength(out long length)) + { + _headers["Content-Length"] = new List { length.ToString() }; + + } + else + { + _headers.Remove("Content-Length"); + } + base.Content = value; + } + } + + + protected override void AddHeader(string name, string value) + { + if (!_headers.TryGetValue(name, out List values)) + { + _headers[name] = values = new List(); + } + + values.Add(value); + } + + protected override bool TryGetHeader(string name, out string value) + { + if (_headers.TryGetValue(name, out List values)) + { + value = JoinHeaderValue(values); + return true; + } + + value = null; + return false; + } + + protected override bool TryGetHeaderValues(string name, out IEnumerable values) + { + var result = _headers.TryGetValue(name, out List valuesList); + values = valuesList; + return result; + } + + protected override bool ContainsHeader(string name) + { + return TryGetHeaderValues(name, out _); + } + + protected override bool RemoveHeader(string name) + { + return _headers.Remove(name); + } + + protected override IEnumerable EnumerateHeaders() => _headers.Select(h => new HttpHeader(h.Key, JoinHeaderValue(h.Value))); + + private static string JoinHeaderValue(IEnumerable values) + { + return string.Join(",", values); + } + + public override string ClientRequestId { get; set; } + + public override string ToString() => $"{Method} {Uri}"; + + public override void Dispose() + { + IsDisposed = true; + } + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/MockResponse.cs b/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/MockResponse.cs new file mode 100644 index 000000000000..55adad7cf058 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/MockResponse.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace Azure.Core.TestFramework +{ + public class MockResponse : Response + { + private readonly Dictionary> _headers = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + public MockResponse(int status, string reasonPhrase = null) + { + Status = status; + ReasonPhrase = reasonPhrase; + } + + public override int Status { get; } + + public override string ReasonPhrase { get; } + + public override Stream ContentStream { get; set; } + + public override string ClientRequestId { get; set; } + + public bool IsDisposed { get; private set; } + + public void SetContent(byte[] content) + { + ContentStream = new MemoryStream(content); + } + + public void SetContent(string content) + { + SetContent(Encoding.UTF8.GetBytes(content)); + } + + public void AddHeader(HttpHeader header) + { + if (!_headers.TryGetValue(header.Name, out List values)) + { + _headers[header.Name] = values = new List(); + } + + values.Add(header.Value); + } + + protected override bool TryGetHeader(string name, out string value) + { + if (_headers.TryGetValue(name, out List values)) + { + value = JoinHeaderValue(values); + return true; + } + + value = null; + return false; + } + + protected override bool TryGetHeaderValues(string name, out IEnumerable values) + { + var result = _headers.TryGetValue(name, out List valuesList); + values = valuesList; + return result; + } + + protected override bool ContainsHeader(string name) + { + return TryGetHeaderValues(name, out _); + } + + protected override IEnumerable EnumerateHeaders() => _headers.Select(h => new HttpHeader(h.Key, JoinHeaderValue(h.Value))); + + private static string JoinHeaderValue(IEnumerable values) + { + return string.Join(",", values); + } + + public override void Dispose() + { + IsDisposed = true; + } + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/MockTransport.cs b/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/MockTransport.cs new file mode 100644 index 000000000000..a5cbd1ce985f --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/MockTransport.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Azure.Core.Pipeline; + +namespace Azure.Core.TestFramework +{ + public class MockTransport : HttpPipelineTransport + { + private readonly Func _responseFactory; + + public AsyncGate RequestGate { get; } + + public List Requests { get; } = new List(); + + public bool? ExpectSyncPipeline { get; set; } + + public MockTransport() + { + RequestGate = new AsyncGate(); + } + + public MockTransport(params MockResponse[] responses) + { + var requestIndex = 0; + _responseFactory = req => responses[requestIndex++]; + } + + public MockTransport(Func responseFactory) + { + _responseFactory = responseFactory; + } + + public override Request CreateRequest() + => new MockRequest(); + + public override void Process(HttpMessage message) + { + if (ExpectSyncPipeline == false) + { + throw new InvalidOperationException("Sync pipeline invocation not expected"); + } + + ProcessCore(message).GetAwaiter().GetResult(); + } + + public override async ValueTask ProcessAsync(HttpMessage message) + { + if (ExpectSyncPipeline == true) + { + throw new InvalidOperationException("Async pipeline invocation not expected"); + } + + await ProcessCore(message); + } + + private async Task ProcessCore(HttpMessage message) + { + if (!(message.Request is MockRequest request)) + throw new InvalidOperationException("the request is not compatible with the transport"); + + Requests.Add(request); + + if (RequestGate != null) + { + message.Response = await RequestGate.WaitForRelease(request); + } + else + { + message.Response = _responseFactory(request); + } + + message.Response.ClientRequestId = request.ClientRequestId; + + if (message.Response.ContentStream != null && ExpectSyncPipeline != null) + { + message.Response.ContentStream = new AsyncValidatingStream(!ExpectSyncPipeline.Value, message.Response.ContentStream); + } + } + + public MockRequest SingleRequest => Requests.Single(); + } +} diff --git a/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/TaskExtensions.cs b/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/TaskExtensions.cs new file mode 100644 index 000000000000..3ad64ec22a52 --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/tests/TestFramework/TaskExtensions.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Azure.Core.TestFramework +{ + public static class TaskExtensions + { + public static TimeSpan DefaultTimeout { get; } = TimeSpan.FromSeconds(10); + + public static Task TimeoutAfterDefault(this Task task, + [CallerFilePath] string filePath = null, + [CallerLineNumber] int lineNumber = default) + { + return task.TimeoutAfter(DefaultTimeout, filePath, lineNumber); + } + + public static Task TimeoutAfterDefault(this Task task, + [CallerFilePath] string filePath = null, + [CallerLineNumber] int lineNumber = default) + { + return task.TimeoutAfter(DefaultTimeout, filePath, lineNumber); + } + + + public static async Task TimeoutAfter(this Task task, TimeSpan timeout, + [CallerFilePath] string filePath = null, + [CallerLineNumber] int lineNumber = default) + { + // Don't create a timer if the task is already completed + // or the debugger is attached + if (task.IsCompleted || Debugger.IsAttached) + { + return await task; + } + + var cts = new CancellationTokenSource(); + if (task == await Task.WhenAny(task, Task.Delay(timeout, cts.Token))) + { + cts.Cancel(); + return await task; + } + else + { + throw new TimeoutException(CreateMessage(timeout, filePath, lineNumber)); + } + } + + public static async Task TimeoutAfter(this Task task, TimeSpan timeout, + [CallerFilePath] string filePath = null, + [CallerLineNumber] int lineNumber = default) + { + // Don't create a timer if the task is already completed + // or the debugger is attached + if (task.IsCompleted || Debugger.IsAttached) + { + await task; + return; + } + + var cts = new CancellationTokenSource(); + if (task == await Task.WhenAny(task, Task.Delay(timeout, cts.Token))) + { + cts.Cancel(); + await task; + } + else + { + throw new TimeoutException(CreateMessage(timeout, filePath, lineNumber)); + } + } + + private static string CreateMessage(TimeSpan timeout, string filePath, int lineNumber) + => string.IsNullOrEmpty(filePath) + ? $"The operation timed out after reaching the limit of {timeout.TotalMilliseconds}ms." + : $"The operation at {filePath}:{lineNumber} timed out after reaching the limit of {timeout.TotalMilliseconds}ms."; + } +} From 5aa354a892f2a1c48f08d402cd809f3357c6c027 Mon Sep 17 00:00:00 2001 From: Heath Stewart Date: Fri, 25 Sep 2020 01:39:35 -0700 Subject: [PATCH 2/2] Upload sample from src only --- sdk/keyvault/samples/keyvaultproxy/README.md | 62 +------------------ .../samples/keyvaultproxy/src/README.md | 61 ++++++++++++++++++ 2 files changed, 62 insertions(+), 61 deletions(-) create mode 100644 sdk/keyvault/samples/keyvaultproxy/src/README.md diff --git a/sdk/keyvault/samples/keyvaultproxy/README.md b/sdk/keyvault/samples/keyvaultproxy/README.md index 55dbcd01ca2b..7fa503de3c54 100644 --- a/sdk/keyvault/samples/keyvaultproxy/README.md +++ b/sdk/keyvault/samples/keyvaultproxy/README.md @@ -1,61 +1 @@ ---- -page_type: sample -languages: -- csharp -products: -- azure -- azure-key-vault -urlFragment: keyvaultproxy -name: Cache certain responses from Key Vault -description: Shows how to implement a pipeline policy to cache certain responses from Key Vault to mitigate rate limiting. ---- - -# Azure Key Vault Proxy - -This is a sample showing how to use an `HttpPipelinePolicy` to cache and proxy secrets, keys, and certificates from Azure Key Vault. The [Azure.Core](https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/README.md) packages provides a number of useful HTTP pipeline policies like configurable retries, logging, and more; and, you can add your own policies. - -## Getting started - -To use this sample, you will need to install the [Azure.Core](https://nuget.org/packages/Azure.Core) package, which is installed automatically when installing any of the Azure Key Vault packages: - -* [Azure.Security.KeyVault.Certificates](https://nuget.org/packages/Azure.Security.KeyVault.Certificates) -* [Azure.Security.KeyVault.Keys](https://nuget.org/packages/Azure.Security.KeyVault.Keys) -* [Azure.Security.KeyVault.Secrets](https://nuget.org/packages/Azure.Security.KeyVault.Secrets) - -Once you build this project, you can reference this sample in your own project by either: - -* Adding a `` to this sample project in your own project, or -* Running `dotnet pack` on this sample project, publish it to a private NuGet source, and add a `` to `AzureSamples.Security.KeyVault.Proxy`. - -After you reference this sample, in your own project source, add the following: - -```csharp -using AzureSamples.Security.KeyVault.Proxy; -``` - -## Examples - -All HTTP clients for Azure.* packages allow you to customize the HTTP pipeline using their respective client options classes, such as the `SecretClientOptions` class below: - -```csharp -SecretClientOptions options = new SecretClientOptions(); -options.AddPolicy(new KeyVaultProxy(), HttpPipelinePosition.PerCall); - -SecretClient client = new SecretClient( - new Uri("https://myvault.vault.azure.net"), - new DefaultAzureCredential(), - options); -``` - -Whenever you make a call to a resource with given a unique URI, it will be cached, by default, for 1 hour. You can change the default time-to-live (TTL) like so: - -```csharp -SecretClientOptions options = new SecretClientOptions(); -options.AddPolicy(new KeyVaultProxy(TimeSpan.FromSeconds(30)), HttpPipelinePosition.PerCall); -``` - -When the resource has expired, the next request will go to the server and a successful `GET` response for certificates, keys, or secrets will be cached. - -## License - -This project is licensed under the [MIT license](https://github.com/Azure/azure-sdk-for-net/blob/master/LICENSE.txt). +See the [src/README.md](https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/keyvault/samples/keyvaultproxy/src/README.md) for more information about this sample. diff --git a/sdk/keyvault/samples/keyvaultproxy/src/README.md b/sdk/keyvault/samples/keyvaultproxy/src/README.md new file mode 100644 index 000000000000..55dbcd01ca2b --- /dev/null +++ b/sdk/keyvault/samples/keyvaultproxy/src/README.md @@ -0,0 +1,61 @@ +--- +page_type: sample +languages: +- csharp +products: +- azure +- azure-key-vault +urlFragment: keyvaultproxy +name: Cache certain responses from Key Vault +description: Shows how to implement a pipeline policy to cache certain responses from Key Vault to mitigate rate limiting. +--- + +# Azure Key Vault Proxy + +This is a sample showing how to use an `HttpPipelinePolicy` to cache and proxy secrets, keys, and certificates from Azure Key Vault. The [Azure.Core](https://github.com/Azure/azure-sdk-for-net/blob/master/sdk/core/Azure.Core/README.md) packages provides a number of useful HTTP pipeline policies like configurable retries, logging, and more; and, you can add your own policies. + +## Getting started + +To use this sample, you will need to install the [Azure.Core](https://nuget.org/packages/Azure.Core) package, which is installed automatically when installing any of the Azure Key Vault packages: + +* [Azure.Security.KeyVault.Certificates](https://nuget.org/packages/Azure.Security.KeyVault.Certificates) +* [Azure.Security.KeyVault.Keys](https://nuget.org/packages/Azure.Security.KeyVault.Keys) +* [Azure.Security.KeyVault.Secrets](https://nuget.org/packages/Azure.Security.KeyVault.Secrets) + +Once you build this project, you can reference this sample in your own project by either: + +* Adding a `` to this sample project in your own project, or +* Running `dotnet pack` on this sample project, publish it to a private NuGet source, and add a `` to `AzureSamples.Security.KeyVault.Proxy`. + +After you reference this sample, in your own project source, add the following: + +```csharp +using AzureSamples.Security.KeyVault.Proxy; +``` + +## Examples + +All HTTP clients for Azure.* packages allow you to customize the HTTP pipeline using their respective client options classes, such as the `SecretClientOptions` class below: + +```csharp +SecretClientOptions options = new SecretClientOptions(); +options.AddPolicy(new KeyVaultProxy(), HttpPipelinePosition.PerCall); + +SecretClient client = new SecretClient( + new Uri("https://myvault.vault.azure.net"), + new DefaultAzureCredential(), + options); +``` + +Whenever you make a call to a resource with given a unique URI, it will be cached, by default, for 1 hour. You can change the default time-to-live (TTL) like so: + +```csharp +SecretClientOptions options = new SecretClientOptions(); +options.AddPolicy(new KeyVaultProxy(TimeSpan.FromSeconds(30)), HttpPipelinePosition.PerCall); +``` + +When the resource has expired, the next request will go to the server and a successful `GET` response for certificates, keys, or secrets will be cached. + +## License + +This project is licensed under the [MIT license](https://github.com/Azure/azure-sdk-for-net/blob/master/LICENSE.txt).