feat(dashboard): add administrator authentication #3

Merged
genichin merged 2 commits from gitea-impl-agent/jarca-ledger-mcp:gitea-agent/issue-1-dashboard-admin-auth into main 2026-09-28 06:51:13 +00:00
12 changed files with 1309 additions and 1 deletions
+4
View File
@@ -1,2 +1,6 @@
# Optional. Defaults to ~/.local/share/jarca/ledger.sqlite3 # Optional. Defaults to ~/.local/share/jarca/ledger.sqlite3
JARCA_LEDGER_DB_PATH= JARCA_LEDGER_DB_PATH=
# Required for the Streamlit Dashboard. Inject real values from a secret store.
JARCA_DASHBOARD_ADMIN_ID=
JARCA_DASHBOARD_ADMIN_PASSWORD=
+15 -1
View File
@@ -31,12 +31,25 @@ hermes mcp test jarca-ledger
로컬 웹 대시보드는 실제 기본 DB `~/.local/share/jarca/ledger.sqlite3`를 읽고 씁니다. 로컬 웹 대시보드는 실제 기본 DB `~/.local/share/jarca/ledger.sqlite3`를 읽고 씁니다.
Dashboard는 모든 조회·수정·Import 기능 앞에서 단일 관리자 로그인을 요구합니다. 관리자 ID와 password는 Streamlit secrets가 아니라 배포 secret이 주입하는 다음 환경변수에서만 읽습니다.
```bash
export JARCA_DASHBOARD_ADMIN_ID='<secret store에서 주입>'
export JARCA_DASHBOARD_ADMIN_PASSWORD='<secret store에서 주입>'
```
두 값 중 하나라도 누락됐거나 비어 있거나 읽을 수 없으면 Dashboard는 fail-closed하여 로그인 설정 오류만 표시하고 DB나 page view에 접근하지 않습니다. 실제 credential을 `.env`, Compose 파일, shell history 또는 repository 문서에 저장하지 마세요.
```bash ```bash
cd /root/projects/jarca-ledger-mcp cd /root/projects/jarca-ledger-mcp
. .venv/bin/activate . .venv/bin/activate
streamlit run dashboard/app.py --server.address 0.0.0.0 --server.port 8501 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로 함께 실행하지 않습니다. - 하나의 Pod 안에서 MCP와 Streamlit을 **별도 컨테이너**로 실행합니다. 한 컨테이너에서 두 프로세스를 supervisor나 background command로 함께 실행하지 않습니다.
- 두 컨테이너는 동일한 PVC를 `/data`에 마운트하고 `JARCA_LEDGER_DB_PATH=/data/ledger.sqlite3`를 사용합니다. - 두 컨테이너는 동일한 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`로 유지합니다. - SQLite 파일을 공유하므로 Deployment의 replica는 `1`로 유지합니다.
- MCP와 frontend는 목적별 Kubernetes Service로 분리합니다. MCP는 `8000`, frontend는 `8501`을 대상으로 합니다. - MCP와 frontend는 목적별 Kubernetes Service로 분리합니다. MCP는 `8000`, frontend는 `8501`을 대상으로 합니다.
- 외부 공개가 필요하면 일반적으로 frontend Service만 Ingress 또는 NodePort에 연결하고, MCP Service는 필요한 범위에서만 노출합니다. - 외부 공개가 필요하면 일반적으로 frontend Service만 Ingress 또는 NodePort에 연결하고, MCP Service는 필요한 범위에서만 노출합니다.
@@ -80,7 +94,7 @@ Deployment: jarca-ledger (replicas: 1)
Streamlit이 SQLite에 직접 접근하지 않고 MCP/API만 호출하도록 변경하거나 네트워크 DB로 전환한 뒤에는 MCP와 frontend를 각각 독립 Deployment로 분리할 수 있습니다. 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로 지정해야 합니다.
## 주요 도구 ## 주요 도구
+4
View File
@@ -13,6 +13,7 @@ if __package__ in (None, ""):
st.set_page_config(page_title="Jarca Ledger", page_icon="💰", layout="wide") 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.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.accounts import page_accounts
from dashboard.page_views.assets import page_assets from dashboard.page_views.assets import page_assets
from dashboard.page_views.categories import page_categories 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 from dashboard.page_views.uncategorized import page_uncategorized
def main() -> None: def main() -> None:
if not require_dashboard_authentication():
return
db_path = get_db_path() db_path = get_db_path()
inject_app_css() inject_app_css()
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"] pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
+139
View File
@@ -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()
+2
View File
@@ -29,6 +29,8 @@ services:
- "40059:8501" - "40059:8501"
environment: environment:
JARCA_LEDGER_DB_PATH: /data/ledger.sqlite3 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" STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false"
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
volumes: volumes:
+154
View File
@@ -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=<secret-injected-admin-id>
JARCA_DASHBOARD_ADMIN_PASSWORD=<secret-injected-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이 없음을 확인
+101
View File
@@ -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이 생성되지 않았는지 확인한다.
@@ -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: #3
- 관련 설계 문서: docs/interface-contract.md, docs/project-structure.md, docs/runtime-flow.md
- 관련 요구사항: 없음
+91
View File
@@ -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를 표시한다.
+7
View File
@@ -6,6 +6,7 @@ from streamlit.testing.v1 import AppTest
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dashboard.app import get_transfer_review_items 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 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" fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture) create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(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" app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
page_expectations = { page_expectations = {
@@ -58,6 +61,10 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
if page == "월정산": if page == "월정산":
at.query_params["month"] = "2026-06" at.query_params["month"] = "2026-06"
at.run() 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 assert not at.exception
body_text = "\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption)) 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 assert expected_text in body_text
+125
View File
@@ -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)
+174
View File
@@ -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)