1
0
forked from baron/baron-sso

4 Commits

Author SHA1 Message Date
9464c15698 merge upstream 2026-06-18 11:12:58 +09:00
4089455985 Update docs/setup/WSL · Ubuntu · Docker 이해하기.md 2026-06-12 07:57:24 +09:00
a6e2b9bc29 WSL · Ubuntu · Docker 이해하기
내가 사용할 개발환경에 대한 이해를 돕기 위하여 작성
2026-06-12 07:56:52 +09:00
bbf25683ce WSL2 Docker Engine 개발환경 구축 문서 추가
2026-06-11 작업 내용 정리

- WSL2 설치
- Ubuntu 환경 구성
- Docker Engine 설치
- Ory Stack 기동
- CRLF 문제 해결
- Gateway/UserFront/Backend 정상화
- 기능검증 전 개발환경 구축 완료
2026-06-11 16:51:22 +09:00
72 changed files with 3156 additions and 4484 deletions

View File

@@ -254,46 +254,41 @@ 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
pnpm install -C ../common --no-frozen-lockfile
pnpm install --no-frozen-lockfile
npx pnpm install -C ../common --no-frozen-lockfile
npx pnpm install --no-frozen-lockfile
- name: Biome check adminfront
run: |
cd adminfront
pnpm exec biome check . --formatter-enabled=false --assist-enabled=false
pnpm exec biome check . --linter-enabled=false --assist-enabled=false
npx biome check . --formatter-enabled=false --assist-enabled=false
npx biome check . --linter-enabled=false --assist-enabled=false
- name: Install devfront dependencies
run: |
cd devfront
pnpm install -C ../common --no-frozen-lockfile
pnpm install --no-frozen-lockfile
npx pnpm install -C ../common --no-frozen-lockfile
npx pnpm install --no-frozen-lockfile
- name: Biome check devfront
run: |
cd devfront
pnpm exec biome check . --formatter-enabled=false --assist-enabled=false
pnpm exec biome check . --linter-enabled=false --assist-enabled=false
npx biome check . --formatter-enabled=false --assist-enabled=false
npx biome check . --linter-enabled=false --assist-enabled=false
- name: Install orgfront dependencies
run: |
cd orgfront
pnpm install -C ../common --no-frozen-lockfile
pnpm install --no-frozen-lockfile
npx pnpm install -C ../common --no-frozen-lockfile
npx pnpm install --no-frozen-lockfile
- name: Biome check orgfront
run: |
cd orgfront
pnpm exec biome check . --formatter-enabled=false --assist-enabled=false
pnpm exec biome check . --linter-enabled=false --assist-enabled=false
npx biome check . --formatter-enabled=false --assist-enabled=false
npx biome check . --linter-enabled=false --assist-enabled=false
backend-tests:
needs:
@@ -736,9 +731,9 @@ jobs:
set +e
cd userfront-e2e
if [ "$USERFRONT_E2E_FULL" = "true" ]; then
test_command="npx playwright test"
test_command="npm test"
else
test_command="npx playwright test --project=chromium-desktop --project=chromium-mobile-webapp"
test_command="npm test -- --project=chromium-desktop --project=chromium-mobile-webapp"
fi
workers="${USERFRONT_E2E_WORKERS:-2}"
case "$workers" in
@@ -764,10 +759,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. \`npx playwright test\`"
echo "5. \`npm test\`"
else
echo "4. \`cd ../userfront-e2e && npx playwright install --with-deps chromium\`"
echo "5. \`npx playwright test --project=chromium-desktop --project=chromium-mobile-webapp\`"
echo "5. \`npm test -- --project=chromium-desktop --project=chromium-mobile-webapp\`"
fi
echo
echo "## Log Tail (last 200 lines)"
@@ -1255,11 +1250,6 @@ 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: |
@@ -1333,11 +1323,10 @@ jobs:
path: |
reports/adminfront-test-failure-report.md
reports/adminfront-install.log
reports/adminfront-build.log
reports/adminfront-provision.log
reports/adminfront-test.log
reports/adminfront-playwright-report
reports/adminfront-test-results
adminfront/playwright-report
adminfront/test-results
if-no-files-found: ignore
devfront-tests:
@@ -1355,11 +1344,6 @@ 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

View File

@@ -1,244 +0,0 @@
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

View File

@@ -29,63 +29,45 @@ 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.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 }}
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.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.PROD_ADMINFRONT_PORT }}
DEVFRONT_PORT: ${{ vars.PROD_DEVFRONT_PORT }}
ORGFRONT_PORT: ${{ vars.PROD_ORGFRONT_PORT }}
ADMINFRONT_PORT: ${{ vars.ADMINFRONT_PORT }}
DEVFRONT_PORT: ${{ vars.DEVFRONT_PORT }}
ORGFRONT_PORT: ${{ vars.ORGFRONT_PORT }}
IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT: ${{ vars.PROD_OATHKEEPER_PROXY_PORT }}
IMAGE_DEPLOY_DOMAIN_SUFFIX: ${{ vars.PROD_DOMAIN_SUFFIX }}
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
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
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 }}
@@ -106,16 +88,9 @@ jobs:
DEPLOY_HOST: ${{ vars.PROD_HOST }}
DEPLOY_USER: ${{ vars.PROD_USER }}
DEPLOY_PATH: ${{ vars.PROD_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 }}
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
run: |
set -euo pipefail
scripts/deploy/upload_and_run_image_deploy.sh

View File

@@ -0,0 +1,182 @@
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

View File

@@ -101,33 +101,33 @@ jobs:
"PROD_BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
"BACKEND_PORT=3000" \
"USERFRONT_PORT=${{ vars.PROD_FRONTEND_PORT }}" \
"ADMINFRONT_PORT=${{ vars.PROD_ADMINFRONT_PORT }}" \
"DEVFRONT_PORT=${{ vars.PROD_DEVFRONT_PORT }}" \
"ORGFRONT_PORT=${{ vars.PROD_ORGFRONT_PORT }}" \
"ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}" \
"DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}" \
"ORGFRONT_PORT=${{ vars.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=${{ 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 }}" \
"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 }}" \
"USERFRONT_URL=${{ vars.PROD_FRONTEND_URL }}" \
"ADMINFRONT_URL=${{ vars.PROD_ADMINFRONT_URL }}" \
"DEVFRONT_URL=${{ vars.PROD_DEVFRONT_URL }}" \
"ORGFRONT_URL=${{ vars.PROD_ORGFRONT_URL }}" \
"ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}" \
"DEVFRONT_URL=${{ vars.DEVFRONT_URL }}" \
"ORGFRONT_URL=${{ vars.ORGFRONT_URL }}" \
"BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \
"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 }}" \
"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 }}" \
> .env
required_dotenv_keys="

View File

@@ -18,13 +18,13 @@ jobs:
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.STG_SSH_PRIVATE_KEY }}
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
- name: Deploy to Staging by git pull
env:
DEPLOY_PATH: ${{ vars.STG_DEPLOY_PATH }}
STAGE_HOST: ${{ vars.STG_HOST }}
STAGE_USER: ${{ vars.STG_USER }}
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
STAGE_HOST: ${{ vars.STAGE_HOST }}
STAGE_USER: ${{ vars.STAGE_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.STG_WORKS_ADMIN_API_BASE_URL }}
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.STG_WORKS_ADMIN_OAUTH_TOKEN_URL }}
WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
TZ=Asia/Seoul
IDP_PROVIDER=ory
# DB & Clickhouse
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 }}
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 }}
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 }}
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 }}
OATHKEEPER_API_URL=${{ vars.STG_OATHKEEPER_API_URL }}
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
DB_USER=${{ vars.STG_DB_USER }}
DB_USER=${{ vars.DB_USER }}
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
DB_NAME=${{ vars.STG_DB_NAME }}
DB_NAME=${{ vars.DB_NAME }}
COOKIE_SECRET=${{ secrets.STG_COOKIE_SECRET }}
JWT_SECRET=${{ secrets.STG_JWT_SECRET }}
REDIS_ADDR=${{ vars.STG_REDIS_ADDR }}
CORS_ALLOWED_ORIGINS=${{ vars.STG_CORS_ALLOWED_ORIGINS }}
REDIS_ADDR=${{ vars.REDIS_ADDR }}
CORS_ALLOWED_ORIGINS=${{ vars.CORS_ALLOWED_ORIGINS }}
AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000
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 }}
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 }}
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
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 }}
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 }}
ORY_POSTGRES_PASSWORD=${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
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 }}
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 }}
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
# Frontend/Ory URL configs for Staging
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 }}
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 }}
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
# Monitoring & Alerts
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' }}
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' }}
EOF
# 코드 업데이트 (Git)

View File

@@ -18,78 +18,60 @@ jobs:
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.STG_SSH_PRIVATE_KEY }}
ssh-private-key: ${{ secrets.STAGE_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.STG_INSTANCE_NAME }}
IMAGE_DEPLOY_PORT_PREFIX: ${{ vars.STG_PORT_PREFIX }}
IMAGE_DEPLOY_PUBLIC_URL: ${{ vars.STG_USERFRONT_URL }}
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_COMPOSE_TEMPLATE: deploy/templates/docker-compose.images.yaml
IMAGE_DEPLOY_BUNDLE_FILE: stage-image-deploy-bundle.tgz
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
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
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.STG_CLICKHOUSE_PASSWORD }}
IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.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 }}
@@ -101,19 +83,12 @@ jobs:
- name: Upload bundle and run requested staging image tag
env:
IMAGE_DEPLOY_BUNDLE_FILE: stage-image-deploy-bundle.tgz
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 }}
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 }}
run: |
set -euo pipefail
scripts/deploy/upload_and_run_image_deploy.sh

View File

@@ -18,7 +18,7 @@ jobs:
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.STG_SSH_PRIVATE_KEY }}
ssh-private-key: ${{ secrets.STAGE_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.STG_DEPLOY_PATH }}
STAGE_HOST: ${{ vars.STG_HOST }}
STAGE_USER: ${{ vars.STG_USER }}
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
STAGE_HOST: ${{ vars.STAGE_HOST }}
STAGE_USER: ${{ vars.STAGE_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.STG_WORKS_ADMIN_API_BASE_URL }}
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.STG_WORKS_ADMIN_OAUTH_TOKEN_URL }}
WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
TZ=Asia/Seoul
IDP_PROVIDER=ory
# DB & Clickhouse
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 }}
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 }}
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 }}
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 }}
OATHKEEPER_API_URL=${{ vars.STG_OATHKEEPER_API_URL }}
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
DB_USER=${{ vars.STG_DB_USER }}
DB_USER=${{ vars.DB_USER }}
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
DB_NAME=${{ vars.STG_DB_NAME }}
DB_NAME=${{ vars.DB_NAME }}
COOKIE_SECRET=${{ secrets.STG_COOKIE_SECRET }}
JWT_SECRET=${{ secrets.STG_JWT_SECRET }}
REDIS_ADDR=${{ vars.STG_REDIS_ADDR }}
CORS_ALLOWED_ORIGINS=${{ vars.STG_CORS_ALLOWED_ORIGINS }}
REDIS_ADDR=${{ vars.REDIS_ADDR }}
CORS_ALLOWED_ORIGINS=${{ vars.CORS_ALLOWED_ORIGINS }}
AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000
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 }}
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 }}
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
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 }}
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 }}
ORY_POSTGRES_PASSWORD=${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
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 }}
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 }}
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
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 }}
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 }}
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
EOF

View File

@@ -31,10 +31,6 @@ 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 ?=
@@ -55,7 +51,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 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
.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
help: ## 생성된 타깃과 옵션 목록 표시
@printf "Usage:\n make <target> [OPTION=value ...]\n\n"
@@ -318,14 +314,10 @@ backup-tools-build: ## 백업 도구 Docker 이미지 빌드
ifeq ($(BACKUP_USE_DOCKER),true)
dump: backup-tools-build ## 백업 덤프 생성
$(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'
$(BACKUP_DOCKER_RUN) bash -lc 'DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/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)" RESTORE_DATASET="$(RESTORE_DATASET)" 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)" 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'
@@ -337,7 +329,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)" RESTORE_DATASET="$(RESTORE_DATASET)" 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)" 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'
@@ -346,14 +338,10 @@ 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_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
DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh
restore: ensure-restore-containers ## 백업 덤프 복구
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
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
dump-verify: ## 백업 덤프 검증
BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh
@@ -365,7 +353,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)" RESTORE_DATASET="$(RESTORE_DATASET)" 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)" 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
@@ -379,9 +367,6 @@ 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

View File

@@ -1040,7 +1040,7 @@ func normalizePhoneForLoginID(phone string) string {
return domain.NormalizePhoneNumber(phone)
}
func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string, profileStatus string) map[string]any {
func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID string) map[string]any {
claims := map[string]any{}
if traits == nil {
return claims
@@ -1089,27 +1089,28 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID
if _, ok := scopeSet["profile"]; ok {
profile := map[string]any{}
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
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
}
}
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
}
@@ -1214,63 +1215,13 @@ 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, profileStatus string) map[string]any {
claims := buildOidcClaimsFromTraits(traits, scopes, tenantID, profileStatus)
func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any {
claims := buildOidcClaimsFromTraits(traits, scopes, tenantID)
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{}
@@ -1413,7 +1364,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), "tenants") || strings.EqualFold(strings.TrimSpace(scope), "tenant") {
if strings.EqualFold(strings.TrimSpace(scope), "tenant") {
return true
}
}
@@ -4193,17 +4144,7 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
ale.LoginIDs["loginId_normalized"] = loginID
userfrontURL := h.resolveUserfrontURL(c)
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))
redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password")
if parseErr != nil {
ale.Status = fiber.StatusInternalServerError
ale.LatencyMs = time.Since(startTime)
@@ -6344,7 +6285,6 @@ 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)
@@ -6383,7 +6323,6 @@ 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)
@@ -6575,7 +6514,6 @@ 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)

View File

@@ -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": "tenants", "mandatory": true, "locked": true},
{"name": "tenant", "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", "tenants", "profile"}, body["requested_scope"])
assert.Equal(t, []any{"openid", "tenant", "profile"}, body["requested_scope"])
scopeDetails := body["scope_details"].(map[string]any)
tenantDetail := scopeDetails["tenants"].(map[string]any)
tenantDetail := scopeDetails["tenant"].(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": "tenants", "mandatory": true, "locked": true},
{"name": "tenant", "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", "tenants", "profile"}, capturedGrantScopes)
assert.Equal(t, []string{"openid", "tenant", "profile"}, capturedGrantScopes)
}

View File

@@ -70,12 +70,9 @@ func TestWithRefreshTokenExpiryClaim_UsesHydraRefreshTokenTTL(t *testing.T) {
func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
traits := map[string]any{
"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
"email": "user@baron.com",
"name": "홍길동",
"tenant_id": "primary-tenant-999", // Added primary tenant
"tenant-1": map[string]any{
"department": "개발팀",
"grade": "선임",
@@ -88,19 +85,12 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
scopes := []string{"openid", "profile"}
t.Run("No tenantID", func(t *testing.T) {
claims := buildOidcClaimsFromTraits(traits, scopes, "", "leave_of_absence")
claims := buildOidcClaimsFromTraits(traits, scopes, "")
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")
@@ -109,19 +99,12 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
})
t.Run("With tenant-1", func(t *testing.T) {
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1", "leave_of_absence")
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1")
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")
@@ -130,56 +113,35 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
})
t.Run("With tenant-2", func(t *testing.T) {
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2", "leave_of_absence")
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-2")
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", "leave_of_absence")
claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-3")
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("Tenants scope includes detailed tenant metadata", func(t *testing.T) {
claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenants"}, "tenant-1", "leave_of_absence")
t.Run("Tenant scope includes detailed tenant metadata", func(t *testing.T) {
claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenant"}, "tenant-1")
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")
@@ -228,7 +190,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", "tenants"},
"requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-123",
"client": map[string]any{
"client_id": "client-app",
@@ -298,7 +260,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-dynamic",
"grant_scope": []string{"openid", "profile", "tenants"},
"grant_scope": []string{"openid", "profile", "tenant"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -328,7 +290,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", "tenants"},
"requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-representative",
"client": map[string]any{
"client_id": "client-app",
@@ -405,7 +367,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", "tenants"},
"requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-hanmac",
"client": map[string]any{
"client_id": "hanmac-rp",
@@ -500,7 +462,7 @@ func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.
reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-hanmac-tenant-claim",
"grant_scope": []string{"openid", "profile", "tenants"},
"grant_scope": []string{"openid", "profile", "tenant"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
@@ -612,7 +574,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", "tenants"},
"requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-123",
"client": map[string]any{
"client_id": "client-app",
@@ -704,7 +666,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", "tenants"},
"requested_scope": []string{"openid", "profile", "tenant"},
"skip": true,
"subject": "user-456",
"client": map[string]any{
@@ -883,7 +845,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"])
@@ -899,7 +861,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", "tenants"},
"requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-789",
"client": map[string]any{
"client_id": "client-configured-claims",
@@ -1011,7 +973,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", "tenants"},
"requested_scope": []string{"openid", "profile", "tenant"},
"subject": "user-rp-claims",
"client": map[string]any{
"client_id": "client-rp-claims",
@@ -1157,7 +1119,7 @@ func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T)
reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-rp-user-claims",
"grant_scope": []string{"openid", "profile", "tenants"},
"grant_scope": []string{"openid", "profile", "tenant"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")

View File

@@ -463,7 +463,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
out := make([]string, 0, len(combined))
appendIfPresent := func(scope string) {
scope = canonicalConsentScopeName(scope)
scope = strings.TrimSpace(scope)
if scope == "" || isLegacyRefreshTokenScopeAlias(scope) {
return
}
@@ -471,7 +471,7 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
return
}
for _, candidate := range combined {
if canonicalConsentScopeName(candidate) != scope {
if strings.TrimSpace(candidate) != scope {
continue
}
seen[scope] = struct{}{}
@@ -481,10 +481,10 @@ func normalizeScopesInConsentOrder(scopes []string) []string {
}
appendIfPresent("openid")
appendIfPresent("tenants")
appendIfPresent("tenant")
for _, scope := range combined {
scope = canonicalConsentScopeName(scope)
scope = strings.TrimSpace(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, "tenants")
required = append(required, "tenant")
}
if client.Metadata == nil {
@@ -535,12 +535,3 @@ 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)
}
}

View File

@@ -121,20 +121,20 @@ func TestCreateClient_RejectsTenantAccessWithoutAllowedTenants(t *testing.T) {
assert.False(t, hydraCalled)
}
func TestMergeRequestedScopesWithClientRequirements_AddsTenantsScope(t *testing.T) {
func TestMergeRequestedScopesWithClientRequirements_AddsTenantScope(t *testing.T) {
client := domain.HydraClient{
Metadata: map[string]any{
"tenant_access_restricted": true,
"structured_scopes": []map[string]any{
{"name": "openid", "mandatory": true},
{"name": "tenants", "mandatory": true, "locked": true},
{"name": "tenant", "mandatory": true, "locked": true},
{"name": "profile", "mandatory": false},
},
},
}
merged := mergeRequestedScopesWithClientRequirements(client, []string{"openid", "profile"})
assert.Equal(t, []string{"openid", "tenants", "profile"}, merged)
assert.Equal(t, []string{"openid", "tenant", "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", "tenants", "profile", "offline_access", "email"}, merged)
assert.Equal(t, []string{"openid", "tenant", "profile", "offline_access", "email"}, merged)
}
func TestBuildHydraAuthorizationURL_StripsRefreshTokenScopeAliases(t *testing.T) {

View File

@@ -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 tenants",
"scope": "openid profile email tenant",
"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", "tenants"},
"scopes": []string{"openid", "profile", "email", "tenant"},
"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 tenants profile email",
Scope: "openid tenant 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 tenants profile email",
Scope: "openid tenant profile email",
TokenEndpointAuthMethod: "none",
Metadata: map[string]any{
"tenant_access_restricted": true,

View File

@@ -29,23 +29,6 @@ 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);
}
@@ -99,7 +82,7 @@ function parseToml(raw: string): TomlObject {
cursor = cursor[section] as TomlObject;
}
setTomlValue(cursor, key.split("."), value);
cursor[key] = value;
}
return root;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 301 KiB

View File

@@ -43,13 +43,17 @@ export const devFrontRoutes: RouteObject[] = [
},
{
path: "/",
element: <AuthGuard />,
children: [
{
element: <AppLayout />,
children: devFrontAppChildren,
},
],
element:
import.meta.env.MODE === "development" ? <AppLayout /> : <AuthGuard />,
children:
import.meta.env.MODE === "development"
? devFrontAppChildren
: [
{
element: <AppLayout />,
children: devFrontAppChildren,
},
],
},
];

View File

@@ -2,7 +2,6 @@ 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();
@@ -17,7 +16,7 @@ export default function AuthCallbackPage() {
return;
}
if (auth.isAuthenticated && isValidOidcSessionUser(auth.user)) {
if (auth.isAuthenticated) {
const returnTo =
typeof auth.user?.state === "object" &&
auth.user?.state !== null &&
@@ -30,7 +29,7 @@ export default function AuthCallbackPage() {
console.error("Auth Error:", auth.error);
navigate("/login", { replace: true });
}
}, [auth.isAuthenticated, auth.error, navigate, auth.user]);
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
return <div>Loading Auth...</div>;
}

View File

@@ -2,26 +2,22 @@ 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,
isValidOidcSessionUser,
} from "../../lib/oidcStorage";
import { findPersistedOidcUser } 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;
._IS_TEST_MODE === true || navigator.webdriver === true;
useEffect(() => {
let cancelled = false;
if (isTestMode) {
if (isDevelopmentMode || isTestMode) {
setHasStoredUser(true);
return () => {
cancelled = true;
@@ -40,7 +36,7 @@ export default function AuthGuard() {
.getUser()
.then((user) => {
if (!cancelled) {
setHasStoredUser(isValidOidcSessionUser(user));
setHasStoredUser(Boolean(user && !user.expired));
}
})
.catch(() => {
@@ -54,7 +50,7 @@ export default function AuthGuard() {
};
}, [isTestMode]);
if (isTestMode) {
if (isDevelopmentMode || isTestMode) {
return <Outlet />;
}
@@ -80,7 +76,7 @@ export default function AuthGuard() {
);
}
if (!hasActiveAuthUser && !hasStoredUser) {
if (!auth.isAuthenticated && !hasStoredUser) {
return <Navigate to="/login" replace />;
}

View File

@@ -11,7 +11,6 @@ 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)을 정확히 등록해 주세요.";
@@ -40,14 +39,12 @@ function LoginPage() {
return message;
}, [auth.error?.message]);
const visibleLoginError = loginError || authErrorMessage;
const hasActiveAuthUser =
auth.isAuthenticated && isValidOidcSessionUser(auth.user);
useEffect(() => {
if (hasActiveAuthUser) {
if (auth.isAuthenticated) {
navigate(returnTo, { replace: true });
}
}, [hasActiveAuthUser, navigate, returnTo]);
}, [auth.isAuthenticated, navigate, returnTo]);
useEffect(() => {
if (!shouldAutoLogin) {

View File

@@ -2,22 +2,21 @@ 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 AuthTestUser | undefined,
user: undefined as
| {
state?: unknown;
}
| undefined,
signinRedirect: vi.fn(),
};
@@ -34,19 +33,7 @@ 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;
@@ -150,50 +137,13 @@ 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 = createAuthUser({ state: { returnTo: "/profile" } });
authState.user = { state: { returnTo: "/profile" } };
const authenticated = await renderWithRouter(<AuthCallbackPage />, {
entry: "/auth/callback",

View File

@@ -91,7 +91,7 @@ function makeClientDetail(
if (includeTenantScope) {
structuredScopes.push({
id: "2",
name: "tenants",
name: "tenant",
description: "Tenant access",
mandatory: tenantScopeMandatory,
locked: tenantAccessRestricted,
@@ -106,7 +106,7 @@ function makeClientDetail(
status: "active",
redirectUris: ["https://rp.example.com/callback"],
scopes: includeTenantScope
? ["openid", "tenants", "profile"]
? ["openid", "tenant", "profile"]
: ["openid", "profile"],
tokenEndpointAuthMethod: "client_secret_basic",
metadata: {
@@ -334,48 +334,7 @@ describe("ClientGeneralPage RP claims", () => {
);
});
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 () => {
it("preserves tenant scope mandatory state when tenant access restriction is off", async () => {
fetchClientMock.mockResolvedValue(
makeClientDetail("old_claim", {
includeTenantScope: true,
@@ -396,7 +355,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 === "tenants",
(input) => (input as HTMLInputElement).value === "tenant",
),
);
@@ -434,7 +393,7 @@ describe("ClientGeneralPage RP claims", () => {
tenant_access_restricted: false,
structured_scopes: expect.arrayContaining([
expect.objectContaining({
name: "tenants",
name: "tenant",
mandatory: false,
locked: false,
}),

File diff suppressed because it is too large Load Diff

View File

@@ -1,130 +0,0 @@
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,
};

View File

@@ -6,32 +6,6 @@ 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");
@@ -58,27 +32,5 @@ 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.");
});
});

View File

@@ -48,14 +48,14 @@ describe("findPersistedOidcUser", () => {
JSON.stringify({
access_token: "token-1",
expires_at: expiresAt,
profile: { sub: "dev-admin-1", name: "Dev Admin" },
profile: { name: "Dev Admin" },
}),
);
expect(findPersistedOidcUser(storage)).toEqual({
access_token: "token-1",
expires_at: expiresAt,
profile: { sub: "dev-admin-1", name: "Dev Admin" },
profile: { name: "Dev Admin" },
});
});
@@ -73,20 +73,4 @@ 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();
});
});

View File

@@ -7,27 +7,6 @@ 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 {
@@ -48,7 +27,10 @@ export function findPersistedOidcUser(
try {
const parsed = JSON.parse(rawValue) as PersistedOidcUser;
if (isValidOidcSessionUser(parsed)) {
if (
typeof parsed.expires_at === "number" &&
parsed.expires_at * 1000 > Date.now()
) {
return parsed;
}
} catch {

View File

@@ -515,11 +515,9 @@ 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 = "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"
email = "Email"
openid = "Openid"
profile = "Profile"
[msg.dev.dashboard]
access_denied = "The dashboard is available only to users with developer access."
@@ -1613,7 +1611,6 @@ 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"
@@ -1625,7 +1622,7 @@ description = "Scope Description"
mandatory = "Mandatory"
name = "Scope Name"
delete = "Delete"
tenants = "Tenants"
tenant = "Tenant"
[ui.dev.clients.general.tenant_access]
title = "Tenant access restriction"
@@ -1636,7 +1633,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 tenants scope automatically and requires at least one allowed tenant."
hint = "Turning this on adds the tenant 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"
@@ -1649,7 +1646,6 @@ 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"
@@ -1675,11 +1671,6 @@ 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"
@@ -1804,9 +1795,9 @@ subtitle = "Applications"
title = "RP registry"
[ui.dev.clients.scopes]
email = "User email information"
email = "Email"
openid = "Openid"
profile = "User profile data access: name, email, phones, secondary_emails, employee_id, and status"
profile = "Profile"
[ui.dev.clients.table]
actions = "Actions"

View File

@@ -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 = "사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다."
preview_hint = "설정 저장 시 반영될 claim 구성을 미리 수 있습니다."
hint = "RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다."
preview_hint = "저장될 metadata.id_token_claims조를 미리 확인할 수 있습니다."
key_required = "Claim key를 입력해야 합니다."
reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}"
@@ -515,11 +515,9 @@ subtitle = "Developer guides for Confidential/Public clients, redirect URIs, and
description = "OIDC 앱, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다."
[msg.dev.clients.scopes]
email = "사용자 이메일 정보"
openid = "OIDC 로그인에 필요한 기본 scope"
profile = "사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근"
tenant = "테넌트 접근"
tenants = "소속 테넌트 정보 접근"
email = "이메일 주소 접근"
openid = "OIDC 인증 필수 스코프"
profile = "기본 프로필 정보 접근"
[msg.dev.dashboard]
access_denied = "대시보드는 개발자 권한이 있어야 볼 수 있습니다."
@@ -1612,7 +1610,6 @@ 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 = "상세 안내 보기"
@@ -1624,7 +1621,7 @@ description = "설명"
mandatory = "필수"
name = "스코프 이름"
delete = "삭제"
tenants = "테넌트"
tenant = "테넌트"
[ui.dev.clients.general.tenant_access]
title = "테넌트 접근 제한"
@@ -1635,7 +1632,7 @@ search_placeholder = "테넌트 이름 또는 슬러그로 검색"
selected_title = "허용 테넌트"
selected_empty = "아직 선택된 테넌트가 없습니다."
empty = "검색 결과가 없습니다."
hint = "제한을 켜면 tenants 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다."
autocomplete_hint = "테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다."
validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다."
picker_title = "테넌트 선택"
@@ -1648,7 +1645,6 @@ 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"
@@ -1656,10 +1652,10 @@ namespace_rp_claims = "rp_claims"
nullable_label = "Nullable"
read_user_allowed_label = "사용자 읽기 허용"
write_user_allowed_label = "사용자 쓰기 허용"
table.key = "클레임 키"
table.namespace = "네임스페이스"
table.value_type = "값 유형"
table.nullable = "Null 허용"
table.key = "Claim Key"
table.namespace = "Namespace"
table.value_type = "Value Type"
table.nullable = "Nullable"
table.read_user_allowed = "사용자 읽기"
table.write_user_allowed = "사용자 쓰기"
table.default_value = "기본값"
@@ -1798,9 +1794,9 @@ subtitle = "연동 앱"
title = "RP registry"
[ui.dev.clients.scopes]
email = "사용자 이메일 정보"
email = "이메일 주소 접근"
openid = "OIDC 인증 필수 스코프"
profile = "사용자 기본 정보(name, email, phones, secondary_emails, employee_id, status) 접근"
profile = "기본 프로필 정보 접근"
[ui.dev.clients.table]
actions = "액션"

View File

@@ -556,8 +556,6 @@ description = ""
email = ""
openid = ""
profile = ""
tenant = ""
tenants = ""
[msg.dev.dashboard]
access_denied = ""
@@ -1662,7 +1660,6 @@ session_required_info = ""
add = ""
description_placeholder = ""
name_placeholder = ""
subtitle = ""
title = ""
offline_access_title = ""
offline_access_toggle = ""
@@ -1698,7 +1695,6 @@ picker_hint_with_count = ""
[ui.dev.clients.general.id_token_claims]
title = ""
add = ""
enabled = ""
preview_title = ""
namespace_label = ""
namespace_top_level = ""
@@ -1724,11 +1720,6 @@ 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 = ""

View File

@@ -143,7 +143,6 @@ 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(

View File

@@ -1,35 +0,0 @@
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,
});
});
});

View File

@@ -1,176 +0,0 @@
# 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)

View File

@@ -1,225 +0,0 @@
# 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 반영 필요 여부를 검토한다.

View File

@@ -0,0 +1,321 @@
# 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 로컬 세션 종료까지 이어집니다.

View File

@@ -238,17 +238,12 @@ Baron은 기본적으로 대표소속 tenant와 전체 소속 tenant 목록을
}
},
"profile": {
"email": "hanmac-user@example.com",
"secondary_emails": [
"alias1@hanmaceng.co.kr",
"alias2@hanmaceng.co.kr"
"emails": [
"hanmac-user@example.com"
],
"phones": [
"+821012345678"
],
"name": "한맥 사용자",
"employee_id": "EMP-001",
"status": "temporary_leave"
"names": {
"name": "한맥 사용자"
}
}
}
```

