From 29038254dd3a2cb698b9b564d6671cd6884e8c62 Mon Sep 17 00:00:00 2001 From: Lectom Date: Fri, 5 Jun 2026 12:26:51 +0900 Subject: [PATCH 01/20] =?UTF-8?q?=EB=B0=B1=EC=97=85/=EB=B3=B5=EA=B5=AC?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD,=20=EA=B9=9C=EB=B9=A1?= =?UTF-8?q?=EC=9E=84=20=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 28 + Makefile | 67 +- README.md | 149 ++++ .../src/features/auth/AuthGuard.test.tsx | 56 ++ adminfront/src/features/auth/AuthGuard.tsx | 20 +- .../src/features/users/UserCreatePage.tsx | 18 +- .../src/features/users/UserDetailPage.tsx | 9 +- .../users/UserListPage.render.test.tsx | 15 + .../src/features/users/UserListPage.tsx | 7 + adminfront/src/lib/apiClient.ts | 9 +- adminfront/src/lib/auth.ts | 28 + adminfront/src/lib/roles.test.ts | 41 ++ adminfront/src/lib/roles.ts | 72 ++ adminfront/tests/security_roles.spec.ts | 77 ++- compose.ory.yaml | 33 +- config-restored/compose/compose.infra.yaml | 82 +++ config-restored/compose/compose.ory.yaml | 319 +++++++++ config-restored/compose/docker-compose.yaml | 184 +++++ config-restored/env.redacted | 119 ++++ config-restored/gateway.tar.zst | Bin 0 -> 1954 bytes config-restored/generated-ory.tar.zst | Bin 0 -> 6516 bytes deploy/templates/docker-compose.yaml | 13 +- docker/backup-tools/Dockerfile | 24 + docker/compose.ory.yaml | 13 +- docker/staging_pull_compose.template.yaml | 13 +- docs/backup-restore-design.md | 241 +++++++ scripts/backup/dump-list.sh | 15 + scripts/backup/dump.sh | 69 ++ scripts/backup/lib/clickhouse.sh | 115 ++++ scripts/backup/lib/common.sh | 137 ++++ scripts/backup/lib/config.sh | 37 + scripts/backup/lib/manifest.sh | 42 ++ scripts/backup/lib/postgres.sh | 95 +++ scripts/backup/lib/report.sh | 142 ++++ scripts/backup/restore-plan.sh | 5 + scripts/backup/restore.sh | 460 +++++++++++++ scripts/backup/upload_cloud.sh | 642 ++++++++++++++++++ scripts/backup/verify-dump.sh | 16 + scripts/backup/verify-restore.sh | 13 + test/backup_make_targets_test.sh | 76 +++ test/backup_scripts_policy_test.sh | 135 ++++ test/backup_upload_cloud_policy_test.sh | 108 +++ test/ory_v26_compose_policy_test.sh | 26 +- 43 files changed, 3695 insertions(+), 75 deletions(-) create mode 100644 adminfront/src/features/auth/AuthGuard.test.tsx create mode 100644 config-restored/compose/compose.infra.yaml create mode 100644 config-restored/compose/compose.ory.yaml create mode 100644 config-restored/compose/docker-compose.yaml create mode 100644 config-restored/env.redacted create mode 100644 config-restored/gateway.tar.zst create mode 100644 config-restored/generated-ory.tar.zst create mode 100644 docker/backup-tools/Dockerfile create mode 100755 scripts/backup/dump-list.sh create mode 100755 scripts/backup/dump.sh create mode 100644 scripts/backup/lib/clickhouse.sh create mode 100644 scripts/backup/lib/common.sh create mode 100644 scripts/backup/lib/config.sh create mode 100644 scripts/backup/lib/manifest.sh create mode 100644 scripts/backup/lib/postgres.sh create mode 100644 scripts/backup/lib/report.sh create mode 100755 scripts/backup/restore-plan.sh create mode 100755 scripts/backup/restore.sh create mode 100755 scripts/backup/upload_cloud.sh create mode 100755 scripts/backup/verify-dump.sh create mode 100755 scripts/backup/verify-restore.sh create mode 100755 test/backup_make_targets_test.sh create mode 100755 test/backup_scripts_policy_test.sh create mode 100755 test/backup_upload_cloud_policy_test.sh diff --git a/.env.sample b/.env.sample index 6960fbc3..ea673da9 100644 --- a/.env.sample +++ b/.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 # 비동기 감사 로그 처리를 위한 고루틴 워커 수 diff --git a/Makefile b/Makefile index d207fcb9..99d92498 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index 66c161b6..6a41a4a1 100644 --- a/README.md +++ b/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는 `/reports/restore-report.json`이고, `DUMP_FILE` archive 입력의 기본 JSON report는 `reports/restore/-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` 프로파일을 로컬에서만 켜세요. diff --git a/adminfront/src/features/auth/AuthGuard.test.tsx b/adminfront/src/features/auth/AuthGuard.test.tsx new file mode 100644 index 00000000..ce90319c --- /dev/null +++ b/adminfront/src/features/auth/AuthGuard.test.tsx @@ -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( + + + }> + Users outlet} /> + + Login outlet} /> + + , + ); +} + +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(); + }); +}); diff --git a/adminfront/src/features/auth/AuthGuard.tsx b/adminfront/src/features/auth/AuthGuard.tsx index d809a7de..701c73c2 100644 --- a/adminfront/src/features/auth/AuthGuard.tsx +++ b/adminfront/src/features/auth/AuthGuard.tsx @@ -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 ; } diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index c5dd9622..9b71b3c1 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -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 (
diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 7b473159..fcd625b7 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -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 (
diff --git a/adminfront/src/features/users/UserListPage.render.test.tsx b/adminfront/src/features/users/UserListPage.render.test.tsx index d5b5408d..ec64d2f7 100644 --- a/adminfront/src/features/users/UserListPage.render.test.tsx +++ b/adminfront/src/features/users/UserListPage.render.test.tsx @@ -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); + }); }); diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 13fd7975..3a10e02a 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -207,6 +207,8 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
{ 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); + }); }); diff --git a/adminfront/src/lib/roles.ts b/adminfront/src/lib/roles.ts index 20dd93c6..6c89f8bc 100644 --- a/adminfront/src/lib/roles.ts +++ b/adminfront/src/lib/roles.ts @@ -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(); + 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)); +} diff --git a/adminfront/tests/security_roles.spec.ts b/adminfront/tests/security_roles.spec.ts index 207b5dae..fd42a5fe 100644 --- a/adminfront/tests/security_roles.spec.ts +++ b/adminfront/tests/security_roles.spec.ts @@ -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 = {}, + ) => { // 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(); + }); + }); }); diff --git a/compose.ory.yaml b/compose.ory.yaml index 07eb83e9..b8c6e55d 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -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}" \ diff --git a/config-restored/compose/compose.infra.yaml b/config-restored/compose/compose.infra.yaml new file mode 100644 index 00000000..368b8911 --- /dev/null +++ b/config-restored/compose/compose.infra.yaml @@ -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 + diff --git a/config-restored/compose/compose.ory.yaml b/config-restored/compose/compose.ory.yaml new file mode 100644 index 00000000..07eb83e9 --- /dev/null +++ b/config-restored/compose/compose.ory.yaml @@ -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 diff --git a/config-restored/compose/docker-compose.yaml b/config-restored/compose/docker-compose.yaml new file mode 100644 index 00000000..889ac824 --- /dev/null +++ b/config-restored/compose/docker-compose.yaml @@ -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 diff --git a/config-restored/env.redacted b/config-restored/env.redacted new file mode 100644 index 00000000..da4420a1 --- /dev/null +++ b/config-restored/env.redacted @@ -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 diff --git a/config-restored/gateway.tar.zst b/config-restored/gateway.tar.zst new file mode 100644 index 0000000000000000000000000000000000000000..de064f5f38cbe9308e212a606e45ca40ec252079 GIT binary patch literal 1954 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 a&b0rJ+K1J3F39Z0Xa$?xVgwg>rSl)TBY7AA literal 0 HcmV?d00001 diff --git a/deploy/templates/docker-compose.yaml b/deploy/templates/docker-compose.yaml index 392f8095..edab33fa 100644 --- a/deploy/templates/docker-compose.yaml +++ b/deploy/templates/docker-compose.yaml @@ -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 diff --git a/docker/backup-tools/Dockerfile b/docker/backup-tools/Dockerfile new file mode 100644 index 00000000..37bb7509 --- /dev/null +++ b/docker/backup-tools/Dockerfile @@ -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"] diff --git a/docker/compose.ory.yaml b/docker/compose.ory.yaml index 2494ed35..4805dbaa 100644 --- a/docker/compose.ory.yaml +++ b/docker/compose.ory.yaml @@ -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 \ diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml index e804b112..e7ac39df 100644 --- a/docker/staging_pull_compose.template.yaml +++ b/docker/staging_pull_compose.template.yaml @@ -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 diff --git a/docs/backup-restore-design.md b/docs/backup-restore-design.md index 06341a94..fe48b70e 100644 --- a/docs/backup-restore-design.md +++ b/docs/backup-restore-design.md @@ -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=`는 압축 해제된 백업 디렉터리를 입력으로 사용한다. +- `make restore DUMP_FILE=.tar.zst`는 archive를 임시 디렉터리에 풀어 복구한다. +- `BACKUP`과 `DUMP_FILE`은 동시에 지정하지 않는다. +- restore report 기본 위치는 `BACKUP` 입력일 때 `/reports/restore-report.json`, `DUMP_FILE` 입력일 때 `reports/restore/-restore-report.json`이다. +- restore report JSON이 생성되면 같은 경로에 `.md` 확장자의 Markdown 요약도 함께 생성한다. +- `RESTORE_REPORT=`로 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[/]` | +| `mydrive` | 선택 `WORKS_DRIVE_USER_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/users/{userId}/drive/files[/]` | +| `group` | `WORKS_DRIVE_GROUP_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/groups/{groupId}/folder/files[/]` | +| `sharedfolder` | `WORKS_DRIVE_USER_ID`, `WORKS_DRIVE_SHARED_FOLDER_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/users/{userId}/drive/sharedfolders/{sharedFolderId}/files[/]` | + +운영 주의: + +- 업로드 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 diff --git a/scripts/backup/dump-list.sh b/scripts/backup/dump-list.sh new file mode 100755 index 00000000..9349352a --- /dev/null +++ b/scripts/backup/dump-list.sh @@ -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 diff --git a/scripts/backup/dump.sh b/scripts/backup/dump.sh new file mode 100755 index 00000000..8ad7cfed --- /dev/null +++ b/scripts/backup/dump.sh @@ -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" diff --git a/scripts/backup/lib/clickhouse.sh b/scripts/backup/lib/clickhouse.sh new file mode 100644 index 00000000..936e280c --- /dev/null +++ b/scripts/backup/lib/clickhouse.sh @@ -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}" +} diff --git a/scripts/backup/lib/common.sh b/scripts/backup/lib/common.sh new file mode 100644 index 00000000..e9917376 --- /dev/null +++ b/scripts/backup/lib/common.sh @@ -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 +} diff --git a/scripts/backup/lib/config.sh b/scripts/backup/lib/config.sh new file mode 100644 index 00000000..14a289d7 --- /dev/null +++ b/scripts/backup/lib/config.sh @@ -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." +} diff --git a/scripts/backup/lib/manifest.sh b/scripts/backup/lib/manifest.sh new file mode 100644 index 00000000..c67a83ae --- /dev/null +++ b/scripts/backup/lib/manifest.sh @@ -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" +} diff --git a/scripts/backup/lib/postgres.sh b/scripts/backup/lib/postgres.sh new file mode 100644 index 00000000..3904805a --- /dev/null +++ b/scripts/backup/lib/postgres.sh @@ -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' +} diff --git a/scripts/backup/lib/report.sh b/scripts/backup/lib/report.sh new file mode 100644 index 00000000..ff51612f --- /dev/null +++ b/scripts/backup/lib/report.sh @@ -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" +} diff --git a/scripts/backup/restore-plan.sh b/scripts/backup/restore-plan.sh new file mode 100755 index 00000000..5e829b78 --- /dev/null +++ b/scripts/backup/restore-plan.sh @@ -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 diff --git a/scripts/backup/restore.sh b/scripts/backup/restore.sh new file mode 100755 index 00000000..011b06cc --- /dev/null +++ b/scripts/backup/restore.sh @@ -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" diff --git a/scripts/backup/upload_cloud.sh b/scripts/backup/upload_cloud.sh new file mode 100755 index 00000000..5da455cb --- /dev/null +++ b/scripts/backup/upload_cloud.sh @@ -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" diff --git a/scripts/backup/verify-dump.sh b/scripts/backup/verify-dump.sh new file mode 100755 index 00000000..698edd72 --- /dev/null +++ b/scripts/backup/verify-dump.sh @@ -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" diff --git a/scripts/backup/verify-restore.sh b/scripts/backup/verify-restore.sh new file mode 100755 index 00000000..5e07275a --- /dev/null +++ b/scripts/backup/verify-restore.sh @@ -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." diff --git a/test/backup_make_targets_test.sh b/test/backup_make_targets_test.sh new file mode 100755 index 00000000..19833e77 --- /dev/null +++ b/test/backup_make_targets_test.sh @@ -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" diff --git a/test/backup_scripts_policy_test.sh b/test/backup_scripts_policy_test.sh new file mode 100755 index 00000000..e4cddff3 --- /dev/null +++ b/test/backup_scripts_policy_test.sh @@ -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" diff --git a/test/backup_upload_cloud_policy_test.sh b/test/backup_upload_cloud_policy_test.sh new file mode 100755 index 00000000..a06e8adc --- /dev/null +++ b/test/backup_upload_cloud_policy_test.sh @@ -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" diff --git a/test/ory_v26_compose_policy_test.sh b/test/ory_v26_compose_policy_test.sh index 2f1c01a4..8c440416 100644 --- a/test/ory_v26_compose_policy_test.sh +++ b/test/ory_v26_compose_policy_test.sh @@ -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 From 47d2f152837c0ede9d18e59625a501cfaebff986 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 4 Jun 2026 13:08:11 +0900 Subject: [PATCH 02/20] =?UTF-8?q?tenants=20=EB=AA=A9=EB=A1=9D=20=ED=88=B4?= =?UTF-8?q?=EB=B0=94=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantListPage.tsx | 340 +++++++++--------- 1 file changed, 177 insertions(+), 163 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index cc3e8af1..96646a18 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -33,6 +33,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"; @@ -708,174 +709,187 @@ function TenantListPage() { "시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.", )} actions={ - <> -
-
- - setSearch(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - query.refetch(); - } - }} - /> -
+
+ +
+ + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + query.refetch(); + } + }} + /> +
-
- - -
- - - {scopeTenantId ? ( - - ) : null} - - - - - +
- - - setViewMode("table")} + data-testid="tenant-view-table-btn" > - - {t("ui.admin.tenants.csv_template", "템플릿 다운로드")} - - - fileInputRef.current?.click()} - disabled={importMutation.isPending} - data-testid="tenant-import-menu-item" - className="cursor-pointer" - > - - {t("ui.admin.tenants.import", "CSV 가져오기")} - - - exportMutation.mutate(false)} - disabled={exportMutation.isPending} - data-testid="tenant-export-menu-item" - className="cursor-pointer" - > - - {t( - "ui.admin.tenants.export_without_ids", - "UUID 제외 내보내기", - )} - - exportMutation.mutate(true)} - disabled={exportMutation.isPending} - data-testid="tenant-export-with-ids-menu-item" - className="cursor-pointer" - > - - {t( - "ui.admin.tenants.export_with_ids", - "UUID 포함 내보내기", - )} - - - - + + {t("ui.admin.tenants.view.table", "평면")} + +
- - - - -
+ + {scopeTenantId ? ( + + ) : null} + + } + actions={ + <> + + + + + + + + + + {t( + "ui.admin.tenants.csv_template", + "템플릿 다운로드", + )} + + + fileInputRef.current?.click()} + disabled={importMutation.isPending} + data-testid="tenant-import-menu-item" + className="cursor-pointer" + > + + {t("ui.admin.tenants.import", "CSV 가져오기")} + + + exportMutation.mutate(false)} + disabled={exportMutation.isPending} + data-testid="tenant-export-menu-item" + className="cursor-pointer" + > + + {t( + "ui.admin.tenants.export_without_ids", + "UUID 제외 내보내기", + )} + + exportMutation.mutate(true)} + disabled={exportMutation.isPending} + data-testid="tenant-export-with-ids-menu-item" + className="cursor-pointer" + > + + {t( + "ui.admin.tenants.export_with_ids", + "UUID 포함 내보내기", + )} + + + + + + + + + + + } + /> {importMessage ? (
) : null} - +
} /> From f6c7cb3b225e727df197a5c957a25022730421a6 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 4 Jun 2026 15:15:05 +0900 Subject: [PATCH 03/20] =?UTF-8?q?tenants=20=EB=A0=88=EC=A7=80=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=20=EA=B0=80=EB=8F=85=EC=84=B1/=EB=A1=9C?= =?UTF-8?q?=EC=BC=80=EC=9D=BC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tenants/routes/TenantListPage.tsx | 96 +++++++++++++------ adminfront/src/locales/en.toml | 10 ++ adminfront/src/locales/ko.toml | 18 +++- locales/en.toml | 15 +++ locales/ko.toml | 23 ++++- locales/template.toml | 14 +++ 6 files changed, 138 insertions(+), 38 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 96646a18..6d99ddae 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -69,7 +69,6 @@ import { SelectTrigger, SelectValue, } from "../../../components/ui/select"; -import { Switch } from "../../../components/ui/switch"; import { Table, TableBody, @@ -80,7 +79,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, @@ -142,6 +140,51 @@ const getTenantIcon = (type?: string) => { } }; +function getTenantTypeLabel(type?: string) { + if (!type) return "-"; + return t(`domain.tenant_type.${type.toLowerCase()}`, type); +} + +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(); + + for (const tenant of tenants) { + const names: string[] = []; + const visited = new Set(); + 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) { @@ -339,19 +382,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, @@ -935,8 +965,6 @@ function TenantListPage() { onSelectAll={handleSelectAll} search={search} deletableTenants={deletableTenants} - statusMutation={statusMutation} - profile={profile} sortConfig={sortConfig} requestSort={requestSort} getSortIcon={getSortIcon} @@ -1513,13 +1541,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 | null; requestSort: (key: TenantSortKey) => void; getSortIcon: (key: TenantSortKey) => React.ReactNode; @@ -1536,8 +1557,6 @@ const TenantHierarchyView: React.FC<{ onSelectAll, search, deletableTenants, - statusMutation, - profile, sortConfig, requestSort, getSortIcon, @@ -1558,6 +1577,10 @@ const TenantHierarchyView: React.FC<{ () => 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>(() => { @@ -1682,6 +1705,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, @@ -1923,10 +1962,7 @@ const TenantHierarchyView: React.FC<{ colSpan={8} className="py-8 text-center text-muted-foreground" > - {t( - "msg.admin.tenants.empty", - "아직 등록된 테넌트가 없습니다.", - )} + {emptyMessage} )} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index ffb10bb1..abe23e4f 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -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,6 +1268,15 @@ 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" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 03f6502b..8f9d3694 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -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,24 @@ 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 = "멤버수" -name = "NAME" -slug = "SLUG" -status = "STATUS" +name = "이름" +slug = "슬러그" +status = "상태" type = "유형" -updated = "UPDATED" +updated = "수정일" [ui.admin.users] csv_template = "템플릿 다운로드" diff --git a/locales/en.toml b/locales/en.toml index 6c33882b..215edaf2 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -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,6 +1166,7 @@ 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" @@ -1441,9 +1451,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" diff --git a/locales/ko.toml b/locales/ko.toml index f2655ee2..f9a0f288 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -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,6 +1661,7 @@ user = "TENANT MEMBER" [ui.admin.tenants] add = "테넌트 추가" +data_mgmt = "데이터 관리" delete_selected = "선택 삭제" seed_badge = "초기 설정" title = "테넌트 목록" @@ -1904,13 +1914,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" diff --git a/locales/template.toml b/locales/template.toml index eac89aca..c0f26def 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -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 = "" @@ -1781,9 +1790,14 @@ status = "" [ui.admin.tenants.table] actions = "" +context = "" id = "" +id_copy = "" members = "" +members_count = "" +members_recursive = "" name = "" +root = "" slug = "" status = "" type = "" From 1596342d031d1b1f9d85b1f7ab9d613a62d8868c Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 4 Jun 2026 15:56:33 +0900 Subject: [PATCH 04/20] =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20?= =?UTF-8?q?=EC=A0=91=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/AppLayout.test.tsx | 16 ++++ .../src/components/layout/AppLayout.tsx | 52 ++++++++++-- adminfront/src/locales/en.toml | 4 + adminfront/src/locales/ko.toml | 4 + adminfront/src/locales/template.toml | 4 + common/shell/AppSidebar.tsx | 70 ++++++++++++++-- common/shell/index.ts | 21 +++++ common/shell/layout.ts | 17 ++++ .../src/components/layout/AppLayout.test.tsx | 18 +++++ devfront/src/components/layout/AppLayout.tsx | 80 ++++++++++++++----- devfront/src/locales/en.toml | 4 + devfront/src/locales/ko.toml | 4 + devfront/src/locales/template.toml | 4 + locales/en.toml | 4 + locales/ko.toml | 4 + locales/template.toml | 4 + 16 files changed, 277 insertions(+), 33 deletions(-) diff --git a/adminfront/src/components/layout/AppLayout.test.tsx b/adminfront/src/components/layout/AppLayout.test.tsx index 366e3f10..6634a80a 100644 --- a/adminfront/src/components/layout/AppLayout.test.tsx +++ b/adminfront/src/components/layout/AppLayout.test.tsx @@ -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(); diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 23d8a065..6ba74cb6 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -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 = (
{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} > - {t(labelKey, labelFallback)} + + {label} + ); } @@ -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} > - {t(labelKey, labelFallback)} + {label} ); })} @@ -561,10 +586,17 @@ function AppLayout() {
); @@ -578,13 +610,23 @@ function AppLayout() { } return ( -
+
} navContent={sidebarNavContent} footerContent={sidebarFooterContent} + collapsed={isSidebarCollapsed} + onToggleCollapsed={handleSidebarToggle} + collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")} + expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")} />
diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index abe23e4f..bdac84f5 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -1541,6 +1541,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)" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 8f9d3694..81cd50bf 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -1544,6 +1544,10 @@ unknown_name = "Unknown User" logout = "Logout" profile = "내 정보" +[ui.shell.sidebar] +collapse = "사이드바 접기" +expand = "사이드바 펼치기" + [ui.shell.role] rp_admin = "서비스 관리자 (RP Admin)" super_admin = "시스템 관리자 (Super Admin)" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 83582b7d..444bff20 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -1513,6 +1513,10 @@ unknown_name = "" logout = "" profile = "" +[ui.shell.sidebar] +collapse = "" +expand = "" + [ui.shell.role] rp_admin = "" super_admin = "" diff --git a/common/shell/AppSidebar.tsx b/common/shell/AppSidebar.tsx index d49f56cc..30ef40f5 100644 --- a/common/shell/AppSidebar.tsx +++ b/common/shell/AppSidebar.tsx @@ -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 ( -