forked from baron/baron-sso
Compare commits
24 Commits
dev
...
05864ca70c
| Author | SHA1 | Date | |
|---|---|---|---|
| 05864ca70c | |||
| 1351c981a8 | |||
| 95485632a8 | |||
| a1a4620d3e | |||
| 0062633bee | |||
| ec41f8da00 | |||
| d29e4d42ed | |||
| 617040d30b | |||
| b1aaaceb51 | |||
| bec2a6b958 | |||
| 77cd05fcbf | |||
| 0ab2c01718 | |||
| 7145e703d7 | |||
| 28dad91b1a | |||
| c308d0a7d4 | |||
| 8b183cab61 | |||
| efab2a7291 | |||
| fd05c049d3 | |||
| 95ac26734a | |||
| 62d8563836 | |||
| 016d783482 | |||
| 7ea385a9f4 | |||
| 9af038271d | |||
| fb90403b7c |
@@ -254,41 +254,46 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.5.2
|
||||
|
||||
- name: Install adminfront dependencies
|
||||
run: |
|
||||
cd adminfront
|
||||
npx pnpm install -C ../common --no-frozen-lockfile
|
||||
npx pnpm install --no-frozen-lockfile
|
||||
pnpm install -C ../common --no-frozen-lockfile
|
||||
pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Biome check adminfront
|
||||
run: |
|
||||
cd adminfront
|
||||
npx biome check . --formatter-enabled=false --assist-enabled=false
|
||||
npx biome check . --linter-enabled=false --assist-enabled=false
|
||||
pnpm exec biome check . --formatter-enabled=false --assist-enabled=false
|
||||
pnpm exec biome check . --linter-enabled=false --assist-enabled=false
|
||||
|
||||
- name: Install devfront dependencies
|
||||
run: |
|
||||
cd devfront
|
||||
npx pnpm install -C ../common --no-frozen-lockfile
|
||||
npx pnpm install --no-frozen-lockfile
|
||||
pnpm install -C ../common --no-frozen-lockfile
|
||||
pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Biome check devfront
|
||||
run: |
|
||||
cd devfront
|
||||
npx biome check . --formatter-enabled=false --assist-enabled=false
|
||||
npx biome check . --linter-enabled=false --assist-enabled=false
|
||||
pnpm exec biome check . --formatter-enabled=false --assist-enabled=false
|
||||
pnpm exec biome check . --linter-enabled=false --assist-enabled=false
|
||||
|
||||
- name: Install orgfront dependencies
|
||||
run: |
|
||||
cd orgfront
|
||||
npx pnpm install -C ../common --no-frozen-lockfile
|
||||
npx pnpm install --no-frozen-lockfile
|
||||
pnpm install -C ../common --no-frozen-lockfile
|
||||
pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Biome check orgfront
|
||||
run: |
|
||||
cd orgfront
|
||||
npx biome check . --formatter-enabled=false --assist-enabled=false
|
||||
npx biome check . --linter-enabled=false --assist-enabled=false
|
||||
pnpm exec biome check . --formatter-enabled=false --assist-enabled=false
|
||||
pnpm exec biome check . --linter-enabled=false --assist-enabled=false
|
||||
|
||||
backend-tests:
|
||||
needs:
|
||||
@@ -731,9 +736,9 @@ jobs:
|
||||
set +e
|
||||
cd userfront-e2e
|
||||
if [ "$USERFRONT_E2E_FULL" = "true" ]; then
|
||||
test_command="npm test"
|
||||
test_command="npx playwright test"
|
||||
else
|
||||
test_command="npm test -- --project=chromium-desktop --project=chromium-mobile-webapp"
|
||||
test_command="npx playwright test --project=chromium-desktop --project=chromium-mobile-webapp"
|
||||
fi
|
||||
workers="${USERFRONT_E2E_WORKERS:-2}"
|
||||
case "$workers" in
|
||||
@@ -759,10 +764,10 @@ jobs:
|
||||
echo "3. \`cd ../userfront && flutter build web --wasm --release\`"
|
||||
if [ "$USERFRONT_E2E_FULL" = "true" ]; then
|
||||
echo "4. \`cd ../userfront-e2e && npx playwright install --with-deps\`"
|
||||
echo "5. \`npm test\`"
|
||||
echo "5. \`npx playwright test\`"
|
||||
else
|
||||
echo "4. \`cd ../userfront-e2e && npx playwright install --with-deps chromium\`"
|
||||
echo "5. \`npm test -- --project=chromium-desktop --project=chromium-mobile-webapp\`"
|
||||
echo "5. \`npx playwright test --project=chromium-desktop --project=chromium-mobile-webapp\`"
|
||||
fi
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
@@ -1250,6 +1255,11 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.5.2
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
run: |
|
||||
@@ -1323,10 +1333,11 @@ jobs:
|
||||
path: |
|
||||
reports/adminfront-test-failure-report.md
|
||||
reports/adminfront-install.log
|
||||
reports/adminfront-build.log
|
||||
reports/adminfront-provision.log
|
||||
reports/adminfront-test.log
|
||||
adminfront/playwright-report
|
||||
adminfront/test-results
|
||||
reports/adminfront-playwright-report
|
||||
reports/adminfront-test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
devfront-tests:
|
||||
@@ -1344,6 +1355,11 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.5.2
|
||||
|
||||
- name: Get Playwright version
|
||||
id: playwright-version
|
||||
working-directory: devfront
|
||||
|
||||
244
.gitea/workflows/image_publish.yml
Normal file
244
.gitea/workflows/image_publish.yml
Normal file
@@ -0,0 +1,244 @@
|
||||
name: Publish Baron SSO Images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_prefix:
|
||||
description: "stage/prod 공용 이미지 태그 prefix (예: v1.2606, 최종 태그는 v1.2606.<커밋해시4자리>)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout dev branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: dev
|
||||
|
||||
- name: Validate publish inputs
|
||||
env:
|
||||
VERSION_PREFIX: ${{ github.event.inputs.version_prefix }}
|
||||
ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }}
|
||||
DEVFRONT_URL: ${{ vars.DEVFRONT_URL }}
|
||||
ORGFRONT_URL: ${{ vars.ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_CMD: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_CMD }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! printf '%s' "${VERSION_PREFIX}" | grep -Eq '^v[0-9]+\.[0-9]{4}$'; then
|
||||
echo "::error::version_prefix must look like vX.YYMM (got: ${VERSION_PREFIX})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
required_values="
|
||||
ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL VITE_OIDC_AUTHORITY WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID
|
||||
"
|
||||
for key in ${required_values}; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
echo "::error::Missing required publish value: ${key}. Check Gitea repo variables/secrets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "${WORKS_DRIVE_ACCESS_TOKEN_INPUT:-}" ] \
|
||||
&& [ -z "${WORKS_DRIVE_ACCESS_TOKEN_FILE:-}" ] \
|
||||
&& [ -z "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ] \
|
||||
&& [ -z "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]; then
|
||||
echo "::error::Missing WORKS Drive access auth. Provide WORKS_DRIVE_ACCESS_TOKEN, WORKS_DRIVE_ACCESS_TOKEN_FILE, WORKS_DRIVE_ACCESS_TOKEN_CMD, or WORKS_DRIVE_OAUTH_REFRESH_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${WORKS_DRIVE_ACCESS_TOKEN_INPUT:-}" ] \
|
||||
&& [ -z "${WORKS_DRIVE_ACCESS_TOKEN_FILE:-}" ] \
|
||||
&& [ -z "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ] \
|
||||
&& [ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ] \
|
||||
&& { [ -z "${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" ] || [ -z "${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" ]; }; then
|
||||
echo "::error::WORKS_DRIVE_OAUTH_CLIENT_ID and WORKS_DRIVE_OAUTH_CLIENT_SECRET are required when WORKS_DRIVE_OAUTH_REFRESH_TOKEN is the selected auth source."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Compute commit-hash image tag
|
||||
id: version
|
||||
env:
|
||||
VERSION_PREFIX: ${{ github.event.inputs.version_prefix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
short_sha="$(git rev-parse --short=4 HEAD)"
|
||||
if ! printf '%s' "${short_sha}" | grep -Eq '^[0-9a-f]{4}$'; then
|
||||
echo "::error::commit hash suffix must be 4 lowercase hexadecimal characters (got: ${short_sha})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
image_tag="${VERSION_PREFIX}.${short_sha}"
|
||||
echo "image_tag=${image_tag}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Computed shared image tag: ${image_tag}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build backend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
load: true
|
||||
tags: baron_sso/backend:${{ steps.version.outputs.image_tag }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build userfront image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./userfront/Dockerfile
|
||||
target: production
|
||||
load: true
|
||||
tags: baron_sso/userfront:${{ steps.version.outputs.image_tag }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build adminfront image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./adminfront/Dockerfile
|
||||
target: production
|
||||
load: true
|
||||
tags: baron_sso/adminfront:${{ steps.version.outputs.image_tag }}
|
||||
build-args: |
|
||||
VITE_ADMIN_PUBLIC_URL=${{ vars.ADMINFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=adminfront
|
||||
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build devfront image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./devfront/Dockerfile
|
||||
target: production
|
||||
load: true
|
||||
tags: baron_sso/devfront:${{ steps.version.outputs.image_tag }}
|
||||
build-args: |
|
||||
VITE_DEVFRONT_PUBLIC_URL=${{ vars.DEVFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=devfront
|
||||
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build orgfront image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./orgfront/Dockerfile
|
||||
target: production
|
||||
load: true
|
||||
tags: baron_sso/orgfront:${{ steps.version.outputs.image_tag }}
|
||||
build-args: |
|
||||
VITE_ORGFRONT_PUBLIC_URL=${{ vars.ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=orgfront
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Resolve WORKS Drive access token
|
||||
env:
|
||||
WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_CMD: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_CMD }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL: ${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
access_token=""
|
||||
rotated_refresh_token_file="${RUNNER_TEMP}/works-drive-rotated-refresh-token"
|
||||
|
||||
if [ -n "${WORKS_DRIVE_ACCESS_TOKEN_INPUT:-}" ]; then
|
||||
access_token="${WORKS_DRIVE_ACCESS_TOKEN_INPUT}"
|
||||
elif [ -n "${WORKS_DRIVE_ACCESS_TOKEN_FILE:-}" ]; then
|
||||
access_token="$(sed -n '1p' "${WORKS_DRIVE_ACCESS_TOKEN_FILE}")"
|
||||
elif [ -n "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ]; then
|
||||
access_token="$(sh -c "${WORKS_DRIVE_ACCESS_TOKEN_CMD}")"
|
||||
else
|
||||
token_url="${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}"
|
||||
response="$(curl -sS -w $'\n%{http_code}' -X POST \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
--data-urlencode "grant_type=refresh_token" \
|
||||
--data-urlencode "refresh_token=${WORKS_DRIVE_OAUTH_REFRESH_TOKEN}" \
|
||||
--data-urlencode "client_id=${WORKS_DRIVE_OAUTH_CLIENT_ID}" \
|
||||
--data-urlencode "client_secret=${WORKS_DRIVE_OAUTH_CLIENT_SECRET}" \
|
||||
"${token_url}")"
|
||||
http_status="$(tail -n 1 <<<"${response}")"
|
||||
response_body="$(sed '$d' <<<"${response}")"
|
||||
if [ "${http_status}" -lt 200 ] || [ "${http_status}" -ge 300 ]; then
|
||||
echo "::error::WORKS Drive access token refresh failed with HTTP ${http_status}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
access_token="$(jq -er '.access_token' <<<"${response_body}")"
|
||||
rotated_refresh_token="$(jq -r '.refresh_token // empty' <<<"${response_body}")"
|
||||
if [ -n "${rotated_refresh_token}" ]; then
|
||||
echo "::add-mask::${rotated_refresh_token}"
|
||||
printf '%s\n' "${rotated_refresh_token}" >"${rotated_refresh_token_file}"
|
||||
chmod 600 "${rotated_refresh_token_file}"
|
||||
echo "WORKS_DRIVE_ROTATED_REFRESH_TOKEN_FILE=${rotated_refresh_token_file}" >>"${GITHUB_ENV}"
|
||||
echo "::warning::WORKS returned a rotated refresh token. Persist it to the WORKS_DRIVE_OAUTH_REFRESH_TOKEN secret before old refresh tokens age out."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${access_token}" ]; then
|
||||
echo "::error::WORKS Drive access token could not be resolved."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::add-mask::${access_token}"
|
||||
echo "WORKS_DRIVE_ACCESS_TOKEN=${access_token}" >>"${GITHUB_ENV}"
|
||||
|
||||
- name: Upload built images to WORKS Drive archive
|
||||
env:
|
||||
IMAGE_TAG: ${{ steps.version.outputs.image_tag }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DIR }}
|
||||
WORKS_DRIVE_TARGET: sharedrive
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID }}
|
||||
WORKS_ADMIN_API_BASE_URL: ${{ vars.WORKS_ADMIN_API_BASE_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
: "${WORKS_DRIVE_DOCKER_IMAGE_DIR:=baron-sso}"
|
||||
|
||||
required_values="
|
||||
IMAGE_TAG WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID
|
||||
"
|
||||
for key in ${required_values}; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
echo "::error::Missing required WORKS image archive value: ${key}."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for image in backend userfront adminfront devfront orgfront; do
|
||||
image_ref="baron_sso/${image}:${IMAGE_TAG}"
|
||||
DOCKER_IMAGE_REF="${image_ref}" \
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DIR="${WORKS_DRIVE_DOCKER_IMAGE_DIR}" \
|
||||
WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID}" \
|
||||
WORKS_DRIVE_PARENT_FILE_ID="${WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID:-}" \
|
||||
WORKS_DOCKER_IMAGE_ARCHIVE_DIR="${RUNNER_TEMP}/baron-sso-docker-image-upload" \
|
||||
scripts/docker-image/upload_works_drive.sh
|
||||
done
|
||||
@@ -29,45 +29,63 @@ jobs:
|
||||
IMAGE_DEPLOY_PUBLIC_URL: ${{ vars.PROD_FRONTEND_URL }}
|
||||
IMAGE_DEPLOY_COMPOSE_TEMPLATE: deploy/templates/docker-compose.images.yaml
|
||||
IMAGE_DEPLOY_BUNDLE_FILE: prod-image-deploy-bundle.tgz
|
||||
ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }}
|
||||
DEVFRONT_URL: ${{ vars.DEVFRONT_URL }}
|
||||
ORGFRONT_URL: ${{ vars.ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
ADMINFRONT_URL: ${{ vars.PROD_ADMINFRONT_URL }}
|
||||
DEVFRONT_URL: ${{ vars.PROD_DEVFRONT_URL }}
|
||||
ORGFRONT_URL: ${{ vars.PROD_ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY: ${{ vars.PROD_VITE_OIDC_AUTHORITY }}
|
||||
IMAGE_DEPLOY_BACKEND_LOG_LEVEL: ${{ vars.PROD_BACKEND_LOG_LEVEL }}
|
||||
IMAGE_DEPLOY_CLIENT_LOG_DEBUG: ${{ vars.PROD_CLIENT_LOG_DEBUG }}
|
||||
IMAGE_DEPLOY_BACKEND_PUBLIC_URL: ${{ vars.PROD_BACKEND_URL || vars.PROD_FRONTEND_URL }}
|
||||
IMAGE_DEPLOY_BACKEND_URL: ${{ vars.PROD_BACKEND_URL || vars.PROD_FRONTEND_URL }}
|
||||
WORKS_ADMIN_API_BASE_URL: ${{ vars.PROD_WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL: ${{ vars.PROD_WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
PROFILE_CACHE_TTL: ${{ vars.PROD_PROFILE_CACHE_TTL }}
|
||||
NAVER_CLOUD_ACCESS_KEY: ${{ secrets.PROD_NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY: ${{ secrets.PROD_NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID: ${{ vars.PROD_NAVER_CLOUD_SERVICE_ID }}
|
||||
NAVER_SENDER_PHONE_NUMBER: ${{ vars.PROD_NAVER_SENDER_PHONE_NUMBER }}
|
||||
AWS_REGION: ${{ vars.PROD_AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ vars.PROD_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SES_SENDER: ${{ vars.PROD_AWS_SES_SENDER }}
|
||||
CORS_ALLOWED_ORIGINS: ${{ vars.PROD_CORS_ALLOWED_ORIGINS }}
|
||||
OATHKEEPER_API_URL: ${{ vars.PROD_OATHKEEPER_API_URL }}
|
||||
CLICKHOUSE_HOST: ${{ vars.PROD_CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER: ${{ vars.PROD_CLICKHOUSE_USER }}
|
||||
IMAGE_DEPLOY_DB_PORT: ${{ vars.PROD_DB_PORT }}
|
||||
IMAGE_DEPLOY_REDIS_PORT: ${{ vars.PROD_REDIS_PORT }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP: ${{ vars.PROD_CLICKHOUSE_PORT_HTTP }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE: ${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }}
|
||||
IMAGE_DEPLOY_BACKEND_PORT: ${{ vars.PROD_BACKEND_PORT }}
|
||||
IMAGE_DEPLOY_FRONTEND_PORT: ${{ vars.PROD_FRONTEND_PORT }}
|
||||
ADMINFRONT_PORT: ${{ vars.ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT: ${{ vars.DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT: ${{ vars.ORGFRONT_PORT }}
|
||||
ADMINFRONT_PORT: ${{ vars.PROD_ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT: ${{ vars.PROD_DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT: ${{ vars.PROD_ORGFRONT_PORT }}
|
||||
IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT: ${{ vars.PROD_OATHKEEPER_PROXY_PORT }}
|
||||
IMAGE_DEPLOY_DOMAIN_SUFFIX: ${{ vars.PROD_DOMAIN_SUFFIX }}
|
||||
ADMINFRONT_CALLBACK_URLS: ${{ vars.ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS: ${{ vars.DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS: ${{ vars.ORGFRONT_CALLBACK_URLS }}
|
||||
HYDRA_REFRESH_TOKEN_TTL: ${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
|
||||
ORY_POSTGRES_USER: ${{ vars.ORY_POSTGRES_USER }}
|
||||
ORY_POSTGRES_DB: ${{ vars.ORY_POSTGRES_DB }}
|
||||
KRATOS_DB: ${{ vars.KRATOS_DB }}
|
||||
HYDRA_DB: ${{ vars.HYDRA_DB }}
|
||||
KETO_DB: ${{ vars.KETO_DB }}
|
||||
KRATOS_VERSION: ${{ vars.KRATOS_VERSION }}
|
||||
HYDRA_VERSION: ${{ vars.HYDRA_VERSION }}
|
||||
KETO_VERSION: ${{ vars.KETO_VERSION }}
|
||||
OATHKEEPER_VERSION: ${{ vars.OATHKEEPER_VERSION }}
|
||||
ORY_POSTGRES_TAG: ${{ vars.ORY_POSTGRES_TAG }}
|
||||
OATHKEEPER_UID: ${{ vars.OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID: ${{ vars.OATHKEEPER_GID }}
|
||||
OATHKEEPER_INTROSPECT_CLIENT_ID: ${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
ADMIN_EMAIL: ${{ vars.ADMIN_EMAIL }}
|
||||
HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }}
|
||||
BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend
|
||||
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
|
||||
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
|
||||
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
|
||||
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
|
||||
ADMINFRONT_CALLBACK_URLS: ${{ vars.PROD_ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS: ${{ vars.PROD_DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS: ${{ vars.PROD_ORGFRONT_CALLBACK_URLS }}
|
||||
HYDRA_REFRESH_TOKEN_TTL: ${{ vars.PROD_HYDRA_REFRESH_TOKEN_TTL }}
|
||||
ORY_POSTGRES_USER: ${{ vars.PROD_ORY_POSTGRES_USER }}
|
||||
ORY_POSTGRES_DB: ${{ vars.PROD_ORY_POSTGRES_DB }}
|
||||
KRATOS_DB: ${{ vars.PROD_KRATOS_DB }}
|
||||
HYDRA_DB: ${{ vars.PROD_HYDRA_DB }}
|
||||
KETO_DB: ${{ vars.PROD_KETO_DB }}
|
||||
KRATOS_VERSION: ${{ vars.PROD_KRATOS_VERSION }}
|
||||
HYDRA_VERSION: ${{ vars.PROD_HYDRA_VERSION }}
|
||||
KETO_VERSION: ${{ vars.PROD_KETO_VERSION }}
|
||||
OATHKEEPER_VERSION: ${{ vars.PROD_OATHKEEPER_VERSION }}
|
||||
ORY_POSTGRES_TAG: ${{ vars.PROD_ORY_POSTGRES_TAG }}
|
||||
OATHKEEPER_UID: ${{ vars.PROD_OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID: ${{ vars.PROD_OATHKEEPER_GID }}
|
||||
OATHKEEPER_INTROSPECT_CLIENT_ID: ${{ vars.PROD_OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
ADMIN_EMAIL: ${{ vars.PROD_ADMIN_EMAIL }}
|
||||
BACKEND_IMAGE_NAME: baron_sso/backend
|
||||
USERFRONT_IMAGE_NAME: baron_sso/userfront
|
||||
ADMINFRONT_IMAGE_NAME: baron_sso/adminfront
|
||||
DEVFRONT_IMAGE_NAME: baron_sso/devfront
|
||||
ORGFRONT_IMAGE_NAME: baron_sso/orgfront
|
||||
IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
|
||||
IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD: ${{ secrets.PROD_ORY_POSTGRES_PASSWORD }}
|
||||
IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.PROD_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
@@ -88,9 +106,16 @@ jobs:
|
||||
DEPLOY_HOST: ${{ vars.PROD_HOST }}
|
||||
DEPLOY_USER: ${{ vars.PROD_USER }}
|
||||
DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
|
||||
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
|
||||
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DIR || 'baron-sso' }}
|
||||
WORKS_ADMIN_API_BASE_URL: ${{ vars.WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_CMD: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_CMD }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/deploy/upload_and_run_image_deploy.sh
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
name: Publish Baron SSO Production Images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_prefix:
|
||||
description: "공용 저장소 이미지 태그 prefix (예: v1.2606, 최종 태그는 v1.2606.<커밋해시4자리>)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout dev branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: dev
|
||||
|
||||
- name: Validate publish inputs
|
||||
env:
|
||||
VERSION_PREFIX: ${{ github.event.inputs.version_prefix }}
|
||||
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
|
||||
HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }}
|
||||
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
|
||||
ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }}
|
||||
DEVFRONT_URL: ${{ vars.DEVFRONT_URL }}
|
||||
ORGFRONT_URL: ${{ vars.ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! printf '%s' "${VERSION_PREFIX}" | grep -Eq '^v[0-9]+\.[0-9]{4}$'; then
|
||||
echo "::error::version_prefix must look like vX.YYMM (got: ${VERSION_PREFIX})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
required_values="
|
||||
HARBOR_ENDPOINT HARBOR_HOSTNAME HARBOR_ROBOT_ACCOUNT HARBOR_ROBOT_KEY
|
||||
ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL VITE_OIDC_AUTHORITY
|
||||
"
|
||||
for key in ${required_values}; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
echo "::error::Missing required publish value: ${key}. Check Gitea repo variables/secrets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Compute commit-hash image tag
|
||||
id: version
|
||||
env:
|
||||
VERSION_PREFIX: ${{ github.event.inputs.version_prefix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
short_sha="$(git rev-parse --short=4 HEAD)"
|
||||
if ! printf '%s' "${short_sha}" | grep -Eq '^[0-9a-f]{4}$'; then
|
||||
echo "::error::commit hash suffix must be 4 lowercase hexadecimal characters (got: ${short_sha})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
image_tag="${VERSION_PREFIX}.${short_sha}"
|
||||
echo "image_tag=${image_tag}" >> "${GITHUB_OUTPUT}"
|
||||
echo "Computed production image tag: ${image_tag}"
|
||||
|
||||
- name: Login to shared registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.HARBOR_ENDPOINT }}
|
||||
username: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||
password: ${{ secrets.HARBOR_ROBOT_KEY }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push backend production image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./backend
|
||||
file: ./backend/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend:${{ steps.version.outputs.image_tag }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build and push userfront production image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./userfront/Dockerfile
|
||||
target: production
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront:${{ steps.version.outputs.image_tag }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build and push adminfront production image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./adminfront/Dockerfile
|
||||
target: production
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.version.outputs.image_tag }}
|
||||
build-args: |
|
||||
VITE_ADMIN_PUBLIC_URL=${{ vars.ADMINFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=adminfront
|
||||
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build and push devfront production image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./devfront/Dockerfile
|
||||
target: production
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.version.outputs.image_tag }}
|
||||
build-args: |
|
||||
VITE_DEVFRONT_PUBLIC_URL=${{ vars.DEVFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=devfront
|
||||
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Build and push orgfront production image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./orgfront/Dockerfile
|
||||
target: production
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.version.outputs.image_tag }}
|
||||
build-args: |
|
||||
VITE_ORGFRONT_PUBLIC_URL=${{ vars.ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=orgfront
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
- name: Upload pushed images to WORKS Drive archive
|
||||
if: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_ARCHIVE_ENABLED == 'true' }}
|
||||
env:
|
||||
IMAGE_TAG: ${{ steps.version.outputs.image_tag }}
|
||||
HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }}
|
||||
WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR }}
|
||||
WORKS_DRIVE_TARGET: sharedrive
|
||||
WORKS_DRIVE_SHARED_DRIVE_ID: ${{ vars.WORKS_DRIVE_SHARED_DRIVE_ID }}
|
||||
WORKS_DRIVE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_PARENT_FILE_ID }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_SECRET }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY }}
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_OAUTH_REFRESH_TOKEN }}
|
||||
WORKS_ADMIN_API_BASE_URL: ${{ vars.WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL: ${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
: "${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:=docker-build-image}"
|
||||
|
||||
required_values="
|
||||
IMAGE_TAG HARBOR_HOSTNAME WORKS_DRIVE_SHARED_DRIVE_ID
|
||||
"
|
||||
for key in ${required_values}; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
echo "::error::Missing required WORKS image archive value: ${key}."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for image in backend userfront adminfront devfront orgfront; do
|
||||
image_ref="${HARBOR_HOSTNAME}/baron_sso/${image}:${IMAGE_TAG}"
|
||||
docker pull "${image_ref}"
|
||||
DOCKER_IMAGE_REF="${image_ref}" \
|
||||
WORKS_DOCKER_IMAGE_ARCHIVE_DIR="${RUNNER_TEMP}/baron-sso-docker-image-upload" \
|
||||
scripts/docker-image/upload_works_drive.sh
|
||||
done
|
||||
@@ -101,33 +101,33 @@ jobs:
|
||||
"PROD_BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
|
||||
"BACKEND_PORT=3000" \
|
||||
"USERFRONT_PORT=${{ vars.PROD_FRONTEND_PORT }}" \
|
||||
"ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}" \
|
||||
"DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}" \
|
||||
"ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}" \
|
||||
"ADMINFRONT_PORT=${{ vars.PROD_ADMINFRONT_PORT }}" \
|
||||
"DEVFRONT_PORT=${{ vars.PROD_DEVFRONT_PORT }}" \
|
||||
"ORGFRONT_PORT=${{ vars.PROD_ORGFRONT_PORT }}" \
|
||||
"DB_USER=${{ vars.PROD_DB_USER }}" \
|
||||
"DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}" \
|
||||
"DB_NAME=${{ vars.PROD_DB_NAME }}" \
|
||||
"COOKIE_SECRET=${{ secrets.PROD_COOKIE_SECRET }}" \
|
||||
"JWT_SECRET=${{ secrets.PROD_JWT_SECRET }}" \
|
||||
"REDIS_ADDR=${{ vars.PROD_REDIS_ADDR }}" \
|
||||
"NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}" \
|
||||
"NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}" \
|
||||
"NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}" \
|
||||
"NAVER_SENDER_PHONE_NUMBER=${{ vars.NAVER_SENDER_PHONE_NUMBER }}" \
|
||||
"AWS_REGION=${{ vars.AWS_REGION }}" \
|
||||
"AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}" \
|
||||
"AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}" \
|
||||
"AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}" \
|
||||
"NAVER_CLOUD_ACCESS_KEY=${{ secrets.PROD_NAVER_CLOUD_ACCESS_KEY }}" \
|
||||
"NAVER_CLOUD_SECRET_KEY=${{ secrets.PROD_NAVER_CLOUD_SECRET_KEY }}" \
|
||||
"NAVER_CLOUD_SERVICE_ID=${{ vars.PROD_NAVER_CLOUD_SERVICE_ID }}" \
|
||||
"NAVER_SENDER_PHONE_NUMBER=${{ vars.PROD_NAVER_SENDER_PHONE_NUMBER }}" \
|
||||
"AWS_REGION=${{ vars.PROD_AWS_REGION }}" \
|
||||
"AWS_ACCESS_KEY_ID=${{ vars.PROD_AWS_ACCESS_KEY_ID }}" \
|
||||
"AWS_SECRET_ACCESS_KEY=${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }}" \
|
||||
"AWS_SES_SENDER=${{ vars.PROD_AWS_SES_SENDER }}" \
|
||||
"USERFRONT_URL=${{ vars.PROD_FRONTEND_URL }}" \
|
||||
"ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}" \
|
||||
"DEVFRONT_URL=${{ vars.DEVFRONT_URL }}" \
|
||||
"ORGFRONT_URL=${{ vars.ORGFRONT_URL }}" \
|
||||
"ADMINFRONT_URL=${{ vars.PROD_ADMINFRONT_URL }}" \
|
||||
"DEVFRONT_URL=${{ vars.PROD_DEVFRONT_URL }}" \
|
||||
"ORGFRONT_URL=${{ vars.PROD_ORGFRONT_URL }}" \
|
||||
"BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \
|
||||
"VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}" \
|
||||
"HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}" \
|
||||
"ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}" \
|
||||
"DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}" \
|
||||
"ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}" \
|
||||
"VITE_OIDC_AUTHORITY=${{ vars.PROD_VITE_OIDC_AUTHORITY }}" \
|
||||
"HYDRA_REFRESH_TOKEN_TTL=${{ vars.PROD_HYDRA_REFRESH_TOKEN_TTL }}" \
|
||||
"ADMINFRONT_CALLBACK_URLS=${{ vars.PROD_ADMINFRONT_CALLBACK_URLS }}" \
|
||||
"DEVFRONT_CALLBACK_URLS=${{ vars.PROD_DEVFRONT_CALLBACK_URLS }}" \
|
||||
"ORGFRONT_CALLBACK_URLS=${{ vars.PROD_ORGFRONT_CALLBACK_URLS }}" \
|
||||
> .env
|
||||
|
||||
required_dotenv_keys="
|
||||
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||
ssh-private-key: ${{ secrets.STG_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Deploy to Staging by git pull
|
||||
env:
|
||||
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||
STAGE_HOST: ${{ vars.STAGE_HOST }}
|
||||
STAGE_USER: ${{ vars.STAGE_USER }}
|
||||
DEPLOY_PATH: ${{ vars.STG_DEPLOY_PATH }}
|
||||
STAGE_HOST: ${{ vars.STG_HOST }}
|
||||
STAGE_USER: ${{ vars.STG_USER }}
|
||||
TARGET_BRANCH: ${{ inputs.target_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -48,99 +48,99 @@ jobs:
|
||||
APP_ENV=stage
|
||||
BACKEND_LOG_LEVEL=debug
|
||||
CLIENT_LOG_DEBUG=true
|
||||
WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
WORKS_ADMIN_API_BASE_URL=${{ vars.STG_WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.STG_WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
TZ=Asia/Seoul
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
# DB & Clickhouse
|
||||
DB_PORT=${{ vars.DB_PORT }}
|
||||
CLICKHOUSE_PORT_HTTP=${{ vars.CLICKHOUSE_PORT_HTTP }}
|
||||
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
|
||||
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
|
||||
CLICKHOUSE_PASSWORD=${{ secrets.CLICKHOUSE_PASSWORD }}
|
||||
DB_PORT=${{ vars.STG_DB_PORT }}
|
||||
CLICKHOUSE_PORT_HTTP=${{ vars.STG_CLICKHOUSE_PORT_HTTP }}
|
||||
CLICKHOUSE_PORT_NATIVE=${{ vars.STG_CLICKHOUSE_PORT_NATIVE }}
|
||||
CLICKHOUSE_HOST=${{ vars.STG_CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER=${{ vars.STG_CLICKHOUSE_USER }}
|
||||
CLICKHOUSE_PASSWORD=${{ secrets.STG_CLICKHOUSE_PASSWORD }}
|
||||
|
||||
|
||||
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}
|
||||
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
||||
BACKEND_PORT=${{ vars.STG_BACKEND_PORT }}
|
||||
ADMINFRONT_PORT=${{ vars.STG_ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT=${{ vars.STG_DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT=${{ vars.STG_ORGFRONT_PORT }}
|
||||
USERFRONT_PORT=${{ vars.STG_USERFRONT_PORT }}
|
||||
|
||||
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
||||
OATHKEEPER_API_URL=${{ vars.STG_OATHKEEPER_API_URL }}
|
||||
|
||||
DB_USER=${{ vars.DB_USER }}
|
||||
DB_USER=${{ vars.STG_DB_USER }}
|
||||
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
|
||||
DB_NAME=${{ vars.DB_NAME }}
|
||||
DB_NAME=${{ vars.STG_DB_NAME }}
|
||||
COOKIE_SECRET=${{ secrets.STG_COOKIE_SECRET }}
|
||||
JWT_SECRET=${{ secrets.STG_JWT_SECRET }}
|
||||
REDIS_ADDR=${{ vars.REDIS_ADDR }}
|
||||
CORS_ALLOWED_ORIGINS=${{ vars.CORS_ALLOWED_ORIGINS }}
|
||||
REDIS_ADDR=${{ vars.STG_REDIS_ADDR }}
|
||||
CORS_ALLOWED_ORIGINS=${{ vars.STG_CORS_ALLOWED_ORIGINS }}
|
||||
AUDIT_WORKER_COUNT=5
|
||||
AUDIT_QUEUE_SIZE=2000
|
||||
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
|
||||
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
|
||||
NAVER_SENDER_PHONE_NUMBER=${{ vars.NAVER_SENDER_PHONE_NUMBER }}
|
||||
AWS_REGION=${{ vars.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}
|
||||
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
|
||||
PROFILE_CACHE_TTL=${{ vars.STG_PROFILE_CACHE_TTL }}
|
||||
NAVER_CLOUD_ACCESS_KEY=${{ secrets.STG_NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.STG_NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID=${{ vars.STG_NAVER_CLOUD_SERVICE_ID }}
|
||||
NAVER_SENDER_PHONE_NUMBER=${{ vars.STG_NAVER_SENDER_PHONE_NUMBER }}
|
||||
AWS_REGION=${{ vars.STG_AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID=${{ vars.STG_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY=${{ secrets.STG_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SES_SENDER=${{ vars.STG_AWS_SES_SENDER }}
|
||||
ADMIN_EMAIL=${{ vars.STG_ADMIN_EMAIL }}
|
||||
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
|
||||
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
||||
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
|
||||
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
|
||||
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
|
||||
BACKEND_URL=${{ vars.BACKEND_URL }}
|
||||
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
||||
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
|
||||
ORY_POSTGRES_USER=${{ vars.ORY_POSTGRES_USER }}
|
||||
USERFRONT_URL=${{ vars.STG_USERFRONT_URL }}
|
||||
ADMINFRONT_URL=${{ vars.STG_ADMINFRONT_URL }}
|
||||
DEVFRONT_URL=${{ vars.STG_DEVFRONT_URL }}
|
||||
ORGFRONT_URL=${{ vars.STG_ORGFRONT_URL }}
|
||||
BACKEND_PUBLIC_URL=${{ vars.STG_BACKEND_URL }}
|
||||
BACKEND_URL=${{ vars.STG_BACKEND_URL }}
|
||||
OATHKEEPER_PUBLIC_URL=${{ vars.STG_OATHKEEPER_PUBLIC_URL }}
|
||||
ORY_POSTGRES_TAG=${{ vars.STG_ORY_POSTGRES_TAG }}
|
||||
ORY_POSTGRES_USER=${{ vars.STG_ORY_POSTGRES_USER }}
|
||||
ORY_POSTGRES_PASSWORD=${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
|
||||
ORY_POSTGRES_DB=${{ vars.ORY_POSTGRES_DB }}
|
||||
KRATOS_DB=${{ vars.KRATOS_DB }}
|
||||
HYDRA_DB=${{ vars.HYDRA_DB }}
|
||||
KETO_DB=${{ vars.KETO_DB }}
|
||||
KRATOS_VERSION=${{ vars.KRATOS_VERSION }}
|
||||
KRATOS_UI_NODE_VERSION=${{ vars.KRATOS_UI_NODE_VERSION }}
|
||||
HYDRA_VERSION=${{ vars.HYDRA_VERSION }}
|
||||
KETO_VERSION=${{ vars.KETO_VERSION }}
|
||||
ORY_SDK_URL=${{ vars.ORY_SDK_URL }}
|
||||
KRATOS_PUBLIC_URL=${{ vars.KRATOS_PUBLIC_URL }}
|
||||
KRATOS_ADMIN_URL=${{ vars.KRATOS_ADMIN_URL }}
|
||||
KRATOS_BROWSER_URL=${{ vars.KRATOS_BROWSER_URL }}
|
||||
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
|
||||
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
|
||||
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
|
||||
HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
|
||||
JWKS_URL=${{ vars.JWKS_URL }}
|
||||
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
|
||||
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID=${{ vars.OATHKEEPER_GID }}
|
||||
OATHKEEPER_HEALTH_URL=${{ vars.OATHKEEPER_HEALTH_URL }}
|
||||
OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.OATHKEEPER_HEALTH_INTERVAL_SECONDS }}
|
||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.OATHKEEPER_HEALTH_TIMEOUT_SECONDS }}
|
||||
OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }}
|
||||
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
|
||||
ORY_POSTGRES_DB=${{ vars.STG_ORY_POSTGRES_DB }}
|
||||
KRATOS_DB=${{ vars.STG_KRATOS_DB }}
|
||||
HYDRA_DB=${{ vars.STG_HYDRA_DB }}
|
||||
KETO_DB=${{ vars.STG_KETO_DB }}
|
||||
KRATOS_VERSION=${{ vars.STG_KRATOS_VERSION }}
|
||||
KRATOS_UI_NODE_VERSION=${{ vars.STG_KRATOS_UI_NODE_VERSION }}
|
||||
HYDRA_VERSION=${{ vars.STG_HYDRA_VERSION }}
|
||||
KETO_VERSION=${{ vars.STG_KETO_VERSION }}
|
||||
ORY_SDK_URL=${{ vars.STG_ORY_SDK_URL }}
|
||||
KRATOS_PUBLIC_URL=${{ vars.STG_KRATOS_PUBLIC_URL }}
|
||||
KRATOS_ADMIN_URL=${{ vars.STG_KRATOS_ADMIN_URL }}
|
||||
KRATOS_BROWSER_URL=${{ vars.STG_KRATOS_BROWSER_URL }}
|
||||
KRATOS_UI_URL=${{ vars.STG_KRATOS_UI_URL }}
|
||||
HYDRA_ADMIN_URL=${{ vars.STG_HYDRA_ADMIN_URL }}
|
||||
HYDRA_PUBLIC_URL=${{ vars.STG_HYDRA_PUBLIC_URL }}
|
||||
HYDRA_REFRESH_TOKEN_TTL=${{ vars.STG_HYDRA_REFRESH_TOKEN_TTL }}
|
||||
JWKS_URL=${{ vars.STG_JWKS_URL }}
|
||||
OATHKEEPER_VERSION=${{ vars.STG_OATHKEEPER_VERSION }}
|
||||
OATHKEEPER_UID=${{ vars.STG_OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID=${{ vars.STG_OATHKEEPER_GID }}
|
||||
OATHKEEPER_HEALTH_URL=${{ vars.STG_OATHKEEPER_HEALTH_URL }}
|
||||
OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.STG_OATHKEEPER_HEALTH_INTERVAL_SECONDS }}
|
||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.STG_OATHKEEPER_HEALTH_TIMEOUT_SECONDS }}
|
||||
OATHKEEPER_HEALTH_ENABLED=${{ vars.STG_OATHKEEPER_HEALTH_ENABLED }}
|
||||
CSRF_COOKIE_NAME=${{ vars.STG_CSRF_COOKIE_NAME }}
|
||||
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
|
||||
|
||||
# Frontend/Ory URL configs for Staging
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}
|
||||
KRATOS_ALLOWED_RETURN_URLS_JSON=${{ vars.KRATOS_ALLOWED_RETURN_URLS_JSON }}
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=${{ vars.KRATOS_ALLOWED_RETURN_URLS_EXTRA }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.STG_VITE_OIDC_AUTHORITY }}
|
||||
ADMINFRONT_CALLBACK_URLS=${{ vars.STG_ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS=${{ vars.STG_DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS=${{ vars.STG_ORGFRONT_CALLBACK_URLS }}
|
||||
KRATOS_ALLOWED_RETURN_URLS_JSON=${{ vars.STG_KRATOS_ALLOWED_RETURN_URLS_JSON }}
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA=${{ vars.STG_KRATOS_ALLOWED_RETURN_URLS_EXTRA }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.STG_OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
|
||||
# Monitoring & Alerts
|
||||
SMS_WEBHOOK_PORT=${{ vars.SMS_WEBHOOK_PORT || '8080' }}
|
||||
MONITOR_RECIPIENT_PHONES=${{ vars.MONITOR_RECIPIENT_PHONES || '01012345678,01098765432' }}
|
||||
LOKI_URL=${{ vars.LOKI_URL || 'http://loki:3100/loki/api/v1/push' }}
|
||||
SMS_WEBHOOK_PORT=${{ vars.STG_SMS_WEBHOOK_PORT || '8080' }}
|
||||
MONITOR_RECIPIENT_PHONES=${{ vars.STG_MONITOR_RECIPIENT_PHONES || '01012345678,01098765432' }}
|
||||
LOKI_URL=${{ vars.STG_LOKI_URL || 'http://loki:3100/loki/api/v1/push' }}
|
||||
EOF
|
||||
|
||||
# 코드 업데이트 (Git)
|
||||
|
||||
@@ -18,60 +18,78 @@ jobs:
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||
ssh-private-key: ${{ secrets.STG_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Build staging deployment bundle
|
||||
env:
|
||||
IMAGE_TAG: ${{ github.event.inputs.image_tag }}
|
||||
IMAGE_DEPLOY_ENV: stage
|
||||
IMAGE_DEPLOY_INSTANCE_NAME: ${{ vars.STAGE_INSTANCE_NAME }}
|
||||
IMAGE_DEPLOY_PORT_PREFIX: ${{ vars.STAGE_PORT_PREFIX }}
|
||||
IMAGE_DEPLOY_PUBLIC_URL: ${{ vars.USERFRONT_URL }}
|
||||
IMAGE_DEPLOY_INSTANCE_NAME: ${{ vars.STG_INSTANCE_NAME }}
|
||||
IMAGE_DEPLOY_PORT_PREFIX: ${{ vars.STG_PORT_PREFIX }}
|
||||
IMAGE_DEPLOY_PUBLIC_URL: ${{ vars.STG_USERFRONT_URL }}
|
||||
IMAGE_DEPLOY_COMPOSE_TEMPLATE: deploy/templates/docker-compose.images.yaml
|
||||
IMAGE_DEPLOY_BUNDLE_FILE: stage-image-deploy-bundle.tgz
|
||||
ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }}
|
||||
DEVFRONT_URL: ${{ vars.DEVFRONT_URL }}
|
||||
ORGFRONT_URL: ${{ vars.ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
IMAGE_DEPLOY_DB_PORT: ${{ vars.DB_PORT }}
|
||||
IMAGE_DEPLOY_REDIS_PORT: ${{ vars.REDIS_PORT }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP: ${{ vars.CLICKHOUSE_PORT_HTTP }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE: ${{ vars.CLICKHOUSE_PORT_NATIVE }}
|
||||
IMAGE_DEPLOY_BACKEND_PORT: ${{ vars.BACKEND_PORT }}
|
||||
IMAGE_DEPLOY_FRONTEND_PORT: ${{ vars.USERFRONT_PORT }}
|
||||
ADMINFRONT_PORT: ${{ vars.ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT: ${{ vars.DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT: ${{ vars.ORGFRONT_PORT }}
|
||||
IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT: ${{ vars.OATHKEEPER_PROXY_PORT }}
|
||||
IMAGE_DEPLOY_DOMAIN_SUFFIX: ${{ vars.DOMAIN_SUFFIX }}
|
||||
ADMINFRONT_CALLBACK_URLS: ${{ vars.ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS: ${{ vars.DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS: ${{ vars.ORGFRONT_CALLBACK_URLS }}
|
||||
HYDRA_REFRESH_TOKEN_TTL: ${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
|
||||
ORY_POSTGRES_USER: ${{ vars.ORY_POSTGRES_USER }}
|
||||
ORY_POSTGRES_DB: ${{ vars.ORY_POSTGRES_DB }}
|
||||
KRATOS_DB: ${{ vars.KRATOS_DB }}
|
||||
HYDRA_DB: ${{ vars.HYDRA_DB }}
|
||||
KETO_DB: ${{ vars.KETO_DB }}
|
||||
KRATOS_VERSION: ${{ vars.KRATOS_VERSION }}
|
||||
HYDRA_VERSION: ${{ vars.HYDRA_VERSION }}
|
||||
KETO_VERSION: ${{ vars.KETO_VERSION }}
|
||||
OATHKEEPER_VERSION: ${{ vars.OATHKEEPER_VERSION }}
|
||||
ORY_POSTGRES_TAG: ${{ vars.ORY_POSTGRES_TAG }}
|
||||
OATHKEEPER_UID: ${{ vars.OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID: ${{ vars.OATHKEEPER_GID }}
|
||||
OATHKEEPER_INTROSPECT_CLIENT_ID: ${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
ADMIN_EMAIL: ${{ vars.ADMIN_EMAIL }}
|
||||
HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }}
|
||||
BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend
|
||||
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
|
||||
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
|
||||
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
|
||||
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
|
||||
ADMINFRONT_URL: ${{ vars.STG_ADMINFRONT_URL }}
|
||||
DEVFRONT_URL: ${{ vars.STG_DEVFRONT_URL }}
|
||||
ORGFRONT_URL: ${{ vars.STG_ORGFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY: ${{ vars.STG_VITE_OIDC_AUTHORITY }}
|
||||
IMAGE_DEPLOY_BACKEND_LOG_LEVEL: ${{ vars.STG_BACKEND_LOG_LEVEL || 'debug' }}
|
||||
IMAGE_DEPLOY_CLIENT_LOG_DEBUG: ${{ vars.STG_CLIENT_LOG_DEBUG || 'true' }}
|
||||
IMAGE_DEPLOY_BACKEND_PUBLIC_URL: ${{ vars.STG_BACKEND_URL }}
|
||||
IMAGE_DEPLOY_BACKEND_URL: ${{ vars.STG_BACKEND_URL }}
|
||||
WORKS_ADMIN_API_BASE_URL: ${{ vars.STG_WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL: ${{ vars.STG_WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
PROFILE_CACHE_TTL: ${{ vars.STG_PROFILE_CACHE_TTL }}
|
||||
NAVER_CLOUD_ACCESS_KEY: ${{ secrets.STG_NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY: ${{ secrets.STG_NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID: ${{ vars.STG_NAVER_CLOUD_SERVICE_ID }}
|
||||
NAVER_SENDER_PHONE_NUMBER: ${{ vars.STG_NAVER_SENDER_PHONE_NUMBER }}
|
||||
AWS_REGION: ${{ vars.STG_AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ vars.STG_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.STG_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SES_SENDER: ${{ vars.STG_AWS_SES_SENDER }}
|
||||
CORS_ALLOWED_ORIGINS: ${{ vars.STG_CORS_ALLOWED_ORIGINS }}
|
||||
OATHKEEPER_API_URL: ${{ vars.STG_OATHKEEPER_API_URL }}
|
||||
CLICKHOUSE_HOST: ${{ vars.STG_CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER: ${{ vars.STG_CLICKHOUSE_USER }}
|
||||
IMAGE_DEPLOY_DB_PORT: ${{ vars.STG_DB_PORT }}
|
||||
IMAGE_DEPLOY_REDIS_PORT: ${{ vars.STG_REDIS_PORT }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP: ${{ vars.STG_CLICKHOUSE_PORT_HTTP }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE: ${{ vars.STG_CLICKHOUSE_PORT_NATIVE }}
|
||||
IMAGE_DEPLOY_BACKEND_PORT: ${{ vars.STG_BACKEND_PORT }}
|
||||
IMAGE_DEPLOY_FRONTEND_PORT: ${{ vars.STG_USERFRONT_PORT }}
|
||||
ADMINFRONT_PORT: ${{ vars.STG_ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT: ${{ vars.STG_DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT: ${{ vars.STG_ORGFRONT_PORT }}
|
||||
IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT: ${{ vars.STG_OATHKEEPER_PROXY_PORT }}
|
||||
IMAGE_DEPLOY_DOMAIN_SUFFIX: ${{ vars.STG_DOMAIN_SUFFIX }}
|
||||
ADMINFRONT_CALLBACK_URLS: ${{ vars.STG_ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS: ${{ vars.STG_DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS: ${{ vars.STG_ORGFRONT_CALLBACK_URLS }}
|
||||
HYDRA_REFRESH_TOKEN_TTL: ${{ vars.STG_HYDRA_REFRESH_TOKEN_TTL }}
|
||||
ORY_POSTGRES_USER: ${{ vars.STG_ORY_POSTGRES_USER }}
|
||||
ORY_POSTGRES_DB: ${{ vars.STG_ORY_POSTGRES_DB }}
|
||||
KRATOS_DB: ${{ vars.STG_KRATOS_DB }}
|
||||
HYDRA_DB: ${{ vars.STG_HYDRA_DB }}
|
||||
KETO_DB: ${{ vars.STG_KETO_DB }}
|
||||
KRATOS_VERSION: ${{ vars.STG_KRATOS_VERSION }}
|
||||
HYDRA_VERSION: ${{ vars.STG_HYDRA_VERSION }}
|
||||
KETO_VERSION: ${{ vars.STG_KETO_VERSION }}
|
||||
OATHKEEPER_VERSION: ${{ vars.STG_OATHKEEPER_VERSION }}
|
||||
ORY_POSTGRES_TAG: ${{ vars.STG_ORY_POSTGRES_TAG }}
|
||||
OATHKEEPER_UID: ${{ vars.STG_OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID: ${{ vars.STG_OATHKEEPER_GID }}
|
||||
OATHKEEPER_INTROSPECT_CLIENT_ID: ${{ vars.STG_OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
ADMIN_EMAIL: ${{ vars.STG_ADMIN_EMAIL }}
|
||||
BACKEND_IMAGE_NAME: baron_sso/backend
|
||||
USERFRONT_IMAGE_NAME: baron_sso/userfront
|
||||
ADMINFRONT_IMAGE_NAME: baron_sso/adminfront
|
||||
DEVFRONT_IMAGE_NAME: baron_sso/devfront
|
||||
ORGFRONT_IMAGE_NAME: baron_sso/orgfront
|
||||
IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.STG_DB_PASSWORD }}
|
||||
IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD: ${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
|
||||
IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }}
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.STG_CLICKHOUSE_PASSWORD }}
|
||||
IMAGE_DEPLOY_COOKIE_SECRET: ${{ secrets.STG_COOKIE_SECRET }}
|
||||
IMAGE_DEPLOY_JWT_SECRET: ${{ secrets.STG_JWT_SECRET }}
|
||||
IMAGE_DEPLOY_CSRF_COOKIE_SECRET: ${{ secrets.STG_CSRF_COOKIE_SECRET }}
|
||||
@@ -83,12 +101,19 @@ jobs:
|
||||
- name: Upload bundle and run requested staging image tag
|
||||
env:
|
||||
IMAGE_DEPLOY_BUNDLE_FILE: stage-image-deploy-bundle.tgz
|
||||
DEPLOY_HOST: ${{ vars.STAGE_HOST }}
|
||||
DEPLOY_USER: ${{ vars.STAGE_USER }}
|
||||
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
|
||||
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
|
||||
DEPLOY_HOST: ${{ vars.STG_HOST }}
|
||||
DEPLOY_USER: ${{ vars.STG_USER }}
|
||||
DEPLOY_PATH: ${{ vars.STG_DEPLOY_PATH }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID }}
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DIR || 'baron-sso' }}
|
||||
WORKS_ADMIN_API_BASE_URL: ${{ vars.WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_INPUT: ${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_FILE: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_FILE }}
|
||||
WORKS_DRIVE_ACCESS_TOKEN_CMD: ${{ vars.WORKS_DRIVE_ACCESS_TOKEN_CMD }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }}
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/deploy/upload_and_run_image_deploy.sh
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
|
||||
ssh-private-key: ${{ secrets.STG_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Deploy to Staging
|
||||
env:
|
||||
@@ -30,9 +30,9 @@ jobs:
|
||||
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
|
||||
|
||||
# Staging-specific variables
|
||||
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
|
||||
STAGE_HOST: ${{ vars.STAGE_HOST }}
|
||||
STAGE_USER: ${{ vars.STAGE_USER }}
|
||||
DEPLOY_PATH: ${{ vars.STG_DEPLOY_PATH }}
|
||||
STAGE_HOST: ${{ vars.STG_HOST }}
|
||||
STAGE_USER: ${{ vars.STG_USER }}
|
||||
|
||||
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
|
||||
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
|
||||
@@ -58,88 +58,88 @@ jobs:
|
||||
APP_ENV=stage
|
||||
BACKEND_LOG_LEVEL=debug
|
||||
CLIENT_LOG_DEBUG=true
|
||||
WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
WORKS_ADMIN_API_BASE_URL=${{ vars.STG_WORKS_ADMIN_API_BASE_URL }}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.STG_WORKS_ADMIN_OAUTH_TOKEN_URL }}
|
||||
TZ=Asia/Seoul
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
# DB & Clickhouse
|
||||
DB_PORT=${{ vars.DB_PORT }}
|
||||
CLICKHOUSE_PORT_HTTP=${{ vars.CLICKHOUSE_PORT_HTTP }}
|
||||
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
|
||||
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
|
||||
CLICKHOUSE_PASSWORD=${{ secrets.CLICKHOUSE_PASSWORD }}
|
||||
DB_PORT=${{ vars.STG_DB_PORT }}
|
||||
CLICKHOUSE_PORT_HTTP=${{ vars.STG_CLICKHOUSE_PORT_HTTP }}
|
||||
CLICKHOUSE_PORT_NATIVE=${{ vars.STG_CLICKHOUSE_PORT_NATIVE }}
|
||||
CLICKHOUSE_HOST=${{ vars.STG_CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER=${{ vars.STG_CLICKHOUSE_USER }}
|
||||
CLICKHOUSE_PASSWORD=${{ secrets.STG_CLICKHOUSE_PASSWORD }}
|
||||
|
||||
|
||||
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}
|
||||
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
|
||||
BACKEND_PORT=${{ vars.STG_BACKEND_PORT }}
|
||||
ADMINFRONT_PORT=${{ vars.STG_ADMINFRONT_PORT }}
|
||||
DEVFRONT_PORT=${{ vars.STG_DEVFRONT_PORT }}
|
||||
ORGFRONT_PORT=${{ vars.STG_ORGFRONT_PORT }}
|
||||
USERFRONT_PORT=${{ vars.STG_USERFRONT_PORT }}
|
||||
|
||||
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
|
||||
OATHKEEPER_API_URL=${{ vars.STG_OATHKEEPER_API_URL }}
|
||||
|
||||
DB_USER=${{ vars.DB_USER }}
|
||||
DB_USER=${{ vars.STG_DB_USER }}
|
||||
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
|
||||
DB_NAME=${{ vars.DB_NAME }}
|
||||
DB_NAME=${{ vars.STG_DB_NAME }}
|
||||
COOKIE_SECRET=${{ secrets.STG_COOKIE_SECRET }}
|
||||
JWT_SECRET=${{ secrets.STG_JWT_SECRET }}
|
||||
REDIS_ADDR=${{ vars.REDIS_ADDR }}
|
||||
CORS_ALLOWED_ORIGINS=${{ vars.CORS_ALLOWED_ORIGINS }}
|
||||
REDIS_ADDR=${{ vars.STG_REDIS_ADDR }}
|
||||
CORS_ALLOWED_ORIGINS=${{ vars.STG_CORS_ALLOWED_ORIGINS }}
|
||||
AUDIT_WORKER_COUNT=5
|
||||
AUDIT_QUEUE_SIZE=2000
|
||||
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
|
||||
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
|
||||
NAVER_SENDER_PHONE_NUMBER=${{ vars.NAVER_SENDER_PHONE_NUMBER }}
|
||||
AWS_REGION=${{ vars.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}
|
||||
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
|
||||
PROFILE_CACHE_TTL=${{ vars.STG_PROFILE_CACHE_TTL }}
|
||||
NAVER_CLOUD_ACCESS_KEY=${{ secrets.STG_NAVER_CLOUD_ACCESS_KEY }}
|
||||
NAVER_CLOUD_SECRET_KEY=${{ secrets.STG_NAVER_CLOUD_SECRET_KEY }}
|
||||
NAVER_CLOUD_SERVICE_ID=${{ vars.STG_NAVER_CLOUD_SERVICE_ID }}
|
||||
NAVER_SENDER_PHONE_NUMBER=${{ vars.STG_NAVER_SENDER_PHONE_NUMBER }}
|
||||
AWS_REGION=${{ vars.STG_AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID=${{ vars.STG_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY=${{ secrets.STG_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SES_SENDER=${{ vars.STG_AWS_SES_SENDER }}
|
||||
ADMIN_EMAIL=${{ vars.STG_ADMIN_EMAIL }}
|
||||
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
|
||||
USERFRONT_URL=${{ vars.USERFRONT_URL }}
|
||||
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
|
||||
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
|
||||
BACKEND_URL=${{ vars.BACKEND_URL }}
|
||||
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
|
||||
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
|
||||
ORY_POSTGRES_USER=${{ vars.ORY_POSTGRES_USER }}
|
||||
USERFRONT_URL=${{ vars.STG_USERFRONT_URL }}
|
||||
ORGFRONT_URL=${{ vars.STG_ORGFRONT_URL }}
|
||||
BACKEND_PUBLIC_URL=${{ vars.STG_BACKEND_URL }}
|
||||
BACKEND_URL=${{ vars.STG_BACKEND_URL }}
|
||||
OATHKEEPER_PUBLIC_URL=${{ vars.STG_OATHKEEPER_PUBLIC_URL }}
|
||||
ORY_POSTGRES_TAG=${{ vars.STG_ORY_POSTGRES_TAG }}
|
||||
ORY_POSTGRES_USER=${{ vars.STG_ORY_POSTGRES_USER }}
|
||||
ORY_POSTGRES_PASSWORD=${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
|
||||
ORY_POSTGRES_DB=${{ vars.ORY_POSTGRES_DB }}
|
||||
KRATOS_DB=${{ vars.KRATOS_DB }}
|
||||
HYDRA_DB=${{ vars.HYDRA_DB }}
|
||||
KETO_DB=${{ vars.KETO_DB }}
|
||||
KRATOS_VERSION=${{ vars.KRATOS_VERSION }}
|
||||
KRATOS_UI_NODE_VERSION=${{ vars.KRATOS_UI_NODE_VERSION }}
|
||||
HYDRA_VERSION=${{ vars.HYDRA_VERSION }}
|
||||
KETO_VERSION=${{ vars.KETO_VERSION }}
|
||||
ORY_SDK_URL=${{ vars.ORY_SDK_URL }}
|
||||
KRATOS_PUBLIC_URL=${{ vars.KRATOS_PUBLIC_URL }}
|
||||
KRATOS_ADMIN_URL=${{ vars.KRATOS_ADMIN_URL }}
|
||||
KRATOS_BROWSER_URL=${{ vars.KRATOS_BROWSER_URL }}
|
||||
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
|
||||
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
|
||||
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
|
||||
HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
|
||||
JWKS_URL=${{ vars.JWKS_URL }}
|
||||
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
|
||||
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID=${{ vars.OATHKEEPER_GID }}
|
||||
OATHKEEPER_HEALTH_URL=${{ vars.OATHKEEPER_HEALTH_URL }}
|
||||
OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.OATHKEEPER_HEALTH_INTERVAL_SECONDS }}
|
||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.OATHKEEPER_HEALTH_TIMEOUT_SECONDS }}
|
||||
OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }}
|
||||
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
|
||||
ORY_POSTGRES_DB=${{ vars.STG_ORY_POSTGRES_DB }}
|
||||
KRATOS_DB=${{ vars.STG_KRATOS_DB }}
|
||||
HYDRA_DB=${{ vars.STG_HYDRA_DB }}
|
||||
KETO_DB=${{ vars.STG_KETO_DB }}
|
||||
KRATOS_VERSION=${{ vars.STG_KRATOS_VERSION }}
|
||||
KRATOS_UI_NODE_VERSION=${{ vars.STG_KRATOS_UI_NODE_VERSION }}
|
||||
HYDRA_VERSION=${{ vars.STG_HYDRA_VERSION }}
|
||||
KETO_VERSION=${{ vars.STG_KETO_VERSION }}
|
||||
ORY_SDK_URL=${{ vars.STG_ORY_SDK_URL }}
|
||||
KRATOS_PUBLIC_URL=${{ vars.STG_KRATOS_PUBLIC_URL }}
|
||||
KRATOS_ADMIN_URL=${{ vars.STG_KRATOS_ADMIN_URL }}
|
||||
KRATOS_BROWSER_URL=${{ vars.STG_KRATOS_BROWSER_URL }}
|
||||
KRATOS_UI_URL=${{ vars.STG_KRATOS_UI_URL }}
|
||||
HYDRA_ADMIN_URL=${{ vars.STG_HYDRA_ADMIN_URL }}
|
||||
HYDRA_PUBLIC_URL=${{ vars.STG_HYDRA_PUBLIC_URL }}
|
||||
HYDRA_REFRESH_TOKEN_TTL=${{ vars.STG_HYDRA_REFRESH_TOKEN_TTL }}
|
||||
JWKS_URL=${{ vars.STG_JWKS_URL }}
|
||||
OATHKEEPER_VERSION=${{ vars.STG_OATHKEEPER_VERSION }}
|
||||
OATHKEEPER_UID=${{ vars.STG_OATHKEEPER_UID }}
|
||||
OATHKEEPER_GID=${{ vars.STG_OATHKEEPER_GID }}
|
||||
OATHKEEPER_HEALTH_URL=${{ vars.STG_OATHKEEPER_HEALTH_URL }}
|
||||
OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.STG_OATHKEEPER_HEALTH_INTERVAL_SECONDS }}
|
||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.STG_OATHKEEPER_HEALTH_TIMEOUT_SECONDS }}
|
||||
OATHKEEPER_HEALTH_ENABLED=${{ vars.STG_OATHKEEPER_HEALTH_ENABLED }}
|
||||
CSRF_COOKIE_NAME=${{ vars.STG_CSRF_COOKIE_NAME }}
|
||||
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
|
||||
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.STG_VITE_OIDC_AUTHORITY }}
|
||||
ADMINFRONT_CALLBACK_URLS=${{ vars.STG_ADMINFRONT_CALLBACK_URLS }}
|
||||
DEVFRONT_CALLBACK_URLS=${{ vars.STG_DEVFRONT_CALLBACK_URLS }}
|
||||
ORGFRONT_CALLBACK_URLS=${{ vars.STG_ORGFRONT_CALLBACK_URLS }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.STG_OATHKEEPER_INTROSPECT_CLIENT_ID }}
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
EOF
|
||||
|
||||
|
||||
29
Makefile
29
Makefile
@@ -31,6 +31,10 @@ endif
|
||||
|
||||
DUMP_SERVICES ?= all
|
||||
RESTORE_SERVICES ?= all
|
||||
DUMP_DATASET ?= full
|
||||
RESTORE_DATASET ?=
|
||||
FILTER_SERVICES ?= postgres,ory-postgres
|
||||
OUTPUT_BACKUP ?=
|
||||
FILE_PATH ?=
|
||||
RESTORE_INPUT ?= $(or $(FILE_PATH),$(word 2,$(MAKECMDGOALS)))
|
||||
CONFIRM_RESTORE ?=
|
||||
@@ -51,7 +55,7 @@ DOCKER_IMAGE_REF ?=
|
||||
WORKS_DOCKER_COMMIT_CONTAINER ?=
|
||||
WORKS_DOCKER_IMAGE_ARCHIVE_DIR ?= /tmp/baron-sso-docker-image-upload
|
||||
|
||||
.PHONY: help build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory ensure-restore-containers up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app backup-tools-build dump restore dump-verify restore-verify dump-list restore-plan upload-cloud works-drive-refresh-token dump-upload-cloud docker-image-upload-works
|
||||
.PHONY: help build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory ensure-restore-containers up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app backup-tools-build dump filter-personnel-dump restore dump-verify restore-verify dump-list restore-plan upload-cloud works-drive-refresh-token dump-upload-cloud docker-image-upload-works docker-image-verify-works
|
||||
|
||||
help: ## 생성된 타깃과 옵션 목록 표시
|
||||
@printf "Usage:\n make <target> [OPTION=value ...]\n\n"
|
||||
@@ -314,10 +318,14 @@ backup-tools-build: ## 백업 도구 Docker 이미지 빌드
|
||||
|
||||
ifeq ($(BACKUP_USE_DOCKER),true)
|
||||
dump: backup-tools-build ## 백업 덤프 생성
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh'
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_DATASET="$(DUMP_DATASET)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh'
|
||||
|
||||
filter-personnel-dump: backup-tools-build ## 전체 백업에서 personnel dataset 백업 추출
|
||||
$(MAKE) --no-print-directory ensure-restore-containers RESTORE_SERVICES="$(FILTER_SERVICES)" CONFIRM_RESTORE=baron-sso
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" OUTPUT_BACKUP="$(OUTPUT_BACKUP)" FILTER_SERVICES="$(FILTER_SERVICES)" scripts/backup/filter_personnel_dump.sh'
|
||||
|
||||
restore: backup-tools-build ensure-restore-containers ## 백업 덤프 복구
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh'
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" RESTORE_DATASET="$(RESTORE_DATASET)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh'
|
||||
|
||||
dump-verify: backup-tools-build ## 백업 덤프 검증
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh'
|
||||
@@ -329,7 +337,7 @@ dump-list: backup-tools-build ## 사용 가능한 백업 덤프 목록 조회
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh'
|
||||
|
||||
restore-plan: backup-tools-build ## 복구 실행 계획 출력
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh'
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" RESTORE_DATASET="$(RESTORE_DATASET)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh'
|
||||
|
||||
upload-cloud: backup-tools-build ## 백업 덤프 클라우드 업로드
|
||||
$(BACKUP_DOCKER_RUN) bash -lc '$(if $(WORKS_DRIVE_DRY_RUN),WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" )$(if $(WORKS_DRIVE_AUTH_MODE),WORKS_DRIVE_AUTH_MODE="$(WORKS_DRIVE_AUTH_MODE)" )BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh'
|
||||
@@ -338,10 +346,14 @@ works-drive-refresh-token: ## WORKS Drive OAuth refresh token 갱신
|
||||
WORKS_DRIVE_TOKEN_GRANT="$(WORKS_DRIVE_TOKEN_GRANT)" WORKS_DRIVE_AUTH_CODE="$(WORKS_DRIVE_AUTH_CODE)" WORKS_DRIVE_AUTH_CALLBACK_URL="$(WORKS_DRIVE_AUTH_CALLBACK_URL)" scripts/backup/refresh_works_drive_token.sh
|
||||
else
|
||||
dump: ## 백업 덤프 생성
|
||||
DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh
|
||||
DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_DATASET="$(DUMP_DATASET)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh
|
||||
|
||||
filter-personnel-dump: ## 전체 백업에서 personnel dataset 백업 추출
|
||||
$(MAKE) --no-print-directory ensure-restore-containers RESTORE_SERVICES="$(FILTER_SERVICES)" CONFIRM_RESTORE=baron-sso
|
||||
BACKUP="$(BACKUP)" OUTPUT_BACKUP="$(OUTPUT_BACKUP)" FILTER_SERVICES="$(FILTER_SERVICES)" scripts/backup/filter_personnel_dump.sh
|
||||
|
||||
restore: ensure-restore-containers ## 백업 덤프 복구
|
||||
RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh
|
||||
RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" RESTORE_DATASET="$(RESTORE_DATASET)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh
|
||||
|
||||
dump-verify: ## 백업 덤프 검증
|
||||
BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh
|
||||
@@ -353,7 +365,7 @@ dump-list: ## 사용 가능한 백업 덤프 목록 조회
|
||||
BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh
|
||||
|
||||
restore-plan: ## 복구 실행 계획 출력
|
||||
RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh
|
||||
RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" RESTORE_DATASET="$(RESTORE_DATASET)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh
|
||||
|
||||
upload-cloud: ## 백업 덤프 클라우드 업로드
|
||||
$(if $(WORKS_DRIVE_DRY_RUN),WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" )$(if $(WORKS_DRIVE_AUTH_MODE),WORKS_DRIVE_AUTH_MODE="$(WORKS_DRIVE_AUTH_MODE)" )BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh
|
||||
@@ -367,6 +379,9 @@ dump-upload-cloud: dump upload-cloud ## 백업 덤프 생성 후 클라우드
|
||||
docker-image-upload-works: ## Docker 이미지를 WORKS Shared Drive archive로 업로드
|
||||
WORKS_DOCKER_COMMIT_CONTAINER="$(WORKS_DOCKER_COMMIT_CONTAINER)" DOCKER_IMAGE_REF="$(DOCKER_IMAGE_REF)" WORKS_DOCKER_IMAGE_ARCHIVE_DIR="$(WORKS_DOCKER_IMAGE_ARCHIVE_DIR)" scripts/docker-image/upload_works_drive.sh
|
||||
|
||||
docker-image-verify-works: ## WORKS Shared Drive Docker image archive 검증
|
||||
WORKS_DOCKER_VERIFY_LOAD="$(WORKS_DOCKER_VERIFY_LOAD)" WORKS_DOCKER_IMAGE_ARCHIVE_DIR="$(WORKS_DOCKER_IMAGE_ARCHIVE_DIR)" scripts/docker-image/verify_archive.sh "$(WORKS_DOCKER_IMAGE_ARCHIVE_DIR)"
|
||||
|
||||
# --- 로컬 통합 코드 체크 ---
|
||||
PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright
|
||||
PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE
|
||||
|
||||
@@ -1040,7 +1040,7 @@ func normalizePhoneForLoginID(phone string) string {
|
||||
return domain.NormalizePhoneNumber(phone)
|
||||
}
|
||||
|
||||
func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string) map[string]any {
|
||||
func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string, profileStatus string) map[string]any {
|
||||
claims := map[string]any{}
|
||||
if traits == nil {
|
||||
return claims
|
||||
@@ -1089,28 +1089,27 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
|
||||
|
||||
if _, ok := scopeSet["profile"]; ok {
|
||||
profile := map[string]any{}
|
||||
names := map[string]any{}
|
||||
for _, key := range []string{
|
||||
"name",
|
||||
"displayname",
|
||||
"preferred_username",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"middle_name",
|
||||
"nickname",
|
||||
} {
|
||||
if value := getString(key); value != "" {
|
||||
names[key] = value
|
||||
displayProfileName := displayName
|
||||
if displayProfileName != "" {
|
||||
profile["name"] = displayProfileName
|
||||
}
|
||||
if primaryEmail != "" {
|
||||
profile["email"] = primaryEmail
|
||||
}
|
||||
if secondaryEmails := collectStringValues(traits, "sub_email", "secondary_emails", "additional_emails", "aliasEmails", "worksmobileAliasEmails"); len(secondaryEmails) > 0 {
|
||||
profile["secondary_emails"] = secondaryEmails
|
||||
}
|
||||
if phone := getString("phone_number"); phone != "" {
|
||||
profile["phones"] = []string{phone}
|
||||
}
|
||||
if employeeID := getString("employee_id"); employeeID != "" {
|
||||
profile["employee_id"] = employeeID
|
||||
}
|
||||
if trimmedStatus := strings.TrimSpace(profileStatus); trimmedStatus != "" {
|
||||
if normalizedStatus := strings.TrimSpace(domain.NormalizeUserStatus(trimmedStatus)); normalizedStatus != "" {
|
||||
profile["status"] = normalizedStatus
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
profile["names"] = names
|
||||
}
|
||||
|
||||
emails := collectEmailList(traits, primaryEmail)
|
||||
if len(emails) > 0 {
|
||||
profile["emails"] = emails
|
||||
}
|
||||
if len(profile) > 0 {
|
||||
claims["profile"] = profile
|
||||
}
|
||||
@@ -1215,13 +1214,63 @@ func withRefreshTokenExpiryClaim(claims map[string]any, issuedAt time.Time) map[
|
||||
return claims
|
||||
}
|
||||
|
||||
func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any {
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, tenantID)
|
||||
func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string, profileStatus string) map[string]any {
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, tenantID, profileStatus)
|
||||
claims = applyConfiguredIDTokenClaims(claims, client.Metadata)
|
||||
claims = withRefreshTokenExpiryClaim(claims, time.Now())
|
||||
return withOidcSessionMetadata(claims, sessionID)
|
||||
}
|
||||
|
||||
func collectStringValues(traits map[string]any, keys ...string) []string {
|
||||
values := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
add := func(raw string) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
return
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
values = append(values, value)
|
||||
}
|
||||
for _, key := range keys {
|
||||
raw, ok := traits[key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
add(value)
|
||||
case []string:
|
||||
for _, item := range value {
|
||||
add(item)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range value {
|
||||
add(fmt.Sprint(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func (h *AuthHandler) resolveProfileStatus(ctx context.Context, subject string) string {
|
||||
if h == nil || h.UserRepo == nil {
|
||||
return ""
|
||||
}
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" {
|
||||
return ""
|
||||
}
|
||||
user, err := h.UserRepo.FindByID(ctx, subject)
|
||||
if err != nil || user == nil {
|
||||
return ""
|
||||
}
|
||||
return domain.NormalizeUserStatus(user.Status)
|
||||
}
|
||||
|
||||
func applyGlobalCustomClaims(baseClaims map[string]any, traits map[string]any) map[string]any {
|
||||
if baseClaims == nil {
|
||||
baseClaims = map[string]any{}
|
||||
@@ -1364,7 +1413,7 @@ func (h *AuthHandler) withHanmacFamilyTenantClaims(ctx context.Context, claims m
|
||||
|
||||
func tenantClaimScopeRequested(scopes []string) bool {
|
||||
for _, scope := range scopes {
|
||||
if strings.EqualFold(strings.TrimSpace(scope), "tenant") {
|
||||
if strings.EqualFold(strings.TrimSpace(scope), "tenants") || strings.EqualFold(strings.TrimSpace(scope), "tenant") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -4144,7 +4193,17 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
|
||||
ale.LoginIDs["loginId_normalized"] = loginID
|
||||
|
||||
userfrontURL := h.resolveUserfrontURL(c)
|
||||
redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password")
|
||||
locale := "ko"
|
||||
acceptLang := c.Get("Accept-Language")
|
||||
if acceptLang != "" {
|
||||
// Accept-Language 헤더는 선호도가 가장 높은 순서대로 쉼표로 구분되어 나열됩니다. (예: ko-KR,ko;q=0.9,en-US;q=0.8)
|
||||
// 따라서 첫 번째 쉼표 이전의 가장 첫 번째 세그먼트가 사용자가 최우선으로 선호하는 언어입니다.
|
||||
firstLang := strings.Split(acceptLang, ",")[0]
|
||||
if strings.Contains(strings.ToLower(firstLang), "en") {
|
||||
locale = "en"
|
||||
}
|
||||
}
|
||||
redirectBase, parseErr := url.Parse(fmt.Sprintf("%s/%s/reset-password", strings.TrimRight(userfrontURL, "/"), locale))
|
||||
if parseErr != nil {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
@@ -6285,6 +6344,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
consentRequest.RequestedScope,
|
||||
representativeTenantIDFromTraits(identity.Traits),
|
||||
currentSessionID,
|
||||
h.resolveProfileStatus(c.Context(), consentRequest.Subject),
|
||||
)
|
||||
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
|
||||
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
@@ -6323,6 +6383,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
consentRequest.RequestedScope,
|
||||
representativeTenantIDFromTraits(identity.Traits),
|
||||
currentSessionID,
|
||||
h.resolveProfileStatus(c.Context(), consentRequest.Subject),
|
||||
)
|
||||
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
|
||||
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
@@ -6514,6 +6575,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
||||
consentRequest.RequestedScope,
|
||||
representativeTenantIDFromTraits(identity.Traits),
|
||||
currentSessionID,
|
||||
h.resolveProfileStatus(c.Context(), consentRequest.Subject),
|
||||
)
|
||||
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
|
||||
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
|
||||
|
||||
@@ -203,7 +203,7 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
|
||||
"allowed_tenants": []string{"tenant-allow"},
|
||||
"structured_scopes": []map[string]any{
|
||||
{"name": "openid", "mandatory": true},
|
||||
{"name": "tenant", "mandatory": true, "locked": true},
|
||||
{"name": "tenants", "mandatory": true, "locked": true},
|
||||
{"name": "profile", "mandatory": false},
|
||||
},
|
||||
},
|
||||
@@ -262,9 +262,9 @@ func TestGetConsentRequest_AddsMandatoryTenantScope(t *testing.T) {
|
||||
var body map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
|
||||
assert.Equal(t, []any{"openid", "tenant", "profile"}, body["requested_scope"])
|
||||
assert.Equal(t, []any{"openid", "tenants", "profile"}, body["requested_scope"])
|
||||
scopeDetails := body["scope_details"].(map[string]any)
|
||||
tenantDetail := scopeDetails["tenant"].(map[string]any)
|
||||
tenantDetail := scopeDetails["tenants"].(map[string]any)
|
||||
assert.Equal(t, true, tenantDetail["mandatory"])
|
||||
}
|
||||
|
||||
@@ -448,7 +448,7 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
|
||||
"allowed_tenants": []string{"tenant-abc"},
|
||||
"structured_scopes": []map[string]any{
|
||||
{"name": "openid", "mandatory": true},
|
||||
{"name": "tenant", "mandatory": true, "locked": true},
|
||||
{"name": "tenants", "mandatory": true, "locked": true},
|
||||
{"name": "profile", "mandatory": false},
|
||||
},
|
||||
},
|
||||
@@ -511,5 +511,5 @@ func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) {
|
||||
resp, err := app.Test(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, []string{"openid", "tenant", "profile"}, capturedGrantScopes)
|
||||
assert.Equal(t, []string{"openid", "tenants", "profile"}, capturedGrantScopes)
|
||||
}
|
||||
|
||||
@@ -70,9 +70,12 @@ func TestWithRefreshTokenExpiryClaim_UsesHydraRefreshTokenTTL(t *testing.T) {
|
||||
|
||||
func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
|
||||
traits := map[string]any{
|
||||
"email": "user@baron.com",
|
||||
"name": "홍길동",
|
||||
"tenant_id": "primary-tenant-999", // Added primary tenant
|
||||
"email": "user@baron.com",
|
||||
"name": "홍길동",
|
||||
"phone_number": "+821012345678",
|
||||
"employee_id": "EMP-001",
|
||||
"sub_email": []any{"alias1@baron.com", "alias2@baron.com"},
|
||||
"tenant_id": "primary-tenant-999", // Added primary tenant
|
||||
"tenant-1": map[string]any{
|
||||
"department": "개발팀",
|
||||
"grade": "선임",
|
||||
@@ -85,12 +88,19 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
|
||||
scopes := []string{"openid", "profile"}
|
||||
|
||||
t.Run("No tenantID", func(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, "")
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, "", "leave_of_absence")
|
||||
assert.Equal(t, "user@baron.com", claims["email"])
|
||||
assert.Equal(t, "홍길동", claims["name"])
|
||||
assert.Equal(t, "primary-tenant-999", claims["tenant_id"])
|
||||
assert.Nil(t, claims["department"])
|
||||
assert.Nil(t, claims["grade"])
|
||||
profile := claims["profile"].(map[string]any)
|
||||
assert.Equal(t, "홍길동", profile["name"])
|
||||
assert.Equal(t, "user@baron.com", profile["email"])
|
||||
assert.Equal(t, "EMP-001", profile["employee_id"])
|
||||
assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"])
|
||||
assert.Equal(t, "temporary_leave", profile["status"])
|
||||
assert.Equal(t, []string{"+821012345678"}, profile["phones"])
|
||||
|
||||
assert.Nil(t, claims["tenants"])
|
||||
assert.Contains(t, claims["joined_tenants"], "tenant-1")
|
||||
@@ -99,12 +109,19 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("With tenant-1", func(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1")
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1", "leave_of_absence")
|
||||
assert.Equal(t, "user@baron.com", claims["email"])
|
||||
assert.Equal(t, "홍길동", claims["name"])
|
||||
assert.Equal(t, "tenant-1", claims["tenant_id"])
|
||||
assert.Nil(t, claims["department"])
|
||||
assert.Nil(t, claims["grade"])
|
||||
profile := claims["profile"].(map[string]any)
|
||||
assert.Equal(t, "홍길동", profile["name"])
|
||||
assert.Equal(t, "user@baron.com", profile["email"])
|
||||
assert.Equal(t, "EMP-001", profile["employee_id"])
|
||||
assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"])
|
||||
assert.Equal(t, "temporary_leave", profile["status"])
|
||||
assert.Equal(t, []string{"+821012345678"}, profile["phones"])
|
||||
|
||||
assert.Nil(t, claims["tenants"])
|
||||
assert.Contains(t, claims["joined_tenants"], "tenant-1")
|
||||
@@ -113,35 +130,56 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("With tenant-2", func(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2")
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2", "leave_of_absence")
|
||||
assert.Equal(t, "user@baron.com", claims["email"])
|
||||
assert.Equal(t, "홍길동", claims["name"])
|
||||
assert.Equal(t, "tenant-2", claims["tenant_id"])
|
||||
assert.Nil(t, claims["department"])
|
||||
assert.Nil(t, claims["grade"])
|
||||
profile := claims["profile"].(map[string]any)
|
||||
assert.Equal(t, "홍길동", profile["name"])
|
||||
assert.Equal(t, "user@baron.com", profile["email"])
|
||||
assert.Equal(t, "EMP-001", profile["employee_id"])
|
||||
assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"])
|
||||
assert.Equal(t, "temporary_leave", profile["status"])
|
||||
assert.Equal(t, []string{"+821012345678"}, profile["phones"])
|
||||
|
||||
assert.Nil(t, claims["tenants"])
|
||||
assert.Contains(t, claims["joined_tenants"], "primary-tenant-999")
|
||||
})
|
||||
|
||||
t.Run("With non-existent tenant", func(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-3")
|
||||
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-3", "leave_of_absence")
|
||||
assert.Equal(t, "user@baron.com", claims["email"])
|
||||
assert.Equal(t, "홍길동", claims["name"])
|
||||
assert.Equal(t, "tenant-3", claims["tenant_id"])
|
||||
assert.Nil(t, claims["department"])
|
||||
assert.Nil(t, claims["grade"])
|
||||
profile := claims["profile"].(map[string]any)
|
||||
assert.Equal(t, "홍길동", profile["name"])
|
||||
assert.Equal(t, "user@baron.com", profile["email"])
|
||||
assert.Equal(t, "EMP-001", profile["employee_id"])
|
||||
assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"])
|
||||
assert.Equal(t, "temporary_leave", profile["status"])
|
||||
assert.Equal(t, []string{"+821012345678"}, profile["phones"])
|
||||
|
||||
assert.Nil(t, claims["tenants"])
|
||||
assert.Contains(t, claims["joined_tenants"], "tenant-1")
|
||||
assert.Contains(t, claims["joined_tenants"], "primary-tenant-999")
|
||||
})
|
||||
|
||||
t.Run("Tenant scope includes detailed tenant metadata", func(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenant"}, "tenant-1")
|
||||
t.Run("Tenants scope includes detailed tenant metadata", func(t *testing.T) {
|
||||
claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenants"}, "tenant-1", "leave_of_absence")
|
||||
assert.Equal(t, "tenant-1", claims["tenant_id"])
|
||||
assert.Equal(t, "개발팀", claims["department"])
|
||||
assert.Equal(t, "선임", claims["grade"])
|
||||
profile := claims["profile"].(map[string]any)
|
||||
assert.Equal(t, "홍길동", profile["name"])
|
||||
assert.Equal(t, "user@baron.com", profile["email"])
|
||||
assert.Equal(t, "EMP-001", profile["employee_id"])
|
||||
assert.Equal(t, []string{"alias1@baron.com", "alias2@baron.com"}, profile["secondary_emails"])
|
||||
assert.Equal(t, "temporary_leave", profile["status"])
|
||||
assert.Equal(t, []string{"+821012345678"}, profile["phones"])
|
||||
assert.NotNil(t, claims["tenants"])
|
||||
assert.Contains(t, claims["joined_tenants"], "tenant-1")
|
||||
assert.Contains(t, claims["joined_tenants"], "tenant-2")
|
||||
@@ -190,7 +228,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-dynamic",
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"requested_scope": []string{"openid", "profile", "tenants"},
|
||||
"subject": "user-123",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-app",
|
||||
@@ -260,7 +298,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-dynamic",
|
||||
"grant_scope": []string{"openid", "profile", "tenant"},
|
||||
"grant_scope": []string{"openid", "profile", "tenants"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -290,7 +328,7 @@ func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantCon
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-representative-tenant",
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"requested_scope": []string{"openid", "profile", "tenants"},
|
||||
"subject": "user-representative",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-app",
|
||||
@@ -367,7 +405,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-hanmac-tenant-claim",
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"requested_scope": []string{"openid", "profile", "tenants"},
|
||||
"subject": "user-hanmac",
|
||||
"client": map[string]any{
|
||||
"client_id": "hanmac-rp",
|
||||
@@ -462,7 +500,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-hanmac-tenant-claim",
|
||||
"grant_scope": []string{"openid", "profile", "tenant"},
|
||||
"grant_scope": []string{"openid", "profile", "tenants"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -574,7 +612,7 @@ func TestAcceptConsentRequest_DoesNotEmitLegacyProfileArray(t *testing.T) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-rp-profile",
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"requested_scope": []string{"openid", "profile", "tenants"},
|
||||
"subject": "user-123",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-app",
|
||||
@@ -666,7 +704,7 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-skip-dynamic",
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"requested_scope": []string{"openid", "profile", "tenants"},
|
||||
"skip": true,
|
||||
"subject": "user-456",
|
||||
"client": map[string]any{
|
||||
@@ -845,7 +883,7 @@ func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) {
|
||||
"writePermission": "admin_only",
|
||||
},
|
||||
},
|
||||
}, []string{"openid", "profile", "email"}, "")
|
||||
}, []string{"openid", "profile", "email"}, "", "")
|
||||
|
||||
assert.Equal(t, "2026-06-09", claims["contract_date"])
|
||||
assert.Equal(t, "2026-06-09T09:30:00+09:00", claims["approved_at"])
|
||||
@@ -861,7 +899,7 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-configured-claims" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-configured-claims",
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"requested_scope": []string{"openid", "profile", "tenants"},
|
||||
"subject": "user-789",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-configured-claims",
|
||||
@@ -973,7 +1011,7 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
|
||||
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" {
|
||||
return httpJSONAny(r, http.StatusOK, map[string]any{
|
||||
"challenge": "challenge-rp-user-claims",
|
||||
"requested_scope": []string{"openid", "profile", "tenant"},
|
||||
"requested_scope": []string{"openid", "profile", "tenants"},
|
||||
"subject": "user-rp-claims",
|
||||
"client": map[string]any{
|
||||
"client_id": "client-rp-claims",
|
||||
@@ -1119,7 +1157,7 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
|
||||
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"consent_challenge": "challenge-rp-user-claims",
|
||||
"grant_scope": []string{"openid", "profile", "tenant"},
|
||||
"grant_scope": []string{"openid", "profile", "tenants"},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -463,7 +463,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
|
||||
out := make([]string, 0, len(combined))
|
||||
|
||||
appendIfPresent := func(scope string) {
|
||||
scope = strings.TrimSpace(scope)
|
||||
scope = canonicalConsentScopeName(scope)
|
||||
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
|
||||
return
|
||||
}
|
||||
@@ -471,7 +471,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
|
||||
return
|
||||
}
|
||||
for _, candidate := range combined {
|
||||
if strings.TrimSpace(candidate) != scope {
|
||||
if canonicalConsentScopeName(candidate) != scope {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
@@ -481,10 +481,10 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
|
||||
}
|
||||
|
||||
appendIfPresent("openid")
|
||||
appendIfPresent("tenant")
|
||||
appendIfPresent("tenants")
|
||||
|
||||
for _, scope := range combined {
|
||||
scope = strings.TrimSpace(scope)
|
||||
scope = canonicalConsentScopeName(scope)
|
||||
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
|
||||
continue
|
||||
}
|
||||
@@ -501,7 +501,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
|
||||
func requiredClientScopes(client domain.HydraClient) []string {
|
||||
required := make([]string, 0, 4)
|
||||
if clientTenantAccessRestricted(client.Metadata) {
|
||||
required = append(required, "tenant")
|
||||
required = append(required, "tenants")
|
||||
}
|
||||
|
||||
if client.Metadata == nil {
|
||||
@@ -535,3 +535,12 @@ func requiredClientScopes(client domain.HydraClient) []string {
|
||||
|
||||
return normalizeScopesInConsentOrder(required)
|
||||
}
|
||||
|
||||
func canonicalConsentScopeName(scope string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(scope)) {
|
||||
case "tenant":
|
||||
return "tenants"
|
||||
default:
|
||||
return strings.TrimSpace(scope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,20 +121,20 @@ func TestCreateClient_RejectsTenantAccessWithoutAllowedTenants(t *testing.T) {
|
||||
assert.False(t, hydraCalled)
|
||||
}
|
||||
|
||||
func TestMergeRequestedScopesWithClientRequirements_AddsTenantScope(t *testing.T) {
|
||||
func TestMergeRequestedScopesWithClientRequirements_AddsTenantsScope(t *testing.T) {
|
||||
client := domain.HydraClient{
|
||||
Metadata: map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"structured_scopes": []map[string]any{
|
||||
{"name": "openid", "mandatory": true},
|
||||
{"name": "tenant", "mandatory": true, "locked": true},
|
||||
{"name": "tenants", "mandatory": true, "locked": true},
|
||||
{"name": "profile", "mandatory": false},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
merged := mergeRequestedScopesWithClientRequirements(client, []string{"openid", "profile"})
|
||||
assert.Equal(t, []string{"openid", "tenant", "profile"}, merged)
|
||||
assert.Equal(t, []string{"openid", "tenants", "profile"}, merged)
|
||||
}
|
||||
|
||||
func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAliases(t *testing.T) {
|
||||
@@ -154,7 +154,7 @@ func TestMergeRequestedScopesWithClientRequirements_StripsRefreshTokenScopeAlias
|
||||
[]string{"openid", "offline", "profile", "offline_access"},
|
||||
)
|
||||
|
||||
assert.Equal(t, []string{"openid", "tenant", "profile", "offline_access", "email"}, merged)
|
||||
assert.Equal(t, []string{"openid", "tenants", "profile", "offline_access", "email"}, merged)
|
||||
}
|
||||
|
||||
func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) {
|
||||
|
||||
@@ -864,7 +864,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
|
||||
},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile email tenant",
|
||||
"scope": "openid profile email tenants",
|
||||
"token_endpoint_auth_method": "private_key_jwt",
|
||||
"metadata": map[string]any{
|
||||
"status": "active",
|
||||
@@ -905,7 +905,7 @@ func TestUpdateClient_AuditDetailsIncludeGeneralSettingChanges(t *testing.T) {
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "App One Updated",
|
||||
"scopes": []string{"openid", "profile", "email", "tenant"},
|
||||
"scopes": []string{"openid", "profile", "email", "tenants"},
|
||||
"metadata": map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
"allowed_tenants": []string{"tenant-1", "tenant-2"},
|
||||
@@ -3009,7 +3009,7 @@ func TestUpdateClient_RevokesExistingConsentsWhenTenantPolicyChanges(t *testing.
|
||||
RedirectURIs: []string{"https://rp.example.com/callback"},
|
||||
GrantTypes: []string{"authorization_code", "refresh_token"},
|
||||
ResponseTypes: []string{"code"},
|
||||
Scope: "openid tenant profile email",
|
||||
Scope: "openid tenants profile email",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
@@ -3093,7 +3093,7 @@ func TestUpdateClient_DoesNotRevokeConsentsWhenTenantPolicyUnchanged(t *testing.
|
||||
RedirectURIs: []string{"https://rp.example.com/callback"},
|
||||
GrantTypes: []string{"authorization_code", "refresh_token"},
|
||||
ResponseTypes: []string{"code"},
|
||||
Scope: "openid tenant profile email",
|
||||
Scope: "openid tenants profile email",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]any{
|
||||
"tenant_access_restricted": true,
|
||||
|
||||
@@ -29,6 +29,23 @@ function mergeTomlObjects(base: TomlObject, override: TomlObject): TomlObject {
|
||||
return result;
|
||||
}
|
||||
|
||||
function setTomlValue(
|
||||
target: TomlObject,
|
||||
path: string[],
|
||||
value: TomlValue,
|
||||
): void {
|
||||
let cursor: TomlObject = target;
|
||||
for (let index = 0; index < path.length - 1; index += 1) {
|
||||
const key = path[index];
|
||||
const current = cursor[key];
|
||||
if (!current || typeof current === "string") {
|
||||
cursor[key] = {};
|
||||
}
|
||||
cursor = cursor[key] as TomlObject;
|
||||
}
|
||||
cursor[path[path.length - 1]] = value;
|
||||
}
|
||||
|
||||
function isSupportedLocale(value: string): value is Locale {
|
||||
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -82,7 +99,7 @@ function parseToml(raw: string): TomlObject {
|
||||
cursor = cursor[section] as TomlObject;
|
||||
}
|
||||
|
||||
cursor[key] = value;
|
||||
setTomlValue(cursor, key.split("."), value);
|
||||
}
|
||||
|
||||
return root;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 458 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 457 KiB |
@@ -43,17 +43,13 @@ export const devFrontRoutes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element:
|
||||
import.meta.env.MODE === "development" ? <AppLayout /> : <AuthGuard />,
|
||||
children:
|
||||
import.meta.env.MODE === "development"
|
||||
? devFrontAppChildren
|
||||
: [
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: devFrontAppChildren,
|
||||
},
|
||||
],
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: devFrontAppChildren,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { userManager } from "../../lib/auth";
|
||||
import { isValidOidcSessionUser } from "../../lib/oidcStorage";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const auth = useAuth();
|
||||
@@ -16,7 +17,7 @@ export default function AuthCallbackPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
if (auth.isAuthenticated && isValidOidcSessionUser(auth.user)) {
|
||||
const returnTo =
|
||||
typeof auth.user?.state === "object" &&
|
||||
auth.user?.state !== null &&
|
||||
@@ -29,7 +30,7 @@ export default function AuthCallbackPage() {
|
||||
console.error("Auth Error:", auth.error);
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
|
||||
}, [auth.isAuthenticated, auth.error, navigate, auth.user]);
|
||||
|
||||
return <div>Loading Auth...</div>;
|
||||
}
|
||||
|
||||
@@ -2,22 +2,26 @@ import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { userManager } from "../../lib/auth";
|
||||
import { findPersistedOidcUser } from "../../lib/oidcStorage";
|
||||
import {
|
||||
findPersistedOidcUser,
|
||||
isValidOidcSessionUser,
|
||||
} from "../../lib/oidcStorage";
|
||||
|
||||
export default function AuthGuard() {
|
||||
const auth = useAuth();
|
||||
const hasActiveAuthUser =
|
||||
auth.isAuthenticated && isValidOidcSessionUser(auth.user);
|
||||
const [hasStoredUser, setHasStoredUser] = useState<boolean | null>(() =>
|
||||
findPersistedOidcUser() ? true : null,
|
||||
);
|
||||
const isDevelopmentMode = import.meta.env.MODE === "development";
|
||||
const isTestMode =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true || navigator.webdriver === true;
|
||||
._IS_TEST_MODE === true;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (isDevelopmentMode || isTestMode) {
|
||||
if (isTestMode) {
|
||||
setHasStoredUser(true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -36,7 +40,7 @@ export default function AuthGuard() {
|
||||
.getUser()
|
||||
.then((user) => {
|
||||
if (!cancelled) {
|
||||
setHasStoredUser(Boolean(user && !user.expired));
|
||||
setHasStoredUser(isValidOidcSessionUser(user));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -50,7 +54,7 @@ export default function AuthGuard() {
|
||||
};
|
||||
}, [isTestMode]);
|
||||
|
||||
if (isDevelopmentMode || isTestMode) {
|
||||
if (isTestMode) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -76,7 +80,7 @@ export default function AuthGuard() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated && !hasStoredUser) {
|
||||
if (!hasActiveAuthUser && !hasStoredUser) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { canStartBrowserPkceLogin } from "../../lib/authConfig";
|
||||
import { isValidOidcSessionUser } from "../../lib/oidcStorage";
|
||||
|
||||
const insecurePkceMessage =
|
||||
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
|
||||
@@ -39,12 +40,14 @@ function LoginPage() {
|
||||
return message;
|
||||
}, [auth.error?.message]);
|
||||
const visibleLoginError = loginError || authErrorMessage;
|
||||
const hasActiveAuthUser =
|
||||
auth.isAuthenticated && isValidOidcSessionUser(auth.user);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
if (hasActiveAuthUser) {
|
||||
navigate(returnTo, { replace: true });
|
||||
}
|
||||
}, [auth.isAuthenticated, navigate, returnTo]);
|
||||
}, [hasActiveAuthUser, navigate, returnTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoLogin) {
|
||||
|
||||
@@ -2,21 +2,22 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PersistedOidcUser } from "../../lib/oidcStorage";
|
||||
import AuthCallbackPage from "./AuthCallbackPage";
|
||||
import AuthGuard from "./AuthGuard";
|
||||
import AuthPage from "./AuthPage";
|
||||
import LoginPage from "./LoginPage";
|
||||
|
||||
type AuthTestUser = PersistedOidcUser & {
|
||||
state?: unknown;
|
||||
};
|
||||
|
||||
const authState = {
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
activeNavigator: undefined as string | undefined,
|
||||
error: null as Error | null,
|
||||
user: undefined as
|
||||
| {
|
||||
state?: unknown;
|
||||
}
|
||||
| undefined,
|
||||
user: undefined as AuthTestUser | undefined,
|
||||
signinRedirect: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -33,7 +34,19 @@ vi.mock("../../lib/auth", () => ({
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
function createAuthUser(overrides: Partial<AuthTestUser> = {}): AuthTestUser {
|
||||
return {
|
||||
access_token: "token-1",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
profile: { sub: "dev-admin-1" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
delete (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE;
|
||||
authState.isAuthenticated = false;
|
||||
authState.isLoading = false;
|
||||
authState.activeNavigator = undefined;
|
||||
@@ -137,13 +150,50 @@ describe("devfront auth pages", () => {
|
||||
expect(redirected.textContent).toContain("Login route");
|
||||
|
||||
authState.isAuthenticated = true;
|
||||
authState.user = createAuthUser();
|
||||
const protectedPage = await renderWithRouter(<AuthGuard />);
|
||||
expect(protectedPage.textContent).toContain("Protected outlet");
|
||||
});
|
||||
|
||||
it("does not restore placeholder unknown OIDC entries as authenticated sessions", async () => {
|
||||
window.localStorage.setItem(
|
||||
"oidc.user:issuer:devfront",
|
||||
JSON.stringify({
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
profile: {
|
||||
name: "Unknown User",
|
||||
email: "unknown@example.com",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const redirected = await renderWithRouter(<AuthGuard />);
|
||||
|
||||
expect(redirected.textContent).toContain("Login route");
|
||||
});
|
||||
|
||||
it("keeps the login page visible for placeholder authenticated users", async () => {
|
||||
authState.isAuthenticated = true;
|
||||
authState.user = {
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
profile: {
|
||||
name: "Unknown User",
|
||||
email: "unknown@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
const container = await renderWithRouter(<LoginPage />, {
|
||||
entry: "/login?returnTo=/profile",
|
||||
path: "/login",
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("개발자 포털 로그인");
|
||||
expect(container.textContent).not.toContain("Profile route");
|
||||
});
|
||||
|
||||
it("navigates from callback by auth result and stored return target", async () => {
|
||||
authState.isAuthenticated = true;
|
||||
authState.user = { state: { returnTo: "/profile" } };
|
||||
authState.user = createAuthUser({ state: { returnTo: "/profile" } });
|
||||
|
||||
const authenticated = await renderWithRouter(<AuthCallbackPage />, {
|
||||
entry: "/auth/callback",
|
||||
|
||||
@@ -91,7 +91,7 @@ function makeClientDetail(
|
||||
if (includeTenantScope) {
|
||||
structuredScopes.push({
|
||||
id: "2",
|
||||
name: "tenant",
|
||||
name: "tenants",
|
||||
description: "Tenant access",
|
||||
mandatory: tenantScopeMandatory,
|
||||
locked: tenantAccessRestricted,
|
||||
@@ -106,7 +106,7 @@ function makeClientDetail(
|
||||
status: "active",
|
||||
redirectUris: ["https://rp.example.com/callback"],
|
||||
scopes: includeTenantScope
|
||||
? ["openid", "tenant", "profile"]
|
||||
? ["openid", "tenants", "profile"]
|
||||
: ["openid", "profile"],
|
||||
tokenEndpointAuthMethod: "client_secret_basic",
|
||||
metadata: {
|
||||
@@ -334,7 +334,48 @@ describe("ClientGeneralPage RP claims", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves tenant scope mandatory state when tenant access restriction is off", async () => {
|
||||
it("clears saved RP claims when custom claims are disabled", async () => {
|
||||
const { container } = await renderPage();
|
||||
|
||||
const claimToggle = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('[role="switch"]'),
|
||||
).find((button) =>
|
||||
(button.getAttribute("aria-label") ?? "").includes("커스텀 클레임 사용"),
|
||||
);
|
||||
expect(claimToggle).toBeDefined();
|
||||
expect(claimToggle?.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
claimToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(claimToggle?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
const saveButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) =>
|
||||
button.textContent?.includes("저장") ||
|
||||
button.textContent?.includes("Save"),
|
||||
);
|
||||
expect(saveButton).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(updateClientMock).toHaveBeenCalledWith(
|
||||
"client-claims",
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
id_token_claims_enabled: false,
|
||||
id_token_claims: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves tenants scope mandatory state when tenant access restriction is off", async () => {
|
||||
fetchClientMock.mockResolvedValue(
|
||||
makeClientDetail("old_claim", {
|
||||
includeTenantScope: true,
|
||||
@@ -355,7 +396,7 @@ describe("ClientGeneralPage RP claims", () => {
|
||||
const tenantScopeRow = Array.from(container.querySelectorAll("tr")).find(
|
||||
(row) =>
|
||||
Array.from(row.querySelectorAll("input")).some(
|
||||
(input) => (input as HTMLInputElement).value === "tenant",
|
||||
(input) => (input as HTMLInputElement).value === "tenants",
|
||||
),
|
||||
);
|
||||
|
||||
@@ -393,7 +434,7 @@ describe("ClientGeneralPage RP claims", () => {
|
||||
tenant_access_restricted: false,
|
||||
structured_scopes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "tenant",
|
||||
name: "tenants",
|
||||
mandatory: false,
|
||||
locked: false,
|
||||
}),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
130
devfront/src/features/clients/components/SettingsTable.tsx
Normal file
130
devfront/src/features/clients/components/SettingsTable.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import type * as React from "react";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
interface SettingsTableShellProps {
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SettingsTableShell({
|
||||
className,
|
||||
bodyClassName,
|
||||
children,
|
||||
}: SettingsTableShellProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-md border border-border bg-background",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("overflow-auto", bodyClassName)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsTable({
|
||||
className,
|
||||
...props
|
||||
}: React.TableHTMLAttributes<HTMLTableElement>) {
|
||||
return <table className={cn("w-full text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
function SettingsTableHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return (
|
||||
<thead
|
||||
className={cn(
|
||||
"bg-muted/50 border-b border-border text-xs uppercase tracking-wider text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsTableBody({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return (
|
||||
<tbody className={cn("divide-y divide-border", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsTableRow({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableRowElement>) {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/20 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsTableHead({
|
||||
className,
|
||||
...props
|
||||
}: React.ThHTMLAttributes<HTMLTableCellElement>) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"h-12 px-4 text-left text-xs font-bold uppercase tracking-wider text-black align-middle dark:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsTableCell({
|
||||
className,
|
||||
...props
|
||||
}: React.TdHTMLAttributes<HTMLTableCellElement>) {
|
||||
return <td className={cn("px-4 py-3 align-top", className)} {...props} />;
|
||||
}
|
||||
|
||||
interface SettingsTableEmptyStateProps {
|
||||
colSpan: number;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function SettingsTableEmptyState({
|
||||
colSpan,
|
||||
children,
|
||||
className,
|
||||
}: SettingsTableEmptyStateProps) {
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={colSpan}
|
||||
className={cn(
|
||||
"px-4 py-8 text-center text-sm text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
SettingsTable,
|
||||
SettingsTableBody,
|
||||
SettingsTableCell,
|
||||
SettingsTableEmptyState,
|
||||
SettingsTableHead,
|
||||
SettingsTableHeader,
|
||||
SettingsTableRow,
|
||||
SettingsTableShell,
|
||||
};
|
||||
@@ -6,6 +6,32 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("i18n", () => {
|
||||
it("returns Korean copy for dotted developer claim headers", () => {
|
||||
window.localStorage.setItem("locale", "ko");
|
||||
|
||||
expect(
|
||||
t("ui.dev.clients.general.id_token_claims.table.key", "Claim Key"),
|
||||
).toBe("클레임 키");
|
||||
expect(
|
||||
t(
|
||||
"ui.dev.clients.general.id_token_claims.table.value_type",
|
||||
"Value Type",
|
||||
),
|
||||
).toBe("값 유형");
|
||||
expect(
|
||||
t(
|
||||
"msg.dev.clients.general.id_token_claims.hint",
|
||||
"RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
|
||||
),
|
||||
).toBe("사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.");
|
||||
expect(
|
||||
t(
|
||||
"msg.dev.clients.general.id_token_claims.preview_hint",
|
||||
"저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다.",
|
||||
),
|
||||
).toBe("설정 저장 시 반영될 claim 구성을 미리 볼 수 있습니다.");
|
||||
});
|
||||
|
||||
it("returns English copy for the developer request and grants screens", () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
|
||||
@@ -32,5 +58,27 @@ describe("i18n", () => {
|
||||
"현재 부여된 개발자 권한 목록입니다.",
|
||||
),
|
||||
).toBe("Current developer access grants.");
|
||||
expect(
|
||||
t(
|
||||
"msg.dev.clients.general.id_token_claims.subtitle",
|
||||
"RP 전용 확장 claim을 구분해서 관리합니다.",
|
||||
),
|
||||
).toBe(
|
||||
"User-specific claim values are edited in the Consent and Claims tabs.",
|
||||
);
|
||||
expect(
|
||||
t(
|
||||
"msg.dev.clients.general.id_token_claims.hint",
|
||||
"사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
|
||||
),
|
||||
).toBe(
|
||||
"User-specific claim values are edited in the Consent and Claims tabs.",
|
||||
);
|
||||
expect(
|
||||
t(
|
||||
"msg.dev.clients.general.id_token_claims.preview_hint",
|
||||
"설정 저장 시 반영될 claim 구성을 미리 볼 수 있습니다.",
|
||||
),
|
||||
).toBe("Preview the claim set that will be saved with these settings.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,14 +48,14 @@ describe("findPersistedOidcUser", () => {
|
||||
JSON.stringify({
|
||||
access_token: "token-1",
|
||||
expires_at: expiresAt,
|
||||
profile: { name: "Dev Admin" },
|
||||
profile: { sub: "dev-admin-1", name: "Dev Admin" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findPersistedOidcUser(storage)).toEqual({
|
||||
access_token: "token-1",
|
||||
expires_at: expiresAt,
|
||||
profile: { name: "Dev Admin" },
|
||||
profile: { sub: "dev-admin-1", name: "Dev Admin" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,4 +73,20 @@ describe("findPersistedOidcUser", () => {
|
||||
|
||||
expect(findPersistedOidcUser(storage)).toBeNull();
|
||||
});
|
||||
|
||||
it("skips placeholder unknown entries without an access token and subject", () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem(
|
||||
"oidc.user:issuer:devfront",
|
||||
JSON.stringify({
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
profile: {
|
||||
name: "Unknown User",
|
||||
email: "unknown@example.com",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(findPersistedOidcUser(storage)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,27 @@ export type PersistedOidcUser = {
|
||||
const OIDC_USER_KEY_PREFIX = "oidc.user:";
|
||||
const OIDC_CLIENT_ID = "devfront";
|
||||
|
||||
export function isValidOidcSessionUser(
|
||||
value: PersistedOidcUser | null | undefined,
|
||||
): value is PersistedOidcUser & {
|
||||
access_token: string;
|
||||
expires_at: number;
|
||||
profile: Record<string, unknown> & { sub: string };
|
||||
} {
|
||||
return (
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
typeof value.access_token === "string" &&
|
||||
value.access_token.trim() !== "" &&
|
||||
typeof value.expires_at === "number" &&
|
||||
value.expires_at * 1000 > Date.now() &&
|
||||
typeof value.profile === "object" &&
|
||||
value.profile !== null &&
|
||||
typeof value.profile.sub === "string" &&
|
||||
value.profile.sub.trim() !== ""
|
||||
);
|
||||
}
|
||||
|
||||
export function findPersistedOidcUser(
|
||||
storage: Storage = window.localStorage,
|
||||
): PersistedOidcUser | null {
|
||||
@@ -27,10 +48,7 @@ export function findPersistedOidcUser(
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as PersistedOidcUser;
|
||||
if (
|
||||
typeof parsed.expires_at === "number" &&
|
||||
parsed.expires_at * 1000 > Date.now()
|
||||
) {
|
||||
if (isValidOidcSessionUser(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -515,9 +515,11 @@ subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and
|
||||
description = "Manage OIDC applications, authentication methods, redirect URIs, and client secret rotation together with audit logs."
|
||||
|
||||
[msg.dev.clients.scopes]
|
||||
email = "Email"
|
||||
openid = "Openid"
|
||||
profile = "Profile"
|
||||
email = "User email information"
|
||||
openid = "Base scope required for OIDC login"
|
||||
profile = "User profile data access: name, email, phones, secondary_emails, employee_id, and status"
|
||||
tenant = "Tenant access"
|
||||
tenants = "Tenant root/parent chain and joined_tenants access"
|
||||
|
||||
[msg.dev.dashboard]
|
||||
access_denied = "The dashboard is available only to users with developer access."
|
||||
@@ -1611,6 +1613,7 @@ session_required_info = "Show SID Claim Required help"
|
||||
add = "Scope Add"
|
||||
description_placeholder = "Description Placeholder"
|
||||
name_placeholder = "e.g. profile"
|
||||
subtitle = "Review the permissions this client can request."
|
||||
title = "Scopes"
|
||||
offline_access_title = "offline_access scope is required when using refresh tokens."
|
||||
offline_access_toggle = "Show details"
|
||||
@@ -1622,7 +1625,7 @@ description = "Scope Description"
|
||||
mandatory = "Mandatory"
|
||||
name = "Scope Name"
|
||||
delete = "Delete"
|
||||
tenant = "Tenant"
|
||||
tenants = "Tenants"
|
||||
|
||||
[ui.dev.clients.general.tenant_access]
|
||||
title = "Tenant access restriction"
|
||||
@@ -1633,7 +1636,7 @@ search_placeholder = "Search by tenant name or slug"
|
||||
selected_title = "Allowed tenants"
|
||||
selected_empty = "No tenants selected yet."
|
||||
empty = "No tenants match your search."
|
||||
hint = "Turning this on adds the tenant scope automatically and requires at least one allowed tenant."
|
||||
hint = "Turning this on adds the tenants scope automatically and requires at least one allowed tenant."
|
||||
autocomplete_hint = "Type a tenant name to see autocomplete suggestions. Click one to add it to the allowed list."
|
||||
validation_required = "Select at least one allowed tenant when tenant access restriction is enabled."
|
||||
picker_title = "Select tenant"
|
||||
@@ -1646,6 +1649,7 @@ picker_hint_with_count = "{{count}} tenants selected."
|
||||
[ui.dev.clients.general.id_token_claims]
|
||||
title = "Custom Claims"
|
||||
add = "Add Claim"
|
||||
enabled = "Custom Claims Enabled"
|
||||
preview_title = "Saved JSON Preview"
|
||||
namespace_label = "Claim namespace"
|
||||
namespace_top_level = "top-level"
|
||||
@@ -1671,6 +1675,11 @@ value_type_object = "Object"
|
||||
key_placeholder = "e.g. locale"
|
||||
value_placeholder = "Enter the default value"
|
||||
|
||||
[msg.dev.clients.general.id_token_claims]
|
||||
subtitle = "User-specific claim values are edited in the Consent and Claims tabs."
|
||||
hint = "User-specific claim values are edited in the Consent and Claims tabs."
|
||||
preview_hint = "Preview the claim set that will be saved with these settings."
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
private = "Server Side App"
|
||||
pkce = "PKCE"
|
||||
@@ -1795,9 +1804,9 @@ subtitle = "Applications"
|
||||
title = "RP registry"
|
||||
|
||||
[ui.dev.clients.scopes]
|
||||
email = "Email"
|
||||
email = "User email information"
|
||||
openid = "Openid"
|
||||
profile = "Profile"
|
||||
profile = "User profile data access: name, email, phones, secondary_emails, employee_id, and status"
|
||||
|
||||
[ui.dev.clients.table]
|
||||
actions = "Actions"
|
||||
|
||||
@@ -463,8 +463,8 @@ offline_access_condition_grant_type = "client grant_types에 refresh_token 포
|
||||
[msg.dev.clients.general.id_token_claims]
|
||||
subtitle = "RP 전용 확장 claim을 구분해서 관리합니다."
|
||||
empty = "아직 추가된 ID Token claim이 없습니다."
|
||||
hint = "RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다."
|
||||
preview_hint = "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다."
|
||||
hint = "사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다."
|
||||
preview_hint = "설정 저장 시 반영될 claim 구성을 미리 볼 수 있습니다."
|
||||
key_required = "Claim key를 입력해야 합니다."
|
||||
reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
|
||||
duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}"
|
||||
@@ -515,9 +515,11 @@ subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and
|
||||
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
|
||||
|
||||
[msg.dev.clients.scopes]
|
||||
email = "이메일 주소 접근"
|
||||
openid = "OIDC 인증 필수 스코프"
|
||||
profile = "기본 프로필 정보 접근"
|
||||
email = "사용자 이메일 정보"
|
||||
openid = "OIDC 로그인에 필요한 기본 scope"
|
||||
profile = "사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근"
|
||||
tenant = "테넌트 접근"
|
||||
tenants = "소속 테넌트 정보 접근"
|
||||
|
||||
[msg.dev.dashboard]
|
||||
access_denied = "대시보드는 개발자 권한이 있어야 볼 수 있습니다."
|
||||
@@ -1610,6 +1612,7 @@ session_required_info = "SID Claim Required 설명 보기"
|
||||
add = "스코프 추가"
|
||||
description_placeholder = "권한에 대한 설명"
|
||||
name_placeholder = "e.g. profile"
|
||||
subtitle = "이 클라이언트가 요청할 수 있는 권한 범위를 정의합니다."
|
||||
title = "스코프"
|
||||
offline_access_title = "Refresh token 사용 시 offline_access scope가 필요합니다."
|
||||
offline_access_toggle = "상세 안내 보기"
|
||||
@@ -1621,7 +1624,7 @@ description = "설명"
|
||||
mandatory = "필수"
|
||||
name = "스코프 이름"
|
||||
delete = "삭제"
|
||||
tenant = "테넌트"
|
||||
tenants = "테넌트들"
|
||||
|
||||
[ui.dev.clients.general.tenant_access]
|
||||
title = "테넌트 접근 제한"
|
||||
@@ -1632,7 +1635,7 @@ search_placeholder = "테넌트 이름 또는 슬러그로 검색"
|
||||
selected_title = "허용 테넌트"
|
||||
selected_empty = "아직 선택된 테넌트가 없습니다."
|
||||
empty = "검색 결과가 없습니다."
|
||||
hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
|
||||
hint = "제한을 켜면 tenants 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
|
||||
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
|
||||
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
|
||||
picker_title = "테넌트 선택"
|
||||
@@ -1645,6 +1648,7 @@ picker_hint_with_count = "현재 {{count}}개가 선택되어 있습니다."
|
||||
[ui.dev.clients.general.id_token_claims]
|
||||
title = "커스텀 클레임"
|
||||
add = "Claim 추가"
|
||||
enabled = "커스텀 클레임 사용"
|
||||
preview_title = "저장 JSON 미리보기"
|
||||
namespace_label = "Claim 네임스페이스"
|
||||
namespace_top_level = "top-level"
|
||||
@@ -1652,10 +1656,10 @@ namespace_rp_claims = "rp_claims"
|
||||
nullable_label = "Nullable"
|
||||
read_user_allowed_label = "사용자 읽기 허용"
|
||||
write_user_allowed_label = "사용자 쓰기 허용"
|
||||
table.key = "Claim Key"
|
||||
table.namespace = "Namespace"
|
||||
table.value_type = "Value Type"
|
||||
table.nullable = "Nullable"
|
||||
table.key = "클레임 키"
|
||||
table.namespace = "네임스페이스"
|
||||
table.value_type = "값 유형"
|
||||
table.nullable = "Null 허용"
|
||||
table.read_user_allowed = "사용자 읽기"
|
||||
table.write_user_allowed = "사용자 쓰기"
|
||||
table.default_value = "기본값"
|
||||
@@ -1794,9 +1798,9 @@ subtitle = "연동 앱"
|
||||
title = "RP registry"
|
||||
|
||||
[ui.dev.clients.scopes]
|
||||
email = "이메일 주소 접근"
|
||||
email = "사용자 이메일 정보"
|
||||
openid = "OIDC 인증 필수 스코프"
|
||||
profile = "기본 프로필 정보 접근"
|
||||
profile = "사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근"
|
||||
|
||||
[ui.dev.clients.table]
|
||||
actions = "액션"
|
||||
|
||||
@@ -556,6 +556,8 @@ description = ""
|
||||
email = ""
|
||||
openid = ""
|
||||
profile = ""
|
||||
tenant = ""
|
||||
tenants = ""
|
||||
|
||||
[msg.dev.dashboard]
|
||||
access_denied = ""
|
||||
@@ -1660,6 +1662,7 @@ session_required_info = ""
|
||||
add = ""
|
||||
description_placeholder = ""
|
||||
name_placeholder = ""
|
||||
subtitle = ""
|
||||
title = ""
|
||||
offline_access_title = ""
|
||||
offline_access_toggle = ""
|
||||
@@ -1695,6 +1698,7 @@ picker_hint_with_count = ""
|
||||
[ui.dev.clients.general.id_token_claims]
|
||||
title = ""
|
||||
add = ""
|
||||
enabled = ""
|
||||
preview_title = ""
|
||||
namespace_label = ""
|
||||
namespace_top_level = ""
|
||||
@@ -1720,6 +1724,11 @@ value_type_object = ""
|
||||
key_placeholder = ""
|
||||
value_placeholder = ""
|
||||
|
||||
[msg.dev.clients.general.id_token_claims]
|
||||
subtitle = ""
|
||||
hint = ""
|
||||
preview_hint = ""
|
||||
|
||||
[ui.dev.clients.general.security]
|
||||
private = ""
|
||||
pkce = ""
|
||||
|
||||
@@ -143,6 +143,7 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
await page.goto("/clients/client-claims/settings");
|
||||
await page.getByLabel(/커스텀 클레임 사용|Custom Claims/i).click();
|
||||
await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click();
|
||||
await expect(page.getByText("rp_claims").first()).toBeVisible();
|
||||
await expect(
|
||||
|
||||
35
devfront/tests/devfront-login-guard.spec.ts
Normal file
35
devfront/tests/devfront-login-guard.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("DevFront login guard", () => {
|
||||
test("shows the login screen instead of restoring an unknown placeholder session", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem(
|
||||
"oidc.user:http://localhost:5000/oidc:devfront",
|
||||
JSON.stringify({
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
profile: {
|
||||
name: "Unknown User",
|
||||
email: "unknown@example.com",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await page.goto("/clients");
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Baron SSO" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "SSO 계정으로 로그인" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("unknown@example.com")).toHaveCount(0);
|
||||
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("login-screen.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
176
docs/backchannel-logout-guide.md
Normal file
176
docs/backchannel-logout-guide.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Back-Channel Logout 통합 가이드
|
||||
|
||||
이 문서는 Baron SSO와 연동하는 RP들이 `Back-Channel Logout`을 어떻게 처리하는지 한 곳에서 정리합니다.
|
||||
|
||||
핵심은 다음 두 가지입니다.
|
||||
|
||||
1. Baron SSO가 RP로 보내는 요청 형식은 공통입니다.
|
||||
2. RP가 그 요청을 받아 세션을 찾고 지우는 내부 로직은 RP 유형에 따라 달라질 수 있습니다.
|
||||
|
||||
## 결론
|
||||
|
||||
Baron SSO는 모든 RP에 대해 같은 방식으로 `POST /backchannel-logout`를 보냅니다.
|
||||
|
||||
- `Content-Type: application/x-www-form-urlencoded`
|
||||
- body: `logout_token=<jwt>`
|
||||
- 검증용 공개키는 `GET /api/v1/auth/backchannel/jwks.json`
|
||||
|
||||
차이는 Baron이 보낸 뒤 RP 내부에서 세션을 어디서 찾고 어떻게 파기하느냐입니다.
|
||||
|
||||
- `PKCE RP`
|
||||
- `server-side-app RP`
|
||||
- `headless login RP`는 `PKCE` 기반 custom login UI 변형
|
||||
|
||||
## 공통 시퀀스
|
||||
|
||||
아래 흐름은 세 RP에 공통입니다.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Baron as Baron SSO
|
||||
participant RP as RP
|
||||
participant JWKS as Baron Back-Channel JWKS
|
||||
participant Store as RP Session Store
|
||||
|
||||
Baron->>RP: POST /backchannel-logout\nlogout_token=<jwt>
|
||||
RP->>RP: logout_token 추출
|
||||
RP->>JWKS: JWKS로 서명 검증
|
||||
JWKS-->>RP: public key
|
||||
RP->>RP: iss / aud / events / nonce / jti 검증
|
||||
RP->>RP: sid 또는 sub로 세션 탐색
|
||||
RP->>Store: session destroy
|
||||
Store-->>RP: 삭제 완료
|
||||
RP->>RP: 세션 매핑 제거
|
||||
RP-->>Baron: 200 OK
|
||||
```
|
||||
|
||||
## 공통 전송 규칙
|
||||
|
||||
Baron SSO에서 RP로 보내는 형식은 동일합니다.
|
||||
|
||||
| 항목 | 값 |
|
||||
| --- | --- |
|
||||
| HTTP method | `POST` |
|
||||
| Path | `/backchannel-logout` |
|
||||
| Content-Type | `application/x-www-form-urlencoded` |
|
||||
| Body | `logout_token=<jwt>` |
|
||||
| 검증 JWKS | `/api/v1/auth/backchannel/jwks.json` |
|
||||
|
||||
전송 로직은 Baron 쪽에서 공통으로 처리됩니다.
|
||||
|
||||
- [`backend/internal/service/backchannel_logout_service.go`](/home/kyy/workspace/baron-sso/backend/internal/service/backchannel_logout_service.go)
|
||||
- [`backend/internal/handler/auth_handler.go`](/home/kyy/workspace/baron-sso/backend/internal/handler/auth_handler.go)
|
||||
|
||||
## RP별 차이
|
||||
|
||||
세 RP는 모두 `logout_token`을 받아 검증하고 세션을 지운다는 점은 같습니다.
|
||||
다만 세션이 만들어지는 시점과 저장 방식이 다릅니다.
|
||||
|
||||
| 항목 | PKCE RP | server-side-app RP | headless login RP |
|
||||
| --- | --- | --- | --- |
|
||||
| 로그인 성격 | Authorization Code + PKCE | confidential client | PKCE 기반 custom login UI |
|
||||
| 백채널 수신 endpoint | 필요 | 필요 | 필요 |
|
||||
| 세션 저장 구조 | 앱 서버/BFF/브라우저 연동에 따라 다양 | 서버 세션 중심 | headless 로그인 이후 로컬 세션 바인딩 |
|
||||
| `sid/sub` 매핑 | callback 이후 저장 | callback 이후 저장 | login 성공 이후 저장 |
|
||||
| 세션 파기 방식 | 매핑된 session id 삭제 | 매핑된 session id 삭제 | 매핑된 session id 삭제 |
|
||||
| 차이의 핵심 | 서버 endpoint가 없으면 처리 불가 | 서버 세션 구조와 잘 맞음 | 로그인 진입점만 다르고 로그아웃 처리는 공통 패턴 |
|
||||
|
||||
## RP별 처리 설명
|
||||
|
||||
### PKCE RP
|
||||
|
||||
PKCE RP는 브라우저 기반 로그인 흐름을 사용하지만, 백채널 로그아웃을 받으려면 **반드시 서버 endpoint**가 있어야 합니다.
|
||||
|
||||
이유는 Baron이 브라우저가 아니라 RP 서버로 직접 `POST`를 보내기 때문입니다.
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. callback 이후 `sid` 또는 `sub`를 RP 세션과 바인딩합니다.
|
||||
2. Baron이 `POST /backchannel-logout`를 보냅니다.
|
||||
3. RP가 `logout_token`을 검증합니다.
|
||||
4. `sid` 우선, 실패 시 `sub`로 세션을 찾습니다.
|
||||
5. 세션 스토어에서 해당 세션을 삭제합니다.
|
||||
|
||||
주의:
|
||||
|
||||
- 순수 frontend-only PKCE 앱은 백채널 로그아웃을 직접 받을 수 없습니다.
|
||||
- 서버나 BFF가 있어야 합니다.
|
||||
|
||||
### server-side-app RP
|
||||
|
||||
server-side-app RP는 confidential client이므로, 서버 세션 구조와 백채널 로그아웃이 자연스럽게 맞습니다.
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. OIDC Authorization Code 로그인과 callback을 처리합니다.
|
||||
2. callback 이후 `sid` 또는 `sub`를 서버 세션과 바인딩합니다.
|
||||
3. Baron이 `POST /backchannel-logout`를 보냅니다.
|
||||
4. RP가 `logout_token`을 검증합니다.
|
||||
5. 세션 매핑을 찾아 직접 파기합니다.
|
||||
|
||||
이 유형은 PKCE보다 세션 관리가 명확해서 문서화와 운영이 단순합니다.
|
||||
|
||||
### headless login RP
|
||||
|
||||
headless login은 별도의 로그아웃 타입이 아니라 PKCE 계열의 로그인 변형입니다.
|
||||
|
||||
즉, 로그인 시에는 custom login UI가 있고 RP backend가 headless login API를 호출하지만, 백채널 로그아웃은 결국 동일한 패턴으로 처리합니다.
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. headless login 성공 후 `sid` 또는 `sub`를 RP 세션에 바인딩합니다.
|
||||
2. Baron이 `POST /backchannel-logout`를 보냅니다.
|
||||
3. RP가 `logout_token`을 검증합니다.
|
||||
4. `sid` 또는 `sub`로 세션을 찾습니다.
|
||||
5. 세션 스토어에서 해당 세션을 삭제합니다.
|
||||
|
||||
핵심은 로그인 진입점만 다르고, 로그아웃 처리 패턴은 PKCE RP와 같습니다.
|
||||
|
||||
## 공통 검증 규칙
|
||||
|
||||
RP는 아래 항목을 검증해야 합니다.
|
||||
|
||||
1. JWT 서명 검증
|
||||
2. `iss`가 Baron OIDC issuer와 일치
|
||||
3. `aud`에 현재 RP `client_id` 포함
|
||||
4. `iat` 존재
|
||||
5. `jti` 존재
|
||||
6. `events`에 `http://schemas.openid.net/event/backchannel-logout` 포함
|
||||
7. `nonce`가 없어야 함
|
||||
8. `sid` 또는 `sub`가 있어야 함
|
||||
|
||||
권장 사항:
|
||||
|
||||
- `jti` replay 방지
|
||||
- 시계 오차 허용
|
||||
- 검증 실패 시 `400`
|
||||
|
||||
## 세션 파기 규칙
|
||||
|
||||
`Back-Channel Logout`은 현재 브라우저 요청의 `req.session.destroy()`만으로는 부족합니다.
|
||||
|
||||
반드시 세션 저장소에서 실제 세션 id를 찾아 직접 파기해야 합니다.
|
||||
|
||||
권장 우선순위:
|
||||
|
||||
1. `sid`로 탐색
|
||||
2. `sid`가 없거나 매칭 실패 시 `sub`로 fallback
|
||||
|
||||
## 공통 테스트 포인트
|
||||
|
||||
1. RP 로그인 후 `sid/sub -> sessionId` 매핑이 생성되는지 확인
|
||||
2. Baron이 `POST /backchannel-logout`를 실제로 보내는지 확인
|
||||
3. RP가 `logout_token`을 검증하는지 확인
|
||||
4. 세션 스토어에서 세션이 삭제되는지 확인
|
||||
5. 동일한 `logout_token` 재전송 시 replay 방지가 동작하는지 확인
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [`docs/pkce-backchannel-logout-guide.md`](/home/kyy/workspace/baron-sso/docs/pkce-backchannel-logout-guide.md)
|
||||
- [`docs/server-side-app-backchannel-logout-guide.md`](/home/kyy/workspace/baron-sso/docs/server-side-app-backchannel-logout-guide.md)
|
||||
|
||||
## 참고 구현
|
||||
|
||||
- [`backend/internal/service/backchannel_logout_service.go`](/home/kyy/workspace/baron-sso/backend/internal/service/backchannel_logout_service.go)
|
||||
- [`backend/internal/handler/auth_handler.go`](/home/kyy/workspace/baron-sso/backend/internal/handler/auth_handler.go)
|
||||
@@ -0,0 +1,225 @@
|
||||
# Production Personnel Dataset Backup and Staging Restore Design
|
||||
|
||||
## Estimate Time
|
||||
|
||||
Estimate Time: 2.5d
|
||||
|
||||
## 목적
|
||||
|
||||
프로덕션의 "인력정보" 관련 데이터만 주기적으로 백업하고, 스테이징에 복구해 운영 데이터에 가까운 검증 환경을 구축한다. 이 기능은 재해 복구용 full backup이 아니라 staging rehearsal용 논리 데이터셋 이관 기능이다.
|
||||
|
||||
기존 `make dump`/`make restore`는 저장소 단위 full backup을 목표로 한다. 이번 기능은 Hydra와 RP 정보를 제외해야 하므로 저장소 단위 필터인 `DUMP_SERVICES=postgres,ory-postgres`만으로는 충분하지 않다. 별도의 dataset profile을 두어 테이블/행/민감값을 명시적으로 제한해야 한다.
|
||||
|
||||
## 정책 전제
|
||||
|
||||
- Ory Stack은 identity, authorization, OAuth/OIDC 상태의 SoT다.
|
||||
- Backend DB의 `users`는 Ory에 저장되지 않거나 Ory API만으로 조회하기 어려운 Baron 운영 read model이다.
|
||||
- Hydra client, consent, token/session state와 Baron RP metadata는 이번 데이터셋 대상이 아니다.
|
||||
- staging restore는 운영 복구가 아니므로 production credential/session을 그대로 들고 오지 않는다.
|
||||
- Wiki는 사용 중이지만, 정책 업데이트 초안은 `docs/` 문서로 남기고 사람이 검토 후 Wiki에 반영한다.
|
||||
|
||||
## 제안 인터페이스
|
||||
|
||||
기존 백업 명령을 유지하면서 dataset profile을 추가한다.
|
||||
|
||||
```bash
|
||||
make dump DUMP_SERVICES=postgres,ory-postgres DUMP_DATASET=personnel DUMP_MODE=maintenance
|
||||
make restore RESTORE_SERVICES=postgres,ory-postgres RESTORE_DATASET=personnel CONFIRM_RESTORE=baron-sso
|
||||
make restore-plan RESTORE_DATASET=personnel BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
```
|
||||
|
||||
운영 자동화에서는 production에서 full backup만 만들고, staging에서 받은 full backup을 personnel dataset으로 필터링한 뒤 복구한다.
|
||||
|
||||
```bash
|
||||
# Production
|
||||
make dump DUMP_SERVICES=all DUMP_MODE=maintenance
|
||||
|
||||
# Staging
|
||||
make filter-personnel-dump \
|
||||
BACKUP=backups/prod-full-backup-YYYYMMDD-HHMMSSZ \
|
||||
OUTPUT_BACKUP=backups/prod-personnel-filtered-YYYYMMDD-HHMMSSZ
|
||||
|
||||
make restore \
|
||||
BACKUP=backups/prod-personnel-filtered-YYYYMMDD-HHMMSSZ \
|
||||
RESTORE_DATASET=personnel \
|
||||
RESTORE_SERVICES=postgres \
|
||||
CONFIRM_RESTORE=baron-sso
|
||||
```
|
||||
|
||||
`filter-personnel-dump`는 full backup의 `baron.dump`, `ory_kratos.dump`, `ory_keto.dump`를 staging scratch DB에 일시 복원한 뒤 `personnel` JSONL dataset을 생성한다. Hydra dump와 RP metadata는 filtered backup에 복사하지 않는다.
|
||||
|
||||
추가 환경 변수:
|
||||
|
||||
| 변수 | 기본값 | 의미 |
|
||||
| --- | --- | --- |
|
||||
| `DUMP_DATASET` | `full` | `full`은 기존 동작, `personnel`은 인력정보 논리 데이터셋 |
|
||||
| `RESTORE_DATASET` | manifest 기준 | 복구할 dataset profile |
|
||||
| `FILTER_SERVICES` | `postgres,ory-postgres` | full backup에서 personnel dataset으로 필터링할 서비스 범위 |
|
||||
| `OUTPUT_BACKUP` | empty | `filter-personnel-dump`의 personnel dataset 출력 경로 |
|
||||
| `PERSONNEL_TENANT_ROOT_SLUGS` | empty | 비어 있으면 전체 인력정보, 값이 있으면 지정 tenant root 하위만 |
|
||||
| `PERSONNEL_INCLUDE_KRATOS_IDENTITIES` | `true` | staging 로그인/subject 일치 검증이 필요할 때 Kratos identity subset 포함 |
|
||||
| `PERSONNEL_RESET_CREDENTIALS` | `true` | Kratos credential/session을 production 그대로 복구하지 않도록 강제 |
|
||||
| `PERSONNEL_INCLUDE_WORKSMOBILE_MAPPING` | `true` | WORKS externalKey 비교와 조직도 검증용 mapping 포함 |
|
||||
| `PERSONNEL_INCLUDE_OUTBOX` | `false` | queue state는 기본 제외, 장애 재현 시에만 별도 허용 |
|
||||
|
||||
`DUMP_DATASET=personnel`은 `DUMP_SERVICES=all`에서도 Hydra DB와 RP metadata를 포함하지 않도록 내부적으로 차단한다. 사용자가 `DUMP_DATASET=personnel DUMP_SERVICES=ory-postgres`를 지정해도 `ory_hydra` dump는 생성하지 않는다.
|
||||
|
||||
## 백업 산출물 구조
|
||||
|
||||
```text
|
||||
baron-sso-backup-YYYYMMDD-HHMMSSZ/
|
||||
manifest.json
|
||||
checksums.sha256
|
||||
datasets/
|
||||
personnel/
|
||||
dataset-manifest.json
|
||||
postgres/
|
||||
users.jsonl
|
||||
user_login_ids.jsonl
|
||||
tenants.jsonl
|
||||
tenant_domains.jsonl
|
||||
user_groups.jsonl
|
||||
worksmobile_resource_mappings.jsonl
|
||||
ory_kratos/
|
||||
identities.jsonl
|
||||
identity_credentials.reset-plan.jsonl
|
||||
ory_keto/
|
||||
relation_tuples.jsonl
|
||||
reports/
|
||||
row-counts.json
|
||||
exclusions.json
|
||||
restore-plan.md
|
||||
```
|
||||
|
||||
`personnel` dataset은 `pg_dump -Fc`만으로 만들지 않는다. 행 필터, 민감값 제거, RP/Hydra 제외를 안전하게 보장하려면 `COPY (SELECT ...) TO STDOUT` 또는 `psql` JSONL export를 사용한다. full backup은 기존 `postgres/baron.dump`, `postgres/ory_*.dump` 형식을 그대로 유지한다.
|
||||
|
||||
## 포함 범위
|
||||
|
||||
### Baron Postgres 포함
|
||||
|
||||
| 테이블/데이터 | 포함 이유 | 처리 |
|
||||
| --- | --- | --- |
|
||||
| `users` | 인력 기본 정보, 상태, 조직 표시 read model | 포함. `relying_party_id`는 null 처리 또는 제외 검증 |
|
||||
| `user_login_ids` | 사번/로그인 ID 등 인력 식별자 | 포함 |
|
||||
| `tenants` | 회사/조직/사용자 그룹 계층 | `COMPANY_GROUP`, `COMPANY`, `ORGANIZATION`, `USER_GROUP` 중심 포함 |
|
||||
| `tenant_domains` | 회사 도메인 기반 소속 판단 | 포함 |
|
||||
| `user_groups` | 조직도/부서 계층 | 포함 |
|
||||
| `worksmobile_resource_mappings` | WORKS externalKey 기반 비교/동기화 기준 | `USER`, `ORGUNIT`만 포함 |
|
||||
|
||||
### Ory Kratos 포함
|
||||
|
||||
`PERSONNEL_INCLUDE_KRATOS_IDENTITIES=true`일 때만 포함한다.
|
||||
|
||||
- `identities`: subject UUID와 traits 기반 식별을 위해 포함한다.
|
||||
- credential/session/recovery/verifiable address는 production 값을 그대로 복구하지 않는다.
|
||||
- restore 단계에서 staging용 임시 credential 정책 또는 password reset-required 상태를 만든다.
|
||||
- Kratos DB 직접 조작은 일반 write path가 아니므로 maintenance guard와 별도 확인값을 요구한다.
|
||||
|
||||
### Ory Keto 포함
|
||||
|
||||
- 사용자, tenant, user group membership/ownership 관계 tuple만 포함한다.
|
||||
- RP namespace 또는 RP object를 참조하는 tuple은 제외한다.
|
||||
- restore 후 Keto relation tuple subject/object가 복구된 user/tenant/group만 참조하는지 검증한다.
|
||||
|
||||
## 제외 범위
|
||||
|
||||
| 제외 대상 | 이유 |
|
||||
| --- | --- |
|
||||
| Hydra DB 전체 | OAuth2 client, consent, token/session은 staging RP 상태를 오염시킬 수 있음 |
|
||||
| Baron RP metadata | 사용자가 명시한 비대상이며 staging RP 설정은 별도 관리 대상 |
|
||||
| `rp_user_metadata` | RP별 custom claim 데이터라 personnel 공통 데이터가 아님 |
|
||||
| `client_consents` | Hydra/RP consent read model 성격 |
|
||||
| API key/client secret류 | staging secret과 충돌 위험 |
|
||||
| audit/clickhouse logs | 인력정보 환경 구축의 필수 원장이 아님 |
|
||||
| `worksmobile_outbox` | 큐 처리 상태라 반복 restore 시 중복 작업 위험 |
|
||||
| Redis | 휘발성 cache/session |
|
||||
|
||||
## Restore 전략
|
||||
|
||||
staging restore는 DB 전체 drop/restore가 아니라 scoped replace 방식으로 설계한다.
|
||||
|
||||
1. restore 전 `restore-plan`을 생성해 포함/제외 테이블, row count, tenant scope, Hydra/RP exclusion을 표시한다.
|
||||
2. backend worker, WORKS relay, Keto outbox relay를 중지한다.
|
||||
3. dataset을 staging scratch schema 또는 임시 DB에 적재한다.
|
||||
4. row count, foreign key, soft-delete, tenant hierarchy, user-login collision을 검증한다.
|
||||
5. staging의 Hydra/RP 관련 테이블과 설정은 건드리지 않는다.
|
||||
6. Baron personnel tables를 dependency order에 따라 replace/upsert한다.
|
||||
7. Kratos identity subset을 포함한 경우 production credential/session을 제거하고 staging credential policy를 적용한다.
|
||||
8. Keto personnel tuple만 replace/upsert하고 RP tuple은 보존한다.
|
||||
9. `restore-verify`에서 다음 항목을 검증한다.
|
||||
|
||||
검증 항목:
|
||||
|
||||
- `users.id`와 Kratos `identities.id` 일치 여부
|
||||
- `user_login_ids.user_id`, `tenant_id` 참조 무결성
|
||||
- `tenants.parent_id`, `user_groups.parent_id` 계층 무결성
|
||||
- `users.relying_party_id`가 남아 있지 않은지
|
||||
- `rp_user_metadata`, Hydra dump 파일, Hydra restore step이 생성되지 않았는지
|
||||
- Keto tuple이 복구 대상 user/tenant/group만 참조하는지
|
||||
- WORKS mapping의 Baron resource 참조가 존재하는지
|
||||
|
||||
## 구현 위치
|
||||
|
||||
제안 파일:
|
||||
|
||||
- `scripts/backup/lib/dataset.sh`: dataset profile validation과 공통 manifest helper
|
||||
- `scripts/backup/lib/personnel_dataset.sh`: personnel export/import SQL, exclusion guard
|
||||
- `scripts/backup/dump.sh`: `DUMP_DATASET` 분기 추가
|
||||
- `scripts/backup/restore.sh`: `RESTORE_DATASET` 분기와 scoped restore 추가
|
||||
- `scripts/backup/restore-plan.sh`: dataset restore plan 출력
|
||||
- `scripts/backup/lib/report.sh`: dataset row-count/exclusion report 표시
|
||||
- `Makefile`: `DUMP_DATASET`, `RESTORE_DATASET`, `PERSONNEL_*` 변수 전달
|
||||
|
||||
## 테스트 계획
|
||||
|
||||
구현 전 RED 테스트를 먼저 추가한다.
|
||||
|
||||
1. `test/personnel_dataset_backup_policy_test.sh`
|
||||
- `DUMP_DATASET=personnel` dry-run 또는 fixture run에서 Hydra dump가 계획되지 않는지 확인한다.
|
||||
- `rp_user_metadata`, `client_consents`, `relying_parties` 계열 데이터가 dataset manifest에 포함되지 않는지 확인한다.
|
||||
- unknown dataset profile을 거부하는지 확인한다.
|
||||
|
||||
2. `test/personnel_dataset_restore_policy_test.sh`
|
||||
- `RESTORE_DATASET=personnel`이 `CONFIRM_RESTORE=baron-sso` 없이 실패하는지 확인한다.
|
||||
- restore-plan에 포함/제외 테이블과 credential reset policy가 표시되는지 확인한다.
|
||||
- non-empty target guard와 scoped restore guard가 동시에 동작하는지 확인한다.
|
||||
|
||||
3. `scripts/backup/lib/personnel_dataset.sh` 단위 shell test
|
||||
- tenant root scope가 있을 때 users/tenants/user_groups 쿼리가 같은 scope로 제한되는지 확인한다.
|
||||
- `PERSONNEL_INCLUDE_OUTBOX=false` 기본값에서 outbox가 제외되는지 확인한다.
|
||||
|
||||
4. 통합 테스트
|
||||
- fixture Postgres에 users/tenants/RP/Hydra 유사 데이터를 넣는다.
|
||||
- personnel dataset dump를 실행한다.
|
||||
- 빈 staging fixture DB에 restore한다.
|
||||
- 인력정보는 들어오고 Hydra/RP/consent/custom claim 데이터는 남지 않는지 검증한다.
|
||||
|
||||
5. E2E 또는 smoke
|
||||
- staging restore 후 AdminFront/User list와 OrgFront 조직도 조회가 정상인지 확인한다.
|
||||
- 화면 변화가 있는 기능은 아니므로 스냅샷 업로드는 구현 범위에서 제외한다. 단, 복구 후 조직도 화면 검증이 필요하면 별도 E2E 이슈로 분리한다.
|
||||
|
||||
## 위험과 결정 필요 사항
|
||||
|
||||
1. Kratos identity 포함 여부
|
||||
- 포함하면 staging에서 production과 같은 subject UUID로 검증할 수 있다.
|
||||
- 대신 credential/session 민감값 제거가 필수이고, Kratos DB maintenance 예외 정책을 문서화해야 한다.
|
||||
- 제외하면 인력/조직 화면은 검증 가능하지만 실제 로그인 subject 일치 검증은 제한된다.
|
||||
|
||||
2. restore 방식
|
||||
- table replace는 단순하지만 staging 고유 사용자와 충돌할 수 있다.
|
||||
- upsert는 staging 고유 데이터 보존에 유리하지만 삭제/퇴사 반영 정책이 복잡해진다.
|
||||
- 기본안은 `PERSONNEL_RESTORE_MODE=replace-scoped`로 두고, staging 고유 tenant slug allowlist는 보호한다.
|
||||
|
||||
3. WORKS mapping 포함 여부
|
||||
- externalKey 비교를 위해 포함하는 것이 좋다.
|
||||
- outbox는 중복 실행 위험이 있어 기본 제외가 맞다.
|
||||
|
||||
## 구현 순서
|
||||
|
||||
1. Gitea 이슈에 본 설계와 테스트 RED 계획을 등록한다.
|
||||
2. RED 테스트를 먼저 추가하고 실패를 확인한다.
|
||||
3. dataset profile validation과 manifest/exclusion guard를 구현한다.
|
||||
4. Baron Postgres personnel export/import를 구현한다.
|
||||
5. Kratos/Keto subset 처리는 guard와 reset policy를 먼저 구현한 뒤 활성화한다.
|
||||
6. `make restore-plan`, report, verification을 보강한다.
|
||||
7. 테스트 통과 후 문서와 Wiki 반영 필요 여부를 검토한다.
|
||||
@@ -1,321 +0,0 @@
|
||||
# PKCE RP Back-Channel Logout 구현 가이드
|
||||
|
||||
이 문서는 Baron SSO와 연동하는 PKCE RP가 `Back-Channel Logout`을 지원하려고 할 때 필요한 구현 기준을 정리합니다.
|
||||
|
||||
## 목적
|
||||
|
||||
PKCE RP도 OIDC `Authorization Code + PKCE` 흐름을 사용하면서 Baron SSO의 원격 세션 종료 이벤트를 받을 수 있어야 합니다. 다만 `Back-Channel Logout`은 브라우저가 아니라 OP(Baron)가 RP 서버로 직접 `logout_token`을 보내는 방식이므로, **순수 frontend-only PKCE 앱만으로는 구현할 수 없습니다.**
|
||||
|
||||
즉, PKCE RP가 `Back-Channel Logout`을 사용하려면 다음 둘을 모두 가져야 합니다.
|
||||
|
||||
1. PKCE 로그인 플로우를 시작하고 callback을 처리하는 RP
|
||||
2. `logout_token`을 수신하는 서버 endpoint
|
||||
|
||||
## 적용 대상
|
||||
|
||||
이 가이드는 다음 경우를 대상으로 합니다.
|
||||
|
||||
- 브라우저에서 `Authorization Code + PKCE`를 사용하는 RP
|
||||
- RP가 자체 세션 또는 BFF 세션을 보유하는 경우
|
||||
- RP가 `Back-Channel Logout URI`를 등록하고 Baron의 세션 종료 이벤트를 직접 수신하려는 경우
|
||||
|
||||
다음 경우는 이 가이드의 직접 대상이 아닙니다.
|
||||
|
||||
- 순수 frontend-only SPA
|
||||
- 서버 없이 `localStorage`/`sessionStorage`만 사용하는 PKCE 앱
|
||||
|
||||
이 경우에는 `Back-Channel Logout` 대신 front-channel logout, 세션 재검증, 짧은 token TTL 같은 별도 전략을 사용해야 합니다.
|
||||
|
||||
## devfront 등록 기준
|
||||
|
||||
PKCE RP는 devfront에서 아래 항목을 등록합니다.
|
||||
|
||||
1. `Type`: `pkce`
|
||||
2. `Redirect URI`: RP callback URL
|
||||
3. `Back-Channel Logout URI`: RP 서버 endpoint
|
||||
4. 필요 시 `SID Claim Required`
|
||||
|
||||
예시:
|
||||
|
||||
```text
|
||||
Type: pkce
|
||||
Redirect URI: https://rp.example.com/callback
|
||||
Back-Channel Logout URI: https://rp.example.com/backchannel-logout
|
||||
SID Claim Required: off
|
||||
```
|
||||
|
||||
로컬 Docker 개발 예시:
|
||||
|
||||
```text
|
||||
Redirect URI: http://localhost:3333/callback
|
||||
Back-Channel Logout URI: http://baron-sso-login-demo:3333/backchannel-logout
|
||||
```
|
||||
|
||||
주의:
|
||||
|
||||
- `Back-Channel Logout URI`는 **브라우저 기준 주소가 아니라 Baron backend가 실제로 접근 가능한 주소**여야 합니다.
|
||||
- Docker 환경에서는 `localhost`가 backend 컨테이너 자신을 가리킬 수 있으므로, Docker 서비스명이나 사설 IP를 사용해야 할 수 있습니다.
|
||||
|
||||
## 구현 요구사항
|
||||
|
||||
PKCE RP는 최소한 아래를 구현해야 합니다.
|
||||
|
||||
### 1. 로그인 후 세션 매핑 저장
|
||||
|
||||
RP는 callback 이후 아래 정보 중 하나 이상을 로컬 세션과 연결해야 합니다.
|
||||
|
||||
- `sid -> rpSessionId`
|
||||
- `sub -> rpSessionId`
|
||||
|
||||
권장 순서는 다음과 같습니다.
|
||||
|
||||
1. `sid`를 우선 저장
|
||||
2. `sub`도 함께 저장
|
||||
3. 한 사용자가 여러 브라우저 세션을 가질 수 있으므로 `1:N` 구조를 가정
|
||||
|
||||
예시:
|
||||
|
||||
```text
|
||||
sid: 796f5cf7-37e7-494b-9b4c-26cc0c217a6a
|
||||
sub: 8150cb83-a905-4b50-bdcf-d22046ecdc30
|
||||
rpSessionId: DqKlQ8MbsGnn_jfOus1k03MFRDpuXCrj
|
||||
```
|
||||
|
||||
### 2. `POST /backchannel-logout` endpoint
|
||||
|
||||
RP는 Baron이 서버 간으로 호출할 endpoint를 제공해야 합니다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
POST /backchannel-logout
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Body: logout_token=<jwt>
|
||||
```
|
||||
|
||||
RP는 이 endpoint에서:
|
||||
|
||||
1. `logout_token` 존재 여부 확인
|
||||
2. JWT 서명 및 claim 검증
|
||||
3. `sid` 또는 `sub`로 로컬 세션 탐색
|
||||
4. 세션 스토어에서 직접 세션 파기
|
||||
5. 성공 시 `2xx` 응답
|
||||
|
||||
을 수행해야 합니다.
|
||||
|
||||
### 3. `logout_token` 검증
|
||||
|
||||
RP는 Baron이 노출하는 Back-Channel Logout JWKS로 `logout_token`을 검증해야 합니다.
|
||||
|
||||
현재 Baron의 JWKS endpoint 예시는 다음과 같습니다.
|
||||
|
||||
```text
|
||||
GET /api/v1/auth/backchannel/jwks.json
|
||||
```
|
||||
|
||||
검증 필수 항목:
|
||||
|
||||
1. JWT 서명 검증
|
||||
2. `iss`가 Baron OIDC issuer와 일치
|
||||
3. `aud`에 현재 RP `client_id` 포함
|
||||
4. `iat` 존재
|
||||
5. `jti` 존재
|
||||
6. `events`에 `http://schemas.openid.net/event/backchannel-logout` 포함
|
||||
7. `nonce`가 없어야 함
|
||||
8. `sid` 또는 `sub`가 있어야 함
|
||||
|
||||
추가 권장 항목:
|
||||
|
||||
- `jti` replay 방지 캐시
|
||||
- 시계 오차 허용 범위 설정
|
||||
- 검증 실패 시 `400`
|
||||
|
||||
## 세션 종료 기준
|
||||
|
||||
### 권장 순서
|
||||
|
||||
1. `sid`로 매칭 시도
|
||||
2. 매칭 실패 시 `sub`로 fallback
|
||||
|
||||
이 기준은 `SID Claim Required` 정책에 따라 달라집니다.
|
||||
|
||||
### `SID Claim Required = true`
|
||||
|
||||
- `logout_token`에 `sid`가 있어야만 처리
|
||||
- `sub` fallback 금지
|
||||
- 세션 모델이 `sid` 중심으로 안정적으로 유지되는 RP에 적합
|
||||
|
||||
### `SID Claim Required = false`
|
||||
|
||||
- `sid`가 있으면 우선 사용
|
||||
- `sid` 매칭이 안 되거나 `sid`가 없어도 `sub`로 fallback 가능
|
||||
- 실제 운영에서는 이 모드가 더 현실적일 수 있음
|
||||
|
||||
## 세션 파기 방식
|
||||
|
||||
`Back-Channel Logout`에서는 현재 브라우저 요청의 `req.session.destroy()`로는 부족합니다.
|
||||
반드시 **세션 스토어에서 session id를 찾아 직접 파기**해야 합니다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
store.destroy(rpSessionId)
|
||||
```
|
||||
|
||||
필수 조건:
|
||||
|
||||
- 로그아웃 대상 세션 ID를 매핑 테이블에서 찾을 수 있어야 함
|
||||
- 이미 삭제된 세션은 idempotent success 처리
|
||||
|
||||
## 권장 로그 항목
|
||||
|
||||
RP는 아래 정도의 로그를 남기는 것을 권장합니다.
|
||||
|
||||
1. 요청 수신
|
||||
2. 토큰 검증 성공/실패
|
||||
3. `sid`, `sub`, `jti`
|
||||
4. 매칭된 `rpSessionId` 목록
|
||||
5. 세션 파기 성공/실패 수
|
||||
|
||||
예시:
|
||||
|
||||
```text
|
||||
[백채널 로그아웃] 요청 수신
|
||||
[백채널 로그아웃] 토큰 검증 성공
|
||||
[백채널 로그아웃] 세션 탐색 결과
|
||||
[백채널 로그아웃] 세션 파기 완료
|
||||
[백채널 로그아웃] 처리 완료
|
||||
```
|
||||
|
||||
주의:
|
||||
|
||||
- raw `logout_token` 전체를 로그에 남기지 않습니다.
|
||||
- access token, refresh token, cookie raw value도 남기지 않습니다.
|
||||
|
||||
## 테스트 체크리스트
|
||||
|
||||
### 기본 성공 시나리오
|
||||
|
||||
1. PKCE RP 로그인
|
||||
2. callback 후 `sid/sub -> rpSessionId` 매핑 생성 확인
|
||||
3. UserFront에서 `세션 종료`
|
||||
4. Baron이 RP의 `Back-Channel Logout URI`로 POST
|
||||
5. RP가 `logout_token` 검증 성공
|
||||
6. RP 세션 파기 성공
|
||||
7. 보호 페이지 접근 시 비로그인 상태 확인
|
||||
|
||||
### 확인 포인트
|
||||
|
||||
1. devfront에 `Back-Channel Logout URI`가 실제 저장됐는가
|
||||
2. Baron backend가 해당 URI에 실제로 도달 가능한가
|
||||
3. RP 로그에 `요청 수신`과 `토큰 검증 성공`이 찍히는가
|
||||
4. 세션 스토어에서 실제 세션이 삭제됐는가
|
||||
5. `SID Claim Required=true`일 때와 `false`일 때 결과가 의도대로 다른가
|
||||
|
||||
## 구현 예시 구조
|
||||
|
||||
Node.js/Express 기준 최소 구조 예시는 다음과 같습니다.
|
||||
|
||||
```text
|
||||
GET /login
|
||||
GET /callback
|
||||
GET /profile
|
||||
GET /logout
|
||||
POST /backchannel-logout
|
||||
```
|
||||
|
||||
내부 저장 예시:
|
||||
|
||||
```text
|
||||
sidToSessionIds: Map<string, Set<string>>
|
||||
subToSessionIds: Map<string, Set<string>>
|
||||
sessionIdToBinding: Map<string, { sid: string, sub: string }>
|
||||
```
|
||||
|
||||
실제 분리 예시는 아래 데모 코드를 참고할 수 있습니다.
|
||||
|
||||
- 백채널 로그아웃 모듈: `https://gitea.hmac.kr/kyy/pkce-login-demo/src/branch/main/backchannel-logout.js`
|
||||
- 데모 앱 엔트리포인트: `https://gitea.hmac.kr/kyy/pkce-login-demo/src/branch/main/app.js`
|
||||
|
||||
이 데모는:
|
||||
|
||||
1. callback 이후 `registerSessionBinding()`으로 `sid/sub -> sessionId`를 등록
|
||||
2. `POST /backchannel-logout`에서 `handleBackchannelLogout`를 그대로 연결
|
||||
3. 로컬 `/logout` 또는 세션 정리 시 `removeSessionBinding()` 호출
|
||||
|
||||
구조로 동작합니다.
|
||||
|
||||
## 자주 생기는 문제
|
||||
|
||||
### 1. `localhost`로는 안 되는데 입력은 저장됨
|
||||
|
||||
입력 validation을 통과하는 것과 Baron backend가 실제로 그 주소에 도달하는 것은 다릅니다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
http://localhost:3333/backchannel-logout
|
||||
```
|
||||
|
||||
이 값은 backend 컨테이너 기준으로는 자기 자신을 가리킬 수 있습니다. Docker 환경에서는 Docker 서비스명 또는 사설 IP를 사용해야 할 수 있습니다.
|
||||
|
||||
### 2. `sid`가 로그인 시 값과 다름
|
||||
|
||||
실제 운영에서는 `logout_token.sid`가 RP가 저장한 `sid`와 항상 같다고 가정하면 안 됩니다.
|
||||
|
||||
따라서:
|
||||
|
||||
1. `sid` 우선
|
||||
2. `sub` fallback
|
||||
|
||||
구현을 권장합니다. 다만 보안 정책상 `SID Claim Required=true`를 선택한 경우에는 fallback 없이 `sid`만 사용해야 합니다.
|
||||
|
||||
### 3. 순수 frontend-only PKCE인데 endpoint를 만들 수 없음
|
||||
|
||||
그 경우는 `Back-Channel Logout` 자체를 구현할 수 없습니다. 최소한 logout 수신용 서버 컴포넌트를 추가해야 합니다.
|
||||
|
||||
## 로직 흐름
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Browser as 브라우저
|
||||
participant RP as PKCE RP
|
||||
participant Baron as Baron SSO
|
||||
participant Store as 세션 스토어
|
||||
|
||||
Browser->>RP: GET /login 호출
|
||||
RP->>Browser: Baron authorize endpoint로 리다이렉트
|
||||
Browser->>Baron: Authorization Code + PKCE 로그인
|
||||
Baron->>Browser: /callback?code=... 으로 리다이렉트
|
||||
Browser->>RP: GET /callback 호출
|
||||
RP->>Baron: code_verifier 포함 token 요청
|
||||
Baron-->>RP: ID Token / Access Token 반환
|
||||
RP->>Store: RP 세션 생성
|
||||
RP->>RP: registerSessionBinding(sessionId, sid, sub)
|
||||
RP-->>Browser: 로그인 완료 응답
|
||||
|
||||
Browser->>Baron: UserFront 또는 연동 서비스에서 세션 종료
|
||||
Baron->>RP: POST /backchannel-logout (logout_token)
|
||||
RP->>Baron: Back-Channel JWKS로 logout_token 검증
|
||||
Baron-->>RP: 서명 / issuer / audience 검증 기준 제공
|
||||
RP->>RP: sid 또는 sub로 sessionId 탐색
|
||||
RP->>Store: destroy(sessionId)
|
||||
RP->>RP: removeSessionBinding(sessionId)
|
||||
RP-->>Baron: 200 OK
|
||||
|
||||
Browser->>RP: GET /profile 호출
|
||||
RP-->>Browser: 루트 리다이렉트 또는 비로그인 응답
|
||||
```
|
||||
|
||||
## 권장 결론
|
||||
|
||||
PKCE RP에서 `Back-Channel Logout`을 쓰려면, 다음 원칙을 따르십시오.
|
||||
|
||||
1. PKCE 로그인 플로우는 그대로 유지
|
||||
2. logout 수신용 서버 endpoint 별도 구현
|
||||
3. `sid`와 `sub`를 모두 저장
|
||||
4. 세션 스토어에서 직접 세션 파기
|
||||
5. 로컬 개발 시 Baron backend가 도달 가능한 URI를 사용
|
||||
|
||||
이 다섯 가지가 갖춰져야 Baron의 원격 세션 종료가 RP 로컬 세션 종료까지 이어집니다.
|
||||
@@ -238,12 +238,17 @@ Baron은 기본적으로 대표소속 tenant와 전체 소속 tenant 목록을
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"emails": [
|
||||
"hanmac-user@example.com"
|
||||
"email": "hanmac-user@example.com",
|
||||
"secondary_emails": [
|
||||
"alias1@hanmaceng.co.kr",
|
||||
"alias2@hanmaceng.co.kr"
|
||||
],
|
||||
"names": {
|
||||
"name": "한맥 사용자"
|
||||
}
|
||||
"phones": [
|
||||
"+821012345678"
|
||||
],
|
||||
"name": "한맥 사용자",
|
||||
"employee_id": "EMP-001",
|
||||
"status": "temporary_leave"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
# Server-Side App RP Back-Channel Logout 구현 가이드
|
||||
|
||||
이 문서는 Baron SSO와 연동하는 `server-side-app` RP가 `Back-Channel Logout`을 지원하려고 할 때 필요한 구현 기준을 정리합니다.
|
||||
|
||||
## 목적
|
||||
|
||||
`server-side-app` RP는 confidential client로 동작하면서, Baron SSO의 원격 세션 종료 이벤트를 받아 RP 로컬 세션을 즉시 정리할 수 있어야 합니다.
|
||||
|
||||
즉, `server-side-app` RP는 다음 둘을 모두 구현해야 합니다.
|
||||
|
||||
1. OIDC Authorization Code 로그인과 callback 처리
|
||||
2. `logout_token`을 수신하는 `Back-Channel Logout URI`
|
||||
|
||||
## 적용 대상
|
||||
|
||||
이 가이드는 다음 경우를 대상으로 합니다.
|
||||
|
||||
- `server-side-app` 타입 RP
|
||||
- confidential client
|
||||
- `client_secret_basic` 또는 `client_secret_post`를 사용하는 RP
|
||||
- 자체 서버 세션 또는 BFF 세션을 보유하는 RP
|
||||
|
||||
다음 경우는 이 가이드의 직접 대상이 아닙니다.
|
||||
|
||||
- 순수 frontend-only SPA
|
||||
- public client 기반 PKCE 앱
|
||||
|
||||
## devfront 등록 기준
|
||||
|
||||
`server-side-app` RP는 devfront에서 아래 항목을 등록합니다.
|
||||
|
||||
1. `Type`: `server-side-app`
|
||||
2. `Redirect URI`: RP callback URL
|
||||
3. `Back-Channel Logout URI`: RP 서버 endpoint
|
||||
4. 필요 시 `SID Claim Required`
|
||||
|
||||
예시:
|
||||
|
||||
```text
|
||||
Type: server-side-app
|
||||
Redirect URI: http://localhost:4444/callback
|
||||
Back-Channel Logout URI: http://172.16.9.208:4444/backchannel-logout
|
||||
SID Claim Required: off
|
||||
```
|
||||
|
||||
주의:
|
||||
- `Back-Channel Logout URI`는 **브라우저 기준 주소가 아니라 Baron backend가 실제로 접근 가능한 주소**여야 합니다.
|
||||
- Docker 환경에서는 `localhost`가 backend 컨테이너 자신을 가리킬 수 있으므로, 필요하면 사설 IP 또는 Docker 서비스명을 사용해야 합니다.
|
||||
|
||||
## 구현 요구사항
|
||||
|
||||
`server-side-app` RP는 최소한 아래를 구현해야 합니다.
|
||||
|
||||
### 1. confidential client 구성
|
||||
|
||||
RP는 일반적으로 아래 중 하나의 인증 방식을 사용합니다.
|
||||
|
||||
1. `client_secret_basic`
|
||||
2. `client_secret_post`
|
||||
|
||||
즉 token 교환 시:
|
||||
|
||||
- `client_id`
|
||||
- `client_secret`
|
||||
|
||||
가 함께 사용됩니다.
|
||||
|
||||
PKCE와 달리 `code_verifier`, `code_challenge`는 필수가 아닙니다.
|
||||
|
||||
### 2. 로그인 후 세션 매핑 저장
|
||||
|
||||
RP는 callback 이후 아래 정보 중 하나 이상을 로컬 세션과 연결해야 합니다.
|
||||
|
||||
- `sid -> rpSessionId`
|
||||
- `sub -> rpSessionId`
|
||||
|
||||
권장 순서는 다음과 같습니다.
|
||||
|
||||
1. `sid`를 우선 저장
|
||||
2. `sub`도 함께 저장
|
||||
3. 한 사용자가 여러 브라우저 세션을 가질 수 있으므로 `1:N` 구조를 가정
|
||||
|
||||
예시:
|
||||
|
||||
```text
|
||||
sid: 796f5cf7-37e7-494b-9b4c-26cc0c217a6a
|
||||
sub: 8150cb83-a905-4b50-bdcf-d22046ecdc30
|
||||
rpSessionId: DqKlQ8MbsGnn_jfOus1k03MFRDpuXCrj
|
||||
```
|
||||
|
||||
### 3. `POST /backchannel-logout` endpoint
|
||||
|
||||
RP는 Baron이 서버 간으로 호출할 endpoint를 제공해야 합니다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
POST /backchannel-logout
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Body: logout_token=<jwt>
|
||||
```
|
||||
|
||||
RP는 이 endpoint에서:
|
||||
|
||||
1. `logout_token` 존재 여부 확인
|
||||
2. JWT 서명 및 claim 검증
|
||||
3. `sid` 또는 `sub`로 로컬 세션 탐색
|
||||
4. 세션 스토어에서 직접 세션 파기
|
||||
5. 성공 시 `2xx` 응답
|
||||
|
||||
을 수행해야 합니다.
|
||||
|
||||
### 4. `logout_token` 검증
|
||||
|
||||
RP는 Baron이 노출하는 Back-Channel Logout JWKS로 `logout_token`을 검증해야 합니다.
|
||||
|
||||
현재 Baron의 JWKS endpoint 예시는 다음과 같습니다.
|
||||
|
||||
```text
|
||||
GET /api/v1/auth/backchannel/jwks.json
|
||||
```
|
||||
|
||||
검증 필수 항목:
|
||||
|
||||
1. JWT 서명 검증
|
||||
2. `iss`가 Baron OIDC issuer와 일치
|
||||
3. `aud`에 현재 RP `client_id` 포함
|
||||
4. `iat` 존재
|
||||
5. `jti` 존재
|
||||
6. `events`에 `http://schemas.openid.net/event/backchannel-logout` 포함
|
||||
7. `nonce`가 없어야 함
|
||||
8. `sid` 또는 `sub`가 있어야 함
|
||||
|
||||
추가 권장 항목:
|
||||
|
||||
- `jti` replay 방지 캐시
|
||||
- 시계 오차 허용 범위 설정
|
||||
- 검증 실패 시 `400`
|
||||
|
||||
## 세션 종료 기준
|
||||
|
||||
### 권장 순서
|
||||
|
||||
1. `sid`로 매칭 시도
|
||||
2. 매칭 실패 시 `sub`로 fallback
|
||||
|
||||
이 기준은 `SID Claim Required` 정책에 따라 달라집니다.
|
||||
|
||||
### `SID Claim Required = true`
|
||||
|
||||
- `logout_token`에 `sid`가 있어야만 처리
|
||||
- `sub` fallback 금지
|
||||
- `sid` 중심 세션 모델을 운영하는 RP에 적합
|
||||
|
||||
### `SID Claim Required = false`
|
||||
|
||||
- `sid`가 있으면 우선 사용
|
||||
- `sid` 매칭이 안 되거나 `sid`가 없어도 `sub`로 fallback 가능
|
||||
- 실제 운영에서는 이 모드가 더 유연할 수 있음
|
||||
|
||||
## 세션 파기 방식
|
||||
|
||||
`Back-Channel Logout`에서는 현재 브라우저 요청의 `req.session.destroy()`로는 부족합니다.
|
||||
반드시 **세션 스토어에서 session id를 찾아 직접 파기**해야 합니다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
store.destroy(rpSessionId)
|
||||
```
|
||||
|
||||
필수 조건:
|
||||
|
||||
- 로그아웃 대상 세션 ID를 매핑 테이블에서 찾을 수 있어야 함
|
||||
- 이미 삭제된 세션은 idempotent success 처리
|
||||
|
||||
## 권장 로그 항목
|
||||
|
||||
RP는 아래 정도의 로그를 남기는 것을 권장합니다.
|
||||
|
||||
1. 요청 수신
|
||||
2. 토큰 검증 성공/실패
|
||||
3. `sid`, `sub`, `jti`
|
||||
4. 매칭된 `rpSessionId` 목록
|
||||
5. 세션 파기 성공/실패 수
|
||||
|
||||
예시:
|
||||
|
||||
```text
|
||||
[백채널 로그아웃] 요청 수신
|
||||
[백채널 로그아웃] 토큰 검증 성공
|
||||
[백채널 로그아웃] 세션 탐색 결과
|
||||
[백채널 로그아웃] 세션 파기 완료
|
||||
[백채널 로그아웃] 처리 완료
|
||||
```
|
||||
|
||||
주의:
|
||||
- raw `logout_token` 전체를 로그에 남기지 않습니다.
|
||||
- access token, refresh token, cookie raw value도 남기지 않습니다.
|
||||
|
||||
## 테스트 체크리스트
|
||||
|
||||
### 기본 성공 시나리오
|
||||
|
||||
1. server-side-app RP 로그인
|
||||
2. callback 후 `sid/sub -> rpSessionId` 매핑 생성 확인
|
||||
3. UserFront에서 `세션 종료`
|
||||
4. Baron이 RP의 `Back-Channel Logout URI`로 POST
|
||||
5. RP가 `logout_token` 검증 성공
|
||||
6. RP 세션 파기 성공
|
||||
7. 보호 페이지 접근 시 비로그인 상태 확인
|
||||
|
||||
### 확인 포인트
|
||||
|
||||
1. devfront에 `Back-Channel Logout URI`가 실제 저장됐는가
|
||||
2. Baron backend가 해당 URI에 실제로 도달 가능한가
|
||||
3. RP 로그에 `요청 수신`과 `토큰 검증 성공`이 찍히는가
|
||||
4. 세션 스토어에서 실제 세션이 삭제됐는가
|
||||
5. `SID Claim Required=true`일 때와 `false`일 때 결과가 의도대로 다른가
|
||||
|
||||
## 구현 예시 구조
|
||||
|
||||
Node.js/Express 기준 최소 구조 예시는 다음과 같습니다.
|
||||
|
||||
```text
|
||||
GET /login
|
||||
GET /callback
|
||||
GET /profile
|
||||
GET /logout
|
||||
POST /backchannel-logout
|
||||
```
|
||||
|
||||
내부 저장 예시:
|
||||
|
||||
```text
|
||||
sidToSessionIds: Map<string, Set<string>>
|
||||
subToSessionIds: Map<string, Set<string>>
|
||||
sessionIdToBinding: Map<string, { sid: string, sub: string }>
|
||||
```
|
||||
|
||||
실제 분리 예시는 아래 데모 코드를 참고할 수 있습니다.
|
||||
|
||||
- 백채널 로그아웃 모듈: `/home/kyy/workspace/baron-sso-server-side-demo/backchannel-logout.js`
|
||||
- 데모 앱 엔트리포인트: `/home/kyy/workspace/baron-sso-server-side-demo/app.js`
|
||||
|
||||
이 데모는:
|
||||
|
||||
1. callback 이후 `registerSessionBinding()`으로 `sid/sub -> sessionId`를 등록
|
||||
2. `POST /backchannel-logout`에서 `handleBackchannelLogout`를 그대로 연결
|
||||
3. 로컬 `/logout` 또는 세션 정리 시 `removeSessionBinding()` 호출
|
||||
|
||||
구조로 동작합니다.
|
||||
|
||||
## 자주 생기는 문제
|
||||
|
||||
### 1. `localhost`로는 안 되는데 입력은 저장됨
|
||||
|
||||
입력 validation을 통과하는 것과 Baron backend가 실제로 그 주소에 도달하는 것은 다릅니다.
|
||||
|
||||
예:
|
||||
|
||||
```text
|
||||
http://localhost:4444/backchannel-logout
|
||||
```
|
||||
|
||||
이 값은 backend 컨테이너 기준으로는 자기 자신을 가리킬 수 있습니다. Docker 환경에서는 Docker 서비스명 또는 사설 IP를 사용해야 할 수 있습니다.
|
||||
|
||||
### 2. `sid`가 로그인 시 값과 다름
|
||||
|
||||
실제 운영에서는 `logout_token.sid`가 RP가 저장한 `sid`와 항상 같다고 가정하면 안 됩니다.
|
||||
|
||||
따라서:
|
||||
|
||||
1. `sid` 우선
|
||||
2. `sub` fallback
|
||||
|
||||
구현을 권장합니다. 다만 보안 정책상 `SID Claim Required=true`를 선택한 경우에는 fallback 없이 `sid`만 사용해야 합니다.
|
||||
|
||||
### 3. `client_secret` 또는 auth method가 잘못되어 callback에서 실패함
|
||||
|
||||
`server-side-app`은 confidential client이므로 아래 값이 정확해야 합니다.
|
||||
|
||||
1. `client_id`
|
||||
2. `client_secret`
|
||||
3. `token_endpoint_auth_method`
|
||||
4. `redirect_uri`
|
||||
|
||||
이 중 하나라도 다르면 authorization code 교환 단계에서 실패할 수 있습니다.
|
||||
|
||||
## 시퀀스 다이어그램
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Browser as 브라우저
|
||||
participant RP as Server-Side RP
|
||||
participant Baron as Baron SSO
|
||||
participant Store as 세션 스토어
|
||||
|
||||
Browser->>RP: GET /login 호출
|
||||
RP->>Browser: Baron authorize endpoint로 리다이렉트
|
||||
Browser->>Baron: Authorization Code 로그인
|
||||
Baron->>Browser: /callback?code=... 으로 리다이렉트
|
||||
Browser->>RP: GET /callback 호출
|
||||
RP->>Baron: client_secret 포함 token 요청
|
||||
Baron-->>RP: ID Token / Access Token 반환
|
||||
RP->>Store: RP 세션 생성
|
||||
RP->>RP: registerSessionBinding(sessionId, sid, sub)
|
||||
RP-->>Browser: 로그인 완료 응답
|
||||
|
||||
Browser->>Baron: UserFront 또는 연동 서비스에서 세션 종료
|
||||
Baron->>RP: POST /backchannel-logout (logout_token)
|
||||
RP->>Baron: Back-Channel JWKS로 logout_token 검증
|
||||
Baron-->>RP: 서명 / issuer / audience 검증 기준 제공
|
||||
RP->>RP: sid 또는 sub로 sessionId 탐색
|
||||
RP->>Store: destroy(sessionId)
|
||||
RP->>RP: removeSessionBinding(sessionId)
|
||||
RP-->>Baron: 200 OK
|
||||
|
||||
Browser->>RP: GET /profile 호출
|
||||
RP-->>Browser: 루트 리다이렉트 또는 비로그인 응답
|
||||
```
|
||||
@@ -2,45 +2,79 @@
|
||||
|
||||
## 목적
|
||||
|
||||
WORKS Drive는 Docker Registry HTTP API v2 backend로 직접 사용하지 않는다. 대신 프로덕션 배포용 Docker 이미지를 `docker save` 결과물로 내보내고, zstd 압축 archive와 검증 파일을 WORKS Shared Drive에 보관하는 CLI 기반 보조 저장소로 사용한다.
|
||||
WORKS Drive는 Docker Registry HTTP API v2 backend로 직접 사용하지 않는다. 대신 stage/production 공용 Docker 이미지를 `docker save` 결과물로 내보내고, zstd 압축 archive와 검증 파일을 WORKS Shared Drive에 보관하는 CLI 기반 이미지 산출물 저장소로 사용한다.
|
||||
|
||||
이 방식은 다음 상황을 목표로 한다.
|
||||
|
||||
- Harbor 또는 공용 Registry 장애 시 수동 복구용 이미지 보관
|
||||
- 작은 규모의 프로덕션 배포 이미지 이관
|
||||
- 공용 Registry 없이 WORKS Drive 접근 권한만으로 이미지 산출물 보관
|
||||
- 작은 규모의 stage/production 배포 이미지 이관
|
||||
- `docker load` 기반 오프라인 배포
|
||||
|
||||
Gitea Actions의 shared image publish workflow는 `baron_sso/<service>:<image_tag>` 형태의 로컬 이미지를 빌드한 뒤 WORKS Drive archive로 업로드한다. Harbor registry login/push/pull은 이 publish 흐름의 필수 조건이 아니다. staging/production은 같은 image tag 계약을 공유하며, WORKS Drive archive를 검증한 뒤 `docker load`로 배포 대상 호스트에 적재하는 흐름으로 확장한다.
|
||||
|
||||
## 현재 Gitea Actions 설정 상태
|
||||
|
||||
2026-06-19 기준 Docker image archive 업로드 단계는 `.gitea/workflows/image_publish.yml`의 `Upload built images to WORKS Drive archive` step에서 실행된다. 이 workflow는 stage/production 공용 산출물을 만들며 `dev` branch의 commit hash 4자리로 immutable tag를 계산한다.
|
||||
|
||||
업로드를 실행하려면 최소한 다음 값을 등록해야 한다.
|
||||
|
||||
- 선택 variable `WORKS_DRIVE_DOCKER_IMAGE_DIR=baron-sso`
|
||||
- variable `WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID`
|
||||
- 선택 variable `WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID`
|
||||
- secret `WORKS_DRIVE_ACCESS_TOKEN`, 또는 variable `WORKS_DRIVE_ACCESS_TOKEN_FILE`, 또는 variable `WORKS_DRIVE_ACCESS_TOKEN_CMD`, 또는 refresh-token 방식의 secret `WORKS_DRIVE_REFRESH_TOKEN`
|
||||
- refresh-token 방식을 쓸 경우 secret `WORKS_DRIVE_OAUTH_CLIENT_ID`, secret `WORKS_OAUTH_CLIENT_SECRET`
|
||||
|
||||
서비스 계정 JWT 방식은 upload script의 fallback으로 남아 있지만 shared image publish workflow의 기본 필수 인증값은 아니다.
|
||||
|
||||
## WORKS Drive 토큰 운영
|
||||
|
||||
WORKS OAuth의 Access Token은 Developer Console 설정에 따라 1시간 또는 24시간 동안 유효하고, Refresh Token은 90일 동안 유효하다. 따라서 Gitea secret에 `WORKS_DRIVE_ACCESS_TOKEN`만 고정해 두는 방식은 publish workflow가 장시간 중단된 뒤 재실행될 때 실패할 수 있다.
|
||||
|
||||
`image_publish.yml`은 업로드 직전에 `Resolve WORKS Drive access token` step을 실행한다.
|
||||
|
||||
- `WORKS_DRIVE_ACCESS_TOKEN`이 있으면 이를 마스킹한 뒤 해당 workflow run 안에서만 사용한다.
|
||||
- `WORKS_DRIVE_ACCESS_TOKEN_FILE` 또는 `WORKS_DRIVE_ACCESS_TOKEN_CMD`가 있으면 그 결과를 같은 방식으로 사용한다.
|
||||
- 위 값이 없고 Gitea secret `WORKS_DRIVE_REFRESH_TOKEN`이 있으면 workflow 내부 env `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`으로 매핑한 뒤 `grant_type=refresh_token`으로 새 Access Token을 발급하고, 이후 다섯 개 이미지 업로드는 모두 이 Access Token을 공유한다.
|
||||
|
||||
Refresh Token Rotation이 켜져 있으면 WORKS가 refresh 응답에 새 Refresh Token을 포함할 수 있다. workflow는 이 값을 로그에 노출하지 않도록 마스킹하고 `${RUNNER_TEMP}/works-drive-rotated-refresh-token`에 `0600` 권한으로 캡처한다. 다만 Gitea repository secret을 자동 갱신하려면 별도의 secret 쓰기 권한이 있는 Gitea token과 secret update 절차가 필요하므로, 기본 publish workflow는 repository secret을 직접 변경하지 않는다.
|
||||
|
||||
운영 권장값은 다음 중 하나다.
|
||||
|
||||
- Refresh Token Rotation을 끄고 `WORKS_DRIVE_REFRESH_TOKEN`으로 매 run마다 Access Token만 자동 발급한다.
|
||||
- Rotation을 켠 경우 publish run에서 rotated refresh token 경고가 나오면 `WORKS_DRIVE_REFRESH_TOKEN` secret을 수동 갱신한다.
|
||||
- secret 자동 갱신이 필요하면 Gitea secret write 전용 token을 별도 설계로 추가한다.
|
||||
|
||||
## 저장 구조
|
||||
|
||||
기본 최상위 디렉터리는 다음 환경 변수로 지정한다.
|
||||
|
||||
```dotenv
|
||||
WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR=docker-build-image
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DIR=baron-sso
|
||||
```
|
||||
|
||||
이미지는 WORKS Shared Drive에서 다음 구조로 저장한다.
|
||||
|
||||
```text
|
||||
docker-build-image/<repository-path>/<tag>/
|
||||
image.tar.zst
|
||||
image.tar.zst.sha256
|
||||
manifest.json
|
||||
baron-sso/<tag>/
|
||||
<service>.<tag>.tar.zst
|
||||
<service>.<tag>.sha256
|
||||
manifest.<tag>.json
|
||||
```
|
||||
|
||||
예시:
|
||||
|
||||
```text
|
||||
docker-build-image/baron_sso/backend/v1.2606.ab12/
|
||||
image.tar.zst
|
||||
image.tar.zst.sha256
|
||||
manifest.json
|
||||
baron-sso/v1.2606.ab12/
|
||||
backend.v1.2606.ab12.tar.zst
|
||||
backend.v1.2606.ab12.sha256
|
||||
manifest.v1.2606.ab12.json
|
||||
```
|
||||
|
||||
Registry hostname은 저장 경로에서 제외한다. 예를 들어 `registry.example/baron_sso/backend:v1.2606.ab12`는 `baron_sso/backend/v1.2606.ab12` 아래에 저장한다.
|
||||
Registry hostname과 image namespace는 저장 경로에서 제외한다. 예를 들어 `registry.example/baron_sso/backend:v1.2606.ab12`는 `baron-sso/v1.2606.ab12/backend.v1.2606.ab12.tar.zst`로 저장한다.
|
||||
|
||||
## Manifest
|
||||
|
||||
`manifest.json`에는 다음 정보를 기록한다.
|
||||
`manifest.<tag>.json`에는 다음 정보를 기록한다.
|
||||
|
||||
- archive format: `docker-save-zstd`
|
||||
- 원본 `image_ref`
|
||||
@@ -49,14 +83,15 @@ Registry hostname은 저장 경로에서 제외한다. 예를 들어 `registry.e
|
||||
- Docker image id
|
||||
- Git commit
|
||||
- archive 파일명, 크기, sha256
|
||||
- 서비스별 archive 정보 (`images.<service>`)
|
||||
- WORKS Drive remote path
|
||||
- 복원 명령 예시
|
||||
|
||||
복원은 다음 흐름으로 처리한다.
|
||||
|
||||
```bash
|
||||
sha256sum -c image.tar.zst.sha256
|
||||
zstd -d -c image.tar.zst | docker load
|
||||
sha256sum -c backend.v1.2606.ab12.sha256
|
||||
zstd -d -c backend.v1.2606.ab12.tar.zst | docker load
|
||||
```
|
||||
|
||||
## 업로드 CLI
|
||||
@@ -64,7 +99,7 @@ zstd -d -c image.tar.zst | docker load
|
||||
로컬 컨테이너를 먼저 이미지로 commit한 뒤 업로드하려면 다음처럼 실행한다.
|
||||
|
||||
```bash
|
||||
WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR=docker-build-image \
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DIR=baron-sso \
|
||||
WORKS_DOCKER_COMMIT_CONTAINER=baron_backend \
|
||||
DOCKER_IMAGE_REF=registry.example/baron_sso/backend:v1.2606.ab12 \
|
||||
scripts/docker-image/upload_works_drive.sh
|
||||
@@ -77,11 +112,11 @@ DOCKER_IMAGE_REF=registry.example/baron_sso/backend:v1.2606.ab12 \
|
||||
scripts/docker-image/upload_works_drive.sh
|
||||
```
|
||||
|
||||
실제 업로드에는 기존 백업 업로드와 같은 WORKS Drive 인증 변수를 사용한다.
|
||||
실제 업로드에는 기존 백업 업로드와 같은 WORKS Drive 인증 변수를 사용하되, Docker image archive 대상 drive/folder는 백업 변수와 분리한다.
|
||||
|
||||
- `WORKS_DRIVE_TARGET=sharedrive`
|
||||
- `WORKS_DRIVE_SHARED_DRIVE_ID` 또는 `WORKS_SHAREDRIVE_ID`
|
||||
- 선택: `WORKS_DRIVE_PARENT_FILE_ID`
|
||||
- `WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID`
|
||||
- 선택: `WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID`
|
||||
- `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`, `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`, 또는 서비스 계정 OAuth 변수
|
||||
|
||||
업로드 전 packaging만 확인하려면 다음을 사용한다.
|
||||
@@ -92,6 +127,70 @@ DOCKER_IMAGE_REF=registry.example/baron_sso/backend:v1.2606.ab12 \
|
||||
scripts/docker-image/upload_works_drive.sh
|
||||
```
|
||||
|
||||
dry-run도 실제 `docker save`, `zstd`, checksum, manifest 생성을 수행한다. WORKS Drive API 호출만 생략하므로 업로드 전 산출물 검증에 사용할 수 있다.
|
||||
|
||||
## 다운로드 및 복원 검증 CLI
|
||||
|
||||
WORKS Drive에서 다음 세 파일을 같은 로컬 디렉터리로 내려받은 뒤 검증한다.
|
||||
|
||||
```text
|
||||
backend.v1.2606.ab12.tar.zst
|
||||
backend.v1.2606.ab12.sha256
|
||||
manifest.v1.2606.ab12.json
|
||||
```
|
||||
|
||||
checksum, manifest, zstd stream 무결성만 확인하려면 다음을 실행한다.
|
||||
|
||||
```bash
|
||||
scripts/docker-image/verify_archive.sh /path/to/downloaded/archive
|
||||
```
|
||||
|
||||
`make` 타깃을 사용할 수도 있다.
|
||||
|
||||
```bash
|
||||
make docker-image-verify-works \
|
||||
WORKS_DOCKER_IMAGE_ARCHIVE_DIR=/path/to/downloaded/archive
|
||||
```
|
||||
|
||||
실제 Docker image load까지 검증하려면 다음을 실행한다.
|
||||
|
||||
```bash
|
||||
WORKS_DOCKER_VERIFY_LOAD=true \
|
||||
scripts/docker-image/verify_archive.sh /path/to/downloaded/archive
|
||||
```
|
||||
|
||||
검증은 다음 조건을 모두 확인한다.
|
||||
|
||||
- `<service>.<tag>.sha256` checksum 성공
|
||||
- `manifest.<tag>.json`의 `schema_version=1`, `format=docker-save-zstd`
|
||||
- manifest의 archive 파일명, sha256, size와 실제 파일 일치
|
||||
- `zstd -t` 무결성 성공
|
||||
- 선택적으로 `docker load` 성공
|
||||
|
||||
현재 repo의 `scripts/docker-image/download_works_drive.sh`는 `WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID`와 `IMAGE_TAG`를 사용해 `baron-sso/<tag>/`의 archive, checksum, manifest를 내려받고 checksum/manifest 검증 후 `docker load`를 수행한다. 공용 드라이브 root 목록은 `GET /v1.0/sharedrives/<sharedDriveId>/files`로 조회하고, 하위 폴더 목록만 `GET /v1.0/sharedrives/<sharedDriveId>/files/<fileId>/children`를 사용한다.
|
||||
|
||||
```bash
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID=@2001000000547281 \
|
||||
WORKS_DRIVE_ACCESS_TOKEN=<access-token> \
|
||||
IMAGE_TAG=v1.2606.ab12 \
|
||||
scripts/docker-image/download_works_drive.sh
|
||||
```
|
||||
|
||||
API로 다운로드할 때는 root 목록에서 `baron-sso` 폴더의 `fileId`를 찾고, 이후 대상 archive 폴더의 children을 조회해 각 파일의 `fileId`를 얻은 뒤 다음 endpoint를 호출한다.
|
||||
|
||||
```text
|
||||
GET /v1.0/sharedrives/<sharedDriveId>/files/<fileId>/download
|
||||
```
|
||||
|
||||
검증 결과 이 endpoint는 `302 Location`을 반환한다. `curl -L`만 사용하면 리다이렉트 대상 요청에 인증 헤더가 유지되지 않아 `UNAUTHORIZED` JSON이 파일로 저장될 수 있다. 자동화할 때는 리다이렉트 대상에도 인증 헤더를 유지하도록 처리해야 한다. `curl` 기준으로는 다음 형태를 사용한다.
|
||||
|
||||
```bash
|
||||
curl -sS -L --location-trusted \
|
||||
-H "Authorization: Bearer <access-token>" \
|
||||
-o backend.v1.2606.ab12.tar.zst \
|
||||
"https://www.worksapis.com/v1.0/sharedrives/<sharedDriveId>/files/<fileId>/download"
|
||||
```
|
||||
|
||||
smoke 검증에는 Alpine 계열보다 운영 환경과 libc/패키지 계열 차이가 적은 Debian slim 계열을 사용한다.
|
||||
|
||||
```bash
|
||||
@@ -103,19 +202,35 @@ DOCKER_IMAGE_REF=registry.example/baron_sso/works-smoke:works-test-ab12 \
|
||||
scripts/docker-image/upload_works_drive.sh
|
||||
```
|
||||
|
||||
로컬 smoke 검증 예시는 다음과 같다.
|
||||
|
||||
```bash
|
||||
WORKS_DRIVE_DRY_RUN=true \
|
||||
WORKS_DOCKER_IMAGE_ARCHIVE_DIR=/tmp/baron-sso-works-verify-smoke \
|
||||
DOCKER_IMAGE_REF=alpine:latest \
|
||||
scripts/docker-image/upload_works_drive.sh
|
||||
|
||||
scripts/docker-image/verify_archive.sh \
|
||||
/tmp/baron-sso-works-verify-smoke/alpine/latest
|
||||
|
||||
WORKS_DOCKER_VERIFY_LOAD=true \
|
||||
scripts/docker-image/verify_archive.sh \
|
||||
/tmp/baron-sso-works-verify-smoke/alpine/latest
|
||||
```
|
||||
|
||||
## Staging/Production 계약
|
||||
|
||||
Action에서 `dev` 브랜치를 checkout한 뒤 한 번만 이미지를 빌드하고 immutable `image_tag`를 계산한다. staging과 production은 같은 image_tag를 입력받아 같은 registry image를 pull한다.
|
||||
Action에서 `dev` 브랜치를 checkout한 뒤 한 번만 이미지를 빌드하고 immutable `image_tag`를 계산한다. staging과 production은 같은 image_tag를 입력받아 같은 image archive를 사용한다.
|
||||
|
||||
```text
|
||||
dev branch -> publish image tag vX.YYMM.<commit4> -> staging deploy -> production deploy
|
||||
```
|
||||
|
||||
WORKS Drive archive도 Action에서 push된 이미지를 다시 pull한 뒤 `docker save`로 만든다. 따라서 WORKS archive, staging, production은 모두 같은 registry image tag를 기준으로 한다.
|
||||
WORKS Drive archive는 Action에서 로컬로 빌드된 이미지를 `docker save`로 내보내 생성한다. 따라서 WORKS archive, staging, production은 모두 같은 immutable image tag를 기준으로 한다.
|
||||
|
||||
## 제한
|
||||
|
||||
- 이 구조는 `docker push`/`docker pull`과 호환되는 Registry backend가 아니다.
|
||||
- layer deduplication이 없으므로 같은 기반 이미지가 반복 저장된다.
|
||||
- 배포 전에는 반드시 `image.tar.zst.sha256` 검증 후 `docker load`를 수행해야 한다.
|
||||
- 배포 전에는 반드시 `<service>.<tag>.sha256` 검증 후 `docker load`를 수행해야 한다.
|
||||
- tag 없는 image ref와 digest-only image ref는 지원하지 않는다.
|
||||
|
||||
@@ -283,6 +283,9 @@ no_users_found = "No Users Found"
|
||||
protected_relation = "This relation is protected and cannot be changed here."
|
||||
super_admin_description = "Grant or revoke system super administrator access."
|
||||
super_admin_grant_success = "Super administrator access granted."
|
||||
super_admin_grant_queue_empty = "Add users to grant from the search results on the left."
|
||||
super_admin_grant_users_required = "Please add one or more users to grant."
|
||||
super_admin_search_empty = "Please enter a search term."
|
||||
super_admin_revoke_success = "Super administrator access revoked."
|
||||
super_admin_users_required = "Select at least one user."
|
||||
target_tenant_picker_desc = "Choose the tenant scope for this permission."
|
||||
@@ -1500,6 +1503,12 @@ target_org_picker = "Select organization"
|
||||
target_queue_empty = "No targets selected"
|
||||
target_queue_remove = "Remove target"
|
||||
target_tenant_picker_title = "Select tenant"
|
||||
search_results = "results"
|
||||
super_admin_grant_queue_add = "Add to targets"
|
||||
super_admin_grant_queue_remove = "Remove from targets"
|
||||
super_admin_grant_targets = "Grant Targets"
|
||||
super_admin_search = "Search User"
|
||||
super_admin_search_placeholder = "Search UUID, name, email, phone number"
|
||||
target_tenant_required_option = "Target tenant required"
|
||||
user_list = "User List"
|
||||
|
||||
|
||||
@@ -283,6 +283,9 @@ no_users_found = "등록된 사용자가 없습니다."
|
||||
protected_relation = "보호된 관계라 이 화면에서 변경할 수 없습니다."
|
||||
super_admin_description = "시스템 Super Admin 권한을 부여하거나 회수합니다."
|
||||
super_admin_grant_success = "Super Admin 권한을 부여했습니다."
|
||||
super_admin_grant_queue_empty = "부여할 사용자를 왼쪽 검색 결과에서 추가하세요."
|
||||
super_admin_grant_users_required = "부여할 사용자를 하나 이상 추가하세요."
|
||||
super_admin_search_empty = "검색어를 입력하세요."
|
||||
super_admin_revoke_success = "Super Admin 권한을 회수했습니다."
|
||||
super_admin_users_required = "사용자를 한 명 이상 선택해 주세요."
|
||||
target_tenant_picker_desc = "이 권한에 적용할 테넌트 범위를 선택합니다."
|
||||
@@ -1500,6 +1503,12 @@ target_org_picker = "조직 선택"
|
||||
target_queue_empty = "선택된 대상이 없습니다."
|
||||
target_queue_remove = "대상 제거"
|
||||
target_tenant_picker_title = "테넌트 선택"
|
||||
search_results = "건"
|
||||
super_admin_grant_queue_add = "부여 대상 추가"
|
||||
super_admin_grant_queue_remove = "부여 대상 제거"
|
||||
super_admin_grant_targets = "부여 대상자"
|
||||
super_admin_search = "사용자 검색"
|
||||
super_admin_search_placeholder = "UUID, 이름, 이메일, 전화번호 검색"
|
||||
target_tenant_required_option = "대상 테넌트 필수"
|
||||
user_list = "대상 사용자"
|
||||
|
||||
|
||||
@@ -283,6 +283,9 @@ no_users_found = ""
|
||||
protected_relation = ""
|
||||
super_admin_description = ""
|
||||
super_admin_grant_success = ""
|
||||
super_admin_grant_queue_empty = ""
|
||||
super_admin_grant_users_required = ""
|
||||
super_admin_search_empty = ""
|
||||
super_admin_revoke_success = ""
|
||||
super_admin_users_required = ""
|
||||
target_tenant_picker_desc = ""
|
||||
@@ -1500,6 +1503,12 @@ target_org_picker = ""
|
||||
target_queue_empty = ""
|
||||
target_queue_remove = ""
|
||||
target_tenant_picker_title = ""
|
||||
search_results = ""
|
||||
super_admin_grant_queue_add = ""
|
||||
super_admin_grant_queue_remove = ""
|
||||
super_admin_grant_targets = ""
|
||||
super_admin_search = ""
|
||||
super_admin_search_placeholder = ""
|
||||
target_tenant_required_option = ""
|
||||
user_list = ""
|
||||
|
||||
|
||||
@@ -8,15 +8,17 @@ source "$script_dir/lib/postgres.sh"
|
||||
source "$script_dir/lib/clickhouse.sh"
|
||||
source "$script_dir/lib/config.sh"
|
||||
source "$script_dir/lib/report.sh"
|
||||
source "$script_dir/lib/personnel_dataset.sh"
|
||||
|
||||
repo_root="$(backup_repo_root)"
|
||||
services="$(normalize_service_filter "${DUMP_SERVICES:-all}")"
|
||||
dataset="$(normalize_dataset_profile "${DUMP_DATASET:-full}")"
|
||||
mode="${DUMP_MODE:-maintenance}"
|
||||
backup_root="${BACKUP_ROOT:-$repo_root/backups}"
|
||||
backup_dir="${BACKUP:-$backup_root/baron-sso-backup-$(backup_timestamp)}"
|
||||
|
||||
mkdir -p "$backup_dir/reports"
|
||||
create_manifest "$backup_dir" "$mode" "$services"
|
||||
create_manifest "$backup_dir" "$mode" "$services" "$dataset"
|
||||
service_timings_json="[]"
|
||||
|
||||
run_backup_step() {
|
||||
@@ -40,26 +42,31 @@ run_backup_step() {
|
||||
|
||||
backup_log "Creating backup at $backup_dir"
|
||||
backup_log "Backup mode: $mode"
|
||||
backup_log "Dataset: $dataset"
|
||||
backup_log "Services: $services"
|
||||
|
||||
if service_enabled postgres "$services"; then
|
||||
run_backup_step postgres dump_baron_postgres "$backup_dir"
|
||||
fi
|
||||
if [[ "$dataset" == "personnel" ]]; then
|
||||
run_backup_step personnel dump_personnel_dataset "$backup_dir" "$services"
|
||||
else
|
||||
if service_enabled postgres "$services"; then
|
||||
run_backup_step postgres dump_baron_postgres "$backup_dir"
|
||||
fi
|
||||
|
||||
if service_enabled ory-postgres "$services"; then
|
||||
run_backup_step ory-postgres dump_ory_postgres "$backup_dir"
|
||||
fi
|
||||
if service_enabled ory-postgres "$services"; then
|
||||
run_backup_step ory-postgres dump_ory_postgres "$backup_dir"
|
||||
fi
|
||||
|
||||
if service_enabled clickhouse "$services"; then
|
||||
run_backup_step clickhouse dump_baron_clickhouse "$backup_dir"
|
||||
fi
|
||||
if service_enabled clickhouse "$services"; then
|
||||
run_backup_step clickhouse dump_baron_clickhouse "$backup_dir"
|
||||
fi
|
||||
|
||||
if service_enabled ory-clickhouse "$services"; then
|
||||
run_backup_step ory-clickhouse dump_ory_clickhouse "$backup_dir"
|
||||
fi
|
||||
if service_enabled ory-clickhouse "$services"; then
|
||||
run_backup_step ory-clickhouse dump_ory_clickhouse "$backup_dir"
|
||||
fi
|
||||
|
||||
if service_enabled config "$services"; then
|
||||
run_backup_step config dump_config_snapshot "$backup_dir"
|
||||
if service_enabled config "$services"; then
|
||||
run_backup_step config dump_config_snapshot "$backup_dir"
|
||||
fi
|
||||
fi
|
||||
|
||||
write_backup_markdown_report "$backup_dir" "succeeded" "$services" "$service_timings_json"
|
||||
|
||||
106
scripts/backup/filter_personnel_dump.sh
Executable file
106
scripts/backup/filter_personnel_dump.sh
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$script_dir/lib/common.sh"
|
||||
source "$script_dir/lib/manifest.sh"
|
||||
source "$script_dir/lib/postgres.sh"
|
||||
source "$script_dir/lib/config.sh"
|
||||
source "$script_dir/lib/report.sh"
|
||||
source "$script_dir/lib/personnel_dataset.sh"
|
||||
|
||||
repo_root="$(backup_repo_root)"
|
||||
source_backup="${BACKUP:-${RESTORE_INPUT:-${FILE_PATH:-}}}"
|
||||
services="$(normalize_service_filter "${FILTER_SERVICES:-postgres,ory-postgres}")"
|
||||
output_backup="${OUTPUT_BACKUP:-$repo_root/backups/baron-sso-personnel-filtered-$(backup_timestamp)}"
|
||||
scratch_suffix="$(date -u '+%Y%m%d%H%M%S')_$$"
|
||||
scratch_baron="baron_personnel_filter_${scratch_suffix}"
|
||||
scratch_kratos="ory_kratos_personnel_filter_${scratch_suffix}"
|
||||
scratch_keto="ory_keto_personnel_filter_${scratch_suffix}"
|
||||
|
||||
cleanup_filter_scratch() {
|
||||
if [[ -n "${scratch_baron:-}" ]]; then
|
||||
docker exec -e "PGPASSWORD=${DB_PASSWORD:-password}" baron_postgres \
|
||||
psql -U "${DB_USER:-baron}" -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "drop database if exists ${scratch_baron} with (force)" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${scratch_kratos:-}" || -n "${scratch_keto:-}" ]]; then
|
||||
docker exec -e "PGPASSWORD=${ORY_POSTGRES_PASSWORD:-secret}" ory_postgres \
|
||||
psql -U "${ORY_POSTGRES_USER:-ory}" -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "drop database if exists ${scratch_kratos} with (force)" \
|
||||
-c "drop database if exists ${scratch_keto} with (force)" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup_filter_scratch EXIT
|
||||
|
||||
quote_pg_database() {
|
||||
local raw="$1"
|
||||
printf '"%s"' "${raw//\"/\"\"}"
|
||||
}
|
||||
|
||||
create_scratch_database() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local password="$3"
|
||||
local database="$4"
|
||||
local database_ident
|
||||
|
||||
database_ident="$(quote_pg_database "$database")"
|
||||
docker exec -e "PGPASSWORD=$password" "$container" \
|
||||
psql -U "$user" -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "drop database if exists ${database_ident} with (force)" \
|
||||
-c "create database ${database_ident}"
|
||||
}
|
||||
|
||||
restore_custom_dump_to_scratch() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local password="$3"
|
||||
local database="$4"
|
||||
local dump_path="$5"
|
||||
|
||||
backup_require_path "$dump_path"
|
||||
docker exec -i -e "PGPASSWORD=$password" "$container" \
|
||||
pg_restore -U "$user" -d "$database" --clean --if-exists <"$dump_path"
|
||||
}
|
||||
|
||||
[[ -n "$source_backup" ]] || backup_die "BACKUP is required. Example: make filter-personnel-dump BACKUP=backups/full OUTPUT_BACKUP=backups/personnel"
|
||||
backup_require_path "$source_backup/manifest.json"
|
||||
backup_require_command docker
|
||||
backup_require_container baron_postgres
|
||||
backup_require_container ory_postgres
|
||||
|
||||
if [[ "$(jq -r '.dataset // "full"' "$source_backup/manifest.json")" == "personnel" ]]; then
|
||||
backup_die "source BACKUP is already a personnel dataset: $source_backup"
|
||||
fi
|
||||
|
||||
backup_log "Filtering personnel dataset from full backup: $source_backup"
|
||||
backup_log "Output backup: $output_backup"
|
||||
backup_log "Services: $services"
|
||||
|
||||
mkdir -p "$output_backup/reports"
|
||||
create_manifest "$output_backup" "filtered-from-full" "$services" "personnel"
|
||||
write_personnel_dataset_manifest "$output_backup" "$services"
|
||||
|
||||
if service_enabled postgres "$services"; then
|
||||
backup_log "Restoring Baron full dump to scratch DB: $scratch_baron"
|
||||
create_scratch_database baron_postgres "${DB_USER:-baron}" "${DB_PASSWORD:-password}" "$scratch_baron"
|
||||
restore_custom_dump_to_scratch baron_postgres "${DB_USER:-baron}" "${DB_PASSWORD:-password}" "$scratch_baron" "$source_backup/postgres/baron.dump"
|
||||
DB_NAME="$scratch_baron" dump_personnel_baron_postgres "$output_backup"
|
||||
fi
|
||||
|
||||
if service_enabled ory-postgres "$services"; then
|
||||
backup_log "Restoring Kratos/Keto full dumps to scratch DBs: $scratch_kratos, $scratch_keto"
|
||||
create_scratch_database ory_postgres "${ORY_POSTGRES_USER:-ory}" "${ORY_POSTGRES_PASSWORD:-secret}" "$scratch_kratos"
|
||||
create_scratch_database ory_postgres "${ORY_POSTGRES_USER:-ory}" "${ORY_POSTGRES_PASSWORD:-secret}" "$scratch_keto"
|
||||
restore_custom_dump_to_scratch ory_postgres "${ORY_POSTGRES_USER:-ory}" "${ORY_POSTGRES_PASSWORD:-secret}" "$scratch_kratos" "$source_backup/postgres/${KRATOS_DB:-ory_kratos}.dump"
|
||||
restore_custom_dump_to_scratch ory_postgres "${ORY_POSTGRES_USER:-ory}" "${ORY_POSTGRES_PASSWORD:-secret}" "$scratch_keto" "$source_backup/postgres/${KETO_DB:-ory_keto}.dump"
|
||||
KRATOS_DB="$scratch_kratos" KETO_DB="$scratch_keto" dump_personnel_ory_postgres "$output_backup"
|
||||
fi
|
||||
|
||||
write_backup_markdown_report "$output_backup" "succeeded" "$services" "[]"
|
||||
backup_checksum_file "$output_backup"
|
||||
BACKUP="$output_backup" "$script_dir/verify-dump.sh"
|
||||
|
||||
backup_log "Personnel filtered backup complete: $output_backup"
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
BACKUP_SUPPORTED_SERVICES="postgres ory-postgres clickhouse ory-clickhouse config"
|
||||
BACKUP_SUPPORTED_DATASETS="full personnel"
|
||||
|
||||
backup_repo_root() {
|
||||
if [[ -n "${BACKUP_REPO_ROOT:-}" ]]; then
|
||||
@@ -65,6 +66,18 @@ normalize_service_filter() {
|
||||
printf '%s\n' "$normalized"
|
||||
}
|
||||
|
||||
normalize_dataset_profile() {
|
||||
local raw="${1:-full}"
|
||||
|
||||
[[ -n "$raw" ]] || raw="full"
|
||||
if ! grep -qw -- "$raw" <<<"$BACKUP_SUPPORTED_DATASETS"; then
|
||||
backup_die "unknown backup dataset: $raw"
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$raw"
|
||||
}
|
||||
|
||||
service_enabled() {
|
||||
local service="$1"
|
||||
local services="$2"
|
||||
|
||||
@@ -4,11 +4,17 @@ create_manifest() {
|
||||
local backup_dir="$1"
|
||||
local mode="$2"
|
||||
local services="$3"
|
||||
local dataset="${4:-full}"
|
||||
local repo_root
|
||||
local created_at
|
||||
local git_commit
|
||||
local service
|
||||
local first=1
|
||||
local environment_scope="same-env-only"
|
||||
|
||||
if [[ "$dataset" == "personnel" ]]; then
|
||||
environment_scope="staging-rehearsal"
|
||||
fi
|
||||
|
||||
repo_root="$(backup_repo_root)"
|
||||
created_at="$(backup_utc_now)"
|
||||
@@ -20,7 +26,8 @@ create_manifest() {
|
||||
printf ' "created_at": "%s",\n' "$created_at"
|
||||
printf ' "git_commit": "%s",\n' "$git_commit"
|
||||
printf ' "mode": "%s",\n' "$mode"
|
||||
printf ' "environment_scope": "same-env-only",\n'
|
||||
printf ' "dataset": "%s",\n' "$dataset"
|
||||
printf ' "environment_scope": "%s",\n' "$environment_scope"
|
||||
printf ' "services": ['
|
||||
for service in $services; do
|
||||
if [[ "$first" -eq 1 ]]; then
|
||||
|
||||
367
scripts/backup/lib/personnel_dataset.sh
Normal file
367
scripts/backup/lib/personnel_dataset.sh
Normal file
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
personnel_dataset_dir() {
|
||||
local backup_dir="$1"
|
||||
printf '%s\n' "$backup_dir/datasets/personnel"
|
||||
}
|
||||
|
||||
write_personnel_dataset_manifest() {
|
||||
local backup_dir="$1"
|
||||
local services="$2"
|
||||
local dataset_dir
|
||||
local reports_dir
|
||||
local include_kratos
|
||||
local reset_credentials
|
||||
local include_worksmobile_mapping
|
||||
local include_outbox
|
||||
local tenant_roots
|
||||
|
||||
dataset_dir="$(personnel_dataset_dir "$backup_dir")"
|
||||
reports_dir="$dataset_dir/reports"
|
||||
include_kratos="${PERSONNEL_INCLUDE_KRATOS_IDENTITIES:-true}"
|
||||
reset_credentials="${PERSONNEL_RESET_CREDENTIALS:-true}"
|
||||
include_worksmobile_mapping="${PERSONNEL_INCLUDE_WORKSMOBILE_MAPPING:-true}"
|
||||
include_outbox="${PERSONNEL_INCLUDE_OUTBOX:-false}"
|
||||
tenant_roots="${PERSONNEL_TENANT_ROOT_SLUGS:-}"
|
||||
|
||||
mkdir -p "$reports_dir"
|
||||
jq -n \
|
||||
--arg dataset "personnel" \
|
||||
--arg services "$services" \
|
||||
--arg tenant_roots "$tenant_roots" \
|
||||
--arg include_kratos "$include_kratos" \
|
||||
--arg reset_credentials "$reset_credentials" \
|
||||
--arg include_worksmobile_mapping "$include_worksmobile_mapping" \
|
||||
--arg include_outbox "$include_outbox" \
|
||||
'{
|
||||
dataset: $dataset,
|
||||
format_version: "1",
|
||||
services: ($services | split(" ") | map(select(length > 0))),
|
||||
scope: {
|
||||
tenant_root_slugs: (if $tenant_roots == "" then [] else ($tenant_roots | split(",") | map(gsub("^\\s+|\\s+$"; ""))) end)
|
||||
},
|
||||
included: {
|
||||
baron_postgres_tables: [
|
||||
"public.tenants",
|
||||
"public.tenant_domains",
|
||||
"public.user_groups",
|
||||
"public.users",
|
||||
"public.user_login_ids",
|
||||
"public.worksmobile_resource_mappings"
|
||||
],
|
||||
ory_kratos_tables: (if $include_kratos == "true" then ["public.identities"] else [] end),
|
||||
ory_keto_tables: ["public.keto_relation_tuples"]
|
||||
},
|
||||
excluded: {
|
||||
databases: ["ory_hydra"],
|
||||
tables: [
|
||||
"public.relying_parties",
|
||||
"public.rp_user_metadata",
|
||||
"public.client_consents",
|
||||
"public.client_secrets",
|
||||
"public.api_keys",
|
||||
"public.worksmobile_outboxes"
|
||||
],
|
||||
volatile: ["redis", "sessions", "oauth2_tokens", "audit_logs"]
|
||||
},
|
||||
restore_policy: {
|
||||
reset_credentials: ($reset_credentials == "true"),
|
||||
include_worksmobile_mapping: ($include_worksmobile_mapping == "true"),
|
||||
include_outbox: ($include_outbox == "true"),
|
||||
hydra_restore: false,
|
||||
rp_metadata_restore: false,
|
||||
default_mode: "replace-dataset-rows"
|
||||
}
|
||||
}' >"$dataset_dir/dataset-manifest.json"
|
||||
}
|
||||
|
||||
personnel_psql_jsonl() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local password="$3"
|
||||
local database="$4"
|
||||
local sql="$5"
|
||||
local output_file="$6"
|
||||
|
||||
docker exec -e "PGPASSWORD=$password" "$container" \
|
||||
psql -U "$user" -d "$database" -At -v ON_ERROR_STOP=1 -c "$sql" \
|
||||
>"$output_file"
|
||||
}
|
||||
|
||||
personnel_write_count() {
|
||||
local report_file="$1"
|
||||
local key="$2"
|
||||
local file_path="$3"
|
||||
local count
|
||||
|
||||
if [[ -f "$file_path" ]]; then
|
||||
count="$(wc -l <"$file_path" | tr -d '[:space:]')"
|
||||
else
|
||||
count="0"
|
||||
fi
|
||||
printf '%s:%s\n' "$key" "$count" >>"$report_file"
|
||||
}
|
||||
|
||||
dump_personnel_baron_postgres() {
|
||||
local backup_dir="$1"
|
||||
local db_user="${DB_USER:-baron}"
|
||||
local db_password="${DB_PASSWORD:-password}"
|
||||
local db_name="${DB_NAME:-baron_sso}"
|
||||
local dataset_dir
|
||||
local output_dir
|
||||
local report_file
|
||||
|
||||
backup_require_command docker
|
||||
backup_require_container baron_postgres
|
||||
|
||||
dataset_dir="$(personnel_dataset_dir "$backup_dir")"
|
||||
output_dir="$dataset_dir/postgres"
|
||||
report_file="$dataset_dir/reports/row-counts.txt"
|
||||
mkdir -p "$output_dir" "$dataset_dir/reports"
|
||||
: >"$report_file"
|
||||
|
||||
backup_log "Dumping personnel Baron Postgres dataset: $db_name"
|
||||
personnel_psql_jsonl baron_postgres "$db_user" "$db_password" "$db_name" \
|
||||
"select to_jsonb(t)::text from public.tenants t order by t.id" \
|
||||
"$output_dir/tenants.jsonl"
|
||||
personnel_psql_jsonl baron_postgres "$db_user" "$db_password" "$db_name" \
|
||||
"select to_jsonb(t)::text from public.tenant_domains t order by t.id" \
|
||||
"$output_dir/tenant_domains.jsonl"
|
||||
personnel_psql_jsonl baron_postgres "$db_user" "$db_password" "$db_name" \
|
||||
"select to_jsonb(t)::text from public.user_groups t order by t.id" \
|
||||
"$output_dir/user_groups.jsonl"
|
||||
personnel_psql_jsonl baron_postgres "$db_user" "$db_password" "$db_name" \
|
||||
"select (to_jsonb(t) - 'password_hash' - 'relying_party_id' || jsonb_build_object('relying_party_id', null))::text from public.users t order by t.id" \
|
||||
"$output_dir/users.jsonl"
|
||||
personnel_psql_jsonl baron_postgres "$db_user" "$db_password" "$db_name" \
|
||||
"select to_jsonb(t)::text from public.user_login_ids t order by t.id" \
|
||||
"$output_dir/user_login_ids.jsonl"
|
||||
|
||||
if [[ "${PERSONNEL_INCLUDE_WORKSMOBILE_MAPPING:-true}" == "true" ]]; then
|
||||
personnel_psql_jsonl baron_postgres "$db_user" "$db_password" "$db_name" \
|
||||
"select to_jsonb(t)::text from public.worksmobile_resource_mappings t where t.baron_resource_type in ('USER', 'ORGUNIT') order by t.id" \
|
||||
"$output_dir/worksmobile_resource_mappings.jsonl"
|
||||
else
|
||||
: >"$output_dir/worksmobile_resource_mappings.jsonl"
|
||||
fi
|
||||
|
||||
personnel_write_count "$report_file" "public.tenants" "$output_dir/tenants.jsonl"
|
||||
personnel_write_count "$report_file" "public.tenant_domains" "$output_dir/tenant_domains.jsonl"
|
||||
personnel_write_count "$report_file" "public.user_groups" "$output_dir/user_groups.jsonl"
|
||||
personnel_write_count "$report_file" "public.users" "$output_dir/users.jsonl"
|
||||
personnel_write_count "$report_file" "public.user_login_ids" "$output_dir/user_login_ids.jsonl"
|
||||
personnel_write_count "$report_file" "public.worksmobile_resource_mappings" "$output_dir/worksmobile_resource_mappings.jsonl"
|
||||
}
|
||||
|
||||
dump_personnel_ory_postgres() {
|
||||
local backup_dir="$1"
|
||||
local db_user="${ORY_POSTGRES_USER:-ory}"
|
||||
local db_password="${ORY_POSTGRES_PASSWORD:-secret}"
|
||||
local kratos_db="${KRATOS_DB:-ory_kratos}"
|
||||
local keto_db="${KETO_DB:-ory_keto}"
|
||||
local dataset_dir
|
||||
local report_file
|
||||
|
||||
backup_require_command docker
|
||||
backup_require_container ory_postgres
|
||||
|
||||
dataset_dir="$(personnel_dataset_dir "$backup_dir")"
|
||||
report_file="$dataset_dir/reports/row-counts.txt"
|
||||
mkdir -p "$dataset_dir/ory_kratos" "$dataset_dir/ory_keto" "$dataset_dir/reports"
|
||||
|
||||
if [[ "${PERSONNEL_INCLUDE_KRATOS_IDENTITIES:-true}" == "true" ]]; then
|
||||
backup_log "Dumping personnel Kratos identity subset: $kratos_db"
|
||||
personnel_psql_jsonl ory_postgres "$db_user" "$db_password" "$kratos_db" \
|
||||
"select (to_jsonb(i) - 'metadata_admin')::text from public.identities i order by i.id" \
|
||||
"$dataset_dir/ory_kratos/identities.jsonl"
|
||||
else
|
||||
: >"$dataset_dir/ory_kratos/identities.jsonl"
|
||||
fi
|
||||
jq -n '{policy:"reset_credentials", credentials:[]}' >"$dataset_dir/ory_kratos/identity_credentials.reset-plan.jsonl"
|
||||
|
||||
backup_log "Dumping personnel Keto relation tuple subset: $keto_db"
|
||||
personnel_psql_jsonl ory_postgres "$db_user" "$db_password" "$keto_db" \
|
||||
"select to_jsonb(t)::text from public.keto_relation_tuples t where t.namespace <> 'RelyingParty' and coalesce(t.subject_set_namespace, '') <> 'RelyingParty' order by t.namespace, t.object, t.relation, t.subject_id" \
|
||||
"$dataset_dir/ory_keto/relation_tuples.jsonl"
|
||||
|
||||
personnel_write_count "$report_file" "public.identities" "$dataset_dir/ory_kratos/identities.jsonl"
|
||||
personnel_write_count "$report_file" "public.keto_relation_tuples" "$dataset_dir/ory_keto/relation_tuples.jsonl"
|
||||
}
|
||||
|
||||
dump_personnel_dataset() {
|
||||
local backup_dir="$1"
|
||||
local services="$2"
|
||||
|
||||
write_personnel_dataset_manifest "$backup_dir" "$services"
|
||||
if service_enabled postgres "$services"; then
|
||||
dump_personnel_baron_postgres "$backup_dir"
|
||||
fi
|
||||
if service_enabled ory-postgres "$services"; then
|
||||
dump_personnel_ory_postgres "$backup_dir"
|
||||
fi
|
||||
if service_enabled config "$services"; then
|
||||
dump_config_snapshot "$backup_dir"
|
||||
fi
|
||||
}
|
||||
|
||||
personnel_dataset_manifest_path() {
|
||||
local backup_dir="$1"
|
||||
printf '%s\n' "$backup_dir/datasets/personnel/dataset-manifest.json"
|
||||
}
|
||||
|
||||
restore_personnel_plan_policy_json() {
|
||||
local backup_dir="$1"
|
||||
local manifest_path
|
||||
|
||||
manifest_path="$(personnel_dataset_manifest_path "$backup_dir")"
|
||||
if [[ -f "$manifest_path" ]]; then
|
||||
jq -c '{dataset, included, excluded, restore_policy, scope}' "$manifest_path"
|
||||
else
|
||||
printf '{}\n'
|
||||
fi
|
||||
}
|
||||
|
||||
personnel_restore_jsonl_id_table() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local password="$3"
|
||||
local database="$4"
|
||||
local table="$5"
|
||||
local input_file="$6"
|
||||
local scratch
|
||||
local columns
|
||||
local updates
|
||||
|
||||
backup_require_path "$input_file"
|
||||
if [[ ! -s "$input_file" ]]; then
|
||||
backup_log "Skipping empty personnel dataset table: public.$table"
|
||||
return 0
|
||||
fi
|
||||
|
||||
scratch="_personnel_restore_${table}_json"
|
||||
columns="$(docker exec -e "PGPASSWORD=$password" "$container" \
|
||||
psql -U "$user" -d "$database" -At -v ON_ERROR_STOP=1 \
|
||||
-c "select string_agg(quote_ident(column_name), ', ' order by ordinal_position) from information_schema.columns where table_schema = 'public' and table_name = '$table'")"
|
||||
updates="$(docker exec -e "PGPASSWORD=$password" "$container" \
|
||||
psql -U "$user" -d "$database" -At -v ON_ERROR_STOP=1 \
|
||||
-c "select string_agg(format('%I = excluded.%I', column_name, column_name), ', ' order by ordinal_position) from information_schema.columns where table_schema = 'public' and table_name = '$table' and column_name <> 'id'")"
|
||||
|
||||
[[ -n "$columns" ]] || backup_die "cannot resolve columns for personnel restore table: public.$table"
|
||||
[[ -n "$updates" ]] || backup_die "cannot resolve update columns for personnel restore table: public.$table"
|
||||
|
||||
backup_log "Restoring personnel dataset table: public.$table"
|
||||
docker exec -e "PGPASSWORD=$password" "$container" \
|
||||
psql -U "$user" -d "$database" -v ON_ERROR_STOP=1 \
|
||||
-c "drop table if exists public.${scratch}" \
|
||||
-c "create table public.${scratch} (line jsonb not null)"
|
||||
docker exec -i -e "PGPASSWORD=$password" "$container" \
|
||||
psql -U "$user" -d "$database" -v ON_ERROR_STOP=1 \
|
||||
-c "\\copy public.${scratch}(line) from stdin" <"$input_file"
|
||||
docker exec -e "PGPASSWORD=$password" "$container" \
|
||||
psql -U "$user" -d "$database" -v ON_ERROR_STOP=1 \
|
||||
-c "insert into public.${table} (${columns}) select ${columns} from (select (jsonb_populate_record(null::public.${table}, line)).* from public.${scratch}) r on conflict (id) do update set ${updates}" \
|
||||
-c "drop table public.${scratch}"
|
||||
}
|
||||
|
||||
restore_personnel_baron_postgres() {
|
||||
local backup_dir="$1"
|
||||
local db_user="${DB_USER:-baron}"
|
||||
local db_password="${DB_PASSWORD:-password}"
|
||||
local db_name="${DB_NAME:-baron_sso}"
|
||||
local input_dir
|
||||
|
||||
input_dir="$(personnel_dataset_dir "$backup_dir")/postgres"
|
||||
backup_require_command docker
|
||||
backup_require_container baron_postgres
|
||||
|
||||
personnel_restore_jsonl_id_table baron_postgres "$db_user" "$db_password" "$db_name" tenants "$input_dir/tenants.jsonl"
|
||||
personnel_restore_jsonl_id_table baron_postgres "$db_user" "$db_password" "$db_name" tenant_domains "$input_dir/tenant_domains.jsonl"
|
||||
personnel_restore_jsonl_id_table baron_postgres "$db_user" "$db_password" "$db_name" user_groups "$input_dir/user_groups.jsonl"
|
||||
personnel_restore_jsonl_id_table baron_postgres "$db_user" "$db_password" "$db_name" users "$input_dir/users.jsonl"
|
||||
personnel_restore_jsonl_id_table baron_postgres "$db_user" "$db_password" "$db_name" user_login_ids "$input_dir/user_login_ids.jsonl"
|
||||
personnel_restore_jsonl_id_table baron_postgres "$db_user" "$db_password" "$db_name" worksmobile_resource_mappings "$input_dir/worksmobile_resource_mappings.jsonl"
|
||||
}
|
||||
|
||||
restore_personnel_keto_relation_tuples() {
|
||||
local backup_dir="$1"
|
||||
local db_user="${ORY_POSTGRES_USER:-ory}"
|
||||
local db_password="${ORY_POSTGRES_PASSWORD:-secret}"
|
||||
local keto_db="${KETO_DB:-ory_keto}"
|
||||
local input_file
|
||||
local scratch="_personnel_restore_keto_relation_tuples_json"
|
||||
|
||||
input_file="$(personnel_dataset_dir "$backup_dir")/ory_keto/relation_tuples.jsonl"
|
||||
backup_require_path "$input_file"
|
||||
if [[ ! -s "$input_file" ]]; then
|
||||
backup_log "Skipping empty personnel Keto relation tuple dataset"
|
||||
return 0
|
||||
fi
|
||||
|
||||
backup_log "Restoring personnel Keto relation tuple subset: $keto_db"
|
||||
docker exec -e "PGPASSWORD=$db_password" ory_postgres \
|
||||
psql -U "$db_user" -d "$keto_db" -v ON_ERROR_STOP=1 \
|
||||
-c "drop table if exists public.${scratch}" \
|
||||
-c "create table public.${scratch} (line jsonb not null)"
|
||||
docker exec -i -e "PGPASSWORD=$db_password" ory_postgres \
|
||||
psql -U "$db_user" -d "$keto_db" -v ON_ERROR_STOP=1 \
|
||||
-c "\\copy public.${scratch}(line) from stdin" <"$input_file"
|
||||
docker exec -e "PGPASSWORD=$db_password" ory_postgres \
|
||||
psql -U "$db_user" -d "$keto_db" -v ON_ERROR_STOP=1 \
|
||||
-c "delete from public.keto_relation_tuples where namespace <> 'RelyingParty' and coalesce(subject_set_namespace, '') <> 'RelyingParty'" \
|
||||
-c "insert into public.keto_relation_tuples select (jsonb_populate_record(null::public.keto_relation_tuples, line)).* from public.${scratch}" \
|
||||
-c "drop table public.${scratch}"
|
||||
}
|
||||
|
||||
restore_personnel_kratos_identities() {
|
||||
local backup_dir="$1"
|
||||
local db_user="${ORY_POSTGRES_USER:-ory}"
|
||||
local db_password="${ORY_POSTGRES_PASSWORD:-secret}"
|
||||
local kratos_db="${KRATOS_DB:-ory_kratos}"
|
||||
local input_file
|
||||
|
||||
input_file="$(personnel_dataset_dir "$backup_dir")/ory_kratos/identities.jsonl"
|
||||
backup_require_command docker
|
||||
backup_require_container ory_postgres
|
||||
personnel_restore_jsonl_id_table ory_postgres "$db_user" "$db_password" "$kratos_db" identities "$input_file"
|
||||
}
|
||||
|
||||
restore_personnel_ory_postgres() {
|
||||
local backup_dir="$1"
|
||||
|
||||
restore_personnel_kratos_identities "$backup_dir"
|
||||
restore_personnel_keto_relation_tuples "$backup_dir"
|
||||
}
|
||||
|
||||
restore_personnel_dataset() {
|
||||
local backup_dir="$1"
|
||||
local services="$2"
|
||||
local report_items=()
|
||||
local item
|
||||
|
||||
backup_require_path "$(personnel_dataset_manifest_path "$backup_dir")"
|
||||
|
||||
if service_enabled postgres "$services"; then
|
||||
restore_personnel_baron_postgres "$backup_dir"
|
||||
item="$(jq -n --arg label "personnel/postgres" --arg status "passed" '{label:$label, status:$status}')"
|
||||
report_items+=("$item")
|
||||
fi
|
||||
|
||||
if service_enabled ory-postgres "$services"; then
|
||||
restore_personnel_ory_postgres "$backup_dir"
|
||||
item="$(jq -n --arg label "personnel/ory-postgres" --arg status "passed" '{label:$label, status:$status}')"
|
||||
report_items+=("$item")
|
||||
fi
|
||||
|
||||
if service_enabled config "$services"; then
|
||||
restore_config_snapshot "$backup_dir"
|
||||
item="$(jq -n --arg label "personnel/config" --arg status "passed" '{label:$label, status:$status}')"
|
||||
report_items+=("$item")
|
||||
fi
|
||||
|
||||
if [[ "${#report_items[@]}" -eq 0 ]]; then
|
||||
target_verification_reports="[]"
|
||||
else
|
||||
target_verification_reports="$(printf '%s\n' "${report_items[@]}" | jq -s '.')"
|
||||
fi
|
||||
target_verification_status="passed"
|
||||
}
|
||||
@@ -130,6 +130,7 @@ write_restore_markdown_report() {
|
||||
"| 입력 유형 | \(.backup_source // "unknown") |",
|
||||
"| 백업 경로 | `\(.backup_dir // "")` |",
|
||||
"| Dump 파일 | `\(.dump_file // "")` |",
|
||||
"| Dataset | `\(.dataset // "full")` |",
|
||||
"| 서비스 | `\(services)` |",
|
||||
"",
|
||||
"## 검증",
|
||||
@@ -138,6 +139,9 @@ write_restore_markdown_report() {
|
||||
"| --- | --- |",
|
||||
"| Dump checksum | \(.verification.dump_checksum // "not_run") |",
|
||||
"| 대상 row count | \(.verification.target_row_counts // "not_run") |",
|
||||
(if (.dataset // "full") == "personnel" then
|
||||
"| Personnel exclusions | `\((.restore_policy.excluded.databases // []) | join(", "))` / `\((.restore_policy.excluded.tables // []) | join(", "))` |"
|
||||
else empty end),
|
||||
"",
|
||||
"## 대상별 검증 결과",
|
||||
"",
|
||||
|
||||
@@ -7,6 +7,7 @@ source "$script_dir/lib/postgres.sh"
|
||||
source "$script_dir/lib/clickhouse.sh"
|
||||
source "$script_dir/lib/config.sh"
|
||||
source "$script_dir/lib/report.sh"
|
||||
source "$script_dir/lib/personnel_dataset.sh"
|
||||
|
||||
dry_run=false
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
@@ -26,6 +27,7 @@ report_message=""
|
||||
dump_checksum_status="not_run"
|
||||
target_verification_status="not_run"
|
||||
target_verification_reports="[]"
|
||||
dataset="full"
|
||||
|
||||
json_array_from_words() {
|
||||
local words="$1"
|
||||
@@ -43,6 +45,7 @@ write_restore_report() {
|
||||
local finished_at
|
||||
local services_json
|
||||
local restore_policy_json="{}"
|
||||
local personnel_policy_json="{}"
|
||||
|
||||
[[ -n "$report_path" ]] || return 0
|
||||
|
||||
@@ -51,6 +54,9 @@ write_restore_report() {
|
||||
if [[ -n "${backup_dir:-}" && -f "$backup_dir/manifest.json" ]]; then
|
||||
restore_policy_json="$(jq -c '.restore_policy // {}' "$backup_dir/manifest.json")"
|
||||
fi
|
||||
if [[ "${dataset:-full}" == "personnel" ]]; then
|
||||
personnel_policy_json="$(restore_personnel_plan_policy_json "$backup_dir")"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$report_path")"
|
||||
jq -n \
|
||||
@@ -69,6 +75,8 @@ write_restore_report() {
|
||||
--arg target_row_counts "$target_verification_status" \
|
||||
--argjson target_reports "$target_verification_reports" \
|
||||
--argjson restore_policy "$restore_policy_json" \
|
||||
--arg dataset "${dataset:-full}" \
|
||||
--argjson personnel_policy "$personnel_policy_json" \
|
||||
'{
|
||||
format_version: $format_version,
|
||||
started_at: $started_at,
|
||||
@@ -78,10 +86,11 @@ write_restore_report() {
|
||||
backup_source: $backup_source,
|
||||
backup_dir: $backup_dir,
|
||||
dump_file: (if $dump_file == "" then null else $dump_file end),
|
||||
dataset: $dataset,
|
||||
services: $services,
|
||||
allow_non_empty_restore: ($allow_non_empty_restore == "true"),
|
||||
dry_run: ($dry_run == "true"),
|
||||
restore_policy: $restore_policy,
|
||||
restore_policy: (if $dataset == "personnel" then $personnel_policy else $restore_policy end),
|
||||
verification: {
|
||||
dump_checksum: $dump_checksum,
|
||||
target_row_counts: $target_row_counts,
|
||||
@@ -439,6 +448,13 @@ if [[ "${CONFIRM_RESTORE:-}" != "baron-sso" ]]; then
|
||||
fi
|
||||
|
||||
services="$(normalize_service_filter "${RESTORE_SERVICES:-all}")"
|
||||
if [[ -n "${RESTORE_DATASET:-}" ]]; then
|
||||
dataset="$(normalize_dataset_profile "$RESTORE_DATASET")"
|
||||
elif [[ -f "$backup_dir/manifest.json" ]]; then
|
||||
dataset="$(normalize_dataset_profile "$(jq -r '.dataset // "full"' "$backup_dir/manifest.json")")"
|
||||
else
|
||||
dataset="full"
|
||||
fi
|
||||
allow_non_empty="${ALLOW_NON_EMPTY_RESTORE:-false}"
|
||||
|
||||
if [[ "${RESTORE_TEST_NON_EMPTY:-}" == "1" && "$allow_non_empty" != "true" ]]; then
|
||||
@@ -447,6 +463,7 @@ fi
|
||||
|
||||
if [[ "$dry_run" == "true" ]]; then
|
||||
backup_log "Restore plan for $backup_dir"
|
||||
backup_log "Dataset: $dataset"
|
||||
backup_log "Services: $services"
|
||||
backup_log "ALLOW_NON_EMPTY_RESTORE=$allow_non_empty"
|
||||
backup_log "RESTORE_REPORT=$report_path"
|
||||
@@ -466,27 +483,32 @@ fi
|
||||
BACKUP="$backup_dir" "$script_dir/verify-dump.sh"
|
||||
dump_checksum_status="passed"
|
||||
|
||||
if service_enabled postgres "$services"; then
|
||||
restore_baron_postgres "$backup_dir"
|
||||
if [[ "$dataset" == "personnel" ]]; then
|
||||
restore_personnel_dataset "$backup_dir" "$services"
|
||||
else
|
||||
if service_enabled postgres "$services"; then
|
||||
restore_baron_postgres "$backup_dir"
|
||||
fi
|
||||
|
||||
if service_enabled ory-postgres "$services"; then
|
||||
restore_ory_postgres "$backup_dir"
|
||||
fi
|
||||
|
||||
if service_enabled clickhouse "$services"; then
|
||||
restore_baron_clickhouse "$backup_dir"
|
||||
fi
|
||||
|
||||
if service_enabled ory-clickhouse "$services"; then
|
||||
restore_ory_clickhouse "$backup_dir"
|
||||
fi
|
||||
|
||||
if service_enabled config "$services"; then
|
||||
restore_config_snapshot "$backup_dir"
|
||||
fi
|
||||
|
||||
verify_restored_targets
|
||||
fi
|
||||
|
||||
if service_enabled ory-postgres "$services"; then
|
||||
restore_ory_postgres "$backup_dir"
|
||||
fi
|
||||
|
||||
if service_enabled clickhouse "$services"; then
|
||||
restore_baron_clickhouse "$backup_dir"
|
||||
fi
|
||||
|
||||
if service_enabled ory-clickhouse "$services"; then
|
||||
restore_ory_clickhouse "$backup_dir"
|
||||
fi
|
||||
|
||||
if service_enabled config "$services"; then
|
||||
restore_config_snapshot "$backup_dir"
|
||||
fi
|
||||
|
||||
verify_restored_targets
|
||||
write_restore_report "succeeded" "restore completed and target row-count verification passed"
|
||||
|
||||
backup_log "Restore complete. Keep WORKS relay disabled until comparison dry-run passes."
|
||||
|
||||
@@ -23,13 +23,12 @@ host_from_url() {
|
||||
|
||||
require_env IMAGE_TAG
|
||||
require_env IMAGE_DEPLOY_ENV
|
||||
require_env IMAGE_DEPLOY_PORT_PREFIX
|
||||
require_env IMAGE_DEPLOY_PUBLIC_URL
|
||||
require_env IMAGE_DEPLOY_BACKEND_PORT
|
||||
require_env ADMINFRONT_URL
|
||||
require_env DEVFRONT_URL
|
||||
require_env ORGFRONT_URL
|
||||
require_env VITE_OIDC_AUTHORITY
|
||||
require_env HARBOR_HOSTNAME
|
||||
|
||||
if ! printf '%s' "$IMAGE_TAG" | grep -Eq '^v[0-9]+\.[0-9]{4}\.[0-9a-f]{4}$'; then
|
||||
die "IMAGE_TAG must look like vX.YYMM.ab12 (got: $IMAGE_TAG)"
|
||||
@@ -50,26 +49,47 @@ case "$IMAGE_DEPLOY_ENV" in
|
||||
esac
|
||||
|
||||
instance_name="${IMAGE_DEPLOY_INSTANCE_NAME:-$default_instance_name}"
|
||||
port_prefix="${IMAGE_DEPLOY_PORT_PREFIX:-${IMAGE_DEPLOY_BACKEND_PORT%???}}"
|
||||
[[ -n "$port_prefix" ]] || die "IMAGE_DEPLOY_PORT_PREFIX is empty and could not be derived from IMAGE_DEPLOY_BACKEND_PORT."
|
||||
bundle_dir="${IMAGE_DEPLOY_BUNDLE_DIR:-$PWD/${instance_name}-image-deploy-bundle}"
|
||||
bundle_file="${IMAGE_DEPLOY_BUNDLE_FILE:-$PWD/${instance_name}-image-deploy-bundle.tgz}"
|
||||
compose_template="${IMAGE_DEPLOY_COMPOSE_TEMPLATE:-$repo_root/deploy/templates/docker-compose.images.yaml}"
|
||||
|
||||
rm -rf "$bundle_dir"
|
||||
TARGET_DIR="$bundle_dir" bash "$repo_root/deploy/create-instance.sh" "$instance_name" "$IMAGE_DEPLOY_PORT_PREFIX"
|
||||
TARGET_DIR="$bundle_dir" bash "$repo_root/deploy/create-instance.sh" "$instance_name" "$port_prefix"
|
||||
cp "$compose_template" "$bundle_dir/docker-compose.yml"
|
||||
mkdir -p "$bundle_dir/scripts/docker-image" "$bundle_dir/scripts/backup/lib"
|
||||
cp "$repo_root/scripts/docker-image/download_works_drive.sh" "$bundle_dir/scripts/docker-image/download_works_drive.sh"
|
||||
cp "$repo_root/scripts/backup/lib/common.sh" "$bundle_dir/scripts/backup/lib/common.sh"
|
||||
chmod +x "$bundle_dir/scripts/docker-image/download_works_drive.sh"
|
||||
|
||||
sed "s/{{BACKEND_PORT}}/${IMAGE_DEPLOY_BACKEND_PORT}/g" \
|
||||
"$repo_root/deploy/templates/gateway/nginx.conf" >"$bundle_dir/gateway/nginx.conf"
|
||||
sed "s/{{BACKEND_PORT}}/${IMAGE_DEPLOY_BACKEND_PORT}/g" \
|
||||
"$repo_root/deploy/templates/ory/oathkeeper/rules.json" >"$bundle_dir/ory/templates/oathkeeper/rules.json"
|
||||
cp "$bundle_dir/ory/templates/oathkeeper/rules.json" "$bundle_dir/ory/templates/oathkeeper/rules.stage.json"
|
||||
cp "$bundle_dir/ory/templates/oathkeeper/rules.json" "$bundle_dir/ory/templates/oathkeeper/rules.prod.json"
|
||||
cp "$bundle_dir/ory/templates/oathkeeper/rules.json" "$bundle_dir/ory/templates/oathkeeper/rules.active.json"
|
||||
|
||||
public_host="$(host_from_url "$IMAGE_DEPLOY_PUBLIC_URL")"
|
||||
admin_host="$(host_from_url "$ADMINFRONT_URL")"
|
||||
dev_host="$(host_from_url "$DEVFRONT_URL")"
|
||||
org_host="$(host_from_url "$ORGFRONT_URL")"
|
||||
backend_log_level="${IMAGE_DEPLOY_BACKEND_LOG_LEVEL:-${BACKEND_LOG_LEVEL:-info}}"
|
||||
client_log_debug="${IMAGE_DEPLOY_CLIENT_LOG_DEBUG:-${CLIENT_LOG_DEBUG:-false}}"
|
||||
backend_public_url="${IMAGE_DEPLOY_BACKEND_PUBLIC_URL:-${BACKEND_PUBLIC_URL:-${BACKEND_URL:-$IMAGE_DEPLOY_PUBLIC_URL}}}"
|
||||
backend_url="${IMAGE_DEPLOY_BACKEND_URL:-${BACKEND_URL:-$backend_public_url}}"
|
||||
|
||||
cat >"$bundle_dir/.env" <<EOF
|
||||
INSTANCE_NAME=${instance_name}
|
||||
COMPOSE_PROJECT_NAME=baron-sso-${instance_name}
|
||||
APP_ENV=${app_env}
|
||||
BACKEND_LOG_LEVEL=${backend_log_level}
|
||||
CLIENT_LOG_DEBUG=${client_log_debug}
|
||||
VITE_CLIENT_LOG_DEBUG=${client_log_debug}
|
||||
TZ=Asia/Seoul
|
||||
SOURCE_ROOT=.
|
||||
P=${IMAGE_DEPLOY_PORT_PREFIX}
|
||||
P=${port_prefix}
|
||||
DB_PORT=${IMAGE_DEPLOY_DB_PORT}
|
||||
REDIS_PORT=${IMAGE_DEPLOY_REDIS_PORT}
|
||||
CLICKHOUSE_PORT_HTTP=${IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP}
|
||||
@@ -85,6 +105,8 @@ USERFRONT_URL=${IMAGE_DEPLOY_PUBLIC_URL}
|
||||
ADMINFRONT_URL=${ADMINFRONT_URL}
|
||||
DEVFRONT_URL=${DEVFRONT_URL}
|
||||
ORGFRONT_URL=${ORGFRONT_URL}
|
||||
BACKEND_PUBLIC_URL=${backend_public_url}
|
||||
BACKEND_URL=${backend_url}
|
||||
PUBLIC_HOST=${public_host}
|
||||
ADMINFRONT_HOST=${admin_host}
|
||||
DEVFRONT_HOST=${dev_host}
|
||||
@@ -106,9 +128,22 @@ HYDRA_CONSENT_URL=${IMAGE_DEPLOY_PUBLIC_URL}/consent
|
||||
HYDRA_ERROR_URL=${IMAGE_DEPLOY_PUBLIC_URL}/error
|
||||
HYDRA_REFRESH_TOKEN_TTL=${HYDRA_REFRESH_TOKEN_TTL}
|
||||
OATHKEEPER_PUBLIC_URL=${IMAGE_DEPLOY_PUBLIC_URL}
|
||||
OATHKEEPER_API_URL=${OATHKEEPER_API_URL:-}
|
||||
KETO_READ_URL=http://keto:4466
|
||||
KETO_WRITE_URL=http://keto:4467
|
||||
IDP_PROVIDER=ory
|
||||
WORKS_ADMIN_API_BASE_URL=${WORKS_ADMIN_API_BASE_URL:-}
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=${WORKS_ADMIN_OAUTH_TOKEN_URL:-}
|
||||
PROFILE_CACHE_TTL=${PROFILE_CACHE_TTL:-}
|
||||
NAVER_CLOUD_ACCESS_KEY=${NAVER_CLOUD_ACCESS_KEY:-}
|
||||
NAVER_CLOUD_SECRET_KEY=${NAVER_CLOUD_SECRET_KEY:-}
|
||||
NAVER_CLOUD_SERVICE_ID=${NAVER_CLOUD_SERVICE_ID:-}
|
||||
NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER:-}
|
||||
AWS_REGION=${AWS_REGION:-}
|
||||
AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
|
||||
AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
|
||||
AWS_SES_SENDER=${AWS_SES_SENDER:-}
|
||||
CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-}
|
||||
DB_PASSWORD=${IMAGE_DEPLOY_DB_PASSWORD}
|
||||
ORY_POSTGRES_USER=${ORY_POSTGRES_USER}
|
||||
ORY_POSTGRES_PASSWORD=${IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD}
|
||||
@@ -125,6 +160,8 @@ OATHKEEPER_UID=${OATHKEEPER_UID}
|
||||
OATHKEEPER_GID=${OATHKEEPER_GID}
|
||||
OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID}
|
||||
OATHKEEPER_INTROSPECT_CLIENT_SECRET=${IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET}
|
||||
CLICKHOUSE_HOST=${CLICKHOUSE_HOST:-clickhouse}
|
||||
CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
|
||||
CLICKHOUSE_PASSWORD=${IMAGE_DEPLOY_CLICKHOUSE_PASSWORD}
|
||||
REDIS_ADDR=redis:6379
|
||||
COOKIE_SECRET=${IMAGE_DEPLOY_COOKIE_SECRET}
|
||||
@@ -146,6 +183,7 @@ required_dotenv_keys="
|
||||
APP_ENV IMAGE_TAG BACKEND_IMAGE_NAME USERFRONT_IMAGE_NAME ADMINFRONT_IMAGE_NAME DEVFRONT_IMAGE_NAME ORGFRONT_IMAGE_NAME
|
||||
USERFRONT_URL PUBLIC_HOST HYDRA_PUBLIC_URL VITE_OIDC_AUTHORITY TRAEFIK_PUBLIC_NETWORK
|
||||
DB_PASSWORD ORY_POSTGRES_PASSWORD COOKIE_SECRET JWT_SECRET CSRF_COOKIE_SECRET
|
||||
BACKEND_LOG_LEVEL CLIENT_LOG_DEBUG BACKEND_PUBLIC_URL BACKEND_URL CLICKHOUSE_HOST CLICKHOUSE_USER
|
||||
"
|
||||
for key in $required_dotenv_keys; do
|
||||
if ! grep -Eq "^${key}=.+" "$bundle_dir/.env"; then
|
||||
|
||||
@@ -15,25 +15,78 @@ require_env IMAGE_DEPLOY_BUNDLE_FILE
|
||||
require_env DEPLOY_HOST
|
||||
require_env DEPLOY_USER
|
||||
require_env DEPLOY_PATH
|
||||
require_env HARBOR_ENDPOINT
|
||||
require_env HARBOR_ROBOT_ACCOUNT
|
||||
require_env HARBOR_ROBOT_KEY
|
||||
require_env WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID
|
||||
|
||||
[[ -f "$IMAGE_DEPLOY_BUNDLE_FILE" ]] || die "bundle file not found: $IMAGE_DEPLOY_BUNDLE_FILE"
|
||||
|
||||
resolve_works_drive_access_token() {
|
||||
if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN:-}" ]]; then
|
||||
printf '%s\n' "$WORKS_DRIVE_ACCESS_TOKEN"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN_INPUT:-}" ]]; then
|
||||
printf '%s\n' "$WORKS_DRIVE_ACCESS_TOKEN_INPUT"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN_FILE:-}" ]]; then
|
||||
[[ -f "$WORKS_DRIVE_ACCESS_TOKEN_FILE" ]] || die "WORKS_DRIVE_ACCESS_TOKEN_FILE not found: $WORKS_DRIVE_ACCESS_TOKEN_FILE"
|
||||
sed -n '1p' "$WORKS_DRIVE_ACCESS_TOKEN_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ]]; then
|
||||
sh -c "$WORKS_DRIVE_ACCESS_TOKEN_CMD"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]]; then
|
||||
[[ -n "${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" ]] || die "WORKS_DRIVE_OAUTH_CLIENT_ID is required when using WORKS_DRIVE_OAUTH_REFRESH_TOKEN."
|
||||
[[ -n "${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" ]] || die "WORKS_DRIVE_OAUTH_CLIENT_SECRET is required when using WORKS_DRIVE_OAUTH_REFRESH_TOKEN."
|
||||
|
||||
local token_url="${WORKS_DRIVE_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}"
|
||||
local response
|
||||
local access_token
|
||||
local rotated_refresh_token
|
||||
|
||||
response="$(curl -fsS -X POST "$token_url" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
--data-urlencode "grant_type=refresh_token" \
|
||||
--data-urlencode "refresh_token=${WORKS_DRIVE_OAUTH_REFRESH_TOKEN}" \
|
||||
--data-urlencode "client_id=${WORKS_DRIVE_OAUTH_CLIENT_ID}" \
|
||||
--data-urlencode "client_secret=${WORKS_DRIVE_OAUTH_CLIENT_SECRET}")"
|
||||
access_token="$(jq -er '.access_token' <<<"$response")"
|
||||
rotated_refresh_token="$(jq -r '.refresh_token // empty' <<<"$response")"
|
||||
if [[ -n "$rotated_refresh_token" && "$rotated_refresh_token" != "$WORKS_DRIVE_OAUTH_REFRESH_TOKEN" ]]; then
|
||||
printf 'WARNING: WORKS returned a rotated refresh token. Update WORKS_DRIVE_REFRESH_TOKEN before the old token ages out.\n' >&2
|
||||
fi
|
||||
printf '%s\n' "$access_token"
|
||||
return
|
||||
fi
|
||||
|
||||
die "Missing WORKS Drive access auth. Provide WORKS_DRIVE_ACCESS_TOKEN, WORKS_DRIVE_ACCESS_TOKEN_FILE, WORKS_DRIVE_ACCESS_TOKEN_CMD, or WORKS_DRIVE_OAUTH_REFRESH_TOKEN."
|
||||
}
|
||||
|
||||
remote_bundle="/tmp/baron-sso-image-deploy-$(date -u '+%Y%m%d%H%M%S').tgz"
|
||||
works_drive_access_token="$(resolve_works_drive_access_token)"
|
||||
|
||||
ssh-keyscan -H "$DEPLOY_HOST" >>~/.ssh/known_hosts
|
||||
scp "$IMAGE_DEPLOY_BUNDLE_FILE" "${DEPLOY_USER}@${DEPLOY_HOST}:${remote_bundle}"
|
||||
|
||||
echo "$HARBOR_ROBOT_KEY" | ssh "${DEPLOY_USER}@${DEPLOY_HOST}" \
|
||||
printf '%s\n' "$works_drive_access_token" | ssh "${DEPLOY_USER}@${DEPLOY_HOST}" \
|
||||
"set -euo pipefail; \
|
||||
read -r works_drive_access_token; \
|
||||
mkdir -p '${DEPLOY_PATH}'; \
|
||||
tar -xzf '${remote_bundle}' -C '${DEPLOY_PATH}'; \
|
||||
cd '${DEPLOY_PATH}'; \
|
||||
chmod 600 .env; \
|
||||
docker network inspect traefik-public >/dev/null 2>&1 || docker network create traefik-public; \
|
||||
docker login '${HARBOR_ENDPOINT}' -u '${HARBOR_ROBOT_ACCOUNT}' --password-stdin; \
|
||||
docker compose --env-file .env -f docker-compose.yml pull; \
|
||||
export WORKS_DRIVE_ACCESS_TOKEN=\"\${works_drive_access_token}\"; \
|
||||
export WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID='${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID}'; \
|
||||
export WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID='${WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID:-}'; \
|
||||
export WORKS_DRIVE_DOCKER_IMAGE_DIR='${WORKS_DRIVE_DOCKER_IMAGE_DIR:-baron-sso}'; \
|
||||
export WORKS_ADMIN_API_BASE_URL='${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}'; \
|
||||
scripts/docker-image/download_works_drive.sh; \
|
||||
docker compose --env-file .env -f docker-compose.yml up -d --remove-orphans; \
|
||||
docker compose --env-file .env -f docker-compose.yml ps"
|
||||
|
||||
189
scripts/docker-image/download_works_drive.sh
Executable file
189
scripts/docker-image/download_works_drive.sh
Executable file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||
source "$repo_root/scripts/backup/lib/common.sh"
|
||||
|
||||
dotenv_value() {
|
||||
local key="$1"
|
||||
local env_file="${WORKS_DOCKER_IMAGE_ENV_FILE:-.env}"
|
||||
|
||||
[[ -f "$env_file" ]] || return 0
|
||||
sed -n "s/^${key}=//p" "$env_file" | tail -n 1
|
||||
}
|
||||
|
||||
urlencode_path() {
|
||||
jq -nr --arg value "$1" '$value|@uri'
|
||||
}
|
||||
|
||||
split_curl_response() {
|
||||
local response="$1"
|
||||
local __body_var="$2"
|
||||
local __status_var="$3"
|
||||
local status
|
||||
local body
|
||||
|
||||
status="$(tail -n 1 <<<"$response")"
|
||||
if [[ "$status" =~ ^[0-9][0-9][0-9]$ ]]; then
|
||||
body="$(sed '$d' <<<"$response")"
|
||||
else
|
||||
status="200"
|
||||
body="$response"
|
||||
fi
|
||||
|
||||
printf -v "$__body_var" '%s' "$body"
|
||||
printf -v "$__status_var" '%s' "$status"
|
||||
}
|
||||
|
||||
redact_for_log() {
|
||||
sed -E 's/("(access_token|refresh_token|assertion|client_secret|Authorization)"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"REDACTED"/Ig'
|
||||
}
|
||||
|
||||
resolve_files_endpoint() {
|
||||
local parent_file_id="${1:-}"
|
||||
local encoded_drive_id
|
||||
|
||||
encoded_drive_id="$(urlencode_path "$drive_id")"
|
||||
if [[ -n "$parent_file_id" ]]; then
|
||||
printf '%s/v1.0/sharedrives/%s/files/%s\n' "$api_base_url" "$encoded_drive_id" "$(urlencode_path "$parent_file_id")"
|
||||
else
|
||||
printf '%s/v1.0/sharedrives/%s/files\n' "$api_base_url" "$encoded_drive_id"
|
||||
fi
|
||||
}
|
||||
|
||||
list_children() {
|
||||
local parent_file_id="${1:-}"
|
||||
local endpoint
|
||||
local response
|
||||
local response_body
|
||||
local http_status
|
||||
|
||||
if [[ -n "$parent_file_id" ]]; then
|
||||
endpoint="$(resolve_files_endpoint "$parent_file_id")/children"
|
||||
else
|
||||
endpoint="$(resolve_files_endpoint)"
|
||||
fi
|
||||
response="$("$curl_bin" -sS -w $'\n%{http_code}' \
|
||||
-H "Authorization: Bearer $access_token" \
|
||||
"$endpoint")"
|
||||
split_curl_response "$response" response_body http_status
|
||||
|
||||
if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then
|
||||
backup_die "WORKS folder list request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$response_body"
|
||||
}
|
||||
|
||||
find_child_id() {
|
||||
local parent_file_id="$1"
|
||||
local child_name="$2"
|
||||
local expected_type="${3:-}"
|
||||
local children_json
|
||||
|
||||
children_json="$(list_children "$parent_file_id")"
|
||||
jq -er --arg name "$child_name" --arg expectedType "$expected_type" '
|
||||
[
|
||||
(.files // .children // .items // [])[]
|
||||
| select((.fileName // .name) == $name)
|
||||
| select(
|
||||
$expectedType == ""
|
||||
or (((.fileType // .type // "") | ascii_downcase) == ($expectedType | ascii_downcase))
|
||||
)
|
||||
| .fileId // .id
|
||||
][0] // empty
|
||||
' <<<"$children_json" 2>/dev/null || true
|
||||
}
|
||||
|
||||
resolve_folder_path() {
|
||||
local path="$1"
|
||||
local parent_file_id="${WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID:-}"
|
||||
local component
|
||||
local folder_id
|
||||
|
||||
IFS='/' read -r -a components <<<"$path"
|
||||
for component in "${components[@]}"; do
|
||||
[[ -n "$component" ]] || continue
|
||||
folder_id="$(find_child_id "$parent_file_id" "$component" "folder")"
|
||||
[[ -n "$folder_id" ]] || backup_die "WORKS Drive folder not found: ${path}"
|
||||
parent_file_id="$folder_id"
|
||||
done
|
||||
|
||||
printf '%s\n' "$parent_file_id"
|
||||
}
|
||||
|
||||
download_file() {
|
||||
local file_id="$1"
|
||||
local output_file="$2"
|
||||
local endpoint
|
||||
|
||||
endpoint="$(resolve_files_endpoint "$file_id")/download"
|
||||
"$curl_bin" -fL -sS \
|
||||
--location-trusted \
|
||||
-H "Authorization: Bearer $access_token" \
|
||||
-o "$output_file" \
|
||||
"$endpoint"
|
||||
}
|
||||
|
||||
load_image_archive() {
|
||||
local archive_file="$1"
|
||||
|
||||
backup_log "Loading Docker image archive: $(basename "$archive_file")"
|
||||
zstd -dc "$archive_file" | docker load >/dev/null
|
||||
}
|
||||
|
||||
image_tag="${IMAGE_TAG:-$(dotenv_value IMAGE_TAG)}"
|
||||
drive_id="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID:-${WORKS_DRIVE_SHARED_DRIVE_ID:-}}"
|
||||
access_token="${WORKS_DRIVE_ACCESS_TOKEN:-}"
|
||||
api_base_url="${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}"
|
||||
curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}"
|
||||
image_root_dir="${WORKS_DRIVE_DOCKER_IMAGE_DIR:-${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-baron-sso}}"
|
||||
download_root="${WORKS_DOCKER_IMAGE_DOWNLOAD_DIR:-/tmp/baron-sso-docker-image-download}"
|
||||
image_list="${WORKS_DOCKER_IMAGE_NAMES:-backend userfront adminfront devfront orgfront}"
|
||||
|
||||
[[ -n "$image_tag" ]] || backup_die "IMAGE_TAG is required."
|
||||
[[ -n "$drive_id" ]] || backup_die "WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID is required."
|
||||
[[ -n "$access_token" ]] || backup_die "WORKS_DRIVE_ACCESS_TOKEN is required."
|
||||
|
||||
backup_require_command jq
|
||||
backup_require_command sha256sum
|
||||
backup_require_command zstd
|
||||
backup_require_command docker
|
||||
backup_require_command "$curl_bin"
|
||||
|
||||
# Normalized remote path: baron-sso/${IMAGE_TAG}/${image}.${IMAGE_TAG}.tar.zst
|
||||
remote_path="${image_root_dir}/${image_tag}"
|
||||
target_folder_id="$(resolve_folder_path "$remote_path")"
|
||||
artifact_dir="${download_root}/${remote_path}"
|
||||
mkdir -p "$artifact_dir"
|
||||
|
||||
for image in $image_list; do
|
||||
archive_name="${image}.${image_tag}.tar.zst"
|
||||
checksum_name="${image}.${image_tag}.sha256"
|
||||
manifest_name="manifest.${image_tag}.json"
|
||||
|
||||
archive_id="$(find_child_id "$target_folder_id" "$archive_name")"
|
||||
checksum_id="$(find_child_id "$target_folder_id" "$checksum_name")"
|
||||
manifest_id="$(find_child_id "$target_folder_id" "$manifest_name")"
|
||||
|
||||
[[ -n "$archive_id" ]] || backup_die "WORKS Drive image archive not found: ${remote_path}/${archive_name}"
|
||||
[[ -n "$checksum_id" ]] || backup_die "WORKS Drive image checksum not found: ${remote_path}/${checksum_name}"
|
||||
[[ -n "$manifest_id" ]] || backup_die "WORKS Drive image manifest not found: ${remote_path}/${manifest_name}"
|
||||
|
||||
download_file "$archive_id" "$artifact_dir/$archive_name"
|
||||
download_file "$checksum_id" "$artifact_dir/$checksum_name"
|
||||
download_file "$manifest_id" "$artifact_dir/$manifest_name"
|
||||
|
||||
(
|
||||
cd "$artifact_dir"
|
||||
sha256sum -c "$checksum_name" >/dev/null
|
||||
)
|
||||
manifest_sha256="$(jq -er --arg image "$image" '.images[$image].archive.sha256 // .archive.sha256' "$artifact_dir/$manifest_name")"
|
||||
actual_sha256="$(sha256sum "$artifact_dir/$archive_name" | awk '{print $1}')"
|
||||
[[ "$manifest_sha256" == "$actual_sha256" ]] || backup_die "manifest sha256 mismatch for $archive_name"
|
||||
|
||||
load_image_archive "$artifact_dir/$archive_name"
|
||||
done
|
||||
|
||||
backup_log "Loaded WORKS Drive Docker image archives from ${remote_path}"
|
||||
@@ -68,7 +68,8 @@ image_ref="${DOCKER_IMAGE_REF:-${IMAGE_REF:-}}"
|
||||
|
||||
commit_container="${WORKS_DOCKER_COMMIT_CONTAINER:-${DOCKER_COMMIT_CONTAINER:-}}"
|
||||
archive_root="${WORKS_DOCKER_IMAGE_ARCHIVE_DIR:-/tmp/baron-sso-docker-image-upload}"
|
||||
image_root_dir="${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-docker-build-image}"
|
||||
folder_cache_file="${WORKS_DOCKER_IMAGE_FOLDER_CACHE_FILE:-${archive_root}/.works-folder-cache.json}"
|
||||
image_root_dir="${WORKS_DRIVE_DOCKER_IMAGE_DIR:-${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-baron-sso}}"
|
||||
dry_run="${WORKS_DRIVE_DRY_RUN:-false}"
|
||||
target="${WORKS_DRIVE_TARGET:-sharedrive}"
|
||||
api_base_url="${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}"
|
||||
@@ -78,6 +79,34 @@ upload_scope="${WORKS_DRIVE_OAUTH_SCOPE:-file}"
|
||||
|
||||
WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_SHARED_DRIVE_ID:-${WORKS_DRIVE_SHAREDRIVE_ID:-${WORKS_SHAREDRIVE_ID:-}}}"
|
||||
|
||||
folder_cache_scope() {
|
||||
printf '%s:%s:%s' "$target" "${WORKS_DRIVE_SHARED_DRIVE_ID:-${WORKS_DRIVE_USER_ID:-${WORKS_DRIVE_GROUP_ID:-${WORKS_DRIVE_SHARED_FOLDER_ID:-}}}}" "${WORKS_DRIVE_PARENT_FILE_ID:-root}"
|
||||
}
|
||||
|
||||
read_cached_folder_id() {
|
||||
local path="$1"
|
||||
local key
|
||||
|
||||
[[ -f "$folder_cache_file" ]] || return 0
|
||||
key="$(folder_cache_scope):${path}"
|
||||
jq -er --arg key "$key" '.[$key] // empty' "$folder_cache_file" 2>/dev/null || true
|
||||
}
|
||||
|
||||
write_cached_folder_id() {
|
||||
local path="$1"
|
||||
local folder_id="$2"
|
||||
local key
|
||||
local tmp_file
|
||||
|
||||
[[ -n "$folder_id" ]] || return 0
|
||||
mkdir -p "$(dirname "$folder_cache_file")"
|
||||
[[ -f "$folder_cache_file" ]] || printf '{}\n' >"$folder_cache_file"
|
||||
key="$(folder_cache_scope):${path}"
|
||||
tmp_file="${folder_cache_file}.tmp"
|
||||
jq --arg key "$key" --arg folderId "$folder_id" '. + {($key): $folderId}' "$folder_cache_file" >"$tmp_file"
|
||||
mv "$tmp_file" "$folder_cache_file"
|
||||
}
|
||||
|
||||
urlencode_path() {
|
||||
jq -nr --arg value "$1" '$value|@uri'
|
||||
}
|
||||
@@ -163,7 +192,11 @@ resolve_target_upload_endpoint() {
|
||||
|
||||
resolve_target_children_endpoint() {
|
||||
local parent_file_id="${1:-${WORKS_DRIVE_PARENT_FILE_ID:-}}"
|
||||
printf '%s/children\n' "$(resolve_target_upload_endpoint "$parent_file_id")"
|
||||
if [[ -n "$parent_file_id" ]]; then
|
||||
printf '%s/children\n' "$(resolve_target_upload_endpoint "$parent_file_id")"
|
||||
else
|
||||
resolve_target_upload_endpoint
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_target_create_folder_endpoint() {
|
||||
@@ -365,24 +398,33 @@ ensure_child_folder() {
|
||||
local children_json
|
||||
local folder_id
|
||||
|
||||
children_endpoint="$(resolve_target_children_endpoint "$parent_file_id")"
|
||||
create_folder_endpoint="$(resolve_target_create_folder_endpoint "$parent_file_id")"
|
||||
children_json="$(list_child_folders "$access_token" "$children_endpoint")"
|
||||
folder_id="$(jq -er --arg name "$folder_name" '
|
||||
[
|
||||
(.files // .children // .items // [])[]
|
||||
| select((.fileName // .name) == $name)
|
||||
| select(((.fileType // .type // "") | ascii_downcase) == "folder")
|
||||
| .fileId // .id
|
||||
][0] // empty
|
||||
' <<<"$children_json" 2>/dev/null || true)"
|
||||
if [[ -n "$parent_file_id" ]]; then
|
||||
children_endpoint="$(resolve_target_children_endpoint "$parent_file_id")"
|
||||
create_folder_endpoint="$(resolve_target_create_folder_endpoint "$parent_file_id")"
|
||||
if ! children_json="$(list_child_folders "$access_token" "$children_endpoint")"; then
|
||||
return 1
|
||||
fi
|
||||
folder_id="$(jq -er --arg name "$folder_name" '
|
||||
[
|
||||
(.files // .children // .items // [])[]
|
||||
| select((.fileName // .name) == $name)
|
||||
| select(((.fileType // .type // "") | ascii_downcase) == "folder")
|
||||
| .fileId // .id
|
||||
][0] // empty
|
||||
' <<<"$children_json" 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$folder_id" ]]; then
|
||||
printf '%s\n' "$folder_id"
|
||||
return
|
||||
if [[ -n "$folder_id" ]]; then
|
||||
printf '%s\n' "$folder_id"
|
||||
return
|
||||
fi
|
||||
else
|
||||
create_folder_endpoint="$(resolve_target_create_folder_endpoint "$parent_file_id")"
|
||||
fi
|
||||
|
||||
create_child_folder "$access_token" "$create_folder_endpoint" "$folder_name"
|
||||
if ! folder_id="$(create_child_folder "$access_token" "$create_folder_endpoint" "$folder_name")"; then
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$folder_id"
|
||||
}
|
||||
|
||||
ensure_folder_path() {
|
||||
@@ -390,11 +432,22 @@ ensure_folder_path() {
|
||||
local path="$2"
|
||||
local parent_file_id="${WORKS_DRIVE_PARENT_FILE_ID:-}"
|
||||
local component
|
||||
local accumulated_path=""
|
||||
local cached_folder_id
|
||||
|
||||
IFS='/' read -r -a components <<<"$path"
|
||||
for component in "${components[@]}"; do
|
||||
[[ -n "$component" ]] || continue
|
||||
parent_file_id="$(ensure_child_folder "$access_token" "$parent_file_id" "$component")"
|
||||
accumulated_path="${accumulated_path:+$accumulated_path/}$component"
|
||||
cached_folder_id="$(read_cached_folder_id "$accumulated_path")"
|
||||
if [[ -n "$cached_folder_id" ]]; then
|
||||
parent_file_id="$cached_folder_id"
|
||||
continue
|
||||
fi
|
||||
if ! parent_file_id="$(ensure_child_folder "$access_token" "$parent_file_id" "$component")"; then
|
||||
return 1
|
||||
fi
|
||||
write_cached_folder_id "$accumulated_path" "$parent_file_id"
|
||||
done
|
||||
|
||||
printf '%s\n' "$parent_file_id"
|
||||
@@ -504,14 +557,16 @@ derive_repository_and_tag() {
|
||||
|
||||
derive_repository_and_tag "$image_ref"
|
||||
|
||||
remote_path="${image_root_dir}/${image_repository}/${image_tag}"
|
||||
artifact_dir="${archive_root}/${image_repository}/${image_tag}"
|
||||
image_name="${image_repository##*/}"
|
||||
release_repository="${image_root_dir}"
|
||||
remote_path="${release_repository}/${image_tag}"
|
||||
artifact_dir="${archive_root}/${release_repository}/${image_tag}"
|
||||
mkdir -p "$artifact_dir"
|
||||
|
||||
tar_file="$artifact_dir/image.tar"
|
||||
archive_file="$artifact_dir/image.tar.zst"
|
||||
checksum_file="$artifact_dir/image.tar.zst.sha256"
|
||||
manifest_file="$artifact_dir/manifest.json"
|
||||
tar_file="$artifact_dir/${image_name}.${image_tag}.tar"
|
||||
archive_file="$artifact_dir/${image_name}.${image_tag}.tar.zst"
|
||||
checksum_file="$artifact_dir/${image_name}.${image_tag}.sha256"
|
||||
manifest_file="$artifact_dir/manifest.${image_tag}.json"
|
||||
upload_report_file="$artifact_dir/works-upload.json"
|
||||
|
||||
rm -f "$tar_file" "$archive_file" "$checksum_file" "$manifest_file" "$upload_report_file"
|
||||
@@ -535,36 +590,77 @@ image_id="$(docker image inspect "$image_ref" --format '{{.Id}}' 2>/dev/null ||
|
||||
archive_size="$(stat -c '%s' "$archive_file")"
|
||||
git_commit="$(backup_git_commit "$repo_root")"
|
||||
|
||||
jq -n \
|
||||
--arg createdAt "$(backup_utc_now)" \
|
||||
--arg imageRef "$image_ref" \
|
||||
--arg repository "$image_repository" \
|
||||
--arg tag "$image_tag" \
|
||||
--arg sourceContainer "$commit_container" \
|
||||
--arg imageId "$image_id" \
|
||||
--arg gitCommit "$git_commit" \
|
||||
--arg remotePath "$remote_path" \
|
||||
--arg archiveFile "$(basename "$archive_file")" \
|
||||
--arg archiveSha256 "$archive_sha256" \
|
||||
--argjson archiveSize "$archive_size" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
format: "docker-save-zstd",
|
||||
created_at: $createdAt,
|
||||
manifest_tmp_file="${manifest_file}.tmp"
|
||||
manifest_jq_filter='
|
||||
def image_entry: {
|
||||
image_ref: $imageRef,
|
||||
repository: $repository,
|
||||
tag: $tag,
|
||||
image_name: $imageName,
|
||||
source_container: $sourceContainer,
|
||||
docker_image_id: $imageId,
|
||||
git_commit: $gitCommit,
|
||||
remote_path: $remotePath,
|
||||
restore_command: ("zstd -d -c " + $archiveFile + " | docker load"),
|
||||
archive: {
|
||||
file_name: $archiveFile,
|
||||
size_bytes: $archiveSize,
|
||||
sha256: $archiveSha256
|
||||
},
|
||||
restore_command: ("zstd -d -c " + $archiveFile + " | docker load")
|
||||
};
|
||||
.schema_version = 1
|
||||
| .format = "docker-save-zstd"
|
||||
| .created_at = (.created_at // $createdAt)
|
||||
| .updated_at = $createdAt
|
||||
| .image_ref = $imageRef
|
||||
| .repository = $repository
|
||||
| .release_repository = $releaseRepository
|
||||
| .image_name = $imageName
|
||||
| .tag = $tag
|
||||
| .source_container = $sourceContainer
|
||||
| .docker_image_id = $imageId
|
||||
| .git_commit = $gitCommit
|
||||
| .remote_path = $remotePath
|
||||
| .restore_command = ("zstd -d -c " + $archiveFile + " | docker load")
|
||||
| .archive = {
|
||||
file_name: $archiveFile,
|
||||
size_bytes: $archiveSize,
|
||||
sha256: $archiveSha256
|
||||
}
|
||||
}' >"$manifest_file"
|
||||
| .images = ((.images // {}) + {($imageName): image_entry})
|
||||
'
|
||||
|
||||
if [[ -f "$manifest_file" ]]; then
|
||||
jq \
|
||||
--arg createdAt "$(backup_utc_now)" \
|
||||
--arg imageRef "$image_ref" \
|
||||
--arg repository "$image_repository" \
|
||||
--arg releaseRepository "$release_repository" \
|
||||
--arg imageName "$image_name" \
|
||||
--arg tag "$image_tag" \
|
||||
--arg sourceContainer "$commit_container" \
|
||||
--arg imageId "$image_id" \
|
||||
--arg gitCommit "$git_commit" \
|
||||
--arg remotePath "$remote_path" \
|
||||
--arg archiveFile "$(basename "$archive_file")" \
|
||||
--arg archiveSha256 "$archive_sha256" \
|
||||
--argjson archiveSize "$archive_size" \
|
||||
"$manifest_jq_filter" "$manifest_file" >"$manifest_tmp_file"
|
||||
else
|
||||
jq -n \
|
||||
--arg createdAt "$(backup_utc_now)" \
|
||||
--arg imageRef "$image_ref" \
|
||||
--arg repository "$image_repository" \
|
||||
--arg releaseRepository "$release_repository" \
|
||||
--arg imageName "$image_name" \
|
||||
--arg tag "$image_tag" \
|
||||
--arg sourceContainer "$commit_container" \
|
||||
--arg imageId "$image_id" \
|
||||
--arg gitCommit "$git_commit" \
|
||||
--arg remotePath "$remote_path" \
|
||||
--arg archiveFile "$(basename "$archive_file")" \
|
||||
--arg archiveSha256 "$archive_sha256" \
|
||||
--argjson archiveSize "$archive_size" \
|
||||
"$manifest_jq_filter" >"$manifest_tmp_file"
|
||||
fi
|
||||
mv "$manifest_tmp_file" "$manifest_file"
|
||||
|
||||
upload_files=("$archive_file" "$checksum_file" "$manifest_file")
|
||||
|
||||
|
||||
69
scripts/docker-image/verify_archive.sh
Executable file
69
scripts/docker-image/verify_archive.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||
source "$repo_root/scripts/backup/lib/common.sh"
|
||||
|
||||
archive_dir="${1:-${WORKS_DOCKER_IMAGE_ARCHIVE_DIR:-}}"
|
||||
verify_load="${WORKS_DOCKER_VERIFY_LOAD:-false}"
|
||||
|
||||
[[ -n "$archive_dir" ]] || backup_die "archive directory is required. Example: scripts/docker-image/verify_archive.sh /path/to/archive"
|
||||
backup_require_path "$archive_dir"
|
||||
|
||||
backup_require_command jq
|
||||
backup_require_command sha256sum
|
||||
backup_require_command stat
|
||||
backup_require_command zstd
|
||||
|
||||
manifest_file="$archive_dir/manifest.json"
|
||||
mapfile -t versioned_manifest_files < <(find "$archive_dir" -maxdepth 1 -type f -name 'manifest.*.json' | sort)
|
||||
if [[ "${#versioned_manifest_files[@]}" -gt 0 ]]; then
|
||||
[[ "${#versioned_manifest_files[@]}" -eq 1 ]] || backup_die "archive directory must contain exactly one manifest.*.json file."
|
||||
manifest_file="${versioned_manifest_files[0]}"
|
||||
fi
|
||||
backup_require_path "$manifest_file"
|
||||
|
||||
schema_version="$(jq -er '.schema_version' "$manifest_file")"
|
||||
format="$(jq -er '.format' "$manifest_file")"
|
||||
archive_name="$(jq -er '.archive.file_name' "$manifest_file")"
|
||||
manifest_sha256="$(jq -er '.archive.sha256' "$manifest_file")"
|
||||
manifest_size="$(jq -er '.archive.size_bytes' "$manifest_file")"
|
||||
|
||||
[[ "$schema_version" == "1" ]] || backup_die "unsupported archive schema_version: $schema_version"
|
||||
[[ "$format" == "docker-save-zstd" ]] || backup_die "unsupported archive format: $format"
|
||||
[[ "$archive_name" != */* && -n "$archive_name" ]] || backup_die "manifest archive.file_name must be a file name: $archive_name"
|
||||
[[ "$manifest_sha256" =~ ^[0-9a-f]{64}$ ]] || backup_die "manifest archive.sha256 is invalid: $manifest_sha256"
|
||||
[[ "$manifest_size" =~ ^[0-9]+$ ]] || backup_die "manifest archive.size_bytes is invalid: $manifest_size"
|
||||
|
||||
archive_file="$archive_dir/$archive_name"
|
||||
checksum_file="$archive_dir/${archive_name}.sha256"
|
||||
if [[ ! -f "$checksum_file" && "$archive_name" == *.tar.zst ]]; then
|
||||
checksum_file="$archive_dir/${archive_name%.tar.zst}.sha256"
|
||||
fi
|
||||
|
||||
backup_require_path "$archive_file"
|
||||
backup_require_path "$checksum_file"
|
||||
|
||||
backup_log "Checking archive checksum"
|
||||
(
|
||||
cd "$archive_dir"
|
||||
sha256sum -c "$(basename "$checksum_file")" >/dev/null
|
||||
)
|
||||
|
||||
actual_sha256="$(sha256sum "$archive_file" | awk '{print $1}')"
|
||||
[[ "$actual_sha256" == "$manifest_sha256" ]] || backup_die "manifest sha256 mismatch: expected=$manifest_sha256 actual=$actual_sha256"
|
||||
|
||||
actual_size="$(stat -c '%s' "$archive_file")"
|
||||
[[ "$actual_size" == "$manifest_size" ]] || backup_die "manifest size mismatch: expected=$manifest_size actual=$actual_size"
|
||||
|
||||
backup_log "Testing zstd archive integrity"
|
||||
zstd -q -t "$archive_file"
|
||||
|
||||
if [[ "$verify_load" == "true" ]]; then
|
||||
backup_require_command docker
|
||||
backup_log "Loading Docker image from archive"
|
||||
zstd -q -d -c "$archive_file" | docker load
|
||||
fi
|
||||
|
||||
backup_log "Docker image archive verification passed: $archive_dir"
|
||||
69
scripts/test_deploy_workflow_env_prefixes.sh
Normal file
69
scripts/test_deploy_workflow_env_prefixes.sh
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
fail_if_contains() {
|
||||
file="$1"
|
||||
pattern="$2"
|
||||
if grep -Fq "$pattern" "$file"; then
|
||||
echo "forbidden pattern in $file: $pattern" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
file="$1"
|
||||
pattern="$2"
|
||||
if ! grep -Fq "$pattern" "$file"; then
|
||||
echo "missing pattern in $file: $pattern" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
staging_workflows="
|
||||
.gitea/workflows/staging_code_pull.yml
|
||||
.gitea/workflows/staging_release.yml
|
||||
.gitea/workflows/staging_image_deploy.yml
|
||||
"
|
||||
|
||||
production_workflows="
|
||||
.gitea/workflows/production_release.yml
|
||||
.gitea/workflows/production_image_deploy.yml
|
||||
"
|
||||
|
||||
for workflow in $staging_workflows; do
|
||||
assert_contains "$workflow" "vars.STG_"
|
||||
assert_contains "$workflow" "secrets.STG_"
|
||||
fail_if_contains "$workflow" "vars.STAGE_"
|
||||
fail_if_contains "$workflow" "secrets.STAGE_"
|
||||
for name in \
|
||||
USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL VITE_OIDC_AUTHORITY \
|
||||
BACKEND_URL BACKEND_LOG_LEVEL CLIENT_LOG_DEBUG PROFILE_CACHE_TTL CORS_ALLOWED_ORIGINS \
|
||||
WORKS_ADMIN_API_BASE_URL WORKS_ADMIN_OAUTH_TOKEN_URL NAVER_CLOUD_ACCESS_KEY \
|
||||
NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER AWS_REGION AWS_ACCESS_KEY_ID \
|
||||
AWS_SES_SENDER CLICKHOUSE_HOST CLICKHOUSE_USER DB_PORT DB_USER DB_NAME REDIS_ADDR
|
||||
do
|
||||
fail_if_contains "$workflow" "vars.$name"
|
||||
done
|
||||
for name in AWS_SECRET_ACCESS_KEY NAVER_CLOUD_SECRET_KEY CLICKHOUSE_PASSWORD STAGE_SSH_PRIVATE_KEY; do
|
||||
fail_if_contains "$workflow" "secrets.$name"
|
||||
done
|
||||
done
|
||||
|
||||
for workflow in $production_workflows; do
|
||||
assert_contains "$workflow" "vars.PROD_"
|
||||
assert_contains "$workflow" "secrets.PROD_"
|
||||
for name in \
|
||||
ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL VITE_OIDC_AUTHORITY BACKEND_LOG_LEVEL \
|
||||
CLIENT_LOG_DEBUG PROFILE_CACHE_TTL CORS_ALLOWED_ORIGINS WORKS_ADMIN_API_BASE_URL \
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SERVICE_ID \
|
||||
NAVER_SENDER_PHONE_NUMBER AWS_REGION AWS_ACCESS_KEY_ID AWS_SES_SENDER \
|
||||
CLICKHOUSE_HOST CLICKHOUSE_USER ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT
|
||||
do
|
||||
fail_if_contains "$workflow" "vars.$name"
|
||||
done
|
||||
for name in AWS_SECRET_ACCESS_KEY NAVER_CLOUD_SECRET_KEY CLICKHOUSE_PASSWORD; do
|
||||
fail_if_contains "$workflow" "secrets.$name"
|
||||
done
|
||||
done
|
||||
|
||||
echo "deploy workflow env prefix checks passed"
|
||||
82
scripts/test_docker_image_archive_verify.sh
Executable file
82
scripts/test_docker_image_archive_verify.sh
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
verify_script="$repo_root/scripts/docker-image/verify_archive.sh"
|
||||
tmp_root="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$tmp_root"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
require_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || {
|
||||
echo "required command not found: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
assert_fails() {
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
echo "expected command to fail: $*" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_command jq
|
||||
require_command sha256sum
|
||||
require_command zstd
|
||||
|
||||
artifact_dir="$tmp_root/baron-sso/v1.2606.ab12"
|
||||
mkdir -p "$artifact_dir"
|
||||
|
||||
printf 'docker image archive smoke\n' >"$artifact_dir/backend.v1.2606.ab12.tar"
|
||||
zstd -q -f -o "$artifact_dir/backend.v1.2606.ab12.tar.zst" "$artifact_dir/backend.v1.2606.ab12.tar"
|
||||
rm -f "$artifact_dir/backend.v1.2606.ab12.tar"
|
||||
|
||||
archive_sha256="$(sha256sum "$artifact_dir/backend.v1.2606.ab12.tar.zst" | awk '{print $1}')"
|
||||
archive_size="$(wc -c <"$artifact_dir/backend.v1.2606.ab12.tar.zst" | tr -d ' ')"
|
||||
printf '%s backend.v1.2606.ab12.tar.zst\n' "$archive_sha256" >"$artifact_dir/backend.v1.2606.ab12.sha256"
|
||||
|
||||
jq -n \
|
||||
--arg remotePath "baron-sso/v1.2606.ab12" \
|
||||
--arg archiveSha256 "$archive_sha256" \
|
||||
--argjson archiveSize "$archive_size" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
format: "docker-save-zstd",
|
||||
image_ref: "reg.hmac.kr/baron_sso/backend:v1.2606.ab12",
|
||||
repository: "baron_sso/backend",
|
||||
release_repository: "baron-sso",
|
||||
image_name: "backend",
|
||||
tag: "v1.2606.ab12",
|
||||
remote_path: $remotePath,
|
||||
archive: {
|
||||
file_name: "backend.v1.2606.ab12.tar.zst",
|
||||
size_bytes: $archiveSize,
|
||||
sha256: $archiveSha256
|
||||
}
|
||||
}' >"$artifact_dir/manifest.v1.2606.ab12.json"
|
||||
|
||||
"$verify_script" "$artifact_dir" >/dev/null
|
||||
|
||||
bad_checksum_dir="$tmp_root/bad-checksum"
|
||||
cp -R "$artifact_dir" "$bad_checksum_dir"
|
||||
printf '0000000000000000000000000000000000000000000000000000000000000000 backend.v1.2606.ab12.tar.zst\n' >"$bad_checksum_dir/backend.v1.2606.ab12.sha256"
|
||||
assert_fails "$verify_script" "$bad_checksum_dir"
|
||||
|
||||
bad_manifest_dir="$tmp_root/bad-manifest"
|
||||
cp -R "$artifact_dir" "$bad_manifest_dir"
|
||||
jq '.archive.sha256 = "1111111111111111111111111111111111111111111111111111111111111111"' \
|
||||
"$bad_manifest_dir/manifest.v1.2606.ab12.json" >"$bad_manifest_dir/manifest.v1.2606.ab12.json.tmp"
|
||||
mv "$bad_manifest_dir/manifest.v1.2606.ab12.json.tmp" "$bad_manifest_dir/manifest.v1.2606.ab12.json"
|
||||
assert_fails "$verify_script" "$bad_manifest_dir"
|
||||
|
||||
bad_archive_dir="$tmp_root/bad-archive"
|
||||
cp -R "$artifact_dir" "$bad_archive_dir"
|
||||
printf 'not a zstd stream\n' >"$bad_archive_dir/backend.v1.2606.ab12.tar.zst"
|
||||
sha256sum "$bad_archive_dir/backend.v1.2606.ab12.tar.zst" | awk '{print $1 " backend.v1.2606.ab12.tar.zst"}' >"$bad_archive_dir/backend.v1.2606.ab12.sha256"
|
||||
assert_fails "$verify_script" "$bad_archive_dir"
|
||||
|
||||
echo "docker image archive verification checks passed"
|
||||
169
scripts/test_image_deploy_env_override.sh
Normal file
169
scripts/test_image_deploy_env_override.sh
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
tmp_root="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$tmp_root"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
assert_contains() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
if ! grep -Fq "$pattern" "$file"; then
|
||||
printf 'missing pattern in %s: %s\n' "$file" "$pattern" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_env_value() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
local expected="$3"
|
||||
if ! grep -Fxq "${key}=${expected}" "$file"; then
|
||||
printf 'missing env value in %s: %s=%s\n' "$file" "$key" "$expected" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
for workflow in \
|
||||
"$repo_root/.gitea/workflows/staging_image_deploy.yml" \
|
||||
"$repo_root/.gitea/workflows/production_image_deploy.yml"
|
||||
do
|
||||
assert_contains "$workflow" "IMAGE_DEPLOY_BACKEND_LOG_LEVEL:"
|
||||
assert_contains "$workflow" "IMAGE_DEPLOY_CLIENT_LOG_DEBUG:"
|
||||
assert_contains "$workflow" "WORKS_ADMIN_API_BASE_URL:"
|
||||
assert_contains "$workflow" "WORKS_ADMIN_OAUTH_TOKEN_URL:"
|
||||
assert_contains "$workflow" "PROFILE_CACHE_TTL:"
|
||||
assert_contains "$workflow" "NAVER_CLOUD_ACCESS_KEY:"
|
||||
assert_contains "$workflow" "NAVER_CLOUD_SECRET_KEY:"
|
||||
assert_contains "$workflow" "NAVER_CLOUD_SERVICE_ID:"
|
||||
assert_contains "$workflow" "NAVER_SENDER_PHONE_NUMBER:"
|
||||
assert_contains "$workflow" "AWS_REGION:"
|
||||
assert_contains "$workflow" "AWS_ACCESS_KEY_ID:"
|
||||
assert_contains "$workflow" "AWS_SECRET_ACCESS_KEY:"
|
||||
assert_contains "$workflow" "AWS_SES_SENDER:"
|
||||
assert_contains "$workflow" "CORS_ALLOWED_ORIGINS:"
|
||||
assert_contains "$workflow" "OATHKEEPER_API_URL:"
|
||||
done
|
||||
|
||||
assert_contains "$repo_root/.gitea/workflows/staging_image_deploy.yml" "secrets.STG_SSH_PRIVATE_KEY"
|
||||
assert_contains "$repo_root/.gitea/workflows/staging_image_deploy.yml" "vars.STG_USERFRONT_URL"
|
||||
assert_contains "$repo_root/.gitea/workflows/staging_image_deploy.yml" "vars.STG_BACKEND_URL"
|
||||
assert_contains "$repo_root/.gitea/workflows/staging_image_deploy.yml" "vars.STG_WORKS_ADMIN_API_BASE_URL"
|
||||
assert_contains "$repo_root/.gitea/workflows/staging_image_deploy.yml" "secrets.STG_NAVER_CLOUD_SECRET_KEY"
|
||||
assert_contains "$repo_root/.gitea/workflows/staging_image_deploy.yml" "secrets.STG_AWS_SECRET_ACCESS_KEY"
|
||||
assert_contains "$repo_root/.gitea/workflows/staging_image_deploy.yml" "secrets.STG_CLICKHOUSE_PASSWORD"
|
||||
|
||||
assert_contains "$repo_root/.gitea/workflows/production_image_deploy.yml" "secrets.PROD_SSH_PRIVATE_KEY"
|
||||
assert_contains "$repo_root/.gitea/workflows/production_image_deploy.yml" "vars.PROD_FRONTEND_URL"
|
||||
assert_contains "$repo_root/.gitea/workflows/production_image_deploy.yml" "vars.PROD_BACKEND_URL"
|
||||
assert_contains "$repo_root/.gitea/workflows/production_image_deploy.yml" "vars.PROD_WORKS_ADMIN_API_BASE_URL"
|
||||
assert_contains "$repo_root/.gitea/workflows/production_image_deploy.yml" "secrets.PROD_NAVER_CLOUD_SECRET_KEY"
|
||||
assert_contains "$repo_root/.gitea/workflows/production_image_deploy.yml" "secrets.PROD_AWS_SECRET_ACCESS_KEY"
|
||||
assert_contains "$repo_root/.gitea/workflows/production_image_deploy.yml" "secrets.PROD_CLICKHOUSE_PASSWORD"
|
||||
|
||||
bundle_dir="$tmp_root/stage-image-deploy-bundle"
|
||||
bundle_file="$tmp_root/stage-image-deploy-bundle.tgz"
|
||||
|
||||
(
|
||||
cd "$repo_root"
|
||||
IMAGE_TAG=v1.2606.ab12 \
|
||||
IMAGE_DEPLOY_ENV=stage \
|
||||
IMAGE_DEPLOY_INSTANCE_NAME=stage-test \
|
||||
IMAGE_DEPLOY_PORT_PREFIX=19 \
|
||||
IMAGE_DEPLOY_PUBLIC_URL=https://sso.example.test \
|
||||
IMAGE_DEPLOY_COMPOSE_TEMPLATE=deploy/templates/docker-compose.images.yaml \
|
||||
IMAGE_DEPLOY_BUNDLE_DIR="$bundle_dir" \
|
||||
IMAGE_DEPLOY_BUNDLE_FILE="$bundle_file" \
|
||||
ADMINFRONT_URL=https://sadmin.example.test \
|
||||
DEVFRONT_URL=https://sdev.example.test \
|
||||
ORGFRONT_URL=https://sorg.example.test \
|
||||
VITE_OIDC_AUTHORITY=https://sso.example.test/oidc \
|
||||
IMAGE_DEPLOY_DB_PORT=15432 \
|
||||
IMAGE_DEPLOY_REDIS_PORT=16379 \
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP=18123 \
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE=19000 \
|
||||
IMAGE_DEPLOY_BACKEND_PORT=13000 \
|
||||
IMAGE_DEPLOY_FRONTEND_PORT=15000 \
|
||||
ADMINFRONT_PORT=15173 \
|
||||
DEVFRONT_PORT=15174 \
|
||||
ORGFRONT_PORT=15175 \
|
||||
IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT=14455 \
|
||||
IMAGE_DEPLOY_DOMAIN_SUFFIX=example.test \
|
||||
ADMINFRONT_CALLBACK_URLS=https://sadmin.example.test/auth/callback \
|
||||
DEVFRONT_CALLBACK_URLS=https://sdev.example.test/auth/callback \
|
||||
ORGFRONT_CALLBACK_URLS=https://sorg.example.test/auth/callback \
|
||||
HYDRA_REFRESH_TOKEN_TTL=720h \
|
||||
ORY_POSTGRES_USER=ory \
|
||||
ORY_POSTGRES_DB=ory \
|
||||
KRATOS_DB=ory_kratos \
|
||||
HYDRA_DB=ory_hydra \
|
||||
KETO_DB=ory_keto \
|
||||
KRATOS_VERSION=v26.2.0-distroless \
|
||||
HYDRA_VERSION=v26.2.0-distroless \
|
||||
KETO_VERSION=v26.2.0-distroless \
|
||||
OATHKEEPER_VERSION=v26.2.0 \
|
||||
ORY_POSTGRES_TAG=17-trixie \
|
||||
OATHKEEPER_UID=1001 \
|
||||
OATHKEEPER_GID=1001 \
|
||||
OATHKEEPER_INTROSPECT_CLIENT_ID=oathkeeper-introspect \
|
||||
ADMIN_EMAIL=admin@example.test \
|
||||
HARBOR_HOSTNAME=reg.example.test \
|
||||
BACKEND_IMAGE_NAME=reg.example.test/baron_sso/backend \
|
||||
USERFRONT_IMAGE_NAME=reg.example.test/baron_sso/userfront \
|
||||
ADMINFRONT_IMAGE_NAME=reg.example.test/baron_sso/adminfront \
|
||||
DEVFRONT_IMAGE_NAME=reg.example.test/baron_sso/devfront \
|
||||
ORGFRONT_IMAGE_NAME=reg.example.test/baron_sso/orgfront \
|
||||
IMAGE_DEPLOY_DB_PASSWORD=db-secret \
|
||||
IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD=ory-secret \
|
||||
IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET=oathkeeper-secret \
|
||||
IMAGE_DEPLOY_CLICKHOUSE_PASSWORD=clickhouse-secret \
|
||||
IMAGE_DEPLOY_COOKIE_SECRET=cookie-secret \
|
||||
IMAGE_DEPLOY_JWT_SECRET=jwt-secret \
|
||||
IMAGE_DEPLOY_CSRF_COOKIE_SECRET=csrf-secret \
|
||||
IMAGE_DEPLOY_ADMIN_PASSWORD=admin-secret \
|
||||
IMAGE_DEPLOY_BACKEND_LOG_LEVEL=debug \
|
||||
IMAGE_DEPLOY_CLIENT_LOG_DEBUG=true \
|
||||
WORKS_ADMIN_API_BASE_URL=https://works-api.example.test \
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=https://works-auth.example.test/token \
|
||||
PROFILE_CACHE_TTL=30m \
|
||||
NAVER_CLOUD_ACCESS_KEY=naver-access \
|
||||
NAVER_CLOUD_SECRET_KEY=naver-secret \
|
||||
NAVER_CLOUD_SERVICE_ID=naver-service \
|
||||
NAVER_SENDER_PHONE_NUMBER=021234567 \
|
||||
AWS_REGION=ap-northeast-2 \
|
||||
AWS_ACCESS_KEY_ID=aws-access \
|
||||
AWS_SECRET_ACCESS_KEY=aws-secret \
|
||||
AWS_SES_SENDER=support@example.test \
|
||||
CORS_ALLOWED_ORIGINS=https://sso.example.test \
|
||||
OATHKEEPER_API_URL=http://oathkeeper:4456 \
|
||||
CLICKHOUSE_HOST=clickhouse \
|
||||
CLICKHOUSE_USER=baron \
|
||||
scripts/deploy/build_image_deploy_bundle.sh >/dev/null
|
||||
)
|
||||
|
||||
env_file="$bundle_dir/.env"
|
||||
assert_env_value "$env_file" "BACKEND_LOG_LEVEL" "debug"
|
||||
assert_env_value "$env_file" "CLIENT_LOG_DEBUG" "true"
|
||||
assert_env_value "$env_file" "WORKS_ADMIN_API_BASE_URL" "https://works-api.example.test"
|
||||
assert_env_value "$env_file" "WORKS_ADMIN_OAUTH_TOKEN_URL" "https://works-auth.example.test/token"
|
||||
assert_env_value "$env_file" "PROFILE_CACHE_TTL" "30m"
|
||||
assert_env_value "$env_file" "NAVER_CLOUD_ACCESS_KEY" "naver-access"
|
||||
assert_env_value "$env_file" "NAVER_CLOUD_SECRET_KEY" "naver-secret"
|
||||
assert_env_value "$env_file" "NAVER_CLOUD_SERVICE_ID" "naver-service"
|
||||
assert_env_value "$env_file" "NAVER_SENDER_PHONE_NUMBER" "021234567"
|
||||
assert_env_value "$env_file" "AWS_REGION" "ap-northeast-2"
|
||||
assert_env_value "$env_file" "AWS_ACCESS_KEY_ID" "aws-access"
|
||||
assert_env_value "$env_file" "AWS_SECRET_ACCESS_KEY" "aws-secret"
|
||||
assert_env_value "$env_file" "AWS_SES_SENDER" "support@example.test"
|
||||
assert_env_value "$env_file" "CORS_ALLOWED_ORIGINS" "https://sso.example.test"
|
||||
assert_env_value "$env_file" "BACKEND_PUBLIC_URL" "https://sso.example.test"
|
||||
assert_env_value "$env_file" "BACKEND_URL" "https://sso.example.test"
|
||||
assert_env_value "$env_file" "OATHKEEPER_API_URL" "http://oathkeeper:4456"
|
||||
assert_env_value "$env_file" "CLICKHOUSE_HOST" "clickhouse"
|
||||
assert_env_value "$env_file" "CLICKHOUSE_USER" "baron"
|
||||
|
||||
echo "image deploy env override checks passed"
|
||||
@@ -17,9 +17,12 @@ do
|
||||
assert_contains "$workflow" "APP_ENV=stage"
|
||||
assert_contains "$workflow" "BACKEND_LOG_LEVEL=debug"
|
||||
assert_contains "$workflow" "CLIENT_LOG_DEBUG=true"
|
||||
assert_contains "$workflow" 'WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}'
|
||||
assert_contains "$workflow" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}'
|
||||
assert_contains "$workflow" 'BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}'
|
||||
assert_contains "$workflow" 'WORKS_ADMIN_API_BASE_URL=${{ vars.STG_WORKS_ADMIN_API_BASE_URL }}'
|
||||
assert_contains "$workflow" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.STG_WORKS_ADMIN_OAUTH_TOKEN_URL }}'
|
||||
assert_contains "$workflow" 'BACKEND_PUBLIC_URL=${{ vars.STG_BACKEND_URL }}'
|
||||
assert_contains "$workflow" 'NAVER_CLOUD_SECRET_KEY=${{ secrets.STG_NAVER_CLOUD_SECRET_KEY }}'
|
||||
assert_contains "$workflow" 'AWS_SECRET_ACCESS_KEY=${{ secrets.STG_AWS_SECRET_ACCESS_KEY }}'
|
||||
assert_contains "$workflow" 'CLICKHOUSE_PASSWORD=${{ secrets.STG_CLICKHOUSE_PASSWORD }}'
|
||||
done
|
||||
|
||||
assert_contains ".gitea/workflows/staging_release.yml" "scp adminfront/seed-tenant.csv"
|
||||
|
||||
@@ -23,11 +23,11 @@ assert_contains "$help_output" "Targets:"
|
||||
assert_contains "$help_output" "Options:"
|
||||
assert_contains "$help_output" "Restore Safety:"
|
||||
|
||||
for target in up dev code-check dump restore-plan code-check-userfront-e2e-tests; do
|
||||
for target in up dev code-check dump filter-personnel-dump restore-plan code-check-userfront-e2e-tests; do
|
||||
assert_contains "$help_output" "$target"
|
||||
done
|
||||
|
||||
for option in DEV_SERVICES CODE_CHECK_TEST_JOBS PLAYWRIGHT_WORKERS BACKUP_USE_DOCKER DUMP_SERVICES RESTORE_SERVICES; do
|
||||
for option in DEV_SERVICES CODE_CHECK_TEST_JOBS PLAYWRIGHT_WORKERS BACKUP_USE_DOCKER DUMP_SERVICES RESTORE_SERVICES DUMP_DATASET RESTORE_DATASET; do
|
||||
assert_contains "$help_output" "$option"
|
||||
done
|
||||
|
||||
|
||||
74
test/personnel_dataset_backup_policy_test.sh
Normal file
74
test/personnel_dataset_backup_policy_test.sh
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local output="$1"
|
||||
local expected="$2"
|
||||
grep -Fq -- "$expected" <<<"$output" || fail "output must contain: $expected"
|
||||
}
|
||||
|
||||
assert_not_exists() {
|
||||
local path="$1"
|
||||
[[ ! -e "$path" ]] || fail "path must not exist: $path"
|
||||
}
|
||||
|
||||
source "$repo_root/scripts/backup/lib/common.sh"
|
||||
source "$repo_root/scripts/backup/lib/manifest.sh"
|
||||
source "$repo_root/scripts/backup/lib/personnel_dataset.sh"
|
||||
|
||||
[[ "$(normalize_dataset_profile full)" == "full" ]] || fail "full dataset profile must be accepted"
|
||||
[[ "$(normalize_dataset_profile personnel)" == "personnel" ]] || fail "personnel dataset profile must be accepted"
|
||||
|
||||
if normalize_dataset_profile unknown >/tmp/baron-sso-dataset-profile.out 2>&1; then
|
||||
fail "unknown dataset profile must be rejected"
|
||||
fi
|
||||
assert_contains "$(cat /tmp/baron-sso-dataset-profile.out)" "unknown backup dataset"
|
||||
|
||||
tmp_dir="$(mktemp -d /tmp/baron-sso-personnel-dataset.XXXXXX)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT INT TERM
|
||||
|
||||
create_manifest "$tmp_dir" "maintenance" "postgres ory-postgres" "personnel"
|
||||
jq -e '.dataset == "personnel" and .environment_scope == "staging-rehearsal"' "$tmp_dir/manifest.json" >/dev/null \
|
||||
|| fail "personnel manifest must mark the staging rehearsal dataset scope"
|
||||
|
||||
mkdir -p "$tmp_dir/datasets/personnel/reports"
|
||||
write_personnel_dataset_manifest "$tmp_dir" "postgres ory-postgres"
|
||||
|
||||
dataset_manifest="$tmp_dir/datasets/personnel/dataset-manifest.json"
|
||||
jq -e '
|
||||
.dataset == "personnel"
|
||||
and (.excluded.databases | index("ory_hydra"))
|
||||
and (.excluded.tables | index("public.relying_parties"))
|
||||
and (.excluded.tables | index("public.rp_user_metadata"))
|
||||
and (.excluded.tables | index("public.client_consents"))
|
||||
and (.restore_policy.reset_credentials == true)
|
||||
' "$dataset_manifest" >/dev/null || fail "personnel dataset manifest must document Hydra/RP exclusions and credential reset policy"
|
||||
|
||||
assert_not_exists "$tmp_dir/postgres/ory_hydra.dump"
|
||||
assert_not_exists "$tmp_dir/postgres/baron.dump"
|
||||
|
||||
dump_dry_run="$(
|
||||
make --dry-run --always-make -C "$repo_root" dump \
|
||||
DUMP_SERVICES="postgres,ory-postgres" \
|
||||
DUMP_DATASET="personnel" \
|
||||
DUMP_MODE="maintenance" 2>&1
|
||||
)"
|
||||
assert_contains "$dump_dry_run" 'DUMP_DATASET="personnel"'
|
||||
|
||||
restore_dry_run="$(
|
||||
make --dry-run --always-make -C "$repo_root" restore \
|
||||
BACKUP="backups/example" \
|
||||
RESTORE_SERVICES="postgres,ory-postgres" \
|
||||
RESTORE_DATASET="personnel" \
|
||||
CONFIRM_RESTORE="baron-sso" 2>&1
|
||||
)"
|
||||
assert_contains "$restore_dry_run" 'RESTORE_DATASET="personnel"'
|
||||
|
||||
echo "OK: personnel dataset backup policy excludes Hydra/RP data and exposes Makefile controls"
|
||||
117
test/personnel_dataset_live_e2e_test.sh
Normal file
117
test/personnel_dataset_live_e2e_test.sh
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ "${RUN_PERSONNEL_DATASET_LIVE_E2E:-}" != "1" ]]; then
|
||||
echo "SKIP: set RUN_PERSONNEL_DATASET_LIVE_E2E=1 to run the Docker-backed personnel dataset E2E test"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp_dir="$(mktemp -d /tmp/baron-sso-personnel-live-e2e.XXXXXX)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT INT TERM
|
||||
|
||||
backup_dir="$tmp_dir/backup"
|
||||
filtered_backup_dir="$tmp_dir/filtered-personnel"
|
||||
source_full_backup="${PERSONNEL_FILTER_SOURCE_BACKUP:-backups/baron-sso-backup-20260622-023904Z}"
|
||||
restore_report="$tmp_dir/restore-report.json"
|
||||
restore_exec_report="$tmp_dir/restore-exec-report.json"
|
||||
restore_db="baron_personnel_restore_e2e_$$"
|
||||
|
||||
BACKUP_USE_DOCKER=false \
|
||||
BACKUP="$backup_dir" \
|
||||
DUMP_SERVICES="postgres,ory-postgres" \
|
||||
DUMP_DATASET="personnel" \
|
||||
DUMP_MODE="maintenance" \
|
||||
"$repo_root/scripts/backup/dump.sh"
|
||||
|
||||
BACKUP_USE_DOCKER=false \
|
||||
BACKUP="$source_full_backup" \
|
||||
OUTPUT_BACKUP="$filtered_backup_dir" \
|
||||
"$repo_root/scripts/backup/filter_personnel_dump.sh"
|
||||
|
||||
[[ -f "$filtered_backup_dir/datasets/personnel/dataset-manifest.json" ]] || fail "filtered dataset manifest must be created"
|
||||
[[ ! -f "$filtered_backup_dir/postgres/ory_hydra.dump" ]] || fail "filtered personnel backup must not contain Hydra dump"
|
||||
[[ ! -f "$filtered_backup_dir/postgres/baron.dump" ]] || fail "filtered personnel backup must not contain full Baron dump"
|
||||
|
||||
filtered_users="$(wc -l <"$filtered_backup_dir/datasets/personnel/postgres/users.jsonl" | tr -d '[:space:]')"
|
||||
direct_users="$(wc -l <"$backup_dir/datasets/personnel/postgres/users.jsonl" | tr -d '[:space:]')"
|
||||
[[ "$filtered_users" == "$direct_users" ]] || fail "filtered personnel users count mismatch: got $filtered_users, want $direct_users"
|
||||
|
||||
[[ -f "$backup_dir/datasets/personnel/dataset-manifest.json" ]] || fail "dataset manifest must be created"
|
||||
[[ -f "$backup_dir/datasets/personnel/postgres/users.jsonl" ]] || fail "users JSONL must be created"
|
||||
[[ -f "$backup_dir/datasets/personnel/postgres/tenants.jsonl" ]] || fail "tenants JSONL must be created"
|
||||
[[ ! -f "$backup_dir/postgres/ory_hydra.dump" ]] || fail "Hydra dump must not be created for personnel dataset"
|
||||
[[ ! -f "$backup_dir/postgres/baron.dump" ]] || fail "full Baron dump must not be created for personnel dataset"
|
||||
|
||||
if grep -R '"password_hash"' "$backup_dir/datasets/personnel/postgres/users.jsonl"; then
|
||||
fail "personnel users export must not contain password_hash"
|
||||
fi
|
||||
|
||||
if grep -R '"relying_party_id":"[^"]' "$backup_dir/datasets/personnel/postgres/users.jsonl"; then
|
||||
fail "personnel users export must not keep RP ownership"
|
||||
fi
|
||||
|
||||
BACKUP_USE_DOCKER=false \
|
||||
BACKUP="$filtered_backup_dir" \
|
||||
RESTORE_SERVICES="postgres,ory-postgres" \
|
||||
RESTORE_DATASET="personnel" \
|
||||
CONFIRM_RESTORE="baron-sso" \
|
||||
RESTORE_REPORT="$restore_report" \
|
||||
"$repo_root/scripts/backup/restore-plan.sh"
|
||||
|
||||
jq -e '
|
||||
.status == "planned"
|
||||
and .dataset == "personnel"
|
||||
and (.restore_policy.excluded.databases | index("ory_hydra"))
|
||||
and (.restore_policy.excluded.tables | index("public.relying_parties"))
|
||||
' "$restore_report" >/dev/null || fail "restore plan report must describe personnel exclusions"
|
||||
|
||||
docker exec -e PGPASSWORD=password baron_postgres \
|
||||
psql -U baron -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "drop database if exists ${restore_db} with (force)" \
|
||||
-c "create database ${restore_db}"
|
||||
docker exec -e PGPASSWORD=password baron_postgres \
|
||||
pg_dump -U baron -d baron_sso --schema-only \
|
||||
| docker exec -i -e PGPASSWORD=password baron_postgres \
|
||||
psql -U baron -d "$restore_db" -v ON_ERROR_STOP=1 >/dev/null
|
||||
|
||||
cleanup_restore_db() {
|
||||
docker exec -e PGPASSWORD=password baron_postgres \
|
||||
psql -U baron -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "drop database if exists ${restore_db} with (force)" >/dev/null || true
|
||||
}
|
||||
trap 'cleanup_restore_db; rm -rf "$tmp_dir"' EXIT INT TERM
|
||||
|
||||
BACKUP_USE_DOCKER=false \
|
||||
BACKUP="$filtered_backup_dir" \
|
||||
RESTORE_SERVICES="postgres" \
|
||||
RESTORE_DATASET="personnel" \
|
||||
CONFIRM_RESTORE="baron-sso" \
|
||||
ALLOW_NON_EMPTY_RESTORE="true" \
|
||||
RESTORE_REPORT="$restore_exec_report" \
|
||||
DB_NAME="$restore_db" \
|
||||
"$repo_root/scripts/backup/restore.sh"
|
||||
|
||||
restored_users="$(
|
||||
docker exec -e PGPASSWORD=password baron_postgres \
|
||||
psql -U baron -d "$restore_db" -Atc "select count(*) from public.users"
|
||||
)"
|
||||
source_users="$(wc -l <"$filtered_backup_dir/datasets/personnel/postgres/users.jsonl" | tr -d '[:space:]')"
|
||||
[[ "$restored_users" == "$source_users" ]] || fail "restored users count mismatch: got $restored_users, want $source_users"
|
||||
|
||||
rp_link_count="$(
|
||||
docker exec -e PGPASSWORD=password baron_postgres \
|
||||
psql -U baron -d "$restore_db" -Atc "select count(*) from public.users where relying_party_id is not null"
|
||||
)"
|
||||
[[ "$rp_link_count" == "0" ]] || fail "restored personnel users must not keep relying_party_id"
|
||||
|
||||
jq -e '.status == "succeeded" and .dataset == "personnel"' "$restore_exec_report" >/dev/null \
|
||||
|| fail "personnel restore execution report must succeed"
|
||||
|
||||
echo "OK: personnel dataset live E2E dump, restore-plan, and scoped restore passed"
|
||||
32
test/personnel_filter_from_full_dump_policy_test.sh
Executable file
32
test/personnel_filter_from_full_dump_policy_test.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local output="$1"
|
||||
local expected="$2"
|
||||
grep -Fq -- "$expected" <<<"$output" || fail "output must contain: $expected"
|
||||
}
|
||||
|
||||
[[ -f "$repo_root/scripts/backup/filter_personnel_dump.sh" ]] \
|
||||
|| fail "filter_personnel_dump.sh must exist"
|
||||
grep -Fq "filter_personnel_dump.sh" "$repo_root/Makefile" \
|
||||
|| fail "Makefile must expose the personnel filter script"
|
||||
|
||||
dry_run="$(
|
||||
make --dry-run --always-make -C "$repo_root" filter-personnel-dump \
|
||||
BACKUP="backups/full-example" \
|
||||
OUTPUT_BACKUP="backups/personnel-example" 2>&1
|
||||
)"
|
||||
|
||||
assert_contains "$dry_run" "filter_personnel_dump.sh"
|
||||
assert_contains "$dry_run" 'BACKUP="backups/full-example"'
|
||||
assert_contains "$dry_run" 'OUTPUT_BACKUP="backups/personnel-example"'
|
||||
|
||||
echo "OK: personnel filter-from-full dump target is exposed"
|
||||
@@ -2,27 +2,31 @@
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
publish_workflow="$repo_root/.gitea/workflows/production_image_publish.yml"
|
||||
publish_workflow="$repo_root/.gitea/workflows/image_publish.yml"
|
||||
legacy_publish_workflow="$repo_root/.gitea/workflows/production_image_publish.yml"
|
||||
staging_deploy_workflow="$repo_root/.gitea/workflows/staging_image_deploy.yml"
|
||||
deploy_workflow="$repo_root/.gitea/workflows/production_image_deploy.yml"
|
||||
image_compose="$repo_root/deploy/templates/docker-compose.images.yaml"
|
||||
bundle_script="$repo_root/scripts/deploy/build_image_deploy_bundle.sh"
|
||||
remote_deploy_script="$repo_root/scripts/deploy/upload_and_run_image_deploy.sh"
|
||||
works_image_download_script="$repo_root/scripts/docker-image/download_works_drive.sh"
|
||||
|
||||
fail() {
|
||||
echo "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$publish_workflow" ]] || fail "production image publish workflow must exist."
|
||||
[[ -f "$publish_workflow" ]] || fail "shared image publish workflow must exist."
|
||||
[[ ! -f "$legacy_publish_workflow" ]] || fail "production-only image publish workflow must be renamed to image_publish.yml."
|
||||
[[ -f "$staging_deploy_workflow" ]] || fail "staging image deploy workflow must exist."
|
||||
[[ -f "$deploy_workflow" ]] || fail "production image deploy workflow must exist."
|
||||
[[ -f "$image_compose" ]] || fail "image-based production compose template must exist."
|
||||
[[ -f "$bundle_script" ]] || fail "shared image deployment bundle script must exist."
|
||||
[[ -f "$remote_deploy_script" ]] || fail "shared image remote deploy script must exist."
|
||||
[[ -f "$works_image_download_script" ]] || fail "shared WORKS Drive image download script must exist."
|
||||
|
||||
grep -Fq "name: Publish Baron SSO Production Images" "$publish_workflow" \
|
||||
|| fail "publish workflow must have the expected name."
|
||||
grep -Fq "name: Publish Baron SSO Images" "$publish_workflow" \
|
||||
|| fail "publish workflow must have the shared stage/production name."
|
||||
grep -Fq "workflow_dispatch:" "$publish_workflow" \
|
||||
|| fail "publish workflow must be manually dispatchable."
|
||||
if grep -Fq "source_ref:" "$publish_workflow"; then
|
||||
@@ -37,21 +41,44 @@ grep -Fq 'git rev-parse --short=4 HEAD' "$publish_workflow" \
|
||||
grep -Fq 'image_tag="${VERSION_PREFIX}.${short_sha}"' "$publish_workflow" \
|
||||
|| fail "publish workflow must append the 4-character commit hash as the last version segment."
|
||||
grep -Fq "steps.version.outputs.image_tag" "$publish_workflow" \
|
||||
|| fail "publish workflow must use the computed image tag for pushed images."
|
||||
grep -Fq "Upload pushed images to WORKS Drive archive" "$publish_workflow" \
|
||||
|| fail "publish workflow must archive the exact pushed images to WORKS Drive."
|
||||
grep -Fq "docker pull" "$publish_workflow" \
|
||||
|| fail "publish workflow must pull the pushed image before WORKS archive upload."
|
||||
|| fail "publish workflow must use the computed image tag for built image archives."
|
||||
grep -Fq "Upload built images to WORKS Drive archive" "$publish_workflow" \
|
||||
|| fail "publish workflow must archive locally built images to WORKS Drive."
|
||||
grep -Fq "scripts/docker-image/upload_works_drive.sh" "$publish_workflow" \
|
||||
|| fail "publish workflow must use the shared WORKS Drive image archive script."
|
||||
grep -Fq "docker/login-action@v3" "$publish_workflow" \
|
||||
|| fail "publish workflow must login to the shared registry."
|
||||
grep -Fq "docker/build-push-action@v5" "$publish_workflow" \
|
||||
|| fail "publish workflow must build and push images."
|
||||
|| fail "publish workflow must build images."
|
||||
grep -Fq "load: true" "$publish_workflow" \
|
||||
|| fail "publish workflow must load built images into the local Docker daemon for WORKS archive upload."
|
||||
for image in backend userfront adminfront devfront orgfront; do
|
||||
grep -Fq "/baron_sso/${image}:" "$publish_workflow" \
|
||||
|| fail "publish workflow must push ${image} image."
|
||||
grep -Fq "baron_sso/${image}:" "$publish_workflow" \
|
||||
|| fail "publish workflow must build ${image} image."
|
||||
done
|
||||
grep -Fq "WORKS_DRIVE_ACCESS_TOKEN_INPUT: \${{ secrets.WORKS_DRIVE_ACCESS_TOKEN }}" "$publish_workflow" \
|
||||
|| fail "publish workflow must support direct WORKS Drive access token auth."
|
||||
grep -Fq "WORKS_DRIVE_OAUTH_CLIENT_SECRET: \${{ secrets.WORKS_OAUTH_CLIENT_SECRET }}" "$publish_workflow" \
|
||||
|| fail "publish workflow must use the Gitea-compatible WORKS OAuth client secret name."
|
||||
grep -Fq "WORKS_DRIVE_OAUTH_REFRESH_TOKEN: \${{ secrets.WORKS_DRIVE_REFRESH_TOKEN }}" "$publish_workflow" \
|
||||
|| fail "publish workflow must support WORKS Drive refresh-token auth."
|
||||
grep -Fq "WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: \${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}" "$publish_workflow" \
|
||||
|| fail "publish workflow must use the Docker-image-specific WORKS Drive ID variable."
|
||||
grep -Fq 'WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID}"' "$publish_workflow" \
|
||||
|| fail "publish workflow must map WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID into the shared upload script."
|
||||
grep -Fq "Resolve WORKS Drive access token" "$publish_workflow" \
|
||||
|| fail "publish workflow must resolve a short-lived WORKS Drive access token before uploads."
|
||||
grep -Fq "WORKS_DRIVE_ACCESS_TOKEN_INPUT" "$publish_workflow" \
|
||||
|| fail "publish workflow must avoid passing the long-lived access-token secret name into upload steps directly."
|
||||
grep -Fq "grant_type=refresh_token" "$publish_workflow" \
|
||||
|| fail "publish workflow must support issuing an access token from WORKS_DRIVE_OAUTH_REFRESH_TOKEN."
|
||||
grep -Fq "WORKS_DRIVE_ACCESS_TOKEN=" "$publish_workflow" \
|
||||
|| fail "publish workflow must export the resolved access token through GITHUB_ENV."
|
||||
grep -Fq "::add-mask::" "$publish_workflow" \
|
||||
|| fail "publish workflow must mask resolved WORKS tokens in logs."
|
||||
grep -Fq "rotated_refresh_token_file" "$publish_workflow" \
|
||||
|| fail "publish workflow must capture rotated refresh tokens when WORKS returns one."
|
||||
if grep -Eiq 'harbor|docker/login-action|push:[[:space:]]*true|docker pull|docker push|HARBOR_' "$publish_workflow"; then
|
||||
fail "shared image publish workflow must not depend on Harbor registry login/push/pull."
|
||||
fi
|
||||
|
||||
grep -Fq "name: Deploy Baron SSO Staging Images" "$staging_deploy_workflow" \
|
||||
|| fail "staging deploy workflow must have the expected name."
|
||||
@@ -67,6 +94,8 @@ grep -Fq "scripts/deploy/build_image_deploy_bundle.sh" "$staging_deploy_workflow
|
||||
|| fail "staging deploy workflow must use the shared bundle script."
|
||||
grep -Fq "scripts/deploy/upload_and_run_image_deploy.sh" "$staging_deploy_workflow" \
|
||||
|| fail "staging deploy workflow must use the shared remote deploy script."
|
||||
grep -Fq "WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: \${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}" "$staging_deploy_workflow" \
|
||||
|| fail "staging deploy workflow must pass the Docker-image-specific WORKS Drive ID variable."
|
||||
|
||||
grep -Fq "name: Deploy Baron SSO Production Images" "$deploy_workflow" \
|
||||
|| fail "deploy workflow must have the expected name."
|
||||
@@ -86,14 +115,23 @@ grep -Fq "scripts/deploy/build_image_deploy_bundle.sh" "$deploy_workflow" \
|
||||
|| fail "production deploy workflow must use the shared bundle script."
|
||||
grep -Fq "scripts/deploy/upload_and_run_image_deploy.sh" "$deploy_workflow" \
|
||||
|| fail "production deploy workflow must use the shared remote deploy script."
|
||||
grep -Fq "WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID: \${{ vars.WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID }}" "$deploy_workflow" \
|
||||
|| fail "production deploy workflow must pass the Docker-image-specific WORKS Drive ID variable."
|
||||
grep -Fq "Same image tag contract as staging" "$deploy_workflow" \
|
||||
|| fail "production deploy workflow must document that it uses the same image tag as staging."
|
||||
grep -Fq "TRAEFIK_PUBLIC_NETWORK=traefik-public" "$bundle_script" \
|
||||
|| fail "shared bundle script must write Traefik public network env."
|
||||
grep -Fq "docker compose --env-file .env -f docker-compose.yml pull" "$remote_deploy_script" \
|
||||
|| fail "shared remote deploy script must pull the requested image version before running."
|
||||
grep -Fq "scripts/docker-image/download_works_drive.sh" "$remote_deploy_script" \
|
||||
|| fail "shared remote deploy script must load requested image archives from WORKS Drive before running."
|
||||
grep -Fq "docker load" "$works_image_download_script" \
|
||||
|| fail "WORKS Drive image download script must load downloaded archives into Docker."
|
||||
grep -Fq 'baron-sso/${IMAGE_TAG}/${image}.${IMAGE_TAG}.tar.zst' "$works_image_download_script" \
|
||||
|| fail "WORKS Drive image download script must document the normalized archive path."
|
||||
grep -Fq "docker compose --env-file .env -f docker-compose.yml up -d" "$remote_deploy_script" \
|
||||
|| fail "shared remote deploy script must start the stack after pulling images."
|
||||
if grep -Eiq 'harbor|docker login|docker compose --env-file .env -f docker-compose.yml pull|HARBOR_' "$staging_deploy_workflow" "$deploy_workflow" "$remote_deploy_script"; then
|
||||
fail "image deploy workflows/scripts must not depend on Harbor registry login or compose pull."
|
||||
fi
|
||||
|
||||
if grep -Eq 'docker (build|commit)' "$staging_deploy_workflow" "$deploy_workflow"; then
|
||||
fail "staging/production deploy workflows must never build or commit images remotely."
|
||||
|
||||
146
test/works_drive_docker_image_download_policy_test.sh
Normal file
146
test/works_drive_docker_image_download_policy_test.sh
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
script="$repo_root/scripts/docker-image/download_works_drive.sh"
|
||||
tmp_root="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$tmp_root"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
fail() {
|
||||
echo "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -x "$script" ]] || fail "download script must exist and be executable."
|
||||
grep -Fq 'baron-sso/${IMAGE_TAG}/${image}.${IMAGE_TAG}.tar.zst' "$script" \
|
||||
|| fail "download script must document the normalized archive path."
|
||||
grep -Fq -- '--location-trusted' "$script" \
|
||||
|| fail "download script must preserve Authorization across WORKS download redirects."
|
||||
|
||||
source_dir="$tmp_root/source"
|
||||
mkdir -p "$source_dir"
|
||||
printf 'backend image archive payload\n' >"$source_dir/backend.txt"
|
||||
tar -C "$source_dir" -cf "$tmp_root/backend.v1.2606.ab12.tar" backend.txt
|
||||
zstd -q -f -o "$tmp_root/backend.v1.2606.ab12.tar.zst" "$tmp_root/backend.v1.2606.ab12.tar"
|
||||
archive_sha256="$(sha256sum "$tmp_root/backend.v1.2606.ab12.tar.zst" | awk '{print $1}')"
|
||||
archive_size="$(wc -c <"$tmp_root/backend.v1.2606.ab12.tar.zst" | tr -d ' ')"
|
||||
printf '%s backend.v1.2606.ab12.tar.zst\n' "$archive_sha256" >"$tmp_root/backend.v1.2606.ab12.sha256"
|
||||
jq -n \
|
||||
--arg sha "$archive_sha256" \
|
||||
--argjson size "$archive_size" \
|
||||
'{
|
||||
schema_version: 1,
|
||||
format: "docker-save-zstd",
|
||||
image_ref: "baron_sso/backend:v1.2606.ab12",
|
||||
repository: "baron_sso/backend",
|
||||
release_repository: "baron-sso",
|
||||
image_name: "backend",
|
||||
tag: "v1.2606.ab12",
|
||||
remote_path: "baron-sso/v1.2606.ab12",
|
||||
archive: {
|
||||
file_name: "backend.v1.2606.ab12.tar.zst",
|
||||
sha256: $sha,
|
||||
size_bytes: $size
|
||||
},
|
||||
images: {
|
||||
backend: {
|
||||
archive: {
|
||||
file_name: "backend.v1.2606.ab12.tar.zst",
|
||||
sha256: $sha,
|
||||
size_bytes: $size
|
||||
}
|
||||
}
|
||||
}
|
||||
}' >"$tmp_root/manifest.v1.2606.ab12.json"
|
||||
|
||||
curl_log="$tmp_root/curl.log"
|
||||
docker_log="$tmp_root/docker.log"
|
||||
fake_curl="$tmp_root/curl"
|
||||
cat >"$fake_curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >>"$FAKE_CURL_LOG"
|
||||
|
||||
output_file=""
|
||||
args=("$@")
|
||||
for ((i = 0; i < ${#args[@]}; i += 1)); do
|
||||
if [[ "${args[$i]}" == "-o" ]]; then
|
||||
output_file="${args[$((i + 1))]}"
|
||||
fi
|
||||
done
|
||||
url="${args[-1]}"
|
||||
|
||||
if [[ -n "$output_file" ]]; then
|
||||
case "$url" in
|
||||
*/files/backend-archive/download) cp "$FAKE_WORKS_SOURCE/backend.v1.2606.ab12.tar.zst" "$output_file" ;;
|
||||
*/files/backend-checksum/download) cp "$FAKE_WORKS_SOURCE/backend.v1.2606.ab12.sha256" "$output_file" ;;
|
||||
*/files/backend-manifest/download) cp "$FAKE_WORKS_SOURCE/manifest.v1.2606.ab12.json" "$output_file" ;;
|
||||
*) echo "unexpected download URL: $url" >&2; exit 2 ;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$url" in
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files)
|
||||
printf '{"files":[{"fileId":"baron-sso-id","fileName":"baron-sso","fileType":"FOLDER"}]}\n200\n'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/baron-sso-id/children)
|
||||
printf '{"files":[{"fileId":"tag-id","fileName":"v1.2606.ab12","fileType":"FOLDER"}]}\n200\n'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/tag-id/children)
|
||||
printf '{"files":[{"fileId":"backend-archive","fileName":"backend.v1.2606.ab12.tar.zst","fileType":"FILE"},{"fileId":"backend-checksum","fileName":"backend.v1.2606.ab12.sha256","fileType":"FILE"},{"fileId":"backend-manifest","fileName":"manifest.v1.2606.ab12.json","fileType":"FILE"}]}\n200\n'
|
||||
;;
|
||||
*)
|
||||
echo "unexpected list URL: $url" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$fake_curl"
|
||||
|
||||
fake_bin="$tmp_root/bin"
|
||||
mkdir -p "$fake_bin"
|
||||
cat >"$fake_bin/docker" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$1" == "load" ]]; then
|
||||
bytes="$(wc -c | tr -d ' ')"
|
||||
printf 'docker load bytes=%s\n' "$bytes" >>"$FAKE_DOCKER_LOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected docker command: $*" >&2
|
||||
exit 2
|
||||
SH
|
||||
chmod +x "$fake_bin/docker"
|
||||
|
||||
PATH="$fake_bin:$PATH" \
|
||||
FAKE_CURL_LOG="$curl_log" \
|
||||
FAKE_DOCKER_LOG="$docker_log" \
|
||||
FAKE_WORKS_SOURCE="$tmp_root" \
|
||||
WORKS_DRIVE_CURL_BIN="$fake_curl" \
|
||||
WORKS_DRIVE_ACCESS_TOKEN="test-access-token" \
|
||||
WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID="shared-drive-1" \
|
||||
WORKS_DOCKER_IMAGE_NAMES="backend" \
|
||||
WORKS_DOCKER_IMAGE_DOWNLOAD_DIR="$tmp_root/downloaded" \
|
||||
IMAGE_TAG="v1.2606.ab12" \
|
||||
"$script" >/dev/null
|
||||
|
||||
grep -Fq "sharedrives/shared-drive-1/files" "$curl_log" \
|
||||
|| fail "download script must list the shared drive root."
|
||||
grep -Fq "sharedrives/shared-drive-1/files/baron-sso-id/children" "$curl_log" \
|
||||
|| fail "download script must resolve the baron-sso folder."
|
||||
grep -Fq "sharedrives/shared-drive-1/files/tag-id/children" "$curl_log" \
|
||||
|| fail "download script must resolve the image tag folder."
|
||||
grep -Fq "files/backend-archive/download" "$curl_log" \
|
||||
|| fail "download script must download the normalized backend archive."
|
||||
grep -Fq "docker load bytes=" "$docker_log" \
|
||||
|| fail "download script must load the downloaded archive into Docker."
|
||||
|
||||
echo "OK: WORKS Drive Docker image archive download flow resolves, verifies, and loads image artifacts"
|
||||
@@ -14,19 +14,19 @@ fail() {
|
||||
[[ -f "$script" ]] || fail "WORKS Drive Docker image upload script must exist."
|
||||
[[ -f "$doc" ]] || fail "WORKS Drive Docker image archive design document must exist."
|
||||
|
||||
grep -Fq 'WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-docker-build-image' "$script" \
|
||||
|| fail "script must default WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR to docker-build-image."
|
||||
grep -Fq 'WORKS_DRIVE_DOCKER_IMAGE_DIR:-${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-baron-sso}' "$script" \
|
||||
|| fail "script must default WORKS_DRIVE_DOCKER_IMAGE_DIR to baron-sso with legacy fallback."
|
||||
grep -Fq 'docker commit' "$script" \
|
||||
|| fail "script must support committing a local container before image export."
|
||||
grep -Fq 'docker save' "$script" \
|
||||
|| fail "script must use docker save for CLI-compatible image artifacts."
|
||||
grep -Fq 'zstd' "$script" \
|
||||
|| fail "script must compress Docker image archives with zstd."
|
||||
grep -Fq 'manifest.json' "$script" \
|
||||
|| fail "script must write a manifest.json next to the image archive."
|
||||
grep -Fq 'image.tar.zst.sha256' "$script" \
|
||||
grep -Fq 'manifest.${image_tag}.json' "$script" \
|
||||
|| fail "script must write a versioned manifest next to the image archive."
|
||||
grep -Fq '${image_name}.${image_tag}.sha256' "$script" \
|
||||
|| fail "script must write a checksum file for the compressed image archive."
|
||||
grep -Fq 'docker-build-image/baron_sso/backend/v1.2606.ab12' "$doc" \
|
||||
grep -Fq 'baron-sso/v1.2606.ab12/backend.v1.2606.ab12.tar.zst' "$doc" \
|
||||
|| fail "document must describe the expected WORKS Drive folder layout."
|
||||
grep -Fq 'debian:trixie-slim' "$doc" \
|
||||
|| fail "document must use debian:trixie-slim for smoke image examples."
|
||||
@@ -124,24 +124,12 @@ case "$last_arg" in
|
||||
printf '{"files":[]}'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/root-folder/createfolder)
|
||||
printf '{"fileId":"docker-build-image-id","fileName":"docker-build-image","fileType":"FOLDER"}'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/docker-build-image-id/children)
|
||||
printf '{"files":[]}'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/docker-build-image-id/createfolder)
|
||||
printf '{"fileId":"baron-sso-id","fileName":"baron_sso","fileType":"FOLDER"}'
|
||||
printf '{"fileId":"baron-sso-id","fileName":"baron-sso","fileType":"FOLDER"}'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/baron-sso-id/children)
|
||||
printf '{"files":[]}'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/baron-sso-id/createfolder)
|
||||
printf '{"fileId":"backend-id","fileName":"backend","fileType":"FOLDER"}'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/backend-id/children)
|
||||
printf '{"files":[]}'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/backend-id/createfolder)
|
||||
printf '{"fileId":"tag-id","fileName":"v1.2606.ab12","fileType":"FOLDER"}'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/tag-id)
|
||||
@@ -175,41 +163,41 @@ WORKS_DOCKER_COMMIT_CONTAINER="baron_backend" \
|
||||
DOCKER_IMAGE_REF="registry.example/baron_sso/backend:v1.2606.ab12" \
|
||||
"$script" >"$tmp_dir/upload.out"
|
||||
|
||||
artifact_dir="$archive_dir/baron_sso/backend/v1.2606.ab12"
|
||||
[[ -f "$artifact_dir/image.tar.zst" ]] || fail "script must create image.tar.zst."
|
||||
[[ -f "$artifact_dir/image.tar.zst.sha256" ]] || fail "script must create image.tar.zst.sha256."
|
||||
[[ -f "$artifact_dir/manifest.json" ]] || fail "script must create manifest.json."
|
||||
artifact_dir="$archive_dir/baron-sso/v1.2606.ab12"
|
||||
[[ -f "$artifact_dir/backend.v1.2606.ab12.tar.zst" ]] || fail "script must create backend.v1.2606.ab12.tar.zst."
|
||||
[[ -f "$artifact_dir/backend.v1.2606.ab12.sha256" ]] || fail "script must create backend.v1.2606.ab12.sha256."
|
||||
[[ -f "$artifact_dir/manifest.v1.2606.ab12.json" ]] || fail "script must create manifest.v1.2606.ab12.json."
|
||||
|
||||
jq -e \
|
||||
'.schema_version == 1
|
||||
and .format == "docker-save-zstd"
|
||||
and .image_ref == "registry.example/baron_sso/backend:v1.2606.ab12"
|
||||
and .repository == "baron_sso/backend"
|
||||
and .tag == "v1.2606.ab12"
|
||||
and .remote_path == "docker-build-image/baron_sso/backend/v1.2606.ab12"
|
||||
and .archive.file_name == "image.tar.zst"
|
||||
and (.archive.sha256 | type == "string")' \
|
||||
"$artifact_dir/manifest.json" >/dev/null || fail "manifest must describe the image archive and remote path."
|
||||
and .release_repository == "baron-sso"
|
||||
and .image_name == "backend"
|
||||
and .tag == "v1.2606.ab12"
|
||||
and .remote_path == "baron-sso/v1.2606.ab12"
|
||||
and .archive.file_name == "backend.v1.2606.ab12.tar.zst"
|
||||
and (.archive.sha256 | type == "string")
|
||||
and .images.backend.archive.file_name == "backend.v1.2606.ab12.tar.zst"
|
||||
and .images.backend.archive.sha256 == .archive.sha256' \
|
||||
"$artifact_dir/manifest.v1.2606.ab12.json" >/dev/null || fail "manifest must describe the image archive and remote path."
|
||||
|
||||
grep -Fq "docker commit baron_backend registry.example/baron_sso/backend:v1.2606.ab12" "$docker_log" \
|
||||
|| fail "script must commit the requested container into the requested image ref."
|
||||
grep -Fq "docker save -o" "$docker_log" \
|
||||
|| fail "script must save the requested image."
|
||||
grep -Fq "sharedrives/shared-drive-1/files/root-folder/createfolder" "$curl_log" \
|
||||
|| fail "script must create the top-level docker-build-image folder when needed."
|
||||
grep -Fq "docker-build-image" "$curl_log" \
|
||||
|| fail "script must use WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR in folder creation."
|
||||
grep -Fq "baron_sso" "$curl_log" \
|
||||
|| fail "script must create the top-level archive folder when needed."
|
||||
grep -Fq "baron-sso" "$curl_log" \
|
||||
|| fail "script must create repository namespace folder."
|
||||
grep -Fq "backend" "$curl_log" \
|
||||
|| fail "script must create image repository folder."
|
||||
grep -Fq "v1.2606.ab12" "$curl_log" \
|
||||
|| fail "script must create tag folder."
|
||||
grep -Fq "image.tar.zst" "$curl_log" \
|
||||
grep -Fq "backend.v1.2606.ab12.tar.zst" "$curl_log" \
|
||||
|| fail "script must upload the compressed image archive."
|
||||
grep -Fq "image.tar.zst.sha256" "$curl_log" \
|
||||
grep -Fq "backend.v1.2606.ab12.sha256" "$curl_log" \
|
||||
|| fail "script must upload the checksum file."
|
||||
grep -Fq "manifest.json" "$curl_log" \
|
||||
grep -Fq "manifest.v1.2606.ab12.json" "$curl_log" \
|
||||
|| fail "script must upload the manifest file."
|
||||
|
||||
report_file="$artifact_dir/works-upload.json"
|
||||
@@ -217,4 +205,68 @@ report_file="$artifact_dir/works-upload.json"
|
||||
jq -e '.status == "uploaded" and (.files | length) == 3' "$report_file" >/dev/null \
|
||||
|| fail "upload report must include three uploaded artifact files."
|
||||
|
||||
root_curl_log="$tmp_dir/root-curl.log"
|
||||
root_fake_curl="$tmp_dir/root-fake-curl.sh"
|
||||
cat >"$root_fake_curl" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$*" >>"${FAKE_CURL_LOG}"
|
||||
last_arg="${!#}"
|
||||
|
||||
case "$last_arg" in
|
||||
https://www.worksapis.com/v1.0/sharedrives/root-drive/files)
|
||||
printf '{"files":[]}\n200'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/root-drive/files/createfolder)
|
||||
create_count_file="${FAKE_CURL_LOG}.root-create-count"
|
||||
create_count=0
|
||||
[[ -f "$create_count_file" ]] && create_count="$(cat "$create_count_file")"
|
||||
create_count=$((create_count + 1))
|
||||
printf '%s' "$create_count" >"$create_count_file"
|
||||
if [[ "$create_count" -eq 1 ]]; then
|
||||
printf '{"fileId":"root-baron-sso-id","fileName":"baron-sso","fileType":"FOLDER"}\n200'
|
||||
else
|
||||
printf '{"code":"RESOURCE_ALREADY_EXIST","description":"Resource already exists."}\n409'
|
||||
fi
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/root-drive/files/root-baron-sso-id/children)
|
||||
printf '{"files":[]}\n200'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/root-drive/files/root-baron-sso-id/createfolder)
|
||||
printf '{"fileId":"root-tag-id","fileName":"v1.2606.ab12","fileType":"FOLDER"}\n200'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/root-drive/files/root-tag-id)
|
||||
printf '{"uploadUrl":"https://upload.example.test/root-docker-image"}\n200'
|
||||
;;
|
||||
https://upload.example.test/root-docker-image)
|
||||
printf '{"fileId":"uploaded-root-file-id"}\n200'
|
||||
;;
|
||||
*)
|
||||
echo "unexpected root curl URL: $last_arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$root_fake_curl"
|
||||
|
||||
root_archive_dir="$tmp_dir/root-archive"
|
||||
for image in backend userfront; do
|
||||
FAKE_DOCKER_LOG="$docker_log" \
|
||||
FAKE_CURL_LOG="$root_curl_log" \
|
||||
PATH="$fake_bin:$PATH" \
|
||||
WORKS_DRIVE_ACCESS_TOKEN="test-access-token" \
|
||||
WORKS_DRIVE_TARGET="sharedrive" \
|
||||
WORKS_DRIVE_SHARED_DRIVE_ID="root-drive" \
|
||||
WORKS_DRIVE_PARENT_FILE_ID="" \
|
||||
WORKS_DRIVE_CURL_BIN="$root_fake_curl" \
|
||||
WORKS_DOCKER_IMAGE_ARCHIVE_DIR="$root_archive_dir" \
|
||||
DOCKER_IMAGE_REF="baron_sso/${image}:v1.2606.ab12" \
|
||||
"$script" >"$tmp_dir/root-${image}.out"
|
||||
done
|
||||
|
||||
root_create_count="$(cat "${root_curl_log}.root-create-count")"
|
||||
[[ "$root_create_count" == "1" ]] || fail "script must reuse the cached root archive folder id across image uploads in the same run."
|
||||
grep -Fq "sharedrives/root-drive/files/root-tag-id" "$root_curl_log" \
|
||||
|| fail "script must upload follow-up images into the cached tag folder."
|
||||
|
||||
echo "OK: WORKS Drive Docker image archive upload flow commits, packages, and uploads image artifacts"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
bool isRefreshTokenScopeAlias(String scope) {
|
||||
bool isOfflineScopeAlias(String scope) {
|
||||
final normalized = scope.trim().toLowerCase();
|
||||
return normalized == 'offline' || normalized == 'offline_access';
|
||||
return normalized == 'offline';
|
||||
}
|
||||
|
||||
List<String> filterConsentScopes(Iterable<String> scopes) {
|
||||
return scopes
|
||||
.map((scope) => scope.trim())
|
||||
.where((scope) => scope.isNotEmpty && !isRefreshTokenScopeAlias(scope))
|
||||
.where((scope) => scope.isNotEmpty && !isOfflineScopeAlias(scope))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/i18n/locale_utils.dart';
|
||||
import '../../../core/services/auth_token_store.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/ui/toast_service.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
@@ -48,7 +51,18 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
);
|
||||
if (mounted) {
|
||||
ToastService.success(tr('msg.userfront.forgot.sent'));
|
||||
Navigator.of(context).pop();
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
final isLoggedIn = AuthTokenStore.hasToken();
|
||||
final localeCode =
|
||||
extractLocaleFromPath(Uri.base) ?? resolvePreferredLocaleCode();
|
||||
if (isLoggedIn) {
|
||||
context.go('/$localeCode/profile');
|
||||
} else {
|
||||
context.go('/$localeCode/signin');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -1097,11 +1097,6 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
)
|
||||
: Text(tr('ui.userfront.profile.password.change')),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/recovery'),
|
||||
child: Text(tr('ui.userfront.profile.password.forgot')),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:userfront/features/auth/domain/consent_scope_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('consent scope policy', () {
|
||||
test('filters offline scope aliases from requested consent scopes', () {
|
||||
test('keeps offline_access visible and filters only offline', () {
|
||||
expect(
|
||||
filterConsentScopes([
|
||||
'openid',
|
||||
@@ -12,14 +12,14 @@ void main() {
|
||||
'offline_access',
|
||||
'email',
|
||||
]),
|
||||
['openid', 'profile', 'email'],
|
||||
['openid', 'profile', 'offline_access', 'email'],
|
||||
);
|
||||
});
|
||||
|
||||
test('detects refresh token scope aliases case-insensitively', () {
|
||||
expect(isRefreshTokenScopeAlias('OFFLINE'), isTrue);
|
||||
expect(isRefreshTokenScopeAlias(' offline_access '), isTrue);
|
||||
expect(isRefreshTokenScopeAlias('profile'), isFalse);
|
||||
test('detects offline scope alias case-insensitively', () {
|
||||
expect(isOfflineScopeAlias('OFFLINE'), isTrue);
|
||||
expect(isOfflineScopeAlias(' offline_access '), isFalse);
|
||||
expect(isOfflineScopeAlias('profile'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user