View File

@@ -0,0 +1,322 @@
# 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: 루트 리다이렉트 또는 비로그인 응답
```

View File

@@ -0,0 +1,412 @@
# [요청과업1] Baron-SSO 개발환경 구축 (WSL2 + Docker Engine)
## 1. 작업 개요
| 항목 | 내용 |
| ------ | ----------------------------- |
| 작업명 | 요청과업1 - Docker 설치 및 WSL 환경 구축 |
| 작업일 | 2026-06-11 |
| 시작 | 08:00 |
| 종료 | 15:00 |
| 휴게시간 | 12:00 ~ 13:00 |
| 총 작업시간 | 6시간 |
| 작업자 | ERP기획팀 |
---
# 2. 작업 배경
기존 Windows 환경에서 Baron-SSO 프로젝트를 실행하기 위해 Docker Desktop 기반으로 개발환경 구축을 진행하였다.
그러나 설치 과정에서 반복적인 오류가 발생하였고, 09:00 ~ 09:30 팀 회의 중 팀장님 의견에 따라 Docker Desktop 사용을 중단하였다.
회의 종료 후 개발환경 전략을 다음과 같이 변경하였다.
```text
기존
Windows + Docker Desktop
변경
Windows + WSL2(Ubuntu) + Docker Engine
```
---
# 3. 작업 목표
Baron-SSO 프로젝트 실행을 위한 개발환경 구축
## 목표 범위
* WSL2 설치
* Ubuntu 설치
* Docker Engine 설치
* Docker Compose 설치
* Baron Infra 실행
* Ory Stack 실행
* Baron App 실행
* UserFront / Backend 정상 기동 확인
---
# 4. 최종 결과
## 완료 항목
* WSL2 구축 완료
* Ubuntu 환경 구축 완료
* Docker Engine 설치 완료
* Docker Compose 설치 완료
* PostgreSQL 정상
* Redis 정상
* ClickHouse 정상
* Ory Kratos 정상
* Ory Hydra 정상
* Ory Keto 정상
* Ory Oathkeeper 정상
* Gateway 정상
* UserFront 정상
* Backend 정상
---
# 5. 실행 순서
```mermaid
flowchart TD
A[WSL2 설치]
--> B[Ubuntu 설치]
B
--> C[Docker Engine 설치]
C
--> D[Docker Compose 설치]
D
--> E[Docker Network 생성]
E
--> F[Baron Infra 실행]
F
--> G[Auth Config 생성]
G
--> H[Ory Config 생성]
H
--> I[Ory Stack 실행]
I
--> J[Baron App 실행]
J
--> K[Gateway 확인]
K
--> L[UserFront 확인]
L
--> M[Backend 확인]
M
--> N[전체 Healthy 상태 확인]
```
---
# 6. 시스템 구성도
```mermaid
flowchart LR
USER[Developer]
USER
--> GATEWAY[Gateway]
GATEWAY
--> USERFRONT[UserFront]
GATEWAY
--> BACKEND[Backend]
BACKEND
--> PG[(PostgreSQL)]
BACKEND
--> REDIS[(Redis)]
BACKEND
--> CLICKHOUSE[(ClickHouse)]
BACKEND
--> KRATOS[Kratos]
BACKEND
--> HYDRA[Hydra]
BACKEND
--> KETO[Keto]
BACKEND
--> OATHKEEPER[Oathkeeper]
```
---
# 7. 주요 문제 발생 내역
## 문제 1
### Windows Git Bash 환경
증상
```text
make 명령 실행 불가
```
원인
```text
Linux 기반 개발환경 전제 프로젝트
```
조치
```text
WSL2 + Ubuntu 환경으로 전환
```
---
## 문제 2
### Docker Desktop 설치 오류
증상
```text
설치 중 반복 오류 발생
```
조치
```text
Docker Desktop 사용 중단
Docker Engine 직접 설치 방식 적용
```
---
## 문제 3
### CRLF(Line Ending) 문제
가장 많은 시간을 소모한 원인
증상
```text
set: pipefail: invalid option
$'\\r': command not found
cannot execute: required file not found
set: Illegal option -
': No such file or directory
```
원인
```text
Windows CRLF 줄바꿈
Linux는 LF 필요
```
---
# 8. 수정 파일 목록
```text
.env
scripts/auth_config.sh
scripts/render_ory_config.sh
docker/ory/init-db/01_create_dbs.sh
userfront/scripts/dev-server.sh
scripts/sync_userfront_locales.sh
config/.generated/ory/oathkeeper/entrypoint.sh
```
---
# 9. 추가 문제 및 해결
## Ory DB 생성 실패
필요 DB
```text
ory_hydra
ory_keto
ory_kratos
```
조치
```sql
CREATE DATABASE ory_hydra;
CREATE DATABASE ory_keto;
CREATE DATABASE ory_kratos;
```
---
## Kratos 설정 오류
문제 값
```env
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
```
수정
```env
KRATOS_ALLOWED_RETURN_URLS_EXTRA=
```
---
## Gateway Unhealthy
원인
```text
baron_userfront 컨테이너 종료
```
조치
```bash
dos2unix userfront/scripts/dev-server.sh
dos2unix scripts/sync_userfront_locales.sh
docker restart baron_userfront
```
---
## Backend Unhealthy
원인
```text
Oathkeeper 기동 실패
```
조치
```bash
dos2unix config/.generated/ory/oathkeeper/entrypoint.sh
docker restart ory_oathkeeper
docker restart baron_backend
```
---
# 10. 작업 시간 초과 원인 분석
## 계획
```text
Docker 설치
→ Baron 실행
→ 기능 테스트
```
예상 2~3시간
````
## 실제
```text
Docker Desktop 설치 실패
→ WSL 환경 전환
→ Linux 환경 구성
→ 다수의 CRLF 문제 발견
→ Ory DB 재구성
→ Oathkeeper 복구
→ Backend Health 복구
````
결과
```text
총 6시간 소요
```
---
# 11. 재발 방지 방안
## Git 정책 검토
```gitattributes
*.sh text eol=lf
*.env text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
```
검토 필요
---
## 개발환경 표준화
권장
```text
Windows
WSL2 Ubuntu
Docker Engine
Baron-SSO
```
팀 공통 환경으로 통일 시
환경 이슈 감소 예상
---
# 12. 작업 결과 요약
금일 WSL2 + Ubuntu 기반 Docker Engine 개발환경 구축을 완료하였다.
Docker Desktop 기반 접근은 설치 오류로 인해 중단하였으며, 팀장님 조언에 따라 WSL2 + Docker Engine 방식으로 전환하였다.
구축 과정에서 다수의 CRLF(Line Ending) 문제가 확인되었으며, 관련 Shell Script 및 환경설정 파일을 수정하여 해결하였다.
최종적으로 Baron-SSO의 Infra, Ory Stack, Gateway, UserFront, Backend가 모두 정상 기동되었으며 Healthy 상태를 확인하였다.
다음 단계는 기능 검증 및 인증 흐름 테스트이다.

