diff --git a/.env.example b/.env.example index 1765908..f1ca59b 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,6 @@ # Optional. Defaults to ~/.local/share/jarca/ledger.sqlite3 JARCA_LEDGER_DB_PATH= + +# Required for the Streamlit Dashboard. Inject real values from a secret store. +JARCA_DASHBOARD_ADMIN_ID= +JARCA_DASHBOARD_ADMIN_PASSWORD= diff --git a/README.md b/README.md index f19aba2..fa8efc2 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,25 @@ hermes mcp test jarca-ledger 로컬 웹 대시보드는 실제 기본 DB `~/.local/share/jarca/ledger.sqlite3`를 읽고 씁니다. +Dashboard는 모든 조회·수정·Import 기능 앞에서 단일 관리자 로그인을 요구합니다. 관리자 ID와 password는 Streamlit secrets가 아니라 배포 secret이 주입하는 다음 환경변수에서만 읽습니다. + +```bash +export JARCA_DASHBOARD_ADMIN_ID='' +export JARCA_DASHBOARD_ADMIN_PASSWORD='' +``` + +두 값 중 하나라도 누락됐거나 비어 있거나 읽을 수 없으면 Dashboard는 fail-closed하여 로그인 설정 오류만 표시하고 DB나 page view에 접근하지 않습니다. 실제 credential을 `.env`, Compose 파일, shell history 또는 repository 문서에 저장하지 마세요. + ```bash cd /root/projects/jarca-ledger-mcp . .venv/bin/activate streamlit run dashboard/app.py --server.address 0.0.0.0 --server.port 8501 ``` +인증 성공 상태는 현재 Streamlit browser session에서만 유지됩니다. 새 session에는 재로그인이 필요하고 sidebar의 `로그아웃`으로 즉시 인증 상태를 폐기할 수 있습니다. 잘못된 ID 또는 password 응답은 매 제출마다 최소 1초 지연되지만 애플리케이션 잠금은 없습니다. 이 지연은 새 session이나 병렬 요청으로 우회할 수 있으므로 외부에 노출할 때는 Ingress, reverse proxy 또는 동등한 배포 경계에 별도 rate limiting을 적용해야 합니다. + +이 로그인은 **Streamlit Dashboard만 보호하며 MCP endpoint를 인증하지 않습니다.** MCP는 별도 요구사항과 적절한 네트워크 경계로 보호해야 합니다. + 주요 화면: - 요약: 실제 수입/지출과 계좌 입출금을 분리 표시, 월별 현금흐름, 지출 카테고리 차트 @@ -72,6 +85,7 @@ Deployment: jarca-ledger (replicas: 1) - 하나의 Pod 안에서 MCP와 Streamlit을 **별도 컨테이너**로 실행합니다. 한 컨테이너에서 두 프로세스를 supervisor나 background command로 함께 실행하지 않습니다. - 두 컨테이너는 동일한 PVC를 `/data`에 마운트하고 `JARCA_LEDGER_DB_PATH=/data/ledger.sqlite3`를 사용합니다. +- frontend 컨테이너에만 `JARCA_DASHBOARD_ADMIN_ID`와 `JARCA_DASHBOARD_ADMIN_PASSWORD`를 Kubernetes Secret 또는 동등한 secret store에서 주입합니다. 실제 값은 manifest나 repository에 기록하지 않습니다. - SQLite 파일을 공유하므로 Deployment의 replica는 `1`로 유지합니다. - MCP와 frontend는 목적별 Kubernetes Service로 분리합니다. MCP는 `8000`, frontend는 `8501`을 대상으로 합니다. - 외부 공개가 필요하면 일반적으로 frontend Service만 Ingress 또는 NodePort에 연결하고, MCP Service는 필요한 범위에서만 노출합니다. @@ -80,7 +94,7 @@ Deployment: jarca-ledger (replicas: 1) Streamlit이 SQLite에 직접 접근하지 않고 MCP/API만 호출하도록 변경하거나 네트워크 DB로 전환한 뒤에는 MCP와 frontend를 각각 독립 Deployment로 분리할 수 있습니다. -로컬 `docker-compose.yml`도 동일한 애플리케이션 이미지를 두 컨테이너로 실행하는 구조입니다. 현재 Compose 설정은 로컬에서 빌드한 `jarca-ledger:latest`를 사용하며, MCP는 호스트 `8000` 포트로, Streamlit은 호스트 `40059`에서 컨테이너 `8501` 포트로 연결됩니다. GHCR 릴리즈를 배포할 때는 두 서비스의 `image`를 검증된 동일 digest로 지정해야 합니다. +로컬 `docker-compose.yml`도 동일한 애플리케이션 이미지를 두 컨테이너로 실행하는 구조입니다. 현재 Compose 설정은 로컬에서 빌드한 `jarca-ledger:latest`를 사용하며, MCP는 호스트 `8000` 포트로, Streamlit은 호스트 `40059`에서 컨테이너 `8501` 포트로 연결됩니다. Compose 실행 전 host environment 또는 안전한 `.env` 전달 경계에 두 Dashboard credential 변수를 설정해야 하며 누락 시 Compose가 시작을 거부합니다. GHCR 릴리즈를 배포할 때는 두 서비스의 `image`를 검증된 동일 digest로 지정해야 합니다. ## 주요 도구 diff --git a/dashboard/app.py b/dashboard/app.py index 46389de..1a4d5b7 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -13,6 +13,7 @@ if __package__ in (None, ""): st.set_page_config(page_title="Jarca Ledger", page_icon="💰", layout="wide") from dashboard.common import clear_cache, get_db_path, get_transfer_review_items, inject_app_css, render_sidebar_nav # noqa: F401 +from dashboard.authentication import require_dashboard_authentication from dashboard.page_views.accounts import page_accounts from dashboard.page_views.assets import page_assets from dashboard.page_views.categories import page_categories @@ -25,6 +26,9 @@ from dashboard.page_views.transactions import page_transactions from dashboard.page_views.uncategorized import page_uncategorized def main() -> None: + if not require_dashboard_authentication(): + return + db_path = get_db_path() inject_app_css() pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"] diff --git a/dashboard/authentication.py b/dashboard/authentication.py new file mode 100644 index 0000000..14205d0 --- /dev/null +++ b/dashboard/authentication.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from collections.abc import Callable as CallableABC, Mapping, MutableMapping +from dataclasses import dataclass +import hmac +import logging +import os +import secrets +import time +from typing import Callable + +import streamlit as st + +logger = logging.getLogger(__name__) + +ADMIN_ID_ENV = "JARCA_DASHBOARD_ADMIN_ID" +ADMIN_PASSWORD_ENV = "JARCA_DASHBOARD_ADMIN_PASSWORD" +CONFIGURATION_ERROR_MESSAGE = "관리자 인증 설정을 확인할 수 없습니다." +INVALID_CREDENTIALS_MESSAGE = "관리자 ID 또는 비밀번호가 올바르지 않습니다." +MINIMUM_FAILURE_DELAY_SECONDS = 1.0 +AUTHENTICATED_SESSION_KEY = "_jarca_dashboard_authenticated" +LOGIN_FAILED_SESSION_KEY = "_jarca_dashboard_login_failed" +LOGIN_ID_SESSION_KEY = "_jarca_dashboard_login_id" +LOGIN_PASSWORD_SESSION_KEY = "_jarca_dashboard_login_password" +AUTH_CORRELATION_SESSION_KEY = "_jarca_dashboard_auth_correlation_id" + + +@dataclass(frozen=True) +class AdminCredentials: + admin_id: str + password: str + + +class AuthenticationConfigurationError(RuntimeError): + """Raised without secret details when dashboard authentication is unavailable.""" + + +def load_admin_credentials(environ: Mapping[str, str] | None = None) -> AdminCredentials: + source = os.environ if environ is None else environ + try: + admin_id = source.get(ADMIN_ID_ENV) + password = source.get(ADMIN_PASSWORD_ENV) + except Exception as exc: + raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE) from exc + + if not isinstance(admin_id, str) or not admin_id or not isinstance(password, str) or not password: + raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE) + + return AdminCredentials(admin_id=admin_id, password=password) + + +def verify_credentials(credentials: AdminCredentials, submitted_id: str, submitted_password: str) -> bool: + id_matches = hmac.compare_digest(submitted_id, credentials.admin_id) + password_matches = hmac.compare_digest(submitted_password, credentials.password) + return id_matches and password_matches + + +def enforce_failed_login_delay( + *, + started_at: float, + minimum_seconds: float = MINIMUM_FAILURE_DELAY_SECONDS, + monotonic: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, +) -> None: + remaining = minimum_seconds - (monotonic() - started_at) + if remaining > 0: + sleep(remaining) + + +def get_auth_correlation_id( + session_state: MutableMapping[str, object], + *, + token_hex: CallableABC[[int], str] = secrets.token_hex, +) -> str: + existing = session_state.get(AUTH_CORRELATION_SESSION_KEY) + if isinstance(existing, str) and existing: + return existing + correlation_id = token_hex(8) + session_state[AUTH_CORRELATION_SESSION_KEY] = correlation_id + return correlation_id + + +def log_auth_event(event: str, correlation_id: str) -> None: + logger.info("dashboard_auth event=%s correlation_id=%s", event, correlation_id) + + +def _clear_submitted_credentials() -> None: + st.session_state.pop(LOGIN_ID_SESSION_KEY, None) + st.session_state.pop(LOGIN_PASSWORD_SESSION_KEY, None) + + +def require_dashboard_authentication() -> bool: + """Render the authentication boundary and return whether access is allowed.""" + correlation_id = get_auth_correlation_id(st.session_state) + try: + credentials = load_admin_credentials() + except AuthenticationConfigurationError: + st.session_state.pop(AUTHENTICATED_SESSION_KEY, None) + _clear_submitted_credentials() + log_auth_event("configuration_error", correlation_id) + st.title("관리자 로그인") + st.error(CONFIGURATION_ERROR_MESSAGE) + return False + + if st.session_state.get(AUTHENTICATED_SESSION_KEY) is True: + _clear_submitted_credentials() + with st.sidebar: + if st.button("로그아웃", key="dashboard_logout", use_container_width=True): + st.session_state.pop(AUTHENTICATED_SESSION_KEY, None) + log_auth_event("logout", correlation_id) + st.rerun() + return True + + st.title("관리자 로그인") + st.caption("Dashboard를 사용하려면 관리자 자격 증명이 필요합니다.") + show_login_error = st.session_state.pop(LOGIN_FAILED_SESSION_KEY, False) is True + if show_login_error: + _clear_submitted_credentials() + with st.form("dashboard_login", clear_on_submit=True): + submitted_id = st.text_input("관리자 ID", key=LOGIN_ID_SESSION_KEY) + submitted_password = st.text_input("비밀번호", type="password", key=LOGIN_PASSWORD_SESSION_KEY) + submitted = st.form_submit_button("로그인", use_container_width=True) + + if show_login_error: + st.error(INVALID_CREDENTIALS_MESSAGE) + + if not submitted: + return False + + started_at = time.monotonic() + if verify_credentials(credentials, submitted_id, submitted_password): + st.session_state[AUTHENTICATED_SESSION_KEY] = True + log_auth_event("login_succeeded", correlation_id) + st.rerun() + + enforce_failed_login_delay(started_at=started_at) + log_auth_event("login_failed", correlation_id) + st.session_state[LOGIN_FAILED_SESSION_KEY] = True + st.rerun() diff --git a/docker-compose.yml b/docker-compose.yml index bc256b6..beabeec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,8 @@ services: - "40059:8501" environment: JARCA_LEDGER_DB_PATH: /data/ledger.sqlite3 + JARCA_DASHBOARD_ADMIN_ID: ${JARCA_DASHBOARD_ADMIN_ID:?set JARCA_DASHBOARD_ADMIN_ID} + JARCA_DASHBOARD_ADMIN_PASSWORD: ${JARCA_DASHBOARD_ADMIN_PASSWORD:?set JARCA_DASHBOARD_ADMIN_PASSWORD} STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false" PYTHONUNBUFFERED: "1" volumes: diff --git a/docs/interface-contract.md b/docs/interface-contract.md new file mode 100644 index 0000000..c157729 --- /dev/null +++ b/docs/interface-contract.md @@ -0,0 +1,154 @@ +# Dashboard 관리자 인증 인터페이스 계약 + +## 목적과 범위 + +Streamlit Dashboard의 관리자 credential 입력, 환경 설정 판정, browser session 인증과 로그아웃 호출 규칙을 정의한다. + +- 포함 범위: Dashboard 환경변수 credential, 로그인·로그아웃 UI, 인증 결과와 오류, 기존 page dispatch 경계 +- 제외 범위: MCP 인증, 다중 사용자, 영속 session, OIDC·SSO, TLS와 Ingress +- 사용 주체: Dashboard 사용자, 배포 운영자, `dashboard/app.py`, Dashboard page view + +## 계약 + +### dashboard-auth-config — 관리자 인증 설정 + +- 책임: 배포 secret에서 단일 관리자 credential을 fail-closed로 읽는다. +- 종류·프로토콜: process environment +- 호출 대상·operation: `dashboard.authentication.load_admin_credentials` +- 버전: 1.0 +- 호출 주체: Dashboard 인증 경계 +- 인증 방식: 해당 없음; 인증을 구성하는 입력 +- 필요 권한: Dashboard process environment를 설정할 배포 권한 + +#### 입력 + +| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---|---| +| `JARCA_DASHBOARD_ADMIN_ID` | 환경변수 | `string` | 필수 | 없음 | 비어 있지 않은 문자열 | 단일 관리자 ID | 예 | +| `JARCA_DASHBOARD_ADMIN_PASSWORD` | 환경변수 | `string` | 필수 | 없음 | 비어 있지 않은 평문 password secret | 관리자 password | 예 | + +#### 출력 + +| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---| +| `AdminCredentials` | process memory | `dataclass` | 성공 시 필수 | 두 비어 있지 않은 문자열 | 현재 rerun에서만 사용할 검증 값 | 예 | + +#### 오류 + +| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | +|---|---|---|---|---|---| +| `AUTH_CONFIGURATION_ERROR` | 설정 | 누락, 빈 값, 비문자열 또는 environment read 실패 | 설정 수정 후 가능 | 일반 설정 오류만 표시하고 Dashboard 중단 | 기존 인증 boolean 폐기, 금융 상태 무변경 | + +### dashboard-login — 관리자 로그인 + +- 책임: 제출 credential을 검증하고 현재 browser session의 인증 상태를 설정한다. +- 종류·프로토콜: Streamlit form rerun +- 호출 대상·operation: `dashboard.authentication.require_dashboard_authentication` +- 버전: 1.0 +- 호출 주체: 미인증 Dashboard 사용자 +- 인증 방식: 관리자 ID·password의 constant-time 비교 +- 필요 권한: 없음 + +#### 입력 + +| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---|---| +| `admin_id` | Streamlit text input | `string` | 필수 | 빈 문자열 | 사용자 제출 문자열 | 관리자 ID 후보 | 예 | +| `password` | Streamlit password input | `string` | 필수 | 빈 문자열 | 사용자 제출 문자열 | 관리자 password 후보 | 예 | +| `page` | URL query parameter | `string` | 선택 | `요약` | 기존 Dashboard page 이름 | 인증 성공 후 이동할 page | 아니요 | + +#### 출력 + +| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---| +| `_jarca_dashboard_authenticated` | `st.session_state` | `boolean` | 성공 후 필수 | `true` | 현재 session의 인증 여부 | 아니요 | +| 화면 | Streamlit UI | `view` | 필수 | 로그인 또는 인증된 Dashboard | 현재 인증 상태의 사용자 화면 | 금융 화면은 인증 후 민감 | +| 인증 event | application log | `string` | 제출 후 필수 | event와 correlation ID | 운영 진단 신호 | 아니요 | + +#### 오류 + +| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | +|---|---|---|---|---|---| +| `INVALID_CREDENTIALS` | 인증 | ID 또는 password 불일치 | 가능 | 최소 1초 후 동일 일반 오류 표시 | 금융 상태 무변경, credential 입력 제거 | +| `AUTH_CONFIGURATION_ERROR` | 설정 | credential source 무효 | 설정 수정 후 가능 | 일반 설정 오류만 표시 | 인증 상태 폐기, 금융 상태 무변경 | + +### dashboard-logout — 관리자 로그아웃 + +- 책임: 현재 browser session의 인증 상태를 즉시 폐기한다. +- 종류·프로토콜: Streamlit button rerun +- 호출 대상·operation: sidebar `로그아웃` +- 버전: 1.0 +- 호출 주체: 인증된 Dashboard 사용자 +- 인증 방식: 현재 session의 인증 boolean +- 필요 권한: 인증된 session + +#### 입력 + +| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---|---| +| `로그아웃` | Streamlit button | `click event` | 필수 | 없음 | 단일 click | 인증 종료 요청 | 아니요 | + +#### 출력 + +| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---| +| 인증 상태 | `st.session_state` | `absent` | 필수 | key 제거 | 현재 session 재인증 요구 | 아니요 | +| 화면 | Streamlit UI | `view` | 필수 | 로그인 화면 | 로그아웃 완료 | 아니요 | + +#### 오류 + +| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | +|---|---|---|---|---|---| +| `없음` | 없음 | 없음 | 해당 없음 | 해당 없음 | 인증 boolean 제거 외 금융 상태 무변경 | + +## 호환성 + +- 유지할 기존 호출 방식: 인증 성공 후 기존 `page` query parameter와 Dashboard page dispatch를 유지한다. +- 버전 변경 정책: 환경변수 이름 또는 session 계약 변경은 requirement revision과 본 문서를 함께 변경한다. +- 폐기 정책: 두 환경변수는 대체 계약 승인 전까지 폐기하지 않는다. +- 재시도 계약: 설정 오류는 설정 수정 후 rerun, credential 오류는 각 제출마다 독립 재시도한다. +- 중복 처리 계약: 반복 실패는 잠금을 만들지 않으며 각 실패에 최소 1초 지연을 적용한다. 로그아웃 재시도는 금융 상태를 변경하지 않는다. +- 순서 보장: 설정 판정 → session 판정 → 로그인 또는 로그아웃 → 인증 성공 후에만 DB 경로 취득과 page dispatch 순서를 유지한다. + +## 예시 + +### config-example — 배포 환경변수 이름 + +- 대상 인터페이스: `dashboard-auth-config` + +#### 호출 + +```text +JARCA_DASHBOARD_ADMIN_ID= +JARCA_DASHBOARD_ADMIN_PASSWORD= +``` + +#### 결과 + +```text +유효한 두 값: 로그인 form 제공 +누락 또는 빈 값: 일반 설정 오류와 Dashboard 차단 +``` + +### login-failure-example — 잘못된 credential + +- 대상 인터페이스: `dashboard-login` + +#### 호출 + +```text +관리자 ID와 password 제출 +``` + +#### 결과 + +```text +최소 1초 후 "관리자 ID 또는 비밀번호가 올바르지 않습니다." +``` + +## 검증 + +- 정상 계약: test-only credential과 synthetic DB를 사용한 AppTest 로그인 후 기존 페이지 렌더링 +- 실패 계약: 누락·빈 값·read error와 잘못된 ID·password의 일반 오류·무변경 검증 +- 경계 계약: query parameter 우회 금지, 새 session 재인증, 로그아웃 직후 차단, credential session 제거 +- 구현·스키마·예시 정합성: `dashboard/authentication.py`, `dashboard/app.py`, `.env.example`, `docker-compose.yml`과 인증 test를 함께 검토하며 DB migration이 없음을 확인 diff --git a/docs/project-structure.md b/docs/project-structure.md new file mode 100644 index 0000000..7f6c81e --- /dev/null +++ b/docs/project-structure.md @@ -0,0 +1,101 @@ +# Dashboard 관리자 인증 프로젝트 구조 + +## 목적과 경계 + +Dashboard 진입점 앞의 인증 경계, 인증 구현, 기존 page view, test와 배포 설정의 책임 및 단방향 의존을 정의한다. + +- 포함 경계: `dashboard/`, Dashboard 인증 test, 환경 예시, Compose와 인증 설계 문서 +- 제외 경계: MCP adapter·server 인증, 가계부 application·SQLite schema 변경 + +## 디렉터리 구조 + +### dashboard-runtime — Dashboard runtime + +```text +dashboard/ +├── app.py +├── authentication.py +├── common.py +└── page_views/ +``` + +| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 | +|---|---|---|---|---|---|---| +| `dashboard/app.py` | Streamlit 진입점 | page config 후 인증 gate를 호출하고 인증된 요청만 기존 Dashboard로 dispatch | `main()` | 인증 정책 세부 구현을 포함하지 않음 | DB 경로 취득 전 gate 유지 | 수동 | +| `dashboard/authentication.py` | 인증 모듈 | 환경변수 설정, constant-time 검증, 실패 지연, session·로그아웃과 event log | `require_dashboard_authentication()` | 가계부 DB·gateway·page view에 의존하지 않음 | credential 비보존과 fail-closed 유지 | 수동 | +| `dashboard/common.py` | Dashboard 공통 지원 | DB 경로, UI와 gateway helper | 기존 함수 | 인증 상태를 결정하지 않음 | 인증된 dispatch 이후 사용 | 수동 | +| `dashboard/page_views/` | 화면 모듈 | 기존 금융 조회·쓰기·import UI | 각 `page_*` 함수 | 인증 구현에 역방향 의존하지 않음 | `app.py`의 인증된 dispatch로만 공개 | 수동 | + +### dashboard-tests — Dashboard 인증·회귀 test + +```text +tests/ +├── test_dashboard_authentication.py +├── test_dashboard_authentication_app.py +└── test_dashboard_app.py +``` + +| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 | +|---|---|---|---|---|---|---| +| `tests/test_dashboard_authentication.py` | 단위 test | 설정·constant-time 비교·지연·비민감 log 검증 | pytest | 실제 credential과 운영 DB 금지 | test-only 값 사용 | 수동 | +| `tests/test_dashboard_authentication_app.py` | Streamlit 통합 test | fail-closed, 우회 금지, 1초 지연, session과 logout 검증 | pytest AppTest | synthetic fixture만 사용 | acceptance criterion 대응 유지 | 수동 | +| `tests/test_dashboard_app.py` | 기존 기능 회귀 test | 인증 후 핵심 page 호환성 검증 | pytest AppTest | synthetic fixture만 사용 | 로그인 후 기존 assertion 유지 | 수동 | + +### deployment-docs — 배포 설정과 계약 문서 + +```text +.env.example +docker-compose.yml +README.md +docs/ +├── requirements/req-001_dashboard-administrator-authentication.md +├── interface-contract.md +├── project-structure.md +└── runtime-flow.md +``` + +| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 | +|---|---|---|---|---|---|---| +| `.env.example` | 설정 예시 | credential 변수 이름과 용도 | `.env` 복사 | 실제 secret 값 금지 | 빈 값만 제공 | 수동 | +| `docker-compose.yml` | local deployment | Dashboard container로 host secret 환경변수 전달 | Compose service | MCP service credential 계약 변경 금지 | required-variable interpolation으로 누락 시 시작 거부 | 수동 | +| `README.md` | 운영 안내 | 설정, fail-closed, 보호 범위와 rate-limit 제약 | 사용자 문서 | 실제 secret 값 금지 | 구현과 함께 갱신 | 수동 | +| `docs/` 인증 문서 | 정본 계약 | 요구사항·인터페이스·구조·흐름 | Markdown | 데이터 모델 문서 생성 없음 | harness template 구조 유지 | gitea-agent impl workflow | + +## 의존 방향 + +| 출발 영역 | 대상 영역 | 허용 여부 | 규칙 | 공유 계약 위치 | +|---|---|---|---|---| +| `dashboard-app` | `dashboard-authentication` | 허용 | DB 경로와 page dispatch보다 먼저 gate 호출 | `docs/runtime-flow.md` | +| `dashboard-authentication` | `dashboard-common` | 금지 | 인증은 가계부 DB·gateway와 독립 | `docs/interface-contract.md` | +| `dashboard-authentication` | `dashboard-page-views` | 금지 | 인증 모듈은 page view를 import·호출하지 않음 | `docs/interface-contract.md` | +| `dashboard-app` | `dashboard-common` | 인증 후 허용 | gate가 참일 때만 DB/UI helper 사용 | `docs/runtime-flow.md` | +| `dashboard-app` | `dashboard-page-views` | 인증 후 허용 | gate가 참일 때만 선택 page dispatch | `docs/runtime-flow.md` | +| `dashboard-page-views` | `dashboard-authentication` | 금지 | page별 인증 분기나 역방향 의존을 만들지 않음 | `docs/requirements/req-001_dashboard-administrator-authentication.md` | +| `dashboard-authentication` | `mcp-adapter` | 금지 | 이번 인증은 MCP를 보호하지 않음 | `README.md` | + +## 변경 규칙 + +| 구분 | 규칙 | +|---|---| +| 새 모듈·파일 배치 | Dashboard 공통 인증은 `dashboard/authentication.py`, page 기능은 기존 `dashboard/page_views/`에 둔다. | +| 이름·패키지 | 환경변수는 `JARCA_DASHBOARD_` prefix, 비공개 session key는 `_jarca_dashboard_` prefix를 사용한다. | +| 소스·테스트 대응 | 인증 pure helper는 `test_dashboard_authentication.py`, Streamlit 경계는 `test_dashboard_authentication_app.py`, 기존 page 호환성은 `test_dashboard_app.py`에서 검증한다. | +| 생성 산출물 | DB schema·migration·credential file을 생성하지 않는다. Requirement와 설계 문서만 `docs/`에 추가한다. | + +## 검증 + +- 구조 검사 명령: + + ```text + python -c "from pathlib import Path; required=['dashboard/app.py','dashboard/authentication.py','tests/test_dashboard_authentication.py','tests/test_dashboard_authentication_app.py']; assert all(Path(p).is_file() for p in required)" + ``` + +- 구조 검사 기대 결과: exit code 0이며 모든 인증 source·test 경로가 존재한다. +- 정적 분석 명령: + + ```text + uv run --extra dashboard --extra dev ruff check dashboard tests + ``` + +- 정적 분석 기대 결과: 새 syntax·undefined-name 오류가 없다. +- 문서와 실제 트리의 정합성: base-to-worktree 변경 파일을 본 문서의 세 영역과 대조하고 `docs/data-model.md`와 DB migration이 생성되지 않았는지 확인한다. diff --git a/docs/requirements/req-001_dashboard-administrator-authentication.md b/docs/requirements/req-001_dashboard-administrator-authentication.md new file mode 100644 index 0000000..340f051 --- /dev/null +++ b/docs/requirements/req-001_dashboard-administrator-authentication.md @@ -0,0 +1,493 @@ +--- +{ + "schema": "gitea-agent.requirement/v1", + "id": "REQ-001", + "title": "Dashboard 관리자 인증", + "revision": "1.0", + "status": "approved", + "depends_on": [], + "applies_to": ["dashboard"], + "language": "ko" +} +--- + +# REQ-001 — Dashboard 관리자 인증 + +## 1. 목적 + +Streamlit Dashboard의 모든 금융 데이터 조회·수정·import 기능 앞에 단일 관리자 인증 경계를 두고, 인증되지 않았거나 인증 설정이 유효하지 않은 요청을 fail-closed한다. + +## 2. 배경 + +Dashboard는 SQLite 가계부를 직접 읽고 쓰며 거래 수정, 계좌 변경, PDF import와 import rollback을 제공한다. 기존 진입점은 인증 없이 DB 경로를 얻고 page view를 dispatch했으므로, 외부 노출 시 금융 데이터와 운영 기능을 보호할 애플리케이션 수준의 경계가 필요하다. + +## 3. 범위 + +### 포함 + +- 모든 Dashboard 페이지의 공통 관리자 로그인과 로그아웃 +- 환경변수 기반 단일 관리자 credential 검증 +- 인증 전 DB·gateway·page dispatch 차단 +- browser session 범위의 인증 상태 +- 실패 로그인 최소 지연, 일반 오류와 인증 event 관찰 가능성 +- Compose·환경 예시·운영 문서의 secret 주입 계약 + +### 제외 + +- 다중 사용자, 역할 기반 권한과 사용자별 데이터 분리 +- 회원가입, 비밀번호 찾기와 credential 관리 UI +- OIDC, SSO, 외부 IdP와 persistent cookie +- SQLite schema와 가계부 도메인 모델 변경 +- MCP endpoint 인증, TLS, Ingress와 네트워크 경계 전체 구현 + +## 4. 용어와 기준 + +- Dashboard: `dashboard/app.py`를 진입점으로 실행되는 Streamlit 애플리케이션 +- 인증 전: 현재 Streamlit browser session의 인증 상태가 참이 아닌 상태 +- credential source: 배포 secret이 주입하는 `JARCA_DASHBOARD_ADMIN_ID`와 `JARCA_DASHBOARD_ADMIN_PASSWORD` 환경변수 +- browser session: Streamlit이 `st.session_state`를 격리하는 단일 클라이언트 session +- 정본 근거: Gitea Issue `#1`과 source revision `069d4c16fd84e871e8cd9dc8d9ae39b4fce4a6c6` + +## 5. 요구사항 + +### REQ-001.01 — 공통 인증 경계 + +Dashboard는 어떤 DB 경로 취득, page dispatch, DB-backed 렌더링, gateway 호출, upload 또는 rollback 처리보다 먼저 인증을 판정해야 한다. 인증 전에는 로그인 화면만 렌더링해야 한다. + +**영향 범위** + +- 인터페이스: Streamlit Dashboard 진입점과 모든 page view +- 영속성: 인증 전에는 SQLite 읽기·쓰기를 수행하지 않음 +- 후속 사용 주체: Dashboard 사용자와 page view +- 마이그레이션: 없음 +- 호환성: 인증 성공 후 기존 page query parameter와 페이지 기능 유지 +- 배포 전제 조건: 관리자 환경변수 두 개가 유효하게 주입됨 + +### REQ-001.02 — Credential 설정과 fail-closed + +애플리케이션은 `JARCA_DASHBOARD_ADMIN_ID`와 `JARCA_DASHBOARD_ADMIN_PASSWORD`만 credential source로 읽어야 한다. Streamlit secrets 또는 다른 fallback을 사용하지 않으며, 어느 값이든 누락·빈 값·비문자열·읽기 오류이면 일반 설정 오류를 표시하고 인증을 허용하지 않아야 한다. + +**영향 범위** + +- 인터페이스: process environment +- 영속성: credential을 저장하지 않음 +- 후속 사용 주체: Dashboard 인증 경계 +- 마이그레이션: 없음 +- 호환성: 두 환경변수를 설정하지 않은 기존 Dashboard 배포는 의도적으로 fail-closed +- 배포 전제 조건: 배포 secret의 환경변수 주입 + +### REQ-001.03 — Credential 검증과 비노출 + +제출된 관리자 ID와 password는 모두 비교하되 password를 포함한 검증은 constant-time primitive를 사용해야 한다. Credential 원문·verifier는 화면, 일반 오류, traceback, URL/query parameter, application log 또는 로그인 완료 후 `st.session_state`에 남지 않아야 한다. + +**영향 범위** + +- 인터페이스: 관리자 ID·password 입력 form +- 영속성: 없음 +- 후속 사용 주체: 현재 Dashboard session +- 마이그레이션: 없음 +- 호환성: 없음 +- 배포 전제 조건: 평문 password secret은 repository가 아닌 secret store에서 제공 + +### REQ-001.04 — 실패 로그인 응답 + +잘못된 ID 또는 password의 각 제출은 제출 처리 시작부터 일반 오류 응답 완료까지 최소 1초가 걸려야 한다. 두 실패는 동일한 오류를 표시하고 DB·gateway·상태 변경을 수행하지 않아야 한다. 성공 로그인에는 실패 지연을 적용하지 않으며 애플리케이션 수준 잠금은 두지 않는다. + +**영향 범위** + +- 인터페이스: 로그인 제출과 오류 응답 +- 영속성: 없음 +- 후속 사용 주체: 미인증 Dashboard 사용자 +- 마이그레이션: 없음 +- 호환성: 없음 +- 배포 전제 조건: 새 session·병렬 요청 우회를 제한할 별도 배포 경계 rate limiting 권고 + +### REQ-001.05 — Session과 로그아웃 + +인증 성공 상태는 현재 Streamlit browser session에서만 유지해야 한다. 같은 session의 rerun은 재로그인 없이 Dashboard를 사용할 수 있고, 명시적 로그아웃은 즉시 인증 상태를 폐기해야 하며 새 session은 다시 인증해야 한다. + +**영향 범위** + +- 인터페이스: `st.session_state`, 로그아웃 button +- 영속성: server-side 영속 session 없음 +- 후속 사용 주체: 현재 browser session +- 마이그레이션: 없음 +- 호환성: browser 재시작·새 session의 자동 로그인 없음 +- 배포 전제 조건: 없음 + +### REQ-001.06 — 보호 범위와 기존 기능 호환성 + +인증 성공 후 요약, 자산/마감, 거래내역, 확인필요 큐, 미분류, 분류 규칙, 카테고리, 계좌와 Import를 포함한 기존 Dashboard 페이지를 사용할 수 있어야 한다. 이 인증은 Dashboard만 보호하며 MCP endpoint가 인증됐다고 간주해서는 안 된다. + +**영향 범위** + +- 인터페이스: 모든 Dashboard page view +- 영속성: 기존 SQLite schema와 데이터 계약 유지 +- 후속 사용 주체: 인증된 Dashboard 사용자 +- 마이그레이션: 없음 +- 호환성: 인증 후 기존 Dashboard 동작 유지 +- 배포 전제 조건: MCP는 별도의 네트워크 또는 후속 인증 요구사항으로 보호 + +### REQ-001.07 — 배포 설정과 운영 제약 + +환경 예시, Compose와 운영 문서는 두 credential 환경변수의 이름·용도·fail-closed 동작을 정의하되 실제 값을 저장하지 않아야 한다. 운영 문서는 1초 지연에 애플리케이션 잠금이 없고 새 session·병렬 요청으로 우회 가능하므로 배포 경계 rate limiting이 필요함을 명시해야 한다. + +**영향 범위** + +- 인터페이스: `.env`, Compose environment와 운영 문서 +- 영속성: repository에 credential 값 저장 금지 +- 후속 사용 주체: 배포 운영자 +- 마이그레이션: 기존 Dashboard 배포에 secret 주입 필요 +- 호환성: MCP container의 설정과 포트는 변경하지 않음 +- 배포 전제 조건: secret manager 또는 동등한 안전한 환경변수 주입 + +### REQ-001.08 — 인증 관찰 가능성 + +설정 오류, 로그인 성공·실패와 로그아웃은 session-local correlation ID와 event 이름만 application log에 기록해야 한다. 관리자 ID, password, verifier, 제출 값과 query parameter를 진단 속성으로 기록하지 않아야 한다. + +**영향 범위** + +- 인터페이스: application log +- 영속성: correlation ID는 현재 session에만 저장 +- 후속 사용 주체: 운영자 +- 마이그레이션: 없음 +- 호환성: 기존 금융 데이터 log 계약에 영향 없음 +- 배포 전제 조건: log 수집기가 민감정보 비수집 계약을 유지 + +## 6. 제약과 불변조건 + +- 인증 전과 설정 오류·잘못된 credential 상태에서는 금융 DB와 gateway 상태가 변하지 않는다. +- Credential은 환경변수에서만 읽고 repository 문서·설정·test fixture에는 test-only 값 외의 실제 값을 기록하지 않는다. +- 인증 상태는 boolean 결과만 저장하며 credential 원문 또는 verifier를 session에 보존하지 않는다. +- 잘못된 ID와 password는 동일한 사용자 오류와 최소 지연을 사용한다. +- Dashboard와 MCP의 인증 경계를 혼동하지 않는다. +- 실제 운영 DB 또는 실제 credential을 test에 사용하지 않는다. + +## 7. 인수 기준 + +### REQ-001.01-AC01 — 미인증 진입 차단 + +**판정 기준** + +미인증 session은 로그인 화면만 표시하며 `get_db_path()`, page dispatch, DB·gateway·upload·rollback을 호출하지 않는다. + +**검증** + +`tests/test_dashboard_authentication_app.py`에서 인증 gate가 거부될 때 DB 경로 함수가 호출되지 않고 Dashboard 본문이 렌더링되지 않는지 검증한다. + +**검증 분류** + +- 수준: 통합 +- 백엔드: Streamlit AppTest와 monkeypatch +- 케이스: 설정 누락, 미인증, 각 Dashboard query page + +**상태 불변조건** + +SQLite와 gateway 상태 무변경 + +### REQ-001.01-AC02 — Query parameter 우회 금지 + +**판정 기준** + +`page=Import` 등 유효한 page query parameter를 제공해도 미인증 session은 해당 page를 렌더링하지 않는다. + +**검증** + +Streamlit AppTest로 Import query parameter를 설정하고 로그인 화면만 존재하는지 확인한다. + +**검증 분류** + +- 수준: 통합 +- 백엔드: Streamlit AppTest +- 케이스: `page=Import` + +**상태 불변조건** + +Upload와 rollback 미호출 + +### REQ-001.02-AC01 — 환경변수 전용 설정 + +**판정 기준** + +두 exact 환경변수가 있을 때만 설정을 생성하며 다른 이름이나 Streamlit secrets 값은 사용하지 않는다. + +**검증** + +`tests/test_dashboard_authentication.py`에서 환경 mapping과 무관한 secrets 형태 값을 함께 제공해도 exact 환경변수 값만 선택하는지 검증한다. + +**검증 분류** + +- 수준: 단위 +- 백엔드: pytest +- 케이스: 두 값 존재, 대체 source 존재 + +**상태 불변조건** + +Credential source 고정 + +### REQ-001.02-AC02 — 설정 오류 fail-closed + +**판정 기준** + +누락·빈 값·읽기 오류는 동일한 일반 설정 오류로 종료되고 Dashboard 기능을 노출하지 않는다. + +**검증** + +단위 test와 AppTest로 각 오류 조합과 로그인 input 미노출을 검증한다. + +**검증 분류** + +- 수준: 단위·통합 +- 백엔드: pytest, Streamlit AppTest +- 케이스: ID 누락, password 누락, 빈 값, environment read error + +**상태 불변조건** + +기존 인증 boolean을 제거하고 DB 미호출 + +### REQ-001.03-AC01 — Constant-time 비교 + +**판정 기준** + +ID와 password 검증이 모두 `hmac.compare_digest`를 호출하며 한 필드 실패로 다른 비교를 생략하지 않는다. + +**검증** + +비교 함수를 계측한 단위 test에서 성공·실패 모두 두 비교가 수행되는지 확인한다. + +**검증 분류** + +- 수준: 단위 +- 백엔드: pytest monkeypatch +- 케이스: 정상 credential, 잘못된 두 필드 + +**상태 불변조건** + +Password 일반 동등 비교 금지 + +### REQ-001.03-AC02 — Credential 비노출 + +**판정 기준** + +로그인 완료 후 제출 ID와 password가 화면·log payload·query parameter 또는 session state에 남지 않는다. + +**검증** + +실패·성공 AppTest의 body와 session state 표현 및 log helper 단위 test를 검사한다. + +**검증 분류** + +- 수준: 단위·통합 +- 백엔드: pytest, Streamlit AppTest +- 케이스: 성공, 잘못된 ID, 잘못된 password, 설정 오류 + +**상태 불변조건** + +인증 boolean과 비민감 correlation ID만 session에 유지 가능 + +### REQ-001.04-AC01 — 실패별 최소 지연과 일반 오류 + +**판정 기준** + +잘못된 ID와 잘못된 password 각각의 AppTest 제출은 1초 전에 완료되지 않고 같은 오류 문자열을 표시한다. + +**검증** + +두 실패 입력을 별도 제출하고 monotonic elapsed time과 화면 오류를 측정한다. + +**검증 분류** + +- 수준: 통합 +- 백엔드: Streamlit AppTest, monotonic clock +- 케이스: 잘못된 ID, 잘못된 password + +**상태 불변조건** + +인증 false, DB·gateway 무변경 + +### REQ-001.04-AC02 — 성공 지연·잠금 없음 + +**판정 기준** + +정상 credential은 실패 지연 함수를 사용하지 않고 인증되며, 실패 횟수나 잠금 만료 상태를 생성하지 않는다. + +**검증** + +단위 test에서 실패 지연의 remaining-time 계산을 검증하고 source·session state 검사에서 failure counter·lock state가 없음을 확인한다. + +**검증 분류** + +- 수준: 단위·정적 +- 백엔드: pytest, source inspection +- 케이스: 정상 credential, 반복 실패 후 재시도 + +**상태 불변조건** + +애플리케이션 잠금 없음 + +### REQ-001.05-AC01 — Session 유지와 새 session 재인증 + +**판정 기준** + +로그인한 AppTest session은 rerun 후 기존 page를 유지하지만 새 AppTest session은 로그인 화면부터 시작한다. + +**검증** + +한 session의 로그인·rerun과 별도 session 생성을 연속 검증한다. + +**검증 분류** + +- 수준: 통합 +- 백엔드: Streamlit AppTest +- 케이스: 동일 session rerun, 새 session + +**상태 불변조건** + +Session 간 인증 상태 격리 + +### REQ-001.05-AC02 — 로그아웃 + +**판정 기준** + +로그아웃 click 직후 인증 boolean이 폐기되고 로그인 화면으로 돌아가며 Dashboard page가 표시되지 않는다. + +**검증** + +인증된 AppTest에서 로그아웃 button을 click하고 화면 전이를 확인한다. + +**검증 분류** + +- 수준: 통합 +- 백엔드: Streamlit AppTest +- 케이스: 인증 후 로그아웃 + +**상태 불변조건** + +인증 상태 폐기, 금융 데이터 무변경 + +### REQ-001.06-AC01 — 인증 후 기존 페이지 호환성 + +**판정 기준** + +Synthetic SQLite fixture와 test-only credential로 인증한 뒤 기존 핵심 페이지가 예외 없이 예상 내용을 렌더링한다. + +**검증** + +`tests/test_dashboard_app.py`에서 요약, 자산/마감, 분류 규칙, 확인필요 큐와 월정산을 인증 후 검증한다. + +**검증 분류** + +- 수준: 회귀 통합 +- 백엔드: Streamlit AppTest, synthetic SQLite +- 케이스: 기존 핵심 page 목록 + +**상태 불변조건** + +기존 SQLite schema와 gateway contract 유지 + +### REQ-001.06-AC02 — MCP 범위 분리 + +**판정 기준** + +Dashboard 인증 구현이 MCP server 진입점·transport·port를 변경하지 않고 운영 문서가 MCP 미보호 경계를 명시한다. + +**검증** + +Base-to-worktree diff와 README를 검사한다. + +**검증 분류** + +- 수준: 정적 +- 백엔드: Git diff, 문서 검사 +- 케이스: MCP source·Compose service + +**상태 불변조건** + +MCP 인증 상태 변경 없음 + +### REQ-001.07-AC01 — Secret 주입 계약 + +**판정 기준** + +`.env.example`과 Dashboard Compose service에 두 exact 변수 이름이 있고 repository에는 실제 값이 없다. + +**검증** + +설정 파일 parsing과 base-to-worktree secret scan을 수행한다. + +**검증 분류** + +- 수준: 정적 +- 백엔드: Python·YAML text validation +- 케이스: 환경 예시, Compose Dashboard environment + +**상태 불변조건** + +실제 credential 비보존 + +### REQ-001.07-AC02 — Rate limiting 운영 문서 + +**판정 기준** + +README가 최소 1초 실패 지연, 애플리케이션 잠금 부재, 새 session·병렬 요청 우회와 배포 경계 rate limiting 필요성을 함께 설명한다. + +**검증** + +README 필수 문구 정적 검사를 수행한다. + +**검증 분류** + +- 수준: 문서 +- 백엔드: text validation +- 케이스: Dashboard 운영 안내 + +**상태 불변조건** + +애플리케이션 내부 잠금 추가 금지 + +### REQ-001.08-AC01 — 비민감 인증 event + +**판정 기준** + +설정 오류, 성공, 실패와 로그아웃 event가 correlation ID와 함께 기록되고 credential은 log argument에 포함되지 않는다. + +**검증** + +Logger를 계측한 단위 test와 source 검사를 수행한다. + +**검증 분류** + +- 수준: 단위·정적 +- 백엔드: pytest monkeypatch +- 케이스: 인증 event logging + +**상태 불변조건** + +Log에 관리자 ID·password·verifier 없음 + +## 8. 오류 및 충돌 계약 + +- 설정 오류: `관리자 인증 설정을 확인할 수 없습니다.`만 표시하고 로그인 form과 Dashboard를 노출하지 않는다. +- Credential 오류: ID와 password 오류를 구분하지 않는 일반 오류를 최소 1초 후 표시한다. +- Issue 또는 base drift: 구현 workflow에서 자동 merge·rebase하지 않고 중단한다. +- 중복 로그인 제출: 각 제출을 독립적으로 검증하며 성공 후 현재 session의 인증 boolean만 유지한다. +- 로그아웃 중복 요청: 인증 상태가 없으면 로그인 화면을 유지하고 금융 상태를 변경하지 않는다. + +## 9. 데이터 및 인터페이스 계약 + +- 입력: 환경변수 두 개, 로그인 form의 ID·password, 로그아웃 button +- 출력: 로그인 화면, 일반 설정/credential 오류, 인증된 기존 Dashboard, session-local 인증 boolean +- 비민감 session 데이터: 인증 boolean, login-error boolean, correlation ID +- 영속 데이터: 인증 기능은 새 DB table, column, migration 또는 credential store를 만들지 않는다. +- 비교: ID와 password 모두 `hmac.compare_digest`를 사용하며 두 비교를 수행한다. + +## 10. 하위 호환성 + +인증 후 기존 Dashboard page query parameter, SQLite schema, gateway와 화면 기능을 유지한다. Dashboard는 새 credential 설정 없이는 의도적으로 fail-closed한다. MCP server API, transport와 인증 상태는 변경하지 않는다. + +## 11. 추적성 + +- 근거 이슈: #1 +- 구현 PR: 미정 +- 관련 설계 문서: docs/interface-contract.md, docs/project-structure.md, docs/runtime-flow.md +- 관련 요구사항: 없음 diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md new file mode 100644 index 0000000..e3dc809 --- /dev/null +++ b/docs/runtime-flow.md @@ -0,0 +1,91 @@ +# Dashboard 관리자 인증 런타임 흐름 + +## 목적과 범위 + +Streamlit session 시작부터 설정 판정, 로그인 성공·실패, 인증된 page dispatch와 로그아웃까지의 실행 순서와 실패 복구 계약을 정의한다. + +- 포함 범위: credential 환경변수, login form, 1초 실패 지연, session 인증, logout, 인증 event +- 제외 범위: MCP 요청, 영속 session, application lock, 외부 rate limiter 구현 +- 시작 조건: Streamlit이 `dashboard/app.py`를 새로 실행하거나 rerun함 +- 성공 종료 상태: 인증된 기존 Dashboard page가 렌더링되거나 로그아웃 후 로그인 화면이 렌더링됨 +- 실패 종료 상태: 안전한 설정·credential 오류를 표시하고 DB·gateway를 호출하지 않은 미인증 상태 + +## 흐름 + +### dashboard-entry — Session 시작과 인증 경계 + +- 시작 이벤트: 새 browser session 또는 Streamlit rerun +- 선행조건: Dashboard process가 시작돼 있음 +- 실행 방식: 동기 +- 트랜잭션·영속성 경계: 인증 상태는 현재 `st.session_state`에만 존재하고 SQLite transaction을 열지 않음 + +| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | +|---|---|---|---|---|---|---|---| +| `ENTRY-01` | `dashboard/app.py` | script 실행 | page config 후 인증 gate 호출 | 금융 상태 읽기·쓰기 없음 | 없음 | `ENTRY-02` | `AUTH-FAIL-CONFIG` | +| `ENTRY-02` | 인증 모듈 | `st.session_state` | correlation ID를 조회하거나 비민감 ID 생성 | correlation ID만 session에 기록 | random token 생성 | `ENTRY-03` | `AUTH-FAIL-CONFIG` | +| `ENTRY-03` | 인증 모듈 | 두 environment key | 비어 있지 않은 문자열 두 개를 읽음 | process memory에 현재 rerun 동안만 credential 보유 | process environment | `ENTRY-04` | `AUTH-FAIL-CONFIG` | +| `ENTRY-04` | 인증 모듈 | 인증 boolean | 현재 session 인증 여부 판정 | 인증 상태 읽기 | 없음 | `DASHBOARD-01` 또는 `LOGIN-01` | `AUTH-FAIL-CONFIG` | +| `DASHBOARD-01` | `dashboard/app.py` | 인증 true | DB 경로 취득, CSS와 nav 구성 | DB 경로 읽기 | filesystem path resolution | `DASHBOARD-02` | `DASHBOARD-FAIL` | +| `DASHBOARD-02` | `dashboard/app.py` | 유효한 page query | 허용 목록으로 page를 정규화하고 dispatch | page session key, 기존 금융 읽기·쓰기 | 선택 page gateway | `AUTHENTICATED` | `DASHBOARD-FAIL` | + +### dashboard-login — Login 제출 + +- 시작 이벤트: 미인증 사용자의 로그인 form 제출 +- 선행조건: 유효한 관리자 환경 설정과 로그인 화면 +- 실행 방식: 동기 +- 트랜잭션·영속성 경계: 금융 DB transaction 없음; 성공 시 session boolean만 기록 + +| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | +|---|---|---|---|---|---|---|---| +| `LOGIN-01` | Streamlit | 미인증, 유효 설정 | ID·password form 렌더링 | widget state | 없음 | `LOGIN-02` 또는 `UNAUTHENTICATED` | `AUTH-FAIL-CONFIG` | +| `LOGIN-02` | 인증 모듈 | form 제출 | monotonic 시작 시각 기록, ID와 password 모두 constant-time 비교 | credential은 local variable | monotonic clock | `LOGIN-03` 또는 `LOGIN-FAIL-01` | `LOGIN-FAIL-01` | +| `LOGIN-03` | 인증 모듈 | 두 비교 성공 | 인증 boolean true, 성공 event 기록, rerun | session 인증 상태 쓰기 | application log | `ENTRY-01` | `AUTH-FAIL-CONFIG` | +| `LOGIN-FAIL-01` | 인증 모듈 | 하나 이상 비교 실패 | 제출 처리 경과가 1초가 될 때까지 remaining delay | 금융 상태 무변경 | monotonic clock, sleep | `LOGIN-FAIL-02` | `LOGIN-FAIL-02` | +| `LOGIN-FAIL-02` | 인증 모듈 | 최소 지연 완료 | 실패 event와 안전한 error flag 기록 후 rerun | credential widget state 제거 | application log | `LOGIN-FAIL-03` | `UNAUTHENTICATED` | +| `LOGIN-FAIL-03` | 인증 모듈 | 실패 rerun | ID·password를 구분하지 않는 일반 오류 표시 | 금융 상태 무변경 | 없음 | `UNAUTHENTICATED` | `UNAUTHENTICATED` | + +### dashboard-logout — Logout + +- 시작 이벤트: 인증된 사용자의 sidebar 로그아웃 click +- 선행조건: 현재 session 인증 boolean true +- 실행 방식: 동기 +- 트랜잭션·영속성 경계: 인증 boolean만 제거하며 금융 DB를 변경하지 않음 + +| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | +|---|---|---|---|---|---|---|---| +| `LOGOUT-01` | 인증 모듈 | 로그아웃 click | 인증 boolean 제거와 logout event 기록 | session 인증 상태 삭제 | application log | `LOGOUT-02` | `UNAUTHENTICATED` | +| `LOGOUT-02` | Streamlit | 인증 상태 없음 | rerun | 금융 상태 무변경 | 없음 | `ENTRY-01` | `UNAUTHENTICATED` | + +## 실패와 복구 + +| 대상 단계 | 실패 조건 | 추가 쓰기 중단 | 재시도 계약 | 중복 방지 | 보상·수동 복구 | 최종 상태 | +|---|---|---|---|---|---|---| +| `ENTRY-03` | 환경변수 누락·빈 값·비문자열·read error | 예; DB·gateway 미호출 | 설정 수정 후 rerun | 기존 인증 boolean 제거 | 운영자가 두 secret을 유효하게 주입 | `AUTH-FAIL-CONFIG` | +| `LOGIN-02` | 잘못된 ID 또는 password | 예; 금융 상태 무변경 | 각 제출 독립 재시도 | lock·failure counter 없음 | 없음 | `UNAUTHENTICATED` | +| `LOGIN-FAIL-01` | sleep interruption | 금융 쓰기 없음 | 다음 제출 가능 | error flag boolean만 사용 | 없음 | `UNAUTHENTICATED` | +| `DASHBOARD-01` | DB 경로·기존 page 오류 | 해당 page의 기존 실패 계약 | 기존 Dashboard 계약에 따름 | 기존 gateway 계약 | 기존 운영 복구 | `DASHBOARD-FAIL` | +| `LOGOUT-01` | 중복 또는 이미 제거됨 | 금융 쓰기 없음 | 로그인 화면 유지 | key 제거는 멱등 | 없음 | `UNAUTHENTICATED` | + +### 동시성과 순서 + +- 동시 실행 허용 여부: 서로 다른 browser session은 독립적으로 실행되며 같은 session은 Streamlit rerun 순서를 따른다. +- 잠금·직렬화 단위: 애플리케이션 인증 잠금과 실패 횟수 잠금은 없다. Session state는 browser session 단위다. +- 순서 보장: 설정 판정과 인증 gate가 DB 경로 취득·page dispatch보다 항상 먼저 실행된다. 실패 지연 완료 후 오류 rerun이 수행된다. +- 충돌 검출·처리: 새 session과 병렬 요청은 1초 지연을 우회할 수 있으므로 배포 경계에서 별도 rate limiting이 필요하다. + +## 관찰 가능성 + +| 신호 종류 | 이름 | 발생 단계·조건 | 상관 식별자 | 진단 속성 | 민감정보 처리 | +|---|---|---|---|---|---| +| log | `dashboard_auth configuration_error` | `ENTRY-03` 설정 오류 | session correlation ID | event 이름 | 환경변수 값·오류 원인 원문 제외 | +| log | `dashboard_auth login_succeeded` | `LOGIN-03` | session correlation ID | event 이름 | ID·password 제외 | +| log | `dashboard_auth login_failed` | `LOGIN-FAIL-02` | session correlation ID | event 이름 | 실패 필드·제출 값 제외 | +| log | `dashboard_auth logout` | `LOGOUT-01` | session correlation ID | event 이름 | 금융 데이터·credential 제외 | + +## 검증 + +- 정상 흐름: test-only credential로 로그인하고 synthetic fixture의 기존 Dashboard page를 렌더링한 뒤 같은 session rerun을 검증한다. +- 실패 흐름: 설정 누락·빈 값·read error, 잘못된 ID·password, 일반 오류와 DB 미호출을 단위·AppTest로 검증한다. +- 경계 흐름: Import query parameter 우회 금지, credential session 제거, logout, 새 session 재인증과 MCP 범위 불변을 검증한다. +- 동시성 흐름: application lock·failure counter가 없음을 정적으로 확인하고 새 session 우회 및 배포 경계 rate limiting 요구를 README에서 검증한다. +- 운영 진입점과 기대 결과: `streamlit run dashboard/app.py`; 유효 secret이면 로그인 form, 무효 설정이면 일반 설정 오류, 인증 성공 후에만 기존 Dashboard를 표시한다. diff --git a/tests/test_dashboard_app.py b/tests/test_dashboard_app.py index ecdf1b3..9ad520a 100644 --- a/tests/test_dashboard_app.py +++ b/tests/test_dashboard_app.py @@ -6,6 +6,7 @@ from streamlit.testing.v1 import AppTest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from dashboard.app import get_transfer_review_items +from dashboard.authentication import ADMIN_ID_ENV, ADMIN_PASSWORD_ENV from scripts.ci_fixture_db import create_ci_fixture_db @@ -43,6 +44,8 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp fixture = tmp_path / "fixture.sqlite3" create_ci_fixture_db(fixture) monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) + monkeypatch.setenv(ADMIN_ID_ENV, "test-admin") + monkeypatch.setenv(ADMIN_PASSWORD_ENV, "test-password") app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py" page_expectations = { @@ -58,6 +61,10 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp if page == "월정산": at.query_params["month"] = "2026-06" at.run() + at.text_input[0].input("test-admin") + at.text_input[1].input("test-password") + next(button for button in at.button if button.label == "로그인").click() + at.run() assert not at.exception body_text = "\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption)) assert expected_text in body_text diff --git a/tests/test_dashboard_authentication.py b/tests/test_dashboard_authentication.py new file mode 100644 index 0000000..47e85ee --- /dev/null +++ b/tests/test_dashboard_authentication.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from dashboard import authentication + + +ADMIN_ID = "test-admin" +ADMIN_PASSWORD = "test-password" + + +def test_load_admin_credentials_uses_only_required_environment_variables(): + credentials = authentication.load_admin_credentials( + { + "JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID, + "JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD, + "auth.admin_id": "ignored-streamlit-id", + "auth.admin_password": "ignored-streamlit-password", + } + ) + + assert credentials.admin_id == ADMIN_ID + assert credentials.password == ADMIN_PASSWORD + + +@pytest.mark.parametrize( + "environ", + [ + {}, + {"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID}, + {"JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD}, + {"JARCA_DASHBOARD_ADMIN_ID": "", "JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD}, + {"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID, "JARCA_DASHBOARD_ADMIN_PASSWORD": ""}, + {"JARCA_DASHBOARD_ADMIN_ID": 123, "JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD}, + {"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID, "JARCA_DASHBOARD_ADMIN_PASSWORD": object()}, + ], +) +def test_load_admin_credentials_fails_closed_for_missing_empty_or_nonstring_values(environ): + with pytest.raises(authentication.AuthenticationConfigurationError) as exc_info: + authentication.load_admin_credentials(environ) + + assert str(exc_info.value) == authentication.CONFIGURATION_ERROR_MESSAGE + assert ADMIN_ID not in str(exc_info.value) + assert ADMIN_PASSWORD not in str(exc_info.value) + + +def test_load_admin_credentials_fails_closed_when_environment_cannot_be_read(): + class UnreadableEnvironment: + def get(self, key): + raise OSError("secret backend unavailable") + + with pytest.raises(authentication.AuthenticationConfigurationError) as exc_info: + authentication.load_admin_credentials(UnreadableEnvironment()) + + assert str(exc_info.value) == authentication.CONFIGURATION_ERROR_MESSAGE + assert "secret backend" not in str(exc_info.value) + + +def test_verify_credentials_uses_constant_time_comparison_for_both_fields(monkeypatch): + credentials = authentication.AdminCredentials(ADMIN_ID, ADMIN_PASSWORD) + comparisons: list[tuple[str, str]] = [] + + def record_compare(left: str, right: str) -> bool: + comparisons.append((left, right)) + return left == right + + monkeypatch.setattr(authentication.hmac, "compare_digest", record_compare) + + assert authentication.verify_credentials(credentials, ADMIN_ID, ADMIN_PASSWORD) is True + assert comparisons == [(ADMIN_ID, ADMIN_ID), (ADMIN_PASSWORD, ADMIN_PASSWORD)] + + comparisons.clear() + assert authentication.verify_credentials(credentials, "wrong-id", "wrong-password") is False + assert comparisons == [("wrong-id", ADMIN_ID), ("wrong-password", ADMIN_PASSWORD)] + + +def test_failed_login_delay_waits_only_for_remaining_minimum_duration(): + sleeps: list[float] = [] + + authentication.enforce_failed_login_delay( + started_at=10.0, + minimum_seconds=1.0, + monotonic=lambda: 10.25, + sleep=sleeps.append, + ) + + assert sleeps == [pytest.approx(0.75)] + + +def test_failed_login_delay_does_not_sleep_after_minimum_duration(): + sleeps: list[float] = [] + + authentication.enforce_failed_login_delay( + started_at=10.0, + minimum_seconds=1.0, + monotonic=lambda: 11.25, + sleep=sleeps.append, + ) + + assert sleeps == [] + + +def test_authentication_correlation_id_is_noncredential_session_state(): + session_state: dict[str, object] = {} + + correlation_id = authentication.get_auth_correlation_id(session_state, token_hex=lambda size: "safe-correlation") + + assert correlation_id == "safe-correlation" + assert session_state == {authentication.AUTH_CORRELATION_SESSION_KEY: "safe-correlation"} + + +def test_authentication_event_log_contains_only_event_and_correlation(monkeypatch): + calls: list[tuple[str, tuple[object, ...]]] = [] + monkeypatch.setattr(authentication.logger, "info", lambda message, *args: calls.append((message, args))) + + authentication.log_auth_event("login_failed", "safe-correlation") + + assert calls == [("dashboard_auth event=%s correlation_id=%s", ("login_failed", "safe-correlation"))] + assert ADMIN_ID not in repr(calls) + assert ADMIN_PASSWORD not in repr(calls) diff --git a/tests/test_dashboard_authentication_app.py b/tests/test_dashboard_authentication_app.py new file mode 100644 index 0000000..786c259 --- /dev/null +++ b/tests/test_dashboard_authentication_app.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +from pathlib import Path +import sys +import time + +import pytest +from streamlit.testing.v1 import AppTest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from dashboard import authentication + + +ADMIN_ID = "test-admin" +ADMIN_PASSWORD = "test-password" +APP_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "app.py" + + +def _body_text(at: AppTest) -> str: + elements = list(at.title) + list(at.header) + list(at.subheader) + list(at.text) + list(at.markdown) + list(at.caption) + list(at.error) + return "\n".join(str(item.value) for item in elements) + + +def _configured_app(monkeypatch, *, page: str = "요약") -> AppTest: + monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID) + monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD) + at = AppTest.from_file(str(APP_PATH), default_timeout=10) + at.query_params["page"] = page + return at.run() + + +def _submit_login(at: AppTest, admin_id: str, password: str) -> AppTest: + assert [field.label for field in at.text_input] == ["관리자 ID", "비밀번호"] + at.text_input[0].input(admin_id) + at.text_input[1].input(password) + login_button = next(button for button in at.button if button.label == "로그인") + login_button.click() + return at.run() + + +def test_missing_authentication_configuration_fails_closed(monkeypatch, tmp_path): + from scripts.ci_fixture_db import create_ci_fixture_db + + fixture = tmp_path / "fixture.sqlite3" + create_ci_fixture_db(fixture) + monkeypatch.delenv(authentication.ADMIN_ID_ENV, raising=False) + monkeypatch.delenv(authentication.ADMIN_PASSWORD_ENV, raising=False) + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) + + at = AppTest.from_file(str(APP_PATH), default_timeout=10).run() + + assert not at.exception + assert "관리자 로그인" in _body_text(at) + assert authentication.CONFIGURATION_ERROR_MESSAGE in _body_text(at) + assert not at.text_input + assert "이번 달 수입/지출 현황" not in _body_text(at) + + +def test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects(monkeypatch): + from dashboard import app + + monkeypatch.setattr(app, "require_dashboard_authentication", lambda: False) + + def forbidden_database_access(): + raise AssertionError("get_db_path must not run before authentication") + + monkeypatch.setattr(app, "get_db_path", forbidden_database_access) + + app.main() + + +def test_query_parameter_cannot_bypass_login_or_reach_import_page(monkeypatch): + at = _configured_app(monkeypatch, page="Import") + + assert not at.exception + assert "관리자 로그인" in _body_text(at) + assert "PDF Import" not in _body_text(at) + assert "Import 이력" not in _body_text(at) + + +@pytest.mark.parametrize( + ("admin_id", "password"), + [ + ("wrong-id", ADMIN_PASSWORD), + (ADMIN_ID, "wrong-password"), + ], +) +def test_invalid_credentials_have_same_error_and_minimum_one_second_delay(monkeypatch, admin_id, password): + at = _configured_app(monkeypatch) + + started_at = time.monotonic() + at = _submit_login(at, admin_id, password) + elapsed = time.monotonic() - started_at + + assert elapsed >= authentication.MINIMUM_FAILURE_DELAY_SECONDS + assert authentication.INVALID_CREDENTIALS_MESSAGE in _body_text(at) + assert ADMIN_ID not in _body_text(at) + assert ADMIN_PASSWORD not in _body_text(at) + assert admin_id not in _body_text(at) + assert password not in _body_text(at) + assert ADMIN_PASSWORD not in repr(at.session_state) + assert admin_id not in repr(at.session_state) + assert password not in repr(at.session_state) + assert "이번 달 수입/지출 현황" not in _body_text(at) + + +def test_successful_login_persists_for_current_session_and_logout_clears_it(monkeypatch, tmp_path): + from scripts.ci_fixture_db import create_ci_fixture_db + + fixture = tmp_path / "fixture.sqlite3" + create_ci_fixture_db(fixture) + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) + at = _configured_app(monkeypatch) + + at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD) + + assert not at.exception + assert "이번 달 수입/지출 현황" in _body_text(at) + assert authentication.INVALID_CREDENTIALS_MESSAGE not in _body_text(at) + assert ADMIN_PASSWORD not in repr(at.session_state) + + at = at.run() + assert "이번 달 수입/지출 현황" in _body_text(at) + + logout_button = next(button for button in at.button if button.label == "로그아웃") + logout_button.click() + at = at.run() + + assert "관리자 로그인" in _body_text(at) + assert "이번 달 수입/지출 현황" not in _body_text(at) + + new_session = AppTest.from_file(str(APP_PATH), default_timeout=10).run() + assert "관리자 로그인" in _body_text(new_session) + assert "이번 달 수입/지출 현황" not in _body_text(new_session) + + +def test_successful_login_does_not_invoke_failure_delay(monkeypatch, tmp_path): + from scripts.ci_fixture_db import create_ci_fixture_db + + fixture = tmp_path / "fixture.sqlite3" + create_ci_fixture_db(fixture) + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) + + def forbidden_failure_delay(**kwargs): + raise AssertionError("successful login must not invoke the failure delay") + + monkeypatch.setattr(authentication, "enforce_failed_login_delay", forbidden_failure_delay) + at = _configured_app(monkeypatch) + + at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD) + + assert not at.exception + assert "이번 달 수입/지출 현황" in _body_text(at) + + +def test_configuration_failure_after_login_clears_existing_authentication(monkeypatch, tmp_path): + from scripts.ci_fixture_db import create_ci_fixture_db + + fixture = tmp_path / "fixture.sqlite3" + create_ci_fixture_db(fixture) + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) + at = _configured_app(monkeypatch) + at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD) + assert "이번 달 수입/지출 현황" in _body_text(at) + + monkeypatch.delenv(authentication.ADMIN_PASSWORD_ENV) + at = at.run() + + assert authentication.CONFIGURATION_ERROR_MESSAGE in _body_text(at) + assert "이번 달 수입/지출 현황" not in _body_text(at) + assert authentication.AUTHENTICATED_SESSION_KEY not in repr(at.session_state) + assert ADMIN_ID not in repr(at.session_state) + assert ADMIN_PASSWORD not in repr(at.session_state)