diff --git a/.env.sample b/.env.sample index 2495893c..509bf2e0 100644 --- a/.env.sample +++ b/.env.sample @@ -96,6 +96,15 @@ HYDRA_ADMIN_URL=http://hydra:4445 HYDRA_PUBLIC_URL=http://hydra:4444 JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json +# Oathkeeper 실행 사용자/프로브 설정 +OATHKEEPER_VERSION=v25.4.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 + # Kratos Selfservice UI required secrets (local only) COOKIE_SECRET=localcookie123 CSRF_COOKIE_NAME=__HOST-baronSSO_csrf diff --git a/README.md b/README.md index 560e9604..a21532fa 100644 --- a/README.md +++ b/README.md @@ -207,36 +207,36 @@ docker compose -f compose.ory.yaml --profile mcp up -d hydra-mcp-server kratos-m ``` - MCP 서버는 stdio 기반이라 외부 포트를 열지 않습니다. -- MCP 클라이언트에서 `npx`로 실행하는 설정 예시입니다. -- `hydra-mcp`는 첫 실행 시 캐시 디렉터리에 의존성을 자동 설치합니다(수동 `npm install` 불필요). +- 최초 실행시거나 빌드된 이미지가 없으면 `docker compose -f mcp/compose.mcp.ory.yaml build' 후에 사용 가능합니다 ```toml [mcp_servers.kratos-mcp] -command = "npx" -args = ["-y", "mcp-ory-kratos"] +command = "docker" +args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "kratos-mcp-server"] [mcp_servers.kratos-mcp.env] -KRATOS_ADMIN_URL = "http://localhost:4434" +KRATOS_ADMIN_URL = "http://kratos:4434" [mcp_servers.hydra-mcp] -command = "npx" -args = ["-y", "/home/lectom/repos/baron-sso/mcp/hydra-mcp"] +command = "docker" +args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "hydra-mcp-server"] [mcp_servers.hydra-mcp.env] -HYDRA_PUBLIC_URL = "http://localhost:4441" -HYDRA_ADMIN_URL = "http://localhost:4445" +HYDRA_PUBLIC_URL = "http://hydra:4444" +HYDRA_ADMIN_URL = "http://hydra:4445" [mcp_servers.keto-mcp] -command = "npx" -args = ["-y", "/home/lectom/repos/baron-sso/mcp/keto-mcp"] +command = "docker" +args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "keto-mcp-server"] [mcp_servers.keto-mcp.env] -KETO_READ_URL = "http://localhost:4466" -KETO_WRITE_URL = "http://localhost:4467" +KETO_READ_URL = "http://keto:4466" +KETO_WRITE_URL = "http://keto:4467" ``` ### 로컬 개발 (Manual) -Docker 없이 코드를 수정하며 개발하려면: +Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능. +백그라운드로 infra 및 ory stack이 구동중이라는 가정 **Backend:** ```bash @@ -292,6 +292,6 @@ baron_sso/ ## 📝 상태 및 로드맵 (Status & Roadmap) - [x] **Phase 1**: 초기 설정 및 아키텍처 설계 (완료) - [x] **Phase 2**: Backend Audit API 구현 (일부 완료) -- [x] **Phase 3**: userfront 로그인 UI 인증 로직 (완료) +- [ ] **Phase 3**: userfront 로그인 UI 인증 로직 (예정) - [ ] **Phase 4**: adminfront 기능 추가 (예정) - [ ] **Phase 5**: 대시보드 및 통합 런처 구현 (예정) diff --git a/backend/cmd/server/health_monitor.go b/backend/cmd/server/health_monitor.go new file mode 100644 index 00000000..ecfe4eb9 --- /dev/null +++ b/backend/cmd/server/health_monitor.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "sync" + "time" +) + +type HTTPProbe struct { + name string + url string + interval time.Duration + timeout time.Duration + client *http.Client + mu sync.RWMutex + status string + lastError string + lastChecked time.Time + lastSuccess time.Time +} + +type ProbeSnapshot struct { + Status string + Error string + LastChecked time.Time + LastSuccess time.Time +} + +func NewHTTPProbe(name, url string, interval, timeout time.Duration) *HTTPProbe { + if interval <= 0 { + interval = 10 * time.Second + } + if timeout <= 0 { + timeout = 2 * time.Second + } + + return &HTTPProbe{ + name: name, + url: url, + interval: interval, + timeout: timeout, + client: &http.Client{ + Timeout: timeout, + }, + } +} + +// Start는 프로브를 백그라운드에서 주기적으로 실행합니다. +func (p *HTTPProbe) Start() { + go func() { + p.checkOnce() + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + for range ticker.C { + p.checkOnce() + } + }() +} + +func (p *HTTPProbe) Snapshot() ProbeSnapshot { + p.mu.RLock() + defer p.mu.RUnlock() + return ProbeSnapshot{ + Status: p.status, + Error: p.lastError, + LastChecked: p.lastChecked, + LastSuccess: p.lastSuccess, + } +} + +func (p *HTTPProbe) StatusText() string { + s := p.Snapshot() + if s.Status == "ok" { + return "ok" + } + if s.Status == "" { + return "unknown" + } + if s.Error == "" { + return "error" + } + return "error: " + s.Error +} + +func (p *HTTPProbe) checkOnce() { + ctx, cancel := context.WithTimeout(context.Background(), p.timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil) + if err != nil { + p.update("error", fmt.Sprintf("request build failed: %v", err), false) + return + } + + resp, err := p.client.Do(req) + if err != nil { + p.update("error", err.Error(), false) + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + p.update("error", fmt.Sprintf("status=%d", resp.StatusCode), false) + return + } + + p.update("ok", "", true) +} + +func (p *HTTPProbe) update(status, errMsg string, success bool) { + p.mu.Lock() + prevStatus := p.status + p.status = status + p.lastError = errMsg + p.lastChecked = time.Now() + if success { + p.lastSuccess = p.lastChecked + } + p.mu.Unlock() + + if prevStatus == status { + return + } + if status == "ok" { + slog.Info("Service probe recovered", "name", p.name, "url", p.url) + return + } + slog.Error("Service probe failed", "name", p.name, "url", p.url, "error", errMsg) +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index db9dd4ee..b52bc94d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -158,6 +158,28 @@ func main() { slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err) } + // Oathkeeper 상태를 주기적으로 확인해 다운을 감지합니다. + var oathkeeperProbe *HTTPProbe + if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" { + intervalSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_INTERVAL_SECONDS", "10")) + if err != nil || intervalSec <= 0 { + intervalSec = 10 + } + timeoutSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_TIMEOUT_SECONDS", "2")) + if err != nil || timeoutSec <= 0 { + timeoutSec = 2 + } + oathkeeperProbe = NewHTTPProbe( + "oathkeeper", + getEnv("OATHKEEPER_HEALTH_URL", "http://oathkeeper:4456/health/ready"), + time.Duration(intervalSec)*time.Second, + time.Duration(timeoutSec)*time.Second, + ) + oathkeeperProbe.Start() + } else { + slog.Info("Oathkeeper probe disabled") + } + // 2. Initialize Handlers auditHandler := handler.NewAuditHandler(auditRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider) @@ -321,6 +343,32 @@ func main() { status = "degraded" } + // Check Oathkeeper + if oathkeeperProbe != nil { + snapshot := oathkeeperProbe.Snapshot() + switch snapshot.Status { + case "ok": + checks["oathkeeper"] = "ok" + case "": + checks["oathkeeper"] = "unknown" + if status != "error" { + status = "degraded" + } + default: + if snapshot.Error == "" { + checks["oathkeeper"] = "error" + } else { + checks["oathkeeper"] = "error: " + snapshot.Error + } + status = "error" + } + } else { + checks["oathkeeper"] = "disabled" + if status != "error" { + status = "degraded" + } + } + if status == "error" { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "status": status, diff --git a/compose.ory.yaml b/compose.ory.yaml index 7d92a4fd..8236279b 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -71,22 +71,6 @@ services: - ory-net - kratosnet - kratos-mcp-server: - build: - context: ./mcp/kratos-mcp - container_name: mcp_ory_kratos - profiles: - - mcp - stdin_open: true - tty: true - init: true - environment: - - KRATOS_ADMIN_URL=http://kratos:4434 - depends_on: - - kratos - networks: - - ory-net - kratos-ui: image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0} container_name: ory_kratos_ui @@ -133,39 +117,7 @@ services: - ory-net - hydranet - hydra-mcp-server: - build: - context: ./mcp/hydra-mcp - container_name: mcp_ory_hydra - profiles: - - mcp - stdin_open: true - tty: true - init: true - environment: - - HYDRA_PUBLIC_URL=http://hydra:4444 - - HYDRA_ADMIN_URL=http://hydra:4445 - depends_on: - - hydra - networks: - - ory-net - keto-mcp-server: - build: - context: ./mcp/keto-mcp - container_name: mcp_ory_keto - profiles: - - mcp - stdin_open: true - tty: true - init: true - environment: - - KETO_READ_URL=http://keto:4466 - - KETO_WRITE_URL=http://keto:4467 - depends_on: - - keto - networks: - - ory-net # --- Keto --- keto-migrate: @@ -197,17 +149,18 @@ services: # --- Oathkeeper --- oathkeeper: - image: oryd/oathkeeper:v0.40.6 + image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v25.4.0} container_name: ory_oathkeeper + user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}" ports: - "4457:4455" # Proxy environment: - - LOG_LEVEL=debug + - LOG_LEVEL=${OATHKEEPER_LOG_LEVEL:info} - APP_ENV=${APP_ENV:-development} volumes: - ./docker/ory/oathkeeper:/etc/config/oathkeeper - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper - command: ["/etc/config/oathkeeper/entrypoint.sh"] + entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] networks: - ory-net diff --git a/docker/ory/oathkeeper/entrypoint.sh b/docker/ory/oathkeeper/entrypoint.sh index 99174eb6..506af8cd 100755 --- a/docker/ory/oathkeeper/entrypoint.sh +++ b/docker/ory/oathkeeper/entrypoint.sh @@ -19,4 +19,20 @@ export RULES_FILE echo "[oathkeeper] APP_ENV=$APP_ENV_VALUE rules=$RULES_FILE" -exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml 2>&1 | tee /var/log/oathkeeper/access.log" +RULES_ACTIVE="/etc/config/oathkeeper/rules.active.json" +if [ ! -f "$RULES_FILE" ]; then + echo "[oathkeeper] rules file not found: $RULES_FILE" + exit 1 +fi +cp "$RULES_FILE" "$RULES_ACTIVE" + +LOG_DIR="/var/log/oathkeeper" +LOG_FILE="${LOG_DIR}/access.log" +mkdir -p "$LOG_DIR" +if ! touch "$LOG_FILE" 2>/dev/null; then + echo "[oathkeeper] log file not writable: $LOG_FILE" + ls -ld "$LOG_DIR" || true + exit 1 +fi + +exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml 2>&1 | tee \"$LOG_FILE\"" diff --git a/docker/ory/oathkeeper/oathkeeper.yml b/docker/ory/oathkeeper/oathkeeper.yml index cb3b787b..7e30286c 100644 --- a/docker/ory/oathkeeper/oathkeeper.yml +++ b/docker/ory/oathkeeper/oathkeeper.yml @@ -14,7 +14,7 @@ errors: access_rules: repositories: - - file://${RULES_FILE:-/etc/config/oathkeeper/rules.json} + - file:///etc/config/oathkeeper/rules.active.json authenticators: noop: @@ -34,6 +34,13 @@ authorizers: enabled: true config: remote: http://keto:4466/check + payload: | + { + "namespace": "permissions", + "object": "{{ print .Request.URL.Path }}", + "relation": "access", + "subject_id": "{{ print .Subject }}" + } mutators: noop: diff --git a/docker/ory/oathkeeper/rules.active.json b/docker/ory/oathkeeper/rules.active.json new file mode 100644 index 00000000..e02c3382 --- /dev/null +++ b/docker/ory/oathkeeper/rules.active.json @@ -0,0 +1,92 @@ +[ + { + "id": "public-health", + "description": "공개 헬스체크", + "match": { + "url": "http://<.*>/health", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-preflight", + "description": "CORS preflight", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-auth", + "description": "인증/회원가입 등 공개 엔드포인트", + "match": { + "url": "http://<.*>/api/v1/auth/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-command", + "description": "Command 요청은 Backend로 전달 (Audit 강제)", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-query", + "description": "Backend Query (admin/dev 포함)", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + } +] diff --git a/mcp/compose.mcp.ory.yaml b/mcp/compose.mcp.ory.yaml new file mode 100644 index 00000000..695a9214 --- /dev/null +++ b/mcp/compose.mcp.ory.yaml @@ -0,0 +1,43 @@ +services: + kratos-mcp-server: + build: + context: ./kratos-mcp + container_name: mcp_ory_kratos + stdin_open: true + tty: true + init: true + environment: + - KRATOS_ADMIN_URL=http://kratos:4434 + networks: + - ory-net + + hydra-mcp-server: + build: + context: ./hydra-mcp + container_name: mcp_ory_hydra + stdin_open: true + tty: true + init: true + environment: + - HYDRA_PUBLIC_URL=http://hydra:4444 + - HYDRA_ADMIN_URL=http://hydra:4445 + networks: + - ory-net + + keto-mcp-server: + build: + context: ./keto-mcp + container_name: mcp_ory_keto + stdin_open: true + tty: true + init: true + environment: + - KETO_READ_URL=http://keto:4466 + - KETO_WRITE_URL=http://keto:4467 + networks: + - ory-net + +networks: + ory-net: + external: true + name: ory-net \ No newline at end of file