View File

@@ -0,0 +1,639 @@
# WSL · Ubuntu · Docker 이해하기 (초보자용 가이드)
## 문서 정보
| 항목 | 내용 |
| ---- | -------------------------- |
| 문서명 | WSL · Ubuntu · Docker 이해하기 |
| 대상 | 개발 입문자, 신규 팀원 |
| 작성목적 | Baron SSO 개발환경 이해 |
| 작성일 | 2026-06-12 |
---
# 1. 개요
현재 Baron SSO 개발환경은 다음과 같은 구조로 구성되어 있습니다.
```text
Windows
WSL
Ubuntu
Docker
Baron 서비스들
```
처음 접하는 사람은 용어가 많아 어렵게 느껴질 수 있지만,
사실은 다음과 같이 이해하면 쉽습니다.
| 구성요소 | 비유 |
| -------------- | ---------- |
| Windows | 집 |
| WSL | 집 안의 리눅스 방 |
| Ubuntu | 방에 사는 사람 |
| Docker | 작업실 |
| Container | 개별 작업실 |
| Baron Backend | 백엔드 작업실 |
| Baron Frontend | 프론트엔드 작업실 |
| ClickHouse | 데이터 저장 작업실 |
---
# 2. 전체 구조 이해하기
## 실제 개발환경 구조
```text
┌─────────────────────────┐
│ Windows 11 │
│ │
│ VS Code │
│ Chrome │
│ Git │
└──────────┬──────────────┘
┌─────────────────────────┐
│ WSL │
│ (Windows Linux Layer) │
└──────────┬──────────────┘
┌─────────────────────────┐
│ Ubuntu │
│ (Linux) │
└──────────┬──────────────┘
┌─────────────────────────┐
│ Docker │
└──────────┬──────────────┘
┌─────────┼─────────┬─────────┐
▼ ▼ ▼ ▼
Backend UserFront AdminFront ClickHouse
```
---
# 3. Windows란?
현재 우리가 사용하는 기본 운영체제(OS)입니다.
예)
```text
C:\Users
D:\Project
E:\Workspace
```
Windows 환경에서 사용하는 경로입니다.
주요 프로그램
* VS Code
* Chrome
* Edge
* Explorer
* Git GUI
등이 Windows 위에서 실행됩니다.
---
# 4. WSL이란?
## WSL
Windows Subsystem for Linux
즉,
"Windows 안에서 Linux를 실행할 수 있게 해주는 기능"
입니다.
---
## WSL이 없던 시절
Linux를 사용하려면
```text
PC
├─ Windows
└─ Virtual Machine
└─ Ubuntu
```
구조를 사용했습니다.
문제점
* 느림
* 무거움
* 메모리 많이 사용
---
## WSL 사용
```text
PC
├─ Windows
└─ WSL
└─ Ubuntu
```
장점
* 빠름
* 가벼움
* 실제 Linux와 거의 동일
---
# 5. Ubuntu란?
Ubuntu는 Linux 운영체제입니다.
Windows와 같은 OS입니다.
비교하면
| Windows | Linux |
| ---------- | -------- |
| Windows 11 | Ubuntu |
| 탐색기 | Shell |
| CMD | Terminal |
| PowerShell | Bash |
---
Ubuntu 접속 예시
```bash
wsl
```
또는
```bash
ubuntu
```
실행 후
```bash
ubuntu@DESKTOP-XXXX:~$
```
가 보이면 Ubuntu 안에 들어온 상태입니다.
---
# 6. Docker란?
Docker는
"프로그램을 독립적으로 실행하는 기술"
입니다.
---
예를 들어
Baron 프로젝트에 필요한 것
```text
NodeJS
Database
Backend
Frontend
ClickHouse
```
를 직접 설치하면
PC가 복잡해집니다.
---
Docker 사용 시
```text
┌────────────┐
│ Backend │
└────────────┘
┌────────────┐
│ Frontend │
└────────────┘
┌────────────┐
│ Database │
└────────────┘
┌────────────┐
│ ClickHouse │
└────────────┘
```
각각 독립적으로 실행됩니다.
---
# 7. Container란?
Docker 안에서 실행되는 작은 서버입니다.
쉽게 말하면
"미니 컴퓨터"
라고 생각하면 됩니다.
---
현재 Baron 환경 예시
```text
Container 1
baron_backend
Container 2
baron_userfront
Container 3
baron_adminfront
Container 4
baron_clickhouse
```
각 컨테이너는
* 독립 실행
* 독립 설정
* 독립 포트
를 가집니다.
---
# 8. Image와 Container 차이
많이 헷갈리는 부분입니다.
## Image
설치 파일
예)
```text
Windows 설치 ISO
```
와 비슷
---
## Container
실행 중인 프로그램
예)
```text
설치 완료 후 실행된 Windows
```
와 비슷
---
관계
```text
Docker Image
실행
Container
```
---
# 9. 실제 Baron 프로젝트 구조
현재 개발환경
```text
Windows
├─ VS Code
├─ Git
└─ E:\h_workspace\baron-sso
```
---
WSL에서 보면
```text
/mnt/e/h_workspace/baron-sso
```
가 됩니다.
---
같은 폴더를
Windows와 Ubuntu가
다르게 표현하는 것입니다.
| Windows | Ubuntu |
| ------------------------ | ---------------------------- |
| E:\h_workspace\baron-sso | /mnt/e/h_workspace/baron-sso |
---
# 10. VS Code는 어디서 실행되는가?
VS Code 자체는 Windows에서 실행됩니다.
```text
Windows
└─ VS Code
```
하지만
Remote WSL 기능을 사용하면
```text
VS Code
WSL
Ubuntu
```
에 연결됩니다.
---
좌측 하단에
```text
WSL: Ubuntu
```
가 보이면
Ubuntu 내부를 편집 중인 상태입니다.
---
# 11. Docker Compose란?
여러 컨테이너를 한 번에 실행하는 기능입니다.
예)
docker-compose.yml
```yaml
services:
backend:
...
userfront:
...
adminfront:
...
clickhouse:
...
```
---
실행
```bash
docker compose up -d
```
---
Docker가 자동으로
```text
Backend 시작
UserFront 시작
AdminFront 시작
ClickHouse 시작
```
을 수행합니다.
---
# 12. Baron SSO 실행 흐름
```text
개발자
VS Code 수정
Git 저장
Docker Compose 실행
Backend Container
UserFront Container
AdminFront Container
ClickHouse Container
웹 브라우저 접속
```
---
# 13. 자주 사용하는 명령어
## 현재 컨테이너 보기
```bash
docker ps
```
---
## 전체 컨테이너 보기
```bash
docker ps -a
```
---
## 로그 보기
```bash
docker logs 컨테이너명
```
예)
```bash
docker logs baron_backend
```
---
## 컨테이너 접속
```bash
docker exec -it 컨테이너명 bash
```
예)
```bash
docker exec -it baron_backend bash
```
---
## 컨테이너 중지
```bash
docker stop 컨테이너명
```
---
## 컨테이너 재시작
```bash
docker restart 컨테이너명
```
---
# 14. 최종 정리
## 한 문장 설명
### Windows
사용자가 사용하는 실제 운영체제
---
### WSL
Windows 안에서 Linux를 사용할 수 있게 해주는 기능
---
### Ubuntu
WSL 안에서 실행되는 Linux 운영체제
---
### Docker
프로그램을 독립된 환경에서 실행하는 기술
---
### Container
Docker 안에서 실행되는 개별 서비스
---
## Baron SSO 전체 구조
```text
사용자
Windows
VS Code
WSL
Ubuntu
Docker
├─ baron_backend
├─ baron_userfront
├─ baron_adminfront
└─ baron_clickhouse
브라우저 접속
```
---
## 기억해야 할 핵심
"Windows 위에서 WSL이 동작하고,
WSL 안에서 Ubuntu가 실행되며,
Ubuntu 안에서 Docker가 실행되고,
Docker 안에서 Baron 서비스들이 실행된다."

