<!-- canonical: https://docs.axelabs.ai/services/vaultwarden -->
<!-- source: content/services/vaultwarden.mdx -->

---
title: Vaultwarden (AXE vault)
description: AXE 플랫폼의 Tier-1 비밀 저장소 — Timshel fork (axe.2/axe.3 patch) + Microsoft Entra SSO + Caddy sub-path. customers.yaml secrets[] → vault → axe secret pull.
---

# Vaultwarden (AXE vault)

AXE 플랫폼의 **모든 서비스 비밀의 canonical store** ([D-ops-9](/ops/decisions)). frame · hive · blueprint · stream · magnet 의 DB password · OAuth client_secret · JWT signing key · 외부 API token 이 전부 여기에 산다. 서비스 컨테이너는 vault 를 직접 모르고, `axe ship` / `axe secret pull` 이 **배포 직전** vault → `env_file` 로 동기화한 뒤 docker compose 가 그 파일을 읽는다 ([D-ops-17](/ops/decisions), runtime vault 호출 없음).

동시에 Microsoft Entra ID **OIDC RP** 로서 직원이 SSO 로 로그인하는 password manager 이기도 하다 — 즉 **운영자용 secret CLI 백엔드** + **직원용 password vault** 두 역할을 한 인스턴스가 겸한다.

