diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..f9ecf576e1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f396a4cd1..f77598c899 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,27 +16,46 @@ jobs: if: startsWith(github.repository, 'spinnaker/') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-java@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - uses: actions/setup-java@v4 with: - java-version: 11 + java-version: | + 17 + 11 distribution: 'zulu' cache: 'gradle' - name: Prepare build variables id: build_variables run: | - echo ::set-output name=REPO::${GITHUB_REPOSITORY##*/} - echo ::set-output name=VERSION::"$(git describe --tags --abbrev=0 --match="v[0-9]*" | cut -c2-)-dev-${GITHUB_REF_NAME}-$(git rev-parse --short HEAD)-$(date --utc +'%Y%m%d%H%M')" + echo REPO="${GITHUB_REPOSITORY##*/}" >> $GITHUB_OUTPUT + echo VERSION="$(git describe --tags --abbrev=0 --match='v[0-9]*' | cut -c2-)-dev-${GITHUB_REF_NAME}-$(git rev-parse --short HEAD)-$(date --utc +'%Y%m%d%H%M')" >> $GITHUB_OUTPUT - name: Build env: ORG_GRADLE_PROJECT_version: ${{ steps.build_variables.outputs.VERSION }} run: ./gradlew build --stacktrace ${{ steps.build_variables.outputs.REPO }}-web:installDist + - name: Build local slim container image for testing + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.slim + load: true + platforms: local + tags: | + "${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}-unvalidated" + - name: Test local slim container image + env: + FULL_DOCKER_IMAGE_NAME: "${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}-unvalidated" + run: ./gradlew ${{ steps.build_variables.outputs.REPO }}-integration:test - name: Login to GAR # Only run this on repositories in the 'spinnaker' org, not on forks. if: startsWith(github.repository, 'spinnaker/') - uses: docker/login-action@v1 + uses: docker/login-action@v3 # use service account flow defined at: https://github.com/docker/login-action#service-account-based-authentication-1 with: registry: us-docker.pkg.dev @@ -45,10 +64,11 @@ jobs: - name: Build and publish slim container image # Only run this on repositories in the 'spinnaker' org, not on forks. if: startsWith(github.repository, 'spinnaker/') - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v5 with: context: . file: Dockerfile.slim + platforms: linux/amd64,linux/arm64 push: true tags: | "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ github.ref_name }}-latest-unvalidated" @@ -58,11 +78,37 @@ jobs: - name: Build and publish ubuntu container image # Only run this on repositories in the 'spinnaker' org, not on forks. if: startsWith(github.repository, 'spinnaker/') - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v5 with: context: . file: Dockerfile.ubuntu + platforms: linux/amd64,linux/arm64 push: true tags: | "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ github.ref_name }}-latest-unvalidated-ubuntu" "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}-unvalidated-ubuntu" + - name: Build and publish slim JRE 11 container image + # Only run this on repositories in the 'spinnaker' org, not on forks. + if: startsWith(github.repository, 'spinnaker/') + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.java11.slim + platforms: linux/amd64,linux/arm64 + push: true + tags: | + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ github.ref_name }}-latest-java11-unvalidated" + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}-java11-unvalidated" + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ github.ref_name }}-latest-java11-unvalidated-slim" + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}-java11-unvalidated-slim" + - name: Build and publish ubuntu JRE 11 container image + # Only run this on repositories in the 'spinnaker' org, not on forks. + if: startsWith(github.repository, 'spinnaker/') + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.java11.ubuntu + push: true + tags: | + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ github.ref_name }}-latest-java11-unvalidated-ubuntu" + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}-java11-unvalidated-ubuntu" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 41d84a9bcf..6574b215e8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,38 +10,79 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-java@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - uses: actions/setup-java@v4 with: - java-version: 11 + java-version: | + 17 + 11 distribution: 'zulu' cache: 'gradle' - name: Prepare build variables id: build_variables run: | - echo ::set-output name=REPO::${GITHUB_REPOSITORY##*/} - echo ::set-output name=VERSION::"$(git describe --tags --abbrev=0 --match="v[0-9]*" | cut -c2-)-dev-pr-$(git rev-parse --short HEAD)-$(date --utc +'%Y%m%d%H%M')" + echo REPO="${GITHUB_REPOSITORY##*/}" >> $GITHUB_OUTPUT + echo VERSION="$(git describe --tags --abbrev=0 --match='v[0-9]*' | cut -c2-)-dev-pr-$(git rev-parse --short HEAD)-$(date --utc +'%Y%m%d%H%M')" >> $GITHUB_OUTPUT - name: Build env: ORG_GRADLE_PROJECT_version: ${{ steps.build_variables.outputs.VERSION }} run: ./gradlew build ${{ steps.build_variables.outputs.REPO }}-web:installDist - name: Build slim container image - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v5 with: context: . file: Dockerfile.slim + platforms: linux/amd64,linux/arm64 tags: | "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:latest" "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}" "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:latest-slim" "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}-slim" - name: Build ubuntu container image - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v5 with: context: . file: Dockerfile.ubuntu + platforms: linux/amd64,linux/arm64 tags: | "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:latest-ubuntu" "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}-ubuntu" + - name: Build slim JRE 11 container image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.java11.slim + platforms: linux/amd64,linux/arm64 + tags: | + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:latest-java11" + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}-java11" + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:latest-java11-slim" + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}-java11-slim" + - name: Build ubuntu JRE 11 container image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.java11.ubuntu + platforms: linux/amd64,linux/arm64 + tags: | + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:latest-java11-ubuntu" + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}-java11-ubuntu" + - name: Build local slim container image for testing + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.slim + load: true + platforms: local + tags: | + "${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}" + - name: Test local slim container image + env: + FULL_DOCKER_IMAGE_NAME: "${{ steps.build_variables.outputs.REPO }}:${{ steps.build_variables.outputs.VERSION }}" + run: ./gradlew ${{ steps.build_variables.outputs.REPO }}-integration:test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b40b05013..e565d9bb88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,12 +14,18 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-java@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - uses: actions/setup-java@v4 with: - java-version: 11 + java-version: | + 17 + 11 distribution: 'zulu' cache: 'gradle' - name: Assemble release info @@ -28,15 +34,15 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | . .github/workflows/release_info.sh ${{ github.event.repository.full_name }} - echo ::set-output name=CHANGELOG::$(echo -e "${CHANGELOG}") - echo ::set-output name=SKIP_RELEASE::${SKIP_RELEASE} - echo ::set-output name=IS_CANDIDATE::${IS_CANDIDATE} - echo ::set-output name=RELEASE_VERSION::${RELEASE_VERSION} + echo CHANGELOG=$(echo -e "${CHANGELOG}") >> $GITHUB_OUTPUT + echo SKIP_RELEASE="${SKIP_RELEASE}" >> $GITHUB_OUTPUT + echo IS_CANDIDATE="${IS_CANDIDATE}" >> $GITHUB_OUTPUT + echo RELEASE_VERSION="${RELEASE_VERSION}" >> $GITHUB_OUTPUT - name: Prepare build variables id: build_variables run: | - echo ::set-output name=REPO::${GITHUB_REPOSITORY##*/} - echo ::set-output name=VERSION::"$(git rev-parse --short HEAD)-$(date --utc +'%Y%m%d%H%M')" + echo REPO="${GITHUB_REPOSITORY##*/}" >> $GITHUB_OUTPUT + echo VERSION="$(git rev-parse --short HEAD)-$(date --utc +'%Y%m%d%H%M')" >> $GITHUB_OUTPUT - name: Release build env: ORG_GRADLE_PROJECT_version: ${{ steps.release_info.outputs.RELEASE_VERSION }} @@ -57,7 +63,7 @@ jobs: - name: Login to Google Cloud # Only run this on repositories in the 'spinnaker' org, not on forks. if: startsWith(github.repository, 'spinnaker/') - uses: 'google-github-actions/auth@v0' + uses: 'google-github-actions/auth@v2' # use service account flow defined at: https://github.com/google-github-actions/upload-cloud-storage#authenticating-via-service-account-key-json with: credentials_json: '${{ secrets.GAR_JSON_KEY }}' @@ -65,7 +71,7 @@ jobs: # https://console.cloud.google.com/storage/browser/halconfig # Only run this on repositories in the 'spinnaker' org, not on forks. if: startsWith(github.repository, 'spinnaker/') - uses: 'google-github-actions/upload-cloud-storage@v0' + uses: 'google-github-actions/upload-cloud-storage@v2' with: path: 'halconfig/' destination: 'halconfig/${{ steps.build_variables.outputs.REPO }}/${{ steps.release_info.outputs.RELEASE_VERSION }}' @@ -73,7 +79,7 @@ jobs: - name: Login to GAR # Only run this on repositories in the 'spinnaker' org, not on forks. if: startsWith(github.repository, 'spinnaker/') - uses: docker/login-action@v1 + uses: docker/login-action@v3 # use service account flow defined at: https://github.com/docker/login-action#service-account-based-authentication-1 with: registry: us-docker.pkg.dev @@ -82,10 +88,11 @@ jobs: - name: Build and publish slim container image # Only run this on repositories in the 'spinnaker' org, not on forks. if: startsWith(github.repository, 'spinnaker/') - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v5 with: context: . file: Dockerfile.slim + platforms: linux/amd64,linux/arm64 push: true tags: | "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.release_info.outputs.RELEASE_VERSION }}-unvalidated" @@ -94,23 +101,48 @@ jobs: - name: Build and publish ubuntu container image # Only run this on repositories in the 'spinnaker' org, not on forks. if: startsWith(github.repository, 'spinnaker/') - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v5 with: context: . file: Dockerfile.ubuntu + platforms: linux/amd64,linux/arm64 push: true tags: | "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.release_info.outputs.RELEASE_VERSION }}-unvalidated-ubuntu" "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.release_info.outputs.RELEASE_VERSION }}-${{ steps.build_variables.outputs.VERSION }}-unvalidated-ubuntu" + - name: Build and publish slim JRE 11 container image + # Only run this on repositories in the 'spinnaker' org, not on forks. + if: startsWith(github.repository, 'spinnaker/') + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.java11.slim + platforms: linux/amd64,linux/arm64 + push: true + tags: | + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.release_info.outputs.RELEASE_VERSION }}-java11-unvalidated" + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.release_info.outputs.RELEASE_VERSION }}-java11-unvalidated-slim" + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.release_info.outputs.RELEASE_VERSION }}-${{ steps.build_variables.outputs.VERSION }}-java11-unvalidated-slim" + - name: Build and publish ubuntu JRE 11 container image + # Only run this on repositories in the 'spinnaker' org, not on forks. + if: startsWith(github.repository, 'spinnaker/') + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.java11.ubuntu + platforms: linux/amd64,linux/arm64 + push: true + tags: | + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.release_info.outputs.RELEASE_VERSION }}-java11-unvalidated-ubuntu" + "${{ env.CONTAINER_REGISTRY }}/${{ steps.build_variables.outputs.REPO }}:${{ steps.release_info.outputs.RELEASE_VERSION }}-${{ steps.build_variables.outputs.VERSION }}-java11-unvalidated-ubuntu" - name: Create release if: steps.release_info.outputs.SKIP_RELEASE == 'false' - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref }} - release_name: ${{ github.event.repository.name }} ${{ github.ref }} body: | ${{ steps.release_info.outputs.CHANGELOG }} draft: false + name: ${{ github.event.repository.name }} ${{ github.ref_name }} prerelease: ${{ steps.release_info.outputs.IS_CANDIDATE }} + tag_name: ${{ github.ref }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release_info.sh b/.github/workflows/release_info.sh index 0cadf01b76..3c3a158aa5 100755 --- a/.github/workflows/release_info.sh +++ b/.github/workflows/release_info.sh @@ -1,20 +1,24 @@ #!/bin/bash -x -# Only look to the latest release to determine the previous tag -- this allows us to skip unsupported tag formats (like `version-1.0.0`) -export PREVIOUS_TAG=`curl --silent "https://api.github.com/repos/$1/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/'` -echo "PREVIOUS_TAG=$PREVIOUS_TAG" -export NEW_TAG=${GITHUB_REF/refs\/tags\//} +NEW_TAG=${GITHUB_REF/refs\/tags\//} +export NEW_TAG echo "NEW_TAG=$NEW_TAG" -export CHANGELOG=`git log $NEW_TAG...$PREVIOUS_TAG --oneline` +# Glob match previous tags which should be format v1.2.3. Avoids Deck's npm tagging. +PREVIOUS_TAG=$(git describe --abbrev=0 --tags "${NEW_TAG}"^ --match 'v[0-9]*') +export PREVIOUS_TAG +echo "PREVIOUS_TAG=$PREVIOUS_TAG" +CHANGELOG=$(git log "$NEW_TAG"..."$PREVIOUS_TAG" --oneline) +export CHANGELOG echo "CHANGELOG=$CHANGELOG" -#Format the changelog so it's markdown compatible +# Format the changelog so it's markdown compatible CHANGELOG="${CHANGELOG//$'%'/%25}" CHANGELOG="${CHANGELOG//$'\n'/%0A}" CHANGELOG="${CHANGELOG//$'\r'/%0D}" # If the previous release tag is the same as this tag the user likely cut a release (and in the process created a tag), which means we can skip the need to create a release -export SKIP_RELEASE=`[[ "$PREVIOUS_TAG" = "$NEW_TAG" ]] && echo "true" || echo "false"` +SKIP_RELEASE=$([[ "$PREVIOUS_TAG" = "$NEW_TAG" ]] && echo "true" || echo "false") +export SKIP_RELEASE # https://github.com/fsaintjacques/semver-tool/blob/master/src/semver#L5-L14 NAT='0|[1-9][0-9]*' @@ -28,8 +32,10 @@ SEMVER_REGEX="\ (\\+${FIELD}(\\.${FIELD})*)?$" # Used in downstream steps to determine if the release should be marked as a "prerelease" and if the build should build candidate release artifacts -export IS_CANDIDATE=`[[ $NEW_TAG =~ $SEMVER_REGEX && ! -z ${BASH_REMATCH[4]} ]] && echo "true" || echo "false"` +IS_CANDIDATE=$([[ $NEW_TAG =~ $SEMVER_REGEX && -n ${BASH_REMATCH[4]} ]] && echo "true" || echo "false") +export IS_CANDIDATE # This is the version string we will pass to the build, trim off leading 'v' if present -export RELEASE_VERSION=`[[ $NEW_TAG =~ $SEMVER_REGEX ]] && echo "${NEW_TAG:1}" || echo "${NEW_TAG}"` +RELEASE_VERSION=$([[ $NEW_TAG =~ $SEMVER_REGEX ]] && echo "${NEW_TAG:1}" || echo "${NEW_TAG}") +export RELEASE_VERSION echo "RELEASE_VERSION=$RELEASE_VERSION" diff --git a/.mergify.yml b/.mergify.yml index 3b41473867..876d88127a 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -28,20 +28,6 @@ pull_request_rules: name: default label: add: ["auto merged"] - # This rule exists to handle release branches that are still building using Travis CI instead of - # using Github actions. It can be deleted once all active release branches are running Github actions. - - name: Automatically merge release branch changes on Travis CI success and release manager review - conditions: - - base~=^release- - - status-success=continuous-integration/travis-ci/pr - - "label=ready to merge" - - "approved-reviews-by=@release-managers" - actions: - queue: - method: squash - name: default - label: - add: ["auto merged"] - name: Automatically merge PRs from maintainers on CI success and review conditions: - base=master @@ -56,7 +42,7 @@ pull_request_rules: add: ["auto merged"] - name: Automatically merge autobump PRs on CI success conditions: - - base=master + - base~=^(master|release-) - status-success=build - "label~=autobump-*" - "author:spinnakerbot" @@ -68,7 +54,7 @@ pull_request_rules: add: ["auto merged"] - name: Request reviews for autobump PRs on CI failure conditions: - - base=master + - base~=^(master|release-) - status-failure=build - "label~=autobump-*" - base=master diff --git a/Dockerfile.compile b/Dockerfile.compile index dc53055801..3160976a1a 100644 --- a/Dockerfile.compile +++ b/Dockerfile.compile @@ -1,4 +1,4 @@ -FROM alpine:3.11 +FROM alpine:3.18 RUN apk add --update \ openjdk11 \ && rm -rf /var/cache/apk diff --git a/Dockerfile.java11.slim b/Dockerfile.java11.slim new file mode 100644 index 0000000000..f2a0a94a41 --- /dev/null +++ b/Dockerfile.java11.slim @@ -0,0 +1,10 @@ +FROM alpine:3.16 +LABEL maintainer="sig-platform@spinnaker.io" +RUN apk --no-cache add --update bash curl openjdk11-jre +RUN addgroup -S -g 10111 spinnaker +RUN adduser -S -G spinnaker -u 10111 spinnaker +COPY gate-web/build/install/gate /opt/gate +RUN mkdir -p /opt/gate/plugins && chown -R spinnaker:nogroup /opt/gate/plugins +USER spinnaker +HEALTHCHECK CMD curl http://localhost:8084/health | grep UP || exit 1 +CMD ["/opt/gate/bin/gate"] diff --git a/Dockerfile.java11.ubuntu b/Dockerfile.java11.ubuntu new file mode 100644 index 0000000000..1fb6b0cb38 --- /dev/null +++ b/Dockerfile.java11.ubuntu @@ -0,0 +1,9 @@ +FROM ubuntu:bionic +LABEL maintainer="sig-platform@spinnaker.io" +RUN apt-get update && apt-get -y install curl openjdk-11-jre-headless wget +RUN adduser --system --uid 10111 --group spinnaker +COPY gate-web/build/install/gate /opt/gate +RUN mkdir -p /opt/gate/plugins && chown -R spinnaker:nogroup /opt/gate/plugins +USER spinnaker +HEALTHCHECK CMD curl http://localhost:8084/health | grep UP || exit 1 +CMD ["/opt/gate/bin/gate"] diff --git a/Dockerfile.slim b/Dockerfile.slim index 7b7845b919..60f5e8215e 100644 --- a/Dockerfile.slim +++ b/Dockerfile.slim @@ -1,9 +1,10 @@ -FROM alpine:3.11 +FROM alpine:3.18 LABEL maintainer="sig-platform@spinnaker.io" -RUN apk --no-cache add --update bash openjdk11-jre +RUN apk --no-cache add --update bash curl openjdk17-jre RUN addgroup -S -g 10111 spinnaker RUN adduser -S -G spinnaker -u 10111 spinnaker COPY gate-web/build/install/gate /opt/gate RUN mkdir -p /opt/gate/plugins && chown -R spinnaker:nogroup /opt/gate/plugins USER spinnaker +HEALTHCHECK CMD curl http://localhost:8084/health | grep UP || exit 1 CMD ["/opt/gate/bin/gate"] diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index 2ad183bd54..bedcf5213f 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -1,8 +1,9 @@ -FROM ubuntu:bionic +FROM ubuntu:jammy LABEL maintainer="sig-platform@spinnaker.io" -RUN apt-get update && apt-get -y install openjdk-11-jre-headless wget +RUN apt-get update && apt-get -y install curl openjdk-17-jre-headless wget RUN adduser --system --uid 10111 --group spinnaker COPY gate-web/build/install/gate /opt/gate RUN mkdir -p /opt/gate/plugins && chown -R spinnaker:nogroup /opt/gate/plugins USER spinnaker +HEALTHCHECK CMD curl http://localhost:8084/health | grep UP || exit 1 CMD ["/opt/gate/bin/gate"] diff --git a/build.gradle b/build.gradle index c258b8376e..c5b8b7c294 100644 --- a/build.gradle +++ b/build.gradle @@ -34,7 +34,7 @@ allprojects { annotationProcessor "org.projectlombok:lombok" testAnnotationProcessor "org.projectlombok:lombok" - implementation "org.codehaus.groovy:groovy-all" + implementation "org.codehaus.groovy:groovy" implementation "net.logstash.logback:logstash-logback-encoder" implementation "org.jetbrains.kotlin:kotlin-reflect" @@ -43,6 +43,7 @@ allprojects { testImplementation "org.springframework.boot:spring-boot-starter-test" testImplementation "org.hamcrest:hamcrest-core" testRuntimeOnly "cglib:cglib-nodep" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" testRuntimeOnly "org.objenesis:objenesis" } @@ -52,6 +53,17 @@ allprojects { } } + tasks.withType(JavaCompile).configureEach { + javaCompiler = javaToolchains.compilerFor { + languageVersion = JavaLanguageVersion.of(11) + } + } + tasks.withType(Test).configureEach { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(17) + } + } + tasks.withType(JavaExec) { if (System.getProperty('DEBUG', 'false') == 'true') { jvmArgs '-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8184' @@ -62,6 +74,7 @@ allprojects { testLogging { exceptionFormat = 'full' } + useJUnitPlatform() } } diff --git a/gate-api-tck/gate-api-tck.gradle b/gate-api-tck/gate-api-tck.gradle index 35e8d220a0..7dd6a08c93 100644 --- a/gate-api-tck/gate-api-tck.gradle +++ b/gate-api-tck/gate-api-tck.gradle @@ -3,6 +3,8 @@ apply from: "${project.rootDir}/gradle/kotlin-test.gradle" dependencies { implementation(project(":gate-web")) + implementation("io.spinnaker.kork:kork-jedis-test") + implementation("org.springframework.session:spring-session-data-redis") api("org.springframework.boot:spring-boot-starter-test") api("dev.minutest:minutest") diff --git a/gate-api-tck/src/main/kotlin/com/netflix/spinnaker/gate/api/test/GateFixture.kt b/gate-api-tck/src/main/kotlin/com/netflix/spinnaker/gate/api/test/GateFixture.kt index bdbeff87d7..6a2b81eee9 100644 --- a/gate-api-tck/src/main/kotlin/com/netflix/spinnaker/gate/api/test/GateFixture.kt +++ b/gate-api-tck/src/main/kotlin/com/netflix/spinnaker/gate/api/test/GateFixture.kt @@ -20,10 +20,20 @@ import com.netflix.spinnaker.gate.Main import dev.minutest.TestContextBuilder import dev.minutest.TestDescriptor import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration import org.springframework.test.context.TestContextManager import org.springframework.test.context.TestPropertySource +import org.springframework.context.annotation.Bean +import com.netflix.spinnaker.kork.jedis.EmbeddedRedis +import org.springframework.context.annotation.Primary +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory +import org.springframework.session.data.redis.config.annotation.SpringSessionRedisConnectionFactory +import org.springframework.test.context.ContextConfiguration +import redis.clients.jedis.JedisPool @SpringBootTest(classes = [Main::class]) +@ContextConfiguration(classes = [GateFixtureConfiguration::class]) @TestPropertySource(properties = ["spring.config.location=classpath:gate-test-app.yml"]) abstract class GateFixture @@ -39,3 +49,24 @@ inline fun TestContextBuilder.gateFixture( } } } + +@TestConfiguration +internal open class GateFixtureConfiguration { + @Bean(destroyMethod = "destroy") + fun embeddedRedis(): EmbeddedRedis { + return EmbeddedRedis.embed().also { redis -> redis.jedis.connect() }.also { redis -> redis.jedis.ping() } + } + + @Bean + @Primary + @SpringSessionRedisConnectionFactory + fun jedisConnectionFactory(embeddedRedis: EmbeddedRedis): JedisConnectionFactory { + return JedisConnectionFactory(RedisStandaloneConfiguration(embeddedRedis.host, embeddedRedis.port)) + } + + @Bean + @Primary + fun jedis(embeddedRedis: EmbeddedRedis): JedisPool { + return embeddedRedis.getPool(); + } +} diff --git a/gate-basic/src/main/java/com/netflix/spinnaker/gate/security/basic/BasicAuthConfig.java b/gate-basic/src/main/java/com/netflix/spinnaker/gate/security/basic/BasicAuthConfig.java index 87cde9c3a2..4816a70761 100644 --- a/gate-basic/src/main/java/com/netflix/spinnaker/gate/security/basic/BasicAuthConfig.java +++ b/gate-basic/src/main/java/com/netflix/spinnaker/gate/security/basic/BasicAuthConfig.java @@ -24,7 +24,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; @@ -62,9 +61,4 @@ protected void configure(HttpSecurity http) throws Exception { .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")); authConfig.configure(http); } - - @Override - public void configure(WebSecurity web) throws Exception { - authConfig.configure(web); - } } diff --git a/gate-core/gate-core.gradle b/gate-core/gate-core.gradle index 4418ae225b..7ad8a916ad 100644 --- a/gate-core/gate-core.gradle +++ b/gate-core/gate-core.gradle @@ -21,6 +21,7 @@ dependencies { api "com.squareup.retrofit:retrofit" + implementation "io.spinnaker.kork:kork-artifacts" implementation "io.spinnaker.kork:kork-plugins" implementation "com.jakewharton.retrofit:retrofit1-okhttp3-client:1.1.0" implementation "com.squareup.retrofit:converter-jackson" @@ -37,6 +38,10 @@ dependencies { implementation "com.netflix.spectator:spectator-api" implementation "com.github.ben-manes.caffeine:guava" implementation "org.apache.commons:commons-lang3" + + implementation "io.cloudevents:cloudevents-spring:2.5.0" + implementation "io.cloudevents:cloudevents-json-jackson:2.5.0" + implementation "io.cloudevents:cloudevents-http-basic:2.5.0" } sourceSets { diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/AuthConfig.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/AuthConfig.groovy deleted file mode 100644 index 01399bd769..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/AuthConfig.groovy +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright 2016 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.config - -import com.netflix.spinnaker.fiat.shared.FiatClientConfigurationProperties -import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator -import com.netflix.spinnaker.fiat.shared.FiatStatus -import com.netflix.spinnaker.gate.filters.FiatSessionFilter -import com.netflix.spinnaker.gate.services.PermissionService -import com.netflix.spinnaker.gate.services.ServiceAccountFilterConfigProps -import com.netflix.spinnaker.security.User -import groovy.util.logging.Slf4j -import org.springframework.beans.factory.InitializingBean -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.autoconfigure.security.SecurityProperties -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.context.annotation.Configuration -import org.springframework.http.HttpMethod -import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.annotation.web.builders.WebSecurity -import org.springframework.security.core.Authentication -import org.springframework.security.web.authentication.AnonymousAuthenticationFilter -import org.springframework.security.web.authentication.logout.LogoutSuccessHandler -import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler -import org.springframework.stereotype.Component - -import javax.servlet.Filter -import javax.servlet.ServletException -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse - -@Slf4j -@Configuration -@EnableConfigurationProperties([ServiceConfiguration, ServiceAccountFilterConfigProps]) -class AuthConfig { - - @Autowired - PermissionRevokingLogoutSuccessHandler permissionRevokingLogoutSuccessHandler - - @Autowired - SecurityProperties securityProperties - - @Autowired - FiatClientConfigurationProperties configProps - - @Autowired - FiatStatus fiatStatus - - @Autowired - FiatPermissionEvaluator permissionEvaluator - - @Autowired - RequestMatcherProvider requestMatcherProvider - - @Value('${security.debug:false}') - boolean securityDebug - - @Value('${fiat.session-filter.enabled:true}') - boolean fiatSessionFilterEnabled - - @Value('${security.webhooks.default-auth-enabled:false}') - boolean webhookDefaultAuthEnabled - - void configure(HttpSecurity http) throws Exception { - // @formatter:off - http - .requestMatcher(requestMatcherProvider.requestMatcher()) - .authorizeRequests() - .antMatchers("/error").permitAll() - .antMatchers('/favicon.ico').permitAll() - .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .antMatchers(PermissionRevokingLogoutSuccessHandler.LOGGED_OUT_URL).permitAll() - .antMatchers('/auth/user').permitAll() - .antMatchers('/plugins/deck/**').permitAll() - .antMatchers(HttpMethod.POST, '/webhooks/**').permitAll() - .antMatchers(HttpMethod.POST, '/notifications/callbacks/**').permitAll() - .antMatchers(HttpMethod.POST, '/managed/notifications/callbacks/**').permitAll() - .antMatchers('/health').permitAll() - .antMatchers('/**').authenticated() - if (fiatSessionFilterEnabled) { - Filter fiatSessionFilter = new FiatSessionFilter( - fiatSessionFilterEnabled, - fiatStatus, - permissionEvaluator) - - http.addFilterBefore(fiatSessionFilter, AnonymousAuthenticationFilter.class) - } - - if (webhookDefaultAuthEnabled) { - http.authorizeRequests().antMatchers(HttpMethod.POST, '/webhooks/**').authenticated() - } - - http.logout() - .logoutUrl("/auth/logout") - .logoutSuccessHandler(permissionRevokingLogoutSuccessHandler) - .permitAll() - .and() - .csrf() - .disable() - // @formatter:on - } - - void configure(WebSecurity web) throws Exception { - web.debug(securityDebug) - } - - @Component - static class PermissionRevokingLogoutSuccessHandler implements LogoutSuccessHandler, InitializingBean { - - static final String LOGGED_OUT_URL = "/auth/loggedOut" - - @Autowired - PermissionService permissionService - - SimpleUrlLogoutSuccessHandler delegate = new SimpleUrlLogoutSuccessHandler(); - - @Override - void afterPropertiesSet() throws Exception { - delegate.setDefaultTargetUrl(LOGGED_OUT_URL) - } - - @Override - void onLogoutSuccess(HttpServletRequest request, - HttpServletResponse response, - Authentication authentication) throws IOException, ServletException { - def username = (authentication?.getPrincipal() as User)?.username - if (username) { - permissionService.logout(username) - } - delegate.onLogoutSuccess(request, response, authentication) - } - } -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/Service.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/Service.groovy deleted file mode 100644 index 7596fead44..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/Service.groovy +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2015 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.config - -import groovy.transform.CompileStatic - -@CompileStatic -class Service { - boolean enabled = true - String vipAddress - String baseUrl - MultiBaseUrl shards - int priority = 1 - Map config = [:] - - void setBaseUrl(String baseUrl) { - this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl - } - - static class BaseUrl { - String vipAddress - String baseUrl - int priority = 1 - Map config = [:] - } - - static class MultiBaseUrl { - String baseUrl - List baseUrls - } - - List getBaseUrls() { - if (shards?.baseUrl) { - return [ - new BaseUrl(baseUrl: shards.baseUrl) - ] - } else if (shards?.baseUrls) { - return shards.baseUrls - } - - return [new BaseUrl(baseUrl: baseUrl)] - } -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/ServiceConfiguration.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/ServiceConfiguration.groovy deleted file mode 100644 index f3a2068032..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/ServiceConfiguration.groovy +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2015 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.config - -import groovy.transform.CompileStatic -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.context.ApplicationContext -import org.springframework.stereotype.Component -import retrofit.Endpoint - -import javax.annotation.PostConstruct - -import static retrofit.Endpoints.newFixedEndpoint -import static retrofit.Endpoints.newFixedEndpoint -import static retrofit.Endpoints.newFixedEndpoint - -@CompileStatic -@Component -@ConfigurationProperties -class ServiceConfiguration { - List healthCheckableServices - List discoveryHosts - Map services = [:] - Map integrations = [:] - - @Autowired - ApplicationContext ctx - - @PostConstruct - void postConstruct() { - // this check is done in a @PostConstruct to avoid Spring's list merging in @ConfigurationProperties (vs. overriding) - healthCheckableServices = healthCheckableServices ?: [ - "orca", "clouddriver", "echo", "igor", "flex", "front50", "mahe", "mine", "keel" - ] - } - - Service getService(String name) { - (services + integrations)[name] - } - - Endpoint getServiceEndpoint(String serviceName, String dynamicName = null) { - Service service = getService(serviceName) - - if (service == null) { - throw new IllegalArgumentException("Unknown service ${serviceName}") - } - - Endpoint endpoint - if (dynamicName == null) { - // TODO: move Netflix-specific logic out of the OSS implementation - endpoint = discoveryHosts && service.vipAddress ? - newFixedEndpoint("niws://${service.vipAddress}") - : newFixedEndpoint(service.baseUrl) - } else { - if (!service.getConfig().containsKey("dynamicEndpoints")) { - throw new IllegalArgumentException("Unknown dynamicEndpoint ${dynamicName} for service ${serviceName}") - } - endpoint = newFixedEndpoint(((Map) service.getConfig().get("dynamicEndpoints")).get(dynamicName)) - } - - return endpoint - } - - -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/filters/CorsFilter.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/filters/CorsFilter.groovy deleted file mode 100644 index 27cbd48c65..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/filters/CorsFilter.groovy +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2017 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.filters - -import com.netflix.spinnaker.gate.config.Headers -import groovy.util.logging.Slf4j -import net.logstash.logback.argument.StructuredArguments - -import javax.servlet.Filter -import javax.servlet.FilterChain -import javax.servlet.FilterConfig -import javax.servlet.ServletException -import javax.servlet.ServletRequest -import javax.servlet.ServletResponse -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse - -@Slf4j -class CorsFilter implements Filter { - - private final OriginValidator originValidator - - CorsFilter(OriginValidator originValidator) { - this.originValidator = originValidator - } - - @Override - void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) - throws IOException, ServletException { - HttpServletResponse response = (HttpServletResponse) res - HttpServletRequest request = (HttpServletRequest) req - String origin = request.getHeader("Origin") - if (!originValidator.isValidOrigin(origin)) { - origin = '*' - } else if (!originValidator.isExpectedOrigin(origin)) { - log.debug("CORS request with full authentication support from non-default origin header. Request Method: {}. Origin header: {}.", - StructuredArguments.kv("requestMethod", request.getMethod()), - StructuredArguments.kv("origin", origin)) - } - response.setHeader("Access-Control-Allow-Credentials", "true") - response.setHeader("Access-Control-Allow-Origin", origin) - response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT, PATCH") - response.setHeader("Access-Control-Max-Age", "3600") - response.setHeader("Access-Control-Allow-Headers", "x-requested-with, content-type, authorization, X-RateLimit-App, X-Spinnaker-Priority") - response.setHeader("Access-Control-Expose-Headers", [Headers.AUTHENTICATION_REDIRECT_HEADER_NAME].join(", ")) - chain.doFilter(req, res) - } - - @Override - void init(FilterConfig filterConfig) {} - - @Override - void destroy() {} - -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/filters/FiatSessionFilter.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/filters/FiatSessionFilter.groovy deleted file mode 100644 index 7e07baa48b..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/filters/FiatSessionFilter.groovy +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2017 Google, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.filters - -import com.netflix.spinnaker.fiat.model.UserPermission -import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator -import com.netflix.spinnaker.fiat.shared.FiatStatus -import com.netflix.spinnaker.security.AuthenticatedRequest -import groovy.util.logging.Slf4j -import org.springframework.security.core.context.SecurityContextHolder - -import javax.servlet.Filter -import javax.servlet.FilterChain -import javax.servlet.FilterConfig -import javax.servlet.ServletException -import javax.servlet.ServletRequest -import javax.servlet.ServletResponse -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpSession - -import static net.logstash.logback.argument.StructuredArguments.value - -@Slf4j -class FiatSessionFilter implements Filter { - - boolean enabled - - FiatStatus fiatStatus - - FiatPermissionEvaluator permissionEvaluator - - FiatSessionFilter(boolean enabled, - FiatStatus fiatStatus, - FiatPermissionEvaluator permissionEvaluator) { - this.enabled = enabled - this.fiatStatus = fiatStatus - this.permissionEvaluator = permissionEvaluator - } - - /** - * This filter checks if the user has an entry in Fiat, and if not, forces them to re-login. This - * is handy for (re)populating the Fiat user repo for a deployment with existing users & sessions. - */ - @Override - void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - UserPermission.View fiatPermission = null - - if (fiatStatus.isEnabled() && this.enabled) { - String user = AuthenticatedRequest.getSpinnakerUser().orElse(null) - log.debug("Fiat session filter - found user: ${user}") - - if (user != null) { - fiatPermission = permissionEvaluator.getPermission(user) - if (fiatPermission == null) { - HttpServletRequest httpReq = (HttpServletRequest) request - HttpSession session = httpReq.getSession(false) - if (session != null) { - log.info("Invalidating user '{}' session '{}' because Fiat permission was not found.", - value("user", user), - value("session", session)) - session.invalidate() - SecurityContextHolder.clearContext() - } - } - } else { - log.warn("Authenticated user was not present in authenticated request. Check authentication settings.") - } - } else { - if (log.isDebugEnabled()) { - log.debug("Skipping Fiat session filter: Both `services.fiat.enabled` " + - "(${fiatStatus.isEnabled()}) and the FiatSessionFilter (${this.enabled}) " + - "need to be enabled.") - } - } - - try { - chain.doFilter(request, response) - } finally { - if (fiatPermission != null && fiatPermission.isLegacyFallback()) { - log.info("Invalidating fallback permissions for ${fiatPermission.name}") - permissionEvaluator.invalidatePermission(fiatPermission.name) - } - } - } - - @Override - void init(FilterConfig filterConfig) throws ServletException { - } - - @Override - void destroy() { - } -} - diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/filters/GateOriginValidator.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/filters/GateOriginValidator.groovy deleted file mode 100644 index 5104a7df0a..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/filters/GateOriginValidator.groovy +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2017 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.filters - -import java.util.regex.Pattern - -class GateOriginValidator implements OriginValidator { - private final URI deckUri - private final Pattern redirectHosts - private final Pattern allowedOrigins - private final boolean expectLocalhost - - GateOriginValidator(String deckUri, String redirectHostsPattern, String allowedOriginsPattern, boolean expectLocalhost) { - this.deckUri = deckUri ? deckUri.toURI() : null - this.redirectHosts = redirectHostsPattern ? Pattern.compile(redirectHostsPattern) : null - this.allowedOrigins = allowedOriginsPattern ? Pattern.compile(allowedOriginsPattern) : null - this.expectLocalhost = expectLocalhost - } - - boolean isExpectedOrigin(String origin) { - if (!origin) { - return false - } - - if (!deckUri) { - return false - } - - try { - def uri = URI.create(origin) - if (!(uri.scheme && uri.host)) { - return false - } - - if (expectLocalhost && uri.host.equalsIgnoreCase("localhost")) { - return true - } - - return deckUri.scheme == uri.scheme && deckUri.host == uri.host && deckUri.port == uri.port - } catch (URISyntaxException use) { - return false - } - } - - @Override - boolean isValidOrigin(String origin) { - if (!origin) { - return false - } - - try { - def uri = URI.create(origin) - if (!(uri.scheme && uri.host)) { - return false - } - - if (allowedOrigins) { - return allowedOrigins.matcher(origin).matches() - } - - if (redirectHosts) { - return redirectHosts.matcher(uri.host).matches() - } - - if (!deckUri) { - return false - } - - return deckUri.scheme == uri.scheme && deckUri.host == uri.host && deckUri.port == uri.port - } catch (URISyntaxException) { - return false - } - } -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DataCenterInfo.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DataCenterInfo.groovy deleted file mode 100644 index 01fd0fbe85..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DataCenterInfo.groovy +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2014 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -package com.netflix.spinnaker.gate.model.discovery - -import groovy.transform.EqualsAndHashCode - -@EqualsAndHashCode -class DataCenterInfo { - String name - DataCenterMetadata metadata -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DiscoveryApplication.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DiscoveryApplication.groovy deleted file mode 100644 index 8889e05050..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DiscoveryApplication.groovy +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2014 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -package com.netflix.spinnaker.gate.model.discovery -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.annotation.JsonRootName -import groovy.transform.EqualsAndHashCode - -import java.util.concurrent.ThreadLocalRandom - -@JsonRootName("application") -@EqualsAndHashCode -class DiscoveryApplication { - - static DiscoveryInstance getRandomUpInstance(List apps) { - List candidates = apps.collect { it.instances.findAll { it.status == 'UP' && it.port.enabled } }.flatten() - if (!candidates) { - return null - } - candidates[ThreadLocalRandom.current().nextInt(candidates.size())] - } - - String name - - @JsonProperty('instance') - List instances -} - diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DiscoveryApplications.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DiscoveryApplications.groovy deleted file mode 100644 index 2b3850cc6c..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DiscoveryApplications.groovy +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2014 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.model.discovery - -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.annotation.JsonRootName -import groovy.transform.EqualsAndHashCode - -@JsonRootName('applications') -@EqualsAndHashCode -class DiscoveryApplications { - @JsonProperty('application') - List applications -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DiscoveryInstance.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DiscoveryInstance.groovy deleted file mode 100644 index ad25dfa827..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DiscoveryInstance.groovy +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2014 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -package com.netflix.spinnaker.gate.model.discovery - -import com.fasterxml.jackson.annotation.JsonCreator -import com.fasterxml.jackson.annotation.JsonProperty -import groovy.transform.* - -@CompileStatic -@Immutable -@EqualsAndHashCode(cache = true) -class DiscoveryInstance { - public static final String HEALTH_TYPE = 'Discovery' - - public String getType() { - HEALTH_TYPE - } - String hostName - Port port - Port securePort - String application - String ipAddress - String status - String overriddenStatus - String state - - String availabilityZone - String instanceId - String amiId - String instanceType - - String healthCheckUrl - String vipAddress - Long lastUpdatedTimestamp - String asgName - - @JsonCreator - public static DiscoveryInstance buildInstance(@JsonProperty('hostName') String hostName, - @JsonProperty('port') Port port, - @JsonProperty('securePort') Port securePort, - @JsonProperty('app') String app, - @JsonProperty('ipAddr') String ipAddr, - @JsonProperty('status') String status, - @JsonProperty('overriddenstatus') String overriddenstatus, - @JsonProperty('dataCenterInfo') DataCenterInfo dataCenterInfo, - @JsonProperty('healthCheckUrl') String healthCheckUrl, - @JsonProperty('vipAddress') String vipAddress, - @JsonProperty('lastUpdatedTimestamp') long lastUpdatedTimestamp, - @JsonProperty('asgName') String asgName) { - def meta = dataCenterInfo.metadata - new DiscoveryInstance( - hostName, - port, - securePort, - app, - ipAddr, - status, - overriddenstatus, - status, - meta?.availabilityZone, - meta?.instanceId, - meta?.amiId, - meta?.instanceType, - healthCheckUrl, - vipAddress, - lastUpdatedTimestamp, - asgName) - } -} - - diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/Port.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/Port.groovy deleted file mode 100644 index d3e11a8097..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/Port.groovy +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2014 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.model.discovery - -import com.fasterxml.jackson.annotation.JsonCreator -import com.fasterxml.jackson.annotation.JsonProperty -import groovy.transform.CompileStatic -import groovy.transform.Immutable - -@CompileStatic -@Immutable -class Port { - boolean enabled - int port - - @JsonCreator - public static Port buildPort(@JsonProperty('@enabled') boolean enabled, @JsonProperty('$') int port) { - new Port(enabled, port) - } -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/retrofit/Slf4jRetrofitLogger.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/retrofit/Slf4jRetrofitLogger.groovy deleted file mode 100644 index 77cd103ebb..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/retrofit/Slf4jRetrofitLogger.groovy +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2014 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.retrofit - -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import retrofit.RestAdapter - -class Slf4jRetrofitLogger implements RestAdapter.Log { - private final Logger logger - - public Slf4jRetrofitLogger(Class type) { - this(LoggerFactory.getLogger(type)) - } - - public Slf4jRetrofitLogger(Logger logger) { - this.logger = logger - } - - @Override - void log(String message) { - logger.info(message) - } -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/anonymous/AnonymousConfig.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/anonymous/AnonymousConfig.groovy deleted file mode 100644 index 3e073e32ac..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/anonymous/AnonymousConfig.groovy +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2015 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.security.anonymous - -import com.netflix.spinnaker.fiat.shared.FiatStatus -import com.netflix.spinnaker.gate.security.SpinnakerAuthConfig -import com.netflix.spinnaker.gate.services.CredentialsService -import com.netflix.spinnaker.security.User -import groovy.util.logging.Slf4j -import org.apache.commons.lang3.exception.ExceptionUtils -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean -import org.springframework.context.annotation.Configuration -import org.springframework.core.Ordered -import org.springframework.core.annotation.Order -import org.springframework.scheduling.annotation.Scheduled -import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity -import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter - -import java.util.concurrent.CopyOnWriteArrayList - -/** - * Requires auth.anonymous.enabled to be true in Fiat configs to work properly. This - * is because anonymous users are a special permissions case, because the "user" doesn't actually - * exist in the backing UserRolesProvider. - */ -@ConditionalOnMissingBean(annotation = SpinnakerAuthConfig.class) -@Configuration -@Slf4j -@EnableWebSecurity -@Order(Ordered.LOWEST_PRECEDENCE) -class AnonymousConfig extends WebSecurityConfigurerAdapter { - static String key = "spinnaker-anonymous" - static String defaultEmail = "anonymous" - - @Autowired - CredentialsService credentialsService - - @Autowired - FiatStatus fiatStatus - - List anonymousAllowedAccounts = new CopyOnWriteArrayList<>() - - void configure(HttpSecurity http) { - updateAnonymousAccounts() - // Not using the ImmutableUser version in order to update allowedAccounts. - def principal = new User(email: defaultEmail, allowedAccounts: anonymousAllowedAccounts) - - http - .anonymous() - .key(key) - .principal(principal) - .and() - .csrf() - .disable() - } - - @Scheduled(fixedDelay = 60000L) - void updateAnonymousAccounts() { - if (fiatStatus.isEnabled()) { - return - } - - try { - def newAnonAccounts = credentialsService.getAccountNames([]) ?: [] - - def toAdd = newAnonAccounts - anonymousAllowedAccounts - def toRemove = anonymousAllowedAccounts - newAnonAccounts - - anonymousAllowedAccounts.removeAll(toRemove) - anonymousAllowedAccounts.addAll(toAdd) - } catch (Exception e) { - log.warn("Error while updating anonymous accounts", e) - } - } -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/CredentialsService.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/CredentialsService.groovy deleted file mode 100644 index a9201cbec2..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/CredentialsService.groovy +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2014 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.services - -import com.netflix.spinnaker.fiat.shared.FiatStatus -import com.netflix.spinnaker.gate.services.internal.ClouddriverService.AccountDetails -import groovy.util.logging.Slf4j -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.stereotype.Service - -@Slf4j -@Service -class CredentialsService { - @Autowired - AccountLookupService accountLookupService - - @Autowired - FiatStatus fiatStatus - - Collection getAccountNames(Collection userRoles) { - getAccounts(userRoles, false)*.name - } - - Collection getAccountNames(Collection userRoles, boolean ignoreFiatStatus) { - getAccounts(userRoles, ignoreFiatStatus)*.name - } - - /** - * Returns all account names that a user with the specified list of userRoles has access to. - */ - List getAccounts(Collection userRoles, boolean ignoreFiatStatus) { - final Set userRolesLower = userRoles*.toLowerCase() as Set - return accountLookupService.getAccounts().findAll { AccountDetails account -> - if (!ignoreFiatStatus && fiatStatus.isEnabled()) { - return true // Returned list is filtered later. - } - - if (!account.permissions) { - return true - } - - Set permissions = account.permissions.WRITE*.toLowerCase() ?: [] - - return userRolesLower.intersect(permissions) as Boolean - } ?: [] - } -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/DefaultProviderLookupService.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/DefaultProviderLookupService.groovy deleted file mode 100644 index fc584e3839..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/DefaultProviderLookupService.groovy +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright 2016 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.services - -import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.databind.ObjectMapper -import com.google.common.util.concurrent.UncheckedExecutionException -import com.netflix.spinnaker.gate.services.internal.ClouddriverService -import com.netflix.spinnaker.gate.services.internal.ClouddriverService.AccountDetails -import com.netflix.spinnaker.security.AuthenticatedRequest -import groovy.util.logging.Slf4j -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.scheduling.annotation.Scheduled -import org.springframework.stereotype.Component - -import java.util.concurrent.ExecutionException -import java.util.concurrent.atomic.AtomicReference - -/** - * DefaultProviderLookupService. - */ -@Slf4j -@Component("providerLookupService") -class DefaultProviderLookupService implements ProviderLookupService, AccountLookupService { - - private static final String FALLBACK = "unknown" - private static final TypeReference> JSON_LIST = new TypeReference>() {} - private static final TypeReference> ACCOUNT_DETAILS_LIST = new TypeReference>() {} - - private final ClouddriverService clouddriverService - private final ObjectMapper mapper = new ObjectMapper() - - private final AtomicReference> accountsCache = new AtomicReference<>([]) - - @Autowired - DefaultProviderLookupService(ClouddriverService clouddriverService) { - this.clouddriverService = clouddriverService - } - - @Scheduled(fixedDelay = 30000L) - void refreshCache() { - try { - def accounts = AuthenticatedRequest.allowAnonymous { clouddriverService.getAccountDetails() } - //migration support, prefer permissions configuration, translate requiredGroupMembership - // (for credentialsservice in non fiat mode) into permissions collection. - // - // Ignore explicitly set requiredGroupMemberships if permissions are also present. - for (account in accounts) { - if (account.permissions != null) { - account.permissions = account.permissions.collectEntries { String perm, Collection roles -> - Set rolesLower = roles*.toLowerCase() - [(perm): rolesLower] - } - if (account.requiredGroupMembership) { - Set rgmSet = account.requiredGroupMembership*.toLowerCase() - if (account.permissions.WRITE != rgmSet) { - log.warn("on Account $account.name: preferring permissions: $account.permissions over requiredGroupMemberships: $rgmSet for authz decision") - } - } - - } else { - account.requiredGroupMembership = account.requiredGroupMembership.collect { it.toLowerCase() } - if (account.requiredGroupMembership) { - account.permissions = [READ: account.requiredGroupMembership, WRITE: account.requiredGroupMembership] - } else { - account.permissions = [:] - } - } - } - accountsCache.set(accounts) - } catch (Exception e) { - log.error("Unable to refresh account details cache", e) - } - } - - @Override - String providerForAccount(String account) { - try { - return accountsCache.get()?.find { it.name == account }?.type ?: FALLBACK - } catch (ExecutionException | UncheckedExecutionException ex) { - return FALLBACK - } - } - - @Override - List getAccounts() { - final List original = accountsCache.get() - final List accountsCopy = mapper.convertValue(original, JSON_LIST) - return mapper.convertValue(accountsCopy, ACCOUNT_DETAILS_LIST) - } -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/EurekaLookupService.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/EurekaLookupService.groovy deleted file mode 100644 index 579d17f5b0..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/EurekaLookupService.groovy +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2014 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.services - -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import com.jakewharton.retrofit.Ok3Client -import com.netflix.spinnaker.gate.config.ServiceConfiguration -import com.netflix.spinnaker.gate.model.discovery.DiscoveryApplication -import com.netflix.spinnaker.gate.retrofit.Slf4jRetrofitLogger -import com.netflix.spinnaker.gate.services.internal.EurekaService -import groovy.transform.Immutable -import okhttp3.OkHttpClient -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.stereotype.Component -import retrofit.RestAdapter -import retrofit.RetrofitError -import retrofit.converter.JacksonConverter - -import javax.annotation.PostConstruct -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit - -import static retrofit.Endpoints.newFixedEndpoint - -@Component -class EurekaLookupService { - private static final Map instanceCache = new ConcurrentHashMap<>() - - @Autowired - ServiceConfiguration serviceConfiguration - - @Autowired - OkHttpClient okHttpClient - - @PostConstruct - void init() { - Executors.newScheduledThreadPool(1).scheduleAtFixedRate({ - for (vip in instanceCache.keySet()) { - def cached = instanceCache[vip] - if (cached.expired) { - getApplications(vip) - } - } - }, 0, 30, TimeUnit.SECONDS) - } - - List getApplications(String vip) { - if (instanceCache.containsKey(vip) && !instanceCache[vip].expired) { - return instanceCache[vip].applications - } - List hosts = [] - hosts.addAll(serviceConfiguration.discoveryHosts) - Collections.shuffle(hosts) - - def app = null - for (host in hosts) { - EurekaService eureka = getEurekaService(host) - try { - app = eureka.getVips(vip) - if (app && app.applications) { - instanceCache[vip] = new CachedDiscoveryApplication(applications: app.applications) - break - } - } catch (RetrofitError e) { - if (e.response.status != 404) { - throw e - } - } - } - if (!app) { - return null - } - app.applications - } - - private EurekaService getEurekaService(String host) { - def endpoint = newFixedEndpoint(host) - new RestAdapter.Builder() - .setEndpoint(endpoint) - .setConverter(new JacksonConverter(new ObjectMapper().configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true))) - .setClient(new Ok3Client(okHttpClient)) - .setLogLevel(RestAdapter.LogLevel.BASIC) - .setLog(new Slf4jRetrofitLogger(EurekaService)) - .build() - .create(EurekaService) - } - - @Immutable(knownImmutables = ["applications"]) - static class CachedDiscoveryApplication { - private final Long ttl = TimeUnit.SECONDS.toMillis(60) - private final Long cacheTime = System.currentTimeMillis() - final List applications - - boolean isExpired() { - (System.currentTimeMillis() - cacheTime) > ttl - } - } -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/PermissionService.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/PermissionService.groovy deleted file mode 100644 index da34d17409..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/PermissionService.groovy +++ /dev/null @@ -1,201 +0,0 @@ -/* - * Copyright 2016 Google, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.services - - -import com.netflix.spinnaker.fiat.model.UserPermission -import com.netflix.spinnaker.fiat.model.resources.Role -import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator -import com.netflix.spinnaker.fiat.shared.FiatService -import com.netflix.spinnaker.fiat.shared.FiatStatus -import com.netflix.spinnaker.gate.security.SpinnakerUser -import com.netflix.spinnaker.gate.services.internal.ExtendedFiatService -import com.netflix.spinnaker.kork.core.RetrySupport -import com.netflix.spinnaker.kork.exceptions.SpinnakerException -import com.netflix.spinnaker.kork.exceptions.SystemException -import com.netflix.spinnaker.security.AuthenticatedRequest -import com.netflix.spinnaker.security.User -import groovy.transform.PackageScope -import groovy.util.logging.Slf4j -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Component -import retrofit.RetrofitError - -import javax.annotation.Nonnull -import java.time.Duration - -import static com.netflix.spinnaker.gate.retrofit.UpstreamBadRequest.classifyError - -@Slf4j -@Component -class PermissionService { - - @Autowired - FiatService fiatService - - @Autowired - ExtendedFiatService extendedFiatService - - @Autowired - ServiceAccountFilterConfigProps serviceAccountFilterConfigProps - - @Autowired - @Qualifier("fiatLoginService") - Optional fiatLoginService - - @Autowired - FiatPermissionEvaluator permissionEvaluator - - @Autowired - FiatStatus fiatStatus - - boolean isEnabled() { - return fiatStatus.isEnabled() - } - - private FiatService getFiatServiceForLogin() { - return fiatLoginService.orElse(fiatService); - } - - void login(String userId) { - if (fiatStatus.isEnabled()) { - try { - AuthenticatedRequest.allowAnonymous({ - fiatServiceForLogin.loginUser(userId, "") - permissionEvaluator.invalidatePermission(userId) - }) - } catch (RetrofitError e) { - throw classifyError(e) - } - } - } - - void loginWithRoles(String userId, Collection roles) { - if (fiatStatus.isEnabled()) { - try { - AuthenticatedRequest.allowAnonymous({ - fiatServiceForLogin.loginWithRoles(userId, roles) - permissionEvaluator.invalidatePermission(userId) - }) - } catch (RetrofitError e) { - throw classifyError(e) - } - } - } - - void logout(String userId) { - if (fiatStatus.isEnabled()) { - try { - fiatServiceForLogin.logoutUser(userId) - permissionEvaluator.invalidatePermission(userId) - } catch (RetrofitError e) { - throw classifyError(e) - } - } - } - - void sync() { - if (fiatStatus.isEnabled()) { - try { - fiatServiceForLogin.sync(Collections.emptyList()) - } catch (RetrofitError e) { - throw classifyError(e) - } - } - } - - Set getRoles(String userId) { - if (!fiatStatus.isEnabled()) { - return [] - } - try { - return permissionEvaluator.getPermission(userId)?.roles ?: [] - } catch (RetrofitError e) { - throw classifyError(e) - } - } - - //VisibleForTesting - @PackageScope List lookupServiceAccounts(String userId) { - try { - return extendedFiatService.getUserServiceAccounts(userId) - } catch (RetrofitError re) { - boolean notFound = re.response?.status == HttpStatus.NOT_FOUND.value() - if (notFound) { - return [] - } - boolean shouldRetry = re.response == null || HttpStatus.valueOf(re.response.status).is5xxServerError() - throw new SystemException("getUserServiceAccounts failed", re).setRetryable(shouldRetry) - } - } - - List getServiceAccountsForApplication(@SpinnakerUser User user, @Nonnull String application) { - if (!serviceAccountFilterConfigProps.enabled || - !user || - !application || - !fiatStatus.enabled || - serviceAccountFilterConfigProps.matchAuthorizations.isEmpty()) { - return getServiceAccounts(user); - } - - List filteredServiceAccounts - RetrySupport retry = new RetrySupport() - try { - List serviceAccounts = retry.retry({ lookupServiceAccounts(user.username) }, 3, Duration.ofMillis(50), false) - - filteredServiceAccounts = serviceAccounts.findResults { - if (it.applications.find { it.name.equalsIgnoreCase(application) && it.authorizations.find { serviceAccountFilterConfigProps.matchAuthorizations.contains(it) } }) { - return it.name - } - return null - } - } catch (SpinnakerException se) { - log.error("failed to lookup user {} service accounts for application {}, falling back to all user service accounts", user, application, se) - return getServiceAccounts(user) - } - - // if there are no service accounts for the requested application, fall back to the full list of service accounts for the user - // to avoid a chicken and egg problem of trying to enable security for the first time on an application - return filteredServiceAccounts ?: getServiceAccounts(user) - } - - List getServiceAccounts(@SpinnakerUser User user) { - - if (!user) { - log.debug("getServiceAccounts: Spinnaker user is null.") - return [] - } - - if (!fiatStatus.isEnabled()) { - log.debug("getServiceAccounts: Fiat disabled.") - return [] - } - - try { - UserPermission.View view = permissionEvaluator.getPermission(user.username) - return view.getServiceAccounts().collect { it.name } - } catch (RetrofitError re) { - throw classifyError(re) - } - } - - boolean isAdmin(String userId) { - return permissionEvaluator.getPermission(userId)?.isAdmin() - } -} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/internal/EurekaService.groovy b/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/internal/EurekaService.groovy deleted file mode 100644 index 12d0e5ddd0..0000000000 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/internal/EurekaService.groovy +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2014 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.services.internal - -import com.netflix.spinnaker.gate.model.discovery.DiscoveryApplications -import retrofit.http.GET -import retrofit.http.Headers -import retrofit.http.Path - -interface EurekaService { - - @Headers("Accept: application/json") - @GET("/discovery/v2/vips/{vipAddress}") - DiscoveryApplications getVips(@Path("vipAddress") String vipAddress) - - @Headers("Accept: application/json") - @GET("/discovery/v2/svips/{secureVipAddress}") - DiscoveryApplications getSecureVips(@Path("secureVipAddress") String secureVipAddress) -} diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/config/AuthConfig.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/AuthConfig.java new file mode 100644 index 0000000000..a1c68c287c --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/AuthConfig.java @@ -0,0 +1,120 @@ +/* + * Copyright 2016 Netflix, Inc. + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.config; + +import com.netflix.spinnaker.fiat.shared.FiatClientConfigurationProperties; +import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator; +import com.netflix.spinnaker.fiat.shared.FiatStatus; +import com.netflix.spinnaker.gate.filters.FiatSessionFilter; +import com.netflix.spinnaker.gate.services.ServiceAccountFilterConfigProps; +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; + +@Configuration +@EnableConfigurationProperties({ + ServiceConfiguration.class, + ServiceAccountFilterConfigProps.class, + FiatClientConfigurationProperties.class, + DynamicRoutingConfigProperties.class +}) +@NonnullByDefault +@RequiredArgsConstructor +public class AuthConfig { + private final PermissionRevokingLogoutSuccessHandler permissionRevokingLogoutSuccessHandler; + private final FiatStatus fiatStatus; + private final FiatPermissionEvaluator permissionEvaluator; + private final RequestMatcherProvider requestMatcherProvider; + + @Setter( + onMethod_ = {@Autowired}, + onParam_ = {@Value("${security.debug:false}")}) + private boolean securityDebug; + + @Setter( + onMethod_ = {@Autowired}, + onParam_ = {@Value("${fiat.session-filter.enabled:true}")}) + private boolean fiatSessionFilterEnabled; + + @Setter( + onMethod_ = {@Autowired}, + onParam_ = {@Value("${security.webhooks.default-auth-enabled:false}")}) + private boolean webhookDefaultAuthEnabled; + + @Bean + public WebSecurityCustomizer securityDebugCustomizer() { + return web -> web.debug(securityDebug); + } + + public void configure(HttpSecurity http) throws Exception { + http.requestMatcher(requestMatcherProvider.requestMatcher()) + .authorizeRequests( + registry -> { + registry + .antMatchers("/error") + .permitAll() + .antMatchers("/favicon.ico") + .permitAll() + .antMatchers(HttpMethod.OPTIONS, "/**") + .permitAll() + .antMatchers(PermissionRevokingLogoutSuccessHandler.LOGGED_OUT_URL) + .permitAll() + .antMatchers("/auth/user") + .permitAll() + .antMatchers("/plugins/deck/**") + .permitAll(); + var webhooks = registry.antMatchers(HttpMethod.POST, "/webhooks/**"); + if (webhookDefaultAuthEnabled) { + webhooks.authenticated(); + } else { + webhooks.permitAll(); + } + registry + .antMatchers(HttpMethod.POST, "/notifications/callbacks/**") + .permitAll() + .antMatchers(HttpMethod.POST, "/managed/notifications/callbacks/**") + .permitAll() + .antMatchers("/health") + .permitAll() + .antMatchers("/**") + .authenticated(); + }) + .logout( + logout -> + logout + .logoutUrl("/auth/logout") + .logoutSuccessHandler(permissionRevokingLogoutSuccessHandler) + .permitAll()) + .csrf(AbstractHttpConfigurer::disable); + + if (fiatSessionFilterEnabled) { + var filter = new FiatSessionFilter(fiatStatus, permissionEvaluator); + http.addFilterBefore(filter, AnonymousAuthenticationFilter.class); + } + } +} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/Headers.groovy b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/Headers.java similarity index 87% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/Headers.groovy rename to gate-core/src/main/java/com/netflix/spinnaker/gate/config/Headers.java index 4c66162b0e..a09ed8da96 100644 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/Headers.groovy +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/Headers.java @@ -14,8 +14,8 @@ * limitations under the License. */ -package com.netflix.spinnaker.gate.config +package com.netflix.spinnaker.gate.config; -class Headers { - public static final String AUTHENTICATION_REDIRECT_HEADER_NAME = "X-AUTH-REDIRECT-URL" +public class Headers { + public static final String AUTHENTICATION_REDIRECT_HEADER_NAME = "X-AUTH-REDIRECT-URL"; } diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/MultiAuthSupport.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/MultiAuthSupport.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/MultiAuthSupport.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/config/MultiAuthSupport.java diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/config/PermissionRevokingLogoutSuccessHandler.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/PermissionRevokingLogoutSuccessHandler.java new file mode 100644 index 0000000000..4a4b48c057 --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/PermissionRevokingLogoutSuccessHandler.java @@ -0,0 +1,59 @@ +/* + * Copyright 2016 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.config; + +import com.netflix.spinnaker.gate.services.PermissionService; +import java.io.IOException; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PermissionRevokingLogoutSuccessHandler + implements LogoutSuccessHandler, InitializingBean { + public static final String LOGGED_OUT_URL = "/auth/loggedOut"; + + private final PermissionService permissionService; + private final SimpleUrlLogoutSuccessHandler delegate = new SimpleUrlLogoutSuccessHandler(); + + @Override + public void afterPropertiesSet() throws Exception { + delegate.setDefaultTargetUrl(LOGGED_OUT_URL); + } + + @Override + public void onLogoutSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException, ServletException { + if (authentication != null) { + var principal = authentication.getPrincipal(); + if (principal instanceof UserDetails) { + var username = ((UserDetails) principal).getUsername(); + permissionService.logout(username); + } + } + delegate.onLogoutSuccess(request, response, authentication); + } +} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/RequestMatcherProvider.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/RequestMatcherProvider.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/config/RequestMatcherProvider.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/config/RequestMatcherProvider.java diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/config/Service.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/Service.java new file mode 100644 index 0000000000..a2b8345b1e --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/Service.java @@ -0,0 +1,73 @@ +/* + * Copyright 2015 Netflix, Inc. + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.config; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +@Getter +@Setter +public class Service { + private boolean enabled = true; + private String baseUrl; + private MultiBaseUrl shards; + private Map config = new LinkedHashMap<>(); + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + } + + public List getBaseUrls() { + if (shards != null) { + String baseUrl = shards.getBaseUrl(); + if (StringUtils.hasLength(baseUrl)) { + return List.of(new BaseUrl(baseUrl)); + } + List baseUrls = shards.getBaseUrls(); + if (!CollectionUtils.isEmpty(baseUrls)) { + return baseUrls; + } + } + return List.of(new BaseUrl(baseUrl)); + } + + @Getter + @Setter + public static class MultiBaseUrl { + private String baseUrl; + private List baseUrls; + } + + @Getter + @Setter + @NoArgsConstructor + @RequiredArgsConstructor + public static class BaseUrl { + @Nonnull private String baseUrl; + private int priority = 1; + private Map config = new LinkedHashMap<>(); + } +} diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/config/ServiceConfiguration.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/ServiceConfiguration.java new file mode 100644 index 0000000000..dd86d96fc6 --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/ServiceConfiguration.java @@ -0,0 +1,89 @@ +/* + * Copyright 2015 Netflix, Inc. + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.config; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +import retrofit.Endpoint; +import retrofit.Endpoints; + +@Getter +@Setter +@Component +@ConfigurationProperties +public class ServiceConfiguration { + private static final String DYNAMIC_ENDPOINTS = "dynamicEndpoints"; + + private List healthCheckableServices = new ArrayList<>(); + private Map services = new LinkedHashMap<>(); + private Map integrations = new LinkedHashMap<>(); + + @PostConstruct + void postConstruct() { + // this check is done in a @PostConstruct to avoid Spring's list merging in + // @ConfigurationProperties (vs. overriding) + if (CollectionUtils.isEmpty(healthCheckableServices)) { + healthCheckableServices = + List.of("orca", "clouddriver", "echo", "igor", "flex", "front50", "mahe", "mine", "keel"); + } + } + + @Nullable + public Service getService(@Nonnull String name) { + if (services.containsKey(name)) { + return services.get(name); + } + return integrations.get(name); + } + + @Nonnull + public Endpoint getServiceEndpoint(@Nonnull String serviceName) { + return getServiceEndpoint(serviceName, null); + } + + @Nonnull + public Endpoint getServiceEndpoint(@Nonnull String serviceName, @Nullable String dynamicName) { + Service service = getService(serviceName); + if (service == null) { + throw new IllegalArgumentException("Unknown service " + serviceName); + } + if (dynamicName == null) { + String serviceBaseUrl = service.getBaseUrl(); + return Endpoints.newFixedEndpoint(serviceBaseUrl); + } + Map config = service.getConfig(); + if (!config.containsKey(DYNAMIC_ENDPOINTS)) { + throw new IllegalArgumentException( + String.format("Unknown dynamicEndpoint %s for service %s", dynamicName, serviceName)); + } + @SuppressWarnings("unchecked") + Map dynamicEndpoints = (Map) config.get(DYNAMIC_ENDPOINTS); + String dynamicEndpoint = dynamicEndpoints.get(dynamicName); + return Endpoints.newFixedEndpoint(dynamicEndpoint); + } +} diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/config/TaskServiceProperties.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/TaskServiceProperties.java new file mode 100644 index 0000000000..66efa0c2f9 --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/config/TaskServiceProperties.java @@ -0,0 +1,29 @@ +/* + * Copyright 2022 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties("task-service") +@Data +public class TaskServiceProperties { + private int maxNumberOfPolls = 32; + private int defaultIntervalBetweenPolls = 1000; +} diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/filters/CorsFilter.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/filters/CorsFilter.java new file mode 100644 index 0000000000..7331ff7393 --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/filters/CorsFilter.java @@ -0,0 +1,63 @@ +/* + * Copyright 2017 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.filters; + +import static net.logstash.logback.argument.StructuredArguments.kv; + +import com.netflix.spinnaker.gate.config.Headers; +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.web.filter.OncePerRequestFilter; + +@Log4j2 +@NonnullByDefault +@RequiredArgsConstructor +public class CorsFilter extends OncePerRequestFilter { + private final OriginValidator originValidator; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String origin = request.getHeader("Origin"); + if (!originValidator.isValidOrigin(origin)) { + origin = "*"; + } else if (!originValidator.isExpectedOrigin(origin)) { + log.debug( + "CORS request with full authentication support from non-default origin header. Request Method: {}. Origin header: {}.", + kv("requestMethod", request.getMethod()), + kv("origin", origin)); + } + + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setHeader("Access-Control-Allow-Origin", origin); + response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT, PATCH"); + response.setHeader("Access-Control-Max-Age", "3600"); + response.setHeader( + "Access-Control-Allow-Headers", + "x-requested-with, content-type, authorization, X-RateLimit-App, X-Spinnaker-Priority"); + response.setHeader( + "Access-Control-Expose-Headers", Headers.AUTHENTICATION_REDIRECT_HEADER_NAME); + chain.doFilter(request, response); + } +} diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/filters/FiatSessionFilter.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/filters/FiatSessionFilter.java new file mode 100644 index 0000000000..52dcb1fc03 --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/filters/FiatSessionFilter.java @@ -0,0 +1,94 @@ +/* + * Copyright 2017 Google, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.filters; + +import static net.logstash.logback.argument.StructuredArguments.value; + +import com.netflix.spinnaker.fiat.model.UserPermission; +import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator; +import com.netflix.spinnaker.fiat.shared.FiatStatus; +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import com.netflix.spinnaker.security.AuthenticatedRequest; +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import lombok.extern.log4j.Log4j2; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +@Log4j2 +@NonnullByDefault +public class FiatSessionFilter extends OncePerRequestFilter { + private final FiatStatus fiatStatus; + private final FiatPermissionEvaluator permissionEvaluator; + + public FiatSessionFilter(FiatStatus fiatStatus, FiatPermissionEvaluator permissionEvaluator) { + this.fiatStatus = fiatStatus; + this.permissionEvaluator = permissionEvaluator; + } + + /** + * This filter checks if the user has an entry in Fiat, and if not, forces them to re-login. This + * is handy for (re)populating the Fiat user repo for a deployment with existing users & sessions. + */ + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + UserPermission.View fiatPermission = null; + + if (fiatStatus.isEnabled()) { + final String user = AuthenticatedRequest.getSpinnakerUser().orElse(null); + log.debug("Fiat session filter - found user: {}", user); + + if (user != null) { + fiatPermission = permissionEvaluator.getPermission(user); + if (fiatPermission == null) { + HttpSession session = request.getSession(false); + if (session != null) { + log.info( + "Invalidating user '{}' session '{}' because Fiat permission was not found.", + value("user", user), + value("session", session)); + session.invalidate(); + SecurityContextHolder.clearContext(); + } + } + } else { + log.warn( + "Authenticated user was not present in authenticated request. Check authentication settings."); + } + + } else { + log.debug( + "Skipping Fiat session filter: Both `services.fiat.enabled` ({}) and the FiatSessionFilter need to be enabled.", + fiatStatus.isEnabled()); + } + + try { + chain.doFilter(request, response); + } finally { + if (fiatPermission != null && fiatPermission.isLegacyFallback()) { + log.info("Invalidating fallback permissions for {}", fiatPermission.getName()); + permissionEvaluator.invalidatePermission(fiatPermission.getName()); + } + } + } +} diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/filters/GateOriginValidator.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/filters/GateOriginValidator.java new file mode 100644 index 0000000000..5053879a72 --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/filters/GateOriginValidator.java @@ -0,0 +1,101 @@ +/* + * Copyright 2017 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.filters; + +import java.net.URI; +import java.util.regex.Pattern; +import javax.annotation.Nullable; +import org.springframework.util.StringUtils; + +public class GateOriginValidator implements OriginValidator { + private final URI deckUri; + private final Pattern redirectHosts; + private final Pattern allowedOrigins; + private final boolean expectLocalhost; + + public GateOriginValidator( + @Nullable String deckUri, + @Nullable String redirectHostsPattern, + @Nullable String allowedOriginsPattern, + boolean expectLocalhost) { + this.deckUri = deckUri != null ? URI.create(deckUri) : null; + this.redirectHosts = + redirectHostsPattern != null ? Pattern.compile(redirectHostsPattern) : null; + this.allowedOrigins = + allowedOriginsPattern != null ? Pattern.compile(allowedOriginsPattern) : null; + this.expectLocalhost = expectLocalhost; + } + + public boolean isExpectedOrigin(String origin) { + if (!StringUtils.hasLength(origin)) { + return false; + } + + if (deckUri == null) { + return false; + } + + try { + URI uri = URI.create(origin); + if (!StringUtils.hasLength(uri.getScheme()) || !StringUtils.hasLength(uri.getHost())) { + return false; + } + + if (expectLocalhost && uri.getHost().equalsIgnoreCase("localhost")) { + return true; + } + + return deckUri.getScheme().equals(uri.getScheme()) + && deckUri.getHost().equals(uri.getHost()) + && deckUri.getPort() == uri.getPort(); + } catch (IllegalArgumentException ignored) { + return false; + } + } + + @Override + public boolean isValidOrigin(String origin) { + if (!StringUtils.hasLength(origin)) { + return false; + } + + try { + URI uri = URI.create(origin); + if (!StringUtils.hasLength(uri.getScheme()) || !StringUtils.hasLength(uri.getHost())) { + return false; + } + + if (allowedOrigins != null) { + return allowedOrigins.matcher(origin).matches(); + } + + if (redirectHosts != null) { + return redirectHosts.matcher(uri.getHost()).matches(); + } + + if (deckUri == null) { + return false; + } + + return deckUri.getScheme().equals(uri.getScheme()) + && deckUri.getHost().equals(uri.getHost()) + && deckUri.getPort() == uri.getPort(); + } catch (IllegalArgumentException ignored) { + return false; + } + } +} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/filters/OriginValidator.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/filters/OriginValidator.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/filters/OriginValidator.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/filters/OriginValidator.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/ConstraintState.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/ConstraintState.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/ConstraintState.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/ConstraintState.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/ConstraintStatus.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/ConstraintStatus.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/ConstraintStatus.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/ConstraintStatus.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/DeliveryConfig.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/DeliveryConfig.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/DeliveryConfig.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/DeliveryConfig.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/Environment.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/Environment.java similarity index 96% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/Environment.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/Environment.java index 148d519ff7..28e3397bf9 100644 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/Environment.java +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/Environment.java @@ -28,5 +28,6 @@ public class Environment { Collection> constraints; Collection notifications; Map locations; + List> postDeploy; List> verifyWith; } diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/EnvironmentArtifactPin.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/EnvironmentArtifactPin.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/EnvironmentArtifactPin.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/EnvironmentArtifactPin.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/EnvironmentArtifactVeto.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/EnvironmentArtifactVeto.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/EnvironmentArtifactVeto.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/EnvironmentArtifactVeto.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/GraphQLRequest.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/GraphQLRequest.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/GraphQLRequest.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/GraphQLRequest.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/Notification.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/Notification.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/Notification.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/Notification.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/OverrideVerificationRequest.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/OverrideVerificationRequest.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/OverrideVerificationRequest.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/OverrideVerificationRequest.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/Resource.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/Resource.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/Resource.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/Resource.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/RetryVerificationRequest.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/RetryVerificationRequest.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/manageddelivery/RetryVerificationRequest.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/model/manageddelivery/RetryVerificationRequest.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DataCenterMetadata.groovy b/gate-core/src/main/java/com/netflix/spinnaker/gate/retrofit/Slf4jRetrofitLogger.java similarity index 56% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DataCenterMetadata.groovy rename to gate-core/src/main/java/com/netflix/spinnaker/gate/retrofit/Slf4jRetrofitLogger.java index badabe3fa4..84a14175a0 100644 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/model/discovery/DataCenterMetadata.groovy +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/retrofit/Slf4jRetrofitLogger.java @@ -14,24 +14,25 @@ * limitations under the License. */ +package com.netflix.spinnaker.gate.retrofit; -package com.netflix.spinnaker.gate.model.discovery +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import retrofit.RestAdapter; -import com.fasterxml.jackson.annotation.JsonProperty -import groovy.transform.EqualsAndHashCode +public class Slf4jRetrofitLogger implements RestAdapter.Log { + public Slf4jRetrofitLogger(Class type) { + this(LoggerFactory.getLogger(type)); + } -@EqualsAndHashCode -class DataCenterMetadata { - @JsonProperty('availability-zone') - String availabilityZone + public Slf4jRetrofitLogger(Logger logger) { + this.logger = logger; + } - @JsonProperty('instance-id') - String instanceId + @Override + public void log(String message) { + logger.info(message); + } - @JsonProperty('ami-id') - String amiId - - @JsonProperty('instance-type') - String instanceType + private final Logger logger; } - diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/retrofit/UpstreamBadRequest.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/retrofit/UpstreamBadRequest.java similarity index 94% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/retrofit/UpstreamBadRequest.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/retrofit/UpstreamBadRequest.java index a84e5762ef..e0b38769b0 100644 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/retrofit/UpstreamBadRequest.java +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/retrofit/UpstreamBadRequest.java @@ -48,7 +48,7 @@ public Object getError() { return error; } - public static Exception classifyError(RetrofitError error) { + public static RuntimeException classifyError(RetrofitError error) { if (error.getKind() == HTTP && error.getResponse().getStatus() < INTERNAL_SERVER_ERROR.value()) { return new UpstreamBadRequest(error); @@ -57,7 +57,7 @@ public static Exception classifyError(RetrofitError error) { } } - public static Exception classifyError( + public static RuntimeException classifyError( RetrofitError error, Collection supportedHttpStatuses) { if (error.getKind() == HTTP && supportedHttpStatuses.contains(error.getResponse().getStatus())) { diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/AllowedAccountsSupport.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/security/AllowedAccountsSupport.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/AllowedAccountsSupport.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/security/AllowedAccountsSupport.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/RequestIdentityExtractor.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/security/RequestIdentityExtractor.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/RequestIdentityExtractor.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/security/RequestIdentityExtractor.java diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/SpinnakerAuthConfig.groovy b/gate-core/src/main/java/com/netflix/spinnaker/gate/security/SpinnakerAuthConfig.java similarity index 70% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/SpinnakerAuthConfig.groovy rename to gate-core/src/main/java/com/netflix/spinnaker/gate/security/SpinnakerAuthConfig.java index 0afbad0a5b..c0f8d8e274 100644 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/SpinnakerAuthConfig.groovy +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/security/SpinnakerAuthConfig.java @@ -14,17 +14,17 @@ * limitations under the License. */ -package com.netflix.spinnaker.gate.security +package com.netflix.spinnaker.gate.security; -import java.lang.annotation.ElementType -import java.lang.annotation.Retention -import java.lang.annotation.RetentionPolicy -import java.lang.annotation.Target +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; /** - * Any non-anonymous Spinnaker authentication mechanism should have this annotation included on its @Configuration bean. + * Any non-anonymous Spinnaker authentication mechanism should have this annotation included on + * its @Configuration bean. */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) -@interface SpinnakerAuthConfig { -} +public @interface SpinnakerAuthConfig {} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/SpinnakerUser.groovy b/gate-core/src/main/java/com/netflix/spinnaker/gate/security/SpinnakerUser.java similarity index 71% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/SpinnakerUser.groovy rename to gate-core/src/main/java/com/netflix/spinnaker/gate/security/SpinnakerUser.java index b1be8d665d..2267fde87d 100644 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/SpinnakerUser.groovy +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/security/SpinnakerUser.java @@ -14,17 +14,15 @@ * limitations under the License. */ -package com.netflix.spinnaker.gate.security +package com.netflix.spinnaker.gate.security; -import org.springframework.security.core.annotation.AuthenticationPrincipal +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.security.core.annotation.AuthenticationPrincipal; -import java.lang.annotation.ElementType -import java.lang.annotation.Retention -import java.lang.annotation.RetentionPolicy -import java.lang.annotation.Target - -@Target([ElementType.PARAMETER]) +@Target({ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @AuthenticationPrincipal -@interface SpinnakerUser { -} +public @interface SpinnakerUser {} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/SpringSecurityAnnotationConfig.groovy b/gate-core/src/main/java/com/netflix/spinnaker/gate/security/SpringSecurityAnnotationConfig.java similarity index 80% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/SpringSecurityAnnotationConfig.groovy rename to gate-core/src/main/java/com/netflix/spinnaker/gate/security/SpringSecurityAnnotationConfig.java index 70e8d5fe0a..c3e185a0b9 100644 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/security/SpringSecurityAnnotationConfig.groovy +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/security/SpringSecurityAnnotationConfig.java @@ -14,14 +14,13 @@ * limitations under the License. */ -package com.netflix.spinnaker.gate.security +package com.netflix.spinnaker.gate.security; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean -import org.springframework.context.annotation.Configuration -import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; @Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) @ConditionalOnBean(annotation = SpinnakerAuthConfig.class) -class SpringSecurityAnnotationConfig { -} +public class SpringSecurityAnnotationConfig {} diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/security/anonymous/AnonymousConfig.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/security/anonymous/AnonymousConfig.java new file mode 100644 index 0000000000..70c1c5e093 --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/security/anonymous/AnonymousConfig.java @@ -0,0 +1,93 @@ +/* + * Copyright 2015 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.security.anonymous; + +import com.netflix.spinnaker.fiat.shared.FiatStatus; +import com.netflix.spinnaker.gate.security.SpinnakerAuthConfig; +import com.netflix.spinnaker.gate.services.CredentialsService; +import com.netflix.spinnaker.security.User; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.util.CollectionUtils; + +/** + * Requires auth.anonymous.enabled to be true in Fiat configs to work properly. This is because + * anonymous users are a special permissions case, because the "user" doesn't actually exist in the + * backing UserRolesProvider. + */ +@ConditionalOnMissingBean(annotation = SpinnakerAuthConfig.class) +@Configuration +@Log4j2 +@EnableWebSecurity +@Order(Ordered.LOWEST_PRECEDENCE) +@RequiredArgsConstructor +public class AnonymousConfig extends WebSecurityConfigurerAdapter { + private static final String key = "spinnaker-anonymous"; + private static final String defaultEmail = "anonymous"; + + private final CredentialsService credentialsService; + private final FiatStatus fiatStatus; + @Getter private final List anonymousAllowedAccounts = new CopyOnWriteArrayList<>(); + + @Override + @SuppressWarnings("deprecation") + public void configure(HttpSecurity http) throws Exception { + updateAnonymousAccounts(); + // Not using the ImmutableUser version in order to update allowedAccounts. + User principal = new User(); + principal.setEmail(defaultEmail); + principal.setAllowedAccounts(anonymousAllowedAccounts); + + http.anonymous().key(key).principal(principal).and().csrf().disable(); + } + + @Scheduled(fixedDelay = 60000L) + public void updateAnonymousAccounts() { + if (fiatStatus.isEnabled()) { + return; + } + + try { + Collection names = credentialsService.getAccountNames(Set.of()); + Collection newAnonAccounts = !CollectionUtils.isEmpty(names) ? names : Set.of(); + + var toAdd = new HashSet<>(newAnonAccounts); + anonymousAllowedAccounts.forEach(toAdd::remove); + var toRemove = new HashSet<>(anonymousAllowedAccounts); + newAnonAccounts.forEach(toRemove::remove); + + anonymousAllowedAccounts.removeAll(toRemove); + anonymousAllowedAccounts.addAll(toAdd); + } catch (Exception e) { + log.warn("Error while updating anonymous accounts", e); + } + } +} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/AccountLookupService.groovy b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/AccountLookupService.java similarity index 78% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/AccountLookupService.groovy rename to gate-core/src/main/java/com/netflix/spinnaker/gate/services/AccountLookupService.java index ee9b05d8a4..e25e4003f8 100644 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/AccountLookupService.groovy +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/AccountLookupService.java @@ -1,7 +1,7 @@ /* * Copyright 2016 Netflix, Inc. * - * Licensed under the Apache License, Version 2.0 (the "License") + * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * @@ -14,10 +14,11 @@ * limitations under the License. */ -package com.netflix.spinnaker.gate.services +package com.netflix.spinnaker.gate.services; -import com.netflix.spinnaker.gate.services.internal.ClouddriverService.AccountDetails +import com.netflix.spinnaker.gate.services.internal.ClouddriverService; +import java.util.List; -interface AccountLookupService { - List getAccounts() +public interface AccountLookupService { + List getAccounts(); } diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/services/AuthenticationService.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/AuthenticationService.java new file mode 100644 index 0000000000..14773db1b5 --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/AuthenticationService.java @@ -0,0 +1,100 @@ +/* + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.netflix.spinnaker.gate.services; + +import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator; +import com.netflix.spinnaker.fiat.shared.FiatService; +import com.netflix.spinnaker.fiat.shared.FiatStatus; +import com.netflix.spinnaker.security.AuthenticatedRequest; +import io.micrometer.core.annotation.Counted; +import java.util.Collection; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/** Facade for logging in an authenticated user and obtaining Fiat authorities. */ +@Log4j2 +@Service +@RequiredArgsConstructor +public class AuthenticationService { + private final FiatStatus fiatStatus; + private final FiatService fiatService; + private final FiatPermissionEvaluator permissionEvaluator; + + @Setter( + onParam_ = {@Qualifier("fiatLoginService")}, + onMethod_ = {@Autowired(required = false)}) + private FiatService fiatLoginService; + + private FiatService getFiatServiceForLogin() { + return fiatLoginService != null ? fiatLoginService : fiatService; + } + + @Counted("fiat.login") + public Collection login(String userid) { + if (!fiatStatus.isEnabled()) { + return Set.of(); + } + + return AuthenticatedRequest.allowAnonymous( + () -> { + getFiatServiceForLogin().loginUser(userid, ""); + return resolveAuthorities(userid); + }); + } + + @Counted("fiat.login") + public Collection loginWithRoles( + String userid, Collection roles) { + if (!fiatStatus.isEnabled()) { + return Set.of(); + } + + return AuthenticatedRequest.allowAnonymous( + () -> { + getFiatServiceForLogin().loginWithRoles(userid, roles); + return resolveAuthorities(userid); + }); + } + + @Counted("fiat.logout") + public void logout(String userid) { + if (!fiatStatus.isEnabled()) { + return; + } + + getFiatServiceForLogin().logoutUser(userid); + permissionEvaluator.invalidatePermission(userid); + } + + private Collection resolveAuthorities(String userid) { + permissionEvaluator.invalidatePermission(userid); + var permission = permissionEvaluator.getPermission(userid); + if (permission == null) { + throw new UsernameNotFoundException( + String.format("No user found in Fiat named '%s'", userid)); + } + return permission.toGrantedAuthorities(); + } +} diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/services/CredentialsService.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/CredentialsService.java new file mode 100644 index 0000000000..56288b17e6 --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/CredentialsService.java @@ -0,0 +1,86 @@ +/* + * Copyright 2014 Netflix, Inc. + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.services; + +import com.netflix.spinnaker.fiat.model.Authorization; +import com.netflix.spinnaker.fiat.shared.FiatStatus; +import com.netflix.spinnaker.gate.services.internal.ClouddriverService; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Log4j2 +@Service +@RequiredArgsConstructor +public class CredentialsService { + private final AccountLookupService accountLookupService; + private final FiatStatus fiatStatus; + + public Collection getAccountNames(@Nullable Collection userRoles) { + return getAccounts(userRoles, false).stream() + .map(ClouddriverService.Account::getName) + .collect(Collectors.toList()); + } + + public Collection getAccountNames( + @Nullable Collection userRoles, boolean ignoreFiatStatus) { + return getAccounts(userRoles, ignoreFiatStatus).stream() + .map(ClouddriverService.Account::getName) + .collect(Collectors.toList()); + } + + /** Returns all account names that a user with the specified list of userRoles has access to. */ + List getAccounts( + @Nullable Collection userRoles, boolean ignoreFiatStatus) { + Set userRolesLower = + userRoles == null + ? Set.of() + : userRoles.stream() + .filter(Objects::nonNull) + .map(role -> role.toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + return accountLookupService.getAccounts().stream() + .filter( + account -> { + if (!ignoreFiatStatus && fiatStatus.isEnabled()) { + return true; // Returned list is filtered later. + } + + Map> permissions = account.getPermissions(); + if (CollectionUtils.isEmpty(permissions)) { + return true; + } + Set permittedRoles = + permissions.getOrDefault(Authorization.WRITE.name(), Set.of()).stream() + .map(role -> role.toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + return !Collections.disjoint(userRolesLower, permittedRoles); + }) + .collect(Collectors.toList()); + } +} diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/services/DefaultProviderLookupService.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/DefaultProviderLookupService.java new file mode 100644 index 0000000000..aa9219f80a --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/DefaultProviderLookupService.java @@ -0,0 +1,113 @@ +/* + * Copyright 2016 Netflix, Inc. + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.services; + +import com.netflix.spinnaker.fiat.model.Authorization; +import com.netflix.spinnaker.gate.services.internal.ClouddriverService; +import com.netflix.spinnaker.security.AuthenticatedRequest; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +@Log4j2 +@Service("providerLookupService") +@RequiredArgsConstructor +public class DefaultProviderLookupService implements ProviderLookupService, AccountLookupService { + private static final String FALLBACK = "unknown"; + private final ClouddriverService clouddriverService; + + private volatile List accountsCache = List.of(); + + @Scheduled(fixedDelay = 30, timeUnit = TimeUnit.SECONDS) + void refreshCache() { + try { + accountsCache = loadAccounts(); + } catch (Exception e) { + log.error("Unable to refresh account details cache", e); + } + } + + private List loadAccounts() { + var accounts = AuthenticatedRequest.allowAnonymous(clouddriverService::getAccountDetails); + // migration support, prefer permissions configuration, translate requiredGroupMembership + // (for CredentialsService in non fiat mode) into permissions collection. + // + // Ignore explicitly set requiredGroupMemberships if permissions are also present. + for (var account : accounts) { + Map> permissions = account.getPermissions(); + Collection requiredGroupMembership = account.getRequiredGroupMembership(); + if (permissions != null) { + for (var entry : permissions.entrySet()) { + entry.setValue(toLowerCase(entry.getValue()).collect(Collectors.toList())); + } + + if (!CollectionUtils.isEmpty(requiredGroupMembership)) { + Set rgmSet = toLowerCase(requiredGroupMembership).collect(Collectors.toSet()); + Collection permittedRoles = permissions.get(Authorization.WRITE.name()); + if (!rgmSet.equals(permittedRoles)) { + log.warn( + "On account {}: preferring permissions: {} over requiredGroupMemberships: {} for authorization decision", + account.getName(), + permissions, + rgmSet); + } + } + } else { + if (CollectionUtils.isEmpty(requiredGroupMembership)) { + account.setPermissions(Map.of()); + } else { + List rgm = toLowerCase(requiredGroupMembership).collect(Collectors.toList()); + account.setRequiredGroupMembership(rgm); + account.setPermissions( + Map.of( + Authorization.READ.name(), rgm, + Authorization.WRITE.name(), rgm)); + } + } + } + return accounts; + } + + @Override + public String providerForAccount(String account) { + return accountsCache.stream() + .filter(it -> account.equals(it.getName())) + .map(ClouddriverService.Account::getType) + .findFirst() + .orElse(FALLBACK); + } + + @Override + public List getAccounts() { + return accountsCache; + } + + private static Stream toLowerCase(Collection strings) { + return strings.stream().map(s -> s.toLowerCase(Locale.ROOT)); + } +} diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/services/PermissionService.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/PermissionService.java new file mode 100644 index 0000000000..91c32e9f65 --- /dev/null +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/PermissionService.java @@ -0,0 +1,229 @@ +/* + * Copyright 2016 Google, Inc. + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.services; + +import com.netflix.spinnaker.fiat.model.UserPermission; +import com.netflix.spinnaker.fiat.model.resources.Role; +import com.netflix.spinnaker.fiat.model.resources.ServiceAccount; +import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator; +import com.netflix.spinnaker.fiat.shared.FiatService; +import com.netflix.spinnaker.fiat.shared.FiatStatus; +import com.netflix.spinnaker.gate.retrofit.UpstreamBadRequest; +import com.netflix.spinnaker.gate.security.SpinnakerUser; +import com.netflix.spinnaker.gate.services.internal.ExtendedFiatService; +import com.netflix.spinnaker.kork.core.RetrySupport; +import com.netflix.spinnaker.kork.exceptions.SpinnakerException; +import com.netflix.spinnaker.kork.exceptions.SystemException; +import com.netflix.spinnaker.security.AuthenticatedRequest; +import com.netflix.spinnaker.security.User; +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import retrofit.RetrofitError; + +@Log4j2 +@Component +@RequiredArgsConstructor +public class PermissionService { + private final FiatService fiatService; + private final ExtendedFiatService extendedFiatService; + private final ServiceAccountFilterConfigProps serviceAccountFilterConfigProps; + private final FiatPermissionEvaluator permissionEvaluator; + private final FiatStatus fiatStatus; + + @Setter( + onParam_ = {@Qualifier("fiatLoginService")}, + onMethod_ = {@Autowired(required = false)}) + private FiatService fiatLoginService; + + public boolean isEnabled() { + return fiatStatus.isEnabled(); + } + + private FiatService getFiatServiceForLogin() { + return fiatLoginService != null ? fiatLoginService : fiatService; + } + + public void login(final String userId) { + if (fiatStatus.isEnabled()) { + try { + AuthenticatedRequest.allowAnonymous( + () -> { + // TODO(jvz): FiatService::loginUser should have only one parameter as Retrofit no + // longer requires this body parameter + getFiatServiceForLogin().loginUser(userId, ""); + permissionEvaluator.invalidatePermission(userId); + return null; + }); + } catch (RetrofitError e) { + throw UpstreamBadRequest.classifyError(e); + } + } + } + + public void loginWithRoles(final String userId, final Collection roles) { + if (fiatStatus.isEnabled()) { + try { + AuthenticatedRequest.allowAnonymous( + () -> { + getFiatServiceForLogin().loginWithRoles(userId, roles); + permissionEvaluator.invalidatePermission(userId); + return null; + }); + } catch (RetrofitError e) { + throw UpstreamBadRequest.classifyError(e); + } + } + } + + public void logout(String userId) { + if (fiatStatus.isEnabled()) { + try { + getFiatServiceForLogin().logoutUser(userId); + permissionEvaluator.invalidatePermission(userId); + } catch (RetrofitError e) { + throw UpstreamBadRequest.classifyError(e); + } + } + } + + public void sync() { + if (fiatStatus.isEnabled()) { + try { + getFiatServiceForLogin().sync(List.of()); + } catch (RetrofitError e) { + throw UpstreamBadRequest.classifyError(e); + } + } + } + + public Set getRoles(String userId) { + if (!fiatStatus.isEnabled()) { + return Set.of(); + } + try { + var permission = permissionEvaluator.getPermission(userId); + var roles = permission != null ? permission.getRoles() : null; + return roles != null ? roles : Set.of(); + } catch (RetrofitError e) { + throw UpstreamBadRequest.classifyError(e); + } + } + + List lookupServiceAccounts(String userId) { + try { + return extendedFiatService.getUserServiceAccounts(userId); + } catch (RetrofitError re) { + var response = re.getResponse(); + if (response != null && response.getStatus() == HttpStatus.NOT_FOUND.value()) { + return List.of(); + } + boolean shouldRetry = + response == null || HttpStatus.valueOf(response.getStatus()).is5xxServerError(); + throw new SystemException("getUserServiceAccounts failed", re).setRetryable(shouldRetry); + } + } + + public List getServiceAccountsForApplication( + @SpinnakerUser final User user, @Nonnull final String application) { + var matchAuthorizations = serviceAccountFilterConfigProps.getMatchAuthorizations(); + boolean requiresFiltering = + fiatStatus.isEnabled() + && serviceAccountFilterConfigProps.isEnabled() + && user != null + && StringUtils.hasLength(application) + && !CollectionUtils.isEmpty(matchAuthorizations); + if (!requiresFiltering) { + return getServiceAccounts(user); + } + + List filteredServiceAccounts; + RetrySupport retry = new RetrySupport(); + try { + var serviceAccounts = + retry.retry( + () -> lookupServiceAccounts(user.getUsername()), 3, Duration.ofMillis(50), false); + filteredServiceAccounts = + serviceAccounts.stream() + .filter( + permission -> + permission.getApplications().stream() + .anyMatch( + app -> + application.equalsIgnoreCase(app.getName()) + && !Collections.disjoint( + matchAuthorizations, app.getAuthorizations()))) + .map(UserPermission.View::getName) + .collect(Collectors.toList()); + } catch (SpinnakerException se) { + log.error( + "failed to lookup user {} service accounts for application {}, falling back to all user service accounts", + user, + application, + se); + return getServiceAccounts(user); + } + + // if there are no service accounts for the requested application, fall back to the full list of + // service accounts for the user to avoid a chicken and egg problem of trying to enable security + // for the first time on an application + return !filteredServiceAccounts.isEmpty() ? filteredServiceAccounts : getServiceAccounts(user); + } + + public List getServiceAccounts(@SpinnakerUser User user) { + if (user == null) { + log.debug("getServiceAccounts: Spinnaker user is null."); + return List.of(); + } + + if (!fiatStatus.isEnabled()) { + log.debug("getServiceAccounts: Fiat disabled."); + return List.of(); + } + + try { + var permission = permissionEvaluator.getPermission(user.getUsername()); + if (permission == null) { + return List.of(); + } + return permission.getServiceAccounts().stream() + .map(ServiceAccount.View::getName) + .collect(Collectors.toList()); + } catch (RetrofitError re) { + throw UpstreamBadRequest.classifyError(re); + } + } + + public boolean isAdmin(String userId) { + var permission = permissionEvaluator.getPermission(userId); + return permission != null && permission.isAdmin(); + } +} diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/ProviderLookupService.groovy b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/ProviderLookupService.java similarity index 81% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/ProviderLookupService.groovy rename to gate-core/src/main/java/com/netflix/spinnaker/gate/services/ProviderLookupService.java index 32b4794354..9f278219e5 100644 --- a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/ProviderLookupService.groovy +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/ProviderLookupService.java @@ -1,7 +1,7 @@ /* * Copyright 2016 Netflix, Inc. * - * Licensed under the Apache License, Version 2.0 (the "License") + * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * @@ -14,8 +14,8 @@ * limitations under the License. */ -package com.netflix.spinnaker.gate.services +package com.netflix.spinnaker.gate.services; -interface ProviderLookupService { - String providerForAccount(String account) +public interface ProviderLookupService { + String providerForAccount(String account); } diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/ServiceAccountFilterConfigProps.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/ServiceAccountFilterConfigProps.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/ServiceAccountFilterConfigProps.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/services/ServiceAccountFilterConfigProps.java diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/services/TaskService.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/TaskService.java index aa584f5174..07c22f7b08 100644 --- a/gate-core/src/main/java/com/netflix/spinnaker/gate/services/TaskService.java +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/TaskService.java @@ -16,27 +16,47 @@ package com.netflix.spinnaker.gate.services; +import com.netflix.spinnaker.gate.config.TaskServiceProperties; import com.netflix.spinnaker.gate.services.internal.ClouddriverServiceSelector; import com.netflix.spinnaker.gate.services.internal.OrcaServiceSelector; import com.netflix.spinnaker.security.AuthenticatedRequest; import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import javax.annotation.PreDestroy; +import lombok.Data; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @Service +@Data public class TaskService { private final Logger log = LoggerFactory.getLogger(getClass()); private OrcaServiceSelector orcaServiceSelector; private ClouddriverServiceSelector clouddriverServiceSelector; + private TaskServiceProperties taskServiceProperties; + private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + @Autowired public TaskService( OrcaServiceSelector orcaServiceSelector, - ClouddriverServiceSelector clouddriverServiceSelector) { + ClouddriverServiceSelector clouddriverServiceSelector, + TaskServiceProperties taskServiceProperties) { this.orcaServiceSelector = orcaServiceSelector; this.clouddriverServiceSelector = clouddriverServiceSelector; + this.taskServiceProperties = taskServiceProperties; + } + + @PreDestroy + protected void shutdown() { + scheduler.shutdown(); } public Map create(Map body) { @@ -103,20 +123,38 @@ public Map createAndWaitForCompletion(Map body, int maxPolls, int intervalMs) { LinkedHashMap map = new LinkedHashMap(1); map.put("id", taskId); Map task = map; - for (int i = 0; i < maxPolls; i++) { - try { - Thread.sleep(intervalMs); - } catch (InterruptedException ignored) { - } - task = getTask(taskId); - if (new ArrayList<>(Arrays.asList("SUCCEEDED", "TERMINAL")) - .contains((String) task.get("status"))) { - return task; - } - } + CompletableFuture> pollerTask = new CompletableFuture<>(); + Runnable poller = + new Runnable() { + private int polls = 0; + + @Override + public void run() { + if (polls >= maxPolls) { + pollerTask.complete(task); + return; + } + polls++; + Map currentTask = getTask(taskId); + if (Arrays.asList("SUCCEEDED", "TERMINAL").contains(currentTask.get("status"))) { + pollerTask.complete(currentTask); + } else if (polls >= maxPolls) { + pollerTask.complete(currentTask); + } + } + }; + + scheduler.scheduleAtFixedRate( + poller, 0, intervalMs, java.util.concurrent.TimeUnit.MILLISECONDS); - return task; + try { + return pollerTask.get(); + } catch (InterruptedException | ExecutionException e) { + log.error("Error while waiting for task completion", e); + Thread.currentThread().interrupt(); + return task; + } } public Map createAndWaitForCompletion(Map body, int maxPolls) { @@ -124,7 +162,10 @@ public Map createAndWaitForCompletion(Map body, int maxPolls) { } public Map createAndWaitForCompletion(Map body) { - return createAndWaitForCompletion(body, 32, 1000); + return createAndWaitForCompletion( + body, + taskServiceProperties.getMaxNumberOfPolls(), + taskServiceProperties.getDefaultIntervalBetweenPolls()); } /** @deprecated This pipeline operation does not belong here. */ @@ -149,20 +190,4 @@ public void setApplicationForTask(String id) { log.error("Error loading execution {} from orca", id, e); } } - - public OrcaServiceSelector getOrcaServiceSelector() { - return orcaServiceSelector; - } - - public void setOrcaServiceSelector(OrcaServiceSelector orcaServiceSelector) { - this.orcaServiceSelector = orcaServiceSelector; - } - - public ClouddriverServiceSelector getClouddriverServiceSelector() { - return clouddriverServiceSelector; - } - - public void setClouddriverServiceSelector(ClouddriverServiceSelector clouddriverServiceSelector) { - this.clouddriverServiceSelector = clouddriverServiceSelector; - } } diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/services/internal/ClouddriverService.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/internal/ClouddriverService.java index ec4390716f..9910b7d3b1 100644 --- a/gate-core/src/main/java/com/netflix/spinnaker/gate/services/internal/ClouddriverService.java +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/internal/ClouddriverService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; import com.netflix.spinnaker.kork.plugins.SpinnakerPluginDescriptor; import java.util.ArrayList; import java.util.Collection; @@ -400,6 +401,10 @@ List getFunctions( @GET("/installedPlugins") List getInstalledPlugins(); + @GET("/artifacts/content-address/{application}/{hash}") + Artifact.StoredView getStoredArtifact( + @Path("application") String application, @Path("hash") String hash); + @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) class Account { diff --git a/gate-core/src/main/java/com/netflix/spinnaker/gate/services/internal/EchoService.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/internal/EchoService.java index e79a34bd75..b52b9c15a3 100644 --- a/gate-core/src/main/java/com/netflix/spinnaker/gate/services/internal/EchoService.java +++ b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/internal/EchoService.java @@ -1,8 +1,10 @@ package com.netflix.spinnaker.gate.services.internal; import com.netflix.spinnaker.kork.plugins.SpinnakerPluginDescriptor; +import io.cloudevents.CloudEvent; import java.util.List; import java.util.Map; +import org.springframework.http.ResponseEntity; import retrofit.http.Body; import retrofit.http.GET; import retrofit.http.Header; @@ -17,6 +19,17 @@ public interface EchoService { @POST("/webhooks/{type}/{source}") Map webhooks(@Path("type") String type, @Path("source") String source, @Body Map event); + @Headers("Accept: application/json") + @POST("/webhooks/cdevents/{source}") + ResponseEntity webhooks( + @Path("source") String source, + @Body CloudEvent cdevent, + @Header("Ce-Data") String ceDataJsonString, + @Header("Ce-Id") String cdId, + @Header("Ce-Specversion") String cdSpecVersion, + @Header("Ce-Type") String cdType, + @Header("Ce-Source") String cdSource); + @Headers("Accept: application/json") @POST("/webhooks/{type}/{source}") Map webhooks( diff --git a/gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/internal/GoogleCloudBuildTrigger.java b/gate-core/src/main/java/com/netflix/spinnaker/gate/services/internal/GoogleCloudBuildTrigger.java similarity index 100% rename from gate-core/src/main/groovy/com/netflix/spinnaker/gate/services/internal/GoogleCloudBuildTrigger.java rename to gate-core/src/main/java/com/netflix/spinnaker/gate/services/internal/GoogleCloudBuildTrigger.java diff --git a/gate-core/src/test/groovy/com/netflix/spinnaker/gate/config/AuthConfigTest.groovy b/gate-core/src/test/groovy/com/netflix/spinnaker/gate/config/AuthConfigTest.groovy index 052ae29669..bd230754d9 100644 --- a/gate-core/src/test/groovy/com/netflix/spinnaker/gate/config/AuthConfigTest.groovy +++ b/gate-core/src/test/groovy/com/netflix/spinnaker/gate/config/AuthConfigTest.groovy @@ -15,18 +15,23 @@ * */ package com.netflix.spinnaker.gate.config -import com.netflix.spinnaker.fiat.shared.FiatClientConfigurationProperties + import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator import com.netflix.spinnaker.fiat.shared.FiatStatus -import org.springframework.boot.autoconfigure.security.SecurityProperties +import org.springframework.context.ApplicationContext import org.springframework.security.config.annotation.ObjectPostProcessor import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.web.util.matcher.AnyRequestMatcher +import org.springframework.context.support.GenericApplicationContext import spock.lang.Specification + import java.util.stream.Collectors class AuthConfigTest extends Specification { + + private GenericApplicationContext context = new GenericApplicationContext() + @SuppressWarnings("GroovyAccessibility") def "test webhooks are unauthenticated by default"() { given: @@ -35,19 +40,16 @@ class AuthConfigTest extends Specification { requestMatcher() >> requestMatcher } def authConfig = new AuthConfig( - permissionRevokingLogoutSuccessHandler: Mock(AuthConfig.PermissionRevokingLogoutSuccessHandler), - securityProperties: Mock(SecurityProperties), - configProps: Mock(FiatClientConfigurationProperties), - fiatStatus: Mock(FiatStatus), - permissionEvaluator: Mock(FiatPermissionEvaluator), - requestMatcherProvider: mockRequestMatcherProvider, - securityDebug: false, - fiatSessionFilterEnabled: false, - ) + Mock(PermissionRevokingLogoutSuccessHandler), + Mock(FiatStatus), + Mock(FiatPermissionEvaluator), + mockRequestMatcherProvider) + authConfig.securityDebug = false + authConfig.fiatSessionFilterEnabled = false def httpSecurity = new HttpSecurity( Mock(ObjectPostProcessor), Mock(AuthenticationManagerBuilder), - new HashMap>() + getSharedObjects() ) when: @@ -72,20 +74,17 @@ class AuthConfigTest extends Specification { requestMatcher() >> requestMatcher } def authConfig = new AuthConfig( - permissionRevokingLogoutSuccessHandler: Mock(AuthConfig.PermissionRevokingLogoutSuccessHandler), - securityProperties: Mock(SecurityProperties), - configProps: Mock(FiatClientConfigurationProperties), - fiatStatus: Mock(FiatStatus), - permissionEvaluator: Mock(FiatPermissionEvaluator), - requestMatcherProvider: mockRequestMatcherProvider, - securityDebug: false, - fiatSessionFilterEnabled: false, - webhookDefaultAuthEnabled: true, - ) + Mock(PermissionRevokingLogoutSuccessHandler), + Mock(FiatStatus), + Mock(FiatPermissionEvaluator), + mockRequestMatcherProvider) + authConfig.securityDebug = false + authConfig.fiatSessionFilterEnabled = false + authConfig.webhookDefaultAuthEnabled = true def httpSecurity = new HttpSecurity( Mock(ObjectPostProcessor), Mock(AuthenticationManagerBuilder), - new HashMap>() + getSharedObjects() ) when: @@ -101,4 +100,11 @@ class AuthConfigTest extends Specification { .collect(Collectors.toList()) filtered.size() == 1 } + + private HashMap, Object> getSharedObjects(){ + HashMap map = new HashMap, Object>() + context.refresh() + map.put(ApplicationContext.class, context) + return map; + } } diff --git a/gate-core/src/test/groovy/com/netflix/spinnaker/gate/security/anonymous/AnonymousConfigSpec.groovy b/gate-core/src/test/groovy/com/netflix/spinnaker/gate/security/anonymous/AnonymousConfigSpec.groovy index ecbd710e70..c8edcc1d6c 100644 --- a/gate-core/src/test/groovy/com/netflix/spinnaker/gate/security/anonymous/AnonymousConfigSpec.groovy +++ b/gate-core/src/test/groovy/com/netflix/spinnaker/gate/security/anonymous/AnonymousConfigSpec.groovy @@ -21,8 +21,6 @@ import com.netflix.spinnaker.gate.services.CredentialsService import spock.lang.Specification import spock.lang.Unroll -import java.util.concurrent.CopyOnWriteArrayList - class AnonymousConfigSpec extends Specification { @Unroll def "should update accounts when fiat is not enabled"() { @@ -33,11 +31,8 @@ class AnonymousConfigSpec extends Specification { } and: - AnonymousConfig config = new AnonymousConfig( - anonymousAllowedAccounts: new CopyOnWriteArrayList(oldAccounts), - credentialsService: credentialsService, - fiatStatus: fiatStatus - ) + AnonymousConfig config = new AnonymousConfig(credentialsService, fiatStatus) + config.anonymousAllowedAccounts.addAll(oldAccounts) when: config.updateAnonymousAccounts() diff --git a/gate-core/src/test/groovy/com/netflix/spinnaker/gate/services/PermissionServiceSpec.groovy b/gate-core/src/test/groovy/com/netflix/spinnaker/gate/services/PermissionServiceSpec.groovy index c009f0db9c..bc93465fc5 100644 --- a/gate-core/src/test/groovy/com/netflix/spinnaker/gate/services/PermissionServiceSpec.groovy +++ b/gate-core/src/test/groovy/com/netflix/spinnaker/gate/services/PermissionServiceSpec.groovy @@ -39,7 +39,7 @@ class PermissionServiceSpec extends Specification { def extendedFiatService = Stub(ExtendedFiatService) { getUserServiceAccounts(user) >> { throw theFailure } } - def subject = new PermissionService(extendedFiatService: extendedFiatService) + def subject = new PermissionService(null, extendedFiatService, null, null, null) when: def result = subject.lookupServiceAccounts(user) @@ -61,7 +61,7 @@ class PermissionServiceSpec extends Specification { def extendedFiatService = Stub(ExtendedFiatService) { getUserServiceAccounts(user) >> { throw theFailure } } - def subject = new PermissionService(extendedFiatService: extendedFiatService) + def subject = new PermissionService(null, extendedFiatService, null, null, null) when: subject.lookupServiceAccounts(user) @@ -123,11 +123,7 @@ class PermissionServiceSpec extends Specification { def extendedFiatService = Mock(ExtendedFiatService) def result = hasResult ? lookupResult : [] - def subject = new PermissionService( - fiatStatus: fiatStatus, - permissionEvaluator: permissionEvaluator, - extendedFiatService: extendedFiatService, - serviceAccountFilterConfigProps: cfgProps) + def subject = new PermissionService(null, extendedFiatService, cfgProps, permissionEvaluator, fiatStatus) when: subject.getServiceAccountsForApplication(user, application) diff --git a/gate-core/src/test/java/com/netflix/spinnaker/gate/services/TaskServiceTest.java b/gate-core/src/test/java/com/netflix/spinnaker/gate/services/TaskServiceTest.java new file mode 100644 index 0000000000..cfc23b2470 --- /dev/null +++ b/gate-core/src/test/java/com/netflix/spinnaker/gate/services/TaskServiceTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2022 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spinnaker.gate.services; + +import static org.mockito.Mockito.*; + +import com.netflix.spinnaker.gate.config.TaskServiceProperties; +import com.netflix.spinnaker.gate.services.internal.ClouddriverServiceSelector; +import com.netflix.spinnaker.gate.services.internal.OrcaService; +import com.netflix.spinnaker.gate.services.internal.OrcaServiceSelector; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@SpringBootTest(classes = {TaskService.class, TaskServiceProperties.class}) +public class TaskServiceTest { + + @MockBean private OrcaServiceSelector selector; + @MockBean private ClouddriverServiceSelector clouddriverServiceSelector; + @MockBean private OrcaService orcaService; + + @Autowired TaskService taskService; + + @Test + public void callAsManyTimesAsSet() { + Map operation = new LinkedHashMap(); + + Map task = Map.of("ref", "apps/bob/someRandomId"); + when(selector.select()).thenReturn(orcaService); + when(orcaService.doOperation(operation)).thenReturn(task); + taskService.createAndWaitForCompletion(operation, 32, 1); + verify(orcaService, times(32)).getTask("someRandomId"); + } +} diff --git a/gate-iap/src/main/java/com/netflix/spinnaker/gate/security/iap/IapSsoConfig.java b/gate-iap/src/main/java/com/netflix/spinnaker/gate/security/iap/IapSsoConfig.java index 1385a6da8c..d739908617 100644 --- a/gate-iap/src/main/java/com/netflix/spinnaker/gate/security/iap/IapSsoConfig.java +++ b/gate-iap/src/main/java/com/netflix/spinnaker/gate/security/iap/IapSsoConfig.java @@ -32,7 +32,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; @@ -101,9 +100,4 @@ public void configure(HttpSecurity http) throws Exception { authConfig.configure(http); http.addFilterBefore(iapAuthenticationFilter(), BasicAuthenticationFilter.class); } - - @Override - public void configure(WebSecurity web) throws Exception { - authConfig.configure(web); - } } diff --git a/gate-integration/gate-integration.gradle b/gate-integration/gate-integration.gradle new file mode 100644 index 0000000000..919149e21c --- /dev/null +++ b/gate-integration/gate-integration.gradle @@ -0,0 +1,25 @@ +dependencies { + testImplementation "com.fasterxml.jackson.core:jackson-databind" + testImplementation "com.github.tomakehurst:wiremock-jre8-standalone" + testImplementation "org.assertj:assertj-core" + testImplementation "org.junit.jupiter:junit-jupiter-api" + testImplementation "org.slf4j:slf4j-api" + testImplementation "org.testcontainers:testcontainers" + testImplementation "org.testcontainers:junit-jupiter" + testRuntimeOnly "ch.qos.logback:logback-classic" +} + +test.configure { + def fullDockerImageName = System.getenv('FULL_DOCKER_IMAGE_NAME') + onlyIf("there is a docker image to test") { + fullDockerImageName != null && fullDockerImageName.trim() != '' + } +} + +test { + // So stdout and stderr from the just-built container are available in CI + testLogging.showStandardStreams = true + + // Run the tests when the docker image changes + inputs.property 'fullDockerImageName', System.getenv('FULL_DOCKER_IMAGE_NAME') +} diff --git a/gate-integration/src/test/java/com/netflix/spinnaker/gate/StandaloneContainerTest.java b/gate-integration/src/test/java/com/netflix/spinnaker/gate/StandaloneContainerTest.java new file mode 100644 index 0000000000..1a91825de6 --- /dev/null +++ b/gate-integration/src/test/java/com/netflix/spinnaker/gate/StandaloneContainerTest.java @@ -0,0 +1,200 @@ +/* + * Copyright 2024 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spinnaker.gate; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +class StandaloneContainerTest { + + private static final String REDIS_NETWORK_ALIAS = "redisHost"; + + private static final int REDIS_PORT = 6379; + + private static final Logger logger = LoggerFactory.getLogger(StandaloneContainerTest.class); + + private static final Network network = Network.newNetwork(); + + // gate caches application information from both clouddriver and front50, and + // account information from clouddriver. gate's health doesn't depend on this + // succeeding, but when it fails it spams the log with a log of noise. + @RegisterExtension + static final WireMockExtension wmClouddriver = + WireMockExtension.newInstance().options(wireMockConfig().dynamicPort()).build(); + + @RegisterExtension + static final WireMockExtension wmFront50 = + WireMockExtension.newInstance().options(wireMockConfig().dynamicPort()).build(); + + static int clouddriverPort; + static int front50Port; + + private static final GenericContainer redis = + new GenericContainer(DockerImageName.parse("library/redis:5-alpine")) + .withNetwork(network) + .withNetworkAliases(REDIS_NETWORK_ALIAS) + .withExposedPorts(REDIS_PORT); + + private static GenericContainer gateContainer; + + @BeforeAll + static void setupOnce() throws Exception { + front50Port = wmFront50.getRuntimeInfo().getHttpPort(); + logger.info("wiremock front50 http port: {} ", front50Port); + + clouddriverPort = wmClouddriver.getRuntimeInfo().getHttpPort(); + logger.info("wiremock clouddriver http port: {} ", clouddriverPort); + + // set up front50 stubs + wmFront50.stubFor( + WireMock.get(urlPathEqualTo("/v2/applications")) + .willReturn(aResponse().withStatus(200).withBody("[]"))); + + wmFront50.stubFor( + WireMock.get(urlPathEqualTo("/health")) + .willReturn(aResponse().withStatus(200).withBody("{}"))); + + // set up clouddriver stubs + wmClouddriver.stubFor( + WireMock.get(urlPathEqualTo("/applications")) + .willReturn(aResponse().withStatus(200).withBody("[]"))); + + wmClouddriver.stubFor( + WireMock.get(urlPathEqualTo("/credentials")) + .willReturn(aResponse().withStatus(200).withBody("[]"))); + + wmClouddriver.stubFor( + WireMock.get(urlPathEqualTo("/health")) + .willReturn(aResponse().withStatus(200).withBody("{}"))); + + String fullDockerImageName = System.getenv("FULL_DOCKER_IMAGE_NAME"); + + // Skip the tests if there's no docker image. This allows gradlew build to work. + assumeTrue(fullDockerImageName != null); + + // expose front50 to gate + org.testcontainers.Testcontainers.exposeHostPorts(front50Port); + + // expose clouddriver to gate + org.testcontainers.Testcontainers.exposeHostPorts(clouddriverPort); + + redis.start(); + + DockerImageName dockerImageName = DockerImageName.parse(fullDockerImageName); + + gateContainer = + new GenericContainer(dockerImageName) + .withNetwork(network) + .withExposedPorts(8084) + .dependsOn(redis) + .waitingFor(Wait.forHealthcheck()) + .withEnv("SPRING_APPLICATION_JSON", getSpringApplicationJson()); + + Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(logger); + gateContainer.start(); + gateContainer.followOutput(logConsumer); + } + + private static String getSpringApplicationJson() throws JsonProcessingException { + String redisUrl = "redis://" + REDIS_NETWORK_ALIAS + ":" + REDIS_PORT; + logger.info("redisUrl: '{}'", redisUrl); + Map properties = + Map.of( + "services.rosco.enabled", + "false", + "services.echo.enabled", + "false", + "services.orca.enabled", + "false", + "services.fiat.baseUrl", + "http://nowhere", + "redis.connection", + redisUrl, + "services.clouddriver.baseUrl", + "http://" + GenericContainer.INTERNAL_HOST_HOSTNAME + ":" + clouddriverPort, + "services.front50.baseUrl", + "http://" + GenericContainer.INTERNAL_HOST_HOSTNAME + ":" + front50Port); + ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsString(properties); + } + + @AfterAll + static void cleanupOnce() { + if (gateContainer != null) { + gateContainer.stop(); + } + + if (redis != null) { + redis.stop(); + } + } + + @BeforeEach + void init(TestInfo testInfo) { + System.out.println("--------------- Test " + testInfo.getDisplayName()); + } + + @Test + void testHealthCheck() throws Exception { + // hit an arbitrary endpoint + HttpRequest request = + HttpRequest.newBuilder() + .uri( + new URI( + "http://" + + gateContainer.getHost() + + ":" + + gateContainer.getFirstMappedPort() + + "/health")) + .GET() + .build(); + + HttpClient client = HttpClient.newHttpClient(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response).isNotNull(); + logger.info("response: {}, {}", response.statusCode(), response.body()); + assertThat(response.statusCode()).isEqualTo(200); + } +} diff --git a/gate-integration/src/test/resources/logback.xml b/gate-integration/src/test/resources/logback.xml new file mode 100644 index 0000000000..6145d38780 --- /dev/null +++ b/gate-integration/src/test/resources/logback.xml @@ -0,0 +1,36 @@ + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + diff --git a/gate-integrations-gremlin/src/main/java/com/netflix/spinnaker/config/GremlinConfig.java b/gate-integrations-gremlin/src/main/java/com/netflix/spinnaker/config/GremlinConfig.java index e535e91325..31841916f9 100644 --- a/gate-integrations-gremlin/src/main/java/com/netflix/spinnaker/config/GremlinConfig.java +++ b/gate-integrations-gremlin/src/main/java/com/netflix/spinnaker/config/GremlinConfig.java @@ -21,11 +21,9 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.jakewharton.retrofit.Ok3Client; -import com.netflix.spectator.api.Registry; import com.netflix.spinnaker.gate.config.Service; import com.netflix.spinnaker.gate.config.ServiceConfiguration; import com.netflix.spinnaker.gate.retrofit.Slf4jRetrofitLogger; -import com.netflix.spinnaker.gate.services.EurekaLookupService; import com.netflix.spinnaker.gate.services.gremlin.GremlinService; import groovy.transform.CompileStatic; import groovy.util.logging.Slf4j; @@ -48,8 +46,6 @@ class GremlinConfig { GremlinService gremlinService( OkHttpClient okHttpClient, ServiceConfiguration serviceConfiguration, - Registry registry, - EurekaLookupService eurekaLookupService, RequestInterceptor spinnakerRequestInterceptor, @Value("${retrofit.log-level:BASIC}") String retrofitLogLevel) { return createClient( @@ -57,8 +53,6 @@ GremlinService gremlinService( GremlinService.class, okHttpClient, serviceConfiguration, - registry, - eurekaLookupService, spinnakerRequestInterceptor, retrofitLogLevel); } @@ -68,8 +62,6 @@ private T createClient( Class type, OkHttpClient okHttpClient, ServiceConfiguration serviceConfiguration, - Registry registry, - EurekaLookupService eurekaLookupService, RequestInterceptor spinnakerRequestInterceptor, String retrofitLogLevel) { Service service = serviceConfiguration.getService(serviceName); diff --git a/gate-ldap/src/main/groovy/com/netflix/spinnaker/gate/security/ldap/LdapSsoConfig.groovy b/gate-ldap/src/main/groovy/com/netflix/spinnaker/gate/security/ldap/LdapSsoConfig.groovy index e9aea89fa1..4bcbb69e48 100644 --- a/gate-ldap/src/main/groovy/com/netflix/spinnaker/gate/security/ldap/LdapSsoConfig.groovy +++ b/gate-ldap/src/main/groovy/com/netflix/spinnaker/gate/security/ldap/LdapSsoConfig.groovy @@ -31,7 +31,6 @@ import org.springframework.ldap.core.DirContextAdapter import org.springframework.ldap.core.DirContextOperations import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.annotation.web.builders.WebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter import org.springframework.security.core.GrantedAuthority @@ -98,11 +97,6 @@ class LdapSsoConfig extends WebSecurityConfigurerAdapter { http.addFilterBefore(new BasicAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter) } - @Override - void configure(WebSecurity web) throws Exception { - authConfig.configure(web) - } - @Component static class LdapUserContextMapper implements UserDetailsContextMapper { diff --git a/gate-oauth2/gate-oauth2.gradle b/gate-oauth2/gate-oauth2.gradle index 53962e0588..91403e7ea9 100644 --- a/gate-oauth2/gate-oauth2.gradle +++ b/gate-oauth2/gate-oauth2.gradle @@ -4,6 +4,7 @@ dependencies { implementation "io.spinnaker.fiat:fiat-api:$fiatVersion" implementation "io.spinnaker.kork:kork-exceptions" implementation "io.spinnaker.kork:kork-security" + implementation "org.codehaus.groovy:groovy-json" implementation "org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure" implementation "org.springframework.session:spring-session-core" } diff --git a/gate-oauth2/src/main/groovy/com/netflix/spinnaker/gate/security/oauth2/OAuth2SsoConfig.groovy b/gate-oauth2/src/main/groovy/com/netflix/spinnaker/gate/security/oauth2/OAuth2SsoConfig.groovy index af8b6c5438..654b523681 100644 --- a/gate-oauth2/src/main/groovy/com/netflix/spinnaker/gate/security/oauth2/OAuth2SsoConfig.groovy +++ b/gate-oauth2/src/main/groovy/com/netflix/spinnaker/gate/security/oauth2/OAuth2SsoConfig.groovy @@ -29,7 +29,6 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Primary import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.annotation.web.builders.WebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter import org.springframework.security.core.AuthenticationException @@ -88,10 +87,6 @@ class OAuth2SsoConfig extends WebSecurityConfigurerAdapter { http.addFilterBefore(externalAuthTokenFilter, AbstractPreAuthenticatedProcessingFilter.class) } - void configure(WebSecurity web) throws Exception { - authConfig.configure(web) - } - /** * Use this class to specify how to map fields from the userInfoUri response to what's expected to be in the User. */ diff --git a/gate-plugins-test/gate-plugins-test.gradle b/gate-plugins-test/gate-plugins-test.gradle index e9089cb81c..3c4f990212 100644 --- a/gate-plugins-test/gate-plugins-test.gradle +++ b/gate-plugins-test/gate-plugins-test.gradle @@ -9,13 +9,8 @@ dependencies { testImplementation("io.spinnaker.kork:kork-plugins") testImplementation("io.spinnaker.kork:kork-plugins-tck") + testImplementation("io.spinnaker.kork:kork-jedis-test") + testImplementation("org.springframework.session:spring-session-data-redis") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") } - -test { - useJUnitPlatform { - includeEngines("junit-jupiter", "junit-vintage") - } -} diff --git a/gate-plugins-test/src/test/kotlin/com/netflix/spinnaker/gate/plugins/test/GatePluginsFixture.kt b/gate-plugins-test/src/test/kotlin/com/netflix/spinnaker/gate/plugins/test/GatePluginsFixture.kt index af93403da2..a709c94aca 100644 --- a/gate-plugins-test/src/test/kotlin/com/netflix/spinnaker/gate/plugins/test/GatePluginsFixture.kt +++ b/gate-plugins-test/src/test/kotlin/com/netflix/spinnaker/gate/plugins/test/GatePluginsFixture.kt @@ -30,6 +30,13 @@ import org.springframework.boot.test.context.TestConfiguration import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.TestPropertySource import org.springframework.test.web.servlet.MockMvc +import org.springframework.context.annotation.Bean +import com.netflix.spinnaker.kork.jedis.EmbeddedRedis +import org.springframework.context.annotation.Primary +import org.springframework.data.redis.connection.RedisStandaloneConfiguration +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory +import org.springframework.session.data.redis.config.annotation.SpringSessionRedisConnectionFactory +import redis.clients.jedis.JedisPool class GatePluginsFixture : PluginsTckFixture, GateTestService() { @@ -75,4 +82,22 @@ class GatePluginsFixture : PluginsTckFixture, GateTestService() { abstract class GateTestService @TestConfiguration -internal open class PluginTestConfiguration +internal open class PluginTestConfiguration { + @Bean(destroyMethod = "destroy") + fun embeddedRedis(): EmbeddedRedis { + return EmbeddedRedis.embed().also { redis -> redis.jedis.connect() }.also { redis -> redis.jedis.ping() } + } + + @Bean + @Primary + @SpringSessionRedisConnectionFactory + fun jedisConnectionFactory(embeddedRedis: EmbeddedRedis): JedisConnectionFactory { + return JedisConnectionFactory(RedisStandaloneConfiguration(embeddedRedis.host, embeddedRedis.port)) + } + + @Bean + @Primary + fun jedis(embeddedRedis: EmbeddedRedis): JedisPool { + return embeddedRedis.getPool(); + } +} diff --git a/gate-plugins/gate-plugins.gradle b/gate-plugins/gate-plugins.gradle index 2b8a830362..36154c4ff3 100644 --- a/gate-plugins/gate-plugins.gradle +++ b/gate-plugins/gate-plugins.gradle @@ -32,7 +32,3 @@ dependencies { implementation "org.springframework:spring-web" implementation "org.pf4j:pf4j-update" } - -test { - useJUnitPlatform() -} diff --git a/gate-plugins/src/main/kotlin/com/netflix/spinnaker/gate/plugins/web/publish/PluginPublishController.kt b/gate-plugins/src/main/kotlin/com/netflix/spinnaker/gate/plugins/web/publish/PluginPublishController.kt index 24cf4d28f0..234ec6c22c 100644 --- a/gate-plugins/src/main/kotlin/com/netflix/spinnaker/gate/plugins/web/publish/PluginPublishController.kt +++ b/gate-plugins/src/main/kotlin/com/netflix/spinnaker/gate/plugins/web/publish/PluginPublishController.kt @@ -28,10 +28,12 @@ import io.swagger.annotations.ApiOperation import java.lang.String.format import lombok.SneakyThrows import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -92,7 +94,7 @@ class PluginPublishController( .addFormDataPart( "plugin", format("%s-%s.zip", pluginId, pluginVersion), - RequestBody.create(MediaType.parse("application/octet-stream"), body) + body.toRequestBody(("application/octet-stream").toMediaType()) ) .build() ) @@ -100,7 +102,7 @@ class PluginPublishController( val response = okHttpClient.newCall(request).execute() if (!response.isSuccessful) { - val reason = response.body()?.string() ?: "Unknown reason: ${response.code()}" + val reason = response.body?.string() ?: "Unknown reason: ${response.code}" throw SystemException("Failed to upload plugin binary: $reason") } }.call() diff --git a/gate-plugins/src/test/kotlin/com/netflix/spinnaker/gate/plugins/deck/DeckPluginCacheTest.kt b/gate-plugins/src/test/kotlin/com/netflix/spinnaker/gate/plugins/deck/DeckPluginCacheTest.kt index e5065da533..236c34ff45 100644 --- a/gate-plugins/src/test/kotlin/com/netflix/spinnaker/gate/plugins/deck/DeckPluginCacheTest.kt +++ b/gate-plugins/src/test/kotlin/com/netflix/spinnaker/gate/plugins/deck/DeckPluginCacheTest.kt @@ -28,7 +28,6 @@ import dev.minutest.junit.JUnit5Minutests import dev.minutest.rootContext import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.verify import java.nio.file.Files import java.nio.file.Paths @@ -37,6 +36,7 @@ import strikt.api.expectThat import strikt.assertions.hasSize import strikt.assertions.isEmpty import strikt.assertions.isEqualTo +import java.nio.file.Path import java.util.Optional class DeckPluginCacheTest : JUnit5Minutests { @@ -44,6 +44,10 @@ class DeckPluginCacheTest : JUnit5Minutests { fun tests() = rootContext { fixture { Fixture() } + after { + reset(pluginsDir) + } + context("caching") { test("latest plugin releases with deck artifacts are added to cache") { every { updateManager.downloadPluginRelease(any(), any()) } returns Paths.get("/dev/null") @@ -96,13 +100,14 @@ class DeckPluginCacheTest : JUnit5Minutests { } private inner class Fixture { + val pluginsDir = Files.createTempDirectory("plugins") val updateManager: SpinnakerUpdateManager = mockk(relaxed = true) val pluginBundleExtractor: PluginBundleExtractor = mockk(relaxed = true) val pluginStatusProvider: SpringPluginStatusProvider = mockk(relaxed = true) val pluginInfoReleaseProvider: PluginInfoReleaseProvider = mockk(relaxed = true) val registry: Registry = NoopRegistry() val springStrictPluginLoaderStatusProvider: SpringStrictPluginLoaderStatusProvider = mockk(relaxed = true) - val subject = DeckPluginCache(updateManager, pluginBundleExtractor, pluginStatusProvider, pluginInfoReleaseProvider, registry, springStrictPluginLoaderStatusProvider, Optional.empty()) + val subject = DeckPluginCache(updateManager, pluginBundleExtractor, pluginStatusProvider, pluginInfoReleaseProvider, registry, springStrictPluginLoaderStatusProvider, Optional.of(pluginsDir.toString())) init { val plugins = listOf( @@ -146,9 +151,11 @@ class DeckPluginCacheTest : JUnit5Minutests { } temp } - mockkStatic(Files::class) - every { Files.createDirectories(any()) } returns Paths.get("/dev/null") - every { Files.move(any(), any(), any()) } returns Paths.get("/dev/null") + } + + fun reset(path: Path) { + path.toFile().deleteRecursively() + path.toFile().mkdir() } } } diff --git a/gate-plugins/src/test/resources/gate-test.yml b/gate-plugins/src/test/resources/gate-test.yml index d348453491..c4340e0fc8 100644 --- a/gate-plugins/src/test/resources/gate-test.yml +++ b/gate-plugins/src/test/resources/gate-test.yml @@ -30,7 +30,9 @@ services: --- spring: - profiles: test + config: + activate: + on-profile: test spinnaker: extensions: diff --git a/gate-proxy/src/main/kotlin/com/netflix/spinnaker/gate/controllers/ProxyController.kt b/gate-proxy/src/main/kotlin/com/netflix/spinnaker/gate/controllers/ProxyController.kt index a6d7be313b..557e2e39aa 100644 --- a/gate-proxy/src/main/kotlin/com/netflix/spinnaker/gate/controllers/ProxyController.kt +++ b/gate-proxy/src/main/kotlin/com/netflix/spinnaker/gate/controllers/ProxyController.kt @@ -26,8 +26,9 @@ import com.netflix.spinnaker.kork.web.exceptions.InvalidRequestException import com.netflix.spinnaker.kork.web.interceptors.Criticality import com.netflix.spinnaker.security.AuthenticatedRequest import okhttp3.Request -import okhttp3.RequestBody import okhttp3.internal.http.HttpMethod +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody import java.net.SocketException import java.util.stream.Collectors import javax.servlet.http.HttpServletRequest @@ -120,7 +121,7 @@ class ProxyController( .toString() .substringAfter("/proxies/$proxyId") - val proxiedUrlBuilder = Request.Builder().url(proxyConfig.uri + proxyPath).build().url().newBuilder() + val proxiedUrlBuilder = Request.Builder().url(proxyConfig.uri + proxyPath).build().url.newBuilder() for ((key, value) in requestParams) { proxiedUrlBuilder.addQueryParameter(key, value) } @@ -134,10 +135,7 @@ class ProxyController( val method = request.method val body = if (HttpMethod.permitsRequestBody(method) && request.contentType != null) { - RequestBody.create( - okhttp3.MediaType.parse(request.contentType), - request.reader.lines().collect(Collectors.joining(System.lineSeparator())) - ) + request.reader.lines().collect(Collectors.joining(System.lineSeparator())).toRequestBody(request.contentType.toMediaType()) } else { null } @@ -145,9 +143,9 @@ class ProxyController( val response = proxy.okHttpClient.newCall( Request.Builder().url(proxiedUrl).method(method, body).build() ).execute() - statusCode = response.code() + statusCode = response.code contentType = response.header("Content-Type") ?: contentType - responseBody = response.body()?.string() ?: "" + responseBody = response.body?.string() ?: "" } catch (e: SocketException) { log.error("Exception processing proxy request", e) statusCode = HttpStatus.GATEWAY_TIMEOUT.value() diff --git a/gate-saml/gate-saml.gradle b/gate-saml/gate-saml.gradle index 5c9dc672ca..311f2b668d 100644 --- a/gate-saml/gate-saml.gradle +++ b/gate-saml/gate-saml.gradle @@ -1,14 +1,20 @@ -dependencies{ +dependencies { + constraints { + implementation 'org.opensaml:opensaml-core:4.1.0' + implementation 'org.opensaml:opensaml-saml-api:4.1.0' + implementation 'org.opensaml:opensaml-saml-impl:4.1.0' + } + implementation project(':gate-core') - // RetrySupport is in kork-exceptions and not kork-core! + implementation 'io.spinnaker.kork:kork-core' + implementation 'io.spinnaker.kork:kork-crypto' + implementation 'io.spinnaker.kork:kork-exceptions' + implementation 'io.spinnaker.kork:kork-security' implementation "io.spinnaker.fiat:fiat-api:$fiatVersion" - implementation "io.spinnaker.kork:kork-exceptions" - implementation "io.spinnaker.kork:kork-security" - implementation "com.netflix.spectator:spectator-api" - implementation 'org.springframework:spring-context' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.security:spring-security-saml2-service-provider' implementation 'org.springframework.session:spring-session-core' - implementation 'org.springframework.boot:spring-boot-autoconfigure' - implementation "org.springframework.security.extensions:spring-security-saml2-core" - implementation "org.springframework.security.extensions:spring-security-saml-dsl-core" + testImplementation 'org.springframework.boot:spring-boot-starter-test' } diff --git a/gate-saml/src/main/groovy/com/netflix/spinnaker/gate/security/saml/SamlSsoConfig.groovy b/gate-saml/src/main/groovy/com/netflix/spinnaker/gate/security/saml/SamlSsoConfig.groovy deleted file mode 100644 index 2739db43de..0000000000 --- a/gate-saml/src/main/groovy/com/netflix/spinnaker/gate/security/saml/SamlSsoConfig.groovy +++ /dev/null @@ -1,355 +0,0 @@ -/* - * Copyright 2014 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.security.saml - -import com.netflix.spectator.api.Registry -import com.netflix.spinnaker.fiat.shared.FiatClientConfigurationProperties -import com.netflix.spinnaker.gate.config.AuthConfig -import com.netflix.spinnaker.gate.security.AllowedAccountsSupport -import com.netflix.spinnaker.gate.security.SpinnakerAuthConfig -import com.netflix.spinnaker.gate.services.PermissionService -import com.netflix.spinnaker.kork.core.RetrySupport -import com.netflix.spinnaker.security.User -import groovy.util.logging.Slf4j -import org.opensaml.saml2.core.Assertion -import org.opensaml.saml2.core.Attribute -import org.opensaml.xml.schema.XSAny -import org.opensaml.xml.schema.XSString -import org.opensaml.xml.security.BasicSecurityConfiguration -import org.opensaml.xml.signature.SignatureConstants -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression -import org.springframework.boot.autoconfigure.web.ServerProperties -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.security.authentication.BadCredentialsException -import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.annotation.web.builders.WebSecurity -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity -import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter -import org.springframework.security.core.userdetails.UserDetailsService -import org.springframework.security.core.userdetails.UsernameNotFoundException -import org.springframework.security.extensions.saml2.config.SAMLConfigurer -import org.springframework.security.saml.websso.WebSSOProfileConsumerImpl -import org.springframework.security.saml.SAMLCredential -import org.springframework.security.saml.userdetails.SAMLUserDetailsService -import org.springframework.security.web.authentication.RememberMeServices -import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices -import org.springframework.session.web.http.DefaultCookieSerializer -import org.springframework.stereotype.Component - -import javax.annotation.PostConstruct -import java.security.KeyStore - -import static org.springframework.security.extensions.saml2.config.SAMLConfigurer.saml - -@ConditionalOnExpression('${saml.enabled:false}') -@Configuration -@SpinnakerAuthConfig -@EnableWebSecurity -@Slf4j -class SamlSsoConfig extends WebSecurityConfigurerAdapter { - - @Autowired - ServerProperties serverProperties - - @Autowired - DefaultCookieSerializer defaultCookieSerializer - - @Autowired - AuthConfig authConfig - - @Component - @ConfigurationProperties("saml") - static class SAMLSecurityConfigProperties { - String keyStore - String keyStorePassword - String keyStoreAliasName - - // SAML DSL uses a metadata URL instead of hard coding a certificate/issuerId/redirectBase into the config. - String metadataUrl - // The parts of this endpoint passed to/used by the SAML IdP. - String redirectProtocol = "https" - String redirectHostname - String redirectBasePath = "/" - // The application identifier given to the IdP for this app. - String issuerId - - List requiredRoles - boolean sortRoles = false - boolean forceLowercaseRoles = true - UserAttributeMapping userAttributeMapping = new UserAttributeMapping() - long maxAuthenticationAge = 7200 - - String signatureDigest = "SHA1" // SHA1 is the default registered in DefaultSecurityConfigurationBootstrap.populateSignatureParams - - /** - * Ensure that the keystore exists and can be accessed with the given keyStorePassword and keyStoreAliasName - */ - @PostConstruct - void validate() { - if (metadataUrl && metadataUrl.startsWith("/")) { - metadataUrl = "file:" + metadataUrl - } - - if (keyStore) { - if (!keyStore.startsWith("file:")) { - keyStore = "file:" + keyStore - } - new File(new URI(keyStore)).withInputStream { is -> - def keystore = KeyStore.getInstance(KeyStore.getDefaultType()) - - // will throw an exception if `keyStorePassword` is invalid - keystore.load(is, keyStorePassword.toCharArray()) - - if (keyStoreAliasName && !keystore.aliases().find { it.equalsIgnoreCase(keyStoreAliasName) }) { - throw new IllegalStateException("Keystore '${keyStore}' does not contain alias '${keyStoreAliasName}'") - } - } - } - - // Validate signature digest algorithm - if (SignatureAlgorithms.fromName(signatureDigest) == null) { - throw new IllegalStateException("Invalid saml.signatureDigest value '${signatureDigest}'. Valid values are ${SignatureAlgorithms.values()}") - } - } - } - - static class UserAttributeMapping { - String firstName = "User.FirstName" - String lastName = "User.LastName" - String roles = "memberOf" - String rolesDelimiter = ";" - String username - String email - } - - @Autowired - SAMLSecurityConfigProperties samlSecurityConfigProperties - - @Autowired - SAMLUserDetailsService samlUserDetailsService - - @Override - void configure(HttpSecurity http) { - //We need our session cookie to come across when we get redirected back from the IdP: - defaultCookieSerializer.setSameSite(null) - authConfig.configure(http) - - http - .rememberMe() - .rememberMeServices(rememberMeServices(userDetailsService())) - - // @formatter:off - SAMLConfigurer saml = saml() - saml - .userDetailsService(samlUserDetailsService) - .identityProvider() - .metadataFilePath(samlSecurityConfigProperties.metadataUrl) - .discoveryEnabled(false) - .and() - .webSSOProfileConsumer(getWebSSOProfileConsumerImpl()) - .serviceProvider() - .entityId(samlSecurityConfigProperties.issuerId) - .protocol(samlSecurityConfigProperties.redirectProtocol) - .hostname(samlSecurityConfigProperties.redirectHostname ?: serverProperties?.address?.hostName) - .basePath(samlSecurityConfigProperties.redirectBasePath) - .keyStore() - .storeFilePath(samlSecurityConfigProperties.keyStore) - .password(samlSecurityConfigProperties.keyStorePassword) - .keyname(samlSecurityConfigProperties.keyStoreAliasName) - .keyPassword(samlSecurityConfigProperties.keyStorePassword) - - saml.init(http) - initSignatureDigest() // Need to be after SAMLConfigurer initializes the global SecurityConfiguration - - // @formatter:on - - } - - private void initSignatureDigest() { - def secConfig = org.opensaml.Configuration.getGlobalSecurityConfiguration() - if (secConfig != null && secConfig instanceof BasicSecurityConfiguration) { - BasicSecurityConfiguration basicSecConfig = (BasicSecurityConfiguration) secConfig - def algo = SignatureAlgorithms.fromName(samlSecurityConfigProperties.signatureDigest) - log.info("Using ${algo} digest for signing SAML messages") - basicSecConfig.registerSignatureAlgorithmURI("RSA", algo.rsaSignatureMethod) - basicSecConfig.setSignatureReferenceDigestMethod(algo.digestMethod) - } else { - log.warn("Unable to find global BasicSecurityConfiguration (found '${secConfig}'). Ignoring signatureDigest configuration value.") - } - } - - void configure(WebSecurity web) throws Exception { - authConfig.configure(web) - } - - public WebSSOProfileConsumerImpl getWebSSOProfileConsumerImpl() { - WebSSOProfileConsumerImpl profileConsumer = new WebSSOProfileConsumerImpl(); - profileConsumer.setMaxAuthenticationAge(samlSecurityConfigProperties.maxAuthenticationAge); - return profileConsumer; - } - - @Bean - public RememberMeServices rememberMeServices(UserDetailsService userDetailsService) { - TokenBasedRememberMeServices rememberMeServices = new TokenBasedRememberMeServices("password", userDetailsService) - rememberMeServices.setCookieName("cookieName") - rememberMeServices.setParameter("rememberMe") - rememberMeServices - } - - @Bean - SAMLUserDetailsService samlUserDetailsService() { - // TODO(ttomsu): This is a NFLX specific user extractor. Make a more generic one? - new SAMLUserDetailsService() { - - @Autowired - PermissionService permissionService - - @Autowired - AllowedAccountsSupport allowedAccountsSupport - - @Autowired - FiatClientConfigurationProperties fiatClientConfigurationProperties - - @Autowired - Registry registry - - RetrySupport retrySupport = new RetrySupport() - - @Override - User loadUserBySAML(SAMLCredential credential) throws UsernameNotFoundException { - def assertion = credential.authenticationAssertion - def attributes = extractAttributes(assertion) - def userAttributeMapping = samlSecurityConfigProperties.userAttributeMapping - - def subjectNameId = assertion.getSubject().nameID.value - def email = attributes[userAttributeMapping.email]?.get(0) ?: subjectNameId - String username = attributes[userAttributeMapping.username]?.get(0) ?: subjectNameId - def roles = extractRoles(email, attributes, userAttributeMapping, samlSecurityConfigProperties.forceLowercaseRoles) - - if (samlSecurityConfigProperties.sortRoles) { - roles = roles.sort() - } - - if (samlSecurityConfigProperties.requiredRoles) { - if (!samlSecurityConfigProperties.requiredRoles.any { it in roles }) { - throw new BadCredentialsException("User $email does not have all roles $samlSecurityConfigProperties.requiredRoles") - } - } - - def id = registry - .createId("fiat.login") - .withTag("type", "saml") - - try { - retrySupport.retry({ -> - permissionService.loginWithRoles(username, roles) - }, 5, 2000, false) - - log.debug("Successful SAML authentication (user: {}, roleCount: {}, roles: {})", username, roles.size(), roles) - id = id.withTag("success", true).withTag("fallback", "none") - } catch (Exception e) { - log.debug( - "Unsuccessful SAML authentication (user: {}, roleCount: {}, roles: {}, legacyFallback: {})", - username, - roles.size(), - roles, - fiatClientConfigurationProperties.legacyFallback, - e - ) - id = id.withTag("success", false).withTag("fallback", fiatClientConfigurationProperties.legacyFallback) - - if (!fiatClientConfigurationProperties.legacyFallback) { - throw e - } - } finally { - registry.counter(id).increment() - } - - return new User( - email: email, - firstName: attributes[userAttributeMapping.firstName]?.get(0), - lastName: attributes[userAttributeMapping.lastName]?.get(0), - roles: roles, - allowedAccounts: allowedAccountsSupport.filterAllowedAccounts(username, roles), - username: username - ) - } - - Set extractRoles(String email, - Map> attributes, - UserAttributeMapping userAttributeMapping, - boolean forceLowercaseRoles) { - def assertionRoles = attributes[userAttributeMapping.roles].collect { String roles -> - def commonNames = roles.split(userAttributeMapping.rolesDelimiter) - commonNames.collect { - return it.indexOf("CN=") < 0 ? it : it.substring(it.indexOf("CN=") + 3, it.indexOf(",")) - } - }.flatten() as Set - - if (forceLowercaseRoles) { - assertionRoles = assertionRoles*.toLowerCase() - } - - return assertionRoles - } - - static Map> extractAttributes(Assertion assertion) { - def attributes = [:] - assertion.attributeStatements*.attributes.flatten().each { Attribute attribute -> - def name = attribute.name - def values = attribute.attributeValues.findResults { - switch (it) { - case XSString: - return (it as XSString)?.value - case XSAny: - return (it as XSAny)?.textContent - } - return null - } ?: [] - attributes[name] = values - } - - return attributes - } - } - } - - // Available digests taken from org.opensaml.xml.signature.SignatureConstants (RSA signatures) - private enum SignatureAlgorithms { - SHA1(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA1, SignatureConstants.ALGO_ID_DIGEST_SHA1), - SHA256(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256, SignatureConstants.ALGO_ID_DIGEST_SHA256), - SHA384(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA384, SignatureConstants.ALGO_ID_DIGEST_SHA384), - SHA512(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA512, SignatureConstants.ALGO_ID_DIGEST_SHA512), - RIPEMD160(SignatureConstants.ALGO_ID_SIGNATURE_RSA_RIPEMD160, SignatureConstants.ALGO_ID_DIGEST_RIPEMD160), - MD5(SignatureConstants.ALGO_ID_SIGNATURE_NOT_RECOMMENDED_RSA_MD5, SignatureConstants.ALGO_ID_DIGEST_NOT_RECOMMENDED_MD5) - - String rsaSignatureMethod - String digestMethod - SignatureAlgorithms(String rsaSignatureMethod, String digestMethod) { - this.rsaSignatureMethod = rsaSignatureMethod - this.digestMethod = digestMethod - } - - static SignatureAlgorithms fromName(String digestName) { - SignatureAlgorithms.find { it -> (it.name() == digestName.toUpperCase()) } as SignatureAlgorithms - } - } - -} diff --git a/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/DefaultUserIdentifierExtractor.java b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/DefaultUserIdentifierExtractor.java new file mode 100644 index 0000000000..65d3a994d8 --- /dev/null +++ b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/DefaultUserIdentifierExtractor.java @@ -0,0 +1,36 @@ +/* + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.netflix.spinnaker.gate.security.saml; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; + +/** + * Default implementation for extracting the user id from an authenticated SAML user. This uses the + * settings in {@link SecuritySamlProperties.UserAttributeMapping#getUsername()} + */ +@RequiredArgsConstructor +public class DefaultUserIdentifierExtractor implements UserIdentifierExtractor { + private final SecuritySamlProperties properties; + + @Override + public String fromPrincipal(Saml2AuthenticatedPrincipal principal) { + String userid = principal.getFirstAttribute(properties.getUserAttributeMapping().getUsername()); + return userid != null ? userid : principal.getName(); + } +} diff --git a/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/DefaultUserRolesExtractor.java b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/DefaultUserRolesExtractor.java new file mode 100644 index 0000000000..15f947e7b7 --- /dev/null +++ b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/DefaultUserRolesExtractor.java @@ -0,0 +1,82 @@ +/* + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.netflix.spinnaker.gate.security.saml; + +import com.netflix.spinnaker.kork.exceptions.ConfigurationException; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import lombok.RequiredArgsConstructor; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; + +/** + * Default implementation for extracting roles from an authenticated SAML user. This uses the + * settings in {@link SecuritySamlProperties} related to roles. If role names appear to be + * distinguished names (i.e., they contain the substring {@code CN=}), then they will be parsed as + * DNs to extract the common name (CN) attribute. + */ +@RequiredArgsConstructor +public class DefaultUserRolesExtractor implements UserRolesExtractor { + private final SecuritySamlProperties properties; + + @Override + public Set getRoles(Saml2AuthenticatedPrincipal principal) { + var userAttributeMapping = properties.getUserAttributeMapping(); + List roles = principal.getAttribute(userAttributeMapping.getRoles()); + Stream roleStream = roles != null ? roles.stream() : Stream.empty(); + String delimiter = userAttributeMapping.getRolesDelimiter(); + roleStream = + delimiter != null + ? roleStream.flatMap(role -> Stream.of(role.split(delimiter))) + : roleStream; + roleStream = roleStream.map(DefaultUserRolesExtractor::parseRole); + if (properties.isForceLowercaseRoles()) { + roleStream = roleStream.map(role -> role.toLowerCase(Locale.ROOT)); + } + if (properties.isSortRoles()) { + roleStream = roleStream.sorted(); + } + return roleStream.collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private static String parseRole(String role) { + if (!role.contains("CN=")) { + return role; + } + try { + return new LdapName(role) + .getRdns().stream() + .filter(rdn -> rdn.getType().equals("CN")) + .map(rdn -> (String) rdn.getValue()) + .findFirst() + .orElseThrow( + () -> + new ConfigurationException( + String.format( + "SAML role '%s' contains 'CN=' but cannot be parsed as a DN", role))); + } catch (InvalidNameException e) { + throw new ConfigurationException( + String.format("Unable to parse SAML role name '%s'", role), e); + } + } +} diff --git a/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/ResponseAuthenticationConverter.java b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/ResponseAuthenticationConverter.java new file mode 100644 index 0000000000..3887fd1428 --- /dev/null +++ b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/ResponseAuthenticationConverter.java @@ -0,0 +1,95 @@ +/* + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.netflix.spinnaker.gate.security.saml; + +import com.netflix.spinnaker.gate.services.AuthenticationService; +import com.netflix.spinnaker.security.User; +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider; +import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider.ResponseToken; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.util.CollectionUtils; + +/** Handles conversion of an authenticated SAML user into a Spinnaker user and populating Fiat. */ +@Log4j2 +@RequiredArgsConstructor +public class ResponseAuthenticationConverter + implements Converter { + private final SecuritySamlProperties properties; + private final ObjectFactory userIdentifierExtractorFactory; + private final ObjectFactory userRolesExtractorFactory; + private final ObjectFactory authenticationServiceFactory; + + @Override + public PreAuthenticatedAuthenticationToken convert(ResponseToken source) { + UserIdentifierExtractor userIdentifierExtractor = userIdentifierExtractorFactory.getObject(); + UserRolesExtractor userRolesExtractor = userRolesExtractorFactory.getObject(); + AuthenticationService loginService = authenticationServiceFactory.getObject(); + log.debug("Decoding SAML response: {}", source.getToken()); + + Saml2Authentication authentication = convertToken(source); + @SuppressWarnings("deprecation") + var user = new User(); + Saml2AuthenticatedPrincipal principal = + (Saml2AuthenticatedPrincipal) authentication.getPrincipal(); + String principalName = principal.getName(); + var userAttributeMapping = properties.getUserAttributeMapping(); + String email = principal.getFirstAttribute(userAttributeMapping.getEmail()); + user.setEmail(email != null ? email : principalName); + String userid = userIdentifierExtractor.fromPrincipal(principal); + user.setUsername(userid); + user.setFirstName(principal.getFirstAttribute(userAttributeMapping.getFirstName())); + user.setLastName(principal.getFirstAttribute(userAttributeMapping.getLastName())); + + Set roles = userRolesExtractor.getRoles(principal); + user.setRoles(roles); + + if (!CollectionUtils.isEmpty(properties.getRequiredRoles())) { + var requiredRoles = Set.copyOf(properties.getRequiredRoles()); + // check for at least one common role in both sets + if (Collections.disjoint(roles, requiredRoles)) { + throw new BadCredentialsException( + String.format("User %s is not in any required role from %s", email, requiredRoles)); + } + } + + Collection authorities = loginService.loginWithRoles(userid, roles); + return new PreAuthenticatedAuthenticationToken(user, principal, authorities); + } + + private static final Converter DEFAULT_CONVERTER = + OpenSaml4AuthenticationProvider.createDefaultResponseAuthenticationConverter(); + + private static Saml2Authentication convertToken(ResponseToken token) { + Saml2Authentication authentication = DEFAULT_CONVERTER.convert(token); + if (authentication == null) { + throw new IllegalArgumentException("Response token could not be converted"); + } + return authentication; + } +} diff --git a/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/SAMLConfiguration.java b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/SAMLConfiguration.java new file mode 100644 index 0000000000..7f3f1493a8 --- /dev/null +++ b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/SAMLConfiguration.java @@ -0,0 +1,107 @@ +/* + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.netflix.spinnaker.gate.security.saml; + +import com.netflix.spinnaker.gate.config.AuthConfig; +import com.netflix.spinnaker.gate.security.SpinnakerAuthConfig; +import com.netflix.spinnaker.gate.services.AuthenticationService; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.session.DefaultCookieSerializerCustomizer; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.saml2.core.Saml2X509Credential; +import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider; +import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrations; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(SecuritySamlProperties.class) +public class SAMLConfiguration { + + @EnableWebSecurity + @SpinnakerAuthConfig + @RequiredArgsConstructor + @ConditionalOnProperty("saml.enabled") + public static class WebSecurityConfig extends WebSecurityConfigurerAdapter { + private final SecuritySamlProperties properties; + private final AuthConfig authConfig; + private final ObjectProvider userIdentifierExtractorProvider; + private final ObjectProvider userRolesExtractorProvider; + private final ObjectFactory authenticationServiceFactory; + + /** Disables the same-site requirement for cookies as configured in other SSO modules. */ + @Bean + public static DefaultCookieSerializerCustomizer defaultCookieSerializerCustomizer() { + return cookieSerializer -> cookieSerializer.setSameSite(null); + } + + @Bean + public ResponseAuthenticationConverter responseAuthenticationConverter() { + return new ResponseAuthenticationConverter( + properties, + () -> + userIdentifierExtractorProvider.getIfAvailable( + () -> new DefaultUserIdentifierExtractor(properties)), + () -> + userRolesExtractorProvider.getIfAvailable( + () -> new DefaultUserRolesExtractor(properties)), + authenticationServiceFactory); + } + + @Bean + @SneakyThrows + public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() { + var builder = + RelyingPartyRegistrations.fromMetadataLocation(properties.getMetadataUrl()) + .registrationId(properties.getRegistrationId()) + .entityId(properties.getIssuerId()) + .assertionConsumerServiceLocation(properties.getAssertionConsumerServiceLocation()); + Saml2X509Credential decryptionCredential = properties.getDecryptionCredential(); + if (decryptionCredential != null) { + builder.decryptionX509Credentials(credentials -> credentials.add(decryptionCredential)); + } + RelyingPartyRegistration registration = builder.build(); + return new InMemoryRelyingPartyRegistrationRepository(registration); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + authConfig.configure(http); + var authenticationProvider = new OpenSaml4AuthenticationProvider(); + authenticationProvider.setResponseAuthenticationConverter(responseAuthenticationConverter()); + http.rememberMe(Customizer.withDefaults()) + .saml2Login( + saml -> + saml.authenticationManager(new ProviderManager(authenticationProvider)) + .loginProcessingUrl(properties.getLoginProcessingUrl()) + .relyingPartyRegistrationRepository(relyingPartyRegistrationRepository())); + } + } +} diff --git a/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/SecuritySamlProperties.java b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/SecuritySamlProperties.java new file mode 100644 index 0000000000..288234b097 --- /dev/null +++ b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/SecuritySamlProperties.java @@ -0,0 +1,162 @@ +/* + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.netflix.spinnaker.gate.security.saml; + +import com.netflix.spinnaker.kork.annotations.NullableByDefault; +import com.netflix.spinnaker.kork.exceptions.ConfigurationException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Enumeration; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import javax.annotation.Nonnull; +import javax.annotation.PostConstruct; +import javax.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.security.saml2.core.Saml2X509Credential; +import org.springframework.util.StringUtils; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@ConfigurationProperties("saml") +@NullableByDefault +public class SecuritySamlProperties { + private Path keyStore; + private String keyStoreType = "PKCS12"; + private String keyStorePassword; + private String keyStoreAliasName = "mykey"; // default alias for keytool + + public Saml2X509Credential getDecryptionCredential() + throws IOException, GeneralSecurityException { + if (keyStore == null) { + return null; + } + if (keyStoreType == null) { + keyStoreType = "PKCS12"; + } + KeyStore store = KeyStore.getInstance(keyStoreType); + char[] password = keyStorePassword != null ? keyStorePassword.toCharArray() : new char[0]; + try (var stream = Files.newInputStream(keyStore)) { + store.load(stream, password); + } + String alias = keyStoreAliasName; + var certificate = (X509Certificate) store.getCertificate(alias); + var privateKey = (PrivateKey) store.getKey(alias, password); + return Saml2X509Credential.decryption(privateKey, certificate); + } + + /** URL pointing to the SAML metadata to use. */ + private String metadataUrl; + + /** Registration id for this SAML provider. Used in SAML processing URLs. */ + @NotEmpty private String registrationId = "SSO"; + + /** + * The Relying Party's entity ID (sometimes called an issuer ID). The value may contain a number + * of placeholders. They are "baseUrl", "registrationId", "baseScheme", "baseHost", and + * "basePort". + */ + @NotEmpty private String issuerId = "{baseUrl}/saml2/metadata"; + + /** + * The path used for login processing. When combined with the base URL, this should form the + * assertion consumer service location. + */ + @NotEmpty private String loginProcessingUrl = "/saml/{registrationId}"; + + /** + * Returns the assertion consumer service location template to use for redirecting back from the + * identity provider. + */ + public String getAssertionConsumerServiceLocation() { + return "{baseUrl}" + loginProcessingUrl; + } + + /** Optional list of roles required for authentication to succeed. */ + private List requiredRoles; + + /** Determines whether to sort the roles returned from the SAML provider. */ + private boolean sortRoles = false; + + /** Toggles whether role names should be converted to lowercase. */ + private boolean forceLowercaseRoles = true; + + @Nonnull @NestedConfigurationProperty + private UserAttributeMapping userAttributeMapping = new UserAttributeMapping(); + + @PostConstruct + public void validate() throws IOException, GeneralSecurityException { + if (StringUtils.hasLength(metadataUrl) && metadataUrl.startsWith("/")) { + metadataUrl = "file:" + metadataUrl; + } + if (keyStore != null) { + if (keyStoreType == null) { + keyStoreType = "PKCS12"; + } + var keystore = KeyStore.getInstance(keyStoreType); + var password = keyStorePassword != null ? keyStorePassword.toCharArray() : new char[0]; + try (var stream = Files.newInputStream(keyStore)) { + // will throw an exception if `keyStorePassword` is invalid or if the key store file is + // invalid + keystore.load(stream, password); + } + if (StringUtils.hasLength(keyStoreAliasName)) { + var aliases = caseInsensitiveSetFromAliasEnumeration(keystore.aliases()); + if (!aliases.contains(keyStoreAliasName)) { + throw new ConfigurationException( + String.format( + "Keystore '%s' does not contain alias '%s'; found aliases: %s", + keyStore, keyStoreAliasName, aliases)); + } + } + } + } + + @Nonnull + private static Set caseInsensitiveSetFromAliasEnumeration( + @Nonnull Enumeration enumeration) { + Set set = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + while (enumeration.hasMoreElements()) { + set.add(enumeration.nextElement()); + } + return set; + } + + @Getter + @Setter + @Validated + public static class UserAttributeMapping { + @NotEmpty private String firstName = "User.FirstName"; + @NotEmpty private String lastName = "User.LastName"; + @NotEmpty private String roles = "memberOf"; + @NotEmpty private String rolesDelimiter = ";"; + private String username; + private String email; + } +} diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/NoopOidcConfigService.java b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/UserIdentifierExtractor.java similarity index 58% rename from gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/NoopOidcConfigService.java rename to gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/UserIdentifierExtractor.java index 2229e2cd58..c34f324675 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/NoopOidcConfigService.java +++ b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/UserIdentifierExtractor.java @@ -1,7 +1,7 @@ /* - * Copyright 2018 Netflix, Inc. + * Copyright 2023 Apple, Inc. * - * Licensed under the Apache License, Version 2.0 (the "License") + * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * @@ -12,21 +12,14 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * */ -package com.netflix.spinnaker.gate.services; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +package com.netflix.spinnaker.gate.security.saml; -public class NoopOidcConfigService implements OidcConfigService { - public List getOidcConfigs(String app) { - return new ArrayList<>(); - } +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; - public Map getOidcConfig(String id) { - return new HashMap<>(); - } +/** Strategy for extracting a userid from an authenticated SAML2 principal. */ +public interface UserIdentifierExtractor { + String fromPrincipal(Saml2AuthenticatedPrincipal principal); } diff --git a/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/UserRolesExtractor.java b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/UserRolesExtractor.java new file mode 100644 index 0000000000..1eee3bef9c --- /dev/null +++ b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/UserRolesExtractor.java @@ -0,0 +1,27 @@ +/* + * Copyright 2023 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.netflix.spinnaker.gate.security.saml; + +import java.util.Set; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal; + +/** Strategy for extracting and potentially filtering roles from a SAML assertion. */ +public interface UserRolesExtractor { + /** Returns the roles to assign the given principal. */ + Set getRoles(Saml2AuthenticatedPrincipal principal); +} diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/OidcConfigService.java b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/package-info.java similarity index 70% rename from gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/OidcConfigService.java rename to gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/package-info.java index 50700c03cc..4b8309fff5 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/OidcConfigService.java +++ b/gate-saml/src/main/java/com/netflix/spinnaker/gate/security/saml/package-info.java @@ -1,7 +1,7 @@ /* - * Copyright 2018 Netflix, Inc. + * Copyright 2023 Apple, Inc. * - * Licensed under the Apache License, Version 2.0 (the "License") + * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * @@ -12,15 +12,10 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * */ -package com.netflix.spinnaker.gate.services; - -import java.util.List; -import java.util.Map; - -public interface OidcConfigService { - List getOidcConfigs(String app); +@NonnullByDefault +package com.netflix.spinnaker.gate.security.saml; - Map getOidcConfig(String id); -} +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; diff --git a/gate-saml/src/test/groovy/com/netflix/spinnaker/gate/security/saml/SAMLSecurityConfigPropertiesSpec.groovy b/gate-saml/src/test/groovy/com/netflix/spinnaker/gate/security/saml/SAMLSecurityConfigPropertiesSpec.groovy deleted file mode 100644 index 943cbf5898..0000000000 --- a/gate-saml/src/test/groovy/com/netflix/spinnaker/gate/security/saml/SAMLSecurityConfigPropertiesSpec.groovy +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2016 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -package com.netflix.spinnaker.gate.security.saml - -import spock.lang.Specification -import spock.lang.Unroll - -class SAMLSecurityConfigPropertiesSpec extends Specification { - @Unroll - def "should validate that the keystore exists and the password/alias are valid"() { - given: - def ssoConfig = new SamlSsoConfig.SAMLSecurityConfigProperties( - keyStore: keyStore.toString(), keyStorePassword: keyStorePassword, keyStoreAliasName: keyStoreAliasName - ) - - expect: - try { - ssoConfig.validate() - assert !expectsException - } catch (Exception ignored) { - assert expectsException - } - - try { - // ensure validation works if a keystore is not prefixed with "file:" - ssoConfig.keyStore = ssoConfig ? ssoConfig.keyStore.replaceAll("file:", "") : null - ssoConfig.validate() - assert !expectsException - } catch (Exception ignored) { - assert expectsException - } - - where: - keyStore | keyStorePassword | keyStoreAliasName || expectsException - this.class.getResource("/does-not-exist.jks") | null | null || true // keystore does not exist - this.class.getResource("/saml-client.jks") | "invalid" | "saml-client" || true // password is invalid - this.class.getResource("/saml-client.jks") | "123456" | "invalid" || true // alias is invalid - this.class.getResource("/saml-client.jks") | "123456" | "saml-client" || false - } -} diff --git a/gate-saml/src/test/resources/saml-client.jks b/gate-saml/src/test/resources/saml-client.jks deleted file mode 100644 index ea2a99098d..0000000000 Binary files a/gate-saml/src/test/resources/saml-client.jks and /dev/null differ diff --git a/gate-web/config/gate.yml b/gate-web/config/gate.yml index 54f01b2a10..e731f1d70a 100644 --- a/gate-web/config/gate.yml +++ b/gate-web/config/gate.yml @@ -118,7 +118,9 @@ spring.session.store-type: redis --- spring: - profiles: googleOAuth + config: + activate: + on-profile: googleOAuth security: oauth2: @@ -139,7 +141,9 @@ security: --- spring: - profiles: azureOAuth + config: + activate: + on-profile: azureOAuth security: oauth2: @@ -162,7 +166,9 @@ security: --- spring: - profiles: githubOAuth + config: + activate: + on-profile: githubOAuth security: oauth2: diff --git a/gate-web/gate-web.gradle b/gate-web/gate-web.gradle index 7573ae62ea..18d3615d94 100644 --- a/gate-web/gate-web.gradle +++ b/gate-web/gate-web.gradle @@ -1,14 +1,5 @@ apply plugin: 'io.spinnaker.package' -ext { - springConfigLocation = System.getProperty('spring.config.additional-location', "${System.getProperty('user.home')}/.spinnaker/".toString()) - springProfiles = System.getProperty('spring.profiles.active', "test,local") -} - -run { - systemProperty('spring.config.additional-location', project.springConfigLocation) - systemProperty('spring.profiles.active', project.springProfiles) -} mainClassName = 'com.netflix.spinnaker.gate.Main' repositories { @@ -37,13 +28,17 @@ dependencies { implementation "io.spinnaker.fiat:fiat-core:$fiatVersion" implementation "io.spinnaker.fiat:fiat-api:$fiatVersion" + implementation "io.spinnaker.kork:kork-artifacts" + implementation "io.spinnaker.kork:kork-core" + implementation "io.spinnaker.kork:kork-config" implementation "io.spinnaker.kork:kork-plugins" implementation "io.spinnaker.kork:kork-web" implementation "com.netflix.frigga:frigga" implementation "redis.clients:jedis" - implementation 'commons-io:commons-io' - implementation 'org.springframework.session:spring-session-data-redis' + implementation "commons-io:commons-io" + implementation "org.codehaus.groovy:groovy-templates" + implementation "org.springframework.session:spring-session-data-redis" implementation "de.huxhorn.sulky:de.huxhorn.sulky.ulid" implementation "org.apache.commons:commons-lang3" @@ -57,6 +52,10 @@ dependencies { implementation "io.springfox:springfox-swagger2" + implementation "io.cloudevents:cloudevents-spring:2.5.0" + implementation "io.cloudevents:cloudevents-json-jackson:2.5.0" + implementation "io.cloudevents:cloudevents-http-basic:2.5.0" + runtimeOnly "io.spinnaker.kork:kork-runtime" runtimeOnly "org.springframework.boot:spring-boot-properties-migrator" @@ -73,11 +72,13 @@ dependencies { testImplementation "org.springframework.security:spring-security-oauth2-jose" testImplementation "com.unboundid:unboundid-ldapsdk" testImplementation "io.spinnaker.kork:kork-jedis-test" + testImplementation "io.spinnaker.kork:kork-test" + testImplementation "org.codehaus.groovy:groovy-json" testRuntimeOnly "io.spinnaker.kork:kork-retrofit" // Add each included authz provider as a runtime dependency gradle.includedProviderProjects.each { - runtime project(it) + runtimeOnly project(it) } } @@ -102,5 +103,4 @@ test { } } } - useJUnitPlatform() } diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/Main.groovy b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/Main.groovy index a7fab3f352..2fd1db765e 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/Main.groovy +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/Main.groovy @@ -24,6 +24,7 @@ import org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration import org.springframework.boot.builder.SpringApplicationBuilder import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.scheduling.annotation.EnableAsync +import com.netflix.spinnaker.kork.boot.DefaultPropertiesBuilder @EnableAsync @EnableConfigurationProperties @@ -41,15 +42,7 @@ import org.springframework.scheduling.annotation.EnableAsync ) class Main { - static final Map DEFAULT_PROPS = [ - 'netflix.environment': 'test', - 'netflix.account': '${netflix.environment}', - 'netflix.stack': 'test', - 'spring.config.additional-location': '${user.home}/.spinnaker/', - 'spring.application.name': 'gate', - 'spring.config.name': 'spinnaker,${spring.application.name}', - 'spring.profiles.active': '${netflix.environment},local' - ] + static final Map DEFAULT_PROPS = new DefaultPropertiesBuilder().property("spring.application.name", "gate").build() static void main(String... args) { new SpringApplicationBuilder().properties(DEFAULT_PROPS).sources(Main).run(args) diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/OidcClientConfig.java b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/CloudEventHandlerConfiguration.java similarity index 52% rename from gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/OidcClientConfig.java rename to gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/CloudEventHandlerConfiguration.java index 30d7cbe502..f7c2165e7e 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/OidcClientConfig.java +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/CloudEventHandlerConfiguration.java @@ -1,11 +1,10 @@ /* - * Copyright 2017 Netflix, Inc. * - * Licensed under the Apache License, Version 2.0 (the "License"); + * Licensed under the Apache License, Version 2.0 (the "License") * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -13,19 +12,26 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package com.netflix.spinnaker.gate.config; -import com.netflix.spinnaker.gate.services.NoopOidcConfigService; -import com.netflix.spinnaker.gate.services.OidcConfigService; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import io.cloudevents.spring.mvc.CloudEventHttpMessageConverter; +import java.util.List; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration -public class OidcClientConfig { +public class CloudEventHandlerConfiguration implements WebMvcConfigurer { + + @Override + public void configureMessageConverters(List> converters) { + converters.add(cloudEventHttpMessageConverter()); + } + @Bean - @ConditionalOnMissingBean(OidcConfigService.class) - OidcConfigService noopOidcConfigService() { - return new NoopOidcConfigService(); + public CloudEventHttpMessageConverter cloudEventHttpMessageConverter() { + return new CloudEventHttpMessageConverter(); } } diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/GateConfig.groovy b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/GateConfig.groovy index 322fba59ac..e3a3bf41ba 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/GateConfig.groovy +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/GateConfig.groovy @@ -16,21 +16,18 @@ package com.netflix.spinnaker.gate.config -import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.dataformat.yaml.YAMLMapper -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import com.netflix.spectator.api.Registry import com.netflix.spinnaker.config.DefaultServiceEndpoint import com.netflix.spinnaker.config.OkHttp3ClientConfiguration import com.netflix.spinnaker.config.PluginsAutoConfiguration -import com.netflix.spinnaker.config.okhttp3.OkHttpClientProvider import com.netflix.spinnaker.fiat.shared.FiatClientConfigurationProperties import com.netflix.spinnaker.fiat.shared.FiatPermissionEvaluator import com.netflix.spinnaker.fiat.shared.FiatService import com.netflix.spinnaker.fiat.shared.FiatStatus import com.netflix.spinnaker.filters.AuthenticatedRequestFilter -import com.netflix.spinnaker.gate.config.PostConnectionConfiguringJedisConnectionFactory.ConnectionPostProcessor import com.netflix.spinnaker.gate.converters.JsonHttpMessageConverter import com.netflix.spinnaker.gate.converters.YamlHttpMessageConverter import com.netflix.spinnaker.gate.filters.RequestLoggingFilter @@ -38,7 +35,6 @@ import com.netflix.spinnaker.gate.filters.RequestSheddingFilter import com.netflix.spinnaker.gate.filters.ResetAuthenticatedRequestFilter import com.netflix.spinnaker.gate.plugins.deck.DeckPluginConfiguration import com.netflix.spinnaker.gate.plugins.web.PluginWebConfiguration -import com.netflix.spinnaker.gate.services.EurekaLookupService import com.netflix.spinnaker.gate.services.internal.* import com.netflix.spinnaker.kork.client.ServiceClientProvider import com.netflix.spinnaker.kork.dynamicconfig.DynamicConfigService @@ -52,11 +48,9 @@ import com.netflix.spinnaker.okhttp.OkHttpClientConfigurationProperties import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.web.servlet.FilterRegistrationBean import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -64,15 +58,11 @@ import org.springframework.context.annotation.Import import org.springframework.context.annotation.Primary import org.springframework.core.Ordered import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter -import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer -import org.springframework.session.data.redis.config.ConfigureRedisAction -import org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder import org.springframework.util.CollectionUtils import org.springframework.web.client.RestTemplate -import redis.clients.jedis.JedisPool import retrofit.Endpoint -import javax.servlet.* import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -81,61 +71,22 @@ import static retrofit.Endpoints.newFixedEndpoint @CompileStatic @Configuration @Slf4j -@EnableConfigurationProperties([FiatClientConfigurationProperties, DynamicRoutingConfigProperties]) @Import([PluginsAutoConfiguration, DeckPluginConfiguration, PluginWebConfiguration]) -class GateConfig extends RedisHttpSessionConfiguration { +class GateConfig { private ServiceClientProvider serviceClientProvider - @Value('${server.session.timeout-in-seconds:3600}') - void setSessionTimeout(int maxInactiveIntervalInSeconds) { - super.setMaxInactiveIntervalInSeconds(maxInactiveIntervalInSeconds) - } - @Autowired void setServiceClientProvider(ServiceClientProvider serviceClientProvider) { this.serviceClientProvider = serviceClientProvider } - @Autowired - GateConfig(@Value('${server.session.timeout-in-seconds:3600}') int maxInactiveIntervalInSeconds) { - super.setMaxInactiveIntervalInSeconds(maxInactiveIntervalInSeconds) - } - - /** - * This pool is used for the rate limit storage, as opposed to the JedisConnectionFactory, which - * is a separate pool used for Spring Boot's session management. - */ - @Bean - JedisPool jedis(@Value('${redis.connection:redis://localhost:6379}') String connection, - @Value('${redis.timeout:2000}') int timeout) { - return new JedisPool(new URI(connection), timeout) - } - @Bean @ConditionalOnMissingBean(RestTemplate) RestTemplate restTemplate() { new RestTemplate() } - /** - * Always disable the ConfigureRedisAction that Spring Boot uses internally. Instead we use one - * qualified with @ConnectionPostProcessor. See - * {@link PostConnectionConfiguringJedisConnectionFactory}. - * */ - @Bean - @Primary - ConfigureRedisAction springBootConfigureRedisAction() { - return ConfigureRedisAction.NO_OP - } - - @Bean - @ConnectionPostProcessor - @ConditionalOnProperty("redis.configuration.secure") - ConfigureRedisAction connectionPostProcessorConfigureRedisAction() { - return ConfigureRedisAction.NO_OP - } - @Bean ExecutorService executorService() { Executors.newCachedThreadPool() @@ -145,10 +96,10 @@ class GateConfig extends RedisHttpSessionConfiguration { Registry registry @Autowired - EurekaLookupService eurekaLookupService + ServiceConfiguration serviceConfiguration @Autowired - ServiceConfiguration serviceConfiguration + Jackson2ObjectMapperBuilder objectMapperBuilder /** * This needs to be before the yaml converter in order for json to be the default @@ -156,17 +107,12 @@ class GateConfig extends RedisHttpSessionConfiguration { */ @Bean AbstractJackson2HttpMessageConverter jsonHttpMessageConverter() { - ObjectMapper objectMapper = new ObjectMapper() - .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .registerModule(new JavaTimeModule()) - - return new JsonHttpMessageConverter(objectMapper) + return new JsonHttpMessageConverter(objectMapperBuilder.build()) } @Bean AbstractJackson2HttpMessageConverter yamlHttpMessageConverter() { - return new YamlHttpMessageConverter(new YAMLMapper()) + return new YamlHttpMessageConverter(objectMapperBuilder.factory(new YAMLFactory()).build()) } @Bean @@ -210,12 +156,6 @@ class GateConfig extends RedisHttpSessionConfiguration { createClient "clouddriver", ClouddriverService } - @Bean - @ConditionalOnProperty("services.keel.enabled") - KeelService keelService(OkHttpClientProvider clientProvider) { - createClient "keel", KeelService - } - @Bean ClouddriverServiceSelector clouddriverServiceSelector(ClouddriverService defaultClouddriverService, DynamicConfigService dynamicConfigService, @@ -234,7 +174,7 @@ class GateConfig extends RedisHttpSessionConfiguration { // priority: 2 // origin: deck - def defaultSelector = new DefaultServiceSelector( + ServiceSelector defaultSelector = new DefaultServiceSelector( defaultClouddriverService, 1, null) @@ -335,14 +275,12 @@ class GateConfig extends RedisHttpSessionConfiguration { } private T buildService(String serviceName, Class type, Endpoint endpoint) { - // New role providers break deserialization if this is not enabled. - ObjectMapper objectMapper = new ObjectMapper() - .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .registerModule(new JavaTimeModule()) - + ObjectMapper objectMapper = objectMapperBuilder.build() as ObjectMapper + if(serviceName.equals("echo")) { + objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false) + objectMapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, false) + } serviceClientProvider.getService(type, new DefaultServiceEndpoint(serviceName, endpoint.url), objectMapper) - } private SelectableService createClientSelector(String serviceName, Class type) { @@ -374,8 +312,8 @@ class GateConfig extends RedisHttpSessionConfiguration { } @Bean - FilterRegistrationBean resetAuthenticatedRequestFilter() { - def frb = new FilterRegistrationBean(new ResetAuthenticatedRequestFilter()) + FilterRegistrationBean resetAuthenticatedRequestFilter() { + def frb = new FilterRegistrationBean<>(new ResetAuthenticatedRequestFilter()) frb.order = Ordered.HIGHEST_PRECEDENCE return frb } @@ -387,32 +325,18 @@ class GateConfig extends RedisHttpSessionConfiguration { * Additionally forwards request origin metadata (deck vs api). */ @Bean - FilterRegistrationBean authenticatedRequestFilter() { + FilterRegistrationBean authenticatedRequestFilter() { // no need to force the `AuthenticatedRequestFilter` to create a request id as that is // handled by the `RequestTimingFilter`. - def frb = new FilterRegistrationBean(new AuthenticatedRequestFilter(false, true, false, false)) + def frb = new FilterRegistrationBean<>(new AuthenticatedRequestFilter(true, true, false, false)) frb.order = Ordered.LOWEST_PRECEDENCE - 1 return frb } - /** - * This pulls the `springSecurityFilterChain` in front of the {@link AuthenticatedRequestFilter}, - * because the user must be authenticated through the security filter chain before their username/credentials - * can be pulled and forwarded in the AuthenticatedRequestFilter. - */ - @Bean - FilterRegistrationBean securityFilterChain( - @Qualifier(AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME) Filter securityFilter) { - def frb = new FilterRegistrationBean(securityFilter) - frb.order = 0 - frb.name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME - return frb - } - @Bean @ConditionalOnProperty("request-logging.enabled") - FilterRegistrationBean requestLoggingFilter() { - def frb = new FilterRegistrationBean(new RequestLoggingFilter()) + FilterRegistrationBean requestLoggingFilter() { + def frb = new FilterRegistrationBean<>(new RequestLoggingFilter()) // this filter should be placed very early in the request chain to ensure we track an accurate start time and // have a request id available to propagate across thread and service boundaries. frb.order = Ordered.HIGHEST_PRECEDENCE + 1 @@ -420,8 +344,8 @@ class GateConfig extends RedisHttpSessionConfiguration { } @Bean - FilterRegistrationBean requestSheddingFilter(DynamicConfigService dynamicConfigService) { - def frb = new FilterRegistrationBean(new RequestSheddingFilter(dynamicConfigService, registry)) + FilterRegistrationBean requestSheddingFilter(DynamicConfigService dynamicConfigService) { + def frb = new FilterRegistrationBean<>(new RequestSheddingFilter(dynamicConfigService, registry)) /* * This filter should: diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/GateWebConfig.groovy b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/GateWebConfig.groovy index 00f63e1de3..4acd511ec0 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/GateWebConfig.groovy +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/GateWebConfig.groovy @@ -19,13 +19,15 @@ package com.netflix.spinnaker.gate.config import com.netflix.spectator.api.Registry import com.netflix.spinnaker.gate.filters.ContentCachingFilter import com.netflix.spinnaker.gate.interceptors.RequestContextInterceptor -import com.netflix.spinnaker.gate.interceptors.RequestIdInterceptor - +import com.netflix.spinnaker.gate.interceptors.ResponseHeaderInterceptor +import com.netflix.spinnaker.gate.interceptors.ResponseHeaderInterceptorConfigurationProperties import com.netflix.spinnaker.gate.retrofit.UpstreamBadRequest import com.netflix.spinnaker.kork.dynamicconfig.DynamicConfigService +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import com.netflix.spinnaker.kork.web.interceptors.MetricsInterceptor import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Bean import org.springframework.context.annotation.ComponentScan @@ -45,6 +47,7 @@ import javax.servlet.http.HttpServletResponse @Configuration @ComponentScan +@EnableConfigurationProperties(ResponseHeaderInterceptorConfigurationProperties.class) public class GateWebConfig implements WebMvcConfigurer { @Autowired Registry registry @@ -58,6 +61,9 @@ public class GateWebConfig implements WebMvcConfigurer { @Value('${rate-limit.learning:true}') Boolean rateLimitLearningMode + @Autowired + ResponseHeaderInterceptorConfigurationProperties responseHeaderInterceptorConfigurationProperties + @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor( @@ -66,7 +72,7 @@ public class GateWebConfig implements WebMvcConfigurer { ) ) - registry.addInterceptor(new RequestIdInterceptor()) + registry.addInterceptor(new ResponseHeaderInterceptor(responseHeaderInterceptorConfigurationProperties)) registry.addInterceptor(new RequestContextInterceptor()) } @@ -75,7 +81,10 @@ public class GateWebConfig implements WebMvcConfigurer { return new HandlerMappingIntrospector(context) } + + // Add the ability to disable as this breaks numerous integration patterns @Bean + @ConditionalOnProperty(value = "content.cachingFilter.enabled", matchIfMissing = true) Filter contentCachingFilter() { // This filter simply buffers the response so that Content-Length header can be set return new ContentCachingFilter() diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/RateLimiterConfig.java b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/RateLimiterConfig.java index 514b08b58a..f0e383ebd4 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/RateLimiterConfig.java +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/RateLimiterConfig.java @@ -59,13 +59,13 @@ RateLimitPrincipalProvider staticRateLimiterPrincipalProvider() { } @Bean - FilterRegistrationBean rateLimitingFilter( + FilterRegistrationBean rateLimitingFilter( RateLimiter rateLimiter, Registry registry, RateLimitPrincipalProvider rateLimitPrincipalProvider, Optional> requestIdentityExtractors) { - FilterRegistrationBean frb = - new FilterRegistrationBean( + var frb = + new FilterRegistrationBean<>( new RateLimitingFilter( rateLimiter, registry, diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/RetrofitConfig.groovy b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/RetrofitConfig.groovy deleted file mode 100644 index 5814ed8afe..0000000000 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/RetrofitConfig.groovy +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2016 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.config - -import com.netflix.spinnaker.config.OkHttp3ClientConfiguration -import okhttp3.OkHttpClient -import org.springframework.beans.factory.config.ConfigurableBeanFactory -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Scope - -@Configuration -class RetrofitConfig { - @Bean - @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) - OkHttpClient okHttpClient(OkHttp3ClientConfiguration okHttpClientConfig) { - return okHttpClientConfig.create().build() - } - -} diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/ArtifactController.java b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/ArtifactController.java index 4d1ba03658..8caba35f76 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/ArtifactController.java +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/ArtifactController.java @@ -17,11 +17,12 @@ package com.netflix.spinnaker.gate.controllers; import com.netflix.spinnaker.gate.services.ArtifactService; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; import io.swagger.annotations.ApiOperation; -import java.io.IOException; -import java.io.OutputStream; +import java.io.InputStream; import java.util.List; import java.util.Map; +import org.apache.commons.io.IOUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; @@ -51,10 +52,9 @@ List all(@RequestHeader(value = "X-RateLimit-App", required = false) String StreamingResponseBody fetch( @RequestBody Map artifact, @RequestHeader(value = "X-RateLimit-App", required = false) String sourceApp) { - return new StreamingResponseBody() { - public void writeTo(OutputStream outputStream) throws IOException { - artifactService.getArtifactContents(sourceApp, artifact, outputStream); - outputStream.flush(); + return outputStream -> { + try (InputStream inputStream = artifactService.getArtifactContents(sourceApp, artifact)) { + IOUtils.copy(inputStream, outputStream); } }; } @@ -98,4 +98,12 @@ Map getArtifact( @PathVariable String version) { return artifactService.getArtifactByVersion(provider, packageName, version); } + + @ApiOperation(value = "Retrieve artifact by content hash") + @RequestMapping(value = "/content-address/{application}/{hash}", method = RequestMethod.GET) + Artifact.StoredView getStoredArtifact( + @PathVariable(value = "application") String application, + @PathVariable(value = "hash") String hash) { + return artifactService.getStoredArtifact(application, hash); + } } diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/CredentialsController.groovy b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/CredentialsController.groovy index 1b7093b79b..a0944f3a84 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/CredentialsController.groovy +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/CredentialsController.groovy @@ -97,11 +97,11 @@ class CredentialsController { @ApiParam(value = 'Value of the "@type" key for accounts to search for.', example = 'kubernetes') @PathVariable String accountType, @ApiParam('Maximum number of entries to return in results. Used for pagination.') - @RequestParam OptionalInt limit, + @RequestParam(required = false) Integer limit, @ApiParam('Account name to start account definition listing from. Used for pagination.') - @RequestParam Optional startingAccountName + @RequestParam(required = false) String startingAccountName ) { - clouddriverService.getAccountDefinitionsByType(accountType, limit.isPresent() ? limit.getAsInt() : null, startingAccountName.orElse(null)) + clouddriverService.getAccountDefinitionsByType(accountType, limit, startingAccountName) } @PostMapping diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/OidcConfigController.java b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/OidcConfigController.java deleted file mode 100644 index ee4a4cb319..0000000000 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/OidcConfigController.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2018 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.controllers; - -import com.netflix.spinnaker.gate.services.OidcConfigService; -import java.util.List; -import java.util.Map; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class OidcConfigController { - @Autowired OidcConfigService oidcConfigService; - - @RequestMapping(value = "/oidcConfigs", method = RequestMethod.GET) - List byApp(@RequestParam(value = "app") String app) { - return oidcConfigService.getOidcConfigs(app); - } - - @RequestMapping(value = "/oidcConfig", method = RequestMethod.GET) - Map byId(@RequestParam(value = "id") String id) { - return oidcConfigService.getOidcConfig(id); - } -} diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/PipelineController.groovy b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/PipelineController.groovy index 418cfa280b..9fced36317 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/PipelineController.groovy +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/PipelineController.groovy @@ -203,6 +203,22 @@ class PipelineController { @ApiOperation(value = "Restart a stage execution", response = HashMap.class) @PutMapping("/{id}/stages/{stageId}/restart") Map restartStage(@PathVariable("id") String id, @PathVariable("stageId") String stageId, @RequestBody Map context) { + Map pipelineMap = getPipeline(id) + + String pipelineName = pipelineMap.get("name"); + String application = pipelineMap.get("application"); + + List pipelineConfigs = front50Service.getPipelineConfigsForApplication(application, true) + + if (pipelineConfigs!=null && !pipelineConfigs.isEmpty()){ + Optional filterResult = pipelineConfigs.stream() + .filter({pipeline -> ((String) pipeline.get("name")) != null && ((String) pipeline.get("name")).trim().equalsIgnoreCase(pipelineName)}) + .findFirst() + if (filterResult.isPresent()){ + context = filterResult.get() + } + } + pipelineService.restartPipelineStage(id, stageId, context) } diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/WebhookController.groovy b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/WebhookController.groovy index 5a08935d7f..e752cb4480 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/WebhookController.groovy +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/controllers/WebhookController.groovy @@ -19,12 +19,17 @@ package com.netflix.spinnaker.gate.controllers import com.netflix.spinnaker.gate.services.WebhookService import io.swagger.annotations.ApiOperation import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders +import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMethod import org.springframework.web.bind.annotation.RestController +import io.cloudevents.CloudEvent + +import java.nio.charset.StandardCharsets @RestController @RequestMapping("/webhooks") @@ -48,6 +53,15 @@ class WebhookController { } } + @ApiOperation(value = "Endpoint for posting webhooks to Spinnaker's CDEvents webhook service") + @RequestMapping(value = "/cdevents/{source}", method = RequestMethod.POST) + ResponseEntity webhooks(@PathVariable String source, + @RequestBody CloudEvent cdEvent) + { + String ceDataJsonString = new String(cdEvent.getData().toBytes(), StandardCharsets.UTF_8); + webhookService.webhooks(source, cdEvent, ceDataJsonString) + } + @ApiOperation(value = "Retrieve a list of preconfigured webhooks in Orca") @RequestMapping(value = "/preconfigured", method = RequestMethod.GET) List preconfiguredWebhooks() { diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/interceptors/RequestIdInterceptor.java b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/interceptors/RequestIdInterceptor.java deleted file mode 100644 index af0c619f85..0000000000 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/interceptors/RequestIdInterceptor.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2017 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.interceptors; - -import static com.netflix.spinnaker.kork.common.Header.REQUEST_ID; - -import com.netflix.spinnaker.security.AuthenticatedRequest; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; - -/** - * Return value of SPINNAKER_REQUEST_ID (set via - * com.netflix.spinnaker.filters.AuthenticatedRequestFilter) to gate callers as a response header. - */ -public class RequestIdInterceptor extends HandlerInterceptorAdapter { - @Override - public boolean preHandle( - HttpServletRequest request, HttpServletResponse response, Object handler) { - AuthenticatedRequest.getSpinnakerRequestId() - .ifPresent(requestId -> response.setHeader(REQUEST_ID.getHeader(), requestId)); - return true; - } -} diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/interceptors/ResponseHeaderInterceptor.java b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/interceptors/ResponseHeaderInterceptor.java new file mode 100644 index 0000000000..5245e1c81c --- /dev/null +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/interceptors/ResponseHeaderInterceptor.java @@ -0,0 +1,60 @@ +/* + * Copyright 2017 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.interceptors; + +import static com.netflix.spinnaker.kork.common.Header.REQUEST_ID; + +import com.netflix.spinnaker.security.AuthenticatedRequest; +import java.util.Optional; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; + +/** + * Return values (e.g. X-SPINNAKER-*) stored in the AuthenticatedRequest (backed by MDC and set via + * com.netflix.spinnaker.filters.AuthenticatedRequestFilter) to gate callers as a response header. + * For X-SPINNAKER-REQUEST-ID, if its value is absent from AuthenticatedRequest, the value of + * X-SPINNAKER-EXECUTION-ID is returned as the request ID, or a UUID is generated and returned if + * X-SPINNAKER-EXECUTION-ID is also absent. For other fields, no values are returned if they are + * absent from AuthenticatedRequest. + */ +public class ResponseHeaderInterceptor extends HandlerInterceptorAdapter { + + private final ResponseHeaderInterceptorConfigurationProperties properties; + + public ResponseHeaderInterceptor(ResponseHeaderInterceptorConfigurationProperties properties) { + this.properties = properties; + } + + @Override + public boolean preHandle( + HttpServletRequest request, HttpServletResponse response, Object handler) { + for (String field : this.properties.getFields()) { + // getSpinnakerRequestId() contains logic to either return the spinnaker + // execution id or generate a new one if no current value exists in MDC, + // the generic get() does not contain such logic + // check whether we are processing the request id to make sure we call + // the right method to retain the above logic + Optional value = + field.equals(REQUEST_ID.getHeader()) + ? AuthenticatedRequest.getSpinnakerRequestId() + : AuthenticatedRequest.get(field); + value.ifPresent(v -> response.setHeader(field, v)); + } + return true; + } +} diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/interceptors/ResponseHeaderInterceptorConfigurationProperties.java b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/interceptors/ResponseHeaderInterceptorConfigurationProperties.java new file mode 100644 index 0000000000..ab5dea55ed --- /dev/null +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/interceptors/ResponseHeaderInterceptorConfigurationProperties.java @@ -0,0 +1,30 @@ +/* + * Copyright 2022 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.interceptors; + +import com.netflix.spinnaker.kork.common.Header; +import java.util.List; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "interceptors.response-header") +public class ResponseHeaderInterceptorConfigurationProperties { + // default to having request id in response header, the original behavior + // before other fields are added + private List fields = List.of(Header.REQUEST_ID.getHeader()); +} diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/ArtifactService.java b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/ArtifactService.java index 7445a99f78..2c73013ca4 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/ArtifactService.java +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/ArtifactService.java @@ -18,16 +18,15 @@ import com.netflix.spinnaker.gate.services.internal.ClouddriverServiceSelector; import com.netflix.spinnaker.gate.services.internal.IgorService; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; import groovy.transform.CompileStatic; -import java.io.OutputStream; +import java.io.IOException; +import java.io.InputStream; import java.util.List; import java.util.Map; import java.util.Optional; -import lombok.SneakyThrows; -import org.apache.commons.io.IOUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; -import retrofit.client.Response; @CompileStatic @Component @@ -56,11 +55,13 @@ public List getArtifactVersions( return clouddriverServiceSelector.select().getArtifactVersions(accountName, type, artifactName); } - @SneakyThrows - public void getArtifactContents( - String selectorKey, Map artifact, OutputStream outputStream) { - Response contentResponse = clouddriverServiceSelector.select().getArtifactContent(artifact); - IOUtils.copy(contentResponse.getBody().in(), outputStream); + public InputStream getArtifactContents(String selectorKey, Map artifact) + throws IOException { + return clouddriverServiceSelector.select().getArtifactContent(artifact).getBody().in(); + } + + public Artifact.StoredView getStoredArtifact(String application, String hash) { + return clouddriverServiceSelector.select().getStoredArtifact(application, hash); } public List getVersionsOfArtifactForProvider( diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/WebhookService.groovy b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/WebhookService.groovy index 4a0394a9d1..81acca5293 100644 --- a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/WebhookService.groovy +++ b/gate-web/src/main/groovy/com/netflix/spinnaker/gate/services/WebhookService.groovy @@ -20,7 +20,10 @@ import com.netflix.spinnaker.gate.services.internal.EchoService import com.netflix.spinnaker.gate.services.internal.OrcaServiceSelector import com.netflix.spinnaker.security.AuthenticatedRequest import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders +import org.springframework.http.ResponseEntity import org.springframework.stereotype.Component +import io.cloudevents.CloudEvent @Component class WebhookService { @@ -53,6 +56,12 @@ class WebhookService { }) } + ResponseEntity webhooks(String source, CloudEvent cdEvent, String ceDataJsonString) { + return AuthenticatedRequest.allowAnonymous( { + echoService.webhooks(source, cdEvent, ceDataJsonString, cdEvent.getId(), cdEvent.getSpecVersion().V1.toString(), cdEvent.getType(), cdEvent.getSource().toString()) + }) + } + List preconfiguredWebhooks() { return AuthenticatedRequest.allowAnonymous({ orcaServiceSelector.select().preconfiguredWebhooks() diff --git a/gate-web/src/main/java/com/netflix/spinnaker/gate/config/GateCorsConfig.java b/gate-web/src/main/java/com/netflix/spinnaker/gate/config/GateCorsConfig.java index 8d7b60c198..f3102894d7 100644 --- a/gate-web/src/main/java/com/netflix/spinnaker/gate/config/GateCorsConfig.java +++ b/gate-web/src/main/java/com/netflix/spinnaker/gate/config/GateCorsConfig.java @@ -55,16 +55,15 @@ OriginValidator gateOriginValidator( @Bean @ConditionalOnProperty(name = "cors.allow-mode", havingValue = "regex", matchIfMissing = true) - FilterRegistrationBean regExCorsFilter(OriginValidator gateOriginValidator) { - FilterRegistrationBean filterRegBean = - new FilterRegistrationBean<>(new CorsFilter(gateOriginValidator)); + FilterRegistrationBean regExCorsFilter(OriginValidator gateOriginValidator) { + var filterRegBean = new FilterRegistrationBean<>(new CorsFilter(gateOriginValidator)); filterRegBean.setOrder(Ordered.HIGHEST_PRECEDENCE); return filterRegBean; } @Bean @ConditionalOnProperty(name = "cors.allow-mode", havingValue = "list") - FilterRegistrationBean allowedOriginCorsFilter( + FilterRegistrationBean allowedOriginCorsFilter( @Value("${cors.allowed-origins:*}") List allowedOriginList) { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); @@ -74,7 +73,7 @@ FilterRegistrationBean allowedOriginCorsFilter( config.setMaxAge(MAX_AGE_IN_SECONDS); config.addAllowedMethod("*"); // Enable CORS for all methods. source.registerCorsConfiguration("/**", config); // Enable CORS for all paths - FilterRegistrationBean filterRegBean = + var filterRegBean = new FilterRegistrationBean<>(new org.springframework.web.filter.CorsFilter(source)); filterRegBean.setOrder(Ordered.HIGHEST_PRECEDENCE); return filterRegBean; diff --git a/gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/PostConnectionConfiguringJedisConnectionFactory.java b/gate-web/src/main/java/com/netflix/spinnaker/gate/config/PostConnectionConfiguringJedisConnectionFactory.java similarity index 100% rename from gate-web/src/main/groovy/com/netflix/spinnaker/gate/config/PostConnectionConfiguringJedisConnectionFactory.java rename to gate-web/src/main/java/com/netflix/spinnaker/gate/config/PostConnectionConfiguringJedisConnectionFactory.java diff --git a/gate-web/src/main/java/com/netflix/spinnaker/gate/config/RedisActionConfig.java b/gate-web/src/main/java/com/netflix/spinnaker/gate/config/RedisActionConfig.java new file mode 100644 index 0000000000..2ff9781f54 --- /dev/null +++ b/gate-web/src/main/java/com/netflix/spinnaker/gate/config/RedisActionConfig.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024 OpsMx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.session.data.redis.config.ConfigureRedisAction; + +@Configuration +public class RedisActionConfig { + + /** + * Always disable the ConfigureRedisAction that Spring Boot uses internally. Instead we use one + * qualified with @ConnectionPostProcessor. See {@link + * PostConnectionConfiguringJedisConnectionFactory}. + */ + @Bean + @Primary + public ConfigureRedisAction springBootConfigureRedisAction() { + return ConfigureRedisAction.NO_OP; + } + + @Bean + @ConditionalOnProperty("redis.configuration.secure") + @PostConnectionConfiguringJedisConnectionFactory.ConnectionPostProcessor + public ConfigureRedisAction connectionPostProcessorConfigureRedisAction() { + return ConfigureRedisAction.NO_OP; + } +} diff --git a/gate-web/src/main/java/com/netflix/spinnaker/gate/config/RedisConfig.java b/gate-web/src/main/java/com/netflix/spinnaker/gate/config/RedisConfig.java new file mode 100644 index 0000000000..f2a703f55b --- /dev/null +++ b/gate-web/src/main/java/com/netflix/spinnaker/gate/config/RedisConfig.java @@ -0,0 +1,37 @@ +package com.netflix.spinnaker.gate.config; + +import java.net.URI; +import java.net.URISyntaxException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration; +import redis.clients.jedis.JedisPool; + +@Configuration +public class RedisConfig extends RedisHttpSessionConfiguration { + + @Value("${server.session.timeout-in-seconds:3600}") + public void setSessionTimeout(int maxInactiveIntervalInSeconds) { + super.setMaxInactiveIntervalInSeconds(maxInactiveIntervalInSeconds); + } + + @Autowired + public RedisConfig( + @Value("${server.session.timeout-in-seconds:3600}") int maxInactiveIntervalInSeconds) { + super.setMaxInactiveIntervalInSeconds(maxInactiveIntervalInSeconds); + } + + /** + * This pool is used for the rate limit storage, as opposed to the JedisConnectionFactory, which + * is a separate pool used for Spring Boot's session management. + */ + @Bean + public JedisPool jedis( + @Value("${redis.connection:redis://localhost:6379}") String connection, + @Value("${redis.timeout:2000}") int timeout) + throws URISyntaxException { + return new JedisPool(new URI(connection), timeout); + } +} diff --git a/gate-web/src/main/resources/application.properties b/gate-web/src/main/resources/application.properties new file mode 100644 index 0000000000..3d38c74dce --- /dev/null +++ b/gate-web/src/main/resources/application.properties @@ -0,0 +1,25 @@ +# +# Copyright 2023 Apple, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/converter/json/Jackson2ObjectMapperBuilder.html +# Note: fail on unknown properties is disabled by default here. This also pulls in some common Jackson modules. +# New role providers break deserialization if this is not enabled. +spring.jackson.deserialization.read-unknown-enum-values-as-null=true + +# This defaults to -100, but for reasons (?) is set to 0 +# https://github.com/spinnaker/gate/pull/230 +# Either way, this filter needs to come before the AuthenticatedRequestFilter +spring.security.filter.order=0 diff --git a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/FunctionalSpec.groovy b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/FunctionalSpec.groovy index f4a48925c4..dbb29e04f8 100644 --- a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/FunctionalSpec.groovy +++ b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/FunctionalSpec.groovy @@ -41,6 +41,7 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur import retrofit.RetrofitError import retrofit.RestAdapter; import retrofit.client.OkClient +import retrofit.converter.JacksonConverter import retrofit.mime.TypedInput import spock.lang.Shared import spock.lang.Specification @@ -71,6 +72,21 @@ class FunctionalSpec extends Specification { ConfigurableApplicationContext ctx + static Properties origProperties; + + void setupSpec() { + origProperties = System.getProperties(); + Properties copy = new Properties(); + copy.putAll(origProperties); + System.setProperties(copy); + } + + def cleanupSpec() { + if (origProperties != null) { + System.setProperties(origProperties) + } + } + void setup() { applicationService = Mock(ApplicationService) orcaServiceSelector = Mock(OrcaServiceSelector) @@ -86,11 +102,7 @@ class FunctionalSpec extends Specification { serviceConfiguration = new ServiceConfiguration() fiatStatus = Mock(FiatStatus) - - def sock = new ServerSocket(0) - def localPort = sock.localPort - sock.close() - System.setProperty("server.port", localPort.toString()) + System.setProperty("server.port", "0") // to get a random port System.setProperty("saml.enabled", "false") System.setProperty('spring.session.store-type', 'NONE') System.setProperty("spring.main.allow-bean-definition-overriding", "true") @@ -100,16 +112,18 @@ class FunctionalSpec extends Specification { spring.setSources([FunctionalConfiguration] as Set) ctx = spring.run() + def localPort = ctx.environment.getProperty("local.server.port") api = new RestAdapter.Builder() .setEndpoint("http://localhost:${localPort}") .setClient(new OkClient()) + .setConverter(new JacksonConverter()) .setLogLevel(RestAdapter.LogLevel.FULL) .build() .create(Api) } def cleanup() { - ctx.close() + ctx?.close() } void "should call ApplicationService for applications"() { diff --git a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/MainSpec.java b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/MainSpec.java index 222cd784f7..e32af71e8e 100644 --- a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/MainSpec.java +++ b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/MainSpec.java @@ -1,10 +1,18 @@ package com.netflix.spinnaker.gate; +import static org.assertj.core.api.Assertions.assertThat; + +import com.netflix.spinnaker.fiat.shared.FiatService; +import com.netflix.spinnaker.gate.services.ApplicationService; +import com.netflix.spinnaker.gate.services.PermissionService; +import com.netflix.spinnaker.gate.services.internal.ClouddriverService; +import com.netflix.spinnaker.gate.services.internal.ExtendedFiatService; +import com.netflix.spinnaker.gate.services.internal.Front50Service; import com.netflix.spinnaker.kork.client.ServiceClientProvider; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -14,11 +22,22 @@ @ActiveProfiles("test") @TestPropertySource(properties = {"spring.config.location=classpath:gate-test.yml"}) public class MainSpec { + @MockBean private ClouddriverService mockClouddriverService; + + @MockBean private ServiceClientProvider serviceClientProvider; + + @MockBean private ApplicationService mockApplicationService; + + @MockBean private PermissionService mockPermissionService; + + @MockBean private FiatService mockFiatService; + + @MockBean private ExtendedFiatService mockExtendedFiatService; - @Autowired ServiceClientProvider serviceClientProvider; + @MockBean private Front50Service mockFront50Service; @Test public void startupTest() { - assert serviceClientProvider != null; + assertThat(serviceClientProvider != null); } } diff --git a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/config/RedisTestConfig.groovy b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/config/RedisTestConfig.groovy index 679c5b5b1f..0e039b8ff0 100644 --- a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/config/RedisTestConfig.groovy +++ b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/config/RedisTestConfig.groovy @@ -26,12 +26,12 @@ class RedisTestConfig { @Primary @SpringSessionRedisConnectionFactory JedisConnectionFactory jedisConnectionFactory(EmbeddedRedis embeddedRedis) { - return new JedisConnectionFactory(new RedisStandaloneConfiguration("127.0.0.1", embeddedRedis.port)) + return new JedisConnectionFactory(new RedisStandaloneConfiguration(embeddedRedis.host, embeddedRedis.port)) } @Bean @Primary JedisPool jedis(EmbeddedRedis embeddedRedis) { - return new JedisPool(new URI("redis://127.0.0.1:$embeddedRedis.port"), 5000) + return embeddedRedis.getPool(); } } diff --git a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/controllers/CredentialsControllerSpec.groovy b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/controllers/CredentialsControllerSpec.groovy index 8db2c6664d..2205bde4ff 100644 --- a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/controllers/CredentialsControllerSpec.groovy +++ b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/controllers/CredentialsControllerSpec.groovy @@ -49,10 +49,7 @@ class CredentialsControllerSpec extends Specification { } @Subject - CredentialsService credentialsService = new CredentialsService( - accountLookupService: accountLookupService, - fiatStatus: fiatStatus - ) + CredentialsService credentialsService = new CredentialsService(accountLookupService, fiatStatus) FiatPermissionEvaluator fpe = Stub(FiatPermissionEvaluator) AllowedAccountsSupport allowedAccountsSupport = new AllowedAccountsSupport(fiatStatus, fpe, credentialsService) @@ -61,7 +58,11 @@ class CredentialsControllerSpec extends Specification { contentNegotiationManagerFactoryBean.addMediaType("json", MediaType.APPLICATION_JSON) contentNegotiationManagerFactoryBean.favorPathExtension = false mockMvc = MockMvcBuilders - .standaloneSetup(new CredentialsController(accountLookupService: accountLookupService, allowedAccountsSupport: allowedAccountsSupport)) + .standaloneSetup(new CredentialsController( + accountLookupService: accountLookupService, + allowedAccountsSupport: allowedAccountsSupport, + clouddriverService: clouddriverService + )) .setContentNegotiationManager(contentNegotiationManagerFactoryBean.build()) .build() } @@ -81,4 +82,39 @@ class CredentialsControllerSpec extends Specification { "test" || "test" "test.com" || "test.com" } + + @Unroll + void "listing credentials by type should succeed when optional arguments are not provided"() { + when: + MockHttpServletResponse response = mockMvc.perform(get("/credentials/type/${accountType}") + .accept(MediaType.APPLICATION_JSON)).andReturn().response + + then: + response.status == 200 + 1 * clouddriverService.getAccountDefinitionsByType(accountType, _, _) + + where: + accountType | _ + "type1" | _ + "type2" | _ + } + + @Unroll + void "listing credentials by type should succeed when optional arguments are provided"() { + when: + MockHttpServletResponse response = mockMvc.perform( + get("/credentials/type/${accountType}") + .param("limit", "${limit}") + .param("startingAccountName", startingAccountName) + .accept(MediaType.APPLICATION_JSON)).andReturn().response + + then: + response.status == 200 + 1 * clouddriverService.getAccountDefinitionsByType(accountType, limit, startingAccountName) + + where: + accountType | limit | startingAccountName + "type1" | 2 | "account1" + "type2" | 500 | "account2" + } } diff --git a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/controllers/WebhookControllerSpec.groovy b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/controllers/WebhookControllerSpec.groovy index 74f80b3669..f346de917c 100644 --- a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/controllers/WebhookControllerSpec.groovy +++ b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/controllers/WebhookControllerSpec.groovy @@ -16,23 +16,26 @@ package com.netflix.spinnaker.gate.controllers +import com.fasterxml.jackson.databind.ObjectMapper +import com.netflix.spinnaker.gate.services.WebhookService import com.netflix.spinnaker.gate.services.internal.EchoService import com.netflix.spinnaker.gate.services.internal.OrcaServiceSelector -import com.netflix.spinnaker.gate.services.WebhookService import com.squareup.okhttp.mockwebserver.MockWebServer -import org.mockito.MockitoAnnotations; +import io.cloudevents.spring.mvc.CloudEventHttpMessageConverter +import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.mock.web.MockHttpServletResponse import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.web.util.NestedServletException import retrofit.RestAdapter -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import retrofit.client.OkClient -import retrofit.http.* +import retrofit.converter.JacksonConverter import spock.lang.Specification +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + class WebhooksControllerSpec extends Specification { MockMvc mockMvc @@ -52,6 +55,7 @@ class WebhooksControllerSpec extends Specification { EchoService echoService = new RestAdapter.Builder() .setEndpoint("http://localhost:${localPort}") .setClient(new OkClient()) + .setConverter(new JacksonConverter()) .build() .create(EchoService) @@ -59,7 +63,9 @@ class WebhooksControllerSpec extends Specification { webhookService = new WebhookService(echoService: echoService, orcaServiceSelector: orcaServiceSelector) server.start() - mockMvc = MockMvcBuilders.standaloneSetup(new WebhookController(webhookService: webhookService)).build() + mockMvc = MockMvcBuilders.standaloneSetup(new WebhookController(webhookService: webhookService)) + .setMessageConverters(new CloudEventHttpMessageConverter()) + .build() } void 'handles null Maps'() { @@ -91,4 +97,61 @@ class WebhooksControllerSpec extends Specification { NestedServletException ex = thrown() ex.message.startsWith("Request processing failed; nested exception is retrofit.RetrofitError: Failed to connect to localhost") } + + void 'handles CDEvents API with BAD_REQUEST'() { + given: + + when: + MockHttpServletResponse response = mockMvc.perform(post("/webhooks/cdevents/artifactPackaged") + .accept(MediaType.APPLICATION_JSON)) + .andReturn().response + + then: + response.status == 400 + } + + void 'handles CDEvents API server Ping'() { + given: + HttpHeaders headers = new HttpHeaders(); + headers.add("Ce-Id", "1234") + headers.add("Ce-Specversion", "1.0") + headers.add("Ce-Type", "dev.cdevents.artifact.packaged") + headers.add("Ce-Source", "spinnaker.test.io") + headers.add("Content-Type", "application/cloudevents+json") + String cdEventData = "{\n" + + " \"context\": {\n" + + " \"version\": \"0.1.2\",\n" + + " \"id\": \"c046b63b-a340-4847-bc39-ee408ad666d9\",\n" + + " \"source\": \"http://dev.cdevents\",\n" + + " \"type\": \"dev.cdevents.artifact.published.0.1.0\",\n" + + " \"timestamp\": \"2023-11-28T15:33:03Z\"\n" + + " },\n" + + " \"subject\": {\n" + + " \"id\": \"pkg:oci/myapp@sha256%3A0b31b1c02ff458ad9b7b\",\n" + + " \"source\": \"/dev/artifact/source\",\n" + + " \"type\": \"artifact\",\n" + + " \"content\": {\n" + + " }\n" + + " },\n" + + " \"customData\": {},\n" + + " \"customDataContentType\": \"application/json\"\n" + + "}" + Map cdEvent = [ + specversion: "1.0", + type: "dev.cdevents.artifact.packaged", + source: "/spinnaker.test.io", + id: "12345", + data: cdEventData + ] + + when: + mockMvc.perform(post("/webhooks/cdevents/artifactPackaged") + .headers(headers) + .content(new ObjectMapper().writeValueAsString(cdEvent))) + .andExpect(status().isOk()).andReturn() + + then: + NestedServletException ex = thrown() + ex.message.startsWith("Request processing failed; nested exception is retrofit.RetrofitError: Failed to connect to localhost") + } } diff --git a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/services/CredentialsServiceSpec.groovy b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/services/CredentialsServiceSpec.groovy index ccbfe956c0..93a7e3083c 100644 --- a/gate-web/src/test/groovy/com/netflix/spinnaker/gate/services/CredentialsServiceSpec.groovy +++ b/gate-web/src/test/groovy/com/netflix/spinnaker/gate/services/CredentialsServiceSpec.groovy @@ -16,7 +16,6 @@ package com.netflix.spinnaker.gate.services -import com.netflix.spinnaker.fiat.shared.FiatClientConfigurationProperties import com.netflix.spinnaker.fiat.shared.FiatStatus import com.netflix.spinnaker.gate.services.internal.ClouddriverService import com.netflix.spinnaker.gate.services.internal.ClouddriverService.AccountDetails @@ -41,10 +40,7 @@ class CredentialsServiceSpec extends Specification { } @Subject - CredentialsService credentialsService = new CredentialsService( - accountLookupService: accountLookupService, - fiatStatus: fiatStatus - ) + CredentialsService credentialsService = new CredentialsService(accountLookupService, fiatStatus) expect: credentialsService.getAccountNames(roles) == expectedAccounts diff --git a/gate-web/src/test/java/com/netflix/spinnaker/gate/config/RedisConfigTest.java b/gate-web/src/test/java/com/netflix/spinnaker/gate/config/RedisConfigTest.java new file mode 100644 index 0000000000..8c26f3b173 --- /dev/null +++ b/gate-web/src/test/java/com/netflix/spinnaker/gate/config/RedisConfigTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024 OpsMx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.session.data.redis.config.ConfigureRedisAction; + +class RedisConfigTest { + + @Test + public void testCircularDependenciesException() { + ApplicationContextRunner applicationContextRunner = + new ApplicationContextRunner() + .withUserConfiguration(RedisConfig.class, RedisActionConfig.class) + .withBean(PostConnectionConfiguringJedisConnectionFactory.class); + assertDoesNotThrow( + () -> + applicationContextRunner.run( + ctx -> assertThat(ctx).hasSingleBean(ConfigureRedisAction.class))); + } + + @Test + public void testCircularDependenciesExceptionSecure() { + ApplicationContextRunner applicationContextRunner = + new ApplicationContextRunner() + .withUserConfiguration(RedisConfig.class, RedisActionConfig.class) + .withBean(PostConnectionConfiguringJedisConnectionFactory.class) + .withPropertyValues("redis.configuration.secure", "true"); + + assertDoesNotThrow( + () -> + applicationContextRunner.run( + ctx -> assertThat(ctx).getBeans(ConfigureRedisAction.class).hasSize(2))); + } +} diff --git a/gate-web/src/test/java/com/netflix/spinnaker/gate/controllers/ArtifactControllerTest.java b/gate-web/src/test/java/com/netflix/spinnaker/gate/controllers/ArtifactControllerTest.java new file mode 100644 index 0000000000..e711d908fc --- /dev/null +++ b/gate-web/src/test/java/com/netflix/spinnaker/gate/controllers/ArtifactControllerTest.java @@ -0,0 +1,150 @@ +/* + * Copyright 2022 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.controllers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.spinnaker.fiat.shared.FiatService; +import com.netflix.spinnaker.gate.Main; +import com.netflix.spinnaker.gate.services.ApplicationService; +import com.netflix.spinnaker.gate.services.PermissionService; +import com.netflix.spinnaker.gate.services.internal.ClouddriverService; +import com.netflix.spinnaker.gate.services.internal.ClouddriverServiceSelector; +import com.netflix.spinnaker.gate.services.internal.ExtendedFiatService; +import com.netflix.spinnaker.gate.services.internal.Front50Service; +import com.netflix.spinnaker.kork.client.ServiceClientProvider; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Map; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.context.WebApplicationContext; +import retrofit.client.Response; +import retrofit.mime.TypedByteArray; + +@SpringBootTest(classes = {Main.class}) +@TestPropertySource(properties = {"spring.config.location=classpath:gate-test.yml"}) +public class ArtifactControllerTest { + + private static final String API_BASE = "/artifacts"; + private static final String API_FETCH = "/fetch"; + private static final String ARTIFACT_DATA = "Some data"; + + private MockMvc mockMvc; + + @MockBean private ClouddriverServiceSelector mockClouddriverServiceSelector; + + @MockBean private ClouddriverService mockClouddriverService; + + @MockBean private Front50Service mockFront50Service; + + @MockBean private FiatService mocFiatService; + + @MockBean private ExtendedFiatService mockExtendedFiatService; + + @MockBean private PermissionService mockPermissionService; + + @MockBean private ApplicationService mockApplicationService; + + @MockBean private ServiceClientProvider mockServiceClientProvider; + + @MockBean private InputStream mockInputStream; + + @MockBean private TypedByteArray mockBody; + + @Autowired private ObjectMapper objectMapper; + + @Autowired private WebApplicationContext webApplicationContext; + + @Test + void TestFetch() throws Exception { + + TypedByteArray responseBody = + new TypedByteArray(null, objectMapper.writeValueAsBytes(ARTIFACT_DATA)); + Response response = + new Response("https://localhost", 200, "Some reason", new ArrayList<>(), responseBody); + + when(mockClouddriverServiceSelector.select()).thenReturn(mockClouddriverService); + when(mockClouddriverService.getArtifactContent(anyMap())).thenReturn(response); + + mockMvc = webAppContextSetup(webApplicationContext).build(); + + MvcResult result = + mockMvc + .perform( + put(API_BASE + API_FETCH) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("k1", "v1")))) + .andExpect(request().asyncStarted()) + .andDo(print()) + .andReturn(); + + mockMvc + .perform(asyncDispatch(result)) + .andExpect(content().bytes(objectMapper.writeValueAsBytes(ARTIFACT_DATA))) + .andDo(print()); + } + + @Test + void TestFetchInputStreamIsClosed() throws Exception { + + Response response = + new Response("https://localhost", 200, "Some reason", new ArrayList<>(), mockBody); + + when(mockClouddriverServiceSelector.select()).thenReturn(mockClouddriverService); + when(mockClouddriverService.getArtifactContent(anyMap())).thenReturn(response); + when(mockBody.in()).thenReturn(mockInputStream); + // IOUtil copy default buffer size is 8K, so should expect two read() calls + when(mockInputStream.read(any(byte[].class))) + .thenReturn(ARTIFACT_DATA.length()) + .thenReturn(IOUtils.EOF); + + mockMvc = webAppContextSetup(webApplicationContext).build(); + + MvcResult result = + mockMvc + .perform( + put(API_BASE + API_FETCH) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("k1", "v1")))) + .andExpect(request().asyncStarted()) + .andDo(print()) + .andReturn(); + + mockMvc.perform(asyncDispatch(result)).andDo(print()); + + verify(mockInputStream, Mockito.times(1)).close(); + } +} diff --git a/gate-web/src/test/java/com/netflix/spinnaker/gate/interceptors/ResponseHeaderInterceptorConfigurationPropertiesTest.java b/gate-web/src/test/java/com/netflix/spinnaker/gate/interceptors/ResponseHeaderInterceptorConfigurationPropertiesTest.java new file mode 100644 index 0000000000..4879532bae --- /dev/null +++ b/gate-web/src/test/java/com/netflix/spinnaker/gate/interceptors/ResponseHeaderInterceptorConfigurationPropertiesTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2022 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.interceptors; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import com.netflix.spinnaker.kork.common.Header; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +public class ResponseHeaderInterceptorConfigurationPropertiesTest { + + @Test + public void testResponseHeaderInterceptorSettingsDefault() { + ApplicationContextRunner runner = + new ApplicationContextRunner() + .withUserConfiguration(ResponseHeaderInterceptorTestConfiguration.class); + + runner.run( + ctx -> { + ResponseHeaderInterceptorConfigurationProperties properties = + ctx.getBean(ResponseHeaderInterceptorConfigurationProperties.class); + + assertThat(properties.getFields().size(), equalTo(1)); + assertThat(properties.getFields(), contains(Header.REQUEST_ID.getHeader())); + }); + } + + @Test + public void testResponseHeaderInterceptorSettingsAllFields() { + String[] values = { + "X-SPINNAKER-REQUEST-ID", + "X-SPINNAKER-USER", + "X-SPINNAKER-EXECUTION-ID", + "X-SPINNAKER-EXECUTION-TYPE", + "X-SPINNAKER-APPLICATION" + }; + String value = String.join(",", values); + + ApplicationContextRunner runner = + new ApplicationContextRunner() + .withPropertyValues("interceptors.responseHeader.fields=" + value) + .withUserConfiguration(ResponseHeaderInterceptorTestConfiguration.class); + + runner.run( + ctx -> { + ResponseHeaderInterceptorConfigurationProperties properties = + ctx.getBean(ResponseHeaderInterceptorConfigurationProperties.class); + + assertThat(properties.getFields().size(), equalTo(values.length)); + assertThat(properties.getFields(), containsInAnyOrder(values)); + }); + } + + @Test + public void testResponseHeaderInterceptorSettingsNoFields() { + ApplicationContextRunner runner = + new ApplicationContextRunner() + .withPropertyValues("interceptors.responseHeader.fields=") + .withUserConfiguration(ResponseHeaderInterceptorTestConfiguration.class); + + runner.run( + ctx -> { + ResponseHeaderInterceptorConfigurationProperties properties = + ctx.getBean(ResponseHeaderInterceptorConfigurationProperties.class); + + assertThat(properties.getFields(), empty()); + }); + } + + @EnableConfigurationProperties(ResponseHeaderInterceptorConfigurationProperties.class) + static class ResponseHeaderInterceptorTestConfiguration {} +} diff --git a/gate-web/src/test/java/com/netflix/spinnaker/gate/interceptors/ResponseHeaderInterceptorTest.java b/gate-web/src/test/java/com/netflix/spinnaker/gate/interceptors/ResponseHeaderInterceptorTest.java new file mode 100644 index 0000000000..71988f27a4 --- /dev/null +++ b/gate-web/src/test/java/com/netflix/spinnaker/gate/interceptors/ResponseHeaderInterceptorTest.java @@ -0,0 +1,239 @@ +/* + * Copyright 2022 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.interceptors; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; + +import com.netflix.spinnaker.fiat.shared.FiatService; +import com.netflix.spinnaker.gate.Main; +import com.netflix.spinnaker.gate.services.ApplicationService; +import com.netflix.spinnaker.gate.services.PermissionService; +import com.netflix.spinnaker.gate.services.internal.ClouddriverService; +import com.netflix.spinnaker.gate.services.internal.ExtendedFiatService; +import com.netflix.spinnaker.gate.services.internal.Front50Service; +import com.netflix.spinnaker.kork.client.ServiceClientProvider; +import com.netflix.spinnaker.kork.common.Header; +import com.netflix.spinnaker.security.AuthenticatedRequest; +import java.util.EnumSet; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; + +public class ResponseHeaderInterceptorTest { + + private static final String API_BASE = "/responseHeader"; + private static final String API_PATH = "/api"; + private static final String TEST_REQUEST_ID = "Test-Request-ID"; + private static final String TEST_USER = "Test-User"; + private static final String TEST_EXECUTION_ID = "Test-Execution-ID"; + private static final String TEST_EXECUTION_TYPE = "Test-Execution-Type"; + private static final String TEST_APPLICATION = "Test-Application"; + + @MockBean private ClouddriverService mockClouddriverService; + + @MockBean private ServiceClientProvider mockServiceClientProvider; + + @MockBean private ApplicationService mockApplicationService; + + @MockBean private PermissionService mockPermissionService; + + @MockBean private FiatService mockFiatService; + + @MockBean private ExtendedFiatService mockExtendedFiatService; + + @MockBean private Front50Service mockFront50Service; + + @RestController + @RequestMapping(value = API_BASE) + static class TestController { + @RequestMapping(value = API_PATH, method = RequestMethod.GET) + public void api() {} + } + + private MockMvc mockMvc; + + @BeforeEach + private void setup() { + AuthenticatedRequest.clear(); + } + + @Nested + @SpringBootTest(classes = {Main.class, ResponseHeaderInterceptorTest.TestController.class}) + @TestPropertySource( + properties = { + "spring.config.location=classpath:gate-test.yml", + "interceptors.responseHeader.fields=X-SPINNAKER-REQUEST-ID, X-SPINNAKER-USER, X-SPINNAKER-EXECUTION-ID, X-SPINNAKER-EXECUTION-TYPE, X-SPINNAKER-APPLICATION" + }) + @DisplayName("All fields defined in response header property") + class AllFieldsDefinedInPropertyTest { + @Autowired private WebApplicationContext webApplicationContext; + + @BeforeEach + private void setup() { + mockMvc = webAppContextSetup(webApplicationContext).build(); + } + + @Test + public void testRequestIdExistsInAuthenticatedRequest() throws Exception { + AuthenticatedRequest.setRequestId(TEST_REQUEST_ID); + + mockMvc + .perform(get(API_BASE + API_PATH)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(header().exists(Header.REQUEST_ID.getHeader())) + .andExpect(header().string(Header.REQUEST_ID.getHeader(), equalTo(TEST_REQUEST_ID))) + .andExpect(header().doesNotExist((Header.USER.getHeader()))) + .andExpect(header().doesNotExist((Header.EXECUTION_ID.getHeader()))) + .andExpect(header().doesNotExist((Header.EXECUTION_TYPE.getHeader()))) + .andExpect(header().doesNotExist((Header.APPLICATION.getHeader()))); + } + + @Test + public void testAllHeaderFieldsInAuthenticatedRequest() throws Exception { + AuthenticatedRequest.setRequestId(TEST_REQUEST_ID); + AuthenticatedRequest.setUser(TEST_USER); + AuthenticatedRequest.setExecutionId(TEST_EXECUTION_ID); + AuthenticatedRequest.setExecutionType(TEST_EXECUTION_TYPE); + AuthenticatedRequest.setApplication(TEST_APPLICATION); + + mockMvc + .perform(get(API_BASE + API_PATH)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(header().exists(Header.REQUEST_ID.getHeader())) + .andExpect(header().string(Header.REQUEST_ID.getHeader(), equalTo(TEST_REQUEST_ID))) + .andExpect(header().exists(Header.USER.getHeader())) + .andExpect(header().string(Header.USER.getHeader(), equalTo(TEST_USER))) + .andExpect(header().exists(Header.EXECUTION_ID.getHeader())) + .andExpect(header().string(Header.EXECUTION_ID.getHeader(), equalTo(TEST_EXECUTION_ID))) + .andExpect(header().exists(Header.EXECUTION_TYPE.getHeader())) + .andExpect( + header().string(Header.EXECUTION_TYPE.getHeader(), equalTo(TEST_EXECUTION_TYPE))) + .andExpect(header().exists(Header.APPLICATION.getHeader())) + .andExpect(header().string(Header.APPLICATION.getHeader(), equalTo(TEST_APPLICATION))); + } + + @Test + public void testNoHeaderFieldsInAuthenticatedRequest() throws Exception { + // AuthenticatedRequest generates an uuid as request id if none exists + // so there will always be a request id in the response header if the + // interceptor is enabled + mockMvc + .perform(get(API_BASE + API_PATH)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(header().exists(Header.REQUEST_ID.getHeader())) + .andExpect(header().string(Header.REQUEST_ID.getHeader(), notNullValue())); + } + } + + @Nested + @SpringBootTest(classes = {Main.class, ResponseHeaderInterceptorTest.TestController.class}) + @TestPropertySource( + properties = { + "spring.config.location=classpath:gate-test.yml", + "interceptors.responseHeader.fields=X-SPINNAKER-USER, X-SPINNAKER-EXECUTION-ID, X-SPINNAKER-APPLICATION" + }) + @DisplayName("Partial list of fields defined in response header property") + class PartialFieldsDefinedInPropertyTest { + @Autowired private WebApplicationContext webApplicationContext; + + @BeforeEach + private void setup() { + mockMvc = webAppContextSetup(webApplicationContext).build(); + } + + @Test + public void testPartialFieldsConfiguredAllHeaderFieldsInAuthenticatedRequest() + throws Exception { + AuthenticatedRequest.setRequestId(TEST_REQUEST_ID); + AuthenticatedRequest.setUser(TEST_USER); + AuthenticatedRequest.setExecutionId(TEST_EXECUTION_ID); + AuthenticatedRequest.setExecutionType(TEST_EXECUTION_TYPE); + AuthenticatedRequest.setApplication(TEST_APPLICATION); + + mockMvc + .perform(get(API_BASE + API_PATH)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(header().doesNotExist(Header.REQUEST_ID.getHeader())) + .andExpect(header().exists(Header.USER.getHeader())) + .andExpect(header().string(Header.USER.getHeader(), equalTo(TEST_USER))) + .andExpect(header().exists(Header.EXECUTION_ID.getHeader())) + .andExpect(header().string(Header.EXECUTION_ID.getHeader(), equalTo(TEST_EXECUTION_ID))) + .andExpect(header().doesNotExist(Header.EXECUTION_TYPE.getHeader())) + .andExpect(header().exists(Header.APPLICATION.getHeader())) + .andExpect(header().string(Header.APPLICATION.getHeader(), equalTo(TEST_APPLICATION))); + } + } + + @Nested + @SpringBootTest(classes = {Main.class, ResponseHeaderInterceptorTest.TestController.class}) + @TestPropertySource( + properties = { + "spring.config.location=classpath:gate-test.yml", + "interceptors.responseHeader.fields=" + }) + @DisplayName("Empty fields defined in response header property") + class EmptyFieldsDefinedInPropertyTest { + @Autowired private WebApplicationContext webApplicationContext; + + @BeforeEach + private void setup() { + mockMvc = webAppContextSetup(webApplicationContext).build(); + } + + @Test + public void testNoHeaderFieldsInAuthenticatedRequest() throws Exception { + // test scenario where fields property is configured to empty list so no + // fields should be added to response header, overriding default behavior + // where request id is added + EnumSet
headers = + EnumSet.of( + Header.REQUEST_ID, + Header.USER, + Header.EXECUTION_ID, + Header.EXECUTION_TYPE, + Header.APPLICATION); + + ResultActions actions = + mockMvc.perform(get(API_BASE + API_PATH)).andDo(print()).andExpect(status().isOk()); + + for (Header header : headers) { + actions.andExpect(header().doesNotExist(header.getHeader())); + } + } + } +} diff --git a/gate-web/src/test/java/com/netflix/spinnaker/gate/service/EchoServiceTest.java b/gate-web/src/test/java/com/netflix/spinnaker/gate/service/EchoServiceTest.java new file mode 100644 index 0000000000..c69fc2a994 --- /dev/null +++ b/gate-web/src/test/java/com/netflix/spinnaker/gate/service/EchoServiceTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2024 Harness, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.netflix.spinnaker.gate.Main; +import com.netflix.spinnaker.gate.services.internal.EchoService; +import com.squareup.okhttp.mockwebserver.Dispatcher; +import com.squareup.okhttp.mockwebserver.MockResponse; +import com.squareup.okhttp.mockwebserver.MockWebServer; +import com.squareup.okhttp.mockwebserver.RecordedRequest; +import groovy.util.logging.Slf4j; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +@ActiveProfiles("echo") +@Slf4j +@DirtiesContext +@SpringBootTest(classes = {Main.class}) +@TestPropertySource("/application-echo.properties") +class EchoServiceTest { + + @Autowired EchoService echoService; + + private static MockWebServer echoServer; + private static MockWebServer clouddriverServer; + private static MockWebServer front50Server; + + @BeforeAll + static void setUp() throws IOException { + clouddriverServer = new MockWebServer(); + clouddriverServer.start(7002); + + Dispatcher clouddriverDispatcher = + new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return new MockResponse().setResponseCode(200); + } + }; + clouddriverServer.setDispatcher(clouddriverDispatcher); + + front50Server = new MockWebServer(); + front50Server.start(8081); + Dispatcher front50Dispatcher = + new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return new MockResponse().setResponseCode(200); + } + }; + front50Server.setDispatcher(front50Dispatcher); + + echoServer = new MockWebServer(); + echoServer.start(8089); + } + + @AfterAll + static void tearDown() throws IOException { + echoServer.shutdown(); + clouddriverServer.shutdown(); + front50Server.shutdown(); + } + + @Test + void shouldNotOrderTheKeysWhenCallingEcho() throws InterruptedException { + + echoServer.enqueue(new MockResponse().setResponseCode(200)); + Map body = new HashMap<>(); + body.put("ref", "refs/heads/main"); + body.put("before", "ca7376e4b730f1f2878760abaeaed6c039fc5414"); + body.put("after", "c2420ce6e341ef0042f2e12591bdbe9eec29a032"); + body.put("id", 105648914); + + echoService.webhooks("git", "github", body); + RecordedRequest recordedRequest = echoServer.takeRequest(2, TimeUnit.SECONDS); + String requestBody = recordedRequest.getBody().readUtf8(); + assertThat(requestBody) + .isEqualTo( + "{\"ref\":\"refs/heads/main\",\"before\":\"ca7376e4b730f1f2878760abaeaed6c039fc5414\",\"after\":\"c2420ce6e341ef0042f2e12591bdbe9eec29a032\",\"id\":105648914}"); + } +} diff --git a/gate-web/src/test/java/com/netflix/spinnaker/gate/testconfig/GateConfigAuthenticatedRequestFilterTest.java b/gate-web/src/test/java/com/netflix/spinnaker/gate/testconfig/GateConfigAuthenticatedRequestFilterTest.java new file mode 100644 index 0000000000..ada2b93fb9 --- /dev/null +++ b/gate-web/src/test/java/com/netflix/spinnaker/gate/testconfig/GateConfigAuthenticatedRequestFilterTest.java @@ -0,0 +1,163 @@ +/* + * Copyright 2023 Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.testconfig; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; + +import ch.qos.logback.classic.Level; +import com.netflix.spinnaker.fiat.shared.FiatService; +import com.netflix.spinnaker.gate.Main; +import com.netflix.spinnaker.gate.services.ApplicationService; +import com.netflix.spinnaker.gate.services.PermissionService; +import com.netflix.spinnaker.gate.services.internal.ClouddriverService; +import com.netflix.spinnaker.gate.services.internal.ClouddriverServiceSelector; +import com.netflix.spinnaker.gate.services.internal.ExtendedFiatService; +import com.netflix.spinnaker.gate.services.internal.Front50Service; +import com.netflix.spinnaker.kork.client.ServiceClientProvider; +import com.netflix.spinnaker.kork.common.Header; +import com.netflix.spinnaker.kork.test.log.MemoryAppender; +import com.netflix.spinnaker.security.AuthenticatedRequest; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest( + classes = {Main.class, GateConfigAuthenticatedRequestFilterTest.TestController.class}) +@TestPropertySource(properties = {"spring.config.location=classpath:gate-test.yml"}) +public class GateConfigAuthenticatedRequestFilterTest { + + private static final String API_BASE = "/test"; + private static final String API_PATH = "/api"; + private static final String TEST_USER = "Test-User"; + private static final String TEST_EXECUTION_ID = "Test-Execution-ID"; + private static final String TEST_EXECUTION_TYPE = "Test-Execution-Type"; + private static final String TEST_APPLICATION = "Test-Application"; + + private static final String LOG_MESSAGE = " logged in api: "; + private static final String NULL_VALUE = "null"; + + @MockBean private ServiceClientProvider mockServiceClientProvider; + + @MockBean private ClouddriverService mockClouddriverService; + + @MockBean private ClouddriverServiceSelector mockClouddriverServiceSelector; + + @MockBean private ApplicationService mockApplicationService; + + @MockBean private FiatService mockFiatService; + + @MockBean private PermissionService mockPermissionService; + + @MockBean private ExtendedFiatService mockExtendedFiatService; + + @MockBean private Front50Service mockFront50Service; + + @RestController + @RequestMapping(value = API_BASE) + static class TestController { + private final Logger log = LoggerFactory.getLogger(getClass()); + + @RequestMapping(value = API_PATH, method = RequestMethod.GET) + public void api() { + log.info( + Header.USER.name() + + LOG_MESSAGE + + AuthenticatedRequest.get(Header.USER).orElse(NULL_VALUE)); + log.info( + Header.APPLICATION.name() + + LOG_MESSAGE + + AuthenticatedRequest.get(Header.APPLICATION).orElse(NULL_VALUE)); + log.info( + Header.EXECUTION_ID.name() + + LOG_MESSAGE + + AuthenticatedRequest.get(Header.EXECUTION_ID).orElse(NULL_VALUE)); + log.info( + Header.EXECUTION_TYPE.name() + + LOG_MESSAGE + + AuthenticatedRequest.get(Header.EXECUTION_TYPE).orElse(NULL_VALUE)); + } + } + + @Autowired + @Qualifier("authenticatedRequestFilter") + private FilterRegistrationBean filterRegistrationBean; + + @Autowired private WebApplicationContext webApplicationContext; + + private MockMvc mockMvc; + + // Without setting the extractSpinnakerHeaders flag to true when creating + // AuthenticatedRequestFilter, X-SPINNAKER-* headers would not be copied into + // AuthenticatedRequest MDC for downstream consumption + @Test + void TestHeaderAvailableInAuthenticatedRequestMDC() throws Exception { + mockMvc = + webAppContextSetup(webApplicationContext) + .addFilters(filterRegistrationBean.getFilter()) + .build(); + + MemoryAppender memoryAppender = + new MemoryAppender(GateConfigAuthenticatedRequestFilterTest.TestController.class); + + mockMvc + .perform( + get(API_BASE + API_PATH) + .header(Header.USER.getHeader(), TEST_USER) + .header(Header.APPLICATION.getHeader(), TEST_APPLICATION) + .header(Header.EXECUTION_ID.getHeader(), TEST_EXECUTION_ID) + .header(Header.EXECUTION_TYPE.getHeader(), TEST_EXECUTION_TYPE)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(header().exists(Header.REQUEST_ID.getHeader())); + + List messages = memoryAppender.layoutSearch(LOG_MESSAGE, Level.INFO); + String expectedUserLog = Header.USER.name() + LOG_MESSAGE + TEST_USER; + String expectedApplicationLog = Header.APPLICATION.name() + LOG_MESSAGE + TEST_APPLICATION; + String expectedExecutionIdLog = Header.EXECUTION_ID.name() + LOG_MESSAGE + TEST_EXECUTION_ID; + String expectedExecutionTypeLog = + Header.EXECUTION_TYPE.name() + LOG_MESSAGE + TEST_EXECUTION_TYPE; + + assertThat(messages.size(), equalTo(4)); + assertThat( + messages, + contains( + containsString(expectedUserLog), + containsString(expectedApplicationLog), + containsString(expectedExecutionIdLog), + containsString(expectedExecutionTypeLog))); + } +} diff --git a/gate-web/src/test/resources/application-echo.properties b/gate-web/src/test/resources/application-echo.properties new file mode 100644 index 0000000000..f1ee4857ea --- /dev/null +++ b/gate-web/src/test/resources/application-echo.properties @@ -0,0 +1,44 @@ +# +# Copyright 2024 Harness, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http=//www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +spring.application.name=gate +services.clouddriver.baseUrl=http://localhost:7002 +services.deck.baseUrl=http://localhost:9000 + +services.echo.enabled=true +services.echo.baseUrl=http://localhost:8089 + +services.fiat.enabled=false + +services.fiat.baseUrl=http://localhost:8082 + +services.front50.baseUrl=http://localhost:8081 + +services.igor.enabled=false + +services.kayenta.enabled=false + + +services.orca.baseUrl=http://localhost:8083 + +services.mine.enabled=false + +services.swabbie.enabled=false +services.keel.enabled=false +services.keel.baseUrl=http://localhost:8087 + +retrofit.enabled=true +healthCheckableServices=igor diff --git a/gate-web/src/test/resources/gate-test.yml b/gate-web/src/test/resources/gate-test.yml index 192f24b03c..45341085fd 100644 --- a/gate-web/src/test/resources/gate-test.yml +++ b/gate-web/src/test/resources/gate-test.yml @@ -37,7 +37,9 @@ slack: --- spring: - profiles: alloworigincors + config: + activate: + on-profile: alloworigincors cors: allow-mode: "list" @@ -48,7 +50,9 @@ cors: --- spring: - profiles: regexcors + config: + activate: + on-profile: regexcors cors: allowedOriginsPattern: '^https?://(?:localhost|[^/]+\.somewhere\.net)(?::[1-9]\d*)?/?$' @@ -57,7 +61,9 @@ cors: --- spring: - profiles: test + config: + activate: + on-profile: test spinnaker: extensions: diff --git a/gate-x509/gate-x509.gradle b/gate-x509/gate-x509.gradle index 769a71d518..c013ebb8eb 100644 --- a/gate-x509/gate-x509.gradle +++ b/gate-x509/gate-x509.gradle @@ -1,10 +1,22 @@ dependencies { implementation project(':gate-core') - implementation "org.bouncycastle:bcprov-jdk15on" + implementation "org.bouncycastle:bcprov-jdk18on" implementation "io.spinnaker.kork:kork-core" implementation "io.spinnaker.kork:kork-security" implementation "com.netflix.spectator:spectator-api" implementation "com.github.ben-manes.caffeine:caffeine" implementation "io.spinnaker.fiat:fiat-api:$fiatVersion" implementation "io.spinnaker.fiat:fiat-core:$fiatVersion" + testImplementation "org.bouncycastle:bcpkix-jdk18on" +} + +sourceSets { + main { + java { + srcDirs = [] + } + groovy { + srcDirs += ['src/main/java'] + } + } } diff --git a/gate-x509/src/main/groovy/com/netflix/spinnaker/gate/security/x509/OidRolesExtractor.groovy b/gate-x509/src/main/groovy/com/netflix/spinnaker/gate/security/x509/OidRolesExtractor.groovy deleted file mode 100644 index 699e3cc36a..0000000000 --- a/gate-x509/src/main/groovy/com/netflix/spinnaker/gate/security/x509/OidRolesExtractor.groovy +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2017 Target, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.netflix.spinnaker.gate.security.x509 - -import org.bouncycastle.asn1.ASN1InputStream -import org.bouncycastle.asn1.ASN1OctetString -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.stereotype.Component - -import java.security.cert.X509Certificate - -@Component -@ConditionalOnProperty("x509.role-oid") -class OidRolesExtractor implements X509RolesExtractor { - - @Value('${x509.role-oid:}') - String roleOid - - @Override - Collection fromCertificate(X509Certificate cert) { - byte[] bytes = cert.getExtensionValue(roleOid) - - if (bytes == null) { - return [] - } - ASN1OctetString octetString = (ASN1OctetString) new ASN1InputStream(new ByteArrayInputStream(bytes)).readObject() - ASN1InputStream inputStream = new ASN1InputStream(new ByteArrayInputStream(octetString.getOctets())) - def groups = inputStream.readObject()?.toString()?.split("\\n") - return groups.findAll{ !it.isEmpty() } - } -} diff --git a/gate-x509/src/main/groovy/com/netflix/spinnaker/gate/security/x509/X509Config.groovy b/gate-x509/src/main/groovy/com/netflix/spinnaker/gate/security/x509/X509Config.groovy deleted file mode 100644 index a9a35df62b..0000000000 --- a/gate-x509/src/main/groovy/com/netflix/spinnaker/gate/security/x509/X509Config.groovy +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2015 Netflix, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.gate.security.x509 - -import com.netflix.spinnaker.gate.config.AuthConfig -import com.netflix.spinnaker.gate.security.SpinnakerAuthConfig -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.core.annotation.Order -import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.annotation.web.builders.WebSecurity -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity -import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter -import org.springframework.security.web.context.NullSecurityContextRepository -import org.springframework.security.web.util.matcher.AnyRequestMatcher - -@ConditionalOnExpression('${x509.enabled:false}') -@Configuration -@SpinnakerAuthConfig -@EnableWebSecurity -//ensure this configures after a standard WebSecurityConfigurerAdapter (1000) so -// it becomes the fallthrough for a mixed mode of some SSO + x509 for API calls -// and otherwise will just work(tm) if it is the only WebSecurityConfigurerAdapter -// present as well -@Order(2000) -class X509Config extends WebSecurityConfigurerAdapter { - - @Value('${x509.subject-principal-regex:}') - String subjectPrincipalRegex - - @Autowired - AuthConfig authConfig - - @Autowired - X509AuthenticationUserDetailsService x509AuthenticationUserDetailsService - - @Override - void configure(HttpSecurity http) { - authConfig.configure(http) - http.securityContext().securityContextRepository(new NullSecurityContextRepository()) - http.x509().authenticationUserDetailsService(x509AuthenticationUserDetailsService) - - if (subjectPrincipalRegex) { - http.x509().subjectPrincipalRegex(subjectPrincipalRegex) - } - //x509 is the catch-all if configured, this will auth apiPort connections and - // any additional ports that get installed and removes the requestMatcher - // installed by authConfig - http.requestMatcher(AnyRequestMatcher.INSTANCE) - } - - @Override - void configure(WebSecurity web) throws Exception { - authConfig.configure(web) - } - - @Bean - X509IdentityExtractor x509IdentityExtractor() { - return new X509IdentityExtractor(x509AuthenticationUserDetailsService) - } -} diff --git a/gate-x509/src/main/java/com/netflix/spinnaker/gate/security/x509/OidRolesExtractor.java b/gate-x509/src/main/java/com/netflix/spinnaker/gate/security/x509/OidRolesExtractor.java new file mode 100644 index 0000000000..2b8ebcadc8 --- /dev/null +++ b/gate-x509/src/main/java/com/netflix/spinnaker/gate/security/x509/OidRolesExtractor.java @@ -0,0 +1,71 @@ +/* + * Copyright 2017 Target, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.security.x509; + +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.Setter; +import lombok.SneakyThrows; +import org.bouncycastle.asn1.ASN1OctetString; +import org.bouncycastle.asn1.ASN1Primitive; +import org.bouncycastle.asn1.ASN1String; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +@ConditionalOnProperty("x509.role-oid") +public class OidRolesExtractor implements X509RolesExtractor { + private static final Pattern ROLE_SEPARATOR = Pattern.compile("\\n"); + + @Setter( + value = AccessLevel.PACKAGE, + onMethod_ = {@Autowired}, + onParam_ = {@Value("${x509.role-oid:}")}) + private String roleOid; + + @Override + @SneakyThrows + public Collection fromCertificate(X509Certificate cert) { + byte[] bytes = cert.getExtensionValue(roleOid); + + if (bytes == null) { + return List.of(); + } + ASN1OctetString octetString = ASN1OctetString.getInstance(bytes); + var primitive = ASN1Primitive.fromByteArray(octetString.getOctets()); + String string; + if (primitive instanceof ASN1String) { + // when using OID 1.2.840.10070.8.1, this is an ASN1UTF8String + string = ((ASN1String) primitive).getString(); + } else { + // hope for the best (note that ASN1Sequence::toString and ASN1Set::toString are formatted + // like AbstractCollection::toString which is probably not what you want) + string = primitive.toString(); + } + return ROLE_SEPARATOR + .splitAsStream(string) + .filter(StringUtils::hasLength) + .collect(Collectors.toSet()); + } +} diff --git a/gate-x509/src/main/java/com/netflix/spinnaker/gate/security/x509/X509Config.java b/gate-x509/src/main/java/com/netflix/spinnaker/gate/security/x509/X509Config.java new file mode 100644 index 0000000000..7d040fe16f --- /dev/null +++ b/gate-x509/src/main/java/com/netflix/spinnaker/gate/security/x509/X509Config.java @@ -0,0 +1,79 @@ +/* + * Copyright 2015 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.gate.security.x509; + +import com.netflix.spinnaker.gate.config.AuthConfig; +import com.netflix.spinnaker.gate.security.SpinnakerAuthConfig; +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.web.context.NullSecurityContextRepository; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.util.StringUtils; + +@ConditionalOnExpression("${x509.enabled:false}") +@Configuration +@SpinnakerAuthConfig +@EnableWebSecurity +// ensure this configures after a standard WebSecurityConfigurerAdapter (100) so +// it becomes the fallthrough for a mixed mode of some SSO + x509 for API calls +// and otherwise will just work(tm) if it is the only WebSecurityConfigurerAdapter +// present as well +@Order(2000) +@RequiredArgsConstructor +@NonnullByDefault +public class X509Config extends WebSecurityConfigurerAdapter { + private final AuthConfig authConfig; + private final X509AuthenticationUserDetailsService x509AuthenticationUserDetailsService; + + @Setter( + onMethod_ = {@Autowired}, + onParam_ = {@Value("${x509.subject-principal-regex:}")}) + private String subjectPrincipalRegex; + + @Override + public void configure(HttpSecurity http) throws Exception { + authConfig.configure(http); + http.securityContext( + context -> context.securityContextRepository(new NullSecurityContextRepository())) + .x509( + x509 -> { + x509.authenticationUserDetailsService(x509AuthenticationUserDetailsService); + if (StringUtils.hasLength(subjectPrincipalRegex)) { + x509.subjectPrincipalRegex(subjectPrincipalRegex); + } + }) + // x509 is the catch-all if configured, this will auth apiPort connections and + // any additional ports that get installed and removes the requestMatcher + // installed by authConfig + .requestMatcher(AnyRequestMatcher.INSTANCE); + } + + @Bean + public X509IdentityExtractor x509IdentityExtractor() { + return new X509IdentityExtractor(x509AuthenticationUserDetailsService); + } +} diff --git a/gate-x509/src/main/groovy/com/netflix/spinnaker/gate/security/x509/X509IdentityExtractor.java b/gate-x509/src/main/java/com/netflix/spinnaker/gate/security/x509/X509IdentityExtractor.java similarity index 100% rename from gate-x509/src/main/groovy/com/netflix/spinnaker/gate/security/x509/X509IdentityExtractor.java rename to gate-x509/src/main/java/com/netflix/spinnaker/gate/security/x509/X509IdentityExtractor.java diff --git a/gate-x509/src/main/groovy/com/netflix/spinnaker/gate/security/x509/X509RolesExtractor.java b/gate-x509/src/main/java/com/netflix/spinnaker/gate/security/x509/X509RolesExtractor.java similarity index 100% rename from gate-x509/src/main/groovy/com/netflix/spinnaker/gate/security/x509/X509RolesExtractor.java rename to gate-x509/src/main/java/com/netflix/spinnaker/gate/security/x509/X509RolesExtractor.java diff --git a/gate-x509/src/main/groovy/com/netflix/spinnaker/gate/security/x509/X509UserIdentifierExtractor.java b/gate-x509/src/main/java/com/netflix/spinnaker/gate/security/x509/X509UserIdentifierExtractor.java similarity index 100% rename from gate-x509/src/main/groovy/com/netflix/spinnaker/gate/security/x509/X509UserIdentifierExtractor.java rename to gate-x509/src/main/java/com/netflix/spinnaker/gate/security/x509/X509UserIdentifierExtractor.java diff --git a/gate-x509/src/test/groovy/com/netflix/spinnaker/gate/security/x509/OidRolesExtractorSpec.groovy b/gate-x509/src/test/groovy/com/netflix/spinnaker/gate/security/x509/OidRolesExtractorSpec.groovy index f40351316d..17eef13194 100644 --- a/gate-x509/src/test/groovy/com/netflix/spinnaker/gate/security/x509/OidRolesExtractorSpec.groovy +++ b/gate-x509/src/test/groovy/com/netflix/spinnaker/gate/security/x509/OidRolesExtractorSpec.groovy @@ -17,11 +17,18 @@ package com.netflix.spinnaker.gate.security.x509 +import org.bouncycastle.asn1.ASN1ObjectIdentifier +import org.bouncycastle.asn1.DERUTF8String +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import spock.lang.Specification import spock.lang.Unroll +import javax.security.auth.x500.X500Principal +import java.security.KeyPairGenerator import java.security.cert.CertificateFactory import java.security.cert.X509Certificate +import java.time.Duration class OidRolesExtractorSpec extends Specification { @@ -47,4 +54,50 @@ class OidRolesExtractorSpec extends Specification { "one role" | "memberOfOneRole.crt" | 1 "more roles" | "memberOfTwoRoles.crt" | 2 } + + def "should return roles listed in oid extension - #description"() { + given: + String roleOid = '1.2.840.10070.8.1' + def extractor = new OidRolesExtractor(roleOid: roleOid) + def certificate = generateCertificate(roleOid, roles) + + when: + def extractedRoles = extractor.fromCertificate(certificate) + + then: + roles.containsAll(extractedRoles) && extractedRoles.containsAll(roles) + + where: + description | roles + 'empty list' | [] + 'one group' | ['groupA'] + 'two groups' | ['groupA', 'groupB'] + 'three groups' | ['groupA', 'groupC', 'groupE'] + } + + private static X509Certificate generateCertificate(String roleOid, List roles) { + // generate a P.256 keypair + def generator = KeyPairGenerator.getInstance('EC') + generator.initialize(256) + def keypair = generator.generateKeyPair() + def signer = new JcaContentSignerBuilder('SHA256withECDSA') + .build(keypair.private) + // set up a self-signed certificate that expires in an hour + def subject = new X500Principal('CN=spinnaker') + def notBefore = new Date() + def notAfter = Date.from(notBefore.toInstant() + Duration.ofHours(1)) + def serial = BigInteger.valueOf(notBefore.time) + // standard spinnaker OID extension: encode the roles in a string separated by newlines + def oid = new ASN1ObjectIdentifier(roleOid) + def encodedRoles = new DERUTF8String(roles.join('\n')) + // generate a self-signed certificate with only the roles extension specified; + // a real certificate should also set the key usage, basic constraints, and extended key usage extensions + def holder = new JcaX509v3CertificateBuilder( + subject, serial, notBefore, notAfter, subject, keypair.public) + .addExtension(oid, false, encodedRoles) + .build(signer) + // convert from bouncycastle to plain java + CertificateFactory.getInstance('X.509') + .generateCertificate(new ByteArrayInputStream(holder.encoded)) as X509Certificate + } } diff --git a/gradle.properties b/gradle.properties index 9ce8e51188..61a98a6fbb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,10 +1,10 @@ enablePublishing=false -fiatVersion=1.30.0 +fiatVersion=1.47.0 includeProviders=basic,iap,ldap,oauth2,saml,x509 -korkVersion=7.138.0 -kotlinVersion=1.4.0 +korkVersion=7.227.0 +kotlinVersion=1.6.21 org.gradle.parallel=true -spinnakerGradleVersion=8.23.0 +spinnakerGradleVersion=8.32.1 targetJava11=true # To enable a composite reference to a project, set the diff --git a/gradle/kotlin-test.gradle b/gradle/kotlin-test.gradle index 17ccb437f8..dbcbd0744c 100644 --- a/gradle/kotlin-test.gradle +++ b/gradle/kotlin-test.gradle @@ -30,15 +30,9 @@ dependencies { testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" } -test { - useJUnitPlatform { - includeEngines "junit-jupiter" - } -} - compileTestKotlin { kotlinOptions { - languageVersion = "1.4" + languageVersion = "1.6" jvmTarget = "11" } } diff --git a/gradle/kotlin.gradle b/gradle/kotlin.gradle index cbe600fd35..fb3bcd0a57 100644 --- a/gradle/kotlin.gradle +++ b/gradle/kotlin.gradle @@ -19,14 +19,14 @@ apply plugin: "kotlin-spring" compileKotlin { kotlinOptions { - languageVersion = "1.4" + languageVersion = "1.6" jvmTarget = "11" } } compileTestKotlin { kotlinOptions { - languageVersion = "1.4" + languageVersion = "1.6" jvmTarget = "11" } } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023..943f0cbfa7 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 28ff446a21..508322917b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c81..65dcd68d65 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,105 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f938..6689b85bee 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle b/settings.gradle index 89088475ca..497af00ca3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -22,8 +22,6 @@ } } -enableFeaturePreview("VERSION_ORDERING_V2") - rootProject.name = "gate" include "gate-api", @@ -34,6 +32,7 @@ include "gate-api", "gate-basic", "gate-bom", "gate-iap", + "gate-integration", "gate-ldap", "gate-oauth2", "gate-proxy",