View File

@@ -2,79 +2,45 @@
## 목적
WORKS Drive는 Docker Registry HTTP API v2 backend로 직접 사용하지 않는다. 대신 stage/production 공용 Docker 이미지를 `docker save` 결과물로 내보내고, zstd 압축 archive와 검증 파일을 WORKS Shared Drive에 보관하는 CLI 기반 이미지 산출물 저장소로 사용한다.
WORKS Drive는 Docker Registry HTTP API v2 backend로 직접 사용하지 않는다. 대신 프로덕션 배포용 Docker 이미지를 `docker save` 결과물로 내보내고, zstd 압축 archive와 검증 파일을 WORKS Shared Drive에 보관하는 CLI 기반 보조 저장소로 사용한다.
이 방식은 다음 상황을 목표로 한다.
- 공용 Registry 없이 WORKS Drive 접근 권한만으로 이미지 산출물 보관
- 작은 규모의 stage/production 배포 이미지 이관
- Harbor 또는 공용 Registry 장애 시 수동 복구용 이미지 보관
- 작은 규모의 프로덕션 배포 이미지 이관
- `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_DRIVE_DOCKER_IMAGE_DIR=baron-sso
WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR=docker-build-image
```
이미지는 WORKS Shared Drive에서 다음 구조로 저장한다.
```text
baron-sso/<tag>/
<service>.<tag>.tar.zst
<service>.<tag>.sha256
manifest.<tag>.json
docker-build-image/<repository-path>/<tag>/
image.tar.zst
image.tar.zst.sha256
manifest.json
```
예시:
```text
baron-sso/v1.2606.ab12/
backend.v1.2606.ab12.tar.zst
backend.v1.2606.ab12.sha256
manifest.v1.2606.ab12.json
docker-build-image/baron_sso/backend/v1.2606.ab12/
image.tar.zst
image.tar.zst.sha256
manifest.json
```
Registry hostname과 image namespace는 저장 경로에서 제외한다. 예를 들어 `registry.example/baron_sso/backend:v1.2606.ab12``baron-sso/v1.2606.ab12/backend.v1.2606.ab12.tar.zst` 저장한다.
Registry hostname 저장 경로에서 제외한다. 예를 들어 `registry.example/baron_sso/backend:v1.2606.ab12``baron_sso/backend/v1.2606.ab12` 아래에 저장한다.
## Manifest
`manifest.<tag>.json`에는 다음 정보를 기록한다.
`manifest.json`에는 다음 정보를 기록한다.
- archive format: `docker-save-zstd`
- 원본 `image_ref`
@@ -83,15 +49,14 @@ Registry hostname과 image namespace는 저장 경로에서 제외한다. 예를
- Docker image id
- Git commit
- archive 파일명, 크기, sha256
- 서비스별 archive 정보 (`images.<service>`)
- WORKS Drive remote path
- 복원 명령 예시
복원은 다음 흐름으로 처리한다.
```bash
sha256sum -c backend.v1.2606.ab12.sha256
zstd -d -c backend.v1.2606.ab12.tar.zst | docker load
sha256sum -c image.tar.zst.sha256
zstd -d -c image.tar.zst | docker load
```
## 업로드 CLI
@@ -99,7 +64,7 @@ zstd -d -c backend.v1.2606.ab12.tar.zst | docker load
로컬 컨테이너를 먼저 이미지로 commit한 뒤 업로드하려면 다음처럼 실행한다.
```bash
WORKS_DRIVE_DOCKER_IMAGE_DIR=baron-sso \
WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR=docker-build-image \
WORKS_DOCKER_COMMIT_CONTAINER=baron_backend \
DOCKER_IMAGE_REF=registry.example/baron_sso/backend:v1.2606.ab12 \
scripts/docker-image/upload_works_drive.sh
@@ -112,11 +77,11 @@ DOCKER_IMAGE_REF=registry.example/baron_sso/backend:v1.2606.ab12 \
scripts/docker-image/upload_works_drive.sh
```
실제 업로드에는 기존 백업 업로드와 같은 WORKS Drive 인증 변수를 사용하되, Docker image archive 대상 drive/folder는 백업 변수와 분리한다.
실제 업로드에는 기존 백업 업로드와 같은 WORKS Drive 인증 변수를 사용한다.
- `WORKS_DRIVE_TARGET=sharedrive`
- `WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID`
- 선택: `WORKS_DRIVE_DOCKER_IMAGE_PARENT_FILE_ID`
- `WORKS_DRIVE_SHARED_DRIVE_ID` 또는 `WORKS_SHAREDRIVE_ID`
- 선택: `WORKS_DRIVE_PARENT_FILE_ID`
- `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`, `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`, 또는 서비스 계정 OAuth 변수
업로드 전 packaging만 확인하려면 다음을 사용한다.
@@ -127,70 +92,6 @@ 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
@@ -202,35 +103,19 @@ 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를 입력받아 같은 image archive를 사용한다.
Action에서 `dev` 브랜치를 checkout한 뒤 한 번만 이미지를 빌드하고 immutable `image_tag`를 계산한다. staging과 production은 같은 image_tag를 입력받아 같은 registry image를 pull한다.
```text
dev branch -> publish image tag vX.YYMM.<commit4> -> staging deploy -> production deploy
```
WORKS Drive archive Action에서 로컬로 빌드된 이미지를 `docker save`내보내 생성한다. 따라서 WORKS archive, staging, production은 모두 같은 immutable image tag를 기준으로 한다.
WORKS Drive archive Action에서 push된 이미지를 다시 pull한 뒤 `docker save`만든다. 따라서 WORKS archive, staging, production은 모두 같은 registry image tag를 기준으로 한다.
## 제한
- 이 구조는 `docker push`/`docker pull`과 호환되는 Registry backend가 아니다.
- layer deduplication이 없으므로 같은 기반 이미지가 반복 저장된다.
- 배포 전에는 반드시 `<service>.<tag>.sha256` 검증 후 `docker load`를 수행해야 한다.
- 배포 전에는 반드시 `image.tar.zst.sha256` 검증 후 `docker load`를 수행해야 한다.
- tag 없는 image ref와 digest-only image ref는 지원하지 않는다.