> 본 페이지는 서비스 *정의* (image · compose · env · org 모델 · 비밀 흐름). 직원 setup 절차는 [/onboard/vault-setup](/onboard/vault-setup), 운영자 비밀 매니페스트 흐름은 [/architecture/secrets](/architecture/secrets), 기존 vault 보유 customer 통합은 [/partner/deploy § Vault OIDC SSO 통합](/partner/deploy#vault-oidc-sso-통합--기존-vaultwarden-운영-중인-customer-용) 를 참조.

## 한눈에

| 항목 | 값 |
|---|---|
| 역할 | Tier-1 secret store + 직원 password vault (OIDC RP) |
| image | `ghcr.io/axelabs-ai/vault` — Timshel fork + axe.2/axe.3 patch (아래 § 이미지) |
| 노출 | `127.0.0.1:8222` (HTTPS, Caddy) — 외부는 cloudflared 가 `https://axe.axelabs.ai/vault` 로 |
| 컨테이너 | `axe-vaultwarden` (core) + `axe-vault-caddy` (front, :443) |
| 위치 | `/Users/axe/.axe/vault/` (operator macmini) |
| SSO | Microsoft Entra ID (Azure AD), single-tenant `@axellc.com` |
| 비밀 SoT | 자기 자신 — `bootstrap_only: true` (vault 가 자기 OAuth secret 못 읽음, chicken-and-egg) |
| 결정 | [D-ops-9](/ops/decisions) · [D-ops-10](/ops/decisions) · [D-ops-17](/ops/decisions) · [D-ops-37](/ops/decisions) · [D-ops-40](/ops/decisions) |

## 이미지 — Timshel fork + axe.2 / axe.3 patch

upstream Vaultwarden (dani-garcia) 가 아니라 **Timshel fork** (native SSO 지원) 을 base 로, AXE 가 자체 patch 를 얹어 `ghcr.io/axelabs-ai/vault` 로 빌드한다 ([D-ops-37](/ops/decisions) — fork build, [D-ops-40](/ops/decisions) — axe.3 release).

- **base**: Timshel `1.34.1-6` (commit `80439605`, 2025-07-15)
- **axe.2 patch** (Bitwarden client ≥ 2026.4 unlock 호환, dani-garcia mainline 에서 backport):
  1. `/accounts/prelogin/password` alias 추가 — 신 client 가 prelogin 을 password-only sub-endpoint 로 분리하는데 Timshel 은 legacy `/accounts/prelogin` 만 노출 → 404 → 로그인 실패
  2. `/identity/connect/token` 에 `AccountKeys` / `MasterPasswordUnlock` wrapped-variant 필드 backport — 없으면 unlock 시 `Cannot read properties of null (reading toWrappedAccountCryptographicState)`
- **axe.3 patch** (org permission quirk, 자체 진단):
  3. `src/api/core/organizations.rs` 3곳의 `if member.access_all { continue; }` skip 제거 — Owner 는 `access_all=true` 라 explicit `users_collections` row 가 안 생겨 downstream permission 체크 (cipher delete) 가 깨졌음
  4. `Cipher::to_json` 에 `cipher.permissions {response, delete, restore}` 필드 backport — client ≥ 2026.4 가 delete/restore 를 이 필드로 gate. `can_delete` = collection manage flag OR org Owner/Admin

> **axe.2 = KDF cutoff**. axe.2 release (2026-05-26) **이전** 가입 user 는 옛 schema (`client_kdf_type=0`, PBKDF2) 라 SSO→MP unlock 이 `toWrappedAccountCryptographicState` 에러로 막혀 **KDF rotation (PBKDF2 → Argon2id)** 이 필요하다 ([D-ops-40](/ops/decisions) runbook, axe-side 4명 ai/soohun/taehun/jinwoo). axe.2 **이후** 신규 가입 user (realchoice 측 등) 는 새 schema 로 가입돼 rotation 불필요. 상세 = [/onboard/vault-setup § KDF rotation](/onboard/vault-setup).

### pin / rollback

| | tag / digest |
|---|---|
| **현재 (manifest list, multi-arch)** | `ghcr.io/axelabs-ai/vault:1.34.1-6-axe.3` @ `sha256:a26208a0...55aeada` |
| compose 의 실제 pin | digest 직접 (`image: ghcr.io/axelabs-ai/vault@sha256:a26208a0...`) — tag 가 아닌 digest 고정 |
| arm64 (axe-macmini 유효) | `sha256:d97a6ed6...d4016` |
| amd64 | `sha256:402eeac9...58572a` |
| rollback (axe.2) | local tag `axelabs-ai/vault:1.34.1-6-axe.2`, ImageID `sha256:11536845...858af9` |

- **source build**: `axelabs-ai/vault` repo 의 `build/build.sh` + `.github/workflows/build.yml` (GHA, buildx multi-arch linux/amd64 + linux/arm64), commit `799015b`.
- **GHCR auth**: `ghcr.io/axelabs-ai/vault` 는 **private**. pull PAT 는 vault 의 `Platform — Service Secrets / ghcr-axelabs-ai-pull-pat` 에 보관 (값은 vault 안). docker login:
  ```bash
  bw get password ghcr-axelabs-ai-pull-pat | docker login ghcr.io -u axe-labs-ai --password-stdin
  ```
- **upstream PR (저우선)**: axe.3 patch 3+4 를 Timshel 에 제출 — accept 되면 자체 diff 소멸.

## compose / 포트

`/Users/axe/.axe/vault/docker-compose.yml` — 2 컨테이너:

| 컨테이너 | image | 노출 | 역할 |
|---|---|---|---|
| `axe-vaultwarden` | `ghcr.io/axelabs-ai/vault@sha256:...` | `expose: 80` (host 직노출 X) | Vaultwarden core (API + SSO RP) |
| `axe-vault-caddy` | `caddy:2-alpine` | `127.0.0.1:8222 → 443` | TLS 종단 + `/vault` sub-path redirect |

- core 는 host 포트를 안 받는다 — `expose: "80"` 만 (컨테이너 네트워크 내부). Caddy 가 유일한 진입점.
- Caddy 는 **127.0.0.1 에만 바인딩** (`127.0.0.1:8222:443`) — Tailnet/공개 노출 X. bw CLI (운영자 mac) 가 mkcert 로컬 인증서로 신뢰, 외부 직원은 cloudflared 터널 origin 으로만 닿는다.
- volume: core 는 `./data:/data` (sqlite + attachments), Caddy 는 named volume 2개.

### Caddy — sub-path 호스팅 함정

`/Users/axe/.axe/vault/Caddyfile` 는 `:443` 에서 **모든 hostname** 을 받는다 (localhost = bw CLI mkcert cert, `axe.axelabs.ai` = cloudflared origin, Host 보존). Vaultwarden 은 `DOMAIN` env 가 canonical URL 을 가리키는 한 Host mismatch 를 무시한다.

핵심은 **trailing-slash redirect**:

```caddyfile
@vault_no_slash { path /vault }
redir @vault_no_slash /vault/ permanent
reverse_proxy axe-vaultwarden:80
```

Vaultwarden SPA 의 HTML 은 **상대 asset 경로** (`images/...`, `app/main.js`) 를 쓴다. 사용자가 trailing slash 없는 `/vault` 로 들어오면 브라우저가 `vault` 를 파일로 보고 root 로 올라가 `/app/main.js` 를 요청 → cloudflared 가 default ingress (blueprint:3100) 로 보내 모든 static asset 이 502. `/vault/` 강제 301 로 base directory 를 `/vault/` 로 고정해야 한다. (customer 측 onboarding 함정 표에도 동일 항목 — [/architecture/secrets](/architecture/secrets).)

## 환경 변수

`axe-vaultwarden.environment` (값 일부는 `./.env`, mode 600, gitignored 에서 치환):

| env | 값 | 이유 / 결정 |
|---|---|---|
| `DOMAIN` | `https://axe.axelabs.ai/vault` | 외부 canonical URL ([D-ops-10](/ops/decisions)) |
| `SIGNUPS_ALLOWED` | `"true"` | SSO 통과 신규 user JIT provisioning ([D-ops-26](/ops/decisions)) |
| `SSO_ONLY` | `"false"` | master password emergency fallback 보존 — Entra 장애 시 운영자 lockout 방지 ([D-ops-27](/ops/decisions)) |
| `INVITATIONS_ALLOWED` | `"false"` | manual invitation 미사용 (JIT 통일) |
| `ORGANIZATION_INVITE_AUTO_ACCEPT` | `"true"` | 사용자측 "Accept invitation" 자동 — operator 의 "Confirm" (org key 암호화 전달) 은 여전히 필수 ([D-ops-27](/ops/decisions)) |
| `SHOW_PASSWORD_HINT` | `"false"` | — |
| `LOG_LEVEL` | `"info"` | SSO flow 이벤트 캡처용 (warn → info bump) |
| `SSO_ENABLED` | `"true"` | Microsoft Entra ID RP ([D-ops-10](/ops/decisions)) |
| `SSO_AUTHORITY` | `https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0` | single-tenant → 외부 user SSO 통과 불가 |
| `SSO_CLIENT_ID` | `${AZURE_VAULTWARDEN_APP_ID}` | Entra app (Vaultwarden 전용 등록) |
| `SSO_CLIENT_SECRET` | `${AZURE_VAULTWARDEN_CLIENT_SECRET}` | **값은 vault 안** (아래 § 자기 자신 비밀) |
| `SSO_SCOPES` | `openid profile offline_access User.Read` | — |
| `SSO_SIGNUPS_MATCH_EMAIL` | `"true"` | 기존 user 와 email 매칭 |
| `SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION` | `"true"` | Entra id_token 에 `email_verified` claim 부재 — tenant 내부 검증 신원이라 허용. 없으면 첫 SSO 가 "Your provider does not send email verification status" 차단 ([D-ops-24](/ops/decisions)) |
| `SSO_AUTH_ONLY_NOT_SESSION` | `"true"` | SSO 는 인증만, session lifecycle 은 Vaultwarden 자체 (30일 idle). false 면 Entra refresh token 따라가 MP 입력 잦음. trade-off: Entra 측 비활성/제명이 server session 에 ≤30일 lag — AXE 4명 규모는 수동 즉시 offboarding 으로 OK ([D-ops-40](/ops/decisions)) |

> `${AZURE_TENANT_ID}` / `${AZURE_VAULTWARDEN_APP_ID}` / `${AZURE_VAULTWARDEN_CLIENT_SECRET}` 는 `/Users/axe/.axe/vault/.env` (mode 600) 에서 치환된다. customer macmini 의 같은 4-key 패치 (`SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION` / `SIGNUPS_ALLOWED` / `SSO_ONLY=false` / `ORGANIZATION_INVITE_AUTO_ACCEPT`) 는 `axe vault bootstrap {customer}` 가 in-place 적용 ([D-ops-33](/ops/decisions), [customer-onboarding D+1](/ops/runbook/customer-onboarding)).

## Organization 모델

비밀과 직원 password 는 **단일 AXE organization** 안에 산다 (org UUID `0c5d8bbd-ad85-42b4-8b8a-2849031981b1`, axe CLI `_AXE_VAULT_ORG_ID`, MCP catalog org 와 동일 UUID).

### dual-identity 계정 ([D-ops-29](/ops/decisions))

org 운영은 customer 당 두 종류 계정으로 나뉜다:

| identity | 용도 | 권한 |
|---|---|---|
| `ai@{customer-domain}` (axe 측 `ai@axellc.com`) | 자동화 / bot — Graph token 발행, proactive DM, agent run, **axe CLI 가 BW_SESSION 으로 쓰는 계정** | Owner + `access_all=1` |
| 실제 admin 직원 (예: soohun.kang) | 사람 작업 identity | Owner + `access_all=1` |

axe CLI 는 `ai@` 계정의 `BW_SESSION` (macOS Keychain `service=axe.vault.session`, `account=ai@axellc.com`) 으로 `bw` subprocess 를 돌린다. master password 는 **출근 시 1회** `bw unlock` 후 Keychain 에 cache (8h) — 이후 모든 `axe secret`/`axe vault` 명령이 재입력 없이 동작.

> ⚠️ "axe.2 / axe.3" 는 **org 계정 종류가 아니라 image patch release** 다 (위 § 이미지). org 계정 모델은 위 dual-identity (`ai@` bot + 사람 admin) 가 정답.

### Collection 구조 v1 ([D-ops-32](/ops/decisions))

| customer 형태 | collection |
|---|---|
| entity 1개 (예: realchoice) | 4-collection: `Platform — Service Secrets` (operator only) · `Platform — Infrastructure` (operator only) · `{Customer Entity}` (전직원 RW) · `Shared Tools` (전직원 RW) |
| entity 다수 (예: axe 의 axec/axev/axep) | 6-collection (entity별 분리) |

별도로 MCP connector 카탈로그용 `MCP Connectors` collection (`1a62e754-6e47-43e0-a99a-cf71c37b8638`) 이 있다 — `axe mcp publish` 가 connector 메타를 여기에 쓰고, 직원이 claude.ai 에 connector 추가 시 이 collection 에서 값을 본다 ([/onboard/claude-connectors](/onboard/claude-connectors), D-vault-mcp-catalog).

org REST 자동화는 axe CLI 가 bw-derived API token 으로 직접 호출한다 — `axe vault org-invite` (`POST /api/organizations/{org_id}/users/invite`), `axe secret prompt-store` (org collection 에 hidden-text item 생성, 값이 shell history/clipboard 에 안 남음).

## 비밀 흐름 — customers.yaml secrets[] → vault → axe secret pull

핵심 invariant: **서비스 코드는 vault 를 모른다.** `customers.yaml` 의 `services.<svc>.secrets[]` 매니페스트가 env-var ↔ vault-item 매핑의 SSOT 이고, `axe secret` 명령들이 그 사이를 잇는다.

```
customers.yaml                         Vaultwarden (org)              service env_file
services.<svc>.secrets[]               ai@ 계정, bw CLI               <svc>/.env (mode 600)
  - env: FRAME_DB_PASSWORD    ──push──▶  item "frame/axe/db-password"  ──pull──▶ FRAME_DB_PASSWORD=...
    vault: frame/axe/db-password         (password 필드)                          (docker compose env_file)
```

| 명령 | 동작 |
|---|---|
| `axe secret push <ENV> --service <svc> [--customer C]` | 매니페스트로 vault 경로 lookup → 신규 item 생성 or password 필드 갱신. 대형/PEM 비밀은 `--value-stdin` (값이 argv/ps 에 안 뜸) |
| `axe secret pull <svc> [--customer C]` | 매니페스트의 각 `secrets[]` 를 `bw get` → `env_file` 에 **merge** (manifest 키만 덮어쓰고 나머지 config·comment·hand-edit 라인은 보존). 따옴표 없이 raw single-line 으로 write (env_file 은 literal-quote) |
| `axe secret check <svc>` | 매니페스트 vs vault 보유 비교 → 누락 출력. `axe ship` 가 배포 gate 로 사용 |
| `axe secret send <ENV> --service <svc> [--to R]` | vault GET → `bw send` 1회용 링크 (기본 `-d 1 -a 1 --hidden`) → URL stdout. 사람 전달 전용 (직원에게 client_secret 줄 때) |
| `axe secret rotate <ENV> --service <svc>` | 새 값 (외부 provider 입력 or 자동 생성) → vault PUT → pull → ship 트리거 |
| `axe secret get <ENV> [-n]` / `axe secret list` | 단건 조회 / 전체 item 표 (list 는 값 출력 X) |

vault item 네이밍 컨벤션: `<service>/<customer>/<short>` (예: `frame/axe/db-password`, `blueprint/realchoice/azure-client-secret`). 상세 + 전체 매니페스트 (현재 42개 비밀 등재) = [/architecture/secrets](/architecture/secrets).

### 자기 자신 비밀 — bootstrap_only

vault 의 자기 OAuth client_secret (`AZURE_VAULTWARDEN_CLIENT_SECRET`) 은 **vault 자신이 자기를 못 읽는다** (vault 가 부팅돼야 secret 을 줄 수 있는데, 부팅에 그 secret 이 필요한 chicken-and-egg). 그래서 `customers.yaml` 에서 vaultwarden service 는 `bootstrap_only: true` — `axe secret check`/`pull` 이 skip 하고, `/Users/axe/.axe/vault/.env` 가 직접 그 값을 들고 있다 (vault 매니페스트엔 "manually maintained — vault is the SoT but can't read itself" 주석).

> 즉 vaultwarden 의 SSO secret 1개만 매니페스트상 self-referential 예외이고, 나머지 모든 서비스 비밀은 정상 push/pull 경로를 탄다.

## 백업

vault 의 `./data` (sqlite + attachments) 는 AXE 3-tier backup (local + ring + cold SSD, restic) 에 포함된다. 복구 절차는 vault master password 로 `axe-vaultwarden` 잠금 해제 후 data 복원 — 상세 = [/architecture/backup](/architecture/backup).

## 함정

| 함정 | 결과 | 회피 |
|---|---|---|
| Caddyfile `/vault` no trailing slash | SPA 상대 asset 이 root 옆 resolve → asset 502/503 | `/vault` → `/vault/` permanent redirect (Caddyfile 기본 적용) |
| Timshel image `wget`/`curl` 가정 오류 | compose healthcheck 실패 → unhealthy 무한 루프 | image 에 실제 존재하는 바이너리로 healthcheck (Timshel 은 `wget` 없음 → `curl`) |
| bw CLI 2026.4+ ↔ Timshel SSO 호환 | unlock 시 `TypeError: ... toWrappedAccountCryptographicState` | bw CLI **2025.7.0 pin** (`npm install -g @bitwarden/cli@2025.7.0`, [D-ops-28](/ops/decisions)) 또는 axe.2+ patch image |
| axe.2 이전 가입자 KDF | SSO→MP unlock 동일 에러 | KDF rotation PBKDF2 → Argon2id ([D-ops-40](/ops/decisions), [vault-setup](/onboard/vault-setup)) |
| `SSO_ONLY=true` 설정 | Entra 장애 시 운영자 lockout (MP 진입 불가) | `SSO_ONLY: "false"` 유지 ([D-ops-27](/ops/decisions)) |
| `SIGNUPS_ALLOWED=false` + `INVITATIONS_ALLOWED=false` | 첫 employee JIT 가입 "Failed to retrieve the invitation" | `SIGNUPS_ALLOWED: "true"` ([D-ops-26](/ops/decisions)) |
| `docker compose restart` 만 함 | env 변경 미반영 (restart 는 env reload 안 함) | `docker compose up -d --force-recreate axe-vaultwarden` |
| `ghcr.io/axelabs-ai/vault` private pull 실패 | image pull `denied` | `ghcr-axelabs-ai-pull-pat` 로 `docker login ghcr.io` 선행 |

## 참고

- 운영자 비밀 매니페스트 + axe secret 명령: [/architecture/secrets](/architecture/secrets) ([D-ops-17](/ops/decisions))
- 직원 vault setup (SSO/KDF/3-client): [/onboard/vault-setup](/onboard/vault-setup)
- 기존 vault 보유 customer 통합: [/partner/deploy § Vault OIDC SSO 통합](/partner/deploy)
- customer 측 vault bootstrap: [/ops/runbook/customer-onboarding § D+1](/ops/runbook/customer-onboarding)
- 결정: [D-ops-9](/ops/decisions) · [D-ops-10](/ops/decisions) · [D-ops-17](/ops/decisions) · [D-ops-24](/ops/decisions) · [D-ops-26](/ops/decisions) · [D-ops-27](/ops/decisions) · [D-ops-28](/ops/decisions) · [D-ops-32](/ops/decisions) · [D-ops-33](/ops/decisions) · [D-ops-37](/ops/decisions) · [D-ops-40](/ops/decisions)
- 소스: `/Users/axe/.axe/vault/{docker-compose.yml, Caddyfile, .env}` · build: `axelabs-ai/vault` repo
