forked from baron/baron-sso
Compare commits
22 Commits
feature/rb
...
feature/af
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b48fe22c7 | |||
| b8c1b116b1 | |||
| 57c05c9241 | |||
| 9478944197 | |||
| c9cf7d6c67 | |||
| 06d2b71e25 | |||
| 9803108de2 | |||
| 01cd7a0ad3 | |||
| 87a45f0e76 | |||
| a6f9d89477 | |||
| 729a9890a6 | |||
| b4883bc9eb | |||
| d54d258117 | |||
| f3e9ca52be | |||
| 1596342d03 | |||
| f6c7cb3b22 | |||
| 47d2f15283 | |||
| 29038254dd | |||
| 4bae1dd00d | |||
| ded9dfc56b | |||
| 3f4138e3a0 | |||
| ba3e9103f2 |
28
.env.sample
28
.env.sample
@@ -36,6 +36,34 @@ CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한
|
||||
WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token
|
||||
|
||||
# --- NAVER WORKS Drive backup upload ---
|
||||
# Drive API 업로드에는 `file` scope가 필요합니다.
|
||||
# 운영에서는 Drive 권한이 위임된 사용자/OAuth access token을 우선 사용하세요.
|
||||
# 서비스 계정 JWT 방식은 WORKS 앱 정책에서 Drive API scope 위임이 허용된 경우에만 사용할 수 있습니다.
|
||||
WORKS_DRIVE_TARGET=sharedrive
|
||||
WORKS_DRIVE_SHARED_DRIVE_ID=
|
||||
WORKS_DRIVE_PARENT_FILE_ID=
|
||||
WORKS_DRIVE_USER_ID=me
|
||||
WORKS_DRIVE_GROUP_ID=
|
||||
WORKS_DRIVE_SHARED_FOLDER_ID=
|
||||
WORKS_DRIVE_ACCESS_TOKEN=
|
||||
WORKS_DRIVE_ACCESS_TOKEN_FILE=
|
||||
WORKS_DRIVE_ACCESS_TOKEN_CMD=
|
||||
WORKS_DRIVE_OAUTH_SCOPE=file
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID=
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET=
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT=
|
||||
WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE=./config/worksmobile-driveapp-private-key.pem
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN=
|
||||
WORKS_DRIVE_OAUTH_REDIRECT_URI=
|
||||
WORKS_DRIVE_SPLIT_SIZE=9000M
|
||||
WORKS_DRIVE_MAX_SINGLE_FILE_BYTES=0
|
||||
WORKS_DRIVE_FORCE_SPLIT=false
|
||||
WORKS_DRIVE_OVERWRITE=false
|
||||
WORKS_DRIVE_DRY_RUN=false
|
||||
WORKS_DRIVE_UPLOAD_REPORTS=true
|
||||
WORKS_DRIVE_REPORT_FOLDER_NAME=reports
|
||||
|
||||
|
||||
# Audit System Configuration
|
||||
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
|
||||
|
||||
@@ -18,6 +18,30 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y jq curl
|
||||
|
||||
- name: Validate RC build configuration
|
||||
env:
|
||||
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
|
||||
|
||||
required_action_env="
|
||||
HARBOR_ENDPOINT HARBOR_HOSTNAME HARBOR_ROBOT_ACCOUNT HARBOR_ROBOT_KEY
|
||||
ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL VITE_OIDC_AUTHORITY
|
||||
"
|
||||
for key in ${required_action_env}; do
|
||||
if [ -z "${!key:-}" ]; then
|
||||
echo "::error::Missing required RC build value: ${key}. Check Gitea repo variables/secrets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -93,6 +117,11 @@ jobs:
|
||||
file: ./adminfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.rc_calculator.outputs.new_rc_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
|
||||
|
||||
@@ -103,6 +132,10 @@ jobs:
|
||||
file: ./devfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
|
||||
build-args: |
|
||||
VITE_DEVFRONT_PUBLIC_URL=${{ vars.DEVFRONT_URL }}
|
||||
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
|
||||
VITE_OIDC_CLIENT_ID=devfront
|
||||
provenance: false
|
||||
sbom: false
|
||||
|
||||
@@ -113,6 +146,10 @@ jobs:
|
||||
file: ./orgfront/Dockerfile
|
||||
push: true
|
||||
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_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
|
||||
|
||||
|
||||
@@ -42,19 +42,13 @@ jobs:
|
||||
sudo apt-get update -y && sudo apt-get install -y skopeo
|
||||
fi
|
||||
|
||||
# Re-tag backend image
|
||||
echo "Re-tagging backend image..."
|
||||
skopeo copy --preserve-digests \
|
||||
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
|
||||
--src-tls-verify=false --dest-tls-verify=false \
|
||||
"docker://${HARBOR_HOSTNAME}/baron_sso/backend:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/backend:${RE_TAG}"
|
||||
|
||||
# Re-tag userfront image
|
||||
echo "Re-tagging userfront image..."
|
||||
skopeo copy --preserve-digests \
|
||||
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
|
||||
--src-tls-verify=false --dest-tls-verify=false \
|
||||
"docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${RE_TAG}"
|
||||
for image in backend userfront adminfront devfront orgfront; do
|
||||
echo "Re-tagging ${image} image..."
|
||||
skopeo copy --preserve-digests \
|
||||
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
|
||||
--src-tls-verify=false --dest-tls-verify=false \
|
||||
"docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${RE_TAG}"
|
||||
done
|
||||
|
||||
echo "final_image_tag=${RE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -68,6 +62,9 @@ jobs:
|
||||
IMAGE_TAG: ${{ steps.retag.outputs.final_image_tag }}
|
||||
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
|
||||
DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
|
||||
PROD_HOST: ${{ vars.PROD_HOST }}
|
||||
PROD_USER: ${{ vars.PROD_USER }}
|
||||
@@ -101,8 +98,12 @@ jobs:
|
||||
"CLICKHOUSE_PORT_NATIVE=${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }}" \
|
||||
"CLICKHOUSE_USER=${{ vars.PROD_CLICKHOUSE_USER }}" \
|
||||
"CLICKHOUSE_PASSWORD=${{ secrets.PROD_CLICKHOUSE_PASSWORD }}" \
|
||||
"BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
|
||||
"USERFRONT_PORT=${{ vars.PROD_USERFRONT_PORT }}" \
|
||||
"PROD_BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
|
||||
"BACKEND_PORT=3000" \
|
||||
"USERFRONT_PORT=${{ vars.PROD_FRONTEND_PORT }}" \
|
||||
"ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}" \
|
||||
"DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}" \
|
||||
"ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}" \
|
||||
"DB_USER=${{ vars.PROD_DB_USER }}" \
|
||||
"DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}" \
|
||||
"DB_NAME=${{ vars.PROD_DB_NAME }}" \
|
||||
@@ -117,10 +118,33 @@ jobs:
|
||||
"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_USERFRONT_URL }}" \
|
||||
"USERFRONT_URL=${{ vars.PROD_FRONTEND_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.VITE_OIDC_AUTHORITY }}" \
|
||||
"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="
|
||||
APP_ENV TZ DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_USER CLICKHOUSE_PASSWORD
|
||||
PROD_BACKEND_PORT BACKEND_PORT USERFRONT_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT
|
||||
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR
|
||||
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
|
||||
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER
|
||||
USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL BACKEND_URL VITE_OIDC_AUTHORITY
|
||||
ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
|
||||
"
|
||||
for key in ${required_dotenv_keys}; do
|
||||
if ! grep -Eq "^${key}=.+" .env; then
|
||||
echo "::error::Missing required production .env value: ${key}. Check Gitea repo variables/secrets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy compose template and .env file to the remote server
|
||||
scp adminfront/seed-tenant.csv "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/adminfront/"
|
||||
scp docker/docker-compose.template.yaml .env "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/"
|
||||
@@ -131,6 +155,9 @@ jobs:
|
||||
"export DEPLOY_PATH='${DEPLOY_PATH}'; \
|
||||
export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \
|
||||
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
|
||||
export ADMINFRONT_IMAGE_NAME='${ADMINFRONT_IMAGE_NAME}'; \
|
||||
export DEVFRONT_IMAGE_NAME='${DEVFRONT_IMAGE_NAME}'; \
|
||||
export ORGFRONT_IMAGE_NAME='${ORGFRONT_IMAGE_NAME}'; \
|
||||
export IMAGE_TAG='${IMAGE_TAG}'; \
|
||||
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
|
||||
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
|
||||
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
|
||||
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
|
||||
CLICKHOUSE_PASSWORD=${{ vars.CLICKHOUSE_PASSWORD }}
|
||||
CLICKHOUSE_PASSWORD=${{ secrets.CLICKHOUSE_PASSWORD }}
|
||||
|
||||
|
||||
BACKEND_PORT=${{ vars.BACKEND_PORT }}
|
||||
@@ -142,9 +142,32 @@ jobs:
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
|
||||
EOF
|
||||
|
||||
required_dotenv_keys="
|
||||
APP_ENV BACKEND_LOG_LEVEL CLIENT_LOG_DEBUG WORKS_ADMIN_API_BASE_URL WORKS_ADMIN_OAUTH_TOKEN_URL TZ IDP_PROVIDER
|
||||
DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_HOST CLICKHOUSE_USER CLICKHOUSE_PASSWORD
|
||||
BACKEND_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT USERFRONT_PORT OATHKEEPER_API_URL
|
||||
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR CORS_ALLOWED_ORIGINS PROFILE_CACHE_TTL
|
||||
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
|
||||
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER ADMIN_EMAIL ADMIN_PASSWORD
|
||||
USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL
|
||||
ORY_POSTGRES_TAG ORY_POSTGRES_USER ORY_POSTGRES_PASSWORD ORY_POSTGRES_DB KRATOS_DB HYDRA_DB KETO_DB
|
||||
KRATOS_VERSION KRATOS_UI_NODE_VERSION HYDRA_VERSION KETO_VERSION ORY_SDK_URL KRATOS_PUBLIC_URL
|
||||
KRATOS_ADMIN_URL KRATOS_BROWSER_URL KRATOS_UI_URL HYDRA_ADMIN_URL HYDRA_PUBLIC_URL JWKS_URL
|
||||
OATHKEEPER_VERSION OATHKEEPER_UID OATHKEEPER_GID OATHKEEPER_HEALTH_URL OATHKEEPER_HEALTH_INTERVAL_SECONDS
|
||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS OATHKEEPER_HEALTH_ENABLED CSRF_COOKIE_NAME CSRF_COOKIE_SECRET
|
||||
VITE_OIDC_AUTHORITY ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
|
||||
"
|
||||
for key in ${required_dotenv_keys}; do
|
||||
if ! grep -Eq "^${key}=.+" .env; then
|
||||
echo "::error::Missing required staging .env value: ${key}. Check Gitea repo variables/secrets."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 파일 복사
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/docker"
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/adminfront"
|
||||
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/scripts"
|
||||
|
||||
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
|
||||
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
|
||||
@@ -158,9 +181,10 @@ jobs:
|
||||
fi
|
||||
|
||||
scp adminfront/seed-tenant.csv "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/adminfront/"
|
||||
scp scripts/render_ory_config.sh "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/scripts/"
|
||||
scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
|
||||
scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml"
|
||||
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
|
||||
scp compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
|
||||
|
||||
# 배포 실행
|
||||
echo "${HARBOR_ROBOT_KEY}" | ssh "${STAGE_USER}@${STAGE_HOST}" \
|
||||
@@ -181,6 +205,9 @@ jobs:
|
||||
for net in baron_net public_net ory-net hydranet kratosnet; do
|
||||
docker network inspect \"\$net\" >/dev/null 2>&1 || docker network create \"\$net\"
|
||||
done
|
||||
|
||||
bash scripts/render_ory_config.sh; \
|
||||
chmod -R 777 config/.generated/ory || true; \
|
||||
|
||||
envsubst < docker-compose.staging.template.yaml > docker-compose.yml; \
|
||||
|
||||
|
||||
67
Makefile
67
Makefile
@@ -29,7 +29,22 @@ ifneq (,$(wildcard ./.env))
|
||||
COMPOSE_DROP_ENV_ARGS += --env-file .env
|
||||
endif
|
||||
|
||||
.PHONY: 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 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
|
||||
DUMP_SERVICES ?= all
|
||||
RESTORE_SERVICES ?= all
|
||||
DUMP_MODE ?= maintenance
|
||||
BACKUP_USE_DOCKER ?= true
|
||||
BACKUP_TOOLS_IMAGE ?= baron-sso-backup-tools:local
|
||||
BACKUP_TOOLS_DOCKERFILE ?= docker/backup-tools/Dockerfile
|
||||
BACKUP_DOCKER_ENV_ARGS :=
|
||||
ifneq (,$(wildcard ./.env))
|
||||
BACKUP_DOCKER_ENV_ARGS += --env-file .env
|
||||
endif
|
||||
ifneq (,$(wildcard ./$(AUTH_CONFIG_ENV)))
|
||||
BACKUP_DOCKER_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV)
|
||||
endif
|
||||
BACKUP_DOCKER_RUN = docker run --rm $(BACKUP_DOCKER_ENV_ARGS) -e BACKUP_REPO_ROOT=/workspace -v /var/run/docker.sock:/var/run/docker.sock -v "$(CURDIR)":/workspace -v /tmp:/tmp -w /workspace $(BACKUP_TOOLS_IMAGE)
|
||||
|
||||
.PHONY: 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 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 dump-upload-cloud
|
||||
|
||||
# --- 인증 설정 빌드/검증 ---
|
||||
build-auth-config:
|
||||
@@ -188,6 +203,56 @@ logs-ory:
|
||||
logs-app:
|
||||
docker compose -f $(COMPOSE_APP) logs -f
|
||||
|
||||
# --- 백업/복구 ---
|
||||
backup-tools-build:
|
||||
docker build -f $(BACKUP_TOOLS_DOCKERFILE) -t $(BACKUP_TOOLS_IMAGE) .
|
||||
|
||||
ifeq ($(BACKUP_USE_DOCKER),true)
|
||||
dump: backup-tools-build
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh'
|
||||
|
||||
restore: backup-tools-build
|
||||
$(BACKUP_DOCKER_RUN) bash -lc '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'
|
||||
|
||||
restore-verify: backup-tools-build
|
||||
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh'
|
||||
|
||||
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 '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 'WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh'
|
||||
else
|
||||
dump:
|
||||
DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh
|
||||
|
||||
restore:
|
||||
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
|
||||
|
||||
restore-verify:
|
||||
BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh
|
||||
|
||||
dump-list:
|
||||
BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh
|
||||
|
||||
restore-plan:
|
||||
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:
|
||||
WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh
|
||||
endif
|
||||
|
||||
dump-upload-cloud: dump upload-cloud
|
||||
|
||||
# --- 로컬 통합 코드 체크 ---
|
||||
PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright
|
||||
PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE
|
||||
|
||||
149
README.md
149
README.md
@@ -527,6 +527,155 @@ docker compose --env-file .env --env-file config/.generated/auth-config.env -f d
|
||||
- **Hydra Public**: http://localhost:4444
|
||||
- **Kratos UI (UserFront)**: http://localhost:5000
|
||||
|
||||
### 전체 백업/복구
|
||||
|
||||
전체 백업/복구는 CSV export/import가 아니라 Baron SSO와 Ory Stack 저장소를 같은 시점의 재해 복구 단위로 보존하는 절차입니다. 사용자 UUID, Kratos identity ID, Hydra/Keto 원장, WORKS 연동 mapping이 어긋나면 안 되므로 운영 복구는 DB dump와 설정 snapshot을 함께 다룹니다.
|
||||
|
||||
#### 백업 실행
|
||||
```bash
|
||||
# 전체 백업
|
||||
make dump
|
||||
|
||||
# 출력 위치를 직접 지정
|
||||
make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
|
||||
# 일부 서비스만 백업
|
||||
make dump DUMP_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config
|
||||
make dump DUMP_SERVICES=ory-postgres,ory-clickhouse
|
||||
|
||||
# 생성된 백업 검증
|
||||
make dump-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
|
||||
# WORKS Drive로 외부 분산 저장
|
||||
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
|
||||
# 지정 경로로 dump 후 바로 WORKS Drive 업로드
|
||||
make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
|
||||
# 로컬 백업 목록
|
||||
make dump-list
|
||||
```
|
||||
|
||||
기본값은 `DUMP_SERVICES=all`, `DUMP_MODE=maintenance`입니다. `DUMP_SERVICES`는 다음 값을 콤마로 조합할 수 있습니다.
|
||||
|
||||
| 값 | 대상 |
|
||||
| --- | --- |
|
||||
| `postgres` | Baron Postgres (`baron_postgres`, `${DB_NAME:-baron_sso}`) |
|
||||
| `ory-postgres` | Ory Postgres의 `${KRATOS_DB:-ory_kratos}`, `${HYDRA_DB:-ory_hydra}`, `${KETO_DB:-ory_keto}` |
|
||||
| `clickhouse` | Baron ClickHouse (`baron_clickhouse`) |
|
||||
| `ory-clickhouse` | Ory ClickHouse (`ory_clickhouse`) |
|
||||
| `config` | `.env` redacted copy, generated Ory config, gateway, 주요 compose 파일 |
|
||||
|
||||
백업 산출물은 기본적으로 `backups/baron-sso-backup-YYYYMMDD-HHMMSSZ/` 아래에 생성됩니다.
|
||||
|
||||
```text
|
||||
manifest.json
|
||||
checksums.sha256
|
||||
postgres/
|
||||
clickhouse/
|
||||
config/
|
||||
reports/
|
||||
```
|
||||
|
||||
#### WORKS Drive 외부 업로드
|
||||
|
||||
`make dump`, `make restore`, `make upload-cloud`는 기본적으로 `docker/backup-tools/Dockerfile`에서 빌드한 `baron-sso-backup-tools:local` 컨테이너 안에서 실행됩니다. 호스트에는 Docker와 Docker socket 접근 권한만 필요하고, `zstd`, `jq`, `curl`, `openssl`, `postgresql-client` 같은 백업/복구 도구는 backup-tools image에 포함됩니다.
|
||||
|
||||
`make upload-cloud`는 기존 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 묶은 뒤 WORKS Drive에 업로드합니다. 압축 포맷은 `.tar.zst`로 고정되어 있고, 압축/해제는 backup-tools 컨테이너 내부의 `zstd`로 수행합니다.
|
||||
|
||||
백업이 완료되면 `reports/backup-report.md`도 생성됩니다. 이 report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록됩니다. `make upload-cloud`는 `reports/*.md`만 WORKS Drive 대상 폴더 아래의 `reports` 하위 폴더로 업로드하며, 업로드 파일명은 `backup-report-YYYYMMDD-HHMMSSZ.md`처럼 업로드 시각을 붙입니다. `reports/cloud-upload.json`은 로컬 업로드 실행 기록으로만 남기고 Drive에는 업로드하지 않습니다.
|
||||
|
||||
```bash
|
||||
# 권장: 백업 경로를 명시해서 dump와 upload를 분리
|
||||
make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
|
||||
# 또는 같은 BACKUP 경로로 연속 실행
|
||||
make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
|
||||
# 실제 업로드 전 endpoint와 target만 확인
|
||||
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ WORKS_DRIVE_DRY_RUN=true
|
||||
|
||||
# 예외적으로 호스트 도구로 직접 실행
|
||||
make restore BACKUP_USE_DOCKER=false BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ CONFIRM_RESTORE=baron-sso
|
||||
```
|
||||
|
||||
주요 변수:
|
||||
|
||||
| 변수 | 설명 |
|
||||
| --- | --- |
|
||||
| `WORKS_DRIVE_TARGET` | `sharedrive`, `mydrive`, `group`, `sharedfolder` 중 하나. 기본값은 `sharedrive`입니다. |
|
||||
| `WORKS_DRIVE_SHARED_DRIVE_ID` | `WORKS_DRIVE_TARGET=sharedrive`일 때 공용 드라이브 ID입니다. |
|
||||
| `WORKS_DRIVE_PARENT_FILE_ID` | 업로드할 대상 폴더의 WORKS Drive `fileId`입니다. 폴더 이름이나 경로가 아니며, 비우면 대상 drive/folder root에 업로드합니다. |
|
||||
| `WORKS_DRIVE_USER_ID` | `mydrive` 또는 `sharedfolder` 대상 사용자 ID입니다. 기본값은 `me`입니다. |
|
||||
| `WORKS_DRIVE_GROUP_ID` | `WORKS_DRIVE_TARGET=group`일 때 조직/그룹 ID입니다. |
|
||||
| `WORKS_DRIVE_SHARED_FOLDER_ID` | `WORKS_DRIVE_TARGET=sharedfolder`일 때 공유받은 폴더 ID입니다. |
|
||||
| `WORKS_DRIVE_ACCESS_TOKEN` | Drive API 호출용 Bearer token입니다. Drive API는 `file` scope가 필요합니다. |
|
||||
| `WORKS_DRIVE_ACCESS_TOKEN_FILE` | access token을 파일에서 읽을 때 사용합니다. |
|
||||
| `WORKS_DRIVE_ACCESS_TOKEN_CMD` | access token을 명령 출력으로 주입할 때 사용합니다. |
|
||||
| `WORKS_DRIVE_OAUTH_SCOPE` | Drive 업로드 앱 OAuth token에 사용할 scope입니다. 기본값은 `file`입니다. |
|
||||
| `WORKS_DRIVE_OAUTH_CLIENT_ID` | Drive 업로드 앱의 OAuth client ID입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_CLIENT_ID`와 분리합니다. |
|
||||
| `WORKS_DRIVE_OAUTH_CLIENT_SECRET` | Drive 업로드 앱의 OAuth client secret입니다. |
|
||||
| `WORKS_DRIVE_OAUTH_REFRESH_TOKEN` | Drive 업로드 앱의 refresh token입니다. 명시 access token이 없으면 이 값으로 access token을 갱신합니다. |
|
||||
| `WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT` | Drive 업로드 앱의 service account입니다. JWT `sub`에 들어갑니다. |
|
||||
| `WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE` | Drive 업로드 앱 private key 파일입니다. 예: `./config/worksmobile-driveapp-private-key.pem` |
|
||||
| `WORKS_DRIVE_SPLIT_SIZE` | 분할 업로드 시 part 크기입니다. 기본값은 `9000M`입니다. |
|
||||
| `WORKS_DRIVE_MAX_SINGLE_FILE_BYTES` | 이 값보다 archive가 크면 split part로 나눕니다. 기본값 `0`은 자동 분할 비활성입니다. |
|
||||
| `WORKS_DRIVE_FORCE_SPLIT` | `true`이면 크기와 무관하게 split part로 업로드합니다. |
|
||||
| `WORKS_DRIVE_OVERWRITE` | WORKS Drive upload URL 생성 요청의 overwrite 플래그입니다. 기본값은 `false`입니다. |
|
||||
| `WORKS_DRIVE_UPLOAD_REPORTS` | `true`이면 `reports/*.md`를 Drive의 report 폴더로 함께 업로드합니다. 기본값은 `true`입니다. |
|
||||
| `WORKS_DRIVE_REPORT_FOLDER_NAME` | Markdown report를 업로드할 하위 폴더 이름입니다. 기본값은 `reports`입니다. |
|
||||
|
||||
Drive API는 업로드 URL 생성 후 해당 URL에 multipart `Filedata`로 실제 파일을 전송하는 2단계 방식입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 Drive 업로드용 `WORKS_DRIVE_OAUTH_*`는 서로 다른 앱/키로 관리합니다. token 우선순위는 `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`, `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`, 서비스 계정 JWT fallback 순서입니다. 운영에서는 Drive API 권한과 `file` scope 위임 정책을 먼저 확인해야 합니다.
|
||||
|
||||
#### 복구 계획과 복구 실행
|
||||
```bash
|
||||
# 복구 전 계획 확인
|
||||
make restore-plan BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
|
||||
RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \
|
||||
CONFIRM_RESTORE=baron-sso
|
||||
|
||||
# 복구 실행
|
||||
make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
|
||||
RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \
|
||||
CONFIRM_RESTORE=baron-sso
|
||||
|
||||
# .tar.zst archive를 직접 복구 입력으로 사용
|
||||
make restore DUMP_FILE=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ.tar.zst \
|
||||
RESTORE_SERVICES=all \
|
||||
CONFIRM_RESTORE=baron-sso
|
||||
|
||||
# report 경로를 명시
|
||||
make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
|
||||
CONFIRM_RESTORE=baron-sso \
|
||||
RESTORE_REPORT=reports/restore/baron-sso-restore-report.json
|
||||
|
||||
# 복구 후 기본 검증
|
||||
make restore-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||
```
|
||||
|
||||
복구는 반드시 빈 volume 또는 restore 전용 stack에서 수행하는 것을 기본 정책으로 합니다. `make restore`는 `BACKUP` 또는 `DUMP_FILE` 중 하나와 `CONFIRM_RESTORE=baron-sso`가 없으면 실패하고, 기본적으로 non-empty Postgres 대상에는 복구하지 않습니다. 승인된 restore rehearsal에서만 `ALLOW_NON_EMPTY_RESTORE=true`를 사용하세요. `DUMP_FILE=.tar.zst` 해제도 backup-tools 컨테이너에서 수행하므로 호스트 `zstd` 설치에 의존하지 않습니다.
|
||||
|
||||
`make restore`는 복구 report를 JSON과 Markdown으로 남깁니다. `BACKUP` 디렉터리 입력의 기본 JSON report는 `<BACKUP>/reports/restore-report.json`이고, `DUMP_FILE` archive 입력의 기본 JSON report는 `reports/restore/<archive-name>-restore-report.json`입니다. 같은 경로에 `.md` 확장자의 Markdown 요약도 함께 생성됩니다. `RESTORE_REPORT`로 직접 지정할 수 있습니다. report에는 입력 archive, 복구 서비스, checksum 검증 상태, 복구 후 대상 row count 비교 결과가 기록됩니다.
|
||||
|
||||
`config` 복구는 운영 파일을 직접 덮어쓰지 않고 `config-restored/`에 풀어 수동 검토하도록 합니다. migration은 자동 실행하지 않으며, Ory Stack과 backend 기동 후 super admin login, 대표 OIDC login, WORKS comparison dry-run을 통과하기 전까지 WORKS relay를 자동 재개하지 않습니다.
|
||||
|
||||
#### 백업/복구 범위
|
||||
|
||||
필수 백업 대상:
|
||||
- Baron Postgres: users, tenants, user_login_ids, user_groups, RP metadata, WORKS mapping/outbox 등
|
||||
- Ory Postgres: Kratos identity/credentials/session, Hydra client/consent/token state, Keto relation tuple
|
||||
- Baron ClickHouse: 감사 로그와 RP usage event
|
||||
- Ory ClickHouse: Oathkeeper/Ory 계열 접근 로그
|
||||
- 설정 snapshot: `.env` redacted copy, generated Ory config, gateway, compose 파일
|
||||
|
||||
기본 제외 대상:
|
||||
- Redis: pending login, short code, cache 등 휘발성 데이터이므로 복구 후 재수렴 대상으로 봅니다.
|
||||
- 프론트 빌드 산출물: 소스와 이미지 태그로 재생성합니다.
|
||||
- coverage, reports, test-results 같은 로컬 개발 산출물
|
||||
|
||||
상세 설계와 운영 정책은 `docs/backup-restore-design.md`를 기준으로 유지합니다.
|
||||
|
||||
### MCP 서버 (Hydra/Kratos/Keto)
|
||||
MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다.
|
||||
프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요.
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
FROM node:lts
|
||||
FROM node:lts AS build
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Set CI environment variable to true to avoid TTY issues with pnpm
|
||||
ENV CI=true
|
||||
ENV ADMINFRONT_BUILD_OUT_DIR=/workspace/adminfront/dist
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.5.2 --activate
|
||||
|
||||
# Copy workspace configs and common package
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY common ./common
|
||||
COPY adminfront ./adminfront
|
||||
|
||||
# Install dependencies for the workspace
|
||||
RUN pnpm install --filter adminfront... --filter baron-sso... --no-frozen-lockfile --ignore-scripts
|
||||
ARG VITE_ADMIN_PUBLIC_URL
|
||||
ARG VITE_OIDC_AUTHORITY
|
||||
ARG VITE_OIDC_CLIENT_ID
|
||||
ARG ORGFRONT_URL
|
||||
ENV VITE_ADMIN_PUBLIC_URL=$VITE_ADMIN_PUBLIC_URL
|
||||
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
|
||||
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
|
||||
ENV ORGFRONT_URL=$ORGFRONT_URL
|
||||
|
||||
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||
RUN npm install -g serve
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /workspace/adminfront
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV FRONTEND_DIST_DIR=/app/dist
|
||||
ENV PORT=5173
|
||||
|
||||
COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs
|
||||
COPY --from=build /workspace/adminfront/dist ./dist
|
||||
|
||||
# Vite 기본 포트
|
||||
EXPOSE 5173
|
||||
|
||||
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||
RUN chmod +x ./scripts/runtime-mode.sh
|
||||
CMD ["sh", "./scripts/runtime-mode.sh"]
|
||||
CMD ["node", "./serve_frontend_prod.mjs"]
|
||||
|
||||
@@ -127,6 +127,22 @@ describe("admin AppLayout", () => {
|
||||
expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles the sidebar and persists the collapsed state", async () => {
|
||||
renderLayout();
|
||||
|
||||
const collapseButton = await screen.findByRole("button", {
|
||||
name: "사이드바 접기",
|
||||
});
|
||||
fireEvent.click(collapseButton);
|
||||
|
||||
expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "사이드바 펼치기" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
|
||||
renderLayout();
|
||||
|
||||
|
||||
@@ -26,11 +26,13 @@ import {
|
||||
buildShellProfileSummary,
|
||||
buildShellSessionStatus,
|
||||
readShellSessionExpiryEnabled,
|
||||
readShellSidebarCollapsed,
|
||||
readShellTheme,
|
||||
type ShellSidebarNavItem,
|
||||
type ShellTranslator,
|
||||
shellLayoutClasses,
|
||||
writeShellSessionExpiryEnabled,
|
||||
writeShellSidebarCollapsed,
|
||||
} from "../../../../common/shell";
|
||||
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
|
||||
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
||||
@@ -165,6 +167,9 @@ function AppLayout() {
|
||||
const isDevelopmentRuntime = import.meta.env.MODE === "development";
|
||||
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
|
||||
readShellSidebarCollapsed(false),
|
||||
);
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||
);
|
||||
@@ -508,10 +513,18 @@ function AppLayout() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handleSidebarToggle = () => {
|
||||
setIsSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
writeShellSidebarCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const sidebarNavContent = (
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map((item) => {
|
||||
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
|
||||
const label = t(labelKey, labelFallback);
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
@@ -522,11 +535,18 @@ function AppLayout() {
|
||||
rel="noopener noreferrer"
|
||||
className={[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.navItemBaseCollapsed
|
||||
: "",
|
||||
shellLayoutClasses.navItemIdle,
|
||||
].join(" ")}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||
{label}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -539,6 +559,9 @@ function AppLayout() {
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.navItemBaseCollapsed
|
||||
: "",
|
||||
item.isActive !== undefined
|
||||
? item.isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
@@ -548,9 +571,11 @@ function AppLayout() {
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
@@ -561,10 +586,17 @@ function AppLayout() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.logoutButtonCollapsed
|
||||
: shellLayoutClasses.logoutButton
|
||||
}
|
||||
title={t("ui.shell.nav.logout", "Logout")}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||
{t("ui.shell.nav.logout", "Logout")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -578,13 +610,23 @@ function AppLayout() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<div
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.rootCollapsed
|
||||
: shellLayoutClasses.root
|
||||
}
|
||||
>
|
||||
<AppSidebar
|
||||
brandLabel={t("ui.admin.brand", "Baron 로그인")}
|
||||
brandTitle={t("ui.admin.title", "Admin Control")}
|
||||
brandIcon={<ShieldHalf size={20} />}
|
||||
navContent={sidebarNavContent}
|
||||
footerContent={sidebarFooterContent}
|
||||
collapsed={isSidebarCollapsed}
|
||||
onToggleCollapsed={handleSidebarToggle}
|
||||
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
|
||||
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
|
||||
/>
|
||||
|
||||
<div className={shellLayoutClasses.contentWide}>
|
||||
@@ -785,7 +827,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</header>
|
||||
<main className={shellLayoutClasses.mainMinWidth}>
|
||||
<Outlet />
|
||||
<Outlet context={isSidebarCollapsed} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
56
adminfront/src/features/auth/AuthGuard.test.tsx
Normal file
56
adminfront/src/features/auth/AuthGuard.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AuthGuard from "./AuthGuard";
|
||||
|
||||
const authState = {
|
||||
activeNavigator: undefined,
|
||||
error: undefined as Error | undefined,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
removeUser: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
function renderAuthGuard(initialEntry = "/users") {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<AuthGuard />}>
|
||||
<Route path="users" element={<div>Users outlet</div>} />
|
||||
</Route>
|
||||
<Route path="/login" element={<div>Login outlet</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("AuthGuard", () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||
)._IS_TEST_MODE = false;
|
||||
authState.activeNavigator = undefined;
|
||||
authState.error = undefined;
|
||||
authState.isAuthenticated = false;
|
||||
authState.isLoading = false;
|
||||
authState.removeUser.mockClear();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("clears stale auth state and returns to login when OIDC reports an error", async () => {
|
||||
window.localStorage.setItem("admin_session", "stale-token");
|
||||
authState.error = new Error("stale session");
|
||||
|
||||
renderAuthGuard();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authState.removeUser).toHaveBeenCalled();
|
||||
});
|
||||
await screen.findByText("Login outlet");
|
||||
expect(window.localStorage.getItem("admin_session")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,31 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { clearStoredAdminAuthSession } from "../../lib/auth";
|
||||
|
||||
export default function AuthGuard() {
|
||||
const auth = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const handledAuthErrorRef = useRef(false);
|
||||
const isTest =
|
||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||
._IS_TEST_MODE === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.error || handledAuthErrorRef.current || isTest) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledAuthErrorRef.current = true;
|
||||
clearStoredAdminAuthSession();
|
||||
void Promise.resolve(
|
||||
auth.removeUser ? auth.removeUser() : undefined,
|
||||
).finally(() => {
|
||||
navigate("/login", { replace: true });
|
||||
});
|
||||
}, [auth, auth.error, isTest, navigate]);
|
||||
|
||||
if (isTest) {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
type UseMutationResult,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
@@ -25,7 +20,7 @@ import {
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate, useOutletContext } from "react-router-dom";
|
||||
import { PageHeader } from "../../../../../common/core/components/page";
|
||||
import {
|
||||
type SortConfig,
|
||||
@@ -33,6 +28,7 @@ import {
|
||||
sortItems,
|
||||
toggleSort,
|
||||
} from "../../../../../common/core/utils";
|
||||
import { SearchFilterBar } from "../../../../../common/ui/search-filter-bar";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
@@ -68,7 +64,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../../components/ui/select";
|
||||
import { Switch } from "../../../components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -79,7 +74,6 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../../../components/ui/tabs";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import type { UserProfileResponse } from "../../../lib/adminApi";
|
||||
import {
|
||||
deleteTenantsBulk,
|
||||
exportTenantsCSV,
|
||||
@@ -124,6 +118,10 @@ const tenantCSVTemplate =
|
||||
const tenantPageSize = 500;
|
||||
const _tenantVirtualizationThreshold = 250;
|
||||
const _tenantEstimatedRowHeight = 73;
|
||||
const tenantTableHeadClassName =
|
||||
"h-9 px-3 py-1 text-xs leading-tight align-middle whitespace-nowrap";
|
||||
const tenantTableHeadInteractiveClassName = `${tenantTableHeadClassName} cursor-pointer transition-colors hover:bg-muted/50`;
|
||||
const tenantTableHeadContentClassName = "flex h-full items-center gap-1";
|
||||
const _tenantLoadAheadPx = 360;
|
||||
const _tenantLoadAheadRows = 30;
|
||||
|
||||
@@ -141,6 +139,70 @@ const getTenantIcon = (type?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
function getTenantTypeLabel(type?: string) {
|
||||
if (!type) return "-";
|
||||
return t(`domain.tenant_type.${type.toLowerCase()}`, type);
|
||||
}
|
||||
|
||||
function splitTenantTypeLabel(label: string) {
|
||||
const match = label.match(/^(.*?)\s*(\(.+\))$/);
|
||||
if (!match) {
|
||||
return { primary: label, secondary: null as string | null };
|
||||
}
|
||||
return {
|
||||
primary: match[1].trim(),
|
||||
secondary: match[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function abbreviateUuid(value: string) {
|
||||
const parts = value.split("-");
|
||||
if (parts.length < 4) {
|
||||
return value;
|
||||
}
|
||||
return `${parts.slice(0, 4).join("-")}-...`;
|
||||
}
|
||||
|
||||
function getTenantTypeTextClass(type?: string) {
|
||||
switch (type?.toUpperCase()) {
|
||||
case "COMPANY_GROUP":
|
||||
return "text-sky-700";
|
||||
case "COMPANY":
|
||||
return "text-violet-700";
|
||||
case "ORGANIZATION":
|
||||
return "text-emerald-700";
|
||||
case "USER_GROUP":
|
||||
return "text-amber-700";
|
||||
case "PERSONAL":
|
||||
return "text-slate-700";
|
||||
default:
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
function buildTenantParentPathMap(tenants: TenantSummary[]) {
|
||||
const tenantById = new Map(tenants.map((tenant) => [tenant.id, tenant]));
|
||||
const pathMap = new Map<string, string[]>();
|
||||
|
||||
for (const tenant of tenants) {
|
||||
const names: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
let currentParentId = tenant.parentId;
|
||||
|
||||
while (currentParentId && !visited.has(currentParentId)) {
|
||||
visited.add(currentParentId);
|
||||
const parent = tenantById.get(currentParentId);
|
||||
if (!parent) break;
|
||||
names.unshift(parent.name);
|
||||
currentParentId = parent.parentId;
|
||||
}
|
||||
|
||||
pathMap.set(tenant.id, names);
|
||||
}
|
||||
|
||||
return pathMap;
|
||||
}
|
||||
|
||||
const noImportParentRef = "__none__";
|
||||
|
||||
function tenantParentRef(tenantId: string) {
|
||||
@@ -338,19 +400,6 @@ function TenantListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ tenantId, status }: { tenantId: string; status: string }) =>
|
||||
updateTenant(tenantId, { status }),
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t("msg.admin.tenants.status_error", "테넌트 상태 변경에 실패했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const bulkUpdateStatusMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
tenantIds,
|
||||
@@ -450,7 +499,6 @@ function TenantListPage() {
|
||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||
: null;
|
||||
|
||||
const tenantTotal = query.data?.pages[0]?.total ?? 0;
|
||||
const hanmacFamilyTenantId = React.useMemo(() => {
|
||||
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
||||
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
||||
@@ -708,174 +756,187 @@ function TenantListPage() {
|
||||
"시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.",
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative mr-2 w-64">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"테넌트 이름, 슬러그, UUID 검색...",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
query.refetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<>
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"이름 또는 슬러그, ID 검색",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
query.refetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex rounded-md border bg-background p-0.5"
|
||||
data-testid="tenant-view-mode-toggle"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant={viewMode === "tree" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
aria-pressed={viewMode === "tree"}
|
||||
onClick={() => setViewMode("tree")}
|
||||
data-testid="tenant-view-tree-btn"
|
||||
>
|
||||
<Network size={14} />
|
||||
{t("ui.admin.tenants.view.tree", "트리")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={viewMode === "table" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
aria-pressed={viewMode === "table"}
|
||||
onClick={() => setViewMode("table")}
|
||||
data-testid="tenant-view-table-btn"
|
||||
>
|
||||
<List size={14} />
|
||||
{t("ui.admin.tenants.view.table", "평면")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant={scopeTenantId ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-9 gap-2"
|
||||
onClick={() => setScopePickerOpen(true)}
|
||||
data-testid="tenant-scope-picker-btn"
|
||||
>
|
||||
<Network size={16} />
|
||||
{selectedScopeTenant
|
||||
? t("ui.admin.tenants.scope.active", "{{name}} 하위", {
|
||||
name: selectedScopeTenant.name,
|
||||
})
|
||||
: t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
</Button>
|
||||
{scopeTenantId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={() => setScopeTenantId("")}
|
||||
data-testid="tenant-scope-clear-btn"
|
||||
>
|
||||
{t("ui.common.clear", "초기화")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
data-testid="tenant-import-input"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className="flex rounded-md border bg-background p-0.5"
|
||||
data-testid="tenant-view-mode-toggle"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="tenant-data-mgmt-btn"
|
||||
className="gap-2"
|
||||
type="button"
|
||||
variant={viewMode === "tree" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-9 gap-1.5"
|
||||
aria-pressed={viewMode === "tree"}
|
||||
onClick={() => setViewMode("tree")}
|
||||
data-testid="tenant-view-tree-btn"
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
{t("ui.admin.tenants.data_mgmt", "데이터 관리")}
|
||||
<ChevronDown size={14} className="opacity-50" />
|
||||
<Network size={14} />
|
||||
{t("ui.admin.tenants.view.tree", "트리")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={handleTemplateDownload}
|
||||
data-testid="tenant-template-menu-item"
|
||||
className="cursor-pointer"
|
||||
<Button
|
||||
type="button"
|
||||
variant={viewMode === "table" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-9 gap-1.5"
|
||||
aria-pressed={viewMode === "table"}
|
||||
onClick={() => setViewMode("table")}
|
||||
data-testid="tenant-view-table-btn"
|
||||
>
|
||||
<FileSpreadsheet size={16} className="mr-2 opacity-50" />
|
||||
{t("ui.admin.tenants.csv_template", "템플릿 다운로드")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importMutation.isPending}
|
||||
data-testid="tenant-import-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Upload size={16} className="mr-2 opacity-50" />
|
||||
{t("ui.admin.tenants.import", "CSV 가져오기")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportMutation.mutate(false)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="mr-2 opacity-50" />
|
||||
{t(
|
||||
"ui.admin.tenants.export_without_ids",
|
||||
"UUID 제외 내보내기",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportMutation.mutate(true)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-with-ids-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="mr-2 opacity-50" />
|
||||
{t(
|
||||
"ui.admin.tenants.export_with_ids",
|
||||
"UUID 포함 내보내기",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</RoleGuard>
|
||||
<List size={14} />
|
||||
{t("ui.admin.tenants.view.table", "평면")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
className="w-9 px-0"
|
||||
title={t("ui.common.refresh", "새로고침")}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
<span className="sr-only">
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</span>
|
||||
</Button>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Button asChild>
|
||||
<Link to="/tenants/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.tenants.add", "테넌트 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={scopeTenantId ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-9 gap-2"
|
||||
onClick={() => setScopePickerOpen(true)}
|
||||
data-testid="tenant-scope-picker-btn"
|
||||
>
|
||||
<Network size={16} />
|
||||
{selectedScopeTenant
|
||||
? t("ui.admin.tenants.scope.active", "{{name}} 하위", {
|
||||
name: selectedScopeTenant.name,
|
||||
})
|
||||
: t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
</Button>
|
||||
{scopeTenantId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={() => setScopeTenantId("")}
|
||||
data-testid="tenant-scope-clear-btn"
|
||||
>
|
||||
{t("ui.common.clear", "초기화")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
data-testid="tenant-import-input"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="tenant-data-mgmt-btn"
|
||||
className="gap-2 h-9"
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
{t("ui.admin.tenants.data_mgmt", "데이터 관리")}
|
||||
<ChevronDown size={14} className="opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem
|
||||
onClick={handleTemplateDownload}
|
||||
data-testid="tenant-template-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FileSpreadsheet
|
||||
size={16}
|
||||
className="mr-2 opacity-50"
|
||||
/>
|
||||
{t(
|
||||
"ui.admin.tenants.csv_template",
|
||||
"템플릿 다운로드",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={importMutation.isPending}
|
||||
data-testid="tenant-import-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Upload size={16} className="mr-2 opacity-50" />
|
||||
{t("ui.admin.tenants.import", "CSV 가져오기")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportMutation.mutate(false)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="mr-2 opacity-50" />
|
||||
{t(
|
||||
"ui.admin.tenants.export_without_ids",
|
||||
"UUID 제외 내보내기",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => exportMutation.mutate(true)}
|
||||
disabled={exportMutation.isPending}
|
||||
data-testid="tenant-export-with-ids-menu-item"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="mr-2 opacity-50" />
|
||||
{t(
|
||||
"ui.admin.tenants.export_with_ids",
|
||||
"UUID 포함 내보내기",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</RoleGuard>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
className="h-9 w-9 px-0"
|
||||
title={t("ui.common.refresh", "새로고침")}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
<span className="sr-only">
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</span>
|
||||
</Button>
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<Button asChild size="sm" className="h-9">
|
||||
<Link to="/tenants/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.tenants.add", "테넌트 추가")}
|
||||
</Link>
|
||||
</Button>
|
||||
</RoleGuard>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{importMessage ? (
|
||||
<div
|
||||
className="rounded-md border border-border bg-secondary px-3 py-2 text-sm"
|
||||
@@ -884,7 +945,7 @@ function TenantListPage() {
|
||||
{importMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -900,7 +961,9 @@ function TenantListPage() {
|
||||
"msg.admin.tenants.registry.count",
|
||||
"총 {{count}}개의 테넌트가 등록되어 있습니다.",
|
||||
{
|
||||
count: scopeTenantId ? scopedTenants.length : tenantTotal,
|
||||
count: scopeTenantId
|
||||
? scopedTenants.length
|
||||
: allTenants.length,
|
||||
},
|
||||
)}
|
||||
</CardDescription>
|
||||
@@ -921,8 +984,6 @@ function TenantListPage() {
|
||||
onSelectAll={handleSelectAll}
|
||||
search={search}
|
||||
deletableTenants={deletableTenants}
|
||||
statusMutation={statusMutation}
|
||||
profile={profile}
|
||||
sortConfig={sortConfig}
|
||||
requestSort={requestSort}
|
||||
getSortIcon={getSortIcon}
|
||||
@@ -1499,13 +1560,6 @@ const TenantHierarchyView: React.FC<{
|
||||
onSelectAll: (checked: boolean) => void;
|
||||
search: string;
|
||||
deletableTenants: TenantSummary[];
|
||||
statusMutation: UseMutationResult<
|
||||
TenantSummary,
|
||||
Error,
|
||||
{ tenantId: string; status: string },
|
||||
unknown
|
||||
>;
|
||||
profile: UserProfileResponse | undefined;
|
||||
sortConfig: SortConfig<TenantSortKey> | null;
|
||||
requestSort: (key: TenantSortKey) => void;
|
||||
getSortIcon: (key: TenantSortKey) => React.ReactNode;
|
||||
@@ -1522,8 +1576,6 @@ const TenantHierarchyView: React.FC<{
|
||||
onSelectAll,
|
||||
search,
|
||||
deletableTenants,
|
||||
statusMutation,
|
||||
profile,
|
||||
sortConfig,
|
||||
requestSort,
|
||||
getSortIcon,
|
||||
@@ -1535,15 +1587,28 @@ const TenantHierarchyView: React.FC<{
|
||||
isLoading,
|
||||
}) => {
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const isSidebarCollapsed = useOutletContext<boolean>() ?? false;
|
||||
const isTest =
|
||||
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
|
||||
(typeof window !== "undefined" &&
|
||||
(window as Window & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE);
|
||||
const tenantTableGridTemplateColumns = React.useMemo(
|
||||
() =>
|
||||
isSidebarCollapsed
|
||||
? "48px minmax(380px, 1fr) 310px 140px 240px 120px 120px 110px"
|
||||
: "48px minmax(500px, 1fr) 240px 130px 226px 100px 100px 110px",
|
||||
[isSidebarCollapsed],
|
||||
);
|
||||
const tenantTableMinWidth = "100%";
|
||||
|
||||
const { subTree } = React.useMemo(
|
||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined, !!search),
|
||||
[scopeTenantId, tenants, search],
|
||||
);
|
||||
const tenantParentPathMap = React.useMemo(
|
||||
() => buildTenantParentPathMap(tenants),
|
||||
[tenants],
|
||||
);
|
||||
|
||||
// Initial expanded state: everything open
|
||||
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => {
|
||||
@@ -1639,6 +1704,7 @@ const TenantHierarchyView: React.FC<{
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const shouldVirtualizeRows = !(isTest && flattenedRows.length < 100);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isTest) return;
|
||||
@@ -1668,6 +1734,22 @@ const TenantHierarchyView: React.FC<{
|
||||
const visibleSelectedCount = selectedIds.filter((id) =>
|
||||
visibleSelectableIds.has(id),
|
||||
).length;
|
||||
const normalizedSearch = search.trim();
|
||||
const emptyMessage = React.useMemo(() => {
|
||||
if (normalizedSearch) {
|
||||
return t(
|
||||
"msg.admin.tenants.empty_search",
|
||||
"검색 조건에 맞는 테넌트가 없습니다.",
|
||||
);
|
||||
}
|
||||
if (scopeTenantId) {
|
||||
return t(
|
||||
"msg.admin.tenants.empty_scope",
|
||||
"선택한 범위에 표시할 하위 테넌트가 없습니다.",
|
||||
);
|
||||
}
|
||||
return t("msg.admin.tenants.empty", "아직 등록된 테넌트가 없습니다.");
|
||||
}, [normalizedSearch, scopeTenantId]);
|
||||
|
||||
const renderRow = (
|
||||
node: TenantViewRow,
|
||||
@@ -1693,8 +1775,19 @@ const TenantHierarchyView: React.FC<{
|
||||
)}
|
||||
style={
|
||||
virtualRow
|
||||
? { transform: `translateY(${virtualRow.start}px)` }
|
||||
: undefined
|
||||
? {
|
||||
display: "grid",
|
||||
gridTemplateColumns: tenantTableGridTemplateColumns,
|
||||
minWidth: tenantTableMinWidth,
|
||||
position: "absolute",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
width: "100%",
|
||||
}
|
||||
: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: tenantTableGridTemplateColumns,
|
||||
minWidth: tenantTableMinWidth,
|
||||
}
|
||||
}
|
||||
>
|
||||
<TableCell className="text-center px-4">
|
||||
@@ -1742,182 +1835,249 @@ const TenantHierarchyView: React.FC<{
|
||||
className="mr-2 flex-shrink-0 text-muted-foreground"
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/tenants/${node.id}`}
|
||||
className="cursor-pointer truncate text-primary hover:underline"
|
||||
>
|
||||
{node.name}
|
||||
</Link>
|
||||
{isSeedTenant(node) && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex-shrink-0 text-[10px]"
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/tenants/${node.id}`}
|
||||
className="block max-w-full truncate text-foreground transition-colors hover:text-primary hover:underline"
|
||||
>
|
||||
{t("ui.admin.tenants.seed_badge", "초기 설정")}
|
||||
</Badge>
|
||||
)}
|
||||
{node.name}
|
||||
</Link>
|
||||
{isSeedTenant(node) && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="flex-shrink-0 text-[10px]"
|
||||
>
|
||||
{t("ui.admin.tenants.seed_badge", "초기 설정")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const parentPath = tenantParentPathMap.get(node.id) ?? [];
|
||||
return (
|
||||
<p className="mt-0.5 truncate text-xs font-normal text-muted-foreground">
|
||||
{parentPath.length > 0
|
||||
? parentPath.join(" / ")
|
||||
: t("ui.admin.tenants.path.root", "최상위")}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
||||
className="whitespace-nowrap overflow-hidden pl-5"
|
||||
data-testid={`tenant-internal-id-${node.id}`}
|
||||
>
|
||||
{node.id}
|
||||
<code className="inline-block max-w-full overflow-hidden rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground text-ellipsis">
|
||||
{abbreviateUuid(node.id)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap overflow-visible">
|
||||
{(() => {
|
||||
const { primary, secondary } = splitTenantTypeLabel(
|
||||
getTenantTypeLabel(node.type),
|
||||
);
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col leading-tight">
|
||||
<span
|
||||
className={cn(
|
||||
"block max-w-full text-xs font-medium uppercase tracking-[0.04em]",
|
||||
getTenantTypeTextClass(node.type),
|
||||
)}
|
||||
>
|
||||
{primary}
|
||||
</span>
|
||||
{secondary ? (
|
||||
<span className="mt-0.5 block max-w-none whitespace-nowrap text-[11px] text-muted-foreground">
|
||||
{secondary}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap overflow-hidden">
|
||||
<code className="inline-flex max-w-full items-center overflow-hidden rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground text-ellipsis">
|
||||
{node.slug}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
{node.type}
|
||||
<Badge
|
||||
variant={node.status === "active" ? "default" : "muted"}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs uppercase",
|
||||
node.status === "active"
|
||||
? "border-transparent bg-blue-500 text-white hover:bg-blue-500/90 hover:text-white"
|
||||
: "border-border bg-secondary/60 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{node.status === "active"
|
||||
? t("ui.common.status.active", "활성")
|
||||
: t("ui.common.status.inactive", "비활성")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{node.slug}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={node.status === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
statusMutation.mutate({
|
||||
tenantId: node.id,
|
||||
status: checked ? "active" : "inactive",
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending ||
|
||||
node.id === profile?.tenantId ||
|
||||
isSeedTenant(node)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.tenants.toggle_status",
|
||||
"{{name}} 활성 상태",
|
||||
{ name: node.name },
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(`ui.common.status.${node.status}`, node.status)}
|
||||
<TableCell className="whitespace-nowrap pl-3">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-medium">
|
||||
{t("ui.admin.tenants.table.members_count", "{{count}}명", {
|
||||
count: node.recursiveMemberCount,
|
||||
})}
|
||||
</span>
|
||||
<span className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("ui.admin.tenants.table.members_recursive", "하위 포함")}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{node.recursiveMemberCount}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{node.updatedAt
|
||||
? new Date(node.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
<TableCell className="whitespace-nowrap text-right pl-1">
|
||||
{node.updatedAt ? (
|
||||
<div className="flex flex-col items-end leading-tight">
|
||||
<span className="text-xs">
|
||||
{new Date(node.updatedAt).toLocaleDateString("ko-KR")}
|
||||
</span>
|
||||
<span className="mt-0.5 text-xs text-muted-foreground">
|
||||
{new Date(node.updatedAt).toLocaleTimeString("ko-KR")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-1 flex-col overflow-hidden rounded-md border">
|
||||
<div className="flex flex-1 flex-col overflow-hidden rounded-md border">
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="custom-scrollbar relative flex-1 overflow-auto"
|
||||
data-testid="tenant-table-container"
|
||||
>
|
||||
<Table className="relative min-w-[1180px] border-separate border-spacing-0">
|
||||
<Table
|
||||
className="relative border-separate border-spacing-0"
|
||||
style={{ display: "grid", minWidth: tenantTableMinWidth }}
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[48px] whitespace-nowrap px-4 text-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
deletableTenants.length > 0 &&
|
||||
visibleSelectedCount === deletableTenants.length
|
||||
}
|
||||
onCheckedChange={(checked) => onSelectAll(!!checked)}
|
||||
/>
|
||||
<TableRow
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: tenantTableGridTemplateColumns,
|
||||
minWidth: tenantTableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableHead
|
||||
className={`${tenantTableHeadClassName} w-[48px] text-center`}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
deletableTenants.length > 0 &&
|
||||
visibleSelectedCount === deletableTenants.length
|
||||
}
|
||||
onCheckedChange={(checked) => onSelectAll(!!checked)}
|
||||
/>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[280px] cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={`${tenantTableHeadInteractiveClassName} min-w-[500px]`}
|
||||
onClick={() => requestSort("name")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={tenantTableHeadContentClassName}>
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[220px] cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={`${tenantTableHeadInteractiveClassName} min-w-[220px] pl-5`}
|
||||
onClick={() => requestSort("id")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={tenantTableHeadContentClassName}>
|
||||
{t("ui.admin.tenants.table.id", "ID")}
|
||||
{getSortIcon("id")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={`${tenantTableHeadInteractiveClassName} pl-5`}
|
||||
onClick={() => requestSort("type")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={tenantTableHeadContentClassName}>
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
{getSortIcon("type")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={`${tenantTableHeadInteractiveClassName} pl-5`}
|
||||
onClick={() => requestSort("slug")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={tenantTableHeadContentClassName}>
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
{getSortIcon("slug")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={`${tenantTableHeadInteractiveClassName} pl-5`}
|
||||
onClick={() => requestSort("status")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={tenantTableHeadContentClassName}>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={tenantTableHeadInteractiveClassName}
|
||||
onClick={() => requestSort("recursiveMemberCount")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className={tenantTableHeadContentClassName}>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
{getSortIcon("recursiveMemberCount")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer whitespace-nowrap transition-colors hover:bg-muted/50"
|
||||
className={tenantTableHeadInteractiveClassName}
|
||||
onClick={() => requestSort("updatedAt")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`${tenantTableHeadContentClassName} justify-end`}
|
||||
>
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
{getSortIcon("updatedAt")}
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="relative">
|
||||
{rowVirtualizer.getTotalSize() > 0 &&
|
||||
virtualRows.length > 0 &&
|
||||
!(isTest && flattenedRows.length < 100) && (
|
||||
<tr style={{ height: `${virtualRows[0].start}px` }}>
|
||||
<td colSpan={8} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
<TableBody
|
||||
className="relative"
|
||||
style={
|
||||
shouldVirtualizeRows
|
||||
? {
|
||||
display: "grid",
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
minWidth: tenantTableMinWidth,
|
||||
position: "relative",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{flattenedRows.length === 0 && !isLoading && (
|
||||
<TableRow>
|
||||
<TableRow
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: tenantTableGridTemplateColumns,
|
||||
minWidth: tenantTableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
className="py-8 text-center text-muted-foreground"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.empty",
|
||||
"아직 등록된 테넌트가 없습니다.",
|
||||
)}
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{isTest && flattenedRows.length < 100
|
||||
{!shouldVirtualizeRows
|
||||
? flattenedRows.map((row, index) => renderRow(row, index))
|
||||
: virtualRows.map((virtualRow) =>
|
||||
renderRow(
|
||||
@@ -1927,20 +2087,14 @@ const TenantHierarchyView: React.FC<{
|
||||
),
|
||||
)}
|
||||
|
||||
{rowVirtualizer.getTotalSize() > 0 &&
|
||||
virtualRows.length > 0 &&
|
||||
!(isTest && flattenedRows.length < 100) && (
|
||||
<tr
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end}px`,
|
||||
}}
|
||||
>
|
||||
<td colSpan={8} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{isFetchingNextPage && (
|
||||
<TableRow>
|
||||
<TableRow
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: tenantTableGridTemplateColumns,
|
||||
minWidth: tenantTableMinWidth,
|
||||
}}
|
||||
>
|
||||
<TableCell colSpan={8} className="py-4 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
|
||||
@@ -49,7 +49,11 @@ import {
|
||||
type UserCreateResponse,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
canManageTenantScopedUsers,
|
||||
isSuperAdminRole,
|
||||
normalizeAdminRole,
|
||||
} from "../../lib/roles";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
@@ -154,6 +158,7 @@ function UserCreatePage() {
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canManageUsers = canManageTenantScopedUsers(profile);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -204,8 +209,12 @@ function UserCreatePage() {
|
||||
|
||||
// Lock company for non-super_admin
|
||||
React.useEffect(() => {
|
||||
if (profileRole !== "super_admin" && profile?.tenantSlug) {
|
||||
setValue("tenantSlug", profile.tenantSlug);
|
||||
if (profileRole !== "super_admin") {
|
||||
const delegatedTenantSlug =
|
||||
profile?.tenantSlug || profile?.manageableTenants?.[0]?.slug;
|
||||
if (delegatedTenantSlug) {
|
||||
setValue("tenantSlug", delegatedTenantSlug);
|
||||
}
|
||||
}
|
||||
}, [profile, profileRole, setValue]);
|
||||
|
||||
@@ -524,8 +533,7 @@ function UserCreatePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Access Control: Only super_admin can create users
|
||||
if (profile && profileRole !== "super_admin") {
|
||||
if (profile && !canManageUsers) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<ShieldAlert size={48} className="text-destructive" />
|
||||
|
||||
@@ -75,7 +75,10 @@ import {
|
||||
updateUser,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
canManageUserInTenantScope,
|
||||
normalizeAdminRole,
|
||||
} from "../../lib/roles";
|
||||
import { generateSecurePassword } from "../../lib/utils";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
@@ -472,6 +475,7 @@ function UserDetailPage() {
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const isAdmin = profileRole === "super_admin";
|
||||
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
||||
const canManageCurrentUser = canManageUserInTenantScope({ profile, user });
|
||||
const watchedStatus = watch("status");
|
||||
|
||||
const [newSubEmail, setNewSubEmail] = React.useState("");
|
||||
@@ -999,8 +1003,7 @@ function UserDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Access Control: Only super_admin or self can view details
|
||||
if (!isAdmin && !isSelf) {
|
||||
if (!isAdmin && !isSelf && !canManageCurrentUser) {
|
||||
return (
|
||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||
<ShieldAlert size={48} className="text-destructive" />
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("UserListPage search rendering", () => {
|
||||
renderUserListPage();
|
||||
|
||||
await screen.findByText("User 0");
|
||||
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
|
||||
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색");
|
||||
const renderCountBeforeTyping = selectRenderCounter.count;
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "u" } });
|
||||
@@ -179,7 +179,7 @@ describe("UserListPage search rendering", () => {
|
||||
renderUserListPage();
|
||||
|
||||
await screen.findByText("User 0");
|
||||
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
|
||||
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색");
|
||||
const startedAt = performance.now();
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "user 19" } });
|
||||
@@ -189,4 +189,19 @@ describe("UserListPage search rendering", () => {
|
||||
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
|
||||
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
|
||||
});
|
||||
|
||||
it("keeps rendered form fields identifiable for browser autofill diagnostics", async () => {
|
||||
const { container } = renderUserListPage();
|
||||
|
||||
await screen.findByText("User 0");
|
||||
const anonymousFields = Array.from(
|
||||
container.querySelectorAll("input, select, textarea"),
|
||||
).filter(
|
||||
(field) =>
|
||||
!field.getAttribute("id")?.trim() &&
|
||||
!field.getAttribute("name")?.trim(),
|
||||
);
|
||||
|
||||
expect(anonymousFields).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,12 +204,14 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
<SearchFilterBar
|
||||
primary={
|
||||
<>
|
||||
<div className="relative w-48">
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="user-list-search"
|
||||
name="user-list-search"
|
||||
placeholder={t(
|
||||
"ui.admin.users.list.search_placeholder",
|
||||
"이름 또는 이메일 검색...",
|
||||
"이름 또는 이메일 검색",
|
||||
)}
|
||||
className="h-9 pl-9"
|
||||
value={localSearch}
|
||||
@@ -223,6 +225,8 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
||||
</div>
|
||||
|
||||
<select
|
||||
id="user-list-tenant-filter"
|
||||
name="user-list-tenant-filter"
|
||||
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
value={selectedCompany}
|
||||
onChange={(event) => onCompanyChange(event.target.value)}
|
||||
@@ -727,6 +731,7 @@ function UserListPage() {
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg p-2 hover:bg-muted/50"
|
||||
>
|
||||
<input
|
||||
name={`user-list-column-${field.key}`}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
checked={visibleColumns[field.key] !== false}
|
||||
@@ -802,6 +807,7 @@ function UserListPage() {
|
||||
<TableHead className={`${userTableHeadClassName} w-12`}>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<input
|
||||
name="user-list-select-all"
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||
checked={
|
||||
@@ -980,6 +986,7 @@ function UserListPage() {
|
||||
>
|
||||
<TableCell>
|
||||
<input
|
||||
name={`user-list-select-${user.id}`}
|
||||
type="checkbox"
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
@@ -998,7 +1005,7 @@ function UserListPage() {
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/users/${user.id}`}
|
||||
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
|
||||
className="block max-w-[150px] truncate font-medium text-foreground transition-colors hover:text-primary hover:underline"
|
||||
title={user.name}
|
||||
>
|
||||
{user.name}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from "axios";
|
||||
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
||||
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
|
||||
import { userManager } from "./auth";
|
||||
import { clearAdminAuthSession, userManager } from "./auth";
|
||||
|
||||
let isRedirectingToLogin = false;
|
||||
|
||||
@@ -50,12 +50,7 @@ apiClient.interceptors.response.use(
|
||||
"[apiClient] 401 Unauthorized detected. Clearing session state.",
|
||||
);
|
||||
|
||||
// 로컬 스토리지의 세션 키 제거
|
||||
window.localStorage.removeItem("admin_session");
|
||||
|
||||
// oidc-client의 유저 상태도 제거하여 isAuthenticated를 false로 만듭니다.
|
||||
// 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다.
|
||||
await userManager.removeUser();
|
||||
await clearAdminAuthSession();
|
||||
|
||||
if (
|
||||
shouldStartLoginRedirect({
|
||||
|
||||
@@ -21,3 +21,31 @@ export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
|
||||
export const userManager = new UserManager(
|
||||
buildCommonUserManagerSettings(oidcConfig),
|
||||
);
|
||||
|
||||
export function clearStoredAdminAuthSession(
|
||||
storage: Storage = window.localStorage,
|
||||
) {
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (
|
||||
key &&
|
||||
(key === "admin_session" ||
|
||||
key.startsWith("oidc.user:") ||
|
||||
key.startsWith("oidc.state") ||
|
||||
key.startsWith("oidc.signin"))
|
||||
) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
storage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearAdminAuthSession() {
|
||||
clearStoredAdminAuthSession();
|
||||
await userManager.removeUser();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canManageTenantScopedUsers,
|
||||
canManageUserInTenantScope,
|
||||
isSuperAdminRole,
|
||||
normalizeAdminRole,
|
||||
ROLE_SUPER_ADMIN,
|
||||
@@ -32,4 +34,43 @@ describe("admin role helpers", () => {
|
||||
expect(isSuperAdminRole("admin")).toBe(false);
|
||||
expect(isSuperAdminRole(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows delegated tenant admins with manageable tenants to manage scoped users", () => {
|
||||
const profile = {
|
||||
id: "admin-user",
|
||||
role: "user",
|
||||
manageableTenants: [{ id: "tenant-1", slug: "tenant-a" }],
|
||||
};
|
||||
|
||||
expect(canManageTenantScopedUsers(profile)).toBe(true);
|
||||
expect(
|
||||
canManageUserInTenantScope({
|
||||
profile,
|
||||
user: { id: "user-1", tenantSlug: "tenant-a" },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canManageUserInTenantScope({
|
||||
profile,
|
||||
user: { id: "user-2", tenantSlug: "tenant-b" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat ordinary tenant membership as delegated user management", () => {
|
||||
const profile = {
|
||||
id: "member-user",
|
||||
role: "user",
|
||||
tenantSlug: "tenant-a",
|
||||
manageableTenants: [],
|
||||
};
|
||||
|
||||
expect(canManageTenantScopedUsers(profile)).toBe(false);
|
||||
expect(
|
||||
canManageUserInTenantScope({
|
||||
profile,
|
||||
user: { id: "user-1", tenantSlug: "tenant-a" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,21 @@ export const ROLE_USER = "user";
|
||||
|
||||
export type AdminRole = typeof ROLE_SUPER_ADMIN | typeof ROLE_USER;
|
||||
|
||||
export type TenantAccessSubject = {
|
||||
id?: string | null;
|
||||
role?: string | null;
|
||||
tenantId?: string | null;
|
||||
tenantSlug?: string | null;
|
||||
tenant?: {
|
||||
id?: string | null;
|
||||
slug?: string | null;
|
||||
} | null;
|
||||
manageableTenants?: Array<{
|
||||
id?: string | null;
|
||||
slug?: string | null;
|
||||
}> | null;
|
||||
};
|
||||
|
||||
export function normalizeAdminRole(role?: string | null): AdminRole {
|
||||
const normalized = role?.trim().toLowerCase() ?? "";
|
||||
|
||||
@@ -30,3 +45,60 @@ export function normalizeAdminRole(role?: string | null): AdminRole {
|
||||
export function isSuperAdminRole(role?: string | null) {
|
||||
return normalizeAdminRole(role) === ROLE_SUPER_ADMIN;
|
||||
}
|
||||
|
||||
function normalizeTenantAccessKey(value?: string | null) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
export function getManageableTenantAccessKeys(
|
||||
profile?: TenantAccessSubject | null,
|
||||
) {
|
||||
const keys = new Set<string>();
|
||||
for (const tenant of profile?.manageableTenants ?? []) {
|
||||
const id = normalizeTenantAccessKey(tenant.id);
|
||||
const slug = normalizeTenantAccessKey(tenant.slug);
|
||||
if (id) keys.add(id);
|
||||
if (slug) keys.add(slug);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function canManageTenantScopedUsers(
|
||||
profile?: TenantAccessSubject | null,
|
||||
) {
|
||||
return (
|
||||
isSuperAdminRole(profile?.role) ||
|
||||
getManageableTenantAccessKeys(profile).size > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function canManageUserInTenantScope({
|
||||
profile,
|
||||
user,
|
||||
}: {
|
||||
profile?: TenantAccessSubject | null;
|
||||
user?: TenantAccessSubject | null;
|
||||
}) {
|
||||
if (isSuperAdminRole(profile?.role)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (profile?.id && user?.id && profile.id === user.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const manageableKeys = getManageableTenantAccessKeys(profile);
|
||||
if (manageableKeys.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userTenantKeys = [
|
||||
normalizeTenantAccessKey(user?.tenantId),
|
||||
normalizeTenantAccessKey(user?.tenantSlug),
|
||||
normalizeTenantAccessKey(user?.tenant?.id),
|
||||
normalizeTenantAccessKey(user?.tenant?.slug),
|
||||
];
|
||||
|
||||
return userTenantKeys.some((key) => key !== null && manageableKeys.has(key));
|
||||
}
|
||||
|
||||
@@ -1071,6 +1071,7 @@ user = "General User (Tenant Member)"
|
||||
[ui.admin.tenants]
|
||||
add = "Add Tenant"
|
||||
csv_template = "Template"
|
||||
data_mgmt = "Data Management"
|
||||
delete_selected = "Delete Selected"
|
||||
export_with_ids = "Include UUIDs"
|
||||
export_without_ids = "Export without UUIDs"
|
||||
@@ -1267,10 +1268,21 @@ name = "NAME"
|
||||
slug = "SLUG"
|
||||
status = "STATUS"
|
||||
|
||||
[ui.admin.tenants.view]
|
||||
list = "List"
|
||||
table = "Table"
|
||||
tree = "Tree"
|
||||
|
||||
[ui.admin.tenants.scope]
|
||||
active = "{{name}} descendants"
|
||||
pick = "Select parent scope"
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
actions = "ACTIONS"
|
||||
id = "ID"
|
||||
members_count = "{{count}} members"
|
||||
members = "Members"
|
||||
members_recursive = "Includes descendants"
|
||||
name = "NAME"
|
||||
slug = "SLUG"
|
||||
status = "STATUS"
|
||||
@@ -1389,7 +1401,7 @@ change_status = "Change {{name}} status"
|
||||
empty = "No users found."
|
||||
fetch_error = "Failed to fetch user list."
|
||||
search_label = "Search Users"
|
||||
search_placeholder = "Search by name or email..."
|
||||
search_placeholder = "Search by name or email"
|
||||
subtitle = "View and manage system users."
|
||||
toggle_status = "{{name}} active status"
|
||||
title = "User Management"
|
||||
@@ -1424,7 +1436,7 @@ remove_success = "Successfully excluded from organization."
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
search_label = "Search Tenants"
|
||||
search_placeholder = "Search by name or slug..."
|
||||
search_placeholder = "Search by name, slug, or ID"
|
||||
title = "Tenant List"
|
||||
|
||||
[ui.admin.users.list.breadcrumb]
|
||||
@@ -1442,12 +1454,18 @@ count = "Registered users"
|
||||
title = "User Registry"
|
||||
|
||||
[ui.admin.users.list.table]
|
||||
actions = "ACTIONS"
|
||||
created = "CREATED"
|
||||
name_email = "NAME / EMAIL"
|
||||
role = "ROLE"
|
||||
status = "STATUS"
|
||||
tenant_dept = "TENANT / DEPT"
|
||||
actions = "Actions"
|
||||
created = "Created"
|
||||
email = "Email"
|
||||
id = "ID"
|
||||
name = "Name"
|
||||
phone = "Phone"
|
||||
role = "Role"
|
||||
status = "Status"
|
||||
tenant_dept = "Tenant / Dept"
|
||||
|
||||
[ui.admin.users]
|
||||
data_mgmt = "Data Management"
|
||||
|
||||
[ui.admin.users.table]
|
||||
email = "Email"
|
||||
@@ -1531,6 +1549,10 @@ unknown_name = "Unknown User"
|
||||
logout = "Logout"
|
||||
profile = "My Profile"
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = "Collapse sidebar"
|
||||
expand = "Expand sidebar"
|
||||
|
||||
[ui.shell.role]
|
||||
rp_admin = "Service Administrator (RP Admin)"
|
||||
super_admin = "System Administrator (Super Admin)"
|
||||
|
||||
@@ -1074,6 +1074,7 @@ user = "일반 사용자 (Tenant Member)"
|
||||
[ui.admin.tenants]
|
||||
add = "테넌트 추가"
|
||||
csv_template = "템플릿"
|
||||
data_mgmt = "데이터 관리"
|
||||
delete_selected = "선택 삭제"
|
||||
export_with_ids = "UUID 포함"
|
||||
export_without_ids = "UUID 제외 내보내기"
|
||||
@@ -1270,15 +1271,26 @@ name = "NAME"
|
||||
slug = "SLUG"
|
||||
status = "STATUS"
|
||||
|
||||
[ui.admin.tenants.view]
|
||||
list = "평면 목록"
|
||||
table = "평면"
|
||||
tree = "트리"
|
||||
|
||||
[ui.admin.tenants.scope]
|
||||
active = "{{name}} 하위"
|
||||
pick = "상위 범위 선택"
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
actions = "ACTIONS"
|
||||
id = "ID"
|
||||
members_count = "{{count}}명"
|
||||
members = "멤버수"
|
||||
name = "NAME"
|
||||
slug = "SLUG"
|
||||
status = "STATUS"
|
||||
members_recursive = "하위 포함"
|
||||
name = "이름"
|
||||
slug = "슬러그"
|
||||
status = "상태"
|
||||
type = "유형"
|
||||
updated = "UPDATED"
|
||||
updated = "수정일"
|
||||
|
||||
[ui.admin.users]
|
||||
csv_template = "템플릿 다운로드"
|
||||
@@ -1392,7 +1404,7 @@ change_status = "{{name}} 상태 변경"
|
||||
empty = "검색 결과가 없습니다."
|
||||
fetch_error = "사용자 목록 조회에 실패했습니다."
|
||||
search_label = "사용자 검색"
|
||||
search_placeholder = "이름 또는 이메일 검색..."
|
||||
search_placeholder = "이름 또는 이메일 검색"
|
||||
subtitle = "시스템 사용자를 조회하고 관리합니다."
|
||||
toggle_status = "{{name}} 활성 상태"
|
||||
title = "사용자 관리"
|
||||
@@ -1427,7 +1439,7 @@ remove_success = "조직에서 제외되었습니다."
|
||||
|
||||
[ui.admin.tenants.list]
|
||||
search_label = "테넌트 검색"
|
||||
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
|
||||
search_placeholder = "이름 또는 슬러그, ID 검색"
|
||||
title = "테넌트 목록"
|
||||
|
||||
[ui.admin.users.list.breadcrumb]
|
||||
@@ -1445,12 +1457,18 @@ count = "총 {{count}}명의 사용자가 등록되어 있습니다."
|
||||
title = "사용자 레지스트리"
|
||||
|
||||
[ui.admin.users.list.table]
|
||||
actions = "ACTIONS"
|
||||
created = "CREATED"
|
||||
name_email = "NAME / EMAIL"
|
||||
role = "ROLE"
|
||||
status = "STATUS"
|
||||
tenant_dept = "TENANT / DEPT"
|
||||
actions = "액션"
|
||||
created = "등록일"
|
||||
email = "이메일"
|
||||
id = "ID"
|
||||
name = "이름"
|
||||
phone = "전화번호"
|
||||
role = "역할"
|
||||
status = "상태"
|
||||
tenant_dept = "테넌트 / 부서"
|
||||
|
||||
[ui.admin.users]
|
||||
data_mgmt = "데이터 관리"
|
||||
|
||||
[ui.admin.users.table]
|
||||
email = "이메일"
|
||||
@@ -1534,6 +1552,10 @@ unknown_name = "Unknown User"
|
||||
logout = "Logout"
|
||||
profile = "내 정보"
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = "사이드바 접기"
|
||||
expand = "사이드바 펼치기"
|
||||
|
||||
[ui.shell.role]
|
||||
rp_admin = "서비스 관리자 (RP Admin)"
|
||||
super_admin = "시스템 관리자 (Super Admin)"
|
||||
|
||||
@@ -1291,6 +1291,8 @@ slug = ""
|
||||
status = ""
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
members_count = ""
|
||||
members_recursive = ""
|
||||
actions = ""
|
||||
id = ""
|
||||
members = ""
|
||||
@@ -1426,11 +1428,17 @@ title = ""
|
||||
[ui.admin.users.list.table]
|
||||
actions = ""
|
||||
created = ""
|
||||
name_email = ""
|
||||
email = ""
|
||||
id = ""
|
||||
name = ""
|
||||
phone = ""
|
||||
role = ""
|
||||
status = ""
|
||||
tenant_dept = ""
|
||||
|
||||
[ui.admin.users]
|
||||
data_mgmt = ""
|
||||
|
||||
[ui.admin.users.table]
|
||||
email = ""
|
||||
name = ""
|
||||
@@ -1513,6 +1521,10 @@ unknown_name = ""
|
||||
logout = ""
|
||||
profile = ""
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = ""
|
||||
expand = ""
|
||||
|
||||
[ui.shell.role]
|
||||
rp_admin = ""
|
||||
super_admin = ""
|
||||
|
||||
@@ -126,7 +126,7 @@ test.describe("Authentication", () => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("link", { name: "조직도" })).toHaveAttribute(
|
||||
"href",
|
||||
"http://localhost:5175/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue",
|
||||
/\/login\?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue$/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
||||
page.on("console", (msg) => console.log(`[PAGE] ${msg.text()}`));
|
||||
});
|
||||
|
||||
const setupAuth = async (page, role: string) => {
|
||||
const setupAuth = async (
|
||||
page,
|
||||
role: string,
|
||||
profileOverrides: Record<string, unknown> = {},
|
||||
) => {
|
||||
// 1. Inject initial state and mock tokens
|
||||
await page.addInitScript(
|
||||
({ role }) => {
|
||||
@@ -76,6 +80,7 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
||||
email: "test@example.com",
|
||||
role: role,
|
||||
manageableTenants: [],
|
||||
...profileOverrides,
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
@@ -95,6 +100,28 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} else if (url.match(/\/admin\/users\/u1$/)) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: "u1",
|
||||
name: "사용자 1",
|
||||
email: "u1@example.com",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantId: "t1",
|
||||
tenantSlug: "t1",
|
||||
tenant: {
|
||||
id: "t1",
|
||||
name: "테넌트 1",
|
||||
slug: "t1",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
} else if (url.includes("/rp-history")) {
|
||||
await route.fulfill({
|
||||
json: [],
|
||||
@@ -218,4 +245,52 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("테넌트 관리자 권한", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupAuth(page, "tenant_admin", {
|
||||
tenantId: "t1",
|
||||
tenantSlug: "t1",
|
||||
manageableTenants: [
|
||||
{
|
||||
id: "t1",
|
||||
name: "테넌트 1",
|
||||
slug: "t1",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await expect(page.locator("aside")).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("사용자 관리 목록에 접근 가능해야 함", async ({ page }) => {
|
||||
await page.goto("/users");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("page-title").filter({ hasText: /사용자 관리/i }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("사용자 1")).toBeVisible();
|
||||
});
|
||||
|
||||
test("사용자 생성 화면에 접근 가능해야 함", async ({ page }) => {
|
||||
await page.goto("/users/new");
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "사용자 추가" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("관리 대상 테넌트 사용자 상세에 접근 가능해야 함", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/users/u1");
|
||||
|
||||
await expect(page.getByText("사용자 1")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/이 작업을 수행할 권한이 없습니다/i),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,9 +107,11 @@ test.describe("Tenants Management", () => {
|
||||
await expect(page.locator("table")).toContainText("Tenant A", {
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.locator("table")).toContainText(internalTenantId);
|
||||
await expect(
|
||||
page.getByTestId(`tenant-internal-id-${internalTenantId}`),
|
||||
).toHaveText("c5839444-2de0-4a37-99b0-...");
|
||||
await expect(page.locator("table")).toContainText("COMPANY");
|
||||
await expect(page.locator("table")).not.toContainText("일반 기업");
|
||||
await expect(page.locator("table")).toContainText("일반 기업");
|
||||
|
||||
const headerWhiteSpace = await page
|
||||
.locator("table thead th")
|
||||
@@ -188,16 +190,14 @@ test.describe("Tenants Management", () => {
|
||||
await page.goto("/tenants");
|
||||
|
||||
await page
|
||||
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
|
||||
.getByPlaceholder(/이름 또는 슬러그, ID 검색|search/i)
|
||||
.fill("team-1");
|
||||
await expect(page.locator("table")).toContainText("Platform");
|
||||
|
||||
await page.getByPlaceholder(/이름 또는 슬러그, ID 검색|search/i).fill("");
|
||||
await page
|
||||
.getByPlaceholder(/테넌트 이름 또는 슬러그 검색|search/i)
|
||||
.fill("");
|
||||
await page
|
||||
.locator("tbody tr")
|
||||
.filter({ hasText: "Planning" })
|
||||
.getByTestId("tenant-internal-id-dept-1")
|
||||
.locator("xpath=ancestor::tr")
|
||||
.getByRole("checkbox")
|
||||
.click();
|
||||
|
||||
@@ -291,8 +291,8 @@ test.describe("Tenants Management", () => {
|
||||
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("");
|
||||
await page.keyboard.press("Enter");
|
||||
await page
|
||||
.locator("tbody tr")
|
||||
.filter({ hasText: "Acme" })
|
||||
.getByTestId("tenant-internal-id-company-1")
|
||||
.locator("xpath=ancestor::tr")
|
||||
.getByRole("checkbox")
|
||||
.click();
|
||||
|
||||
@@ -363,7 +363,7 @@ test.describe("Tenants Management", () => {
|
||||
await page.goto("/tenants");
|
||||
|
||||
await expect(
|
||||
page.getByText("총 501개의 테넌트가 등록되어 있습니다."),
|
||||
page.getByText("총 500개의 테넌트가 등록되어 있습니다."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "더 불러오기" })).toHaveCount(
|
||||
0,
|
||||
|
||||
@@ -602,11 +602,11 @@ test.describe("User Management", () => {
|
||||
await expect(page.getByText("Load User 0")).toBeVisible();
|
||||
const initialMs = performance.now() - initialStartedAt;
|
||||
|
||||
const searchInput = page.getByPlaceholder("이름 또는 이메일 검색...");
|
||||
const searchInput = page.getByPlaceholder("이름 또는 이메일 검색");
|
||||
await searchInput.fill("Load User 19999");
|
||||
const searchMs = await page.evaluate(async () => {
|
||||
const input = Array.from(document.querySelectorAll("input")).find(
|
||||
(candidate) => candidate.placeholder === "이름 또는 이메일 검색...",
|
||||
(candidate) => candidate.placeholder === "이름 또는 이메일 검색",
|
||||
);
|
||||
|
||||
if (!input) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { getCommonBadgeClasses } from "../../../ui/badge";
|
||||
import type { CommonBadgeVariant } from "../../../ui/badge";
|
||||
import {
|
||||
getCommonBadgeClasses,
|
||||
type CommonBadgeVariant,
|
||||
} from "../../../ui/badge";
|
||||
import { getCommonButtonClasses } from "../../../ui/button";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Menu, SquareMenu } from "lucide-react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { shellLayoutClasses } from "./layout";
|
||||
|
||||
@@ -14,9 +15,13 @@ export type ShellSidebarNavItem = {
|
||||
type ShellSidebarProps = {
|
||||
brandLabel: string;
|
||||
brandTitle: string;
|
||||
brandIcon: ReactNode;
|
||||
brandIcon?: ReactNode;
|
||||
navContent: ReactNode;
|
||||
footerContent: ReactNode;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
collapseLabel?: string;
|
||||
expandLabel?: string;
|
||||
};
|
||||
|
||||
export function AppSidebar({
|
||||
@@ -25,14 +30,57 @@ export function AppSidebar({
|
||||
brandIcon,
|
||||
navContent,
|
||||
footerContent,
|
||||
collapsed = false,
|
||||
onToggleCollapsed,
|
||||
collapseLabel = "Collapse sidebar",
|
||||
expandLabel = "Expand sidebar",
|
||||
}: ShellSidebarProps) {
|
||||
return (
|
||||
<aside className={shellLayoutClasses.aside}>
|
||||
<aside
|
||||
className={
|
||||
collapsed ? shellLayoutClasses.asideCollapsed : shellLayoutClasses.aside
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div className={shellLayoutClasses.brandSection}>
|
||||
<div className={shellLayoutClasses.brandWrap}>
|
||||
<div className={shellLayoutClasses.brandIcon}>{brandIcon}</div>
|
||||
<div>
|
||||
<div
|
||||
className={
|
||||
collapsed
|
||||
? shellLayoutClasses.brandSectionCollapsed
|
||||
: shellLayoutClasses.brandSection
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
collapsed
|
||||
? shellLayoutClasses.brandWrapCollapsed
|
||||
: shellLayoutClasses.brandWrap
|
||||
}
|
||||
>
|
||||
{onToggleCollapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
className="grid h-11 w-11 place-items-center rounded-xl border border-border bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)] transition hover:bg-primary/20"
|
||||
aria-label={collapsed ? expandLabel : collapseLabel}
|
||||
title={collapsed ? expandLabel : collapseLabel}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{collapsed ? expandLabel : collapseLabel}
|
||||
</span>
|
||||
{collapsed ? <Menu size={20} /> : <SquareMenu size={20} />}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
collapsed
|
||||
? shellLayoutClasses.brandIconCollapsed
|
||||
: shellLayoutClasses.brandIcon
|
||||
}
|
||||
>
|
||||
{brandIcon}
|
||||
</div>
|
||||
)}
|
||||
<div className={collapsed ? "hidden" : "block"}>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{brandLabel}
|
||||
</p>
|
||||
@@ -40,7 +88,15 @@ export function AppSidebar({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className={shellLayoutClasses.navWrap}>{navContent}</nav>
|
||||
<nav
|
||||
className={
|
||||
collapsed
|
||||
? shellLayoutClasses.navWrapCollapsed
|
||||
: shellLayoutClasses.navWrap
|
||||
}
|
||||
>
|
||||
{navContent}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div>{footerContent}</div>
|
||||
|
||||
@@ -27,6 +27,8 @@ type ShellProfileSummaryParams = {
|
||||
|
||||
export const SHELL_THEME_STORAGE_KEY = "admin_theme";
|
||||
export const SHELL_SESSION_EXPIRY_STORAGE_KEY = SESSION_EXPIRY_STORAGE_KEY;
|
||||
export const SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY =
|
||||
"baron_shell_sidebar_collapsed";
|
||||
export type { ShellSidebarNavItem } from "./AppSidebar";
|
||||
export { AppSidebar } from "./AppSidebar";
|
||||
export { shellLayoutClasses } from "./layout";
|
||||
@@ -52,6 +54,25 @@ export function writeShellSessionExpiryEnabled(isEnabled: boolean) {
|
||||
writeSessionExpiryEnabled(isEnabled);
|
||||
}
|
||||
|
||||
export function readShellSidebarCollapsed(defaultCollapsed = false) {
|
||||
const stored = window.localStorage.getItem(
|
||||
SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (stored === null) {
|
||||
return defaultCollapsed;
|
||||
}
|
||||
|
||||
return stored === "true";
|
||||
}
|
||||
|
||||
export function writeShellSidebarCollapsed(isCollapsed: boolean) {
|
||||
window.localStorage.setItem(
|
||||
SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY,
|
||||
String(isCollapsed),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildShellProfileSummary({
|
||||
profileName,
|
||||
profileEmail,
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
export const shellLayoutClasses = {
|
||||
root: "grid min-h-screen grid-cols-[240px,minmax(0,1fr)] bg-background text-foreground",
|
||||
rootCollapsed:
|
||||
"grid min-h-screen grid-cols-[80px,minmax(0,1fr)] bg-background text-foreground",
|
||||
aside:
|
||||
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
|
||||
asideCollapsed:
|
||||
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
|
||||
asideStatic:
|
||||
"sticky top-0 h-screen border-r border-border bg-card backdrop-blur",
|
||||
brandSection:
|
||||
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
|
||||
brandSectionCollapsed:
|
||||
"flex items-center justify-between px-3 py-4 md:block md:space-y-4 md:px-2 md:py-6",
|
||||
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
|
||||
brandWrapCollapsed: "flex items-center gap-3 md:flex-col md:items-center",
|
||||
brandIcon:
|
||||
"grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]",
|
||||
brandIconCollapsed:
|
||||
"grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]",
|
||||
scopeBadge:
|
||||
"hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2",
|
||||
navWrap: "px-2 pb-4 md:px-3 md:pb-8",
|
||||
navWrapCollapsed: "px-2 pb-4 md:px-2 md:pb-8",
|
||||
navMeta:
|
||||
"flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start",
|
||||
navList: "flex flex-col gap-1",
|
||||
navItemBase:
|
||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||
navItemBaseCollapsed:
|
||||
"flex items-center justify-center gap-0 rounded-xl px-3 py-3 text-sm transition",
|
||||
navItemActive:
|
||||
"bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]",
|
||||
navItemIdle: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||
@@ -24,6 +36,8 @@ export const shellLayoutClasses = {
|
||||
"hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block",
|
||||
logoutButton:
|
||||
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive",
|
||||
logoutButtonCollapsed:
|
||||
"flex w-full items-center justify-center gap-0 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive",
|
||||
header:
|
||||
"sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
|
||||
headerElevated:
|
||||
@@ -31,8 +45,11 @@ export const shellLayoutClasses = {
|
||||
headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
|
||||
headerTitleWrap: "flex flex-col gap-1",
|
||||
headerActions: "flex items-center gap-2 text-sm",
|
||||
headerActionsCollapsed: "flex items-center gap-2 text-sm",
|
||||
actionButton:
|
||||
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20",
|
||||
sidebarToggleButton:
|
||||
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20",
|
||||
sessionBadge:
|
||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||
profileInitial:
|
||||
|
||||
@@ -236,21 +236,12 @@ services:
|
||||
|
||||
# 기본 RP (Admin Front 등) 자동 등록 컨테이너
|
||||
init-rp:
|
||||
image: alpine:latest
|
||||
image: oryd/hydra:${HYDRA_CLI_VERSION:-v26.2.0}
|
||||
env_file:
|
||||
- .env
|
||||
entrypoint: ["/bin/sh", "-ec"]
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- |
|
||||
apk add --no-cache curl tar
|
||||
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
|
||||
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
|
||||
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
|
||||
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
|
||||
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
||||
rm /tmp/hydra.tar.gz
|
||||
|
||||
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" adminfront >/dev/null 2>&1 || true
|
||||
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" devfront >/dev/null 2>&1 || true
|
||||
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" orgfront >/dev/null 2>&1 || true
|
||||
@@ -270,21 +261,21 @@ services:
|
||||
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||
--id devfront \
|
||||
--name "DevFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
--token-endpoint-auth-method none \
|
||||
--redirect-uri ${DEVFRONT_CALLBACK_URLS}
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
--token-endpoint-auth-method none \
|
||||
--redirect-uri ${DEVFRONT_CALLBACK_URLS}
|
||||
|
||||
hydra create oauth2-client \
|
||||
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||
--id orgfront \
|
||||
--name "OrgFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
--token-endpoint-auth-method none \
|
||||
--redirect-uri ${ORGFRONT_CALLBACK_URLS}
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
--token-endpoint-auth-method none \
|
||||
--redirect-uri ${ORGFRONT_CALLBACK_URLS}
|
||||
|
||||
hydra create oauth2-client \
|
||||
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||
|
||||
82
config-restored/compose/compose.infra.yaml
Normal file
82
config-restored/compose/compose.infra.yaml
Normal file
@@ -0,0 +1,82 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: baron_postgres
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-baron}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||
POSTGRES_DB: ${DB_NAME:-baron_sso}
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./docker/init-metadata:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- baron_net
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${DB_USER:-baron} -d ${DB_NAME:-baron_sso}",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: always
|
||||
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:latest
|
||||
container_name: baron_clickhouse
|
||||
restart: always
|
||||
volumes:
|
||||
- clickhouse_data:/var/lib/clickhouse
|
||||
environment:
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-baron}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-password}
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: baron_redis
|
||||
restart: always
|
||||
command: redis-server --port 6389
|
||||
ports:
|
||||
- "6389:6389"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
gateway:
|
||||
build:
|
||||
context: ./gateway
|
||||
dockerfile: Dockerfile
|
||||
container_name: baron_gateway
|
||||
restart: always
|
||||
ports:
|
||||
- "${USERFRONT_PORT:-5000}:5000"
|
||||
networks:
|
||||
- baron_net
|
||||
- public_net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
clickhouse_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
baron_net:
|
||||
name: baron_net
|
||||
external: true
|
||||
driver: bridge
|
||||
public_net:
|
||||
name: public_net
|
||||
external: true
|
||||
|
||||
319
config-restored/compose/compose.ory.yaml
Normal file
319
config-restored/compose/compose.ory.yaml
Normal file
@@ -0,0 +1,319 @@
|
||||
services:
|
||||
postgres_ory:
|
||||
image: postgres:${ORY_POSTGRES_TAG:-17-alpine}
|
||||
container_name: ory_postgres
|
||||
environment:
|
||||
- POSTGRES_USER=${ORY_POSTGRES_USER:-ory}
|
||||
- POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD:-secret}
|
||||
- POSTGRES_DB=${ORY_POSTGRES_DB:-ory}
|
||||
volumes:
|
||||
- ./docker/ory/init-db:/docker-entrypoint-initdb.d
|
||||
- ory_postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- ory-net
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# --- Kratos ---
|
||||
kratos-migrate:
|
||||
image: oryd/kratos:${KRATOS_VERSION:-v26.2.0}
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
|
||||
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL}
|
||||
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL}
|
||||
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}
|
||||
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/","${USERFRONT_URL}","${USERFRONT_URL}/","${USERFRONT_URL}/ko","${USERFRONT_URL}/ko/","${USERFRONT_URL}/en","${USERFRONT_URL}/en/","${USERFRONT_URL}/auth/callback","${USERFRONT_URL}/ko/auth/callback","${USERFRONT_URL}/en/auth/callback","${ADMINFRONT_URL}/auth/callback","${DEVFRONT_URL}/auth/callback","${ORGFRONT_URL}/auth/callback"]}
|
||||
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error
|
||||
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled
|
||||
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery
|
||||
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification
|
||||
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login
|
||||
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration
|
||||
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login
|
||||
volumes:
|
||||
- ./config/.generated/ory/kratos:/etc/config/kratos
|
||||
command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes
|
||||
depends_on:
|
||||
postgres_ory:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
kratos:
|
||||
image: oryd/kratos:${KRATOS_VERSION:-v26.2.0}
|
||||
container_name: ory_kratos
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
|
||||
- COOKIE_SECRET=${COOKIE_SECRET:-localcookie123}
|
||||
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL}
|
||||
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL}
|
||||
- KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}
|
||||
- KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/","${USERFRONT_URL}","${USERFRONT_URL}/","${USERFRONT_URL}/ko","${USERFRONT_URL}/ko/","${USERFRONT_URL}/en","${USERFRONT_URL}/en/","${USERFRONT_URL}/auth/callback","${USERFRONT_URL}/ko/auth/callback","${USERFRONT_URL}/en/auth/callback","${ADMINFRONT_URL}/auth/callback","${DEVFRONT_URL}/auth/callback","${ORGFRONT_URL}/auth/callback"]}
|
||||
- KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error
|
||||
- KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled
|
||||
- KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery
|
||||
- KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification
|
||||
- KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login
|
||||
- KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration
|
||||
- KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login
|
||||
volumes:
|
||||
- ./config/.generated/ory/kratos:/etc/config/kratos
|
||||
command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier
|
||||
depends_on:
|
||||
kratos-migrate:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- ory-net
|
||||
- kratosnet
|
||||
|
||||
# --- Hydra ---
|
||||
hydra-migrate:
|
||||
image: oryd/hydra:${HYDRA_VERSION:-v26.2.0}
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
|
||||
command: migrate sql up -e --yes
|
||||
depends_on:
|
||||
postgres_ory:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
hydra:
|
||||
image: oryd/hydra:${HYDRA_VERSION:-v26.2.0}
|
||||
container_name: ory_hydra
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
|
||||
- URLS_SELF_ISSUER=${HYDRA_PUBLIC_URL}
|
||||
- URLS_LOGIN=${HYDRA_LOGIN_URL:-${USERFRONT_URL}/login}
|
||||
- URLS_CONSENT=${HYDRA_CONSENT_URL:-${USERFRONT_URL}/consent}
|
||||
- URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error}
|
||||
- SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- ./config/.generated/ory/hydra:/etc/config/hydra
|
||||
command: serve -c /etc/config/hydra/hydra.yml all --dev
|
||||
depends_on:
|
||||
hydra-migrate:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- ory-net
|
||||
- hydranet
|
||||
|
||||
# --- Keto ---
|
||||
keto-migrate:
|
||||
image: oryd/keto:${KETO_VERSION:-v26.2.0}
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
|
||||
volumes:
|
||||
- ./config/.generated/ory/keto:/etc/config/keto
|
||||
command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"]
|
||||
depends_on:
|
||||
postgres_ory:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
keto:
|
||||
image: oryd/keto:${KETO_VERSION:-v26.2.0}
|
||||
container_name: ory_keto
|
||||
environment:
|
||||
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
|
||||
volumes:
|
||||
- ./config/.generated/ory/keto:/etc/config/keto
|
||||
command: serve -c /etc/config/keto/keto.yml
|
||||
depends_on:
|
||||
keto-migrate:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
# --- Oathkeeper ---
|
||||
oathkeeper_logs_init:
|
||||
image: alpine:latest
|
||||
command:
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
"mkdir -p /var/log/oathkeeper && chown -R ${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001} /var/log/oathkeeper",
|
||||
]
|
||||
volumes:
|
||||
- oathkeeper_logs:/var/log/oathkeeper
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
oathkeeper:
|
||||
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v26.2.0}
|
||||
container_name: ory_oathkeeper
|
||||
user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}"
|
||||
ports:
|
||||
- "4457:4455" # Proxy
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-development}
|
||||
- LOG_LEVEL=debug
|
||||
- OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}
|
||||
- OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}
|
||||
volumes:
|
||||
- ./config/.generated/ory/oathkeeper:/etc/config/oathkeeper
|
||||
- oathkeeper_logs:/var/log/oathkeeper
|
||||
entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"]
|
||||
depends_on:
|
||||
oathkeeper_logs_init:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- ory-net
|
||||
- public_net
|
||||
|
||||
ory_clickhouse:
|
||||
image: clickhouse/clickhouse-server:latest
|
||||
container_name: ory_clickhouse
|
||||
environment:
|
||||
- CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory}
|
||||
- CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass}
|
||||
volumes:
|
||||
- ory_clickhouse_data:/var/lib/clickhouse
|
||||
- ./docker/ory/clickhouse:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
ory_vector:
|
||||
image: timberio/vector:0.36.0-alpine
|
||||
container_name: ory_vector
|
||||
environment:
|
||||
- ORY_CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory}
|
||||
- ORY_CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass}
|
||||
volumes:
|
||||
- ./docker/ory/vector:/etc/vector
|
||||
- oathkeeper_logs:/var/log/oathkeeper
|
||||
command: ["-c", "/etc/vector/vector.toml"]
|
||||
depends_on:
|
||||
- oathkeeper
|
||||
- ory_clickhouse
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
# --- 초기화 & 헬스체크 ---
|
||||
ory_stack_check:
|
||||
image: alpine:latest
|
||||
container_name: ory_stack_check
|
||||
command: >
|
||||
/bin/sh -c "
|
||||
apk add --no-cache curl;
|
||||
echo 'Wait for services...';
|
||||
check_ready() {
|
||||
name=\"$$1\";
|
||||
url=\"$$2\";
|
||||
max=\"$${ORY_STACK_CHECK_MAX_ATTEMPTS:-60}\";
|
||||
i=1;
|
||||
while [ \"$$i\" -le \"$$max\" ]; do
|
||||
if curl --connect-timeout 2 --max-time 3 -fsS \"$$url\" >/dev/null; then
|
||||
echo \"Ory service ready: $$name\";
|
||||
return 0;
|
||||
fi;
|
||||
echo \"Waiting for Ory service: $$name ($$i/$$max)\";
|
||||
i=$$((i + 1));
|
||||
sleep 1;
|
||||
done;
|
||||
echo \"ERROR: Ory service not ready: $$name after $$max attempts ($$url)\" >&2;
|
||||
echo \"ERROR: Check service logs: docker logs ory_$$name\" >&2;
|
||||
return 1;
|
||||
};
|
||||
check_ready kratos http://kratos:4433/health/ready || exit 1;
|
||||
check_ready hydra http://hydra:4444/health/ready || exit 1;
|
||||
check_ready keto http://keto:4466/health/ready || exit 1;
|
||||
echo 'Ory Stack is fully operational!';"
|
||||
depends_on:
|
||||
- kratos
|
||||
- hydra
|
||||
- keto
|
||||
networks:
|
||||
- ory-net
|
||||
|
||||
# 기본 RP (Admin Front 등) 자동 등록 컨테이너
|
||||
init-rp:
|
||||
image: alpine:latest
|
||||
env_file:
|
||||
- .env
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- |
|
||||
apk add --no-cache curl tar
|
||||
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
|
||||
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
|
||||
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
|
||||
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
|
||||
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
||||
rm /tmp/hydra.tar.gz
|
||||
|
||||
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" adminfront >/dev/null 2>&1 || true
|
||||
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" devfront >/dev/null 2>&1 || true
|
||||
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" orgfront >/dev/null 2>&1 || true
|
||||
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" >/dev/null 2>&1 || true
|
||||
|
||||
hydra create oauth2-client \
|
||||
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||
--id adminfront \
|
||||
--name "AdminFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
--token-endpoint-auth-method none \
|
||||
--redirect-uri ${ADMINFRONT_CALLBACK_URLS}
|
||||
|
||||
hydra create oauth2-client \
|
||||
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||
--id devfront \
|
||||
--name "DevFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
--token-endpoint-auth-method none \
|
||||
--redirect-uri ${DEVFRONT_CALLBACK_URLS}
|
||||
|
||||
hydra create oauth2-client \
|
||||
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||
--id orgfront \
|
||||
--name "OrgFront" \
|
||||
--grant-type authorization_code,refresh_token \
|
||||
--response-type code \
|
||||
--scope openid,offline_access,profile,email \
|
||||
--token-endpoint-auth-method none \
|
||||
--redirect-uri ${ORGFRONT_CALLBACK_URLS}
|
||||
|
||||
hydra create oauth2-client \
|
||||
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||
--id "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \
|
||||
--secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \
|
||||
--grant-type client_credentials \
|
||||
--response-type token \
|
||||
--scope openid,offline_access,profile,email
|
||||
depends_on:
|
||||
ory_stack_check:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- hydranet
|
||||
|
||||
volumes:
|
||||
ory_postgres_data:
|
||||
ory_clickhouse_data:
|
||||
oathkeeper_logs:
|
||||
|
||||
networks:
|
||||
ory-net:
|
||||
external: true
|
||||
name: ory-net
|
||||
hydranet:
|
||||
external: true
|
||||
name: hydranet
|
||||
kratosnet:
|
||||
external: true
|
||||
name: kratosnet
|
||||
public_net:
|
||||
external: true
|
||||
name: public_net
|
||||
184
config-restored/compose/docker-compose.yaml
Normal file
184
config-restored/compose/docker-compose.yaml
Normal file
@@ -0,0 +1,184 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: baron_backend
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-development}
|
||||
- GO_ENV=${APP_ENV:-development}
|
||||
- BACKEND_LOG_LEVEL=${BACKEND_LOG_LEVEL:-info}
|
||||
- CLIENT_LOG_DEBUG=${CLIENT_LOG_DEBUG:-false}
|
||||
- WORKS_ADMIN_API_BASE_URL=${WORKS_ADMIN_API_BASE_URL}
|
||||
- WORKS_ADMIN_OAUTH_TOKEN_URL=${WORKS_ADMIN_OAUTH_TOKEN_URL}
|
||||
- COOKIE_SECRET=${COOKIE_SECRET}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- 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}
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
- REDIS_ADDR=${REDIS_ADDR}
|
||||
- IDP_PROVIDER=${IDP_PROVIDER:-ory}
|
||||
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
|
||||
- HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL:-http://hydra:4444}
|
||||
- KETO_READ_URL=${KETO_READ_URL:-http://keto:4466}
|
||||
- KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467}
|
||||
- DB_HOST=postgres
|
||||
- CLICKHOUSE_HOST=clickhouse
|
||||
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
||||
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
|
||||
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
|
||||
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
|
||||
depends_on:
|
||||
- infra_check
|
||||
networks:
|
||||
- baron_net
|
||||
- ory-net
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./config:/app/config:ro
|
||||
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
|
||||
command: ["go", "run", "./cmd/server"]
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
adminfront:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./adminfront/Dockerfile
|
||||
container_name: baron_adminfront
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-development}
|
||||
- API_PROXY_TARGET=http://baron_backend:3000
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
|
||||
ports:
|
||||
- "${ADMINFRONT_PORT:-5173}:5173"
|
||||
volumes:
|
||||
- ./adminfront:/workspace/adminfront
|
||||
- ./common:/common
|
||||
- ./common:/workspace/common
|
||||
- /workspace/common/node_modules
|
||||
- ./locales:/locales
|
||||
- ./locales:/workspace/locales
|
||||
- /workspace/adminfront/node_modules
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
devfront:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./devfront/Dockerfile
|
||||
container_name: baron_devfront
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-development}
|
||||
- API_PROXY_TARGET=http://baron_backend:3000
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
|
||||
ports:
|
||||
- "${DEVFRONT_PORT:-5174}:5173"
|
||||
volumes:
|
||||
- ./devfront:/workspace/devfront
|
||||
- ./common:/common
|
||||
- ./common:/workspace/common
|
||||
- /workspace/common/node_modules
|
||||
- ./locales:/locales
|
||||
- ./locales:/workspace/locales
|
||||
- /workspace/devfront/node_modules
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
orgfront:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./orgfront/Dockerfile
|
||||
container_name: baron_orgfront
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-development}
|
||||
- API_PROXY_TARGET=http://baron_backend:3000
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
|
||||
ports:
|
||||
- "${ORGFRONT_PORT:-5175}:5175"
|
||||
volumes:
|
||||
- ./orgfront:/workspace/orgfront
|
||||
- ./common:/common
|
||||
- ./common:/workspace/common
|
||||
- /workspace/common/node_modules
|
||||
- ./locales:/locales
|
||||
- ./locales:/workspace/locales
|
||||
- /workspace/orgfront/node_modules
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
|
||||
userfront:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: userfront/Dockerfile
|
||||
target: ${USERFRONT_BUILD_TARGET:-dev}
|
||||
container_name: baron_userfront
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- BACKEND_URL=${BACKEND_URL:-}
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
- APP_ENV=${APP_ENV}
|
||||
- CLIENT_LOG_DEBUG=${CLIENT_LOG_DEBUG:-false}
|
||||
- USERFRONT_INTERNAL_PORT=5000
|
||||
- USERFRONT_FLUTTER_RUN_FLAGS=${USERFRONT_FLUTTER_RUN_FLAGS:-}
|
||||
volumes:
|
||||
- ./userfront/lib:/workspace/userfront/lib
|
||||
- ./userfront/assets:/workspace/userfront/assets
|
||||
- ./userfront/web:/workspace/userfront/web
|
||||
- ./userfront/scripts:/workspace/userfront/scripts:ro
|
||||
- ./scripts:/workspace/scripts:ro
|
||||
- ./locales:/workspace/locales:ro
|
||||
networks:
|
||||
- baron_net
|
||||
- ory-net
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Dummy service to wait for infra network if needed,
|
||||
# but essentially we assume infra is running.
|
||||
# In a real unified stack, we might include infra here or use external links.
|
||||
# Here we attach to the same network.
|
||||
infra_check:
|
||||
image: alpine
|
||||
command: ["echo", "Infrastructure assumed running"]
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
networks:
|
||||
baron_net:
|
||||
external: true
|
||||
name: baron_net
|
||||
ory-net:
|
||||
external: true
|
||||
name: ory-net
|
||||
public_net:
|
||||
external: true
|
||||
name: public_net
|
||||
119
config-restored/env.redacted
Normal file
119
config-restored/env.redacted
Normal file
@@ -0,0 +1,119 @@
|
||||
APP_ENV=dev
|
||||
BACKEND_LOG_LEVEL=debug
|
||||
CLIENT_LOG_DEBUG=true
|
||||
TZ=Asia/Seoul
|
||||
IDP_PROVIDER=ory
|
||||
|
||||
# DB & Clickhouse
|
||||
DB_PORT=5432
|
||||
CLICKHOUSE_PORT_HTTP=8123
|
||||
CLICKHOUSE_PORT_NATIVE=9000
|
||||
CLICKHOUSE_HOST=clickhouse
|
||||
CLICKHOUSE_USER=baron
|
||||
CLICKHOUSE_PASSWORD=REDACTED
|
||||
|
||||
|
||||
BACKEND_PORT=3000
|
||||
ADMINFRONT_PORT=5173
|
||||
DEVFRONT_PORT=5174
|
||||
USERFRONT_PORT=5000
|
||||
|
||||
OATHKEEPER_API_URL=http://oathkeeper:4456
|
||||
|
||||
DB_USER=baron
|
||||
DB_PASSWORD=REDACTED
|
||||
DB_NAME=baron_sso
|
||||
COOKIE_SECRET=REDACTED
|
||||
JWT_SECRET=REDACTED
|
||||
REDIS_ADDR=redis:6389
|
||||
CORS_ALLOWED_ORIGINS='*'
|
||||
AUDIT_WORKER_COUNT=5
|
||||
AUDIT_QUEUE_SIZE=2000
|
||||
PROFILE_CACHE_TTL=
|
||||
NAVER_CLOUD_ACCESS_KEY=REDACTED
|
||||
NAVER_CLOUD_SECRET_KEY=REDACTED
|
||||
NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:364022321777:baroncs
|
||||
NAVER_SENDER_PHONE_NUMBER=0262857755
|
||||
AWS_REGION=ap-northeast-2
|
||||
AWS_ACCESS_KEY_ID=REDACTED
|
||||
AWS_SECRET_ACCESS_KEY=REDACTED
|
||||
AWS_SES_SENDER=support@baroncs.co.kr
|
||||
# ADMIN_EMAIL=admin@hmac.kr
|
||||
ADMIN_EMAIL=su-@samaneng.com
|
||||
ADMIN_PASSWORD=REDACTED
|
||||
USERFRONT_URL=http://localhost:5000
|
||||
# USERFRONT_URL=http://172.16.9.189:5000
|
||||
ADMINFRONT_URL=http://localhost:5173
|
||||
DEVFRONT_URL=http://localhost:5174
|
||||
VITE_ORGCHART_URL=http://localhost:5175
|
||||
ORGFRONT_URL=http://localhost:5175
|
||||
BACKEND_PUBLIC_URL=${USERFRONT_URL}
|
||||
BACKEND_URL=${USERFRONT_URL}
|
||||
# OATHKEEPER_PUBLIC_URL=http://172.16.9.189:5000
|
||||
OATHKEEPER_PUBLIC_URL=http://localhost:5000
|
||||
|
||||
ORY_POSTGRES_TAG=17-trixie
|
||||
ORY_POSTGRES_USER=ory
|
||||
ORY_POSTGRES_PASSWORD=REDACTED
|
||||
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
|
||||
ORY_SDK_URL=http://kratos:4433
|
||||
KRATOS_PUBLIC_URL=http://kratos:4433
|
||||
KRATOS_ADMIN_URL=http://kratos:4434
|
||||
KRATOS_BROWSER_URL=http://localhost:5000/auth
|
||||
KRATOS_UI_URL=http://localhost:5000
|
||||
HYDRA_ADMIN_URL=http://hydra:4445
|
||||
HYDRA_PUBLIC_URL=http://localhost:5000/oidc
|
||||
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
|
||||
OATHKEEPER_VERSION=v26.2.0
|
||||
OATHKEEPER_UID=1001
|
||||
OATHKEEPER_GID=1001
|
||||
OATHKEEPER_HEALTH_URL=http://oathkeeper:4456/health/ready
|
||||
OATHKEEPER_HEALTH_INTERVAL_SECONDS=10
|
||||
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=2
|
||||
OATHKEEPER_HEALTH_ENABLED=true
|
||||
CSRF_COOKIE_NAME=REDACTED
|
||||
CSRF_COOKIE_SECRET=REDACTED
|
||||
|
||||
# Frontend OIDC configs for Staging
|
||||
VITE_OIDC_AUTHORITY=http://localhost:5000/oidc
|
||||
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback
|
||||
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback
|
||||
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_ID=
|
||||
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=
|
||||
|
||||
#Worksmobile
|
||||
SAMAN_DOMAIN_ID=300285955
|
||||
HANMAC_DOMAIN_ID=300286336
|
||||
GPDTDC_DOMAIN_ID=300286337
|
||||
BARONGROUP_DOMAIN_ID=300286645
|
||||
HALLA_DOMAIN_ID=300293726
|
||||
SAMAN_TENANT_ID=300285955
|
||||
SAMAN_SCIM_LONGLIVE_TOKEN=REDACTED
|
||||
WORKS_ADMIN_OAUTH_CLIENT_ID=JrD1iPz73ugTFV5XL_zO
|
||||
WORKS_ADMIN_OAUTH_CLIENT_SECRET=REDACTED
|
||||
WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT=e3n9j.serviceaccount@samaneng.com
|
||||
WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE=REDACTED
|
||||
WORKS_DEFAULT_DOMAIN_SAMAN=samaneng.com
|
||||
WORKS_DEFAULT_DOMAIN_HANMAC=hanmaceng.co.kr
|
||||
WORKS_DEFAULT_DOMAIN_GPDTDC=baroncs.co.kr
|
||||
WORKS_DEFAULT_DOMAIN_BARONGROUP=brsw.kr
|
||||
WORKS_DEFAULT_DOMAIN_HALLA=hallasanup.com
|
||||
WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL=REDACTED
|
||||
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID=9JapAnmjI9M_1SqDp4Uj
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET=REDACTED
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT=h4bq6.serviceaccount@samaneng.com
|
||||
WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE=REDACTED
|
||||
WORKS_DRIVE_APP_PASSWORD=REDACTED
|
||||
WORKS_DRIVE_OAUTH_REDIRECT_URI=https://drive.hmac.kr/works/callback
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN=REDACTED
|
||||
WORKS_DRIVE_SHARED_DRIVE_ID=@2001000000540386
|
||||
WORKS_DRIVE_PARENT_FILE_ID=QDIwMDEwMDAwMDA1NDAzODZ8MzQ3MjYxMzYwMzE0NjY2NDk2OXxEfDA
|
||||
BIN
config-restored/gateway.tar.zst
Normal file
BIN
config-restored/gateway.tar.zst
Normal file
Binary file not shown.
BIN
config-restored/generated-ory.tar.zst
Normal file
BIN
config-restored/generated-ory.tar.zst
Normal file
Binary file not shown.
@@ -218,20 +218,11 @@ services:
|
||||
networks: [app_net]
|
||||
|
||||
init-rp:
|
||||
image: alpine:latest
|
||||
image: oryd/hydra:${HYDRA_CLI_VERSION:-v26.2.0}
|
||||
env_file: .env
|
||||
entrypoint: ["/bin/sh", "-ec"]
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- |
|
||||
apk add --no-cache curl tar
|
||||
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
|
||||
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
|
||||
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
|
||||
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
|
||||
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
||||
rm /tmp/hydra.tar.gz
|
||||
|
||||
upsert_client() {
|
||||
ID=$$1
|
||||
shift
|
||||
@@ -314,48 +305,56 @@ services:
|
||||
networks: [app_net]
|
||||
|
||||
adminfront:
|
||||
image: node:20-alpine
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: ./adminfront/Dockerfile
|
||||
args:
|
||||
VITE_ADMIN_PUBLIC_URL: ${ADMINFRONT_URL}
|
||||
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
|
||||
VITE_OIDC_CLIENT_ID: adminfront
|
||||
ORGFRONT_URL: ${ORGFRONT_URL}
|
||||
container_name: ${COMPOSE_PROJECT_NAME}_adminfront
|
||||
working_dir: /app
|
||||
env_file: .env
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-production}
|
||||
- API_PROXY_TARGET=http://backend:${BACKEND_PORT}
|
||||
ports:
|
||||
- "${ADMINFRONT_PORT}:5173"
|
||||
volumes:
|
||||
- ../../common:/common
|
||||
- ../../adminfront:/app
|
||||
- ./adminfront/vite.config.ts:/app/vite.config.ts:ro
|
||||
- ./adminfront/auth.ts:/app/src/lib/auth.ts:ro
|
||||
command: sh ./scripts/runtime-mode.sh
|
||||
networks: [app_net]
|
||||
|
||||
devfront:
|
||||
image: node:20-alpine
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: ./devfront/Dockerfile
|
||||
args:
|
||||
VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL}
|
||||
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
|
||||
VITE_OIDC_CLIENT_ID: devfront
|
||||
container_name: ${COMPOSE_PROJECT_NAME}_devfront
|
||||
working_dir: /app
|
||||
env_file: .env
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-production}
|
||||
- API_PROXY_TARGET=http://backend:${BACKEND_PORT}
|
||||
ports:
|
||||
- "${DEVFRONT_PORT}:5173"
|
||||
volumes:
|
||||
- ../../common:/common
|
||||
- ../../devfront:/app
|
||||
- ./devfront/vite.config.ts:/app/vite.config.ts:ro
|
||||
- ./devfront/auth.ts:/app/src/lib/auth.ts:ro
|
||||
command: sh ./scripts/runtime-mode.sh
|
||||
networks: [app_net]
|
||||
|
||||
orgfront:
|
||||
image: node:20-alpine
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: ./orgfront/Dockerfile
|
||||
args:
|
||||
VITE_ORGFRONT_PUBLIC_URL: ${ORGFRONT_URL}
|
||||
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
|
||||
VITE_OIDC_CLIENT_ID: orgfront
|
||||
container_name: ${COMPOSE_PROJECT_NAME}_orgfront
|
||||
working_dir: /app
|
||||
env_file: .env
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-production}
|
||||
- API_PROXY_TARGET=http://backend:${BACKEND_PORT}
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
ports:
|
||||
- "${ORGFRONT_PORT}:5175"
|
||||
volumes:
|
||||
- ../../common:/common
|
||||
- ../../orgfront:/app
|
||||
- ./orgfront/vite.config.ts:/app/vite.config.ts:ro
|
||||
- ./orgfront/auth.ts:/app/src/lib/auth.ts:ro
|
||||
command: sh ./scripts/runtime-mode.sh
|
||||
networks: [app_net]
|
||||
|
||||
networks:
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
FROM node:lts
|
||||
FROM node:lts AS build
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Set CI environment variable to true to avoid TTY issues with pnpm
|
||||
ENV CI=true
|
||||
ENV DEVFRONT_BUILD_OUT_DIR=/workspace/devfront/dist
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.5.2 --activate
|
||||
|
||||
# Copy workspace configs and common package
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY common ./common
|
||||
COPY devfront ./devfront
|
||||
|
||||
# Install dependencies for the workspace
|
||||
RUN pnpm install --filter devfront... --filter baron-sso... --no-frozen-lockfile --ignore-scripts
|
||||
ARG VITE_DEVFRONT_PUBLIC_URL
|
||||
ARG VITE_OIDC_AUTHORITY
|
||||
ARG VITE_OIDC_CLIENT_ID
|
||||
ENV VITE_DEVFRONT_PUBLIC_URL=$VITE_DEVFRONT_PUBLIC_URL
|
||||
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
|
||||
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
|
||||
|
||||
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||
RUN npm install -g serve
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /workspace/devfront
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV FRONTEND_DIST_DIR=/app/dist
|
||||
ENV PORT=5173
|
||||
|
||||
COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs
|
||||
COPY --from=build /workspace/devfront/dist ./dist
|
||||
|
||||
# Vite 기본 포트
|
||||
EXPOSE 5173
|
||||
|
||||
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||
RUN chmod +x ./scripts/runtime-mode.sh
|
||||
CMD ["sh", "./scripts/runtime-mode.sh"]
|
||||
CMD ["node", "./serve_frontend_prod.mjs"]
|
||||
|
||||
@@ -116,6 +116,24 @@ describe("devfront AppLayout", () => {
|
||||
expect(document.documentElement.classList.contains("light")).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles the sidebar and persists the collapsed state", async () => {
|
||||
const container = await renderLayout();
|
||||
|
||||
const collapseButton = container.querySelector(
|
||||
'button[aria-label="사이드바 접기"]',
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
collapseButton.click();
|
||||
});
|
||||
|
||||
expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(
|
||||
container.querySelector('button[aria-label="사이드바 펼치기"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
|
||||
const container = await renderLayout();
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ import {
|
||||
buildShellProfileSummary,
|
||||
buildShellSessionStatus,
|
||||
readShellSessionExpiryEnabled,
|
||||
readShellSidebarCollapsed,
|
||||
readShellTheme,
|
||||
type ShellSidebarNavItem,
|
||||
type ShellTranslator,
|
||||
shellLayoutClasses,
|
||||
writeShellSessionExpiryEnabled,
|
||||
writeShellSidebarCollapsed,
|
||||
} from "../../../../common/shell";
|
||||
import { fetchMe } from "../../features/auth/authApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
@@ -118,6 +120,9 @@ function AppLayout() {
|
||||
const isDevelopmentRuntime = import.meta.env.MODE === "development";
|
||||
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
|
||||
readShellSidebarCollapsed(false),
|
||||
);
|
||||
const [, setDevelopmentRenderRevision] = useState(0);
|
||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||
@@ -352,26 +357,42 @@ function AppLayout() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handleSidebarToggle = () => {
|
||||
setIsSidebarCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
writeShellSidebarCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const sidebarNavContent = (
|
||||
<div className={shellLayoutClasses.navList}>
|
||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span>{t(labelKey, labelFallback)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => {
|
||||
const label = t(labelKey, labelFallback);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
shellLayoutClasses.navItemBase,
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.navItemBaseCollapsed
|
||||
: "",
|
||||
isActive
|
||||
? shellLayoutClasses.navItemActive
|
||||
: shellLayoutClasses.navItemIdle,
|
||||
].join(" ")
|
||||
}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const sidebarFooterContent = (
|
||||
@@ -379,22 +400,39 @@ function AppLayout() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={shellLayoutClasses.logoutButton}
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.logoutButtonCollapsed
|
||||
: shellLayoutClasses.logoutButton
|
||||
}
|
||||
title={t("ui.shell.nav.logout", "Logout")}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
||||
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||
{t("ui.shell.nav.logout", "Logout")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={shellLayoutClasses.root}>
|
||||
<div
|
||||
className={
|
||||
isSidebarCollapsed
|
||||
? shellLayoutClasses.rootCollapsed
|
||||
: shellLayoutClasses.root
|
||||
}
|
||||
>
|
||||
<AppSidebar
|
||||
brandLabel={t("ui.dev.brand", "Baron Sign In")}
|
||||
brandTitle={t("ui.dev.console_title", "Developer Console")}
|
||||
brandIcon={<ShieldHalf size={20} />}
|
||||
navContent={sidebarNavContent}
|
||||
footerContent={sidebarFooterContent}
|
||||
collapsed={isSidebarCollapsed}
|
||||
onToggleCollapsed={handleSidebarToggle}
|
||||
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
|
||||
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
|
||||
/>
|
||||
|
||||
<div className={shellLayoutClasses.content}>
|
||||
|
||||
@@ -1361,6 +1361,10 @@ unknown_name = "Unknown User"
|
||||
logout = "Logout"
|
||||
profile = "My Profile"
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = "Collapse sidebar"
|
||||
expand = "Expand sidebar"
|
||||
|
||||
[ui.shell.role]
|
||||
rp_admin = "Service Administrator (RP Admin)"
|
||||
super_admin = "System Administrator (Super Admin)"
|
||||
|
||||
@@ -1361,6 +1361,10 @@ unknown_name = "Unknown User"
|
||||
logout = "Logout"
|
||||
profile = "내 정보"
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = "사이드바 접기"
|
||||
expand = "사이드바 펼치기"
|
||||
|
||||
[ui.shell.role]
|
||||
rp_admin = "서비스 관리자 (RP Admin)"
|
||||
super_admin = "시스템 관리자 (Super Admin)"
|
||||
|
||||
@@ -1417,6 +1417,10 @@ unknown_name = ""
|
||||
logout = ""
|
||||
profile = ""
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = ""
|
||||
expand = ""
|
||||
|
||||
[ui.shell.role]
|
||||
rp_admin = ""
|
||||
super_admin = ""
|
||||
|
||||
@@ -55,6 +55,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./adminfront/Dockerfile
|
||||
args:
|
||||
VITE_ADMIN_PUBLIC_URL: ${ADMINFRONT_URL}
|
||||
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
|
||||
VITE_OIDC_CLIENT_ID: adminfront
|
||||
container_name: baron_adminfront
|
||||
env_file:
|
||||
- .env
|
||||
@@ -80,6 +84,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./devfront/Dockerfile
|
||||
args:
|
||||
VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL}
|
||||
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
|
||||
VITE_OIDC_CLIENT_ID: devfront
|
||||
container_name: baron_devfront
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
24
docker/backup-tools/Dockerfile
Normal file
24
docker/backup-tools/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM debian:trixie-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
coreutils \
|
||||
curl \
|
||||
docker-cli \
|
||||
findutils \
|
||||
git \
|
||||
grep \
|
||||
jq \
|
||||
openssl \
|
||||
perl \
|
||||
postgresql-client \
|
||||
sed \
|
||||
tar \
|
||||
util-linux \
|
||||
zstd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /workspace
|
||||
CMD ["/bin/bash"]
|
||||
@@ -182,22 +182,13 @@ services:
|
||||
- ory-net
|
||||
|
||||
init-rp:
|
||||
image: alpine:latest
|
||||
image: oryd/hydra:${HYDRA_CLI_VERSION:-v26.2.0}
|
||||
container_name: init-rp
|
||||
env_file:
|
||||
- ../.env
|
||||
entrypoint: ["/bin/sh", "-ec"]
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- |
|
||||
apk add --no-cache curl tar
|
||||
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
|
||||
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
|
||||
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
|
||||
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
|
||||
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
||||
rm /tmp/hydra.tar.gz
|
||||
|
||||
echo "Creating/Updating OAuth2 Clients..."
|
||||
|
||||
hydra create oauth2-client \
|
||||
|
||||
@@ -17,15 +17,16 @@ services:
|
||||
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
|
||||
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
|
||||
- USERFRONT_URL=${USERFRONT_URL:-https://sso.hmac.kr}
|
||||
- BACKEND_PORT=3000
|
||||
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3010}:3010"
|
||||
- "${PROD_BACKEND_PORT:-3010}:3000"
|
||||
volumes:
|
||||
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
|
||||
depends_on:
|
||||
- infra_check
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3010/health"]
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -37,11 +38,71 @@ services:
|
||||
image: ${USERFRONT_IMAGE_NAME}:${IMAGE_TAG}
|
||||
container_name: baron_userfront
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- USERFRONT_URL=${USERFRONT_URL:-https://sso.hmac.kr}
|
||||
- BACKEND_URL=${USERFRONT_URL:-https://sso.hmac.kr}
|
||||
- BACKEND_URL=${BACKEND_URL:-https://sso.hmac.kr}
|
||||
ports:
|
||||
- "${USERFRONT_PORT:-80}:80"
|
||||
- "${USERFRONT_PORT:-80}:5000"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- baron_net
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
adminfront:
|
||||
image: ${ADMINFRONT_IMAGE_NAME}:${IMAGE_TAG}
|
||||
container_name: baron_adminfront
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- APP_ENV=production
|
||||
- API_PROXY_TARGET=http://baron_backend:${BACKEND_PORT:-3000}
|
||||
ports:
|
||||
- "${ADMINFRONT_PORT:-5173}:5173"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
devfront:
|
||||
image: ${DEVFRONT_IMAGE_NAME}:${IMAGE_TAG}
|
||||
container_name: baron_devfront
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- APP_ENV=production
|
||||
- API_PROXY_TARGET=http://baron_backend:${BACKEND_PORT:-3000}
|
||||
ports:
|
||||
- "${DEVFRONT_PORT:-5174}:5173"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- baron_net
|
||||
|
||||
orgfront:
|
||||
image: ${ORGFRONT_IMAGE_NAME}:${IMAGE_TAG}
|
||||
container_name: baron_orgfront
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- APP_ENV=production
|
||||
- API_PROXY_TARGET=http://baron_backend:${BACKEND_PORT:-3000}
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
ports:
|
||||
- "${ORGFRONT_PORT:-5175}:5175"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -301,21 +301,12 @@ services:
|
||||
- ory-net
|
||||
|
||||
init-rp:
|
||||
image: alpine:latest
|
||||
image: oryd/hydra:${HYDRA_CLI_VERSION:-v26.2.0}
|
||||
env_file:
|
||||
- .env
|
||||
entrypoint: ["/bin/sh", "-ec"]
|
||||
command:
|
||||
- /bin/sh
|
||||
- -ec
|
||||
- |
|
||||
apk add --no-cache curl tar
|
||||
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
|
||||
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
|
||||
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
|
||||
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
|
||||
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
||||
rm /tmp/hydra.tar.gz
|
||||
|
||||
# Function to create or update OAuth2 client (Idempotency)
|
||||
upsert_client() {
|
||||
ID=$$1
|
||||
@@ -427,6 +418,11 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./adminfront/Dockerfile
|
||||
args:
|
||||
VITE_ADMIN_PUBLIC_URL: ${ADMINFRONT_URL:-}
|
||||
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY:-}
|
||||
VITE_OIDC_CLIENT_ID: adminfront
|
||||
ORGFRONT_URL: ${ORGFRONT_URL:-}
|
||||
container_name: baron_adminfront
|
||||
env_file:
|
||||
- .env
|
||||
@@ -435,11 +431,6 @@ services:
|
||||
- API_PROXY_TARGET=http://baron_backend:3000
|
||||
ports:
|
||||
- "${ADMINFRONT_PORT:-5173}:5173"
|
||||
volumes:
|
||||
- ./adminfront:/app
|
||||
- ./common:/common
|
||||
- ./locales:/locales
|
||||
- /app/node_modules
|
||||
networks:
|
||||
- baron_net
|
||||
healthcheck:
|
||||
@@ -453,6 +444,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./devfront/Dockerfile
|
||||
args:
|
||||
VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL:-}
|
||||
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY:-}
|
||||
VITE_OIDC_CLIENT_ID: devfront
|
||||
container_name: baron_devfront
|
||||
env_file:
|
||||
- .env
|
||||
@@ -461,11 +456,6 @@ services:
|
||||
- API_PROXY_TARGET=http://baron_backend:3000
|
||||
ports:
|
||||
- "${DEVFRONT_PORT:-5174}:5173"
|
||||
volumes:
|
||||
- ./devfront:/app
|
||||
- ./common:/common
|
||||
- ./locales:/locales
|
||||
- /app/node_modules
|
||||
networks:
|
||||
- baron_net
|
||||
healthcheck:
|
||||
@@ -479,6 +469,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./orgfront/Dockerfile
|
||||
args:
|
||||
VITE_ORGFRONT_PUBLIC_URL: ${ORGFRONT_URL:-}
|
||||
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY:-}
|
||||
VITE_OIDC_CLIENT_ID: orgfront
|
||||
container_name: baron_orgfront
|
||||
env_file:
|
||||
- .env
|
||||
@@ -488,11 +482,6 @@ services:
|
||||
- USERFRONT_URL=${USERFRONT_URL}
|
||||
ports:
|
||||
- "${ORGFRONT_PORT:-5175}:5175"
|
||||
volumes:
|
||||
- ./orgfront:/app
|
||||
- ./common:/common
|
||||
- ./locales:/locales
|
||||
- /app/node_modules
|
||||
networks:
|
||||
- baron_net
|
||||
healthcheck:
|
||||
|
||||
@@ -77,6 +77,247 @@ baron-sso-backup-YYYYMMDD-HHMMSSZ/
|
||||
- 암호화 방식과 key id
|
||||
- 복구 대상 환경 제한: `same-env-only`, `staging-rehearsal`, `cross-env`
|
||||
|
||||
## Dump 대상별 복구 flow 및 영향도
|
||||
|
||||
`make dump`의 서비스 필터는 저장소별 복구 단위를 명확히 나누기 위한 운영 인터페이스다. 전체 재해 복구는 `DUMP_SERVICES=all`을 기본으로 하되, staging rehearsal이나 부분 장애 분석에서는 개별 대상을 분리해서 검증할 수 있다.
|
||||
|
||||
### 대상별 요약
|
||||
|
||||
| 서비스 필터 | 주요 dump 산출물 | 포함 데이터 | 복구 중요도 | 복구 영향도 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `postgres` | `postgres/baron.dump` | Baron users, tenants, membership, user_login_ids, user_groups, RP metadata, API keys, WORKS mapping/outbox, Keto outbox, consent projection 등 | 필수 | Baron control plane의 원장이다. 누락되면 사용자/테넌트/RP/WORKS 참조가 끊기고 Ory DB만 복구해도 서비스 의미가 깨진다. |
|
||||
| `ory-postgres` | `postgres/globals.sql`, `postgres/ory_kratos.dump`, `postgres/ory_hydra.dump`, `postgres/ory_keto.dump` | Kratos identity/credential/session, Hydra client/consent/token state, Keto relation tuple | 필수 | 인증 주체, OAuth2/OIDC 상태, ReBAC 권한 원장이다. Baron DB와 시점이 다르면 로그인/인가/consent 불일치가 발생한다. |
|
||||
| `clickhouse` | `clickhouse/baron_clickhouse/schema/*.sql`, `clickhouse/baron_clickhouse/data/*.native` | Baron audit_logs, RP usage event/aggregate 등 | 운영 정책상 필수 | 인증 자체를 막지는 않지만 감사 추적, 사용량 집계, 사고 분석 이력이 손실된다. |
|
||||
| `ory-clickhouse` | `clickhouse/ory_clickhouse/schema/*.sql`, `clickhouse/ory_clickhouse/data/*.native` | Oathkeeper/Ory/Vector 접근 로그 | 운영 정책상 필수 | Ory edge 접근 로그와 장애 분석 근거가 손실된다. 인증 원장은 Postgres에 있으므로 직접 로그인 기능 영향은 제한적이다. |
|
||||
| `config` | `config/env.redacted`, `config/generated-ory.*`, `config/gateway.*`, `config/compose/*` | 환경 변수 redacted snapshot, generated Ory config, gateway/Oathkeeper 설정, compose 파일 | 필수 | DB만 복구해도 secret/config가 맞지 않으면 Ory Stack, WORKS 연동, callback URL, gateway routing이 정상 기동하지 못한다. |
|
||||
|
||||
### `postgres`: Baron 애플리케이션 DB
|
||||
|
||||
Dump flow:
|
||||
|
||||
1. `baron_postgres` 컨테이너 실행 상태를 확인한다.
|
||||
2. `${DB_NAME:-baron_sso}`를 `pg_dump -Fc`로 `postgres/baron.dump`에 저장한다.
|
||||
3. `pg_stat_user_tables` 기준 row count를 `reports/baron-postgres-row-counts.txt`에 기록한다.
|
||||
4. `checksums.sha256`에 dump와 report checksum을 기록한다.
|
||||
|
||||
Restore flow:
|
||||
|
||||
1. restore 전용 빈 Baron Postgres DB를 준비한다.
|
||||
2. `make restore ... RESTORE_SERVICES=postgres CONFIRM_RESTORE=baron-sso`로 `pg_restore --clean --if-exists`를 실행한다.
|
||||
3. 복구 후 backend migration 자동 실행은 금지하고, dump 시점의 schema version과 현재 binary가 호환되는지 먼저 확인한다.
|
||||
4. Kratos identity와 Baron `users.id`의 참조 검증을 수행한다.
|
||||
5. WORKS relay, Keto outbox relay는 post-restore 검증 전까지 재개하지 않는다.
|
||||
|
||||
영향도:
|
||||
|
||||
- 사용자, 테넌트, 소속, RP metadata, WORKS mapping의 기준 원장이므로 full restore에서 가장 먼저 Baron/Ory 시점 정합성을 확인해야 한다.
|
||||
- Baron DB만 과거 시점으로 되돌리면 Kratos identity, Hydra consent, Keto tuple과 불일치할 수 있다.
|
||||
- WORKS mapping/outbox 상태가 과거로 돌아가면 외부 WORKS와 중복 upsert/delete 후보가 생길 수 있으므로 comparison dry-run이 필수다.
|
||||
|
||||
검증 포인트:
|
||||
|
||||
- `users.id`와 Kratos `identities.id` 일치
|
||||
- `tenants.parent_id`, membership, user group 참조 무결성
|
||||
- RP metadata와 Hydra client 연결성
|
||||
- WORKS mapping의 BaronResourceID 참조 유효성
|
||||
|
||||
### `ory-postgres`: Kratos/Hydra/Keto DB
|
||||
|
||||
Dump flow:
|
||||
|
||||
1. `ory_postgres` 컨테이너 실행 상태를 확인한다.
|
||||
2. `pg_dumpall --globals-only`로 role/권한 정보를 `postgres/globals.sql`에 저장한다.
|
||||
3. `${KRATOS_DB:-ory_kratos}`, `${HYDRA_DB:-ory_hydra}`, `${KETO_DB:-ory_keto}`를 각각 `pg_dump -Fc`로 저장한다.
|
||||
4. 각 DB의 row count report를 `reports/ory_*-row-counts.txt`에 기록한다.
|
||||
|
||||
Restore flow:
|
||||
|
||||
1. restore 전용 빈 Ory Postgres DB를 준비한다.
|
||||
2. 필요 시 `globals.sql`을 먼저 적용한다.
|
||||
3. Kratos, Hydra, Keto DB를 각각 복구한다.
|
||||
4. migration은 자동 실행하지 않고, Ory binary version과 dump schema version을 확인한다.
|
||||
5. Ory Stack을 backend보다 먼저 기동해 admin/public endpoint health를 확인한다.
|
||||
|
||||
영향도:
|
||||
|
||||
- Kratos DB는 사용자 subject와 credential 원장이므로 누락되면 비밀번호, recovery/verifiable address, identity UUID 보존이 불가능하다.
|
||||
- Hydra DB는 client, consent, OAuth2 token/session 상태를 담으므로 누락되면 기존 RP 로그인/consent 상태가 재생성되거나 만료될 수 있다.
|
||||
- Keto DB는 ReBAC tuple 원장이므로 누락되면 사용자/테넌트/RP 권한 판단이 실패한다.
|
||||
- Ory DB만 복구하고 Baron DB가 맞지 않으면 identity는 있으나 Baron user가 없거나, Baron user는 있으나 identity가 없는 상태가 된다.
|
||||
|
||||
검증 포인트:
|
||||
|
||||
- Kratos identity 수와 Baron users 수 비교
|
||||
- Hydra client와 Baron RP metadata 비교
|
||||
- Keto tuple subject/object가 복구된 사용자/테넌트/RP를 참조하는지 확인
|
||||
- 대표 사용자 password login, 대표 RP OIDC login/consent smoke
|
||||
|
||||
### `clickhouse`: Baron ClickHouse
|
||||
|
||||
Dump flow:
|
||||
|
||||
1. `baron_clickhouse` 컨테이너 실행 상태를 확인한다.
|
||||
2. `system.tables`에서 Baron ClickHouse table 목록과 engine을 조회한다.
|
||||
3. 일반 table은 `SHOW CREATE TABLE`과 `FORMAT Native` 데이터를 함께 저장한다.
|
||||
4. view 계열 engine은 restore insert 위험을 피하기 위해 schema만 저장한다.
|
||||
5. table별 row count를 `reports/baron_clickhouse-row-counts.txt`에 기록한다.
|
||||
|
||||
Restore flow:
|
||||
|
||||
1. restore 전용 ClickHouse DB를 준비한다.
|
||||
2. database를 생성한 뒤 table schema를 먼저 적용한다.
|
||||
3. 일반 table의 `.native` 데이터를 insert한다.
|
||||
4. materialized view나 view는 schema만 복구하고, 대상 table 데이터가 들어간 뒤 재계산/재생성 정책을 별도로 확인한다.
|
||||
5. row count와 주요 기간의 min/max timestamp를 비교한다.
|
||||
|
||||
영향도:
|
||||
|
||||
- Baron audit log와 RP usage 집계가 손실되면 보안 감사, 운영 추적, 사용량 화면의 신뢰도가 떨어진다.
|
||||
- 인증 기능 자체의 필수 원장은 아니지만, 사고 대응과 운영 증적 측면에서는 필수 백업 대상이다.
|
||||
- aggregate/materialized view는 원본 event table과 복구 순서가 맞지 않으면 중복 집계나 누락 집계가 생길 수 있다.
|
||||
|
||||
검증 포인트:
|
||||
|
||||
- `audit_logs`, `rp_usage_events`, aggregate table row count 비교
|
||||
- 주요 기간별 min/max timestamp 비교
|
||||
- AdminFront/DevFront의 사용량 조회 smoke
|
||||
|
||||
### `ory-clickhouse`: Ory ClickHouse
|
||||
|
||||
Dump flow:
|
||||
|
||||
1. `ory_clickhouse` 컨테이너 실행 상태를 확인한다.
|
||||
2. Ory/Oathkeeper/Vector 계열 table schema와 일반 table data를 저장한다.
|
||||
3. table별 row count를 `reports/ory_clickhouse-row-counts.txt`에 기록한다.
|
||||
|
||||
Restore flow:
|
||||
|
||||
1. restore 전용 Ory ClickHouse DB를 준비한다.
|
||||
2. schema를 적용한 뒤 일반 table data를 insert한다.
|
||||
3. Vector/Oathkeeper 기동 후 신규 로그가 같은 table로 유입되는지 확인한다.
|
||||
|
||||
영향도:
|
||||
|
||||
- Ory edge 접근 로그와 gateway 관측 자료가 손실된다.
|
||||
- 인증/인가 원장은 Postgres에 있으므로 서비스 기동 자체 영향은 제한적이다.
|
||||
- 보안 사고 분석, OIDC redirect 장애 분석, rate/anomaly 분석에는 영향이 크다.
|
||||
|
||||
검증 포인트:
|
||||
|
||||
- Oathkeeper access log row count 비교
|
||||
- Oathkeeper 경유 요청 후 신규 로그 유입 확인
|
||||
- Vector pipeline 재기동 후 error log 확인
|
||||
|
||||
### `config`: 설정 snapshot
|
||||
|
||||
Dump flow:
|
||||
|
||||
1. `.env`가 있으면 secret 성격 key를 `REDACTED`로 치환해 `config/env.redacted`를 만든다.
|
||||
2. `config/.generated/ory`, `gateway`, 주요 compose 파일을 tar snapshot 또는 file copy로 보존한다.
|
||||
3. 실제 secret 원문은 1차 로컬 구현의 `env.redacted`에 포함하지 않는다. 운영 백업에서는 별도 암호화 산출물로 보관해야 한다.
|
||||
|
||||
Restore flow:
|
||||
|
||||
1. `make restore ... RESTORE_SERVICES=config` 실행 시 운영 파일을 직접 덮어쓰지 않는다.
|
||||
2. snapshot은 `config-restored/`에 풀어 운영자가 diff로 검토한다.
|
||||
3. secret 원문은 승인된 secret manager 또는 암호화 백업에서 별도 복원한다.
|
||||
4. `make validate-auth-config`, `make verify-auth-config`로 callback/redirect/gateway mapping을 검증한다.
|
||||
5. Ory Stack과 gateway를 기동한 뒤 대표 callback과 OIDC flow를 확인한다.
|
||||
|
||||
영향도:
|
||||
|
||||
- DB가 정상이어도 Hydra system secret, Kratos config, Oathkeeper rule, WORKS private key, callback URL이 맞지 않으면 서비스가 정상 동작하지 않는다.
|
||||
- `env.redacted`만으로는 운영 복구가 완성되지 않는다. 운영 복구용 secret은 별도 암호화 저장소가 필요하다.
|
||||
- config를 운영 파일에 바로 덮어쓰면 현재 환경의 도메인, callback, gateway rule을 깨뜨릴 수 있으므로 수동 검토가 기본이다.
|
||||
|
||||
검증 포인트:
|
||||
|
||||
- `make validate-auth-config`
|
||||
- `make verify-auth-config`
|
||||
- gateway/Oathkeeper route smoke
|
||||
- WORKS credential dry-run 또는 외부 호출 차단 상태 검증
|
||||
|
||||
### 제외 대상의 복구 영향
|
||||
|
||||
| 제외 대상 | 제외 이유 | 복구 후 영향 | 운영 대응 |
|
||||
| --- | --- | --- | --- |
|
||||
| Redis | cache, pending login, short code 등 휘발성 상태 | 진행 중인 login/link/short code flow가 만료되거나 재시작된다. | 사용자에게 재시도 안내, 서비스 기동 후 cache 재수렴 확인 |
|
||||
| 프론트 빌드 산출물 | 소스와 이미지 태그로 재생성 가능 | 기존 정적 파일을 그대로 보존하지 않는다. | 동일 commit/image tag로 재배포 |
|
||||
| 로컬 reports/coverage/test-results | 운영 원장이 아님 | 운영 서비스 영향 없음 | CI artifact나 별도 관측 저장소 기준으로 확인 |
|
||||
|
||||
### 전체 복구 순서와 의존성
|
||||
|
||||
Full restore는 다음 순서를 기본으로 한다.
|
||||
|
||||
1. restore 전용 빈 환경과 volume을 준비한다.
|
||||
2. `config` snapshot을 `config-restored/`에 풀고 운영자가 secret/config 차이를 검토한다.
|
||||
3. `ory-postgres`를 복구한다.
|
||||
4. `postgres`를 복구한다.
|
||||
5. `ory-clickhouse`와 `clickhouse`를 복구한다.
|
||||
6. `make dump-verify`와 `make restore-verify`로 산출물 무결성을 확인한다.
|
||||
7. `make validate-auth-config`, `make verify-auth-config`로 Ory/gateway 설정을 확인한다.
|
||||
8. Ory Stack을 먼저 기동하고 Kratos/Hydra/Keto health를 확인한다.
|
||||
9. backend와 app을 기동한다.
|
||||
10. super admin login, 일반 사용자 login, 대표 RP OIDC login/consent를 확인한다.
|
||||
11. WORKS comparison dry-run을 수행한다.
|
||||
12. 대량 delete/upsert 위험이 없을 때 relay worker와 외부 동기화를 재개한다.
|
||||
|
||||
부분 복구는 원칙적으로 장애 분석 또는 rehearsal에서만 사용한다. 운영에서 특정 저장소만 과거 시점으로 되돌리면 cross-store 정합성이 깨질 수 있으므로, 실제 운영 restore는 같은 backup directory에서 나온 동일 시점의 산출물을 함께 적용하는 것을 기본으로 한다.
|
||||
|
||||
Restore 입력과 report:
|
||||
|
||||
- `make dump`, `make restore`, `make upload-cloud`는 기본적으로 Debian Trixie slim 기반 `baron-sso-backup-tools:local` 컨테이너에서 실행한다.
|
||||
- 호스트 요구사항은 Docker와 Docker socket 접근 권한으로 제한한다. `zstd`, `jq`, `curl`, `openssl`, `postgresql-client`, `docker-cli`는 backup-tools image에 포함한다.
|
||||
- `make restore BACKUP=<backup-dir>`는 압축 해제된 백업 디렉터리를 입력으로 사용한다.
|
||||
- `make restore DUMP_FILE=<backup>.tar.zst`는 archive를 임시 디렉터리에 풀어 복구한다.
|
||||
- `BACKUP`과 `DUMP_FILE`은 동시에 지정하지 않는다.
|
||||
- restore report 기본 위치는 `BACKUP` 입력일 때 `<backup-dir>/reports/restore-report.json`, `DUMP_FILE` 입력일 때 `reports/restore/<archive-name>-restore-report.json`이다.
|
||||
- restore report JSON이 생성되면 같은 경로에 `.md` 확장자의 Markdown 요약도 함께 생성한다.
|
||||
- `RESTORE_REPORT=<path>`로 report 경로를 명시할 수 있다.
|
||||
- restore report는 checksum 검증, 서비스별 복구 대상, 복구 후 row count 비교, config snapshot copy 위치를 기록한다.
|
||||
- 외부 접속을 막은 상태에서 복구하려면 app/Ory serve/gateway를 먼저 중지하고 Postgres/ClickHouse/Redis만 기동해 restore를 수행한 뒤 검증 통과 후 Ory/backend/frontend를 순차 기동한다.
|
||||
|
||||
### 외부 분산 저장: WORKS Drive 업로드
|
||||
|
||||
로컬 dump가 끝난 뒤 같은 백업 디렉터리를 외부 저장소에 분산 보관하기 위해 `scripts/backup/upload_cloud.sh`를 사용한다. 현재 1차 대상은 WORKS Drive다.
|
||||
|
||||
Upload flow:
|
||||
|
||||
1. `make dump BACKUP=...` 또는 `make dump-upload-cloud BACKUP=...`로 백업 산출물을 만든다.
|
||||
2. `dump.sh`가 `reports/backup-report.md`를 생성한다. report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록된다.
|
||||
3. `upload_cloud.sh`가 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 압축한다.
|
||||
4. Drive API용 access token을 확인한다.
|
||||
- 우선순위: `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`
|
||||
- fallback 1: `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`으로 Drive 앱 access token 갱신
|
||||
- fallback 2: `WORKS_DRIVE_OAUTH_*` 서비스 계정 JWT 토큰 발급
|
||||
5. WORKS Drive upload URL 생성 API를 호출한다.
|
||||
6. 발급받은 upload URL에 multipart `Filedata`로 `.tar.zst` archive를 업로드한다.
|
||||
7. `WORKS_DRIVE_UPLOAD_REPORTS=true`이면 대상 폴더 아래 `WORKS_DRIVE_REPORT_FOLDER_NAME` 하위 폴더를 찾거나 생성한 뒤 `reports/*.md`만 업로드한다.
|
||||
- 업로드 파일명은 `backup-report-YYYYMMDD-HHMMSSZ.md`처럼 업로드 시각을 붙인다.
|
||||
- `reports/cloud-upload.json`은 로컬 실행 기록이며 Drive 업로드 대상에서 제외한다.
|
||||
8. 업로드 결과를 `reports/cloud-upload.json`에 기록한다.
|
||||
|
||||
대상 drive 선택:
|
||||
|
||||
| `WORKS_DRIVE_TARGET` | 필요 변수 | 업로드 URL 생성 API |
|
||||
| --- | --- | --- |
|
||||
| `sharedrive` | `WORKS_DRIVE_SHARED_DRIVE_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/sharedrives/{sharedriveId}/files[/<folderFileId>]` |
|
||||
| `mydrive` | 선택 `WORKS_DRIVE_USER_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/users/{userId}/drive/files[/<folderFileId>]` |
|
||||
| `group` | `WORKS_DRIVE_GROUP_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/groups/{groupId}/folder/files[/<folderFileId>]` |
|
||||
| `sharedfolder` | `WORKS_DRIVE_USER_ID`, `WORKS_DRIVE_SHARED_FOLDER_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/users/{userId}/drive/sharedfolders/{sharedFolderId}/files[/<folderFileId>]` |
|
||||
|
||||
운영 주의:
|
||||
|
||||
- 업로드 archive는 `.tar.zst`로 고정한다. `zstd`가 없으면 실패해야 한다.
|
||||
- Drive API는 `file` scope가 필요하다.
|
||||
- `WORKS_DRIVE_PARENT_FILE_ID`는 폴더 이름이나 경로가 아니라 WORKS Drive API가 반환하는 폴더 `fileId`여야 한다.
|
||||
- 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 백업 업로드용 `WORKS_DRIVE_OAUTH_*`는 용도가 다른 앱/키로 분리한다.
|
||||
- 운영 기본 경로는 Drive용 access token을 명시 주입하거나 `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`으로 갱신하는 방식이다.
|
||||
- 서비스 계정 JWT fallback은 Drive 업로드 앱 정책에서 Drive scope 위임이 허용된 경우에만 성공한다.
|
||||
- 파일 크기가 WORKS Drive 단일 파일 제한에 걸릴 수 있으면 `WORKS_DRIVE_MAX_SINGLE_FILE_BYTES` 또는 `WORKS_DRIVE_FORCE_SPLIT=true`로 split part 업로드를 사용한다.
|
||||
- Markdown report 업로드 기본 폴더명은 `reports`이며 `WORKS_DRIVE_REPORT_FOLDER_NAME`으로 바꿀 수 있다.
|
||||
- 외부 업로드 성공은 복구 가능성을 보장하지 않는다. 업로드 후에도 `make dump-verify BACKUP=...`와 restore rehearsal을 별도로 수행해야 한다.
|
||||
|
||||
## 백업 모드
|
||||
|
||||
### 1. Offline backup
|
||||
|
||||
@@ -264,6 +264,15 @@ subtitle = "List of owners with top-level permissions for this tenant."
|
||||
|
||||
[msg.admin.tenants.registry]
|
||||
count = "{{count}} tenants loaded."
|
||||
scope_results = "{{count}} tenants under {{name}}"
|
||||
scope_search_results = "{{count}} search results under {{name}}"
|
||||
search_results = "{{count}} search results"
|
||||
table_hint = "Compare IDs, status, and size quickly in the sortable flat list."
|
||||
tree_hint = "Review parent-child relationships and subtree coverage in the hierarchy."
|
||||
|
||||
[msg.admin.tenants]
|
||||
empty_scope = "There are no child tenants to display in the selected scope."
|
||||
empty_search = "No tenants match the current search."
|
||||
|
||||
[msg.admin.tenants.schema]
|
||||
empty = "No custom fields defined. Click \\\\\\\"Add Field\\\\\\\" to begin."
|
||||
@@ -1157,11 +1166,13 @@ user = "TENANT MEMBER"
|
||||
[ui.admin.tenants]
|
||||
add = "Add Tenant"
|
||||
csv_template = "Template"
|
||||
data_mgmt = "Data Management"
|
||||
delete_selected = "Delete Selected"
|
||||
export_with_ids = "Include UUIDs"
|
||||
export_without_ids = "Export without UUIDs"
|
||||
import = "Import"
|
||||
seed_badge = "Seed"
|
||||
path.root = "Root"
|
||||
title = "Tenant Registry"
|
||||
view_org_chart = "View Full Org Chart"
|
||||
|
||||
@@ -1441,9 +1452,14 @@ status = "STATUS"
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
actions = "ACTIONS"
|
||||
context = "Parent Path"
|
||||
id = "ID"
|
||||
id_copy = "Copy ID"
|
||||
members = "Members"
|
||||
members_count = "{{count}} members"
|
||||
members_recursive = "including descendants"
|
||||
name = "NAME"
|
||||
root = "Top Level"
|
||||
slug = "SLUG"
|
||||
status = "STATUS"
|
||||
type = "TYPE"
|
||||
@@ -2586,6 +2602,10 @@ title_remote = "Sign-in Approved"
|
||||
logout = "Logout"
|
||||
profile = "My Profile"
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = "Collapse sidebar"
|
||||
expand = "Expand sidebar"
|
||||
|
||||
[ui.shell.profile]
|
||||
menu_aria = "Open account menu"
|
||||
menu_title = "Account"
|
||||
|
||||
@@ -765,6 +765,15 @@ subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목
|
||||
|
||||
[msg.admin.tenants.registry]
|
||||
count = "총 {{count}}개 테넌트"
|
||||
scope_results = "{{name}} 하위 {{count}}개"
|
||||
scope_search_results = "{{name}} 하위 검색 결과 {{count}}개"
|
||||
search_results = "검색 결과 {{count}}개"
|
||||
table_hint = "정렬 가능한 평면 목록에서 ID, 상태, 규모를 빠르게 비교합니다."
|
||||
tree_hint = "계층 구조를 따라 부모-자식 관계와 하위 범위를 함께 확인합니다."
|
||||
|
||||
[msg.admin.tenants]
|
||||
empty_scope = "선택한 범위에 표시할 하위 테넌트가 없습니다."
|
||||
empty_search = "검색 조건에 맞는 테넌트가 없습니다."
|
||||
|
||||
[msg.admin.tenants.schema]
|
||||
empty = "등록된 커스텀 필드가 없습니다. 필드 추가를 눌러 시작하세요."
|
||||
@@ -1652,8 +1661,10 @@ user = "TENANT MEMBER"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "테넌트 추가"
|
||||
data_mgmt = "데이터 관리"
|
||||
delete_selected = "선택 삭제"
|
||||
seed_badge = "초기 설정"
|
||||
path.root = "최상위"
|
||||
title = "테넌트 목록"
|
||||
view_org_chart = "전체 조직도 보기"
|
||||
|
||||
@@ -1904,13 +1915,18 @@ status = "STATUS"
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
actions = "ACTIONS"
|
||||
context = "상위 경로"
|
||||
id = "ID"
|
||||
id_copy = "ID 복사"
|
||||
members = "멤버수"
|
||||
name = "NAME"
|
||||
slug = "SLUG"
|
||||
status = "STATUS"
|
||||
members_count = "{{count}}명"
|
||||
members_recursive = "하위 포함"
|
||||
name = "이름"
|
||||
root = "최상위"
|
||||
slug = "슬러그"
|
||||
status = "상태"
|
||||
type = "유형"
|
||||
updated = "UPDATED"
|
||||
updated = "수정일"
|
||||
created = "CREATED"
|
||||
created = "CREATED"
|
||||
|
||||
@@ -3011,6 +3027,10 @@ title_remote = "로그인 승인 완료"
|
||||
logout = "로그아웃"
|
||||
profile = "내 정보"
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = "사이드바 접기"
|
||||
expand = "사이드바 펼치기"
|
||||
|
||||
[ui.shell.profile]
|
||||
menu_aria = "계정 메뉴 열기"
|
||||
menu_title = "계정"
|
||||
|
||||
@@ -622,6 +622,15 @@ subtitle = ""
|
||||
|
||||
[msg.admin.tenants.registry]
|
||||
count = ""
|
||||
scope_results = ""
|
||||
scope_search_results = ""
|
||||
search_results = ""
|
||||
table_hint = ""
|
||||
tree_hint = ""
|
||||
|
||||
[msg.admin.tenants]
|
||||
empty_scope = ""
|
||||
empty_search = ""
|
||||
|
||||
[msg.admin.tenants.schema]
|
||||
empty = ""
|
||||
@@ -1514,6 +1523,7 @@ user = ""
|
||||
add = ""
|
||||
delete_selected = ""
|
||||
seed_badge = ""
|
||||
path.root = ""
|
||||
title = ""
|
||||
view_org_chart = ""
|
||||
|
||||
@@ -1781,9 +1791,14 @@ status = ""
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
actions = ""
|
||||
context = ""
|
||||
id = ""
|
||||
id_copy = ""
|
||||
members = ""
|
||||
members_count = ""
|
||||
members_recursive = ""
|
||||
name = ""
|
||||
root = ""
|
||||
slug = ""
|
||||
status = ""
|
||||
type = ""
|
||||
@@ -2890,6 +2905,10 @@ title_remote = ""
|
||||
logout = ""
|
||||
profile = ""
|
||||
|
||||
[ui.shell.sidebar]
|
||||
collapse = ""
|
||||
expand = ""
|
||||
|
||||
[ui.shell.profile]
|
||||
menu_aria = ""
|
||||
menu_title = ""
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
FROM node:lts
|
||||
FROM node:lts AS build
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Set CI environment variable to true to avoid TTY issues with pnpm
|
||||
ENV CI=true
|
||||
ENV ORGFRONT_BUILD_OUT_DIR=/workspace/orgfront/dist
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.5.2 --activate
|
||||
|
||||
# Copy workspace configs and common package
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY common ./common
|
||||
COPY orgfront ./orgfront
|
||||
|
||||
# Install dependencies for the workspace
|
||||
RUN pnpm install --filter orgfront... --filter baron-sso... --no-frozen-lockfile --ignore-scripts
|
||||
ARG VITE_ORGFRONT_PUBLIC_URL
|
||||
ARG VITE_OIDC_AUTHORITY
|
||||
ARG VITE_OIDC_CLIENT_ID
|
||||
ENV VITE_ORGFRONT_PUBLIC_URL=$VITE_ORGFRONT_PUBLIC_URL
|
||||
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
|
||||
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
|
||||
|
||||
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
|
||||
RUN npm install -g serve
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /workspace/orgfront
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV FRONTEND_DIST_DIR=/app/dist
|
||||
ENV PORT=5175
|
||||
|
||||
COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs
|
||||
COPY --from=build /workspace/orgfront/dist ./dist
|
||||
|
||||
# Vite 기본 포트
|
||||
EXPOSE 5175
|
||||
|
||||
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
|
||||
RUN chmod +x ./scripts/runtime-mode.sh
|
||||
CMD ["sh", "./scripts/runtime-mode.sh"]
|
||||
CMD ["node", "./serve_frontend_prod.mjs"]
|
||||
|
||||
15
scripts/backup/dump-list.sh
Executable file
15
scripts/backup/dump-list.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$script_dir/lib/common.sh"
|
||||
|
||||
repo_root="$(backup_repo_root)"
|
||||
backup_root="${BACKUP_ROOT:-$repo_root/backups}"
|
||||
|
||||
if [[ ! -d "$backup_root" ]]; then
|
||||
backup_log "No backup directory found: $backup_root"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
find "$backup_root" -maxdepth 1 -type d -name 'baron-sso-backup-*' | sort
|
||||
69
scripts/backup/dump.sh
Executable file
69
scripts/backup/dump.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo 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/clickhouse.sh"
|
||||
source "$script_dir/lib/config.sh"
|
||||
source "$script_dir/lib/report.sh"
|
||||
|
||||
repo_root="$(backup_repo_root)"
|
||||
services="$(normalize_service_filter "${DUMP_SERVICES:-all}")"
|
||||
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"
|
||||
service_timings_json="[]"
|
||||
|
||||
run_backup_step() {
|
||||
local service="$1"
|
||||
shift
|
||||
|
||||
local started_at
|
||||
local finished_at
|
||||
local duration_seconds
|
||||
|
||||
started_at="$(date +%s)"
|
||||
"$@"
|
||||
finished_at="$(date +%s)"
|
||||
duration_seconds="$((finished_at - started_at))"
|
||||
service_timings_json="$(jq -c \
|
||||
--arg service "$service" \
|
||||
--argjson duration "$duration_seconds" \
|
||||
'. + [{service:$service, duration_seconds:$duration}]' \
|
||||
<<<"$service_timings_json")"
|
||||
}
|
||||
|
||||
backup_log "Creating backup at $backup_dir"
|
||||
backup_log "Backup mode: $mode"
|
||||
backup_log "Services: $services"
|
||||
|
||||
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 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 config "$services"; then
|
||||
run_backup_step config dump_config_snapshot "$backup_dir"
|
||||
fi
|
||||
|
||||
write_backup_markdown_report "$backup_dir" "succeeded" "$services" "$service_timings_json"
|
||||
backup_checksum_file "$backup_dir"
|
||||
BACKUP="$backup_dir" "$script_dir/verify-dump.sh"
|
||||
|
||||
backup_log "Backup complete: $backup_dir"
|
||||
115
scripts/backup/lib/clickhouse.sh
Normal file
115
scripts/backup/lib/clickhouse.sh
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
clickhouse_query() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local password="$3"
|
||||
local query="$4"
|
||||
|
||||
docker exec "$container" clickhouse-client --user "$user" --password "$password" --query "$query"
|
||||
}
|
||||
|
||||
render_clickhouse_schema() {
|
||||
local schema_file="$1"
|
||||
|
||||
if head -n 1 "$schema_file" | grep -q '\\n'; then
|
||||
perl -0pe 's{\\n}{\n}g; s{\\t}{\t}g; s{\\\x27}{\x27}g' "$schema_file"
|
||||
return
|
||||
fi
|
||||
|
||||
cat "$schema_file"
|
||||
}
|
||||
|
||||
dump_clickhouse_container() {
|
||||
local backup_dir="$1"
|
||||
local service_name="$2"
|
||||
local container="$3"
|
||||
local user="$4"
|
||||
local password="$5"
|
||||
local output_dir="$backup_dir/clickhouse/$service_name"
|
||||
local table_list="$output_dir/tables.tsv"
|
||||
local database
|
||||
local table
|
||||
local engine
|
||||
local safe_name
|
||||
|
||||
backup_require_command docker
|
||||
backup_require_container "$container"
|
||||
mkdir -p "$output_dir/schema" "$output_dir/data" "$backup_dir/reports"
|
||||
|
||||
backup_log "Dumping ClickHouse metadata and Native data: $container"
|
||||
clickhouse_query "$container" "$user" "$password" \
|
||||
"select database, name, engine from system.tables where database not in ('INFORMATION_SCHEMA','information_schema','system') order by database, if(positionCaseInsensitive(engine, 'View') > 0, 1, 0), name format TSV" \
|
||||
>"$table_list"
|
||||
|
||||
while IFS=$'\t' read -r database table engine; do
|
||||
[[ -n "$database" && -n "$table" ]] || continue
|
||||
safe_name="${database}__${table}"
|
||||
clickhouse_query "$container" "$user" "$password" "show create table \`${database}\`.\`${table}\` FORMAT RawBLOB" >"$output_dir/schema/${safe_name}.sql"
|
||||
if [[ "$engine" != *View* ]]; then
|
||||
clickhouse_query "$container" "$user" "$password" "select * from \`${database}\`.\`${table}\` format Native" >"$output_dir/data/${safe_name}.native"
|
||||
fi
|
||||
clickhouse_query "$container" "$user" "$password" "select '${database}.${table}:' || toString(count()) from \`${database}\`.\`${table}\`" \
|
||||
>>"$backup_dir/reports/${service_name}-row-counts.txt"
|
||||
done <"$table_list"
|
||||
}
|
||||
|
||||
restore_clickhouse_container() {
|
||||
local backup_dir="$1"
|
||||
local service_name="$2"
|
||||
local container="$3"
|
||||
local user="$4"
|
||||
local password="$5"
|
||||
local input_dir="$backup_dir/clickhouse/$service_name"
|
||||
local table_list="$input_dir/tables.tsv"
|
||||
local database
|
||||
local table
|
||||
local engine
|
||||
local safe_name
|
||||
local restored_databases=""
|
||||
|
||||
backup_require_command docker
|
||||
backup_require_container "$container"
|
||||
backup_require_path "$table_list"
|
||||
|
||||
backup_log "Restoring ClickHouse tables: $container"
|
||||
while IFS=$'\t' read -r database table engine; do
|
||||
[[ -n "$database" && -n "$table" ]] || continue
|
||||
if ! grep -qw -- "$database" <<<"$restored_databases"; then
|
||||
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||
--query "drop database if exists \`${database}\`"
|
||||
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||
--query "create database if not exists \`${database}\`"
|
||||
restored_databases="${restored_databases:+$restored_databases }$database"
|
||||
fi
|
||||
done <"$table_list"
|
||||
|
||||
while IFS=$'\t' read -r database table engine; do
|
||||
[[ -n "$database" && -n "$table" ]] || continue
|
||||
safe_name="${database}__${table}"
|
||||
backup_require_path "$input_dir/schema/${safe_name}.sql"
|
||||
render_clickhouse_schema "$input_dir/schema/${safe_name}.sql" \
|
||||
| docker exec -i "$container" clickhouse-client --user "$user" --password "$password" --multiquery
|
||||
if [[ "$engine" != *View* ]]; then
|
||||
backup_require_path "$input_dir/data/${safe_name}.native"
|
||||
docker exec -i "$container" clickhouse-client --user "$user" --password "$password" \
|
||||
--query "insert into \`${database}\`.\`${table}\` format Native" <"$input_dir/data/${safe_name}.native"
|
||||
fi
|
||||
done <"$table_list"
|
||||
}
|
||||
|
||||
dump_baron_clickhouse() {
|
||||
dump_clickhouse_container "$1" "baron_clickhouse" "baron_clickhouse" "${CLICKHOUSE_USER:-baron}" "${CLICKHOUSE_PASSWORD:-password}"
|
||||
}
|
||||
|
||||
dump_ory_clickhouse() {
|
||||
dump_clickhouse_container "$1" "ory_clickhouse" "ory_clickhouse" "${ORY_CLICKHOUSE_USER:-ory}" "${ORY_CLICKHOUSE_PASSWORD:-orypass}"
|
||||
}
|
||||
|
||||
restore_baron_clickhouse() {
|
||||
restore_clickhouse_container "$1" "baron_clickhouse" "baron_clickhouse" "${CLICKHOUSE_USER:-baron}" "${CLICKHOUSE_PASSWORD:-password}"
|
||||
}
|
||||
|
||||
restore_ory_clickhouse() {
|
||||
restore_clickhouse_container "$1" "ory_clickhouse" "ory_clickhouse" "${ORY_CLICKHOUSE_USER:-ory}" "${ORY_CLICKHOUSE_PASSWORD:-orypass}"
|
||||
}
|
||||
137
scripts/backup/lib/common.sh
Normal file
137
scripts/backup/lib/common.sh
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
BACKUP_SUPPORTED_SERVICES="postgres ory-postgres clickhouse ory-clickhouse config"
|
||||
|
||||
backup_repo_root() {
|
||||
if [[ -n "${BACKUP_REPO_ROOT:-}" ]]; then
|
||||
printf '%s\n' "$BACKUP_REPO_ROOT"
|
||||
return
|
||||
fi
|
||||
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
printf '%s\n' "$script_dir"
|
||||
}
|
||||
|
||||
backup_log() {
|
||||
printf '==> %s\n' "$*"
|
||||
}
|
||||
|
||||
backup_die() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
backup_require_command() {
|
||||
local command_name="$1"
|
||||
command -v "$command_name" >/dev/null 2>&1 || backup_die "required command not found: $command_name"
|
||||
}
|
||||
|
||||
backup_utc_now() {
|
||||
date -u '+%Y-%m-%dT%H:%M:%SZ'
|
||||
}
|
||||
|
||||
backup_timestamp() {
|
||||
date -u '+%Y%m%d-%H%M%SZ'
|
||||
}
|
||||
|
||||
backup_git_commit() {
|
||||
local repo_root="$1"
|
||||
git -c "safe.directory=$repo_root" -C "$repo_root" rev-parse --short=12 HEAD 2>/dev/null || printf 'unknown'
|
||||
}
|
||||
|
||||
normalize_service_filter() {
|
||||
local raw="${1:-all}"
|
||||
local normalized=""
|
||||
local candidate
|
||||
|
||||
if [[ "$raw" == "all" || -z "$raw" ]]; then
|
||||
printf '%s\n' "$BACKUP_SUPPORTED_SERVICES"
|
||||
return
|
||||
fi
|
||||
|
||||
raw="${raw//,/ }"
|
||||
for candidate in $raw; do
|
||||
if ! grep -qw -- "$candidate" <<<"$BACKUP_SUPPORTED_SERVICES"; then
|
||||
backup_die "unknown backup service: $candidate"
|
||||
return 1
|
||||
fi
|
||||
if ! grep -qw -- "$candidate" <<<"$normalized"; then
|
||||
normalized="${normalized:+$normalized }$candidate"
|
||||
fi
|
||||
done
|
||||
|
||||
[[ -n "$normalized" ]] || backup_die "service filter must not be empty"
|
||||
printf '%s\n' "$normalized"
|
||||
}
|
||||
|
||||
service_enabled() {
|
||||
local service="$1"
|
||||
local services="$2"
|
||||
local candidate
|
||||
|
||||
for candidate in $services; do
|
||||
[[ "$candidate" == "$service" ]] && return 0
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
backup_require_path() {
|
||||
local path="$1"
|
||||
[[ -e "$path" ]] || backup_die "required path not found: $path"
|
||||
}
|
||||
|
||||
backup_container_running() {
|
||||
local container="$1"
|
||||
docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -qx 'true'
|
||||
}
|
||||
|
||||
backup_require_container() {
|
||||
local container="$1"
|
||||
backup_container_running "$container" || backup_die "container is not running: $container"
|
||||
}
|
||||
|
||||
backup_redact_env() {
|
||||
local source_file="$1"
|
||||
local target_file="$2"
|
||||
|
||||
sed -E '/^[[:space:]]*#/! s/^([^=]*(SECRET|PASSWORD|TOKEN|KEY|PRIVATE|CLIENT_SECRET|COOKIE)[^=]*)=.*/\1=REDACTED/I' "$source_file" >"$target_file"
|
||||
}
|
||||
|
||||
backup_checksum_file() {
|
||||
local backup_dir="$1"
|
||||
(
|
||||
cd "$backup_dir"
|
||||
find . -type f ! -name 'checksums.sha256' -print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum
|
||||
) >"$backup_dir/checksums.sha256"
|
||||
}
|
||||
|
||||
backup_verify_checksums() {
|
||||
local backup_dir="$1"
|
||||
backup_require_path "$backup_dir/checksums.sha256"
|
||||
(
|
||||
cd "$backup_dir"
|
||||
sha256sum -c checksums.sha256
|
||||
)
|
||||
}
|
||||
|
||||
backup_safe_tar() {
|
||||
local source_path="$1"
|
||||
local target_base="$2"
|
||||
local source_parent
|
||||
local source_name
|
||||
|
||||
[[ -e "$source_path" ]] || return 0
|
||||
|
||||
source_parent="$(dirname "$source_path")"
|
||||
source_name="$(basename "$source_path")"
|
||||
|
||||
if command -v zstd >/dev/null 2>&1; then
|
||||
tar --zstd -cf "${target_base}.tar.zst" -C "$source_parent" "$source_name"
|
||||
else
|
||||
tar -czf "${target_base}.tar.gz" -C "$source_parent" "$source_name"
|
||||
fi
|
||||
}
|
||||
37
scripts/backup/lib/config.sh
Normal file
37
scripts/backup/lib/config.sh
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
dump_config_snapshot() {
|
||||
local backup_dir="$1"
|
||||
local repo_root
|
||||
|
||||
repo_root="$(backup_repo_root)"
|
||||
mkdir -p "$backup_dir/config"
|
||||
|
||||
if [[ -f "$repo_root/.env" ]]; then
|
||||
backup_redact_env "$repo_root/.env" "$backup_dir/config/env.redacted"
|
||||
fi
|
||||
|
||||
backup_safe_tar "$repo_root/config/.generated/ory" "$backup_dir/config/generated-ory"
|
||||
backup_safe_tar "$repo_root/gateway" "$backup_dir/config/gateway"
|
||||
|
||||
mkdir -p "$backup_dir/config/compose"
|
||||
for compose_file in compose.infra.yaml compose.ory.yaml docker-compose.yaml; do
|
||||
if [[ -f "$repo_root/$compose_file" ]]; then
|
||||
cp "$repo_root/$compose_file" "$backup_dir/config/compose/$compose_file"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
restore_config_snapshot() {
|
||||
local backup_dir="$1"
|
||||
local repo_root
|
||||
local output_dir
|
||||
|
||||
repo_root="$(backup_repo_root)"
|
||||
output_dir="$repo_root/config-restored"
|
||||
|
||||
backup_require_path "$backup_dir/config"
|
||||
mkdir -p "$output_dir"
|
||||
cp -R "$backup_dir/config/." "$output_dir/"
|
||||
backup_log "Config snapshot was copied to $output_dir for manual review."
|
||||
}
|
||||
42
scripts/backup/lib/manifest.sh
Normal file
42
scripts/backup/lib/manifest.sh
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
create_manifest() {
|
||||
local backup_dir="$1"
|
||||
local mode="$2"
|
||||
local services="$3"
|
||||
local repo_root
|
||||
local created_at
|
||||
local git_commit
|
||||
local service
|
||||
local first=1
|
||||
|
||||
repo_root="$(backup_repo_root)"
|
||||
created_at="$(backup_utc_now)"
|
||||
git_commit="$(backup_git_commit "$repo_root")"
|
||||
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "format_version": "1",\n'
|
||||
printf ' "created_at": "%s",\n' "$created_at"
|
||||
printf ' "git_commit": "%s",\n' "$git_commit"
|
||||
printf ' "mode": "%s",\n' "$mode"
|
||||
printf ' "environment_scope": "same-env-only",\n'
|
||||
printf ' "services": ['
|
||||
for service in $services; do
|
||||
if [[ "$first" -eq 1 ]]; then
|
||||
first=0
|
||||
else
|
||||
printf ', '
|
||||
fi
|
||||
printf '"%s"' "$service"
|
||||
done
|
||||
printf '],\n'
|
||||
printf ' "restore_policy": {\n'
|
||||
printf ' "requires_empty_target": true,\n'
|
||||
printf ' "requires_confirmation": "baron-sso",\n'
|
||||
printf ' "auto_run_migrations": false,\n'
|
||||
printf ' "works_relay_auto_resume": false\n'
|
||||
printf ' }\n'
|
||||
printf '}\n'
|
||||
} >"$backup_dir/manifest.json"
|
||||
}
|
||||
95
scripts/backup/lib/postgres.sh
Normal file
95
scripts/backup/lib/postgres.sh
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
dump_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}"
|
||||
|
||||
backup_require_command docker
|
||||
backup_require_container baron_postgres
|
||||
mkdir -p "$backup_dir/postgres" "$backup_dir/reports"
|
||||
|
||||
backup_log "Dumping Baron Postgres database: $db_name"
|
||||
docker exec -e "PGPASSWORD=$db_password" baron_postgres \
|
||||
pg_dump -U "$db_user" -d "$db_name" -Fc >"$backup_dir/postgres/baron.dump"
|
||||
|
||||
docker exec -e "PGPASSWORD=$db_password" baron_postgres \
|
||||
psql -U "$db_user" -d "$db_name" -Atc "select schemaname || '.' || relname || ':' || (xpath('/row/c/text()', query_to_xml(format('select count(*) as c from %I.%I', schemaname, relname), false, true, '')))[1]::text from pg_stat_user_tables order by 1" \
|
||||
>"$backup_dir/reports/baron-postgres-row-counts.txt"
|
||||
}
|
||||
|
||||
dump_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 hydra_db="${HYDRA_DB:-ory_hydra}"
|
||||
local keto_db="${KETO_DB:-ory_keto}"
|
||||
local db_name
|
||||
|
||||
backup_require_command docker
|
||||
backup_require_container ory_postgres
|
||||
mkdir -p "$backup_dir/postgres" "$backup_dir/reports"
|
||||
|
||||
backup_log "Dumping Ory Postgres globals"
|
||||
docker exec -e "PGPASSWORD=$db_password" ory_postgres \
|
||||
pg_dumpall -U "$db_user" --globals-only >"$backup_dir/postgres/globals.sql"
|
||||
|
||||
for db_name in "$kratos_db" "$hydra_db" "$keto_db"; do
|
||||
backup_log "Dumping Ory Postgres database: $db_name"
|
||||
docker exec -e "PGPASSWORD=$db_password" ory_postgres \
|
||||
pg_dump -U "$db_user" -d "$db_name" -Fc >"$backup_dir/postgres/${db_name}.dump"
|
||||
docker exec -e "PGPASSWORD=$db_password" ory_postgres \
|
||||
psql -U "$db_user" -d "$db_name" -Atc "select schemaname || '.' || relname || ':' || (xpath('/row/c/text()', query_to_xml(format('select count(*) as c from %I.%I', schemaname, relname), false, true, '')))[1]::text from pg_stat_user_tables order by 1" \
|
||||
>"$backup_dir/reports/${db_name}-row-counts.txt"
|
||||
done
|
||||
}
|
||||
|
||||
restore_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}"
|
||||
|
||||
backup_require_path "$backup_dir/postgres/baron.dump"
|
||||
backup_require_command docker
|
||||
backup_require_container baron_postgres
|
||||
|
||||
backup_log "Restoring Baron Postgres database: $db_name"
|
||||
docker exec -i -e "PGPASSWORD=$db_password" baron_postgres \
|
||||
pg_restore -U "$db_user" -d "$db_name" --clean --if-exists <"$backup_dir/postgres/baron.dump"
|
||||
}
|
||||
|
||||
restore_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 hydra_db="${HYDRA_DB:-ory_hydra}"
|
||||
local keto_db="${KETO_DB:-ory_keto}"
|
||||
local db_name
|
||||
|
||||
backup_require_command docker
|
||||
backup_require_container ory_postgres
|
||||
|
||||
for db_name in "$kratos_db" "$hydra_db" "$keto_db"; do
|
||||
backup_require_path "$backup_dir/postgres/${db_name}.dump"
|
||||
backup_log "Restoring Ory Postgres database: $db_name"
|
||||
docker exec -i -e "PGPASSWORD=$db_password" ory_postgres \
|
||||
pg_restore -U "$db_user" -d "$db_name" --clean --if-exists <"$backup_dir/postgres/${db_name}.dump"
|
||||
done
|
||||
}
|
||||
|
||||
postgres_target_has_data() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local password="$3"
|
||||
local database="$4"
|
||||
|
||||
backup_require_command docker
|
||||
backup_require_container "$container"
|
||||
docker exec -e "PGPASSWORD=$password" "$container" \
|
||||
psql -U "$user" -d "$database" -Atc "select exists (select 1 from pg_tables where schemaname not in ('pg_catalog','information_schema') limit 1)" \
|
||||
2>/dev/null | grep -qx 't'
|
||||
}
|
||||
142
scripts/backup/lib/report.sh
Normal file
142
scripts/backup/lib/report.sh
Normal file
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
report_count_from_file() {
|
||||
local file_path="$1"
|
||||
local key="$2"
|
||||
|
||||
if [[ ! -f "$file_path" ]]; then
|
||||
printf '0\n'
|
||||
return
|
||||
fi
|
||||
|
||||
awk -F: -v key="$key" '$1 == key {print $2; found=1; exit} END {if (!found) print "0"}' "$file_path"
|
||||
}
|
||||
|
||||
report_first_count() {
|
||||
local key="$1"
|
||||
shift
|
||||
|
||||
local file_path
|
||||
local count
|
||||
|
||||
for file_path in "$@"; do
|
||||
count="$(report_count_from_file "$file_path" "$key")"
|
||||
if [[ "$count" != "0" ]]; then
|
||||
printf '%s\n' "$count"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
printf '0\n'
|
||||
}
|
||||
|
||||
write_backup_markdown_report() {
|
||||
local backup_dir="$1"
|
||||
local status="$2"
|
||||
local services="$3"
|
||||
local timings_json="${4:-[]}"
|
||||
local reports_dir="$backup_dir/reports"
|
||||
local output_file="$reports_dir/backup-report.md"
|
||||
local created_at
|
||||
local manifest_created_at="unknown"
|
||||
local git_commit="unknown"
|
||||
local users
|
||||
local tenants
|
||||
local relying_parties
|
||||
local hydra_clients
|
||||
local works_org_units
|
||||
local works_users
|
||||
local timings_table
|
||||
|
||||
mkdir -p "$reports_dir"
|
||||
created_at="$(backup_utc_now)"
|
||||
|
||||
if [[ -f "$backup_dir/manifest.json" ]]; then
|
||||
manifest_created_at="$(jq -r '.created_at // "unknown"' "$backup_dir/manifest.json")"
|
||||
git_commit="$(jq -r '.git_commit // "unknown"' "$backup_dir/manifest.json")"
|
||||
fi
|
||||
|
||||
users="$(report_first_count "public.users" "$reports_dir/baron-postgres-row-counts.txt")"
|
||||
tenants="$(report_first_count "public.tenants" "$reports_dir/baron-postgres-row-counts.txt")"
|
||||
relying_parties="$(report_first_count "public.relying_parties" "$reports_dir/baron-postgres-row-counts.txt")"
|
||||
hydra_clients="$(report_first_count "public.hydra_client" "$reports_dir/ory_hydra-row-counts.txt")"
|
||||
works_org_units="$(report_first_count "public.works_org_units" "$reports_dir/baron-postgres-row-counts.txt")"
|
||||
works_users="$(report_first_count "public.works_users" "$reports_dir/baron-postgres-row-counts.txt")"
|
||||
|
||||
timings_table="$(jq -r '
|
||||
if type != "array" or length == 0 then
|
||||
"| 없음 | 0 |"
|
||||
else
|
||||
.[] | "| \(.service) | \(.duration_seconds) |"
|
||||
end
|
||||
' <<<"$timings_json")"
|
||||
|
||||
{
|
||||
printf '# Baron SSO Backup Report\n\n'
|
||||
printf '| 항목 | 값 |\n'
|
||||
printf '| --- | --- |\n'
|
||||
printf '| 생성 시각 | %s |\n' "$created_at"
|
||||
printf '| 백업 시각 | %s |\n' "$manifest_created_at"
|
||||
printf '| 상태 | %s |\n' "$status"
|
||||
printf '| 백업 경로 | `%s` |\n' "$backup_dir"
|
||||
printf '| Git Commit | `%s` |\n' "$git_commit"
|
||||
printf '| 서비스 | `%s` |\n\n' "$services"
|
||||
printf '## 요약\n\n'
|
||||
printf '| 지표 | 값 |\n'
|
||||
printf '| --- | ---: |\n'
|
||||
printf '| 사용자 | %s |\n' "$users"
|
||||
printf '| 테넌트 | %s |\n' "$tenants"
|
||||
printf '| RP | %s |\n' "$relying_parties"
|
||||
printf '| Hydra Client | %s |\n' "$hydra_clients"
|
||||
printf '| WORKS 조직 | %s |\n' "$works_org_units"
|
||||
printf '| WORKS 사용자 | %s |\n\n' "$works_users"
|
||||
printf '## 서비스별 수행 시간\n\n'
|
||||
printf '| 서비스 | 초 |\n'
|
||||
printf '| --- | ---: |\n'
|
||||
printf '%s\n' "$timings_table"
|
||||
} >"$output_file"
|
||||
}
|
||||
|
||||
write_restore_markdown_report() {
|
||||
local restore_json="$1"
|
||||
local output_file
|
||||
|
||||
[[ -f "$restore_json" ]] || return 0
|
||||
output_file="${restore_json%.json}.md"
|
||||
|
||||
jq -r '
|
||||
def services: (.services // [] | join(", "));
|
||||
def verification_rows:
|
||||
(.verification.target_reports // []) as $reports
|
||||
| if ($reports | length) == 0 then
|
||||
"| 없음 | not_run | |"
|
||||
else
|
||||
$reports[]
|
||||
| "| \(.service) | \(.status) | \(.diff_file // "") |"
|
||||
end;
|
||||
"# Baron SSO Restore Report\n",
|
||||
"| 항목 | 값 |",
|
||||
"| --- | --- |",
|
||||
"| 시작 시각 | \(.started_at // "unknown") |",
|
||||
"| 종료 시각 | \(.finished_at // "unknown") |",
|
||||
"| 상태 | \(.status // "unknown") |",
|
||||
"| 메시지 | \(.message // "") |",
|
||||
"| 입력 유형 | \(.backup_source // "unknown") |",
|
||||
"| 백업 경로 | `\(.backup_dir // "")` |",
|
||||
"| Dump 파일 | `\(.dump_file // "")` |",
|
||||
"| 서비스 | `\(services)` |",
|
||||
"",
|
||||
"## 검증",
|
||||
"",
|
||||
"| 항목 | 상태 |",
|
||||
"| --- | --- |",
|
||||
"| Dump checksum | \(.verification.dump_checksum // "not_run") |",
|
||||
"| 대상 row count | \(.verification.target_row_counts // "not_run") |",
|
||||
"",
|
||||
"## 대상별 검증 결과",
|
||||
"",
|
||||
"| 서비스 | 상태 | Diff |",
|
||||
"| --- | --- | --- |",
|
||||
verification_rows
|
||||
' "$restore_json" >"$output_file"
|
||||
}
|
||||
5
scripts/backup/restore-plan.sh
Executable file
5
scripts/backup/restore-plan.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKUP="${BACKUP:-${1:-}}" DUMP_FILE="${DUMP_FILE:-}" RESTORE_REPORT="${RESTORE_REPORT:-}" "$script_dir/restore.sh" --dry-run
|
||||
460
scripts/backup/restore.sh
Executable file
460
scripts/backup/restore.sh
Executable file
@@ -0,0 +1,460 @@
|
||||
#!/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/postgres.sh"
|
||||
source "$script_dir/lib/clickhouse.sh"
|
||||
source "$script_dir/lib/config.sh"
|
||||
source "$script_dir/lib/report.sh"
|
||||
|
||||
dry_run=false
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
dry_run=true
|
||||
fi
|
||||
|
||||
repo_root="$(backup_repo_root)"
|
||||
backup_input="${BACKUP:-}"
|
||||
dump_file="${DUMP_FILE:-}"
|
||||
backup_source="directory"
|
||||
temp_extract_dir=""
|
||||
report_path=""
|
||||
report_started_at="$(backup_utc_now)"
|
||||
report_status="started"
|
||||
report_message=""
|
||||
dump_checksum_status="not_run"
|
||||
target_verification_status="not_run"
|
||||
target_verification_reports="[]"
|
||||
|
||||
json_array_from_words() {
|
||||
local words="$1"
|
||||
if [[ -z "$words" ]]; then
|
||||
printf '[]\n'
|
||||
return
|
||||
fi
|
||||
|
||||
printf '%s\n' $words | jq -R . | jq -sc .
|
||||
}
|
||||
|
||||
write_restore_report() {
|
||||
local status="$1"
|
||||
local message="${2:-}"
|
||||
local finished_at
|
||||
local services_json
|
||||
local restore_policy_json="{}"
|
||||
|
||||
[[ -n "$report_path" ]] || return 0
|
||||
|
||||
finished_at="$(backup_utc_now)"
|
||||
services_json="$(json_array_from_words "${services:-}")"
|
||||
if [[ -n "${backup_dir:-}" && -f "$backup_dir/manifest.json" ]]; then
|
||||
restore_policy_json="$(jq -c '.restore_policy // {}' "$backup_dir/manifest.json")"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$report_path")"
|
||||
jq -n \
|
||||
--arg format_version "1" \
|
||||
--arg started_at "$report_started_at" \
|
||||
--arg finished_at "$finished_at" \
|
||||
--arg status "$status" \
|
||||
--arg message "$message" \
|
||||
--arg backup_source "$backup_source" \
|
||||
--arg backup_dir "${backup_dir:-}" \
|
||||
--arg dump_file "$dump_file" \
|
||||
--argjson services "$services_json" \
|
||||
--arg allow_non_empty_restore "${allow_non_empty:-false}" \
|
||||
--arg dry_run "$dry_run" \
|
||||
--arg dump_checksum "$dump_checksum_status" \
|
||||
--arg target_row_counts "$target_verification_status" \
|
||||
--argjson target_reports "$target_verification_reports" \
|
||||
--argjson restore_policy "$restore_policy_json" \
|
||||
'{
|
||||
format_version: $format_version,
|
||||
started_at: $started_at,
|
||||
finished_at: $finished_at,
|
||||
status: $status,
|
||||
message: $message,
|
||||
backup_source: $backup_source,
|
||||
backup_dir: $backup_dir,
|
||||
dump_file: (if $dump_file == "" then null else $dump_file end),
|
||||
services: $services,
|
||||
allow_non_empty_restore: ($allow_non_empty_restore == "true"),
|
||||
dry_run: ($dry_run == "true"),
|
||||
restore_policy: $restore_policy,
|
||||
verification: {
|
||||
dump_checksum: $dump_checksum,
|
||||
target_row_counts: $target_row_counts,
|
||||
target_reports: $target_reports
|
||||
}
|
||||
}' >"$report_path"
|
||||
write_restore_markdown_report "$report_path"
|
||||
}
|
||||
|
||||
cleanup_restore_input() {
|
||||
if [[ -n "$temp_extract_dir" ]]; then
|
||||
rm -rf "$temp_extract_dir"
|
||||
fi
|
||||
}
|
||||
|
||||
on_restore_error() {
|
||||
local exit_code=$?
|
||||
write_restore_report "failed" "${report_message:-restore failed}"
|
||||
cleanup_restore_input
|
||||
exit "$exit_code"
|
||||
}
|
||||
|
||||
trap on_restore_error ERR
|
||||
trap cleanup_restore_input EXIT
|
||||
|
||||
resolve_backup_input() {
|
||||
if [[ -n "$backup_input" && -n "$dump_file" ]]; then
|
||||
backup_die "set only one of BACKUP or DUMP_FILE for restore."
|
||||
fi
|
||||
|
||||
if [[ -n "$backup_input" ]]; then
|
||||
backup_dir="$backup_input"
|
||||
backup_require_path "$backup_dir"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -z "$dump_file" ]]; then
|
||||
backup_die "BACKUP or DUMP_FILE is required. Example: make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ CONFIRM_RESTORE=baron-sso"
|
||||
fi
|
||||
|
||||
backup_require_path "$dump_file"
|
||||
backup_require_command tar
|
||||
temp_extract_dir="$(mktemp -d /tmp/baron-sso-restore.XXXXXX)"
|
||||
backup_source="dump_file"
|
||||
|
||||
case "$dump_file" in
|
||||
*.tar.zst)
|
||||
backup_require_command zstd
|
||||
tar --zstd --no-same-owner -xf "$dump_file" -C "$temp_extract_dir"
|
||||
;;
|
||||
*.tar.gz | *.tgz)
|
||||
tar -xzf "$dump_file" -C "$temp_extract_dir"
|
||||
;;
|
||||
*)
|
||||
backup_die "unsupported DUMP_FILE archive format: $dump_file"
|
||||
;;
|
||||
esac
|
||||
|
||||
mapfile -t manifest_files < <(find "$temp_extract_dir" -type f -name manifest.json | sort)
|
||||
if [[ "${#manifest_files[@]}" -ne 1 ]]; then
|
||||
backup_die "DUMP_FILE must contain exactly one backup directory with manifest.json."
|
||||
fi
|
||||
|
||||
backup_dir="$(dirname "${manifest_files[0]}")"
|
||||
}
|
||||
|
||||
quote_pg_ident() {
|
||||
local raw="$1"
|
||||
printf '"%s"' "${raw//\"/\"\"}"
|
||||
}
|
||||
|
||||
collect_postgres_exact_row_counts() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local password="$3"
|
||||
local database="$4"
|
||||
local output_file="$5"
|
||||
local schema
|
||||
local table
|
||||
local quoted_schema
|
||||
local quoted_table
|
||||
local count
|
||||
|
||||
: >"$output_file"
|
||||
docker exec -e "PGPASSWORD=$password" "$container" \
|
||||
psql -U "$user" -d "$database" -At -F $'\t' \
|
||||
-c "select schemaname, tablename from pg_tables where schemaname not in ('pg_catalog','information_schema') order by 1,2" \
|
||||
| while IFS=$'\t' read -r schema table; do
|
||||
[[ -n "$schema" && -n "$table" ]] || continue
|
||||
quoted_schema="$(quote_pg_ident "$schema")"
|
||||
quoted_table="$(quote_pg_ident "$table")"
|
||||
count="$(docker exec -e "PGPASSWORD=$password" "$container" \
|
||||
psql -U "$user" -d "$database" -At \
|
||||
-c "select count(*) from ${quoted_schema}.${quoted_table}")"
|
||||
printf '%s.%s:%s\n' "$schema" "$table" "$count"
|
||||
done | sort >"$output_file"
|
||||
}
|
||||
|
||||
collect_postgres_dump_row_counts() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local password="$3"
|
||||
local database="$4"
|
||||
local dump_path="$5"
|
||||
local output_file="$6"
|
||||
local scratch_db
|
||||
local scratch_ident
|
||||
|
||||
backup_require_path "$dump_path"
|
||||
scratch_db="${database}_restore_verify_$(date -u '+%Y%m%d%H%M%S')_$$"
|
||||
scratch_ident="$(quote_pg_ident "$scratch_db")"
|
||||
|
||||
docker exec -e "PGPASSWORD=$password" "$container" \
|
||||
psql -U "$user" -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "drop database if exists ${scratch_ident} with (force)" \
|
||||
-c "create database ${scratch_ident}"
|
||||
|
||||
docker exec -i -e "PGPASSWORD=$password" "$container" \
|
||||
pg_restore -U "$user" -d "$scratch_db" --clean --if-exists <"$dump_path"
|
||||
|
||||
collect_postgres_exact_row_counts "$container" "$user" "$password" "$scratch_db" "$output_file"
|
||||
|
||||
docker exec -e "PGPASSWORD=$password" "$container" \
|
||||
psql -U "$user" -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "drop database if exists ${scratch_ident} with (force)"
|
||||
}
|
||||
|
||||
collect_clickhouse_exact_row_counts() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local password="$3"
|
||||
local table_list="$4"
|
||||
local output_file="$5"
|
||||
local database
|
||||
local table
|
||||
local engine
|
||||
local count
|
||||
|
||||
: >"$output_file"
|
||||
while IFS=$'\t' read -r database table engine; do
|
||||
[[ -n "$database" && -n "$table" ]] || continue
|
||||
count="$(docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||
--query "select count() from \`${database}\`.\`${table}\`")"
|
||||
printf '%s.%s:%s\n' "$database" "$table" "$count"
|
||||
done <"$table_list" | sort >"$output_file"
|
||||
}
|
||||
|
||||
collect_clickhouse_native_stable_row_counts() {
|
||||
local container="$1"
|
||||
local user="$2"
|
||||
local password="$3"
|
||||
local input_dir="$4"
|
||||
local output_file="$5"
|
||||
local scratch_db
|
||||
local database
|
||||
local table
|
||||
local engine
|
||||
local safe_name
|
||||
|
||||
scratch_db="$(basename "$input_dir")_restore_verify_$(date -u '+%Y%m%d%H%M%S')_$$"
|
||||
: >"$output_file"
|
||||
|
||||
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||
--query "drop database if exists \`${scratch_db}\`"
|
||||
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||
--query "create database \`${scratch_db}\`"
|
||||
|
||||
while IFS=$'\t' read -r database table engine; do
|
||||
[[ -n "$database" && -n "$table" ]] || continue
|
||||
if [[ "$engine" == *View* || "$engine" == *AggregatingMergeTree* ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
safe_name="${database}__${table}"
|
||||
backup_require_path "$input_dir/data/${safe_name}.native"
|
||||
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||
--query "create table \`${scratch_db}\`.\`${table}\` as \`${database}\`.\`${table}\`"
|
||||
docker exec -i "$container" clickhouse-client --user "$user" --password "$password" \
|
||||
--query "insert into \`${scratch_db}\`.\`${table}\` format Native" <"$input_dir/data/${safe_name}.native"
|
||||
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||
--query "select '${database}.${table}:' || toString(count()) from \`${scratch_db}\`.\`${table}\`" \
|
||||
>>"$output_file"
|
||||
done <"$input_dir/tables.tsv"
|
||||
|
||||
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||
--query "drop database if exists \`${scratch_db}\`"
|
||||
sort -o "$output_file" "$output_file"
|
||||
}
|
||||
|
||||
filter_clickhouse_stable_row_counts() {
|
||||
local table_list="$1"
|
||||
local counts_file="$2"
|
||||
local output_file="$3"
|
||||
local database
|
||||
local table
|
||||
local engine
|
||||
|
||||
: >"$output_file"
|
||||
while IFS=$'\t' read -r database table engine; do
|
||||
[[ -n "$database" && -n "$table" ]] || continue
|
||||
if [[ "$engine" == *View* || "$engine" == *AggregatingMergeTree* ]]; then
|
||||
continue
|
||||
fi
|
||||
grep -F "${database}.${table}:" "$counts_file" >>"$output_file" || true
|
||||
done <"$table_list"
|
||||
sort -o "$output_file" "$output_file"
|
||||
}
|
||||
|
||||
compare_row_count_report() {
|
||||
local label="$1"
|
||||
local expected_file="$2"
|
||||
local actual_file="$3"
|
||||
local diff_file="$4"
|
||||
|
||||
backup_require_path "$expected_file"
|
||||
if diff -u <(sort "$expected_file") <(sort "$actual_file") >"$diff_file"; then
|
||||
jq -n \
|
||||
--arg label "$label" \
|
||||
--arg expected "$expected_file" \
|
||||
--arg actual "$actual_file" \
|
||||
--arg status "passed" \
|
||||
'{label:$label, expected:$expected, actual:$actual, status:$status}'
|
||||
else
|
||||
jq -n \
|
||||
--arg label "$label" \
|
||||
--arg expected "$expected_file" \
|
||||
--arg actual "$actual_file" \
|
||||
--arg diff "$diff_file" \
|
||||
--arg status "failed" \
|
||||
'{label:$label, expected:$expected, actual:$actual, diff:$diff, status:$status}'
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
verify_restored_targets() {
|
||||
local report_dir
|
||||
local report_items=()
|
||||
local item
|
||||
local expected
|
||||
local actual
|
||||
local diff_file
|
||||
local db_name
|
||||
|
||||
report_dir="$(dirname "$report_path")/restore-targets-$(date -u '+%Y%m%d-%H%M%SZ')"
|
||||
mkdir -p "$report_dir"
|
||||
|
||||
if service_enabled postgres "$services"; then
|
||||
expected="$report_dir/baron-postgres-expected-row-counts.txt"
|
||||
actual="$report_dir/baron-postgres-row-counts.txt"
|
||||
diff_file="$report_dir/baron-postgres-row-counts.diff"
|
||||
collect_postgres_dump_row_counts baron_postgres "${DB_USER:-baron}" "${DB_PASSWORD:-password}" "${DB_NAME:-baron_sso}" "$backup_dir/postgres/baron.dump" "$expected"
|
||||
collect_postgres_exact_row_counts baron_postgres "${DB_USER:-baron}" "${DB_PASSWORD:-password}" "${DB_NAME:-baron_sso}" "$actual"
|
||||
item="$(compare_row_count_report "postgres" "$expected" "$actual" "$diff_file")"
|
||||
report_items+=("$item")
|
||||
fi
|
||||
|
||||
if service_enabled ory-postgres "$services"; then
|
||||
for db_name in "${KRATOS_DB:-ory_kratos}" "${HYDRA_DB:-ory_hydra}" "${KETO_DB:-ory_keto}"; do
|
||||
expected="$report_dir/${db_name}-expected-row-counts.txt"
|
||||
actual="$report_dir/${db_name}-row-counts.txt"
|
||||
diff_file="$report_dir/${db_name}-row-counts.diff"
|
||||
collect_postgres_dump_row_counts ory_postgres "${ORY_POSTGRES_USER:-ory}" "${ORY_POSTGRES_PASSWORD:-secret}" "$db_name" "$backup_dir/postgres/${db_name}.dump" "$expected"
|
||||
collect_postgres_exact_row_counts ory_postgres "${ORY_POSTGRES_USER:-ory}" "${ORY_POSTGRES_PASSWORD:-secret}" "$db_name" "$actual"
|
||||
item="$(compare_row_count_report "ory-postgres/$db_name" "$expected" "$actual" "$diff_file")"
|
||||
report_items+=("$item")
|
||||
done
|
||||
fi
|
||||
|
||||
if service_enabled clickhouse "$services"; then
|
||||
expected="$report_dir/baron_clickhouse-stable-expected-row-counts.txt"
|
||||
actual="$report_dir/baron_clickhouse-stable-row-counts.txt"
|
||||
diff_file="$report_dir/baron_clickhouse-row-counts.diff"
|
||||
collect_clickhouse_exact_row_counts baron_clickhouse "${CLICKHOUSE_USER:-baron}" "${CLICKHOUSE_PASSWORD:-password}" "$backup_dir/clickhouse/baron_clickhouse/tables.tsv" "$actual"
|
||||
mv "$actual" "$report_dir/baron_clickhouse-row-counts.txt"
|
||||
collect_clickhouse_native_stable_row_counts baron_clickhouse "${CLICKHOUSE_USER:-baron}" "${CLICKHOUSE_PASSWORD:-password}" "$backup_dir/clickhouse/baron_clickhouse" "$expected"
|
||||
filter_clickhouse_stable_row_counts "$backup_dir/clickhouse/baron_clickhouse/tables.tsv" "$report_dir/baron_clickhouse-row-counts.txt" "$actual"
|
||||
item="$(compare_row_count_report "clickhouse" "$expected" "$actual" "$diff_file")"
|
||||
report_items+=("$item")
|
||||
fi
|
||||
|
||||
if service_enabled ory-clickhouse "$services"; then
|
||||
expected="$report_dir/ory_clickhouse-stable-expected-row-counts.txt"
|
||||
actual="$report_dir/ory_clickhouse-stable-row-counts.txt"
|
||||
diff_file="$report_dir/ory_clickhouse-row-counts.diff"
|
||||
collect_clickhouse_exact_row_counts ory_clickhouse "${ORY_CLICKHOUSE_USER:-ory}" "${ORY_CLICKHOUSE_PASSWORD:-orypass}" "$backup_dir/clickhouse/ory_clickhouse/tables.tsv" "$actual"
|
||||
mv "$actual" "$report_dir/ory_clickhouse-row-counts.txt"
|
||||
collect_clickhouse_native_stable_row_counts ory_clickhouse "${ORY_CLICKHOUSE_USER:-ory}" "${ORY_CLICKHOUSE_PASSWORD:-orypass}" "$backup_dir/clickhouse/ory_clickhouse" "$expected"
|
||||
filter_clickhouse_stable_row_counts "$backup_dir/clickhouse/ory_clickhouse/tables.tsv" "$report_dir/ory_clickhouse-row-counts.txt" "$actual"
|
||||
item="$(compare_row_count_report "ory-clickhouse" "$expected" "$actual" "$diff_file")"
|
||||
report_items+=("$item")
|
||||
fi
|
||||
|
||||
if service_enabled config "$services"; then
|
||||
backup_require_path "$repo_root/config-restored"
|
||||
item="$(jq -n \
|
||||
--arg label "config" \
|
||||
--arg actual "$repo_root/config-restored" \
|
||||
--arg status "passed" \
|
||||
'{label:$label, actual:$actual, status:$status}')"
|
||||
report_items+=("$item")
|
||||
fi
|
||||
|
||||
target_verification_reports="$(printf '%s\n' "${report_items[@]}" | jq -s '.')"
|
||||
target_verification_status="passed"
|
||||
}
|
||||
|
||||
resolve_backup_input
|
||||
|
||||
if [[ -n "${RESTORE_REPORT:-}" ]]; then
|
||||
report_path="$RESTORE_REPORT"
|
||||
elif [[ "$backup_source" == "dump_file" ]]; then
|
||||
archive_name="$(basename "$dump_file")"
|
||||
archive_name="${archive_name%.tar.zst}"
|
||||
archive_name="${archive_name%.tar.gz}"
|
||||
archive_name="${archive_name%.tgz}"
|
||||
report_path="$repo_root/reports/restore/${archive_name}-restore-report.json"
|
||||
else
|
||||
report_path="$backup_dir/reports/restore-report.json"
|
||||
fi
|
||||
|
||||
if [[ "${CONFIRM_RESTORE:-}" != "baron-sso" ]]; then
|
||||
backup_die "CONFIRM_RESTORE=baron-sso is required for restore."
|
||||
fi
|
||||
|
||||
services="$(normalize_service_filter "${RESTORE_SERVICES:-all}")"
|
||||
allow_non_empty="${ALLOW_NON_EMPTY_RESTORE:-false}"
|
||||
|
||||
if [[ "${RESTORE_TEST_NON_EMPTY:-}" == "1" && "$allow_non_empty" != "true" ]]; then
|
||||
backup_die "non-empty restore target is not allowed by default. Set ALLOW_NON_EMPTY_RESTORE=true only for an approved restore rehearsal."
|
||||
fi
|
||||
|
||||
if [[ "$dry_run" == "true" ]]; then
|
||||
backup_log "Restore plan for $backup_dir"
|
||||
backup_log "Services: $services"
|
||||
backup_log "ALLOW_NON_EMPTY_RESTORE=$allow_non_empty"
|
||||
backup_log "RESTORE_REPORT=$report_path"
|
||||
write_restore_report "planned" "restore dry-run completed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$allow_non_empty" != "true" ]]; then
|
||||
if service_enabled postgres "$services" && postgres_target_has_data baron_postgres "${DB_USER:-baron}" "${DB_PASSWORD:-password}" "${DB_NAME:-baron_sso}"; then
|
||||
backup_die "non-empty restore target is not allowed by default: baron_postgres/${DB_NAME:-baron_sso}"
|
||||
fi
|
||||
if service_enabled ory-postgres "$services" && postgres_target_has_data ory_postgres "${ORY_POSTGRES_USER:-ory}" "${ORY_POSTGRES_PASSWORD:-secret}" "${KRATOS_DB:-ory_kratos}"; then
|
||||
backup_die "non-empty restore target is not allowed by default: ory_postgres/${KRATOS_DB:-ory_kratos}"
|
||||
fi
|
||||
fi
|
||||
|
||||
BACKUP="$backup_dir" "$script_dir/verify-dump.sh"
|
||||
dump_checksum_status="passed"
|
||||
|
||||
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."
|
||||
backup_log "Restore report: $report_path"
|
||||
642
scripts/backup/upload_cloud.sh
Executable file
642
scripts/backup/upload_cloud.sh
Executable file
@@ -0,0 +1,642 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$script_dir/lib/common.sh"
|
||||
|
||||
repo_root="$(backup_repo_root)"
|
||||
|
||||
if [[ -f "$repo_root/.env" ]]; then
|
||||
env_override_keys=(
|
||||
WORKS_DRIVE_TARGET
|
||||
WORKS_DRIVE_SHARED_DRIVE_ID
|
||||
WORKS_DRIVE_PARENT_FILE_ID
|
||||
WORKS_DRIVE_USER_ID
|
||||
WORKS_DRIVE_GROUP_ID
|
||||
WORKS_DRIVE_SHARED_FOLDER_ID
|
||||
WORKS_DRIVE_ACCESS_TOKEN
|
||||
WORKS_DRIVE_ACCESS_TOKEN_FILE
|
||||
WORKS_DRIVE_ACCESS_TOKEN_CMD
|
||||
WORKS_DRIVE_OAUTH_SCOPE
|
||||
WORKS_DRIVE_SPLIT_SIZE
|
||||
WORKS_DRIVE_MAX_SINGLE_FILE_BYTES
|
||||
WORKS_DRIVE_FORCE_SPLIT
|
||||
WORKS_DRIVE_OVERWRITE
|
||||
WORKS_DRIVE_DRY_RUN
|
||||
WORKS_DRIVE_UPLOAD_REPORTS
|
||||
WORKS_DRIVE_REPORT_FOLDER_NAME
|
||||
WORKS_DRIVE_CURL_BIN
|
||||
WORKS_DRIVE_ARCHIVE_DIR
|
||||
WORKS_DRIVE_SHAREDRIVE_ID
|
||||
WORKS_DRIVE_SHAREDRIVE_BACKUP_DIR
|
||||
WORKS_DRIVE_OAUTH_CLIENT_ID
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SECRET
|
||||
WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT
|
||||
WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY
|
||||
WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE
|
||||
WORKS_DRIVE_OAUTH_REFRESH_TOKEN
|
||||
WORKS_SHAREDRIVE_ID
|
||||
WORKS_SHAREDRIVE_BACKUP_DIR
|
||||
WORKS_ADMIN_API_BASE_URL
|
||||
WORKS_ADMIN_OAUTH_TOKEN_URL
|
||||
)
|
||||
declare -A env_override_values=()
|
||||
env_override_set=()
|
||||
for env_key in "${env_override_keys[@]}"; do
|
||||
if [[ -v "$env_key" ]]; then
|
||||
env_override_set+=("$env_key")
|
||||
env_override_values["$env_key"]="${!env_key}"
|
||||
fi
|
||||
done
|
||||
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "$repo_root/.env"
|
||||
set +a
|
||||
|
||||
for env_key in "${env_override_set[@]}"; do
|
||||
printf -v "$env_key" '%s' "${env_override_values[$env_key]}"
|
||||
export "$env_key"
|
||||
done
|
||||
fi
|
||||
|
||||
backup_path="${BACKUP:-${1:-}}"
|
||||
[[ -n "$backup_path" ]] || backup_die "BACKUP is required. Example: make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ"
|
||||
backup_require_path "$backup_path"
|
||||
|
||||
WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_SHARED_DRIVE_ID:-${WORKS_DRIVE_SHAREDRIVE_ID:-${WORKS_SHAREDRIVE_ID:-}}}"
|
||||
WORKS_DRIVE_PARENT_FILE_ID="${WORKS_DRIVE_PARENT_FILE_ID:-${WORKS_DRIVE_SHAREDRIVE_BACKUP_DIR:-${WORKS_SHAREDRIVE_BACKUP_DIR:-}}}"
|
||||
|
||||
dry_run="${WORKS_DRIVE_DRY_RUN:-false}"
|
||||
target="${WORKS_DRIVE_TARGET:-sharedrive}"
|
||||
api_base_url="${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}"
|
||||
curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}"
|
||||
archive_dir="${WORKS_DRIVE_ARCHIVE_DIR:-/tmp/baron-sso-backup-upload}"
|
||||
split_size="${WORKS_DRIVE_SPLIT_SIZE:-9000M}"
|
||||
force_split="${WORKS_DRIVE_FORCE_SPLIT:-false}"
|
||||
max_single_file_bytes="${WORKS_DRIVE_MAX_SINGLE_FILE_BYTES:-0}"
|
||||
overwrite="${WORKS_DRIVE_OVERWRITE:-false}"
|
||||
upload_scope="${WORKS_DRIVE_OAUTH_SCOPE:-file}"
|
||||
upload_reports="${WORKS_DRIVE_UPLOAD_REPORTS:-true}"
|
||||
report_folder_name="${WORKS_DRIVE_REPORT_FOLDER_NAME:-reports}"
|
||||
report_dir="$backup_path/reports"
|
||||
|
||||
if [[ -f "$backup_path" ]]; then
|
||||
report_dir="$(dirname "$backup_path")"
|
||||
fi
|
||||
|
||||
mkdir -p "$archive_dir" "$report_dir"
|
||||
|
||||
backup_require_command jq
|
||||
|
||||
urlencode_path() {
|
||||
jq -nr --arg value "$1" '$value|@uri'
|
||||
}
|
||||
|
||||
json_string() {
|
||||
jq -nr --arg value "$1" '$value'
|
||||
}
|
||||
|
||||
bytes_from_size() {
|
||||
local raw="$1"
|
||||
local number
|
||||
local unit
|
||||
|
||||
if [[ "$raw" =~ ^([0-9]+)([KkMmGgTt]?)$ ]]; then
|
||||
number="${BASH_REMATCH[1]}"
|
||||
unit="${BASH_REMATCH[2]}"
|
||||
case "$unit" in
|
||||
K | k) printf '%s\n' $((number * 1024)) ;;
|
||||
M | m) printf '%s\n' $((number * 1024 * 1024)) ;;
|
||||
G | g) printf '%s\n' $((number * 1024 * 1024 * 1024)) ;;
|
||||
T | t) printf '%s\n' $((number * 1024 * 1024 * 1024 * 1024)) ;;
|
||||
*) printf '%s\n' "$number" ;;
|
||||
esac
|
||||
return
|
||||
fi
|
||||
|
||||
backup_die "invalid size value: $raw"
|
||||
}
|
||||
|
||||
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_target_upload_endpoint() {
|
||||
local parent_file_id="${1:-${WORKS_DRIVE_PARENT_FILE_ID:-}}"
|
||||
local encoded_parent=""
|
||||
|
||||
if [[ -n "$parent_file_id" ]]; then
|
||||
encoded_parent="$(urlencode_path "$parent_file_id")"
|
||||
fi
|
||||
|
||||
case "$target" in
|
||||
sharedrive)
|
||||
[[ -n "${WORKS_DRIVE_SHARED_DRIVE_ID:-}" ]] || backup_die "WORKS_DRIVE_SHARED_DRIVE_ID is required when WORKS_DRIVE_TARGET=sharedrive."
|
||||
local shared_drive_id
|
||||
shared_drive_id="$(urlencode_path "$WORKS_DRIVE_SHARED_DRIVE_ID")"
|
||||
if [[ -n "$encoded_parent" ]]; then
|
||||
printf '%s/v1.0/sharedrives/%s/files/%s\n' "$api_base_url" "$shared_drive_id" "$encoded_parent"
|
||||
else
|
||||
printf '%s/v1.0/sharedrives/%s/files\n' "$api_base_url" "$shared_drive_id"
|
||||
fi
|
||||
;;
|
||||
mydrive)
|
||||
local user_id="${WORKS_DRIVE_USER_ID:-me}"
|
||||
user_id="$(urlencode_path "$user_id")"
|
||||
if [[ -n "$encoded_parent" ]]; then
|
||||
printf '%s/v1.0/users/%s/drive/files/%s\n' "$api_base_url" "$user_id" "$encoded_parent"
|
||||
else
|
||||
printf '%s/v1.0/users/%s/drive/files\n' "$api_base_url" "$user_id"
|
||||
fi
|
||||
;;
|
||||
group)
|
||||
[[ -n "${WORKS_DRIVE_GROUP_ID:-}" ]] || backup_die "WORKS_DRIVE_GROUP_ID is required when WORKS_DRIVE_TARGET=group."
|
||||
local group_id
|
||||
group_id="$(urlencode_path "$WORKS_DRIVE_GROUP_ID")"
|
||||
if [[ -n "$encoded_parent" ]]; then
|
||||
printf '%s/v1.0/groups/%s/folder/files/%s\n' "$api_base_url" "$group_id" "$encoded_parent"
|
||||
else
|
||||
printf '%s/v1.0/groups/%s/folder/files\n' "$api_base_url" "$group_id"
|
||||
fi
|
||||
;;
|
||||
sharedfolder)
|
||||
[[ -n "${WORKS_DRIVE_SHARED_FOLDER_ID:-}" ]] || backup_die "WORKS_DRIVE_SHARED_FOLDER_ID is required when WORKS_DRIVE_TARGET=sharedfolder."
|
||||
local user_id="${WORKS_DRIVE_USER_ID:-me}"
|
||||
local shared_folder_id
|
||||
user_id="$(urlencode_path "$user_id")"
|
||||
shared_folder_id="$(urlencode_path "$WORKS_DRIVE_SHARED_FOLDER_ID")"
|
||||
if [[ -n "$encoded_parent" ]]; then
|
||||
printf '%s/v1.0/users/%s/drive/sharedfolders/%s/files/%s\n' "$api_base_url" "$user_id" "$shared_folder_id" "$encoded_parent"
|
||||
else
|
||||
printf '%s/v1.0/users/%s/drive/sharedfolders/%s/files\n' "$api_base_url" "$user_id" "$shared_folder_id"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
backup_die "unknown WORKS_DRIVE_TARGET: $target"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
resolve_target_children_endpoint() {
|
||||
local parent_file_id="${1:-${WORKS_DRIVE_PARENT_FILE_ID:-}}"
|
||||
local upload_endpoint
|
||||
|
||||
upload_endpoint="$(resolve_target_upload_endpoint "$parent_file_id")"
|
||||
printf '%s/children\n' "$upload_endpoint"
|
||||
}
|
||||
|
||||
resolve_target_create_folder_endpoint() {
|
||||
local parent_file_id="${1:-${WORKS_DRIVE_PARENT_FILE_ID:-}}"
|
||||
local upload_endpoint
|
||||
|
||||
upload_endpoint="$(resolve_target_upload_endpoint "$parent_file_id")"
|
||||
printf '%s/createfolder\n' "$upload_endpoint"
|
||||
}
|
||||
|
||||
base64url() {
|
||||
openssl base64 -A | tr '+/' '-_' | tr -d '='
|
||||
}
|
||||
|
||||
build_jwt_assertion() {
|
||||
backup_require_command openssl
|
||||
|
||||
local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}"
|
||||
local service_account="${WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT:-}"
|
||||
local private_key="${WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY:-}"
|
||||
local private_key_file="${WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE:-}"
|
||||
local now
|
||||
local exp
|
||||
local header
|
||||
local payload
|
||||
local signing_input
|
||||
local key_file=""
|
||||
local temp_key_file=""
|
||||
|
||||
[[ -n "$client_id" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_ID is required for service-account token mode."
|
||||
[[ -n "$service_account" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT is required for service-account token mode."
|
||||
|
||||
if [[ -n "$private_key" ]]; then
|
||||
temp_key_file="$(mktemp /tmp/baron-sso-works-key.XXXXXX)"
|
||||
printf '%s\n' "$private_key" >"$temp_key_file"
|
||||
key_file="$temp_key_file"
|
||||
elif [[ -n "$private_key_file" ]]; then
|
||||
if [[ "$private_key_file" != /* ]]; then
|
||||
private_key_file="$repo_root/$private_key_file"
|
||||
fi
|
||||
backup_require_path "$private_key_file" || return 1
|
||||
key_file="$private_key_file"
|
||||
else
|
||||
backup_die "WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY or WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE is required for service-account token mode."
|
||||
fi
|
||||
|
||||
now="$(date +%s)"
|
||||
exp="$((now + 3600))"
|
||||
header="$(printf '{"alg":"RS256","typ":"JWT"}' | base64url)"
|
||||
payload="$(jq -cn \
|
||||
--arg iss "$client_id" \
|
||||
--arg sub "$service_account" \
|
||||
--argjson iat "$now" \
|
||||
--argjson exp "$exp" \
|
||||
'{iss:$iss, sub:$sub, iat:$iat, exp:$exp}' | base64url)"
|
||||
signing_input="${header}.${payload}"
|
||||
|
||||
printf '%s' "$signing_input" \
|
||||
| openssl dgst -sha256 -sign "$key_file" -binary \
|
||||
| base64url \
|
||||
| while IFS= read -r signature; do
|
||||
printf '%s.%s\n' "$signing_input" "$signature"
|
||||
done
|
||||
|
||||
if [[ -n "$temp_key_file" ]]; then
|
||||
rm -f "$temp_key_file"
|
||||
fi
|
||||
}
|
||||
|
||||
request_service_account_token() {
|
||||
local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}"
|
||||
local client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}"
|
||||
local token_url="${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}"
|
||||
local assertion
|
||||
local response
|
||||
local response_body
|
||||
local http_status
|
||||
|
||||
[[ -n "$client_secret" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_SECRET is required for service-account token mode."
|
||||
assertion="$(build_jwt_assertion)"
|
||||
|
||||
response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
|
||||
--data-urlencode "assertion=$assertion" \
|
||||
--data-urlencode "client_id=$client_id" \
|
||||
--data-urlencode "client_secret=$client_secret" \
|
||||
--data-urlencode "scope=$upload_scope" \
|
||||
"$token_url")"
|
||||
split_curl_response "$response" response_body http_status
|
||||
|
||||
if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then
|
||||
backup_die "WORKS token request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)"
|
||||
fi
|
||||
|
||||
jq -er '.access_token' <<<"$response_body"
|
||||
}
|
||||
|
||||
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_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}"
|
||||
local response
|
||||
local response_body
|
||||
local http_status
|
||||
|
||||
[[ -n "$client_id" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_ID is required for refresh-token mode."
|
||||
[[ -n "$client_secret" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_SECRET is required for refresh-token mode."
|
||||
[[ -n "$refresh_token" ]] || backup_die "WORKS_DRIVE_OAUTH_REFRESH_TOKEN is required for refresh-token mode."
|
||||
|
||||
response="$("$curl_bin" -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=$refresh_token" \
|
||||
--data-urlencode "client_id=$client_id" \
|
||||
--data-urlencode "client_secret=$client_secret" \
|
||||
"$token_url")"
|
||||
split_curl_response "$response" response_body http_status
|
||||
|
||||
if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then
|
||||
backup_die "WORKS refresh token request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)"
|
||||
fi
|
||||
|
||||
jq -er '.access_token' <<<"$response_body"
|
||||
}
|
||||
|
||||
resolve_access_token() {
|
||||
if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN:-}" ]]; then
|
||||
printf '%s\n' "$WORKS_DRIVE_ACCESS_TOKEN"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN_FILE:-}" ]]; then
|
||||
backup_require_path "$WORKS_DRIVE_ACCESS_TOKEN_FILE"
|
||||
sed -n '1p' "$WORKS_DRIVE_ACCESS_TOKEN_FILE"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ]]; then
|
||||
sh -c "$WORKS_DRIVE_ACCESS_TOKEN_CMD"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]]; then
|
||||
request_refresh_access_token
|
||||
return
|
||||
fi
|
||||
|
||||
request_service_account_token
|
||||
}
|
||||
|
||||
package_backup_path() {
|
||||
local source_path="$1"
|
||||
local source_name
|
||||
local target_base
|
||||
|
||||
if [[ -f "$source_path" ]]; then
|
||||
[[ "$source_path" == *.zst ]] || backup_die "file BACKUP must be a .zst archive. Pass a backup directory to package it as .tar.zst automatically."
|
||||
printf '%s\n' "$source_path"
|
||||
return
|
||||
fi
|
||||
|
||||
source_name="$(basename "$source_path")"
|
||||
target_base="$archive_dir/${source_name}"
|
||||
|
||||
backup_require_command tar
|
||||
backup_require_command zstd
|
||||
tar --zstd -cf "${target_base}.tar.zst" -C "$(dirname "$source_path")" "$source_name"
|
||||
printf '%s\n' "${target_base}.tar.zst"
|
||||
}
|
||||
|
||||
build_upload_file_list() {
|
||||
local package_file="$1"
|
||||
local file_size
|
||||
local split_bytes
|
||||
local split_dir
|
||||
local split_prefix
|
||||
|
||||
backup_require_command stat
|
||||
backup_require_command split
|
||||
file_size="$(stat -c '%s' "$package_file")"
|
||||
split_bytes="$(bytes_from_size "$split_size")"
|
||||
|
||||
if [[ "$force_split" == "true" || ( "$max_single_file_bytes" != "0" && "$file_size" -gt "$max_single_file_bytes" ) ]]; then
|
||||
split_dir="$archive_dir/split-$(basename "$package_file")"
|
||||
rm -rf "$split_dir"
|
||||
mkdir -p "$split_dir"
|
||||
split_prefix="$split_dir/$(basename "$package_file").part-"
|
||||
split -b "$split_bytes" -d -a 4 "$package_file" "$split_prefix"
|
||||
find "$split_dir" -maxdepth 1 -type f -name "$(basename "$split_prefix")*" | sort
|
||||
return
|
||||
fi
|
||||
|
||||
printf '%s\n' "$package_file"
|
||||
}
|
||||
|
||||
create_upload_url() {
|
||||
local access_token="$1"
|
||||
local endpoint="$2"
|
||||
local file_path="$3"
|
||||
local file_name
|
||||
local file_size
|
||||
local payload
|
||||
local response
|
||||
local response_body
|
||||
local http_status
|
||||
|
||||
file_name="$(basename "$file_path")"
|
||||
file_size="$(stat -c '%s' "$file_path")"
|
||||
payload="$(jq -cn \
|
||||
--arg fileName "$file_name" \
|
||||
--argjson fileSize "$file_size" \
|
||||
--argjson overwrite "$overwrite" \
|
||||
'{fileName:$fileName, fileSize:$fileSize, overwrite:$overwrite}')"
|
||||
|
||||
response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer $access_token" \
|
||||
-H "Content-Type: application/json; charset=UTF-8" \
|
||||
-d "$payload" \
|
||||
"$endpoint")"
|
||||
split_curl_response "$response" response_body http_status
|
||||
|
||||
if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then
|
||||
backup_die "WORKS upload URL request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)"
|
||||
fi
|
||||
|
||||
jq -er '.uploadUrl' <<<"$response_body"
|
||||
}
|
||||
|
||||
upload_file_to_url() {
|
||||
local access_token="$1"
|
||||
local upload_url="$2"
|
||||
local file_path="$3"
|
||||
local file_name
|
||||
local response
|
||||
local response_body
|
||||
local http_status
|
||||
|
||||
file_name="$(basename "$file_path")"
|
||||
response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer $access_token" \
|
||||
-F "Filedata=@${file_path};filename=${file_name};type=application/octet-stream" \
|
||||
"$upload_url")"
|
||||
split_curl_response "$response" response_body http_status
|
||||
|
||||
if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then
|
||||
backup_die "WORKS file upload failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$response_body"
|
||||
}
|
||||
|
||||
list_child_folders() {
|
||||
local access_token="$1"
|
||||
local endpoint="$2"
|
||||
local response
|
||||
local response_body
|
||||
local http_status
|
||||
|
||||
response="$("$curl_bin" -sS -w $'\n%{http_code}' -X GET \
|
||||
-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"
|
||||
}
|
||||
|
||||
create_child_folder() {
|
||||
local access_token="$1"
|
||||
local endpoint="$2"
|
||||
local folder_name="$3"
|
||||
local payload
|
||||
local response
|
||||
local response_body
|
||||
local http_status
|
||||
|
||||
payload="$(jq -cn --arg fileName "$folder_name" '{fileName:$fileName}')"
|
||||
response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \
|
||||
-H "Authorization: Bearer $access_token" \
|
||||
-H "Content-Type: application/json; charset=UTF-8" \
|
||||
-d "$payload" \
|
||||
"$endpoint")"
|
||||
split_curl_response "$response" response_body http_status
|
||||
|
||||
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
|
||||
|
||||
jq -er '.fileId // .id' <<<"$response_body"
|
||||
}
|
||||
|
||||
resolve_report_folder_id() {
|
||||
local access_token="$1"
|
||||
local children_endpoint
|
||||
local create_folder_endpoint
|
||||
local children_json
|
||||
local folder_id
|
||||
|
||||
children_endpoint="$(resolve_target_children_endpoint)"
|
||||
create_folder_endpoint="$(resolve_target_create_folder_endpoint)"
|
||||
children_json="$(list_child_folders "$access_token" "$children_endpoint")"
|
||||
folder_id="$(jq -er --arg name "$report_folder_name" '
|
||||
[
|
||||
(.files // .children // .items // [])[]
|
||||
| select((.fileName // .name) == $name)
|
||||
| select(((.fileType // .type // "") | ascii_downcase) == "folder")
|
||||
| .fileId // .id
|
||||
][0] // empty
|
||||
' <<<"$children_json" 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$folder_id" ]]; then
|
||||
printf '%s\n' "$folder_id"
|
||||
return
|
||||
fi
|
||||
|
||||
create_child_folder "$access_token" "$create_folder_endpoint" "$report_folder_name"
|
||||
}
|
||||
|
||||
discover_markdown_reports() {
|
||||
if [[ -f "$backup_path" || ! -d "$report_dir" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
find "$report_dir" -maxdepth 1 -type f -name '*.md' | sort
|
||||
}
|
||||
|
||||
timestamp_report_file_for_upload() {
|
||||
local source_file="$1"
|
||||
local file_name
|
||||
local base_name
|
||||
local stamped_dir
|
||||
local stamped_file
|
||||
|
||||
file_name="$(basename "$source_file")"
|
||||
base_name="${file_name%.md}"
|
||||
stamped_dir="$archive_dir/reports"
|
||||
mkdir -p "$stamped_dir"
|
||||
stamped_file="$stamped_dir/${base_name}-${report_upload_timestamp}.md"
|
||||
cp "$source_file" "$stamped_file"
|
||||
printf '%s\n' "$stamped_file"
|
||||
}
|
||||
|
||||
write_upload_report() {
|
||||
local report_file="$1"
|
||||
local uploaded_json="$2"
|
||||
local uploaded_reports_json="${3:-[]}"
|
||||
|
||||
jq -n \
|
||||
--arg createdAt "$(backup_utc_now)" \
|
||||
--arg backup "$backup_path" \
|
||||
--arg target "$target" \
|
||||
--arg endpoint "$upload_endpoint" \
|
||||
--argjson files "$uploaded_json" \
|
||||
--argjson reportFiles "$uploaded_reports_json" \
|
||||
'{
|
||||
created_at: $createdAt,
|
||||
backup: $backup,
|
||||
target: $target,
|
||||
upload_endpoint: $endpoint,
|
||||
files: $files,
|
||||
report_files: $reportFiles
|
||||
}' >"$report_file"
|
||||
}
|
||||
|
||||
upload_endpoint="$(resolve_target_upload_endpoint)"
|
||||
report_upload_timestamp="$(backup_timestamp)"
|
||||
|
||||
if [[ "$dry_run" == "true" ]]; then
|
||||
backup_log "Dry run: would upload BACKUP=$backup_path to WORKS Drive target=$target endpoint=$upload_endpoint"
|
||||
if [[ "$upload_reports" == "true" ]]; then
|
||||
backup_log "Dry run: would upload markdown reports from $report_dir to WORKS Drive folder=$report_folder_name"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
backup_require_command "$curl_bin"
|
||||
|
||||
package_file="$(package_backup_path "$backup_path")"
|
||||
mapfile -t upload_files < <(build_upload_file_list "$package_file")
|
||||
access_token="$(resolve_access_token)"
|
||||
|
||||
uploaded_items="[]"
|
||||
for file_path in "${upload_files[@]}"; do
|
||||
backup_require_path "$file_path"
|
||||
backup_log "Creating WORKS Drive upload URL for $(basename "$file_path")"
|
||||
upload_url="$(create_upload_url "$access_token" "$upload_endpoint" "$file_path")"
|
||||
backup_log "Uploading $(basename "$file_path") to WORKS Drive"
|
||||
upload_response="$(upload_file_to_url "$access_token" "$upload_url" "$file_path")"
|
||||
upload_response_json="$(jq -c '.' <<<"${upload_response:-{}}" 2>/dev/null || printf '{}')"
|
||||
uploaded_items="$(jq \
|
||||
--arg fileName "$(basename "$file_path")" \
|
||||
--arg filePath "$file_path" \
|
||||
--argjson fileSize "$(stat -c '%s' "$file_path")" \
|
||||
--arg status "uploaded" \
|
||||
--arg response "$upload_response_json" \
|
||||
'. + [{file_name:$fileName, file_path:$filePath, file_size:$fileSize, status:$status, response:($response | fromjson? // {})}]' \
|
||||
<<<"$uploaded_items")"
|
||||
done
|
||||
|
||||
uploaded_report_items="[]"
|
||||
if [[ "$upload_reports" == "true" ]]; then
|
||||
mapfile -t markdown_reports < <(discover_markdown_reports)
|
||||
if [[ "${#markdown_reports[@]}" -gt 0 ]]; then
|
||||
backup_log "Resolving WORKS Drive report folder: $report_folder_name"
|
||||
report_folder_id="$(resolve_report_folder_id "$access_token")"
|
||||
report_upload_endpoint="$(resolve_target_upload_endpoint "$report_folder_id")"
|
||||
|
||||
for report_path_item in "${markdown_reports[@]}"; do
|
||||
backup_require_path "$report_path_item"
|
||||
stamped_report_path="$(timestamp_report_file_for_upload "$report_path_item")"
|
||||
backup_log "Creating WORKS Drive upload URL for $(basename "$stamped_report_path")"
|
||||
upload_url="$(create_upload_url "$access_token" "$report_upload_endpoint" "$stamped_report_path")"
|
||||
backup_log "Uploading $(basename "$stamped_report_path") to WORKS Drive reports folder"
|
||||
upload_response="$(upload_file_to_url "$access_token" "$upload_url" "$stamped_report_path")"
|
||||
upload_response_json="$(jq -c '.' <<<"${upload_response:-{}}" 2>/dev/null || printf '{}')"
|
||||
uploaded_report_items="$(jq \
|
||||
--arg fileName "$(basename "$stamped_report_path")" \
|
||||
--arg sourcePath "$report_path_item" \
|
||||
--arg filePath "$stamped_report_path" \
|
||||
--argjson fileSize "$(stat -c '%s' "$stamped_report_path")" \
|
||||
--arg status "uploaded" \
|
||||
--arg response "$upload_response_json" \
|
||||
'. + [{file_name:$fileName, source_path:$sourcePath, file_path:$filePath, file_size:$fileSize, status:$status, response:($response | fromjson? // {})}]' \
|
||||
<<<"$uploaded_report_items")"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
report_file="$report_dir/cloud-upload.json"
|
||||
write_upload_report "$report_file" "$uploaded_items" "$uploaded_report_items"
|
||||
|
||||
backup_log "Upload complete: $report_file"
|
||||
16
scripts/backup/verify-dump.sh
Executable file
16
scripts/backup/verify-dump.sh
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$script_dir/lib/common.sh"
|
||||
|
||||
backup_dir="${BACKUP:-${1:-}}"
|
||||
[[ -n "$backup_dir" ]] || backup_die "BACKUP is required."
|
||||
backup_require_path "$backup_dir"
|
||||
backup_require_path "$backup_dir/manifest.json"
|
||||
|
||||
if ! backup_verify_checksums "$backup_dir"; then
|
||||
backup_die "checksum verification failed"
|
||||
fi
|
||||
|
||||
backup_log "Dump verification passed: $backup_dir"
|
||||
13
scripts/backup/verify-restore.sh
Executable file
13
scripts/backup/verify-restore.sh
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$script_dir/lib/common.sh"
|
||||
|
||||
backup_dir="${BACKUP:-${1:-}}"
|
||||
[[ -n "$backup_dir" ]] || backup_die "BACKUP is required."
|
||||
|
||||
BACKUP="$backup_dir" "$script_dir/verify-dump.sh"
|
||||
|
||||
backup_log "Restore verification policy check passed."
|
||||
backup_log "Run application smoke checks separately: super admin login, representative OIDC login, and WORKS comparison dry-run."
|
||||
@@ -128,8 +128,8 @@ run_with_retry() {
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
playwright_install_cmd=(npx playwright install)
|
||||
playwright_install_desc="npx playwright install"
|
||||
playwright_install_cmd=(pnpm exec playwright install)
|
||||
playwright_install_desc="pnpm exec playwright install"
|
||||
playwright_project_args=()
|
||||
|
||||
has_webkit_host_dependencies() {
|
||||
@@ -179,21 +179,21 @@ has_webkit_host_dependencies() {
|
||||
}
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
playwright_install_cmd=(pnpm exec playwright install --with-deps)
|
||||
playwright_install_desc="pnpm exec playwright install --with-deps"
|
||||
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||
playwright_install_cmd=(npx playwright install --with-deps)
|
||||
playwright_install_desc="npx playwright install --with-deps"
|
||||
playwright_install_cmd=(pnpm exec playwright install --with-deps)
|
||||
playwright_install_desc="pnpm exec playwright install --with-deps"
|
||||
elif ! has_webkit_host_dependencies; then
|
||||
playwright_install_cmd=(npx playwright install chromium firefox)
|
||||
playwright_install_desc="npx playwright install chromium firefox"
|
||||
playwright_install_cmd=(pnpm exec playwright install chromium firefox)
|
||||
playwright_install_desc="pnpm exec playwright install chromium firefox"
|
||||
playwright_project_args=(--project=chromium --project=firefox)
|
||||
{
|
||||
echo "# Adminfront WebKit Skipped"
|
||||
echo
|
||||
echo "- Reason: WebKit host dependencies are not installed and this user cannot run passwordless sudo."
|
||||
echo "- Action: Running Chromium and Firefox projects only."
|
||||
echo "- To enable WebKit locally: run \`cd adminfront && npx playwright install-deps webkit\` with sudo privileges."
|
||||
echo "- To enable WebKit locally: run \`cd adminfront && pnpm exec playwright install-deps webkit\` with sudo privileges."
|
||||
} > reports/adminfront-webkit-skipped.md
|
||||
fi
|
||||
|
||||
|
||||
155
scripts/serve_frontend_prod.mjs
Normal file
155
scripts/serve_frontend_prod.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { extname, join, normalize, resolve } from "node:path";
|
||||
|
||||
const distDir = resolve(process.env.FRONTEND_DIST_DIR ?? "/app/dist");
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
const port = Number(process.env.PORT ?? 5173);
|
||||
const backendTarget = new URL(
|
||||
process.env.API_PROXY_TARGET || "http://localhost:3000",
|
||||
);
|
||||
|
||||
const contentTypes = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
function getContentType(filePath) {
|
||||
return (
|
||||
contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, body) {
|
||||
res.writeHead(statusCode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function toSafePath(pathname) {
|
||||
const decoded = decodeURIComponent(pathname);
|
||||
const relative = decoded.replace(/^\/+/, "");
|
||||
const safe = normalize(relative).replace(/^(\.\.(?:[\\/]|$))+/, "");
|
||||
return join(distDir, safe);
|
||||
}
|
||||
|
||||
async function tryReadFile(filePath) {
|
||||
try {
|
||||
return await readFile(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyToBackend(req, res, pathname, search) {
|
||||
const target = new URL(pathname + search, backendTarget);
|
||||
const headers = new Headers();
|
||||
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (!value) continue;
|
||||
if (key === "host" || key === "content-length" || key === "connection") {
|
||||
continue;
|
||||
}
|
||||
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
||||
}
|
||||
|
||||
const hasBody = !["GET", "HEAD"].includes(req.method ?? "GET");
|
||||
const response = await fetch(target, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: hasBody ? req : undefined,
|
||||
duplex: hasBody ? "half" : undefined,
|
||||
});
|
||||
|
||||
const responseHeaders = new Headers(response.headers);
|
||||
responseHeaders.delete("content-length");
|
||||
responseHeaders.delete("transfer-encoding");
|
||||
responseHeaders.delete("connection");
|
||||
|
||||
res.writeHead(response.status, Object.fromEntries(responseHeaders.entries()));
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
res.end(Buffer.from(arrayBuffer));
|
||||
}
|
||||
|
||||
async function serveStatic(req, res, pathname) {
|
||||
const indexPath = join(distDir, "index.html");
|
||||
const filePath = toSafePath(pathname);
|
||||
|
||||
let resolvedPath = filePath;
|
||||
try {
|
||||
const fileStat = await stat(resolvedPath);
|
||||
if (fileStat.isDirectory()) {
|
||||
resolvedPath = join(resolvedPath, "index.html");
|
||||
}
|
||||
} catch {
|
||||
resolvedPath = indexPath;
|
||||
}
|
||||
|
||||
let body = await tryReadFile(resolvedPath);
|
||||
if (!body) {
|
||||
body = await tryReadFile(indexPath);
|
||||
resolvedPath = indexPath;
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
sendJson(res, 500, { error: "dist_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": getContentType(resolvedPath),
|
||||
"Cache-Control": resolvedPath.endsWith("index.html")
|
||||
? "no-cache"
|
||||
: "public, max-age=31536000, immutable",
|
||||
});
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(
|
||||
req.url ?? "/",
|
||||
`http://${req.headers.host ?? "localhost"}`,
|
||||
);
|
||||
const { pathname, search } = url;
|
||||
|
||||
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
||||
await proxyToBackend(req, res, pathname, search);
|
||||
return;
|
||||
}
|
||||
|
||||
await serveStatic(req, res, pathname === "/" ? "/index.html" : pathname);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: "internal_server_error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}).listen(port, host, () => {
|
||||
console.log(`Frontend server listening on http://${host}:${port}`);
|
||||
});
|
||||
76
test/backup_make_targets_test.sh
Executable file
76
test/backup_make_targets_test.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_dry_run_contains() {
|
||||
local output="$1"
|
||||
local expected="$2"
|
||||
grep -Fq -- "$expected" <<<"$output" || fail "dry-run output must contain: $expected"
|
||||
}
|
||||
|
||||
grep -Fq "FROM debian:trixie-slim" "$repo_root/docker/backup-tools/Dockerfile" \
|
||||
|| fail "backup-tools image must be based on debian:trixie-slim."
|
||||
grep -Fq "zstd" "$repo_root/docker/backup-tools/Dockerfile" \
|
||||
|| fail "backup-tools image must include zstd so restore does not depend on the host."
|
||||
grep -Fq "docker-cli" "$repo_root/docker/backup-tools/Dockerfile" \
|
||||
|| fail "backup-tools image must include docker CLI for containerized dump/restore orchestration."
|
||||
grep -Fq "perl" "$repo_root/docker/backup-tools/Dockerfile" \
|
||||
|| fail "backup-tools image must include perl for legacy ClickHouse schema dump decoding."
|
||||
|
||||
dump_dry_run="$(
|
||||
make --dry-run --always-make -C "$repo_root" dump DUMP_SERVICES="postgres,config" DUMP_MODE="maintenance" 2>&1
|
||||
)"
|
||||
|
||||
assert_dry_run_contains "$dump_dry_run" "docker build"
|
||||
assert_dry_run_contains "$dump_dry_run" "docker run"
|
||||
assert_dry_run_contains "$dump_dry_run" "baron-sso-backup-tools:local"
|
||||
assert_dry_run_contains "$dump_dry_run" "--env-file .env"
|
||||
assert_dry_run_contains "$dump_dry_run" "/var/run/docker.sock:/var/run/docker.sock"
|
||||
assert_dry_run_contains "$dump_dry_run" "/tmp:/tmp"
|
||||
assert_dry_run_contains "$dump_dry_run" "scripts/backup/dump.sh"
|
||||
assert_dry_run_contains "$dump_dry_run" "DUMP_SERVICES=\"postgres,config\""
|
||||
assert_dry_run_contains "$dump_dry_run" "DUMP_MODE=\"maintenance\""
|
||||
|
||||
restore_dry_run="$(
|
||||
make --dry-run --always-make -C "$repo_root" restore BACKUP="backups/example" DUMP_FILE="backups/example.tar.zst" RESTORE_SERVICES="postgres,config" CONFIRM_RESTORE="baron-sso" RESTORE_REPORT="reports/restore-report.json" 2>&1
|
||||
)"
|
||||
|
||||
assert_dry_run_contains "$restore_dry_run" "scripts/backup/restore.sh"
|
||||
assert_dry_run_contains "$restore_dry_run" "docker run"
|
||||
assert_dry_run_contains "$restore_dry_run" "BACKUP=\"backups/example\""
|
||||
assert_dry_run_contains "$restore_dry_run" "DUMP_FILE=\"backups/example.tar.zst\""
|
||||
assert_dry_run_contains "$restore_dry_run" "RESTORE_SERVICES=\"postgres,config\""
|
||||
assert_dry_run_contains "$restore_dry_run" "CONFIRM_RESTORE=\"baron-sso\""
|
||||
assert_dry_run_contains "$restore_dry_run" "RESTORE_REPORT=\"reports/restore-report.json\""
|
||||
|
||||
for target in dump-verify restore-verify dump-list restore-plan; do
|
||||
target_dry_run="$(
|
||||
make --dry-run --always-make -C "$repo_root" "$target" BACKUP="backups/example" 2>&1
|
||||
)"
|
||||
assert_dry_run_contains "$target_dry_run" "scripts/backup/"
|
||||
done
|
||||
|
||||
upload_dry_run="$(
|
||||
make --dry-run --always-make -C "$repo_root" upload-cloud BACKUP="backups/example" WORKS_DRIVE_DRY_RUN=true 2>&1
|
||||
)"
|
||||
|
||||
assert_dry_run_contains "$upload_dry_run" "docker run"
|
||||
assert_dry_run_contains "$upload_dry_run" "scripts/backup/upload_cloud.sh"
|
||||
assert_dry_run_contains "$upload_dry_run" "BACKUP=\"backups/example\""
|
||||
assert_dry_run_contains "$upload_dry_run" "WORKS_DRIVE_DRY_RUN=\"true\""
|
||||
|
||||
if make -C "$repo_root" BACKUP_USE_DOCKER=false restore >/tmp/baron-sso-restore-missing.out 2>&1; then
|
||||
fail "make restore must fail when BACKUP and CONFIRM_RESTORE are not provided."
|
||||
fi
|
||||
|
||||
if ! grep -Fq "CONFIRM_RESTORE=baron-sso" /tmp/baron-sso-restore-missing.out; then
|
||||
fail "make restore failure must mention the required confirmation value."
|
||||
fi
|
||||
|
||||
echo "OK: backup Makefile targets expose the expected guarded interface"
|
||||
135
test/backup_scripts_policy_test.sh
Executable file
135
test/backup_scripts_policy_test.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
source "$repo_root/scripts/backup/lib/common.sh"
|
||||
source "$repo_root/scripts/backup/lib/manifest.sh"
|
||||
source "$repo_root/scripts/backup/lib/report.sh"
|
||||
|
||||
assert_eq() {
|
||||
local expected="$1"
|
||||
local actual="$2"
|
||||
local message="$3"
|
||||
[[ "$actual" == "$expected" ]] || fail "$message: expected '$expected', got '$actual'"
|
||||
}
|
||||
|
||||
all_services="$(normalize_service_filter "all")"
|
||||
assert_eq "postgres ory-postgres clickhouse ory-clickhouse config" "$all_services" "all must expand to every supported service"
|
||||
|
||||
selected_services="$(normalize_service_filter "postgres,config")"
|
||||
assert_eq "postgres config" "$selected_services" "comma-separated services must be normalized in input order"
|
||||
|
||||
ory_services="$(normalize_service_filter "ory-postgres,ory-clickhouse")"
|
||||
assert_eq "ory-postgres ory-clickhouse" "$ory_services" "ory service filter must not expand to Baron services"
|
||||
|
||||
if service_enabled "postgres" "$ory_services"; then
|
||||
fail "service_enabled must not match postgres inside ory-postgres"
|
||||
fi
|
||||
|
||||
if service_enabled "clickhouse" "$ory_services"; then
|
||||
fail "service_enabled must not match clickhouse inside ory-clickhouse"
|
||||
fi
|
||||
|
||||
service_enabled "ory-postgres" "$ory_services" || fail "service_enabled must match exact ory-postgres token"
|
||||
service_enabled "ory-clickhouse" "$ory_services" || fail "service_enabled must match exact ory-clickhouse token"
|
||||
|
||||
grep -Fq "drop database if exists" "$repo_root/scripts/backup/lib/clickhouse.sh" \
|
||||
|| fail "ClickHouse restore must drop restored databases before replaying schema/data to avoid duplicate rows or init-table conflicts."
|
||||
grep -Fq "FORMAT RawBLOB" "$repo_root/scripts/backup/lib/clickhouse.sh" \
|
||||
|| fail "ClickHouse dump must store SHOW CREATE TABLE as raw SQL, not escaped TSV."
|
||||
grep -Fq "render_clickhouse_schema" "$repo_root/scripts/backup/lib/clickhouse.sh" \
|
||||
|| fail "ClickHouse restore must route schema files through the compatibility decoder."
|
||||
grep -Fq "s{\\\\n}" "$repo_root/scripts/backup/lib/clickhouse.sh" \
|
||||
|| fail "ClickHouse restore must decode escaped newlines from older schema dumps."
|
||||
grep -Fq "x27" "$repo_root/scripts/backup/lib/clickhouse.sh" \
|
||||
|| fail "ClickHouse restore must decode escaped single quotes from older schema dumps."
|
||||
grep -Fq "filter_clickhouse_stable_row_counts" "$repo_root/scripts/backup/restore.sh" \
|
||||
|| fail "restore verification must not fail on unstable ClickHouse aggregate/view physical row counts."
|
||||
grep -Fq "collect_clickhouse_native_stable_row_counts" "$repo_root/scripts/backup/restore.sh" \
|
||||
|| fail "restore verification must derive ClickHouse expected counts from Native data files."
|
||||
|
||||
if normalize_service_filter "postgres,unknown" >/tmp/baron-sso-service-filter.out 2>&1; then
|
||||
fail "unknown backup service must be rejected"
|
||||
fi
|
||||
|
||||
if ! grep -Fq "unknown backup service" /tmp/baron-sso-service-filter.out; then
|
||||
fail "unknown service rejection must explain the service filter problem"
|
||||
fi
|
||||
|
||||
tmp_dir="$(mktemp -d /tmp/baron-sso-backup-policy.XXXXXX)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT INT TERM
|
||||
|
||||
mkdir -p "$tmp_dir/reports"
|
||||
create_manifest "$tmp_dir" "maintenance" "postgres config"
|
||||
|
||||
manifest="$tmp_dir/manifest.json"
|
||||
[[ -f "$manifest" ]] || fail "create_manifest must write manifest.json"
|
||||
grep -Fq '"format_version": "1"' "$manifest" || fail "manifest must include format_version"
|
||||
grep -Fq '"mode": "maintenance"' "$manifest" || fail "manifest must include backup mode"
|
||||
grep -Fq '"services": [' "$manifest" || fail "manifest must include service list"
|
||||
grep -Fq '"git_commit":' "$manifest" || fail "manifest must include git commit"
|
||||
|
||||
cat >"$tmp_dir/reports/baron-postgres-row-counts.txt" <<'EOF'
|
||||
public.users:228
|
||||
public.tenants:266
|
||||
public.relying_parties:1
|
||||
EOF
|
||||
cat >"$tmp_dir/reports/ory_hydra-row-counts.txt" <<'EOF'
|
||||
public.hydra_client:5
|
||||
EOF
|
||||
write_backup_markdown_report "$tmp_dir" "succeeded" "postgres config" '[{"service":"postgres","duration_seconds":2},{"service":"config","duration_seconds":1}]'
|
||||
backup_md="$tmp_dir/reports/backup-report.md"
|
||||
[[ -f "$backup_md" ]] || fail "backup markdown report must be created."
|
||||
grep -Fq "# Baron SSO Backup Report" "$backup_md" || fail "backup report must have a markdown title."
|
||||
grep -Fq "| 사용자 | 228 |" "$backup_md" || fail "backup report must include user count."
|
||||
grep -Fq "| 테넌트 | 266 |" "$backup_md" || fail "backup report must include tenant count."
|
||||
grep -Fq "| RP | 1 |" "$backup_md" || fail "backup report must include RP count."
|
||||
grep -Fq "| postgres | 2 |" "$backup_md" || fail "backup report must include service duration."
|
||||
|
||||
printf 'original\n' >"$tmp_dir/example.txt"
|
||||
(cd "$tmp_dir" && sha256sum example.txt > checksums.sha256)
|
||||
printf 'changed\n' >"$tmp_dir/example.txt"
|
||||
|
||||
if BACKUP="$tmp_dir" "$repo_root/scripts/backup/verify-dump.sh" >/tmp/baron-sso-checksum.out 2>&1; then
|
||||
fail "verify-dump must fail on checksum mismatch"
|
||||
fi
|
||||
|
||||
if ! grep -Fq "checksum verification failed" /tmp/baron-sso-checksum.out; then
|
||||
fail "checksum mismatch output must be explicit"
|
||||
fi
|
||||
|
||||
if BACKUP="$tmp_dir" CONFIRM_RESTORE="baron-sso" RESTORE_TEST_NON_EMPTY=1 "$repo_root/scripts/backup/restore.sh" --dry-run >/tmp/baron-sso-non-empty-restore.out 2>&1; then
|
||||
fail "restore must reject non-empty targets by default"
|
||||
fi
|
||||
|
||||
if ! grep -Fq "non-empty restore target is not allowed" /tmp/baron-sso-non-empty-restore.out; then
|
||||
fail "restore must explain the non-empty target guard"
|
||||
fi
|
||||
|
||||
archive_source="$tmp_dir/archive-source"
|
||||
mkdir -p "$archive_source/reports"
|
||||
create_manifest "$archive_source" "maintenance" "postgres"
|
||||
printf 'archive fixture\n' >"$archive_source/example.txt"
|
||||
(cd "$archive_source" && sha256sum manifest.json example.txt > checksums.sha256)
|
||||
archive_file="$tmp_dir/archive-source.tar.zst"
|
||||
tar --zstd -cf "$archive_file" -C "$tmp_dir" archive-source
|
||||
restore_report="$tmp_dir/restore-report.json"
|
||||
|
||||
DUMP_FILE="$archive_file" \
|
||||
CONFIRM_RESTORE="baron-sso" \
|
||||
RESTORE_REPORT="$restore_report" \
|
||||
"$repo_root/scripts/backup/restore.sh" --dry-run >/tmp/baron-sso-dump-file-restore.out
|
||||
|
||||
[[ -f "$restore_report" ]] || fail "restore dry-run with DUMP_FILE must write RESTORE_REPORT."
|
||||
jq -e \
|
||||
--arg dump_file "$archive_file" \
|
||||
'.status == "planned" and .dump_file == $dump_file and .backup_dir != null and (.services | index("postgres"))' \
|
||||
"$restore_report" >/dev/null || fail "restore report must describe planned DUMP_FILE restore."
|
||||
|
||||
echo "OK: backup scripts enforce parser, manifest, checksum, and restore safety policies"
|
||||
108
test/backup_upload_cloud_policy_test.sh
Executable file
108
test/backup_upload_cloud_policy_test.sh
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
tmp_dir="$(mktemp -d /tmp/baron-sso-upload-cloud-test.XXXXXX)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT INT TERM
|
||||
|
||||
backup_dir="$tmp_dir/baron-sso-backup-20260605-000000Z"
|
||||
mkdir -p "$backup_dir/postgres" "$backup_dir/reports"
|
||||
printf '{"format_version":"1"}\n' >"$backup_dir/manifest.json"
|
||||
printf 'postgres dump fixture\n' >"$backup_dir/postgres/baron.dump"
|
||||
printf '# Baron SSO Backup Report\n' >"$backup_dir/reports/backup-report.md"
|
||||
(cd "$backup_dir" && sha256sum manifest.json postgres/baron.dump > checksums.sha256)
|
||||
|
||||
if "$repo_root/scripts/backup/upload_cloud.sh" >/tmp/baron-sso-upload-missing.out 2>&1; then
|
||||
fail "upload_cloud.sh must require BACKUP."
|
||||
fi
|
||||
|
||||
if ! grep -Fq "BACKUP is required" /tmp/baron-sso-upload-missing.out; then
|
||||
fail "missing BACKUP error must be explicit."
|
||||
fi
|
||||
|
||||
curl_log="$tmp_dir/curl.log"
|
||||
fake_curl="$tmp_dir/fake-curl.sh"
|
||||
fake_bin="$tmp_dir/bin"
|
||||
mkdir -p "$fake_bin"
|
||||
cat >"$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/shared-drive-1/files/folder-1/children)
|
||||
printf '{"files":[]}'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/folder-1/createfolder)
|
||||
printf '{"fileId":"reports-folder-1","fileName":"reports","fileType":"FOLDER"}'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/folder-1)
|
||||
printf '{"uploadUrl":"https://upload.example.test/upload-1"}'
|
||||
;;
|
||||
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/reports-folder-1)
|
||||
printf '{"uploadUrl":"https://upload.example.test/upload-report-1"}'
|
||||
;;
|
||||
https://upload.example.test/upload-1)
|
||||
printf '{"fileId":"file-1"}'
|
||||
;;
|
||||
https://upload.example.test/upload-report-1)
|
||||
printf '{"fileId":"report-file-1"}'
|
||||
;;
|
||||
*)
|
||||
echo "unexpected curl URL: $last_arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
EOF
|
||||
chmod +x "$fake_curl"
|
||||
cat >"$fake_bin/zstd" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
cat
|
||||
EOF
|
||||
chmod +x "$fake_bin/zstd"
|
||||
|
||||
WORKS_DRIVE_ACCESS_TOKEN="test-access-token" \
|
||||
WORKS_DRIVE_TARGET="sharedrive" \
|
||||
WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \
|
||||
WORKS_DRIVE_PARENT_FILE_ID="folder-1" \
|
||||
WORKS_DRIVE_CURL_BIN="$fake_curl" \
|
||||
FAKE_CURL_LOG="$curl_log" \
|
||||
PATH="$fake_bin:$PATH" \
|
||||
BACKUP="$backup_dir" \
|
||||
"$repo_root/scripts/backup/upload_cloud.sh" >"$tmp_dir/upload.out"
|
||||
|
||||
grep -Fq "Upload complete" "$tmp_dir/upload.out" || fail "upload must complete with fake curl."
|
||||
grep -Fq "sharedrives/shared-drive-1/files/folder-1" "$curl_log" || fail "must create upload URL for the configured shared drive folder."
|
||||
grep -Fq "https://upload.example.test/upload-1" "$curl_log" || fail "must upload to the issued upload URL."
|
||||
grep -Fq "Authorization: Bearer test-access-token" "$curl_log" || fail "must pass bearer token to WORKS API calls."
|
||||
grep -Fq "Filedata=@" "$curl_log" || fail "must upload the packaged backup as multipart Filedata."
|
||||
grep -Fq ".tar.zst" "$curl_log" || fail "backup directory uploads must be packaged as .tar.zst."
|
||||
grep -Fq "createfolder" "$curl_log" || fail "must create or resolve a report subfolder."
|
||||
grep -Fq "reports-folder-1" "$curl_log" || fail "must upload markdown reports to the reports folder."
|
||||
grep -Eq "backup-report-[0-9]{8}-[0-9]{6}Z.md" "$curl_log" || fail "must upload timestamped backup markdown report."
|
||||
if grep -Fq "cloud-upload.json" "$curl_log"; then
|
||||
fail "cloud-upload.json must not be uploaded to WORKS Drive."
|
||||
fi
|
||||
|
||||
report_file="$backup_dir/reports/cloud-upload.json"
|
||||
[[ -f "$report_file" ]] || fail "upload must write reports/cloud-upload.json."
|
||||
jq -e '.target == "sharedrive" and .files[0].status == "uploaded" and .report_files[0].status == "uploaded" and (.report_files[0].file_name | test("^backup-report-[0-9]{8}-[0-9]{6}Z[.]md$"))' "$report_file" >/dev/null || fail "upload report must include timestamped markdown report file status."
|
||||
|
||||
WORKS_DRIVE_DRY_RUN=true \
|
||||
WORKS_DRIVE_TARGET="sharedrive" \
|
||||
WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \
|
||||
WORKS_DRIVE_PARENT_FILE_ID="folder-1" \
|
||||
PATH="$fake_bin:$PATH" \
|
||||
BACKUP="$backup_dir" \
|
||||
"$repo_root/scripts/backup/upload_cloud.sh" >"$tmp_dir/dry-run.out"
|
||||
|
||||
grep -Fq "Dry run" "$tmp_dir/dry-run.out" || fail "dry-run must not require a token or call curl."
|
||||
|
||||
echo "OK: upload_cloud uploads current backup artifacts to WORKS Drive"
|
||||
@@ -64,13 +64,19 @@ for file in "$STAGING_COMPOSE" "$PULL_COMPOSE"; do
|
||||
done
|
||||
|
||||
assert_contains "$STAGING_COMPOSE" 'image: ${ORGFRONT_IMAGE_NAME}:${IMAGE_TAG}'
|
||||
assert_contains "$PULL_COMPOSE" "context: ./orgfront"
|
||||
assert_contains "$DEPLOY_TEMPLATE" "../../orgfront:/app"
|
||||
assert_contains "$DEPLOY_TEMPLATE" "./orgfront/vite.config.ts:/app/vite.config.ts:ro"
|
||||
assert_contains "$DEPLOY_TEMPLATE" "./orgfront/auth.ts:/app/src/lib/auth.ts:ro"
|
||||
assert_contains "$PULL_COMPOSE" "context: ."
|
||||
assert_contains "$PULL_COMPOSE" "dockerfile: ./orgfront/Dockerfile"
|
||||
assert_contains "$PULL_COMPOSE" "VITE_ORGFRONT_PUBLIC_URL: \${ORGFRONT_URL:-}"
|
||||
assert_not_contains "$PULL_COMPOSE" "./orgfront:/app"
|
||||
assert_contains "$DEPLOY_TEMPLATE" "dockerfile: ./orgfront/Dockerfile"
|
||||
assert_contains "$DEPLOY_TEMPLATE" "VITE_ORGFRONT_PUBLIC_URL: \${ORGFRONT_URL}"
|
||||
assert_not_contains "$DEPLOY_TEMPLATE" "../../orgfront:/app"
|
||||
assert_not_contains "$DEPLOY_TEMPLATE" "./orgfront/vite.config.ts:/app/vite.config.ts:ro"
|
||||
assert_not_contains "$DEPLOY_TEMPLATE" "./orgfront/auth.ts:/app/src/lib/auth.ts:ro"
|
||||
|
||||
assert_contains "$BUILD_RC" "Build and push orgfront RC image"
|
||||
assert_contains "$BUILD_RC" "context: ./orgfront"
|
||||
assert_contains "$BUILD_RC" "context: ."
|
||||
assert_contains "$BUILD_RC" "file: ./orgfront/Dockerfile"
|
||||
assert_contains "$BUILD_RC" "/baron_sso/orgfront:"
|
||||
|
||||
assert_contains "$CODE_CHECK" "run_orgfront_tests"
|
||||
|
||||
@@ -11,7 +11,7 @@ docker_config="$(
|
||||
)"
|
||||
|
||||
override_env="$(mktemp)"
|
||||
cp "$repo_root/.env" "$override_env"
|
||||
grep -Ev '^(USERFRONT_URL|HYDRA_PUBLIC_URL|KRATOS_UI_URL|KRATOS_BROWSER_URL|ADMINFRONT_CALLBACK_URLS|DEVFRONT_CALLBACK_URLS|ORGFRONT_CALLBACK_URLS)=' "$repo_root/.env" >"$override_env"
|
||||
cat >> "$override_env" <<'EOF'
|
||||
USERFRONT_URL=https://compose-policy.example.test/sso
|
||||
HYDRA_PUBLIC_URL=https://compose-policy.example.test/sso/oidc
|
||||
@@ -112,8 +112,28 @@ root_init_rp="$(
|
||||
docker_init_rp="$(
|
||||
awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^ init-rp:/ { in_block=1 } in_block { print }' "$repo_root/docker/compose.ory.yaml"
|
||||
)"
|
||||
if grep -q "image: oryd/hydra" <<<"$root_init_rp$docker_init_rp"; then
|
||||
echo "ERROR: init-rp must not use the Hydra service image because distroless tags do not provide /bin/sh." >&2
|
||||
for init_rp_file in \
|
||||
"$repo_root/compose.ory.yaml" \
|
||||
"$repo_root/docker/compose.ory.yaml" \
|
||||
"$repo_root/docker/staging_pull_compose.template.yaml" \
|
||||
"$repo_root/deploy/templates/docker-compose.yaml"
|
||||
do
|
||||
if ! grep -Fq 'image: oryd/hydra:${HYDRA_CLI_VERSION:-v26.2.0}' "$init_rp_file"; then
|
||||
echo "ERROR: init-rp must use the official Hydra CLI image with HYDRA_CLI_VERSION in $init_rp_file." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq 'entrypoint: ["/bin/sh", "-ec"]' "$init_rp_file"; then
|
||||
echo "ERROR: init-rp must override the Hydra image entrypoint with /bin/sh -ec in $init_rp_file." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Fq 'HYDRA_CLI_ARCHIVE_VERSION' "$init_rp_file" || grep -Fq 'hydra.tar.gz' "$init_rp_file"; then
|
||||
echo "ERROR: init-rp must not download Hydra CLI tarballs at runtime in $init_rp_file." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if grep -q "image: alpine:latest" <<<"$root_init_rp$docker_init_rp"; then
|
||||
echo "ERROR: init-rp must not use alpine plus runtime Hydra CLI download." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
102
test/production_image_release_policy_test.sh
Normal file
102
test/production_image_release_policy_test.sh
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
assert_contains() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
if ! grep -Fq -- "$pattern" "$file"; then
|
||||
echo "ERROR: missing pattern in $file: $pattern" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
if grep -Fq -- "$pattern" "$file"; then
|
||||
echo "ERROR: forbidden pattern remains in $file: $pattern" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
build_rc="$ROOT_DIR/.gitea/workflows/build_RC.yml"
|
||||
staging_release="$ROOT_DIR/.gitea/workflows/staging_release.yml"
|
||||
production_release="$ROOT_DIR/.gitea/workflows/production_release.yml"
|
||||
production_compose="$ROOT_DIR/docker/docker-compose.template.yaml"
|
||||
|
||||
for file in "$build_rc" "$staging_release" "$production_release" "$production_compose"; do
|
||||
if [[ ! -f "$file" ]]; then
|
||||
echo "ERROR: expected file not found: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for app in adminfront devfront orgfront; do
|
||||
assert_contains "$build_rc" "Build and push $app RC image"
|
||||
assert_contains "$build_rc" "file: ./$app/Dockerfile"
|
||||
assert_contains "$build_rc" "build-args: |"
|
||||
assert_contains "$build_rc" "VITE_OIDC_AUTHORITY=\${{ vars.VITE_OIDC_AUTHORITY }}"
|
||||
done
|
||||
assert_contains "$build_rc" "Validate RC build configuration"
|
||||
assert_contains "$build_rc" "Missing required RC build value"
|
||||
assert_contains "$build_rc" "Check Gitea repo variables/secrets"
|
||||
assert_contains "$build_rc" "VITE_ADMIN_PUBLIC_URL=\${{ vars.ADMINFRONT_URL }}"
|
||||
assert_contains "$build_rc" "VITE_DEVFRONT_PUBLIC_URL=\${{ vars.DEVFRONT_URL }}"
|
||||
assert_contains "$build_rc" "VITE_ORGFRONT_PUBLIC_URL=\${{ vars.ORGFRONT_URL }}"
|
||||
assert_contains "$build_rc" "ORGFRONT_URL=\${{ vars.ORGFRONT_URL }}"
|
||||
|
||||
assert_contains "$staging_release" "CLICKHOUSE_PASSWORD=\${{ secrets.CLICKHOUSE_PASSWORD }}"
|
||||
assert_not_contains "$staging_release" "CLICKHOUSE_PASSWORD=\${{ vars.CLICKHOUSE_PASSWORD }}"
|
||||
assert_contains "$staging_release" "PROFILE_CACHE_TTL=\${{ vars.PROFILE_CACHE_TTL }}"
|
||||
assert_contains "$staging_release" "KRATOS_UI_NODE_VERSION=\${{ vars.KRATOS_UI_NODE_VERSION }}"
|
||||
assert_contains "$staging_release" "Missing required staging .env value"
|
||||
assert_contains "$staging_release" "Check Gitea repo variables/secrets"
|
||||
assert_contains "$staging_release" "scp scripts/render_ory_config.sh"
|
||||
assert_contains "$staging_release" "scp compose.ory.yaml"
|
||||
assert_not_contains "$staging_release" "scp docker/compose.ory.yaml"
|
||||
assert_contains "$staging_release" "bash scripts/render_ory_config.sh"
|
||||
assert_contains "$staging_release" "chmod -R 777 config/.generated/ory"
|
||||
|
||||
assert_contains "$production_release" "for image in backend userfront adminfront devfront orgfront; do"
|
||||
assert_contains "$production_release" 'docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${BASE_TAG}'
|
||||
assert_contains "$production_release" 'docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${RE_TAG}'
|
||||
assert_contains "$production_release" "ADMINFRONT_IMAGE_NAME: \${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront"
|
||||
assert_contains "$production_release" "DEVFRONT_IMAGE_NAME: \${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront"
|
||||
assert_contains "$production_release" "ORGFRONT_IMAGE_NAME: \${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront"
|
||||
assert_contains "$production_release" "USERFRONT_URL=\${{ vars.PROD_FRONTEND_URL }}"
|
||||
assert_contains "$production_release" "BACKEND_URL=\${{ vars.PROD_BACKEND_URL }}"
|
||||
assert_contains "$production_release" "USERFRONT_PORT=\${{ vars.PROD_FRONTEND_PORT }}"
|
||||
assert_contains "$production_release" "PROD_BACKEND_PORT=\${{ vars.PROD_BACKEND_PORT }}"
|
||||
assert_contains "$production_release" "BACKEND_PORT=3000"
|
||||
assert_contains "$production_release" "ADMINFRONT_URL=\${{ vars.ADMINFRONT_URL }}"
|
||||
assert_contains "$production_release" "DEVFRONT_URL=\${{ vars.DEVFRONT_URL }}"
|
||||
assert_contains "$production_release" "ORGFRONT_URL=\${{ vars.ORGFRONT_URL }}"
|
||||
assert_contains "$production_release" "VITE_OIDC_AUTHORITY=\${{ vars.VITE_OIDC_AUTHORITY }}"
|
||||
assert_contains "$production_release" "ADMINFRONT_CALLBACK_URLS=\${{ vars.ADMINFRONT_CALLBACK_URLS }}"
|
||||
assert_contains "$production_release" "DEVFRONT_CALLBACK_URLS=\${{ vars.DEVFRONT_CALLBACK_URLS }}"
|
||||
assert_contains "$production_release" "ORGFRONT_CALLBACK_URLS=\${{ vars.ORGFRONT_CALLBACK_URLS }}"
|
||||
assert_contains "$production_release" "ADMINFRONT_PORT=\${{ vars.ADMINFRONT_PORT }}"
|
||||
assert_contains "$production_release" "DEVFRONT_PORT=\${{ vars.DEVFRONT_PORT }}"
|
||||
assert_contains "$production_release" "ORGFRONT_PORT=\${{ vars.ORGFRONT_PORT }}"
|
||||
assert_contains "$production_release" "export ADMINFRONT_IMAGE_NAME='\${ADMINFRONT_IMAGE_NAME}'"
|
||||
assert_contains "$production_release" "export DEVFRONT_IMAGE_NAME='\${DEVFRONT_IMAGE_NAME}'"
|
||||
assert_contains "$production_release" "export ORGFRONT_IMAGE_NAME='\${ORGFRONT_IMAGE_NAME}'"
|
||||
assert_contains "$production_release" "Missing required production .env value"
|
||||
assert_not_contains "$production_release" "PROD_USERFRONT_URL"
|
||||
assert_not_contains "$production_release" "PROD_USERFRONT_PORT"
|
||||
|
||||
for app in adminfront devfront orgfront; do
|
||||
assert_contains "$production_compose" "$app:"
|
||||
done
|
||||
assert_contains "$production_compose" 'image: ${ADMINFRONT_IMAGE_NAME}:${IMAGE_TAG}'
|
||||
assert_contains "$production_compose" 'image: ${DEVFRONT_IMAGE_NAME}:${IMAGE_TAG}'
|
||||
assert_contains "$production_compose" 'image: ${ORGFRONT_IMAGE_NAME}:${IMAGE_TAG}'
|
||||
assert_contains "$production_compose" 'API_PROXY_TARGET=http://baron_backend:${BACKEND_PORT:-3000}'
|
||||
assert_contains "$production_compose" '${PROD_BACKEND_PORT:-3010}:3000'
|
||||
assert_contains "$production_compose" '${USERFRONT_PORT:-80}:5000'
|
||||
assert_contains "$production_compose" 'BACKEND_PORT=3000'
|
||||
assert_contains "$production_compose" 'http://127.0.0.1:3000/health'
|
||||
|
||||
echo "production image release policy checks passed"
|
||||
@@ -30,6 +30,9 @@ adminfront_vite="adminfront/vite.config.ts"
|
||||
adminfront_runtime="adminfront/scripts/runtime-mode.sh"
|
||||
devfront_runtime="devfront/scripts/runtime-mode.sh"
|
||||
orgfront_runtime="orgfront/scripts/runtime-mode.sh"
|
||||
adminfront_dockerfile="adminfront/Dockerfile"
|
||||
devfront_dockerfile="devfront/Dockerfile"
|
||||
orgfront_dockerfile="orgfront/Dockerfile"
|
||||
|
||||
for file in \
|
||||
"$staging_pull" \
|
||||
@@ -42,7 +45,10 @@ for file in \
|
||||
"$orgfront_vite" \
|
||||
"$adminfront_runtime" \
|
||||
"$devfront_runtime" \
|
||||
"$orgfront_runtime"
|
||||
"$orgfront_runtime" \
|
||||
"$adminfront_dockerfile" \
|
||||
"$devfront_dockerfile" \
|
||||
"$orgfront_dockerfile"
|
||||
do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "missing expected file: $file" >&2
|
||||
@@ -72,8 +78,11 @@ for app in adminfront devfront orgfront; do
|
||||
assert_contains "$pull_compose" "$app:"
|
||||
assert_contains "$pull_compose" "context: ."
|
||||
assert_contains "$pull_compose" "dockerfile: ./$app/Dockerfile"
|
||||
assert_contains "$pull_compose" "VITE_OIDC_AUTHORITY: \${VITE_OIDC_AUTHORITY:-}"
|
||||
assert_not_contains "$pull_compose" "context: ./$app"
|
||||
assert_not_contains "$pull_compose" "./$app:/app"
|
||||
done
|
||||
assert_not_contains "$pull_compose" "/app/node_modules"
|
||||
assert_contains "$pull_compose" "dockerfile: userfront/Dockerfile"
|
||||
assert_not_contains "$pull_compose" 'target: ${USERFRONT_BUILD_TARGET:-dev}'
|
||||
assert_not_contains "$pull_compose" "target: dev"
|
||||
@@ -82,8 +91,12 @@ assert_contains "$pull_compose" "http://127.0.0.1:5173/"
|
||||
assert_contains "$pull_compose" "http://127.0.0.1:5175/"
|
||||
assert_contains "$pull_compose" 'APP_ENV=${APP_ENV:-stage}'
|
||||
|
||||
assert_contains "$deploy_compose" "sh ./scripts/runtime-mode.sh"
|
||||
assert_contains "$deploy_compose" "dockerfile: ./adminfront/Dockerfile"
|
||||
assert_contains "$deploy_compose" "dockerfile: ./devfront/Dockerfile"
|
||||
assert_contains "$deploy_compose" "dockerfile: ./orgfront/Dockerfile"
|
||||
assert_not_contains "$deploy_compose" "sh ./scripts/runtime-mode.sh"
|
||||
assert_not_contains "$deploy_compose" "command: npm run dev"
|
||||
assert_not_contains "$deploy_compose" "image: node:20-alpine"
|
||||
assert_contains "$deploy_gateway" "root /usr/share/nginx/html;"
|
||||
assert_contains "$deploy_gateway" 'try_files $uri $uri/ /index.html;'
|
||||
assert_not_contains "$deploy_gateway" "baron_userfront"
|
||||
@@ -96,6 +109,21 @@ for app in adminfront devfront orgfront; do
|
||||
assert_not_contains ".gitea/workflows/build_RC.yml" "context: ./$app"
|
||||
done
|
||||
|
||||
for app in adminfront devfront orgfront; do
|
||||
dockerfile="$app/Dockerfile"
|
||||
assert_contains "$dockerfile" "COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./"
|
||||
assert_contains "$dockerfile" "RUN pnpm install --frozen-lockfile --ignore-scripts"
|
||||
assert_contains "$dockerfile" "FROM node:24-alpine AS production"
|
||||
assert_contains "$dockerfile" "COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs"
|
||||
assert_contains "$dockerfile" "RUN npm run build"
|
||||
assert_contains "$dockerfile" 'CMD ["node", "./serve_frontend_prod.mjs"]'
|
||||
assert_not_contains "$dockerfile" "cd common && pnpm install"
|
||||
assert_not_contains "$dockerfile" "npm install -g serve"
|
||||
assert_not_contains "$dockerfile" "runtime-mode.sh"
|
||||
done
|
||||
assert_contains "scripts/serve_frontend_prod.mjs" "pathname === \"/api\" || pathname.startsWith(\"/api/\")"
|
||||
assert_contains "scripts/serve_frontend_prod.mjs" "API_PROXY_TARGET"
|
||||
|
||||
assert_contains "$adminfront_vite" "/tmp/baron-sso-adminfront-dist"
|
||||
assert_contains "$adminfront_vite" "/tmp/baron-sso-adminfront-vite-cache"
|
||||
assert_contains "adminfront/biome.json" '".vite"'
|
||||
|
||||
Reference in New Issue
Block a user