View File

@@ -283,9 +283,6 @@ 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."
@@ -1503,12 +1500,6 @@ 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"

View File

@@ -283,9 +283,6 @@ 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 = "이 권한에 적용할 테넌트 범위를 선택합니다."
@@ -1503,12 +1500,6 @@ 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 = "대상 사용자"

View File

@@ -283,9 +283,6 @@ 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 = ""
@@ -1503,12 +1500,6 @@ 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 = ""

View File

@@ -8,17 +8,15 @@ 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" "$dataset"
create_manifest "$backup_dir" "$mode" "$services"
service_timings_json="[]"
run_backup_step() {
@@ -42,31 +40,26 @@ run_backup_step() {
backup_log "Creating backup at $backup_dir"
backup_log "Backup mode: $mode"
backup_log "Dataset: $dataset"
backup_log "Services: $services"
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 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"
fi
if service_enabled config "$services"; then
run_backup_step config dump_config_snapshot "$backup_dir"
fi
write_backup_markdown_report "$backup_dir" "succeeded" "$services" "$service_timings_json"

View File

@@ -1,106 +0,0 @@
#!/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"

View File

@@ -1,7 +1,6 @@
#!/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
@@ -66,18 +65,6 @@ 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"

View File

@@ -4,17 +4,11 @@ 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)"
@@ -26,8 +20,7 @@ create_manifest() {
printf ' "created_at": "%s",\n' "$created_at"
printf ' "git_commit": "%s",\n' "$git_commit"
printf ' "mode": "%s",\n' "$mode"
printf ' "dataset": "%s",\n' "$dataset"
printf ' "environment_scope": "%s",\n' "$environment_scope"
printf ' "environment_scope": "same-env-only",\n'
printf ' "services": ['
for service in $services; do
if [[ "$first" -eq 1 ]]; then

View File

@@ -1,367 +0,0 @@
#!/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"
}

View File

@@ -130,7 +130,6 @@ write_restore_markdown_report() {
"| 입력 유형 | \(.backup_source // "unknown") |",
"| 백업 경로 | `\(.backup_dir // "")` |",
"| Dump 파일 | `\(.dump_file // "")` |",
"| Dataset | `\(.dataset // "full")` |",
"| 서비스 | `\(services)` |",
"",
"## 검증",
@@ -139,9 +138,6 @@ 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),
"",
"## 대상별 검증 결과",
"",

View File

@@ -7,7 +7,6 @@ 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
@@ -27,7 +26,6 @@ report_message=""
dump_checksum_status="not_run"
target_verification_status="not_run"
target_verification_reports="[]"
dataset="full"
json_array_from_words() {
local words="$1"
@@ -45,7 +43,6 @@ write_restore_report() {
local finished_at
local services_json
local restore_policy_json="{}"
local personnel_policy_json="{}"
[[ -n "$report_path" ]] || return 0
@@ -54,9 +51,6 @@ 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 \
@@ -75,8 +69,6 @@ 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,
@@ -86,11 +78,10 @@ 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: (if $dataset == "personnel" then $personnel_policy else $restore_policy end),
restore_policy: $restore_policy,
verification: {
dump_checksum: $dump_checksum,
target_row_counts: $target_row_counts,
@@ -448,13 +439,6 @@ 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
@@ -463,7 +447,6 @@ 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"
@@ -483,32 +466,27 @@ fi
BACKUP="$backup_dir" "$script_dir/verify-dump.sh"
dump_checksum_status="passed"
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
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
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."

View File

@@ -23,12 +23,13 @@ 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)"
@@ -49,47 +50,26 @@ 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" "$port_prefix"
TARGET_DIR="$bundle_dir" bash "$repo_root/deploy/create-instance.sh" "$instance_name" "$IMAGE_DEPLOY_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=${port_prefix}
P=${IMAGE_DEPLOY_PORT_PREFIX}
DB_PORT=${IMAGE_DEPLOY_DB_PORT}
REDIS_PORT=${IMAGE_DEPLOY_REDIS_PORT}
CLICKHOUSE_PORT_HTTP=${IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP}
@@ -105,8 +85,6 @@ 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}
@@ -128,22 +106,9 @@ 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}
@@ -160,8 +125,6 @@ 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}
@@ -183,7 +146,6 @@ 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

