1
0
forked from baron/baron-sso

1 Commits

Author SHA1 Message Date
46f03e2b18 chore: document local WSL setup fixes 2026-06-18 11:45:03 +09:00
104 changed files with 2745 additions and 5973 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,279 +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
target: production
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: Verify built Docker images before WORKS upload
env:
IMAGE_TAG: ${{ steps.version.outputs.image_tag }}
run: |
set -euo pipefail
for image in backend userfront adminfront devfront orgfront; do
image_ref="baron_sso/${image}:${IMAGE_TAG}"
echo "Checking built Docker image before WORKS upload: ${image_ref}"
docker image inspect "${image_ref}" >/dev/null
docker image ls "${image_ref}"
done
- 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_DRIVE_OAUTH_TOKEN_URL: ${{ vars.WORKS_DRIVE_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_DRIVE_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_DRIVE_API_BASE_URL: ${{ vars.WORKS_DRIVE_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
images="backend userfront adminfront devfront orgfront"
image_total=5
image_index=0
uploaded_images=""
for image in ${images}; do
image_index=$((image_index + 1))
image_ref="baron_sso/${image}:${IMAGE_TAG}"
echo "WORKS image upload ${image_index}/${image_total}: ${image_ref}"
docker image inspect "${image_ref}" >/dev/null
if 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; then
uploaded_images="${uploaded_images}${uploaded_images:+ }${image_ref}"
echo "WORKS image upload completed: ${image_ref}"
else
upload_status="$?"
echo "::error::WORKS image upload failed at ${image_index}/${image_total}: ${image_ref}"
echo "Already uploaded images: ${uploaded_images:-none}"
exit "${upload_status}"
fi
done
echo "Uploaded WORKS image archives:"
for image_ref in ${uploaded_images}; do
echo " - ${image_ref}"
done

View File

@@ -29,66 +29,48 @@ 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_SECRET }}
IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.PROD_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.PROD_CLICKHOUSE_PASSWORD }}
IMAGE_DEPLOY_COOKIE_SECRET: ${{ secrets.PROD_COOKIE_SECRET }}
IMAGE_DEPLOY_JWT_SECRET: ${{ secrets.PROD_JWT_SECRET }}
@@ -106,17 +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_DRIVE_API_BASE_URL: ${{ vars.WORKS_DRIVE_API_BASE_URL }}
WORKS_DRIVE_OAUTH_TOKEN_URL: ${{ vars.WORKS_DRIVE_OAUTH_TOKEN_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,20 +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_DRIVE_API_BASE_URL: ${{ vars.WORKS_DRIVE_API_BASE_URL }}
WORKS_DRIVE_OAUTH_TOKEN_URL: ${{ vars.WORKS_DRIVE_OAUTH_TOKEN_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

@@ -76,40 +76,6 @@ describe("TenantProfilePage initial profile loading", () => {
expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀");
expect(screen.getByLabelText("공개 범위")).toHaveValue("internal");
expect(screen.getByLabelText("WORKS 연동")).toHaveValue("excluded");
expect(fetchAllTenantsMock).toHaveBeenCalled();
});
it("resolves the persisted parent tenant label even when org config already exists", async () => {
fetchAllTenantsMock.mockResolvedValue({
items: [
{
id: "tenant-company",
type: "ORGANIZATION",
name: "인프라솔루션",
slug: "infra-solution",
description: "",
status: "active",
domains: [],
parentId: "tenant-root",
memberCount: 0,
config: {},
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
},
],
limit: 100,
offset: 0,
total: 1,
});
renderWithProviders(
<Routes>
<Route path="/tenants/:tenantId" element={<TenantProfilePage />} />
</Routes>,
);
expect(
await screen.findByText(/인프라솔루션 · infra-solution/),
).toBeInTheDocument();
expect(fetchAllTenantsMock).not.toHaveBeenCalled();
});
});

View File

@@ -97,7 +97,7 @@ export function TenantProfilePage() {
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
enabled: !!tenantQuery.data,
enabled: !!tenantQuery.data && !hasPersistedOrgConfig,
});
const allTenants = parentQuery.data?.items ?? [];
const orgConfigCandidate = tenantQuery.data

View File

@@ -284,7 +284,7 @@ describe("TenantWorksmobilePage comparison helpers", () => {
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: true,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,

View File

@@ -29,13 +29,6 @@ import {
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../components/ui/select";
import {
Table,
TableBody,
@@ -54,7 +47,6 @@ import {
fetchMe,
fetchWorksmobileComparison,
fetchWorksmobileOverview,
importWorksmobileUsersFromWorks,
retryWorksmobileJob,
type WorksmobileComparisonItem,
type WorksmobileOutboxItem,
@@ -84,7 +76,7 @@ import {
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
getWorksmobileSelectedCreateUserIds,
getWorksmobileSelectedImportUserIds,
getWorksmobileSelectedUpdateUserIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds,
summarizeWorksmobileComparison,
type WorksmobileAccountStatusFilter,
@@ -280,7 +272,6 @@ export function TenantWorksmobilePage() {
overviewQuery.refetch();
},
onError: (error) => {
overviewQuery.refetch();
toast.error("조직 Sync 작업 등록 실패", {
description: getErrorMessage(error),
});
@@ -294,7 +285,6 @@ export function TenantWorksmobilePage() {
overviewQuery.refetch();
},
onError: (error) => {
overviewQuery.refetch();
toast.error("구성원 Sync 작업 등록 실패", {
description: getErrorMessage(error),
});
@@ -306,15 +296,12 @@ export function TenantWorksmobilePage() {
resourceKind,
ids,
initialPassword,
initialPasswordUserIds,
}: {
resourceKind: "users" | "groups";
ids: string[];
initialPassword?: string;
initialPasswordUserIds?: string[];
}) => {
const trimmedInitialPassword = initialPassword?.trim();
const passwordUserIdSet = new Set(initialPasswordUserIds ?? []);
const failures: string[] = [];
let successCount = 0;
for (const id of ids) {
@@ -324,7 +311,7 @@ export function TenantWorksmobilePage() {
tenantId,
id,
undefined,
passwordUserIdSet.has(id) ? trimmedInitialPassword : undefined,
trimmedInitialPassword,
);
} else {
await enqueueWorksmobileOrgUnitSync(tenantId, id);
@@ -368,56 +355,12 @@ export function TenantWorksmobilePage() {
comparisonQuery.refetch();
},
onError: (error) => {
overviewQuery.refetch();
toast.error("WORKS 생성 작업 등록 실패", {
description: getErrorMessage(error),
});
},
});
const importSelectedUsersMutation = useMutation({
mutationFn: async (worksmobileUserIds: string[]) =>
importWorksmobileUsersFromWorks(tenantId, worksmobileUserIds),
onSuccess: (result) => {
setSelectedUserRowKeys([]);
const failureCount = result.failures?.length ?? 0;
const description = [
`Baron 업데이트 ${result.updatedCount}`,
`Baron 생성 ${result.createdCount}`,
`external_key 반영 ${result.externalKeyUpdates}`,
failureCount > 0 ? `실패 ${failureCount}` : "",
]
.filter(Boolean)
.join(", ");
if (failureCount > 0) {
toast.error("일부 Works정보 가져오기 실패", {
description:
result.failures
?.slice(0, 3)
.map((failure) =>
[
failure.email ?? failure.worksmobileId ?? "unknown",
failure.error,
].join(": "),
)
.join("\n") ?? description,
});
} else {
toast.success("Works정보 가져오기를 완료했습니다.", {
description,
});
}
overviewQuery.refetch();
comparisonQuery.refetch();
},
onError: (error) => {
overviewQuery.refetch();
toast.error("Works정보 가져오기 실패", {
description: getErrorMessage(error),
});
},
});
const syncSelectedOrgUnitsMutation = useMutation({
mutationFn: async ({
baronIds,
@@ -446,7 +389,6 @@ export function TenantWorksmobilePage() {
comparisonQuery.refetch();
},
onError: (error) => {
overviewQuery.refetch();
toast.error("선택 조직 동기화 작업 등록 실패", {
description: getErrorMessage(error),
});
@@ -619,7 +561,6 @@ export function TenantWorksmobilePage() {
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>retry</TableHead>
<TableHead className="w-24" />
</TableRow>
@@ -664,29 +605,7 @@ export function TenantWorksmobilePage() {
</details>
)}
</TableCell>
<TableCell>
<Badge
variant={
job.status === "failed" ? "destructive" : "outline"
}
>
{job.status}
</Badge>
</TableCell>
<TableCell className="max-w-sm">
{job.lastError ? (
<span
className="line-clamp-3 text-xs text-destructive"
title={job.lastError}
>
{job.lastError}
</span>
) : (
<span className="text-xs text-muted-foreground">
-
</span>
)}
</TableCell>
<TableCell>{job.status}</TableCell>
<TableCell>{job.retryCount}</TableCell>
<TableCell>
<Button
@@ -749,24 +668,21 @@ export function TenantWorksmobilePage() {
visibleColumns={userVisibleColumns}
onVisibleColumnsChange={setUserVisibleColumns}
passwordManageTenantId={overview?.config.adminTenantId}
actionLabel="Works에 정보 넣기"
actionDisabled={
isCreatingUsers ||
createSelectedMutation.isPending ||
importSelectedUsersMutation.isPending
}
importActionLabel="Works정보 가져오기"
importActionDisabled={importSelectedUsersMutation.isPending}
onCreateSelected={(ids, initialPassword, initialPasswordUserIds) =>
actionLabel="선택 구성원 WORKS에 생성"
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
updateActionLabel="선택 구성원 업데이트 적용"
onCreateSelected={(ids, initialPassword) =>
createSelectedMutation.mutateAsync({
resourceKind: "users",
ids,
initialPassword,
initialPasswordUserIds,
})
}
onImportSelected={(ids) =>
importSelectedUsersMutation.mutate(ids)
onUpdateSelected={(ids) =>
createSelectedMutation.mutate({
resourceKind: "users",
ids,
})
}
requireInitialPassword
/>
@@ -1099,11 +1015,10 @@ function ComparisonTable({
showBaronIdColumn = true,
showManageColumn = true,
actionLabel,
importActionLabel,
importActionDisabled = false,
updateActionLabel,
actionDisabled,
onCreateSelected,
onImportSelected,
onUpdateSelected,
onRunSelected,
deleteActionLabel,
deleteActionDisabled = false,
@@ -1136,15 +1051,10 @@ function ComparisonTable({
showBaronIdColumn?: boolean;
showManageColumn?: boolean;
actionLabel: string;
importActionLabel?: string;
importActionDisabled?: boolean;
updateActionLabel?: string;
actionDisabled: boolean;
onCreateSelected: (
ids: string[],
initialPassword?: string,
initialPasswordUserIds?: string[],
) => unknown;
onImportSelected?: (worksmobileUserIds: string[]) => void;
onCreateSelected: (ids: string[], initialPassword?: string) => unknown;
onUpdateSelected?: (ids: string[]) => void;
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
deleteActionLabel?: string;
deleteActionDisabled?: boolean;
@@ -1156,7 +1066,6 @@ function ComparisonTable({
const [initialPassword, setInitialPassword] = React.useState("");
const [pendingInitialPasswordIds, setPendingInitialPasswordIds] =
React.useState<string[]>([]);
const [pendingActionIds, setPendingActionIds] = React.useState<string[]>([]);
const tableViewportRef = React.useRef<HTMLDivElement>(null);
const selectableKeys = rows
.filter(canSelectWorksmobileRow)
@@ -1167,7 +1076,7 @@ function ComparisonTable({
rows,
selectedKeys,
);
const selectedImportUserIds = getWorksmobileSelectedImportUserIds(
const selectedUpdateUserIds = getWorksmobileSelectedUpdateUserIds(
rows,
selectedKeys,
);
@@ -1181,7 +1090,7 @@ function ComparisonTable({
selectedActionIds.length === 0 &&
selectedDeleteIds.length > 0 &&
canRunDeleteAction;
const canRunUserImportAction = Boolean(onImportSelected);
const canRunUserUpdateAction = Boolean(onUpdateSelected);
const selectedActionLabel = shouldRunDeleteAction
? deleteActionLabel
: actionLabel;
@@ -1193,9 +1102,11 @@ function ComparisonTable({
? selectedActionIds.length === 0 && selectedDeleteIds.length === 0
: shouldRunDeleteAction
? selectedDeleteIds.length === 0 || deleteActionDisabled
: selectedActionIds.length === 0) || actionDisabled;
const importActionButtonDisabled =
selectedImportUserIds.length === 0 || importActionDisabled;
: requireInitialPassword
? selectedCreateUserIds.length === 0
: selectedActionIds.length === 0) || actionDisabled;
const updateActionDisabled =
selectedUpdateUserIds.length === 0 || actionDisabled;
const allSelectableSelected =
selectableKeys.length > 0 &&
selectableKeys.every((key) => selectedKeys.includes(key));
@@ -1317,8 +1228,7 @@ function ComparisonTable({
onDeleteSelected(selectedDeleteIds);
return;
}
if (requireInitialPassword && selectedCreateUserIds.length > 0) {
setPendingActionIds(selectedActionIds);
if (requireInitialPassword) {
setPendingInitialPasswordIds(selectedCreateUserIds);
setInitialPassword("");
setInitialPasswordOpen(true);
@@ -1327,11 +1237,11 @@ function ComparisonTable({
onCreateSelected(selectedActionIds);
};
const runImportAction = () => {
if (!onImportSelected || selectedImportUserIds.length === 0) {
const runUpdateAction = () => {
if (!onUpdateSelected || selectedUpdateUserIds.length === 0) {
return;
}
onImportSelected(selectedImportUserIds);
onUpdateSelected(selectedUpdateUserIds);
};
const confirmInitialPassword = async () => {
@@ -1341,18 +1251,13 @@ function ComparisonTable({
return;
}
try {
await onCreateSelected(
pendingActionIds,
password,
pendingInitialPasswordIds,
);
await onCreateSelected(pendingInitialPasswordIds, password);
} catch {
return;
}
setInitialPasswordOpen(false);
setInitialPassword("");
setPendingInitialPasswordIds([]);
setPendingActionIds([]);
};
return (
@@ -1395,28 +1300,27 @@ function ComparisonTable({
}
/>
{accountStatusFilter && onAccountStatusFilterChange ? (
<Select
value={accountStatusFilter}
onValueChange={(value) =>
onAccountStatusFilterChange(
value as WorksmobileAccountStatusFilter,
)
}
<div
className="flex flex-wrap items-center gap-2"
role="tablist"
aria-label="WORKS 계정 상태"
>
<SelectTrigger
className="h-9 w-[148px]"
aria-label="WORKS 계정 상태"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{worksmobileAccountStatusFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{worksmobileAccountStatusFilterOptions.map((option) => (
<Button
key={option.value}
type="button"
role="tab"
size="sm"
variant={
accountStatusFilter === option.value ? "default" : "outline"
}
aria-selected={accountStatusFilter === option.value}
onClick={() => onAccountStatusFilterChange(option.value)}
>
{option.label}
</Button>
))}
</div>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-start gap-2 xl:justify-end">
@@ -1476,15 +1380,15 @@ function ComparisonTable({
>
{selectedActionLabel}
</Button>
{canRunUserImportAction && (
{canRunUserUpdateAction && (
<Button
type="button"
size="sm"
variant="outline"
onClick={runImportAction}
disabled={importActionButtonDisabled}
onClick={runUpdateAction}
disabled={updateActionDisabled}
>
{importActionLabel || "Works정보 가져오기"}
{updateActionLabel || "선택 구성원 업데이트 적용"}
</Button>
)}
<Dialog
@@ -1494,7 +1398,6 @@ function ComparisonTable({
if (!open) {
setInitialPassword("");
setPendingInitialPasswordIds([]);
setPendingActionIds([]);
}
}}
>
@@ -1534,7 +1437,7 @@ function ComparisonTable({
onClick={confirmInitialPassword}
disabled={actionDisabled}
>
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -47,7 +47,7 @@ export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonC
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: true,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
@@ -212,24 +212,6 @@ export function getWorksmobileSelectedUpdateUserIds(
.filter((id): id is string => Boolean(id));
}
export function getWorksmobileSelectedImportUserIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter(
(row) =>
row.resourceType === "USER" &&
(row.status === "needs_update" ||
row.status === "missing_external_key" ||
row.status === "missing_in_baron") &&
selected.has(getWorksmobileRowSelectionKey(row)),
)
.map((row) => row.worksmobileId)
.filter((id): id is string => Boolean(id));
}
export function formatWorksmobileSelectionFailureDescription(
successCount: number,
failures: string[],

View File

@@ -1005,23 +1005,6 @@ export type WorksmobileComparison = {
groups: WorksmobileComparisonItem[];
};
export type WorksmobileImportUsersResult = {
updatedCount: number;
createdCount: number;
externalKeyUpdates: number;
failures?: Array<{
worksmobileId?: string;
email?: string;
error: string;
}>;
items?: Array<{
worksmobileId?: string;
baronId?: string;
email?: string;
action: string;
}>;
};
export async function fetchUsers(
limit = 50,
offset = 0,
@@ -1211,17 +1194,6 @@ export async function enqueueWorksmobileUserSync(
return data;
}
export async function importWorksmobileUsersFromWorks(
tenantId: string,
worksmobileUserIds: string[],
) {
const { data } = await apiClient.post<WorksmobileImportUsersResult>(
`/v1/admin/tenants/${tenantId}/worksmobile/users/import-from-works`,
{ worksmobileUserIds },
);
return data;
}
export async function resetWorksmobileUserPassword(
tenantId: string,
userId: string,

View File

@@ -356,10 +356,12 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("row", { name: /김누락/ })
.getByRole("checkbox")
.check();
await page.getByRole("button", { name: "Works에 정보 넣기" }).click();
await page
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.click();
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "작업 등록" }).click();
await page.getByRole("button", { name: "생성 작업 등록" }).click();
await expect
.poll(() => syncRequests)
.toEqual([
@@ -589,11 +591,11 @@ test.describe("Worksmobile tenant management", () => {
.check();
await userComparisonSection
.getByRole("button", { name: "Works에 정보 넣기" })
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.click();
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "작업 등록" }).click();
await page.getByRole("button", { name: "생성 작업 등록" }).click();
await expect
.poll(() => syncRequests)
.toEqual([
@@ -601,12 +603,6 @@ test.describe("Worksmobile tenant management", () => {
userId: "user-missing",
body: expect.objectContaining({ initialPassword: "InitPass123!" }),
},
{
userId: "user-update",
body: expect.not.objectContaining({
initialPassword: expect.anything(),
}),
},
]);
const updateRowCheckbox = userComparisonSection
@@ -618,9 +614,8 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("checkbox")
.check();
await userComparisonSection
.getByRole("button", { name: "Works에 정보 넣기" })
.getByRole("button", { name: "선택 구성원 업데이트 적용" })
.click();
await expect(page.getByText("WORKS 초기 비밀번호")).not.toBeVisible();
await expect
.poll(() => syncRequests)
.toEqual([
@@ -634,12 +629,6 @@ test.describe("Worksmobile tenant management", () => {
initialPassword: expect.anything(),
}),
},
{
userId: "user-update",
body: expect.not.objectContaining({
initialPassword: expect.anything(),
}),
},
]);
});
@@ -745,13 +734,15 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("row", { name: /실패 사용자/ })
.getByRole("checkbox")
.check();
await page.getByRole("button", { name: "Works에 정보 넣기" }).click();
await page
.getByRole("button", { name: "선택 구성원 WORKS에 생성" })
.click();
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "작업 등록" }).click();
await page.getByRole("button", { name: "생성 작업 등록" }).click();
await expect(page.getByText("WORKS 초기 비밀번호")).toBeVisible();
await page.getByLabel("초기 비밀번호").fill("InitPass123!");
await page.getByRole("button", { name: "작업 등록" }).click();
await page.getByRole("button", { name: "생성 작업 등록" }).click();
await expect(page.getByText("WORKS 생성 작업 등록 실패")).toBeVisible();
await expect(
@@ -926,90 +917,6 @@ test.describe("Worksmobile tenant management", () => {
"Access-Control-Allow-Origin": "*",
"Access-Control-Expose-Headers": "Content-Disposition",
};
const buildRecentJobs = () => [
...(requests.includes("org-rejected-sync")
? [
{
id: "job-org-rejected",
resourceType: "ORGUNIT",
resourceId: "org-rejected",
action: "UPSERT",
status: "failed",
retryCount: 0,
lastError: "target tenant is excluded from Worksmobile sync",
createdAt: "2026-05-01T00:02:00Z",
updatedAt: "2026-05-01T00:02:00Z",
payload: {
displayName: "제외팀",
matchLocalPart: "excluded-team",
requestSummary: {
orgUnitName: "제외팀",
orgUnitExternalKey: "org-rejected",
tenantSlug: "excluded-team",
},
},
},
]
: []),
{
id: "job-retry",
resourceType: "USER",
resourceId: "user-failed",
action: "sync",
status: "failed",
retryCount: 1,
lastError: "worksmobile api failed status=400 body=invalid org",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
payload: {
loginEmail: "changed-user@example.com",
displayName: "변경 사용자",
primaryLeafOrgName: "인재성장",
requestSummary: {
email: "changed-user@example.com",
displayName: "변경 사용자",
userExternalKey: "user-failed",
},
},
},
{
id: "job-org-auto",
resourceType: "ORGUNIT",
resourceId: "org-auto",
action: "UPSERT",
status: "processed",
retryCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:01:00Z",
payload: {
matchLocalPart: "people-growth",
requestSummary: {
orgUnitName: "인재성장",
email: "people-growth@example.com",
orgUnitExternalKey: "org-auto",
parentOrgUnitId: "externalKey:parent-org",
},
},
},
{
id: "job-pending",
resourceType: "ORGUNIT",
resourceId: "org-pending",
action: "UPSERT",
status: "pending",
retryCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:01:00Z",
payload: {
matchLocalPart: "halla-site",
requestSummary: {
orgUnitName: "한라 현장",
email: "halla-site@hallasanup.com",
orgUnitExternalKey: "org-pending",
},
},
},
];
await page.route("**/api/v1/**", async (route) => {
const url = new URL(route.request().url());
@@ -1041,7 +948,65 @@ test.describe("Worksmobile tenant management", () => {
tokenConfigured: true,
adminTenantId: "works-tenant-1",
},
recentJobs: buildRecentJobs(),
recentJobs: [
{
id: "job-retry",
resourceType: "USER",
resourceId: "user-failed",
action: "sync",
status: "failed",
retryCount: 1,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
payload: {
loginEmail: "changed-user@example.com",
displayName: "변경 사용자",
primaryLeafOrgName: "인재성장",
requestSummary: {
email: "changed-user@example.com",
displayName: "변경 사용자",
userExternalKey: "user-failed",
},
},
},
{
id: "job-org-auto",
resourceType: "ORGUNIT",
resourceId: "org-auto",
action: "UPSERT",
status: "processed",
retryCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:01:00Z",
payload: {
matchLocalPart: "people-growth",
requestSummary: {
orgUnitName: "인재성장",
email: "people-growth@example.com",
orgUnitExternalKey: "org-auto",
parentOrgUnitId: "externalKey:parent-org",
},
},
},
{
id: "job-pending",
resourceType: "ORGUNIT",
resourceId: "org-pending",
action: "UPSERT",
status: "pending",
retryCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:01:00Z",
payload: {
matchLocalPart: "halla-site",
requestSummary: {
orgUnitName: "한라 현장",
email: "halla-site@hallasanup.com",
orgUnitExternalKey: "org-pending",
},
},
},
],
},
headers,
});
@@ -1103,20 +1068,6 @@ test.describe("Worksmobile tenant management", () => {
return route.fulfill({ json: { id: "job-org-sync" }, headers });
}
if (
url.pathname.endsWith(
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/orgunits/org-rejected/sync",
) &&
method === "POST"
) {
requests.push("org-rejected-sync");
return route.fulfill({
status: 400,
json: { error: "target tenant is excluded from Worksmobile sync" },
headers,
});
}
if (
url.pathname.endsWith(
"/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/users/user-1/sync",
@@ -1165,9 +1116,6 @@ test.describe("Worksmobile tenant management", () => {
await page.getByPlaceholder("orgUnit tenant UUID").fill("org-1");
await page.getByRole("button", { name: "조직 Sync" }).click();
await expect.poll(() => requests).toContain("org-sync");
await page.getByPlaceholder("orgUnit tenant UUID").fill("org-rejected");
await page.getByRole("button", { name: "조직 Sync" }).click();
await expect.poll(() => requests).toContain("org-rejected-sync");
await page.getByRole("tab", { name: "사용자" }).click();
await page.getByPlaceholder("Kratos user UUID").fill("user-1");
@@ -1175,10 +1123,6 @@ test.describe("Worksmobile tenant management", () => {
await expect.poll(() => requests).toContain("user-sync");
await page.getByRole("tab", { name: "이력" }).click();
const rejectedOrgRow = page.getByRole("row", { name: /제외팀/ });
await expect(rejectedOrgRow).toContainText(
"target tenant is excluded from Worksmobile sync",
);
await expect(page.getByRole("row", { name: /변경 사용자/ })).toContainText(
"changed-user@example.com",
);
@@ -1192,9 +1136,6 @@ test.describe("Worksmobile tenant management", () => {
.first(),
).toBeVisible();
const failedJobRow = page.getByRole("row", { name: /변경 사용자/ });
await expect(failedJobRow).toContainText(
"worksmobile api failed status=400 body=invalid org",
);
await failedJobRow.getByText("payload").click();
await expect(
failedJobRow.getByText('"loginEmail": "changed-user@example.com"'),

View File

@@ -1,9 +0,0 @@
.env
.env.*
/.codex
/reports
/tmp
/logs
/server
/main
*.log

View File

@@ -1,49 +1,21 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26.2-alpine AS base
FROM golang:1.26.2-alpine
WORKDIR /app
# Install git for go mod download if needed
RUN apk add --no-cache git
# Pre-copy go.mod/sum to cache dependencies
COPY go.mod go.sum ./
RUN go mod download
FROM base AS dev
# Copy source
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o /usr/local/bin/baron-backend-dev ./cmd/server
# Build for production (optional, can just run go run for dev)
RUN go build -o main ./cmd/server
EXPOSE 3000
CMD ["/usr/local/bin/baron-backend-dev"]
FROM base AS builder
ARG TARGETOS=linux
ARG TARGETARCH=amd64
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/main ./cmd/server && \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o /out/healthcheck ./cmd/healthcheck
FROM gcr.io/distroless/static-debian13:nonroot AS production
WORKDIR /app
COPY --from=builder --chown=65532:65532 /out/main ./main
COPY --from=builder --chown=65532:65532 /out/healthcheck ./healthcheck
COPY --from=builder --chown=65532:65532 /app/docs ./docs
EXPOSE 3000
USER 65532:65532
ENTRYPOINT ["/app/main"]
# Default command (can be overridden by compose)
CMD ["./main"]

View File

@@ -6,7 +6,6 @@ import (
"baron-sso-backend/internal/service"
"context"
"encoding/csv"
"encoding/json"
"errors"
"flag"
"fmt"
@@ -60,9 +59,6 @@ type worksmobileSyncConfig struct {
ComparisonOutput string
AlignBaronFromWorksOutput string
AlignBaronFromWorksExclude string
ImportFromWorksEmails string
PatchWorksUserNameEmail string
PatchWorksUserName string
InspectOutput string
CredentialBatchID string
Process bool
@@ -206,28 +202,6 @@ func runWorksmobileSync(args []string) error {
return err
}
}
if config.ImportFromWorksEmails != "" {
kratosAdmin := service.NewKratosAdminService()
syncService.SetIdentityServices(service.NewIdentityWriteService(kratosAdmin, nil), kratosAdmin)
worksmobileUserIDs, err := resolveWorksmobileUserIDsByEmail(ctx, newWorksmobileAdminClient(), config.ImportFromWorksEmails)
if err != nil {
return err
}
result, err := syncService.ImportUsersFromWorks(ctx, root.ID, worksmobileUserIDs)
if err != nil {
return err
}
encoded, err := json.MarshalIndent(result, "", " ")
if err != nil {
return err
}
fmt.Println(string(encoded))
}
if config.PatchWorksUserNameEmail != "" {
if err := patchWorksmobileUserName(ctx, newWorksmobileAdminClient(), config.PatchWorksUserNameEmail, config.PatchWorksUserName); err != nil {
return err
}
}
if config.Process {
return processWorksmobileOutbox(ctx, db, outboxRepo, config)
}
@@ -282,9 +256,6 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
fs.StringVar(&config.ComparisonOutput, "comparison-output", "", "output CSV path for current Worksmobile user comparison rows whose status is needs_update")
fs.StringVar(&config.AlignBaronFromWorksOutput, "align-baron-from-works-output", "", "output CSV path for one-time Baron user updates from current Worksmobile needs_update rows")
fs.StringVar(&config.AlignBaronFromWorksExclude, "align-baron-from-works-exclude", "", "comma-separated emails or local-parts to exclude from --align-baron-from-works-output")
fs.StringVar(&config.ImportFromWorksEmails, "import-from-works-emails", "", "comma-separated Worksmobile emails to import into Baron and patch Worksmobile externalKey")
fs.StringVar(&config.PatchWorksUserNameEmail, "patch-works-user-name-email", "", "Worksmobile email to patch userName by PATCH-only")
fs.StringVar(&config.PatchWorksUserName, "patch-works-user-name", "", "display name for --patch-works-user-name-email")
fs.StringVar(&config.InspectOutput, "inspect-output", "", "output CSV path for inspect/undelete commands")
fs.StringVar(&config.CredentialBatchID, "credential-batch-id", "", "credential batch id for regenerated user password rows")
fs.BoolVar(&config.Process, "process", false, "process ready Worksmobile outbox jobs")
@@ -296,11 +267,8 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
if err := fs.Parse(args); err != nil {
return config, err
}
if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.UpdateUserLevelsCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && config.ImportFromWorksEmails == "" && config.PatchWorksUserNameEmail == "" && !config.Process {
return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --update-user-levels-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, --import-from-works-emails, --patch-works-user-name-email, or --process")
}
if config.PatchWorksUserNameEmail != "" && strings.TrimSpace(config.PatchWorksUserName) == "" {
return config, fmt.Errorf("--patch-works-user-name is required with --patch-works-user-name-email")
if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.UpdateUserLevelsCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && !config.Process {
return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --update-user-levels-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, or --process")
}
if config.ResetPendingUsersPassword != "" && config.ResetPendingUsersResultOutput == "" {
return config, fmt.Errorf("--reset-pending-users-result-output is required with --reset-pending-users-password")
@@ -338,119 +306,6 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
return config, nil
}
func resolveWorksmobileUserIDsByEmail(ctx context.Context, client service.WorksmobileDirectoryClient, rawEmails string) ([]string, error) {
if client == nil {
return nil, errors.New("worksmobile client is not configured")
}
targetEmails := splitCommaSeparatedValues(rawEmails)
if len(targetEmails) == 0 {
return nil, errors.New("--import-from-works-emails requires at least one email")
}
remoteUsers, err := client.ListUsers(ctx)
if err != nil {
return nil, err
}
remoteByEmail := make(map[string]service.WorksmobileRemoteUser, len(remoteUsers))
for _, remote := range remoteUsers {
email := strings.ToLower(strings.TrimSpace(remote.Email))
if email == "" {
continue
}
remoteByEmail[email] = remote
}
userIDs := make([]string, 0, len(targetEmails))
for _, targetEmail := range targetEmails {
remote, ok := remoteByEmail[strings.ToLower(targetEmail)]
if !ok {
return nil, fmt.Errorf("worksmobile user not found by email: %s", targetEmail)
}
if id := strings.TrimSpace(remote.ID); id != "" {
userIDs = append(userIDs, id)
continue
}
return nil, fmt.Errorf("worksmobile user id is empty for email: %s", targetEmail)
}
return userIDs, nil
}
func splitCommaSeparatedValues(raw string) []string {
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
key := strings.ToLower(value)
if seen[key] {
continue
}
seen[key] = true
values = append(values, value)
}
return values
}
func patchWorksmobileUserName(ctx context.Context, client service.WorksmobileDirectoryClient, email string, displayName string) error {
if client == nil {
return errors.New("worksmobile client is not configured")
}
email = strings.ToLower(strings.TrimSpace(email))
displayName = strings.TrimSpace(displayName)
if email == "" || displayName == "" {
return errors.New("email and display name are required")
}
remoteUsers, err := client.ListUsers(ctx)
if err != nil {
return err
}
var target *service.WorksmobileRemoteUser
for i := range remoteUsers {
if strings.EqualFold(strings.TrimSpace(remoteUsers[i].Email), email) {
target = &remoteUsers[i]
break
}
}
if target == nil {
return fmt.Errorf("worksmobile user not found by email: %s", email)
}
if err := client.UpdateUserOnly(ctx, service.WorksmobileUserPayload{
DomainID: target.DomainID,
Email: strings.TrimSpace(target.Email),
UserExternalKey: strings.TrimSpace(target.ExternalID),
UserName: adminctlWorksmobileUserNameFromDisplayName(displayName),
CellPhone: strings.TrimSpace(target.CellPhone),
EmployeeNumber: strings.TrimSpace(target.EmployeeNumber),
Locale: "ko_KR",
Task: strings.TrimSpace(target.Task),
}); err != nil {
return err
}
fmt.Printf("worksmobile user name patched: email=%s display_name=%s\n", email, displayName)
return nil
}
func adminctlWorksmobileUserNameFromDisplayName(name string) service.WorksmobileUserName {
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, " \t\r\n") {
return service.WorksmobileUserName{LastName: name}
}
runes := []rune(name)
if len(runes) < 2 || len(runes) > 4 {
return service.WorksmobileUserName{LastName: name}
}
for _, r := range runes {
if r < '가' || r > '힣' {
return service.WorksmobileUserName{LastName: name}
}
}
return service.WorksmobileUserName{
LastName: string(runes[:1]),
FirstName: string(runes[1:]),
}
}
func enqueueWorksmobileOrgUnits(ctx context.Context, db *gorm.DB, syncService service.WorksmobileAdminService, rootID string) (int, int, int, error) {
tenantIDs, err := activeTenantSubtreeIDs(ctx, db, rootID)
if err != nil {

View File

@@ -1,72 +0,0 @@
package main
import (
"bufio"
"net"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
url := strings.TrimSpace(os.Getenv("BACKEND_HEALTHCHECK_URL"))
if url == "" {
port := strings.TrimSpace(os.Getenv("BACKEND_PORT"))
if port == "" {
port = strings.TrimSpace(os.Getenv("PORT"))
}
if port == "" {
port = "3000"
}
url = "http://127.0.0.1:" + port + "/health"
}
statusCode, err := checkHTTP(url, 3*time.Second)
if err != nil {
_, _ = os.Stderr.WriteString("healthcheck request failed: " + err.Error() + "\n")
os.Exit(1)
}
if statusCode < 200 || statusCode >= 400 {
_, _ = os.Stderr.WriteString("healthcheck returned HTTP " + strconv.Itoa(statusCode) + "\n")
os.Exit(1)
}
}
func checkHTTP(rawURL string, timeout time.Duration) (int, error) {
parsed, err := url.Parse(rawURL)
if err != nil {
return 0, err
}
host := parsed.Host
if !strings.Contains(host, ":") {
host += ":80"
}
path := parsed.RequestURI()
if path == "" {
path = "/"
}
conn, err := net.DialTimeout("tcp", host, timeout)
if err != nil {
return 0, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
request := "GET " + path + " HTTP/1.1\r\nHost: " + parsed.Host + "\r\nConnection: close\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil {
return 0, err
}
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
return 0, err
}
parts := strings.Fields(line)
if len(parts) < 2 {
return 0, nil
}
return strconv.Atoi(parts[1])
}

View File

@@ -329,7 +329,6 @@ func main() {
configureWorksmobileClientFromEnv(worksmobileClient)
worksmobileService := service.NewWorksmobileSyncService(tenantService, userRepo, worksmobileOutboxRepo, worksmobileClient)
worksmobileService.SetIdentityMirror(redisService)
worksmobileService.SetIdentityServices(service.NewIdentityWriteService(kratosAdminService, redisService), kratosAdminService)
worksmobileRelayClient := *worksmobileClient
worksmobileRelayClient.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, &worksmobileRelayClient)
@@ -782,7 +781,6 @@ func main() {
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit)
admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit)
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser)
admin.Post("/tenants/:tenantId/worksmobile/users/import-from-works", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ImportUsersFromWorks)
admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword)
admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob)
admin.Delete("/tenants/:tenantId/worksmobile/jobs/pending", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeletePendingJobs)

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

@@ -89,26 +89,6 @@ func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusAccepted).JSON(job)
}
func (h *WorksmobileHandler) ImportUsersFromWorks(c *fiber.Ctx) error {
var req struct {
WorksmobileUserIDs []string `json:"worksmobileUserIds"`
}
if len(c.Body()) > 0 {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
}
result, err := h.Service.ImportUsersFromWorks(
c.Context(),
strings.TrimSpace(c.Params("tenantId")),
req.WorksmobileUserIDs,
)
if err != nil {
return worksmobileGuardError(c, err, "import_users_from_works")
}
return c.JSON(result)
}
func (h *WorksmobileHandler) ResetUserPassword(c *fiber.Ctx) error {
userID := strings.TrimSpace(c.Params("userId"))
credentialBatchID, err := parseWorksmobileCredentialBatchID(c)

View File

@@ -230,10 +230,6 @@ func (f *fakeWorksmobileAdminService) GetComparison(ctx context.Context, tenantI
return service.WorksmobileComparison{}, nil
}
func (f *fakeWorksmobileAdminService) ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (service.WorksmobileImportUsersResult, error) {
return service.WorksmobileImportUsersResult{UpdatedCount: len(worksmobileUserIDs)}, nil
}
func (f *fakeWorksmobileAdminService) EnqueueBackfillDryRun(ctx context.Context, tenantID string) (service.WorksmobileBackfillDryRun, error) {
return service.WorksmobileBackfillDryRun{}, nil
}

View File

@@ -12,7 +12,6 @@ import (
type WorksmobileOutboxRepository interface {
Create(ctx context.Context, item *domain.WorksmobileOutbox) error
ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error)
ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error)
ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error)
UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error
DeletePendingByTenantRoot(ctx context.Context, tenantRootID string) (int64, error)
@@ -60,20 +59,6 @@ func (r *worksmobileOutboxRepository) ListRecent(ctx context.Context, limit int)
return rows, err
}
func (r *worksmobileOutboxRepository) ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error) {
if limit <= 0 || limit > 1000 {
limit = 50
}
query := r.db.WithContext(ctx).Where("payload ->> 'tenantRootId' = ?", tenantRootID)
if len(resourceIDs) > 0 {
query = query.Or("resource_id IN ?", resourceIDs)
}
var rows []domain.WorksmobileOutbox
err := query.Order("created_at desc").Limit(limit).Find(&rows).Error
return rows, err
}
func (r *worksmobileOutboxRepository) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
query := r.db.WithContext(ctx).
Where("resource_type = ? AND payload ->> 'tenantRootId' = ? AND coalesce(payload ->> 'credentialBatchId', '') <> ?", domain.WorksmobileResourceUser, tenantRootID, "")

View File

@@ -69,56 +69,6 @@ func TestWorksmobileOutboxRepositoryDeletePendingByTenantRoot(t *testing.T) {
require.Equal(t, "00000000-0000-0000-0000-000000000104", remaining[2].ID)
}
func TestWorksmobileOutboxRepositoryListRecentByTenantRoot(t *testing.T) {
repo := NewWorksmobileOutboxRepository(testDB)
ctx := context.Background()
require.NoError(t, testDB.Exec("DELETE FROM worksmobile_outboxes").Error)
rows := []domain.WorksmobileOutbox{
{
ID: "00000000-0000-0000-0000-000000000151",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-root",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusFailed,
DedupeKey: "recent-root-user",
Payload: domain.JSONMap{"tenantRootId": "root-1"},
CreatedAt: time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC),
},
{
ID: "00000000-0000-0000-0000-000000000152",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: "child-tenant",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusFailed,
DedupeKey: "recent-root-org-legacy",
Payload: domain.JSONMap{},
CreatedAt: time.Date(2026, 6, 1, 11, 0, 0, 0, time.UTC),
},
{
ID: "00000000-0000-0000-0000-000000000153",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-other",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusFailed,
DedupeKey: "recent-other-root",
Payload: domain.JSONMap{"tenantRootId": "root-2"},
CreatedAt: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
},
}
for i := range rows {
require.NoError(t, testDB.Create(&rows[i]).Error)
}
recent, err := repo.ListRecentByTenantRoot(ctx, "root-1", []string{"child-tenant"}, 50)
require.NoError(t, err)
require.Len(t, recent, 2)
require.Equal(t, "00000000-0000-0000-0000-000000000152", recent[0].ID)
require.Equal(t, "00000000-0000-0000-0000-000000000151", recent[1].ID)
}
func TestWorksmobileOutboxRepositoryListReadyWaitsForPendingOrgUnitParent(t *testing.T) {
repo := NewWorksmobileOutboxRepository(testDB)
ctx := context.Background()

View File

@@ -78,6 +78,9 @@ func (s *RedisService) Ping(ctx context.Context) error {
// StoreVerificationCode saves the SMS verification code with a 3-minute expiration
func (s *RedisService) StoreVerificationCode(phone, code string) error {
if s == nil || s.Client == nil {
return os.ErrInvalid
}
// Key format: "sms_verify:01012345678"
key := "sms_verify:" + phone
expiration := 3 * time.Minute
@@ -87,6 +90,9 @@ func (s *RedisService) StoreVerificationCode(phone, code string) error {
// GetVerificationCode retrieves the SMS verification code
func (s *RedisService) GetVerificationCode(phone string) (string, error) {
if s == nil || s.Client == nil {
return "", os.ErrInvalid
}
key := "sms_verify:" + phone
code, err := s.Client.Get(ctx, key).Result()
if err == redis.Nil {
@@ -100,17 +106,26 @@ func (s *RedisService) GetVerificationCode(phone string) (string, error) {
// DeleteVerificationCode removes the verification code after successful verification
func (s *RedisService) DeleteVerificationCode(phone string) error {
if s == nil || s.Client == nil {
return os.ErrInvalid
}
key := "sms_verify:" + phone
return s.Client.Del(ctx, key).Err()
}
// Set stores a key-value pair with expiration
func (s *RedisService) Set(key string, value string, expiration time.Duration) error {
if s == nil || s.Client == nil {
return os.ErrInvalid
}
return s.Client.Set(ctx, key, value, expiration).Err()
}
// Get retrieves a value by key
func (s *RedisService) Get(key string) (string, error) {
if s == nil || s.Client == nil {
return "", os.ErrInvalid
}
val, err := s.Client.Get(ctx, key).Result()
if err == redis.Nil {
return "", nil
@@ -120,6 +135,9 @@ func (s *RedisService) Get(key string) (string, error) {
// Delete removes a key
func (s *RedisService) Delete(key string) error {
if s == nil || s.Client == nil {
return os.ErrInvalid
}
return s.Client.Del(ctx, key).Err()
}

View File

@@ -1924,20 +1924,6 @@ func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) (
return f.recent, nil
}
func (f *fakeWorksmobileOutboxRepo) ListRecentByTenantRoot(ctx context.Context, tenantRootID string, resourceIDs []string, limit int) ([]domain.WorksmobileOutbox, error) {
resourceIDSet := map[string]bool{}
for _, id := range resourceIDs {
resourceIDSet[id] = true
}
rows := make([]domain.WorksmobileOutbox, 0)
for _, row := range f.recent {
if stringValue(row.Payload["tenantRootId"]) == tenantRootID || resourceIDSet[row.ResourceID] {
rows = append(rows, row)
}
}
return rows, nil
}
func (f *fakeWorksmobileOutboxRepo) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) {
rows := make([]domain.WorksmobileOutbox, 0)
for _, row := range f.credentialBatchJobs {

View File

@@ -46,8 +46,7 @@ type WorksmobileUserPayload struct {
}
type WorksmobileUserName struct {
LastName string `json:"lastName,omitempty"`
FirstName string `json:"firstName,omitempty"`
LastName string `json:"lastName,omitempty"`
}
type WorksmobilePasswordConfig struct {
@@ -62,26 +61,6 @@ func (c WorksmobilePasswordConfig) IsZero() bool {
c.ChangePasswordAtNextLogin == nil
}
func worksmobileUserNameFromDisplayName(name string) WorksmobileUserName {
name = strings.TrimSpace(name)
if name == "" || strings.ContainsAny(name, " \t\r\n") {
return WorksmobileUserName{LastName: name}
}
runes := []rune(name)
if len(runes) < 2 || len(runes) > 4 {
return WorksmobileUserName{LastName: name}
}
for _, r := range runes {
if r < '가' || r > '힣' {
return WorksmobileUserName{LastName: name}
}
}
return WorksmobileUserName{
LastName: string(runes[:1]),
FirstName: string(runes[1:]),
}
}
func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
type payloadJSON struct {
DomainID int64 `json:"domainId"`
@@ -320,7 +299,7 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
DomainID: domainID,
Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID,
UserName: worksmobileUserNameFromDisplayName(user.Name),
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: employeeNumber,
Locale: "ko_KR",

View File

@@ -34,7 +34,6 @@ type WorksmobileSyncer interface {
type WorksmobileAdminService interface {
GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error)
GetComparison(ctx context.Context, tenantID string, includeMatched bool) (WorksmobileComparison, error)
ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (WorksmobileImportUsersResult, error)
EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error)
EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error)
EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error)
@@ -69,27 +68,6 @@ type WorksmobilePendingJobDeleteResult struct {
DeletedCount int `json:"deletedCount"`
}
type WorksmobileImportUsersResult struct {
UpdatedCount int `json:"updatedCount"`
CreatedCount int `json:"createdCount"`
ExternalKeyUpdates int `json:"externalKeyUpdates"`
Failures []WorksmobileImportUsersFailure `json:"failures,omitempty"`
Items []WorksmobileImportUsersResultItem `json:"items,omitempty"`
}
type WorksmobileImportUsersFailure struct {
WorksmobileID string `json:"worksmobileId,omitempty"`
Email string `json:"email,omitempty"`
Error string `json:"error"`
}
type WorksmobileImportUsersResultItem struct {
WorksmobileID string `json:"worksmobileId,omitempty"`
BaronID string `json:"baronId,omitempty"`
Email string `json:"email,omitempty"`
Action string `json:"action"`
}
type WorksmobileInitialPasswordCredential struct {
Email string `json:"email"`
Name string `json:"name,omitempty"`
@@ -200,8 +178,6 @@ type worksmobileSyncService struct {
outboxRepo repository.WorksmobileOutboxRepository
client WorksmobileDirectoryClient
identityMirror WorksmobileIdentityMirror
identityWriter IdentityWriteService
kratos KratosAdminService
}
type WorksmobileIdentityMirror interface {
@@ -225,30 +201,18 @@ func (s *worksmobileSyncService) SetIdentityMirror(source WorksmobileIdentityMir
s.identityMirror = source
}
func (s *worksmobileSyncService) SetIdentityServices(writer IdentityWriteService, kratos KratosAdminService) {
if s == nil {
return
}
s.identityWriter = writer
s.kratos = kratos
}
func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID string) (WorksmobileTenantOverview, error) {
root, err := s.hanmacRoot(ctx, tenantID)
tenant, err := s.tenantService.GetTenant(ctx, tenantID)
if err != nil {
return WorksmobileTenantOverview{}, err
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return WorksmobileTenantOverview{}, err
}
jobs, _ := s.outboxRepo.ListRecentByTenantRoot(ctx, root.ID, worksmobileRecentResourceIDs(root.ID, scopeTenants), 50)
jobs, _ := s.outboxRepo.ListRecent(ctx, 50)
jobs = redactWorksmobileOutboxPayloads(jobs)
return WorksmobileTenantOverview{
Tenant: *root,
Tenant: *tenant,
Config: WorksmobileConfigSummary{
Enabled: WorksmobileEnabled(root.Config),
DomainMappings: WorksmobileDomainMappings(root.Config),
Enabled: WorksmobileEnabled(tenant.Config),
DomainMappings: WorksmobileDomainMappings(tenant.Config),
TokenConfigured: worksmobileDirectoryAuthConfigured(),
AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")),
},
@@ -267,15 +231,6 @@ func worksmobileDirectoryAuthConfigured() bool {
strings.TrimSpace(os.Getenv("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE")) != "")
}
func worksmobileRecentResourceIDs(rootID string, tenants []domain.Tenant) []string {
ids := make([]string, 0, len(tenants)+1)
ids = append(ids, rootID)
for _, tenant := range tenants {
ids = append(ids, tenant.ID)
}
return ids
}
func WorksmobileExcluded(config domain.JSONMap) bool {
rawValue, ok := config[worksmobileExcludedConfigKey]
if !ok {
@@ -448,273 +403,6 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
}, nil
}
func (s *worksmobileSyncService) ImportUsersFromWorks(ctx context.Context, tenantID string, worksmobileUserIDs []string) (WorksmobileImportUsersResult, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
if s.client == nil {
return WorksmobileImportUsersResult{}, errors.New("worksmobile client is not configured")
}
if len(worksmobileUserIDs) == 0 {
return WorksmobileImportUsersResult{}, errors.New("worksmobile user ids are required")
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
remoteUsers, err := s.client.ListUsers(ctx)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
remoteGroups, err := s.client.ListGroups(ctx)
if err != nil {
return WorksmobileImportUsersResult{}, err
}
remoteByID := make(map[string]WorksmobileRemoteUser, len(remoteUsers))
for _, remote := range remoteUsers {
if id := strings.TrimSpace(remote.ID); id != "" {
remoteByID[id] = remote
}
}
groupByID := make(map[string]WorksmobileRemoteGroup, len(remoteGroups))
for _, group := range remoteGroups {
if id := strings.TrimSpace(group.ID); id != "" {
groupByID[id] = group
}
}
result := WorksmobileImportUsersResult{}
seen := map[string]bool{}
for _, rawID := range worksmobileUserIDs {
worksmobileID := strings.TrimSpace(rawID)
if worksmobileID == "" || seen[worksmobileID] {
continue
}
seen[worksmobileID] = true
remote, ok := remoteByID[worksmobileID]
if !ok {
result.Failures = append(result.Failures, WorksmobileImportUsersFailure{WorksmobileID: worksmobileID, Error: "worksmobile user not found"})
continue
}
user, created, externalKeyUpdated, err := s.importSingleWorksmobileUser(ctx, root.ID, remote, tenantByID, groupByID)
if err != nil {
result.Failures = append(result.Failures, WorksmobileImportUsersFailure{WorksmobileID: worksmobileID, Email: remote.Email, Error: err.Error()})
continue
}
action := "updated"
if created {
action = "created"
result.CreatedCount++
} else {
result.UpdatedCount++
}
if externalKeyUpdated {
result.ExternalKeyUpdates++
}
result.Items = append(result.Items, WorksmobileImportUsersResultItem{
WorksmobileID: worksmobileID,
BaronID: user.ID,
Email: user.Email,
Action: action,
})
}
return result, nil
}
func (s *worksmobileSyncService) importSingleWorksmobileUser(ctx context.Context, rootID string, remote WorksmobileRemoteUser, tenantByID map[string]domain.Tenant, groupByID map[string]WorksmobileRemoteGroup) (domain.User, bool, bool, error) {
email := strings.ToLower(strings.TrimSpace(remote.Email))
if email == "" {
return domain.User{}, false, false, errors.New("worksmobile user email is required")
}
tenantID := worksmobileTenantIDForRemoteUser(remote, groupByID)
tenant, ok := tenantByID[tenantID]
if !ok || !isWorksmobileUserScopeTenant(tenant) {
return domain.User{}, false, false, fmt.Errorf("worksmobile primary org is outside import scope: %s", tenantID)
}
var existing *domain.User
if externalKey := strings.TrimSpace(remote.ExternalID); externalKey != "" {
if user, err := s.userRepo.FindByID(ctx, externalKey); err == nil {
existing = user
} else {
return domain.User{}, false, false, fmt.Errorf("worksmobile external key does not match a Baron user: %s", externalKey)
}
} else if user, err := s.userRepo.FindByEmail(ctx, email); err == nil {
existing = user
}
if existing != nil {
user := *existing
applyWorksmobileRemoteToUser(&user, remote, tenant.ID)
if err := s.updateImportedWorksmobileUserIdentity(ctx, user); err != nil {
return domain.User{}, false, false, err
}
if err := s.userRepo.Update(ctx, &user); err != nil {
return domain.User{}, false, false, err
}
updatedExternalKey := false
if strings.TrimSpace(remote.ExternalID) == "" {
if err := s.patchWorksmobileUserExternalKey(ctx, remote, user.ID); err != nil {
return domain.User{}, false, false, err
}
updatedExternalKey = true
}
return user, false, updatedExternalKey, nil
}
if strings.TrimSpace(remote.ExternalID) != "" {
return domain.User{}, false, false, errors.New("creating Baron user from non-empty unmatched worksmobile external key is not supported")
}
if s.kratos == nil {
return domain.User{}, false, false, errors.New("kratos admin service is required")
}
identityID, err := s.kratos.CreateUser(ctx, &domain.BrokerUser{
Email: email,
Name: strings.TrimSpace(remote.DisplayName),
PhoneNumber: strings.TrimSpace(remote.CellPhone),
Attributes: map[string]any{
"tenant_id": tenant.ID,
"role": domain.RoleUser,
"status": domain.UserStatusActive,
"grade": strings.TrimSpace(remote.LevelName),
"jobTitle": strings.TrimSpace(remote.Task),
},
}, GenerateWorksmobileInitialPassword())
if err != nil {
return domain.User{}, false, false, err
}
now := time.Now().UTC()
user := domain.User{
ID: identityID,
Email: email,
Name: strings.TrimSpace(remote.DisplayName),
Phone: strings.TrimSpace(remote.CellPhone),
Role: domain.RoleUser,
Status: domain.UserStatusActive,
TenantID: &tenant.ID,
Grade: strings.TrimSpace(remote.LevelName),
JobTitle: strings.TrimSpace(remote.Task),
Metadata: worksmobileImportedUserMetadata(remote, tenant),
CreatedAt: now,
UpdatedAt: now,
}
if err := s.userRepo.Update(ctx, &user); err != nil {
return domain.User{}, false, false, err
}
if err := s.patchWorksmobileUserExternalKey(ctx, remote, user.ID); err != nil {
return domain.User{}, false, false, err
}
return user, true, true, nil
}
func (s *worksmobileSyncService) updateImportedWorksmobileUserIdentity(ctx context.Context, user domain.User) error {
if s.identityWriter == nil {
return nil
}
identity, err := s.identityWriter.GetIdentity(ctx, user.ID)
if err != nil {
return err
}
traits := map[string]any{}
for key, value := range identity.Traits {
traits[key] = value
}
traits["email"] = user.Email
traits["name"] = user.Name
if phone := strings.TrimSpace(user.Phone); phone != "" {
traits["phone_number"] = phone
}
traits["tenant_id"] = strings.TrimSpace(stringPtrValue(user.TenantID))
traits["role"] = user.Role
traits["status"] = user.Status
traits["grade"] = user.Grade
traits["jobTitle"] = user.JobTitle
_, err = s.identityWriter.UpdateIdentity(ctx, IdentityUpdateRequest{
IdentityID: user.ID,
Traits: traits,
State: strings.TrimSpace(identity.State),
Reason: "worksmobile_import_from_works",
Source: "admin_worksmobile",
})
return err
}
func (s *worksmobileSyncService) patchWorksmobileUserExternalKey(ctx context.Context, remote WorksmobileRemoteUser, userID string) error {
return s.client.UpdateUserOnly(ctx, WorksmobileUserPayload{
DomainID: remote.DomainID,
Email: strings.TrimSpace(remote.Email),
UserExternalKey: strings.TrimSpace(userID),
CellPhone: strings.TrimSpace(remote.CellPhone),
EmployeeNumber: strings.TrimSpace(remote.EmployeeNumber),
Locale: "ko_KR",
Task: strings.TrimSpace(remote.Task),
})
}
func applyWorksmobileRemoteToUser(user *domain.User, remote WorksmobileRemoteUser, tenantID string) {
now := time.Now().UTC()
user.Email = strings.ToLower(strings.TrimSpace(remote.Email))
user.Name = strings.TrimSpace(remote.DisplayName)
user.Phone = strings.TrimSpace(remote.CellPhone)
user.Role = domain.NormalizeRole(user.Role)
user.Status = domain.UserStatusActive
user.TenantID = &tenantID
user.Grade = strings.TrimSpace(remote.LevelName)
user.JobTitle = strings.TrimSpace(remote.Task)
user.Metadata = mergeWorksmobileImportedUserMetadata(user.Metadata, remote, tenantID)
user.UpdatedAt = now
}
func worksmobileImportedUserMetadata(remote WorksmobileRemoteUser, tenant domain.Tenant) domain.JSONMap {
return mergeWorksmobileImportedUserMetadata(domain.JSONMap{}, remote, tenant.ID)
}
func mergeWorksmobileImportedUserMetadata(metadata domain.JSONMap, remote WorksmobileRemoteUser, tenantID string) domain.JSONMap {
if metadata == nil {
metadata = domain.JSONMap{}
}
if value := strings.TrimSpace(remote.EmployeeNumber); value != "" {
metadata["employeeNumber"] = value
metadata["employee_id"] = value
}
if value := strings.TrimSpace(remote.LevelName); value != "" {
metadata["grade"] = value
}
if value := strings.TrimSpace(remote.PrimaryOrgUnitName); value != "" {
metadata["department"] = value
}
metadata["worksmobileImportedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
metadata["worksmobileId"] = strings.TrimSpace(remote.ID)
metadata["worksmobileDomainId"] = remote.DomainID
metadata["worksmobilePrimaryOrgUnitId"] = strings.TrimSpace(remote.PrimaryOrgUnitID)
metadata["additionalAppointments"] = []domain.JSONMap{{
"tenantId": tenantID,
"isPrimary": true,
"grade": strings.TrimSpace(remote.LevelName),
}}
return metadata
}
func worksmobileTenantIDForRemoteUser(remote WorksmobileRemoteUser, groupByID map[string]WorksmobileRemoteGroup) string {
primaryOrgUnitID := strings.TrimSpace(remote.PrimaryOrgUnitID)
if tenantID, ok := strings.CutPrefix(primaryOrgUnitID, "externalKey:"); ok {
return strings.TrimSpace(tenantID)
}
if group, ok := groupByID[primaryOrgUnitID]; ok {
return strings.TrimSpace(group.ExternalID)
}
return ""
}
func stringPtrValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string, tenantByID map[string]domain.Tenant) ([]domain.User, error) {
if s.identityMirror != nil {
status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
@@ -898,9 +586,8 @@ func (s *worksmobileSyncService) EnqueueBackfillDryRun(ctx context.Context, tena
Action: domain.WorksmobileActionDryRun,
DedupeKey: "backfill:dry-run:" + root.ID,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"tenantIds": orgUnitTenantIDs,
"userCount": len(users),
"tenantIds": orgUnitTenantIDs,
"userCount": len(users),
},
})
return WorksmobileBackfillDryRun{OrgUnitCount: len(orgUnitTenantIDs), UserCount: len(users)}, nil
@@ -917,17 +604,10 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
}
tenantRoot, ok, err := s.rootForTenant(ctx, *tenant)
if err != nil {
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
if !ok || tenantRoot.ID != root.ID {
err := errors.New("target orgunit is outside hanmac-family subtree")
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
return nil, errors.New("target orgunit is outside hanmac-family subtree")
}
scopeTenants, err := s.hanmacSubtree(ctx, root.ID)
if err != nil {
@@ -935,18 +615,10 @@ func (s *worksmobileSyncService) EnqueueOrgUnitSync(ctx context.Context, tenantI
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
if _, ok := tenantByID[tenant.ID]; !ok {
err := errors.New("target tenant is excluded from Worksmobile sync")
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
return nil, errors.New("target tenant is excluded from Worksmobile sync")
}
if !isWorksmobileOrgUnitTenant(*tenant, tenantByID) {
err := errors.New("target tenant is not a worksmobile orgunit tenant")
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, *tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
return nil, errors.New("target tenant is not a worksmobile orgunit tenant")
}
return s.enqueueOrgUnitUpsert(ctx, root, *tenant, scopeTenants)
}
@@ -960,9 +632,6 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
0,
)
if err != nil {
if recordErr := s.recordRejectedOrgUnitSync(ctx, root.ID, tenant, err); recordErr != nil {
return nil, errors.Join(err, recordErr)
}
return nil, err
}
payload = normalizeWorksmobileOrgUnitParent(payload, tenant, tenantByID, root.ID)
@@ -972,7 +641,6 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:upsert:" + tenant.ID,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"request": payload,
"matchLocalPart": tenant.Slug,
},
@@ -983,36 +651,6 @@ func (s *worksmobileSyncService) enqueueOrgUnitUpsert(ctx context.Context, root
return item, nil
}
func (s *worksmobileSyncService) recordRejectedOrgUnitSync(ctx context.Context, rootID string, tenant domain.Tenant, reason error) error {
if s.outboxRepo == nil {
return nil
}
payload := domain.JSONMap{
"tenantRootId": rootID,
"displayName": strings.TrimSpace(tenant.Name),
"matchLocalPart": strings.TrimSpace(tenant.Slug),
"tenantSlug": strings.TrimSpace(tenant.Slug),
"requestSummary": domain.JSONMap{
"orgUnitName": strings.TrimSpace(tenant.Name),
"orgUnitExternalKey": tenant.ID,
"tenantSlug": strings.TrimSpace(tenant.Slug),
},
}
if tenant.ParentID != nil {
payload["parentTenantId"] = strings.TrimSpace(*tenant.ParentID)
}
item := &domain.WorksmobileOutbox{
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: tenant.ID,
Action: domain.WorksmobileActionUpsert,
DedupeKey: "orgunit:rejected:" + tenant.ID + ":" + uuid.NewString(),
Payload: payload,
Status: domain.WorksmobileOutboxStatusFailed,
LastError: reason.Error(),
}
return s.outboxRepo.Create(ctx, item)
}
func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) {
root, err := s.hanmacRoot(ctx, tenantID)
if err != nil {
@@ -1054,7 +692,6 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
Action: domain.WorksmobileActionDelete,
DedupeKey: "orgunit:delete:works:" + worksmobileOrgUnitID,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"worksmobileId": worksmobileOrgUnitID,
"externalKey": target.ExternalID,
"domainId": target.DomainID,
@@ -1119,7 +756,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
return nil, err
}
action := WorksmobileUserStatusAction(user.Status)
if action == domain.WorksmobileActionUpsert && strings.TrimSpace(initialPassword) != "" {
if action == domain.WorksmobileActionUpsert {
payload.PasswordConfig = worksmobileAdminInitialPasswordConfig(initialPassword)
}
item := &domain.WorksmobileOutbox{
@@ -1131,7 +768,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
}
item.Payload["displayName"] = strings.TrimSpace(user.Name)
item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID)
if batchID := strings.TrimSpace(credentialBatchID); batchID != "" && strings.TrimSpace(payload.PasswordConfig.Password) != "" {
if batchID := strings.TrimSpace(credentialBatchID); batchID != "" {
item.Payload["credentialBatchId"] = batchID
item.Payload["credentialOperation"] = "worksmobile_user_sync"
item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
@@ -1146,7 +783,7 @@ func (s *worksmobileSyncService) recordRejectedUserSync(ctx context.Context, roo
payload := WorksmobileUserPayload{
Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID,
UserName: worksmobileUserNameFromDisplayName(user.Name),
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: metadataEmployeeNumber(user.Metadata),
Locale: "ko_KR",

View File

@@ -164,7 +164,7 @@ func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) {
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
}
func TestWorksmobileSyncServiceSkipsAdminInitialPasswordWhenEmpty(t *testing.T) {
func TestWorksmobileSyncServiceAutoGeneratesAdminInitialPassword(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
tenantID := "saman-tenant"
@@ -201,13 +201,13 @@ func TestWorksmobileSyncServiceSkipsAdminInitialPasswordWhenEmpty(t *testing.T)
require.NoError(t, err)
require.NotNil(t, item)
initialPassword := stringValue(outboxRepo.created[0].Payload["initialPassword"])
require.Empty(t, initialPassword)
require.NotEmpty(t, initialPassword)
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.Empty(t, request.PasswordConfig.PasswordCreationType)
require.Empty(t, request.PasswordConfig.Password)
require.Nil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
require.Empty(t, stringValue(outboxRepo.created[0].Payload["credentialBatchId"]))
require.Equal(t, "ADMIN", request.PasswordConfig.PasswordCreationType)
require.Equal(t, initialPassword, request.PasswordConfig.Password)
require.NotNil(t, request.PasswordConfig.ChangePasswordAtNextLogin)
require.True(t, *request.PasswordConfig.ChangePasswordAtNextLogin)
}
func TestWorksmobileSyncServiceCreatesDistinctUserSyncHistoryJobs(t *testing.T) {
@@ -661,7 +661,6 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"loginEmail": "changed@example.com",
"displayName": "변경 사용자",
"primaryLeafOrgName": "인재성장",
@@ -681,7 +680,6 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusProcessed,
Payload: domain.JSONMap{
"tenantRootId": root.ID,
"matchLocalPart": "people-growth",
"request": WorksmobileOrgUnitPayload{
OrgUnitName: "인재성장",
@@ -727,67 +725,6 @@ func TestWorksmobileSyncServiceOverviewKeepsSafeRecentJobChangeLogPayload(t *tes
}, orgPayload["requestSummary"])
}
func TestWorksmobileSyncServiceOverviewScopesRecentJobsToTenantRoot(t *testing.T) {
rootID := "root-tenant"
childID := "child-org"
otherRootID := "other-root"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "한맥가족",
}
child := domain.Tenant{
ID: childID,
Slug: "structure-planning",
Name: "구조물계획",
Type: domain.TenantTypeUserGroup,
ParentID: &rootID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{
recent: []domain.WorksmobileOutbox{
{
ID: "job-root-user-failed",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-1",
Status: domain.WorksmobileOutboxStatusFailed,
Payload: domain.JSONMap{"tenantRootId": rootID},
LastError: "worksmobile api failed",
},
{
ID: "job-child-org-legacy",
ResourceType: domain.WorksmobileResourceOrgUnit,
ResourceID: childID,
Status: domain.WorksmobileOutboxStatusFailed,
LastError: "legacy org job without tenantRootId",
},
{
ID: "job-other-root",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-2",
Status: domain.WorksmobileOutboxStatusFailed,
Payload: domain.JSONMap{"tenantRootId": otherRootID},
},
},
}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{
tenants: map[string]domain.Tenant{rootID: root, childID: child},
list: []domain.Tenant{child},
},
&fakeWorksmobileUserRepo{},
outboxRepo,
nil,
)
overview, err := service.GetTenantOverview(context.Background(), rootID)
require.NoError(t, err)
require.Len(t, overview.RecentJobs, 2)
require.Equal(t, "job-root-user-failed", overview.RecentJobs[0].ID)
require.Equal(t, "job-child-org-legacy", overview.RecentJobs[1].ID)
require.Equal(t, "legacy org job without tenantRootId", overview.RecentJobs[1].LastError)
}
func TestWorksmobileSyncServiceEnqueueTenantUpsertReflectsChangedParentOrgUnit(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
@@ -1104,12 +1041,7 @@ func TestWorksmobileSyncServiceRejectsDomainCompanyOrgUnitSync(t *testing.T) {
require.Nil(t, item)
require.Error(t, err)
require.Contains(t, err.Error(), "worksmobile orgunit tenant")
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType)
require.Equal(t, domain.WorksmobileOutboxStatusFailed, outboxRepo.created[0].Status)
require.Equal(t, companyID, outboxRepo.created[0].ResourceID)
require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"])
require.Equal(t, "target tenant is not a worksmobile orgunit tenant", outboxRepo.created[0].LastError)
require.Empty(t, outboxRepo.created)
}
func TestWorksmobileSyncServiceEnqueuesBarongroupChildCompanyOrgUnitSync(t *testing.T) {
@@ -2114,13 +2046,7 @@ func TestWorksmobileSyncServiceRejectsExcludedOrgUnitSync(t *testing.T) {
require.Nil(t, item)
require.ErrorContains(t, err, "excluded from Worksmobile sync")
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileResourceOrgUnit, outboxRepo.created[0].ResourceType)
require.Equal(t, domain.WorksmobileOutboxStatusFailed, outboxRepo.created[0].Status)
require.Equal(t, excludedOrgID, outboxRepo.created[0].ResourceID)
require.Equal(t, rootID, outboxRepo.created[0].Payload["tenantRootId"])
require.Equal(t, "excluded-team", outboxRepo.created[0].Payload["matchLocalPart"])
require.Equal(t, "target tenant is excluded from Worksmobile sync", outboxRepo.created[0].LastError)
require.Empty(t, outboxRepo.created)
}
func TestWorksmobileSyncServiceSkipsExcludedTenantAndUserEventSync(t *testing.T) {

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;

View File

@@ -3,7 +3,6 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
target: dev
container_name: baron_backend
env_file:
- .env
@@ -43,14 +42,14 @@ services:
- ./backend:/app
- ./config:/app/config:ro
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
command: ["/usr/local/bin/baron-backend-dev"]
command: ["go", "run", "./cmd/server"]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
retries: 3
start_period: 10s
adminfront:
build:

View File

@@ -6,9 +6,7 @@ services:
image: postgres:17-alpine
container_name: ${COMPOSE_PROJECT_NAME}_db
environment:
- POSTGRES_USER=${DB_USER:-baron}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME:-baron_sso}
ports:
- "${DB_PORT}:5432"
volumes:
@@ -284,10 +282,8 @@ services:
- KETO_READ_URL=${KETO_READ_URL:-http://keto:4466}
- KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467}
- DB_HOST=postgres
- DB_PORT=5432
- REDIS_ADDR=redis:6379
- CLICKHOUSE_HOST=clickhouse
- CLICKHOUSE_PORT_NATIVE=9000
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
ports:
- "${BACKEND_PORT}:${BACKEND_PORT}"
@@ -316,8 +312,6 @@ services:
networks:
- app_net
- traefik_public
depends_on:
backend: { condition: service_started }
adminfront:
image: ${ADMINFRONT_IMAGE_NAME}:${IMAGE_TAG}

View File

@@ -6,9 +6,7 @@ services:
image: postgres:17-alpine
container_name: ${COMPOSE_PROJECT_NAME}_db
environment:
- POSTGRES_USER=${DB_USER:-baron}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME:-baron_sso}
ports:
- "${DB_PORT}:5432"
volumes:
@@ -284,10 +282,8 @@ services:
- KETO_READ_URL=${KETO_READ_URL:-http://keto:4466}
- KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467}
- DB_HOST=postgres
- DB_PORT=5432
- REDIS_ADDR=redis:6379
- CLICKHOUSE_HOST=clickhouse
- CLICKHOUSE_PORT_NATIVE=9000
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
ports:
- "${BACKEND_PORT}:${BACKEND_PORT}"
@@ -319,8 +315,6 @@ services:
networks:
- app_net
- traefik_public
depends_on:
backend: { condition: service_started }
adminfront:
build:

View File

@@ -3,9 +3,6 @@ events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
types {
application/javascript mjs;
}
# 인스턴스별로 계산된 포트 주입
upstream backend_srv {

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

@@ -3,7 +3,6 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
target: dev
container_name: baron_backend
env_file:
- .env
@@ -43,14 +42,14 @@ services:
- ./backend:/app
- ./config:/app/config:ro
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
command: ["/usr/local/bin/baron-backend-dev"]
command: ["go", "run", "./cmd/server"]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
retries: 3
start_period: 10s
adminfront:
build:
@@ -71,6 +70,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
- VITE_OIDC_CLIENT_ID=orgfront
- DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${ADMINFRONT_PORT:-5173}:5173"
@@ -137,6 +137,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
- VITE_OIDC_CLIENT_ID=orgfront
- DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${ORGFRONT_PORT:-5175}:5175"

View File

@@ -26,7 +26,7 @@ services:
- baron_net
- ory-net
healthcheck:
test: ["CMD", "/app/healthcheck"]
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s
timeout: 5s
retries: 10

View File

@@ -26,11 +26,11 @@ services:
depends_on:
- infra_check
healthcheck:
test: ["CMD", "/app/healthcheck"]
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
retries: 3
start_period: 10s
networks:
- baron_net

View File

@@ -364,7 +364,6 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
target: dev
container_name: baron_backend
restart: unless-stopped
env_file:
@@ -413,13 +412,13 @@ services:
volumes:
- ./backend:/app
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
command: ["/usr/local/bin/baron-backend-dev"]
command: ["go", "run", "./cmd/server"]
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
retries: 3
start_period: 10s
adminfront:
build:

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

@@ -0,0 +1,65 @@
# WSL Local Run Notes - 2026-06-18
## Environment
- Host path: `/home/ubuntu/workspace/baron-sso`
- Runtime: WSL Ubuntu
- Docker Compose: available via `docker compose`
- Working branch: `dev`
## Local URLs
- UserFront: `http://localhost:5000`
- AdminFront: `http://localhost:5173`
- DevFront: `http://localhost:5174`
- OrgFront: `http://localhost:5175`
## Run Sequence
```bash
make build-auth-config
make validate-auth-config
make up-dev
make dev
```
## Verified State
- `baron_backend`: healthy
- `baron_userfront`: healthy
- `baron_gateway`: healthy
- `baron_postgres`: healthy
- `baron_redis`: running
- `ory_kratos`, `ory_hydra`, `ory_keto`, `ory_oathkeeper`: running
Backend health check:
```json
{"checks":{"clickhouse":"ok","oathkeeper":"ok","redis":"ok"},"status":"ok"}
```
## Local Fixes Applied
- `.env`
- Switched `APP_ENV` to `dev`.
- Removed inline comments from key env values that were leaking trailing spaces.
- Set public local URLs to `localhost`.
- Removed staging callback URLs from local callback lists.
- `backend/internal/service/redis_service.go`
- Added nil guards to Redis helper methods to prevent backend panic when Redis is not initialized.
- `docker-compose.yaml`
- Set OrgFront runtime `VITE_OIDC_CLIENT_ID=orgfront` so it does not inherit `devfront` from `.env`.
## Browser Verification
- UserFront opens and reaches the login screen.
- AdminFront opens.
- DevFront opens.
- OrgFront opens and reaches the login screen after fixing the OIDC client id.
## Next Tasks
- Connect Gitea MCP.
- Connect Playwright MCP.
- Use local browser checks and repository inspection for architecture and feature analysis.
- Prepare roadmap proposal after architecture and feature review.

View File

@@ -0,0 +1,377 @@
# WSL Local Setup Issue Notes - 2026-06-18
## Issue 1. 작업 위치를 Windows 로컬 소스에서 WSL Ubuntu 소스로 전환
### 배경
팀장 요구사항은 Gitea의 `baron/baron-sso` 저장소를 Ubuntu/WSL 내부에 클론하고, 그 소스코드를 기준으로 로컬 구동, 아키텍처 분석, 기능 분석, 로드맵 제안을 진행하는 것이다.
### 확인 내용
- 작업 경로: `/home/ubuntu/workspace/baron-sso`
- 브랜치: `dev`
- VS Code 상태: `WSL: Ubuntu`
- Docker Compose 사용 가능
### 결과
WSL 내부 Baron SSO 소스코드 기준으로 작업 위치를 확정했다.
---
## Issue 2. `.env` 파일 부재 및 로컬 env 복원
### 증상
WSL 저장소 루트에 실제 `.env` 파일이 없고 `.env.sample`만 존재했다.
### 확인 내용
- 기존 Windows 로컬 env 발견:
- `/mnt/e/h_workspace/baron-sso/.env`
- WSL 저장소의 `.env.sample`과 키 목록을 비교했다.
- 기존 env에는 현재 샘플 대비 일부 키가 부족했다.
### 조치
- 기존 Windows 로컬 `.env`를 WSL 저장소 루트로 복사했다.
- 부족한 로컬 기본 키를 보강했다.
- 로컬 실행 기준으로 URL 값을 정리했다.
### 주요 로컬 값
```env
APP_ENV=dev
USERFRONT_URL=http://localhost:5000
ADMINFRONT_URL=http://localhost:5173
DEVFRONT_URL=http://localhost:5174
ORGFRONT_URL=http://localhost:5175
OATHKEEPER_PUBLIC_URL=${USERFRONT_URL}
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
VITE_OIDC_AUTHORITY=http://localhost:5000/oidc
```
### 결과
로컬 실행용 `.env`가 준비되었다. `.env`는 git ignored 상태라 저장소 변경 목록에는 포함되지 않는다.
---
## Issue 3. `.env` 인라인 주석으로 인한 환경변수 값 오염
### 증상
backend health check에서 Redis가 초기화되지 않았다.
```json
{"checks":{"clickhouse":"ok","oathkeeper":"ok","redis":"not_initialized"},"status":"degraded"}
```
컨테이너 내부 환경변수 확인 결과 `REDIS_ADDR` 끝에 공백이 포함되어 있었다.
```text
redis:6389
```
### 원인
`.env`에서 아래처럼 값 뒤에 인라인 주석이 붙어 있어 Docker/앱 환경변수로 전달될 때 값에 공백이 섞였다.
```env
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트
APP_ENV=dev # 애플리케이션 실행 환경
```
### 조치
주석을 별도 줄로 분리했다.
```env
# 애플리케이션 실행 환경 (dev, stage, production)
APP_ENV=dev
# compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
REDIS_ADDR=redis:6389
```
### 결과
backend health check가 정상화되었다.
```json
{"checks":{"clickhouse":"ok","oathkeeper":"ok","redis":"ok"},"status":"ok"}
```
---
## Issue 4. Backend Redis nil panic
### 증상
`make dev` 실행 중 backend가 panic으로 종료되었다.
```text
panic: runtime error: invalid memory address or nil pointer dereference
baron-sso-backend/internal/service.(*RedisService).Set(...)
```
### 원인
Redis 초기화 실패 시 `redisService`가 nil 상태가 될 수 있는데, 일부 워밍업 경로에서 nil RedisService의 `Set`, `Get`, `Delete` 계열 메서드가 호출되어 panic이 발생했다.
### 조치
`backend/internal/service/redis_service.go`에 nil guard를 추가했다.
대상 메서드:
- `StoreVerificationCode`
- `GetVerificationCode`
- `DeleteVerificationCode`
- `Set`
- `Get`
- `Delete`
### 결과
Redis 초기화 실패 상황에서도 backend 전체가 panic으로 죽지 않도록 방어했다.
### 참고
현재 WSL 환경에는 `gofmt`가 설치되어 있지 않아 `gofmt`는 실행하지 못했다.
---
## Issue 5. Auth/Ory 설정을 로컬 callback 기준으로 재생성
### 증상
기존 env에 staging callback URL이 섞여 있었다.
예:
```env
https://sso.hmac.kr/...
```
로컬 구동 목적상 OAuth/OIDC callback은 WSL 로컬 프론트 주소를 바라봐야 한다.
### 조치
로컬 callback만 남겼다.
```env
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback
```
이후 auth config를 재생성 및 검증했다.
```bash
make build-auth-config
make validate-auth-config
make up-dev
```
### 결과
로컬 callback 기준으로 Ory/Kratos/Hydra 설정이 렌더링되었다.
---
## Issue 6. OrgFront OIDC client id 오설정
### 증상
`http://localhost:5175` 접속 시 OrgFront에서 다음 에러가 발생했다.
```text
오류: invalid_request
redirect_uri parameter does not match any of the OAuth 2.0 Client's pre-registered redirect urls.
```
### 확인 내용
OrgFront 컨테이너 내부 환경변수 확인:
```bash
docker exec baron_orgfront printenv VITE_OIDC_CLIENT_ID
```
초기 값:
```text
devfront
```
### 원인
OrgFront가 `orgfront` OIDC client가 아니라 `devfront` client id를 사용하고 있었다. 따라서 `http://localhost:5175/auth/callback``devfront` client의 redirect URI와 맞지 않아 Hydra가 요청을 거부했다.
### 조치
`docker-compose.yaml`의 OrgFront runtime environment에 아래 값을 명시했다.
```yaml
- VITE_OIDC_CLIENT_ID=orgfront
```
### 결과
OrgFront 컨테이너 내부 값이 정상화되었다.
```text
orgfront
```
브라우저에서 `http://localhost:5175` 접속 시 로그인 화면이 정상 표시되었다.
---
## Issue 7. 로컬 서비스 구동 확인
### 실행 명령
```bash
make up-dev
make dev
```
### 최종 컨테이너 상태
- `baron_backend`: healthy
- `baron_userfront`: healthy
- `baron_gateway`: healthy
- `baron_postgres`: healthy
- `baron_redis`: running
- `baron_adminfront`: running
- `baron_devfront`: running
- `baron_orgfront`: running
- `ory_kratos`: running
- `ory_hydra`: running
- `ory_keto`: running
- `ory_oathkeeper`: running
### 브라우저 확인
- `http://localhost:5000`: UserFront 로그인 화면 정상
- `http://localhost:5173`: AdminFront 정상
- `http://localhost:5174`: DevFront 정상
- `http://localhost:5175`: OrgFront 로그인 화면 정상
### 참고
`5173`, `5174`는 dev mode에서 로그인 없이 대시보드가 보이는 동작이 확인되었다. 이후 기능 분석 단계에서 개발 모드 인증 우회 정책을 별도 확인할 필요가 있다.
---
## Issue 8. Gitea MCP 등록
### 배경
팀장 과업상 Gitea 저장소/이슈/코드 확인을 MCP로 연결할 필요가 있다.
### 참고 받은 설정
Codex는 `config.toml`에 MCP를 등록한다.
기본 위치:
```text
~/.codex/config.toml
```
### 실행 파일 위치
Windows에 받은 `gitea-mcp.exe`를 WSL에서 실행 가능함을 확인했다.
```text
/mnt/e/900.tools/gitea-mcp_Windows_x86_64/gitea-mcp.exe
```
### 확인 명령
```bash
/mnt/e/900.tools/gitea-mcp_Windows_x86_64/gitea-mcp.exe --help
```
### 설정 추가
`~/.codex/config.toml`에 아래 구조를 추가했다.
```toml
[mcp_servers.gitea]
command = "/mnt/e/900.tools/gitea-mcp_Windows_x86_64/gitea-mcp.exe"
args = ["-t", "stdio", "--host", "https://gitea.hmac.kr"]
[mcp_servers.gitea.env]
GITEA_ACCESS_TOKEN = "***"
```
### 결과
Gitea MCP 설정 등록 완료. 실제 활성화 확인은 새 Codex 세션에서 `/mcp`로 확인해야 한다.
---
## Issue 9. Playwright MCP 등록
### 배경
브라우저 자동 확인 및 로컬 화면 점검을 위해 Playwright MCP 연결이 필요하다.
### 확인 내용
`npx` 사용 가능:
```bash
npx --version
```
확인된 버전:
```text
11.13.0
```
### 설정 추가
`~/.codex/config.toml`에 아래 구조를 추가했다.
```toml
[mcp_servers.playwright]
command = "npx"
args = ["-y", "@playwright/mcp"]
startup_timeout_sec = 30
tool_timeout_sec = 120
```
### 결과
Playwright MCP 설정 등록 완료. 실제 활성화 확인은 새 Codex 세션에서 `/mcp`로 확인해야 한다.
---
## Current Git Changes
현재 git 추적 변경:
```text
M backend/internal/service/redis_service.go
M docker-compose.yaml
?? docs/local-run-notes/
```
`.env``~/.codex/config.toml`은 로컬 설정 성격이므로 저장소 변경 목록에는 일반적으로 포함되지 않는다.
---
## Next Actions
1. 새 Codex 세션 또는 VS Code 재시작 후 `/mcp`에서 `gitea`, `playwright` 활성화 여부 확인.
2. Gitea MCP로 저장소 이슈/PR/코드 흐름 확인.
3. Playwright MCP로 로컬 화면 자동 점검.
4. Baron SSO 아키텍처 분석 문서 작성.
5. 기능 분석 문서 작성.
6. 추가 로드맵 제안 문서 작성.

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

@@ -2,90 +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`
- 선택 variable `WORKS_DRIVE_API_BASE_URL`
- 선택 variable `WORKS_DRIVE_OAUTH_TOKEN_URL`
- 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을 별도 설계로 추가한다.
## 변수 분리 원칙
WORKS Drive archive 접근용 변수와 서비스 런타임용 WORKS Admin 변수는 분리한다.
- archive 업로드/다운로드: `WORKS_DRIVE_API_BASE_URL`, `WORKS_DRIVE_OAUTH_TOKEN_URL`
- backend 런타임 설정: `STG_WORKS_ADMIN_API_BASE_URL`, `STG_WORKS_ADMIN_OAUTH_TOKEN_URL`, `PROD_WORKS_ADMIN_API_BASE_URL`, `PROD_WORKS_ADMIN_OAUTH_TOKEN_URL`
archive script는 호환성을 위해 기존 `WORKS_ADMIN_API_BASE_URL`, `WORKS_ADMIN_OAUTH_TOKEN_URL`도 fallback으로 읽지만, Gitea image publish/deploy workflow에서는 `WORKS_DRIVE_*` 변수를 사용한다.
## 저장 구조
기본 최상위 디렉터리는 다음 환경 변수로 지정한다.
```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`
@@ -94,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
@@ -110,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
@@ -123,13 +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_API_BASE_URL`
- 선택: `WORKS_DRIVE_OAUTH_TOKEN_URL`
- `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만 확인하려면 다음을 사용한다.
@@ -140,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
@@ -215,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

@@ -36,7 +36,6 @@ function renderGuard(initialEntry: string) {
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route element={<AuthGuard />}>
<Route path="/chart" element={<div>chart</div>} />
<Route path="/embed/picker" element={<div>picker</div>} />
</Route>
<Route path="/login" element={<LocationProbe />} />
@@ -75,11 +74,4 @@ describe("OrgFront AuthGuard auto login redirects", () => {
);
cleanupRendered(rendered.container, rendered.root);
});
it("redirects regular app entry to the visible login page", () => {
const rendered = renderGuard("/chart");
expect(rendered.container.textContent).toBe("/login?returnTo=%2Fchart");
cleanupRendered(rendered.container, rendered.root);
});
});

View File

@@ -29,12 +29,9 @@ export default function AuthGuard() {
if (!auth.isAuthenticated) {
const returnTo = `${location.pathname}${location.search}`;
const autoLoginParam = location.pathname.startsWith("/embed/")
? "auto=1&"
: "";
return (
<Navigate
to={`/login?${autoLoginParam}returnTo=${encodeURIComponent(returnTo)}`}
to={`/login?auto=1&returnTo=${encodeURIComponent(returnTo)}`}
replace
/>
);

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,50 +50,27 @@ 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}
DB_USER=${DB_USER:-baron}
DB_NAME=${DB_NAME:-baron_sso}
REDIS_PORT=${IMAGE_DEPLOY_REDIS_PORT}
CLICKHOUSE_PORT_HTTP=${IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP}
CLICKHOUSE_PORT_NATIVE=${IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE}
@@ -107,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}
@@ -130,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}
@@ -162,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}
@@ -185,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,103 +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"
log() {
printf '==> %s\n' "$*" >&2
}
refresh_works_drive_access_token() {
[[ -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."
[[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]] || die "WORKS_DRIVE_OAUTH_REFRESH_TOKEN is required for refresh-token mode."
local token_url="${WORKS_DRIVE_OAUTH_TOKEN_URL:-${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}}"
local response
local access_token
local rotated_refresh_token
log "Refreshing WORKS Drive access 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"
}
resolve_works_drive_access_token() {
if [[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]]; then
refresh_works_drive_access_token
return
fi
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
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_DRIVE_API_BASE_URL='${WORKS_DRIVE_API_BASE_URL:-${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}}'; \
echo '==> Validating image deploy compose config'; \
docker compose --env-file .env -f docker-compose.yml config >/dev/null; \
echo '==> Downloading and loading WORKS Drive application images'; \
scripts/docker-image/download_works_drive.sh; \
set -a; \
. ./.env; \
set +a; \
echo '==> Verifying loaded application images'; \
for image_ref in \"\${BACKEND_IMAGE_NAME}:\${IMAGE_TAG}\" \"\${USERFRONT_IMAGE_NAME}:\${IMAGE_TAG}\" \"\${ADMINFRONT_IMAGE_NAME}:\${IMAGE_TAG}\" \"\${DEVFRONT_IMAGE_NAME}:\${IMAGE_TAG}\" \"\${ORGFRONT_IMAGE_NAME}:\${IMAGE_TAG}\"; do \
docker image inspect \"\${image_ref}\" >/dev/null; \
done; \
echo '==> Prefetching runtime images before compose up'; \
docker compose --env-file .env -f docker-compose.yml pull --ignore-pull-failures; \
echo '==> Starting production image stack'; \
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; \
echo '==> Refreshing frontend edge containers'; \
docker compose --env-file .env -f docker-compose.yml up -d --force-recreate --no-deps gateway adminfront devfront orgfront; \
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_DRIVE_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

@@ -23,8 +23,6 @@ if [[ -f "$repo_root/.env" ]]; then
WORKS_DRIVE_ACCESS_TOKEN_FILE
WORKS_DRIVE_ACCESS_TOKEN_CMD
WORKS_DRIVE_OAUTH_SCOPE
WORKS_DRIVE_API_BASE_URL
WORKS_DRIVE_OAUTH_TOKEN_URL
WORKS_DRIVE_OVERWRITE
WORKS_DRIVE_DRY_RUN
WORKS_DRIVE_CURL_BIN
@@ -70,45 +68,16 @@ 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_DRIVE_API_BASE_URL:-${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}}"
api_base_url="${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}"
curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}"
overwrite="${WORKS_DRIVE_OVERWRITE:-true}"
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'
}
@@ -194,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() {
@@ -268,7 +233,7 @@ build_jwt_assertion() {
request_service_account_token() {
local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}"
local client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}"
local token_url="${WORKS_DRIVE_OAUTH_TOKEN_URL:-${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}}"
local token_url="${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}"
local assertion
local response
local response_body
@@ -298,7 +263,7 @@ request_refresh_access_token() {
local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}"
local client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}"
local refresh_token="${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}"
local token_url="${WORKS_DRIVE_OAUTH_TOKEN_URL:-${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}}"
local token_url="${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}"
local response
local response_body
local http_status
@@ -367,27 +332,6 @@ list_child_folders() {
printf '%s\n' "$response_body"
}
find_folder_id_in_listing() {
local listing_json="$1"
local folder_name="$2"
local strict_type="${3:-true}"
jq -er --arg name "$folder_name" --arg strictType "$strict_type" '
[
(.files // .children // .items // .data // .contents // [])[]
| select((.fileName // .name // .displayName // .title) == $name)
| select(
$strictType != "true"
or (
((.fileType // .type // .resourceType // "") | ascii_downcase) as $type
| ($type == "" or $type == "folder" or $type == "dir" or $type == "directory")
)
)
| .fileId // .id
][0] // empty
' <<<"$listing_json" 2>/dev/null || true
}
create_child_folder() {
local access_token="$1"
local endpoint="$2"
@@ -405,11 +349,6 @@ create_child_folder() {
"$endpoint")"
split_curl_response "$response" response_body http_status
if [[ "$http_status" -eq 409 ]]; then
printf 'WORKS_CONFLICT\n'
return 2
fi
if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then
backup_die "WORKS folder create request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)"
fi
@@ -424,48 +363,26 @@ ensure_child_folder() {
local children_endpoint
local create_folder_endpoint
local children_json
local refreshed_children_json
local folder_id
local create_status
children_endpoint="$(resolve_target_children_endpoint "$parent_file_id")"
create_folder_endpoint="$(resolve_target_create_folder_endpoint "$parent_file_id")"
backup_log "Checking WORKS folder: parent=${parent_file_id:-root} name=$folder_name" >&2
if ! children_json="$(list_child_folders "$access_token" "$children_endpoint")"; then
return 1
fi
folder_id="$(find_folder_id_in_listing "$children_json" "$folder_name" "true")"
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
backup_log "Found existing WORKS folder: $folder_name -> $folder_id" >&2
printf '%s\n' "$folder_id"
return
fi
backup_log "Creating WORKS folder: parent=${parent_file_id:-root} name=$folder_name" >&2
if folder_id="$(create_child_folder "$access_token" "$create_folder_endpoint" "$folder_name")"; then
backup_log "Created WORKS folder: $folder_name -> $folder_id" >&2
printf '%s\n' "$folder_id"
return
else
create_status="$?"
fi
if [[ "$create_status" -eq 2 ]]; then
backup_log "WORKS folder already exists, resolving existing folder id: $folder_name" >&2
children_endpoint="$(resolve_target_children_endpoint "$parent_file_id")"
if ! refreshed_children_json="$(list_child_folders "$access_token" "$children_endpoint")"; then
return 1
fi
folder_id="$(find_folder_id_in_listing "$refreshed_children_json" "$folder_name" "false")"
if [[ -n "$folder_id" ]]; then
backup_log "Resolved existing WORKS folder after conflict: $folder_name -> $folder_id" >&2
printf '%s\n' "$folder_id"
return
fi
backup_die "WORKS folder already exists but its fileId could not be resolved: $folder_name"
fi
return 1
create_child_folder "$access_token" "$create_folder_endpoint" "$folder_name"
}
ensure_folder_path() {
@@ -473,24 +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
backup_log "Using cached WORKS folder: $accumulated_path -> $cached_folder_id" >&2
parent_file_id="$cached_folder_id"
continue
fi
backup_log "Resolving WORKS folder component: $accumulated_path" >&2
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"
@@ -600,21 +504,17 @@ 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" "$upload_report_file"
backup_log "Docker image archive context: image_ref=$image_ref remote_path=$remote_path artifact_dir=$artifact_dir"
rm -f "$tar_file" "$archive_file" "$checksum_file" "$manifest_file" "$upload_report_file"
if [[ -n "$commit_container" ]]; then
backup_log "Committing container $commit_container to $image_ref"
@@ -635,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

@@ -38,13 +38,8 @@ if ! grep -Eq "^go ${TARGET_GO_VERSION}$" "$GO_MOD"; then
exit 1
fi
if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine AS base$" "$BACKEND_DOCKERFILE"; then
echo "ERROR: backend Dockerfile base stage must use golang:${TARGET_GO_VERSION}-alpine." >&2
exit 1
fi
if ! grep -Eq "^FROM gcr\\.io/distroless/static-debian13:nonroot AS production$" "$BACKEND_DOCKERFILE"; then
echo "ERROR: backend Dockerfile production stage must use distroless/static-debian13:nonroot." >&2
if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine$" "$BACKEND_DOCKERFILE"; then
echo "ERROR: backend Dockerfile must use golang:${TARGET_GO_VERSION}-alpine." >&2
exit 1
fi

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,56 +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."
grep -Fq "Verify built Docker images before WORKS upload" "$publish_workflow" \
|| fail "publish workflow must verify all built Docker images before WORKS upload."
|| 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 'docker image inspect "${image_ref}"' "$publish_workflow" \
|| fail "publish workflow must inspect each built Docker image before upload."
grep -Fq 'WORKS image upload ${image_index}/${image_total}: ${image_ref}' "$publish_workflow" \
|| fail "publish workflow must log each WORKS image upload with index and image ref."
grep -Fq 'uploaded_images' "$publish_workflow" \
|| fail "publish workflow must track successfully uploaded image refs for failure diagnostics."
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_OAUTH_TOKEN_URL: \${{ vars.WORKS_DRIVE_OAUTH_TOKEN_URL }}" "$publish_workflow" \
|| fail "publish workflow must use the WORKS Drive OAuth token URL variable for archive access."
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_API_BASE_URL: \${{ vars.WORKS_DRIVE_API_BASE_URL }}" "$publish_workflow" \
|| fail "publish workflow must use the WORKS Drive API base URL variable for archive access."
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."
@@ -106,12 +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 "WORKS_DRIVE_API_BASE_URL: \${{ vars.WORKS_DRIVE_API_BASE_URL }}" "$staging_deploy_workflow" \
|| fail "staging deploy workflow must pass the WORKS Drive API base URL into the remote image deploy step."
grep -Fq "WORKS_DRIVE_OAUTH_TOKEN_URL: \${{ vars.WORKS_DRIVE_OAUTH_TOKEN_URL }}" "$staging_deploy_workflow" \
|| fail "staging deploy workflow must pass the WORKS Drive OAuth token URL into the remote image deploy step."
grep -Fq "name: Deploy Baron SSO Production Images" "$deploy_workflow" \
|| fail "deploy workflow must have the expected name."
@@ -131,37 +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 "WORKS_DRIVE_API_BASE_URL: \${{ vars.WORKS_DRIVE_API_BASE_URL }}" "$deploy_workflow" \
|| fail "production deploy workflow must pass the WORKS Drive API base URL into the remote image deploy step."
grep -Fq "WORKS_DRIVE_OAUTH_TOKEN_URL: \${{ vars.WORKS_DRIVE_OAUTH_TOKEN_URL }}" "$deploy_workflow" \
|| fail "production deploy workflow must pass the WORKS Drive OAuth token URL into the remote image deploy step."
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 "refresh_works_drive_access_token" "$remote_deploy_script" \
|| fail "shared remote deploy script must refresh WORKS Drive access tokens when a refresh token is available."
grep -Fq 'WORKS_DRIVE_OAUTH_TOKEN_URL:-${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}' "$remote_deploy_script" \
|| fail "shared remote deploy script must prefer WORKS_DRIVE_OAUTH_TOKEN_URL for refresh-token grants."
grep -Fq "docker compose --env-file .env -f docker-compose.yml config" "$remote_deploy_script" \
|| fail "shared remote deploy script must validate the remote compose config before running."
grep -Fq "docker compose --env-file .env -f docker-compose.yml pull --ignore-pull-failures" "$remote_deploy_script" \
|| fail "shared remote deploy script must prefetch runtime images before compose up."
grep -Fq 'docker image inspect \"\${image_ref}\"' "$remote_deploy_script" \
|| fail "shared remote deploy script must inspect loaded application images before compose up."
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|HARBOR_' "$staging_deploy_workflow" "$deploy_workflow" "$remote_deploy_script"; then
fail "image deploy workflows/scripts must not depend on Harbor registry login."
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,140 +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_artifact_dir="$root_archive_dir/baron-sso/v1.2606.ab12"
[[ -f "$root_artifact_dir/backend.v1.2606.ab12.tar.zst" ]] \
|| fail "script must keep the backend image archive after follow-up image uploads."
[[ -f "$root_artifact_dir/userfront.v1.2606.ab12.tar.zst" ]] \
|| fail "script must keep the userfront image archive after follow-up image uploads."
jq -e \
'.images.backend.archive.file_name == "backend.v1.2606.ab12.tar.zst"
and .images.userfront.archive.file_name == "userfront.v1.2606.ab12.tar.zst"' \
"$root_artifact_dir/manifest.v1.2606.ab12.json" >/dev/null \
|| fail "manifest must accumulate all uploaded images for the same tag."
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."
conflict_curl_log="$tmp_dir/conflict-curl.log"
conflict_fake_curl="$tmp_dir/conflict-fake-curl.sh"
cat >"$conflict_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/conflict-drive/files)
list_count_file="${FAKE_CURL_LOG}.root-list-count"
list_count=0
[[ -f "$list_count_file" ]] && list_count="$(cat "$list_count_file")"
list_count=$((list_count + 1))
printf '%s' "$list_count" >"$list_count_file"
if [[ "$list_count" -eq 1 ]]; then
printf '{"files":[]}\n200'
else
printf '{"files":[{"fileId":"conflict-baron-sso-id","fileName":"baron-sso","fileType":"FILE"}]}\n200'
fi
;;
https://www.worksapis.com/v1.0/sharedrives/conflict-drive/files/createfolder)
printf '{"code":"RESOURCE_ALREADY_EXIST","description":"Resource already exists."}\n409'
;;
https://www.worksapis.com/v1.0/sharedrives/conflict-drive/files/conflict-baron-sso-id/children)
printf '{"files":[]}\n200'
;;
https://www.worksapis.com/v1.0/sharedrives/conflict-drive/files/conflict-baron-sso-id/createfolder)
printf '{"fileId":"conflict-tag-id","fileName":"v1.2606.ab12","fileType":"FOLDER"}\n200'
;;
https://www.worksapis.com/v1.0/sharedrives/conflict-drive/files/conflict-tag-id)
printf '{"uploadUrl":"https://upload.example.test/conflict-docker-image"}\n200'
;;
https://upload.example.test/conflict-docker-image)
printf '{"fileId":"uploaded-conflict-file-id"}\n200'
;;
*)
echo "unexpected conflict curl URL: $last_arg" >&2
exit 2
;;
esac
EOF
chmod +x "$conflict_fake_curl"
FAKE_DOCKER_LOG="$docker_log" \
FAKE_CURL_LOG="$conflict_curl_log" \
PATH="$fake_bin:$PATH" \
WORKS_DRIVE_ACCESS_TOKEN="test-access-token" \
WORKS_DRIVE_TARGET="sharedrive" \
WORKS_DRIVE_SHARED_DRIVE_ID="conflict-drive" \
WORKS_DRIVE_PARENT_FILE_ID="" \
WORKS_DRIVE_CURL_BIN="$conflict_fake_curl" \
WORKS_DOCKER_IMAGE_ARCHIVE_DIR="$tmp_dir/conflict-archive" \
DOCKER_IMAGE_REF="baron_sso/backend:v1.2606.ab12" \
"$script" >"$tmp_dir/conflict.out" 2>&1
grep -Fq "WORKS folder already exists, resolving existing folder id: baron-sso" "$tmp_dir/conflict.out" \
|| fail "script must recover an existing folder id after WORKS createfolder returns 409."
grep -Fq "sharedrives/conflict-drive/files/conflict-tag-id" "$conflict_curl_log" \
|| fail "script must upload into the resolved folder after a create conflict."
echo "OK: WORKS Drive Docker image archive upload flow commits, packages, and uploads image artifacts"

Some files were not shown because too many files have changed in this diff Show More