View File

@@ -15,78 +15,25 @@ require_env IMAGE_DEPLOY_BUNDLE_FILE
require_env DEPLOY_HOST
require_env DEPLOY_USER
require_env DEPLOY_PATH
require_env WORKS_DRIVE_DOCKER_IMAGE_DRIVE_ID
require_env HARBOR_ENDPOINT
require_env HARBOR_ROBOT_ACCOUNT
require_env HARBOR_ROBOT_KEY
[[ -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}"
printf '%s\n' "$works_drive_access_token" | ssh "${DEPLOY_USER}@${DEPLOY_HOST}" \
echo "$HARBOR_ROBOT_KEY" | 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; \
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 login '${HARBOR_ENDPOINT}' -u '${HARBOR_ROBOT_ACCOUNT}' --password-stdin; \
docker compose --env-file .env -f docker-compose.yml pull; \
docker compose --env-file .env -f docker-compose.yml up -d --remove-orphans; \
docker compose --env-file .env -f docker-compose.yml ps"

View File

@@ -1,189 +0,0 @@
#!/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}"

View File

@@ -68,8 +68,7 @@ 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}"
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}}"
image_root_dir="${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-docker-build-image}"
dry_run="${WORKS_DRIVE_DRY_RUN:-false}"
target="${WORKS_DRIVE_TARGET:-sharedrive}"
api_base_url="${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}"
@@ -79,34 +78,6 @@ 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'
}
@@ -192,11 +163,7 @@ resolve_target_upload_endpoint() {
resolve_target_children_endpoint() {
local parent_file_id="${1:-${WORKS_DRIVE_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
printf '%s/children\n' "$(resolve_target_upload_endpoint "$parent_file_id")"
}
resolve_target_create_folder_endpoint() {
@@ -398,33 +365,24 @@ ensure_child_folder() {
local children_json
local folder_id
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)"
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 "$folder_id" ]]; then
printf '%s\n' "$folder_id"
return
fi
else
create_folder_endpoint="$(resolve_target_create_folder_endpoint "$parent_file_id")"
if [[ -n "$folder_id" ]]; then
printf '%s\n' "$folder_id"
return
fi
if ! folder_id="$(create_child_folder "$access_token" "$create_folder_endpoint" "$folder_name")"; then
return 1
fi
printf '%s\n' "$folder_id"
create_child_folder "$access_token" "$create_folder_endpoint" "$folder_name"
}
ensure_folder_path() {
@@ -432,22 +390,11 @@ 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
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"
parent_file_id="$(ensure_child_folder "$access_token" "$parent_file_id" "$component")"
done
printf '%s\n' "$parent_file_id"
@@ -557,16 +504,14 @@ derive_repository_and_tag() {
derive_repository_and_tag "$image_ref"
image_name="${image_repository##*/}"
release_repository="${image_root_dir}"
remote_path="${release_repository}/${image_tag}"
artifact_dir="${archive_root}/${release_repository}/${image_tag}"
remote_path="${image_root_dir}/${image_repository}/${image_tag}"
artifact_dir="${archive_root}/${image_repository}/${image_tag}"
mkdir -p "$artifact_dir"
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"
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"
upload_report_file="$artifact_dir/works-upload.json"
rm -f "$tar_file" "$archive_file" "$checksum_file" "$manifest_file" "$upload_report_file"
@@ -590,77 +535,36 @@ 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")"
manifest_tmp_file="${manifest_file}.tmp"
manifest_jq_filter='
def image_entry: {
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,
image_ref: $imageRef,
repository: $repository,
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
},
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
}
| .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"
}' >"$manifest_file"
upload_files=("$archive_file" "$checksum_file" "$manifest_file")

View File

@@ -1,69 +0,0 @@
#!/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"

View File

@@ -1,69 +0,0 @@
#!/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"

View File

@@ -1,82 +0,0 @@
#!/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"

View File

@@ -1,169 +0,0 @@
#!/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"

View File

@@ -17,12 +17,9 @@ 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.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 }}'
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 }}'
done
assert_contains ".gitea/workflows/staging_release.yml" "scp adminfront/seed-tenant.csv"

View File

@@ -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 filter-personnel-dump restore-plan code-check-userfront-e2e-tests; do
for target in up dev code-check 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 DUMP_DATASET RESTORE_DATASET; do
for option in DEV_SERVICES CODE_CHECK_TEST_JOBS PLAYWRIGHT_WORKERS BACKUP_USE_DOCKER DUMP_SERVICES RESTORE_SERVICES; do
assert_contains "$help_output" "$option"
done

View File

@@ -1,74 +0,0 @@
#!/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"

View File

@@ -1,117 +0,0 @@
#!/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"

View File

@@ -1,32 +0,0 @@
#!/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"

View File

@@ -2,31 +2,27 @@
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
publish_workflow="$repo_root/.gitea/workflows/image_publish.yml"
legacy_publish_workflow="$repo_root/.gitea/workflows/production_image_publish.yml"
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 "shared image publish workflow must exist."
[[ ! -f "$legacy_publish_workflow" ]] || fail "production-only image publish workflow must be renamed to image_publish.yml."
[[ -f "$publish_workflow" ]] || fail "production image publish workflow must exist."
[[ -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 Images" "$publish_workflow" \
|| fail "publish workflow must have the shared stage/production name."
grep -Fq "name: Publish Baron SSO Production Images" "$publish_workflow" \
|| fail "publish workflow must have the expected name."
grep -Fq "workflow_dispatch:" "$publish_workflow" \
|| fail "publish workflow must be manually dispatchable."
if grep -Fq "source_ref:" "$publish_workflow"; then
@@ -41,44 +37,21 @@ 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 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."
|| 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."
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 images."
grep -Fq "load: true" "$publish_workflow" \
|| fail "publish workflow must load built images into the local Docker daemon for WORKS archive upload."
|| fail "publish workflow must build and push images."
for image in backend userfront adminfront devfront orgfront; do
grep -Fq "baron_sso/${image}:" "$publish_workflow" \
|| fail "publish workflow must build ${image} image."
grep -Fq "/baron_sso/${image}:" "$publish_workflow" \
|| fail "publish workflow must push ${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."
@@ -94,8 +67,6 @@ 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."
@@ -115,23 +86,14 @@ 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 "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 pull" "$remote_deploy_script" \
|| fail "shared remote deploy script must pull the requested image version before running."
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."

View File

@@ -1,146 +0,0 @@
#!/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"

View File

@@ -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_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 'WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-docker-build-image' "$script" \
|| fail "script must default WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR to docker-build-image."
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.${image_tag}.json' "$script" \
|| fail "script must write a versioned manifest next to the image archive."
grep -Fq '${image_name}.${image_tag}.sha256' "$script" \
grep -Fq 'manifest.json' "$script" \
|| fail "script must write a manifest.json next to the image archive."
grep -Fq 'image.tar.zst.sha256' "$script" \
|| fail "script must write a checksum file for the compressed image archive."
grep -Fq 'baron-sso/v1.2606.ab12/backend.v1.2606.ab12.tar.zst' "$doc" \
grep -Fq 'docker-build-image/baron_sso/backend/v1.2606.ab12' "$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,12 +124,24 @@ case "$last_arg" in
printf '{"files":[]}'
;;
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/root-folder/createfolder)
printf '{"fileId":"baron-sso-id","fileName":"baron-sso","fileType":"FOLDER"}'
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"}'
;;
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)
@@ -163,41 +175,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/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."
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."
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 .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."
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."
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 archive folder when needed."
grep -Fq "baron-sso" "$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 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 "backend.v1.2606.ab12.tar.zst" "$curl_log" \
grep -Fq "image.tar.zst" "$curl_log" \
|| fail "script must upload the compressed image archive."
grep -Fq "backend.v1.2606.ab12.sha256" "$curl_log" \
grep -Fq "image.tar.zst.sha256" "$curl_log" \
|| fail "script must upload the checksum file."
grep -Fq "manifest.v1.2606.ab12.json" "$curl_log" \
grep -Fq "manifest.json" "$curl_log" \
|| fail "script must upload the manifest file."
report_file="$artifact_dir/works-upload.json"
@@ -205,68 +217,4 @@ 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"

View File

@@ -1,11 +1,11 @@
bool isOfflineScopeAlias(String scope) {
bool isRefreshTokenScopeAlias(String scope) {
final normalized = scope.trim().toLowerCase();
return normalized == 'offline';
return normalized == 'offline' || normalized == 'offline_access';
}
List<String> filterConsentScopes(Iterable<String> scopes) {
return scopes
.map((scope) => scope.trim())
.where((scope) => scope.isNotEmpty && !isOfflineScopeAlias(scope))
.where((scope) => scope.isNotEmpty && !isRefreshTokenScopeAlias(scope))
.toList(growable: false);
}

View File

@@ -1,7 +1,4 @@
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';
@@ -51,18 +48,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
);
if (mounted) {
ToastService.success(tr('msg.userfront.forgot.sent'));
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');
}
}
Navigator.of(context).pop();
}
} catch (e) {
if (mounted) {

View File

@@ -1097,6 +1097,11 @@ 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')),
),
],
),
],

View File

@@ -3,7 +3,7 @@ import 'package:userfront/features/auth/domain/consent_scope_policy.dart';
void main() {
group('consent scope policy', () {
test('keeps offline_access visible and filters only offline', () {
test('filters offline scope aliases from requested consent scopes', () {
expect(
filterConsentScopes([
'openid',
@@ -12,14 +12,14 @@ void main() {
'offline_access',
'email',
]),
['openid', 'profile', 'offline_access', 'email'],
['openid', 'profile', 'email'],
);
});
test('detects offline scope alias case-insensitively', () {
expect(isOfflineScopeAlias('OFFLINE'), isTrue);
expect(isOfflineScopeAlias(' offline_access '), isFalse);
expect(isOfflineScopeAlias('profile'), isFalse);
test('detects refresh token scope aliases case-insensitively', () {
expect(isRefreshTokenScopeAlias('OFFLINE'), isTrue);
expect(isRefreshTokenScopeAlias(' offline_access '), isTrue);
expect(isRefreshTokenScopeAlias('profile'), isFalse);
});
});
}