Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5813dd4841 | ||
|
|
37e8d18da6 | ||
|
|
9afea37424 | ||
|
|
1ebc53d152 | ||
|
|
f01dc182ec | ||
|
|
a02aceee41 | ||
|
|
96e11812ce | ||
|
|
ffd943fe65 | ||
|
|
28527a0914 | ||
|
|
6fe2594c09 | ||
|
|
6a35775cca | ||
|
|
d84ed8ff2a | ||
|
|
755c26865c | ||
|
|
e7242a89f7 | ||
|
|
3311866431 | ||
|
|
8544ea30f4 | ||
|
|
7056e012a7 | ||
|
|
9bc64699cb | ||
|
|
7bb959859a | ||
|
|
91e106d75c | ||
|
|
c5eaddb4f3 | ||
|
|
7bea8b368e | ||
|
|
ee3e3852ee | ||
|
|
bf746e0cf3 |
@@ -4,3 +4,6 @@ JARCA_LEDGER_DB_PATH=
|
||||
# Required for the Streamlit Dashboard. Inject real values from a secret store.
|
||||
JARCA_DASHBOARD_ADMIN_ID=
|
||||
JARCA_DASHBOARD_ADMIN_PASSWORD=
|
||||
JARCA_DASHBOARD_SIGNING_SECRET=
|
||||
# Optional Uvicorn proxy trust allowlist. Keep loopback unless a reviewed HTTPS proxy is used.
|
||||
JARCA_DASHBOARD_FORWARDED_ALLOW_IPS=127.0.0.1
|
||||
|
||||
+12
-13
@@ -50,14 +50,19 @@ artifacts:
|
||||
expected_architecture: arm64
|
||||
- type: container-command
|
||||
environment:
|
||||
JARCA_LEDGER_DB_PATH: /tmp/ledger.sqlite3
|
||||
JARCA_LEDGER_DB_PATH: /data/ledger.sqlite3
|
||||
argv:
|
||||
- python
|
||||
- -c
|
||||
- >-
|
||||
import platform; from jarca_ledger_mcp import service;
|
||||
import os, pathlib, platform; from jarca_ledger_mcp import service;
|
||||
assert os.getuid() == 1000, os.getuid();
|
||||
assert os.getgid() == 1000, os.getgid();
|
||||
probe = pathlib.Path("/data/.write-probe");
|
||||
probe.write_text("ok"); probe.unlink();
|
||||
result = service.health_check();
|
||||
assert platform.machine() == "aarch64";
|
||||
assert pathlib.Path("/data/ledger.sqlite3").is_file();
|
||||
assert result["success"], result
|
||||
- type: container-health
|
||||
name_template: "jarca-mcp-{run_id}"
|
||||
@@ -67,19 +72,13 @@ artifacts:
|
||||
- type: container-http
|
||||
name_template: "jarca-dashboard-{run_id}"
|
||||
command:
|
||||
- python
|
||||
- -m
|
||||
- streamlit
|
||||
- run
|
||||
- dashboard/app.py
|
||||
- --server.address
|
||||
- uvicorn
|
||||
- dashboard.server:app
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --server.port
|
||||
- --port
|
||||
- "8501"
|
||||
- --server.headless
|
||||
- "true"
|
||||
- --browser.gatherUsageStats
|
||||
- "false"
|
||||
- --proxy-headers
|
||||
container_port: 8501
|
||||
path: /_stcore/health
|
||||
expected_status: 200
|
||||
|
||||
+13
-3
@@ -11,6 +11,8 @@ ENV PYTHONUNBUFFERED=1 \
|
||||
JARCA_MCP_HOST=0.0.0.0 \
|
||||
JARCA_MCP_PORT=8000 \
|
||||
JARCA_MCP_STREAMABLE_HTTP_PATH=/mcp \
|
||||
HOME=/home/jarca \
|
||||
TMPDIR=/tmp \
|
||||
PATH=/app/.venv/bin:$PATH
|
||||
|
||||
COPY --from=uv /uv /uvx /usr/local/bin/
|
||||
@@ -20,9 +22,15 @@ WORKDIR /app
|
||||
COPY pyproject.toml uv.lock README.md ./
|
||||
RUN uv sync --frozen --no-dev --extra dashboard --no-install-project
|
||||
|
||||
COPY jarca_ledger_mcp ./jarca_ledger_mcp
|
||||
COPY dashboard ./dashboard
|
||||
RUN uv sync --frozen --no-dev --extra dashboard
|
||||
RUN groupadd --gid 1000 jarca \
|
||||
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/jarca --shell /usr/sbin/nologin jarca \
|
||||
&& chown -R 1000:1000 /home/jarca \
|
||||
&& install -d -o 1000 -g 1000 -m 0770 /data
|
||||
|
||||
COPY --chown=1000:1000 jarca_ledger_mcp ./jarca_ledger_mcp
|
||||
COPY --chown=1000:1000 dashboard ./dashboard
|
||||
RUN uv sync --frozen --no-dev --extra dashboard \
|
||||
&& chmod -R a+rX /app
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8000 8501
|
||||
@@ -30,4 +38,6 @@ EXPOSE 8000 8501
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD python -c "import os, socket; s=socket.create_connection(('127.0.0.1', int(os.getenv('JARCA_MCP_PORT', '8000'))), timeout=3); s.close()"
|
||||
|
||||
USER 1000:1000
|
||||
|
||||
CMD ["jarca-ledger-mcp"]
|
||||
|
||||
@@ -31,22 +31,25 @@ hermes mcp test jarca-ledger
|
||||
|
||||
로컬 웹 대시보드는 실제 기본 DB `~/.local/share/jarca/ledger.sqlite3`를 읽고 씁니다.
|
||||
|
||||
Dashboard는 모든 조회·수정·Import 기능 앞에서 단일 관리자 로그인을 요구합니다. 관리자 ID와 password는 Streamlit secrets가 아니라 배포 secret이 주입하는 다음 환경변수에서만 읽습니다.
|
||||
Dashboard는 모든 조회·수정·Import 기능 앞에서 단일 관리자 로그인을 요구합니다. 관리자 ID, password와 독립적인 서명 secret은 Streamlit secrets가 아니라 배포 secret이 주입하는 다음 환경변수에서만 읽습니다.
|
||||
|
||||
```bash
|
||||
export JARCA_DASHBOARD_ADMIN_ID='<secret store에서 주입>'
|
||||
export JARCA_DASHBOARD_ADMIN_PASSWORD='<secret store에서 주입>'
|
||||
export JARCA_DASHBOARD_SIGNING_SECRET='<32 byte 이상의 무작위 secret을 주입>'
|
||||
```
|
||||
|
||||
두 값 중 하나라도 누락됐거나 비어 있거나 읽을 수 없으면 Dashboard는 fail-closed하여 로그인 설정 오류만 표시하고 DB나 page view에 접근하지 않습니다. 실제 credential을 `.env`, Compose 파일, shell history 또는 repository 문서에 저장하지 마세요.
|
||||
세 값 중 하나라도 누락됐거나 비어 있거나 읽을 수 없으면 Dashboard는 fail-closed하여 로그인 설정 오류만 표시하고 DB나 page view에 접근하지 않습니다. 서명 secret은 32 byte 이상이어야 합니다. 실제 credential이나 서명 secret을 `.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
|
||||
uvicorn dashboard.server:app --host 0.0.0.0 --port 8501
|
||||
```
|
||||
|
||||
인증 성공 상태는 현재 Streamlit browser session에서만 유지됩니다. 새 session에는 재로그인이 필요하고 sidebar의 `로그아웃`으로 즉시 인증 상태를 폐기할 수 있습니다. 잘못된 ID 또는 password 응답은 매 제출마다 최소 1초 지연되지만 애플리케이션 잠금은 없습니다. 이 지연은 새 session이나 병렬 요청으로 우회할 수 있으므로 외부에 노출할 때는 Ingress, reverse proxy 또는 동등한 배포 경계에 별도 rate limiting을 적용해야 합니다.
|
||||
인증 성공 시 Dashboard의 동일 ASGI 프로세스에 포함된 server-side 인증 endpoint가 `__Host-jarca_dashboard_auth` cookie를 발급합니다. Cookie는 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, no `Domain`이며 token의 `sub`는 관리자 ID가 아닌 비민감 고정 role `dashboard-admin`입니다. 기본 절대 만료는 24시간이며 `로그인 상태 유지 (30일)`을 선택하면 30일입니다. 일반 요청이나 재접속은 만료를 연장하지 않습니다. 관리자 ID, password 또는 서명 secret을 교체하면 현재 설정에 묶인 서명 검증이 실패합니다. malformed·expired·tampered·rotation-invalid 또는 configuration-failure cookie는 보호 자원 전에 거부되고 same-origin `/api/auth/cleanup`에서 matching deletion 후 미인증 상태로 전이합니다. `로그아웃`도 browser cookie를 삭제하지만 stateless token을 서버에서 개별 폐기하지 않으므로 사고 대응 시 credential 또는 서명 secret을 교체해야 합니다.
|
||||
|
||||
`Secure` cookie를 사용하므로 browser가 접근하는 공개 origin은 HTTPS여야 하며 login/logout endpoint는 ASGI request scheme도 HTTPS가 아니면 거부합니다. Login과 logout은 same-origin HTTPS POST만 허용하고 cleanup GET은 same-origin HTTPS `Referer`를 검증합니다. TLS를 Ingress 또는 reverse proxy에서 종료할 때는 `JARCA_DASHBOARD_FORWARDED_ALLOW_IPS`를 실제 proxy source IP/CIDR allowlist로 설정해야 합니다. 기본값 `127.0.0.1`은 cluster ingress를 신뢰하지 않는 fail-closed 값입니다. `*` 또는 불특정 network를 신뢰하지 말고 proxy가 `Host`, `Origin`, `Referer`, `X-Forwarded-Proto`를 정확히 보존·설정하게 하세요. 인증 cookie의 발급·검증은 Dashboard 프로세스가 직접 수행합니다. 잘못된 ID 또는 password 응답은 매 제출마다 최소 1초 지연되지만 애플리케이션 잠금은 없습니다. 이 지연은 병렬 요청으로 우회할 수 있으므로 외부 노출 경계에 별도 rate limiting을 적용해야 합니다.
|
||||
|
||||
이 로그인은 **Streamlit Dashboard만 보호하며 MCP endpoint를 인증하지 않습니다.** MCP는 별도 요구사항과 적절한 네트워크 경계로 보호해야 합니다.
|
||||
|
||||
@@ -75,7 +78,7 @@ Deployment: jarca-ledger (replicas: 1)
|
||||
│ └─ containerPort: 8000
|
||||
├─ frontend container
|
||||
│ ├─ 동일 릴리즈 이미지
|
||||
│ ├─ command: python -m streamlit run dashboard/app.py ...
|
||||
│ ├─ command: uvicorn dashboard.server:app ...
|
||||
│ └─ containerPort: 8501
|
||||
└─ shared PVC
|
||||
└─ /data/ledger.sqlite3
|
||||
@@ -85,16 +88,19 @@ 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는 필요한 범위에서만 노출합니다.
|
||||
- 이미지와 Pod는 UID/GID `1000:1000`으로 실행하며, PVC 쓰기 권한은 `fsGroup: 1000`으로 설정합니다.
|
||||
- frontend 컨테이너에만 `JARCA_DASHBOARD_ADMIN_ID`, `JARCA_DASHBOARD_ADMIN_PASSWORD`, `JARCA_DASHBOARD_SIGNING_SECRET`을 Kubernetes Secret 또는 동등한 secret store에서 주입합니다. 실제 값은 manifest나 repository에 기록하지 않습니다.
|
||||
- SQLite 파일을 공유하므로 Deployment의 replica는 `1`, update 전략은 `Recreate`로 유지합니다.
|
||||
- Base는 Dashboard용 `ClusterIP` Service(`8501`)만 생성합니다. 인증 계층이 없는 MCP port `8000`에는 Service를 만들지 않고 `NetworkPolicy`로 ingress를 차단합니다. MCP 원격 접근이 필요하면 environment overlay에서 인증된 gateway와 제한된 ingress 정책을 함께 정의합니다.
|
||||
- 외부 공개가 필요하면 Dashboard Service만 environment overlay의 Ingress 또는 NodePort에 연결합니다.
|
||||
- ARM64 worker에서만 실행하도록 Pod의 `nodeSelector` 또는 node affinity를 설정합니다.
|
||||
- 배포 이미지는 가변 태그 대신 검증된 `gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp` Gitea Container Registry digest로 고정합니다.
|
||||
|
||||
검증된 k3s Kustomize base는 [`deploy/k8s/base`](deploy/k8s/base)에 있으며, Secret 준비·digest pinning·배포 절차는 [`deploy/k8s/README.md`](deploy/k8s/README.md)를 따릅니다. Base에는 실제 Secret, Ingress, TLS와 cluster별 설정을 포함하지 않습니다.
|
||||
|
||||
Streamlit이 SQLite에 직접 접근하지 않고 MCP/API만 호출하도록 변경하거나 네트워크 DB로 전환한 뒤에는 MCP와 frontend를 각각 독립 Deployment로 분리할 수 있습니다.
|
||||
|
||||
로컬 `docker-compose.yml`도 동일한 애플리케이션 이미지를 두 컨테이너로 실행하는 구조입니다. 현재 Compose 설정은 로컬에서 빌드한 `jarca-ledger:latest`를 사용하며, MCP는 호스트 `8000` 포트로, Streamlit은 호스트 `40059`에서 컨테이너 `8501` 포트로 연결됩니다. Compose 실행 전 host environment 또는 안전한 `.env` 전달 경계에 두 Dashboard credential 변수를 설정해야 하며 누락 시 Compose가 시작을 거부합니다. Gitea Container Registry 릴리즈를 배포할 때는 두 서비스의 `image`를 검증된 동일 `gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp` digest로 지정해야 합니다.
|
||||
로컬 `docker-compose.yml`도 동일한 애플리케이션 이미지를 두 컨테이너로 실행하는 구조입니다. 현재 Compose 설정은 로컬에서 빌드한 `jarca-ledger:latest`를 사용하며, MCP는 호스트 `8000` 포트로, Dashboard는 호스트 `40059`에서 컨테이너 `8501` 포트로 연결됩니다. Compose 실행 전 host environment 또는 안전한 `.env` 전달 경계에 세 Dashboard 인증 변수를 설정해야 하며 누락 시 Compose가 시작을 거부합니다. Browser 접근에는 HTTPS reverse proxy를 앞에 두어야 합니다. Bind mount 대상 `/volume1/docker/jarca-ledger/data`는 UID/GID `1000:1000`이 쓸 수 있어야 합니다. Gitea Container Registry 릴리즈를 배포할 때는 두 서비스의 `image`를 검증된 동일 `gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp` digest로 지정해야 합니다.
|
||||
|
||||
## 주요 도구
|
||||
|
||||
@@ -123,4 +129,4 @@ Streamlit이 SQLite에 직접 접근하지 않고 MCP/API만 호출하도록 변
|
||||
- 국민은행 PDF import는 암호화 PDF 비밀번호를 입력받아 PyMuPDF로 표를 추출하고, `transaction_datetime`, `counterparty`, `balance_after`, `branch`, `raw_summary`, `raw_payload`에 원본 세부 정보를 보존합니다.
|
||||
- CSV/PDF/API import는 `import_batches`에 묶음 이력을 남기고, 잘못 가져온 batch는 `rollback_ledger_import_batch`로 되돌릴 수 있습니다.
|
||||
- 카테고리에는 `cashflow_kind`를 저장해 실제 가계부 수입/지출(`income`, `expense`)과 계좌이체/투자이동/분석제외(`transfer`, `investment`, `exclude`, `debt_payment`, `unknown`)를 분리합니다.
|
||||
- CSV import는 먼저 preview 후 저장하는 흐름으로 확장합니다.
|
||||
- CSV import는 먼저 preview 후 저장하는 흐름으로 확장합니다.
|
||||
+14
-6
@@ -12,7 +12,15 @@ 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.common import (
|
||||
clear_cache,
|
||||
get_db_path,
|
||||
get_transfer_review_items, # noqa: F401
|
||||
inject_app_css,
|
||||
render_dashboard_body,
|
||||
render_sidebar_nav,
|
||||
validate_dashboard_page,
|
||||
) # 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
|
||||
@@ -32,15 +40,15 @@ def main() -> None:
|
||||
db_path = get_db_path()
|
||||
inject_app_css()
|
||||
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
|
||||
query_page = st.query_params.get("page", "요약")
|
||||
page = query_page if query_page in pages else "요약"
|
||||
st.session_state["page"] = page
|
||||
query_page = st.query_params.get("page")
|
||||
page = validate_dashboard_page(query_page, pages)
|
||||
st.session_state["dashboard_page"] = page
|
||||
|
||||
with st.sidebar:
|
||||
st.markdown("## 💰 Jarca Ledger")
|
||||
st.caption("가계부 · 분류 · Import 운영 콘솔")
|
||||
st.markdown('<div class="jarca-nav-title">메뉴</div>', unsafe_allow_html=True)
|
||||
render_sidebar_nav(pages, page)
|
||||
page = render_sidebar_nav(pages, page)
|
||||
if st.button("새로고침", key="refresh_button", use_container_width=True):
|
||||
clear_cache()
|
||||
st.rerun()
|
||||
@@ -56,7 +64,7 @@ def main() -> None:
|
||||
"계좌": page_accounts,
|
||||
"Import": page_imports,
|
||||
}
|
||||
dispatch[page](db_path)
|
||||
render_dashboard_body(page, lambda: dispatch[page](db_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import PlainTextResponse, RedirectResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from dashboard import authentication
|
||||
|
||||
_MAX_LOGIN_BODY_BYTES = 4096
|
||||
_MAX_CREDENTIAL_CHARS = 1024
|
||||
|
||||
|
||||
def _redirect(location: str) -> RedirectResponse:
|
||||
response = RedirectResponse(location, status_code=303)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
def _same_origin(request: Request) -> bool:
|
||||
origin = request.headers.get("origin")
|
||||
host = request.headers.get("host")
|
||||
if not origin or not host:
|
||||
return False
|
||||
try:
|
||||
parsed = urlsplit(origin)
|
||||
authority = urlsplit(f"//{host}")
|
||||
origin_port = parsed.port or 443
|
||||
request_port = authority.port or 443
|
||||
return (
|
||||
request.url.scheme == "https"
|
||||
and parsed.scheme == "https"
|
||||
and parsed.hostname is not None
|
||||
and authority.hostname is not None
|
||||
and parsed.hostname.lower() == authority.hostname.lower()
|
||||
and origin_port == request_port
|
||||
and parsed.username is None
|
||||
and parsed.password is None
|
||||
and parsed.path in {"", "/"}
|
||||
and not parsed.query
|
||||
and not parsed.fragment
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _same_origin_navigation(request: Request) -> bool:
|
||||
referer = request.headers.get("referer")
|
||||
host = request.headers.get("host")
|
||||
if not referer or not host:
|
||||
return False
|
||||
try:
|
||||
parsed = urlsplit(referer)
|
||||
authority = urlsplit(f"//{host}")
|
||||
referer_port = parsed.port or 443
|
||||
request_port = authority.port or 443
|
||||
return (
|
||||
request.url.scheme == "https"
|
||||
and parsed.scheme == "https"
|
||||
and parsed.hostname is not None
|
||||
and authority.hostname is not None
|
||||
and parsed.hostname.lower() == authority.hostname.lower()
|
||||
and referer_port == request_port
|
||||
and parsed.username is None
|
||||
and parsed.password is None
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _one(values: dict[str, list[str]], name: str) -> str | None:
|
||||
candidates = values.get(name)
|
||||
if candidates is None or len(candidates) != 1:
|
||||
return None
|
||||
value = candidates[0]
|
||||
if len(value) > _MAX_CREDENTIAL_CHARS:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
async def login(request: Request) -> Response:
|
||||
correlation_id = secrets.token_hex(8)
|
||||
if not _same_origin(request):
|
||||
authentication.log_auth_event("login_origin_rejected", correlation_id)
|
||||
return PlainTextResponse("Forbidden", status_code=403)
|
||||
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
if content_type != "application/x-www-form-urlencoded":
|
||||
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||
return PlainTextResponse("Bad Request", status_code=400)
|
||||
body = bytearray()
|
||||
async for chunk in request.stream():
|
||||
if len(body) + len(chunk) > _MAX_LOGIN_BODY_BYTES:
|
||||
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||
return PlainTextResponse("Bad Request", status_code=400)
|
||||
body.extend(chunk)
|
||||
try:
|
||||
values = parse_qs(
|
||||
bytes(body).decode("utf-8"),
|
||||
keep_blank_values=True,
|
||||
strict_parsing=True,
|
||||
max_num_fields=3,
|
||||
encoding="utf-8",
|
||||
errors="strict",
|
||||
)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||
return PlainTextResponse("Bad Request", status_code=400)
|
||||
|
||||
settings = authentication.load_auth_settings()
|
||||
if settings is None:
|
||||
authentication.log_auth_event("configuration_error", correlation_id)
|
||||
return _redirect("/?auth_error=unavailable")
|
||||
|
||||
submitted_id = _one(values, "admin_id")
|
||||
submitted_password = _one(values, "admin_password")
|
||||
if submitted_id is None or submitted_password is None:
|
||||
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||
return _redirect("/?auth_error=invalid")
|
||||
|
||||
started_at = time.monotonic()
|
||||
if not authentication.verify_credentials(
|
||||
settings.credentials, submitted_id, submitted_password
|
||||
):
|
||||
await asyncio.to_thread(
|
||||
authentication.enforce_failed_login_delay, started_at=started_at
|
||||
)
|
||||
authentication.log_auth_event("login_failed", correlation_id)
|
||||
return _redirect("/?auth_error=invalid")
|
||||
|
||||
remember_me = _one(values, "remember_me") == "on"
|
||||
issued = authentication.issue_persistent_token(
|
||||
settings,
|
||||
remember_me=remember_me,
|
||||
)
|
||||
response = _redirect("/")
|
||||
response.set_cookie(
|
||||
authentication.AUTH_COOKIE_NAME,
|
||||
issued.value,
|
||||
max_age=issued.max_age,
|
||||
path="/",
|
||||
secure=True,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
authentication.log_auth_event("login_succeeded", correlation_id)
|
||||
return response
|
||||
|
||||
|
||||
async def logout(request: Request) -> Response:
|
||||
correlation_id = secrets.token_hex(8)
|
||||
if not _same_origin(request):
|
||||
authentication.log_auth_event("logout_origin_rejected", correlation_id)
|
||||
return PlainTextResponse("Forbidden", status_code=403)
|
||||
response = _redirect("/")
|
||||
_delete_auth_cookie(response)
|
||||
authentication.log_auth_event("logout", correlation_id)
|
||||
return response
|
||||
|
||||
|
||||
def _delete_auth_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
authentication.AUTH_COOKIE_NAME,
|
||||
path="/",
|
||||
secure=True,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
async def cleanup(request: Request) -> Response:
|
||||
correlation_id = secrets.token_hex(8)
|
||||
if not _same_origin_navigation(request):
|
||||
authentication.log_auth_event("cleanup_origin_rejected", correlation_id)
|
||||
return PlainTextResponse("Forbidden", status_code=403)
|
||||
response = _redirect("/")
|
||||
_delete_auth_cookie(response)
|
||||
authentication.log_auth_event("cleanup", correlation_id)
|
||||
return response
|
||||
|
||||
|
||||
def create_auth_routes() -> list[Route]:
|
||||
return [
|
||||
Route("/api/auth/login", login, methods=["POST"]),
|
||||
Route("/api/auth/logout", logout, methods=["POST"]),
|
||||
Route("/api/auth/cleanup", cleanup, methods=["GET"]),
|
||||
]
|
||||
+239
-58
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable as CallableABC, Mapping, MutableMapping
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
@@ -9,20 +10,28 @@ import secrets
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from itsdangerous import BadData, URLSafeSerializer
|
||||
import streamlit as st
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ADMIN_ID_ENV = "JARCA_DASHBOARD_ADMIN_ID"
|
||||
ADMIN_PASSWORD_ENV = "JARCA_DASHBOARD_ADMIN_PASSWORD"
|
||||
SIGNING_SECRET_ENV = "JARCA_DASHBOARD_SIGNING_SECRET"
|
||||
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"
|
||||
AUTH_COOKIE_NAME = "__Host-jarca_dashboard_auth"
|
||||
DEFAULT_TOKEN_TTL_SECONDS = 24 * 60 * 60
|
||||
REMEMBER_ME_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||
MAX_TOKEN_LENGTH = 4096
|
||||
CLOCK_SKEW_SECONDS = 30
|
||||
_TOKEN_VERSION = 1
|
||||
_TOKEN_SALT = "jarca-dashboard-persistent-auth-v1"
|
||||
_TOKEN_CONTEXT = b"jarca-dashboard-persistent-auth-v1\x00"
|
||||
_TOKEN_FIELDS = frozenset({"v", "sub", "iat", "exp", "jti"})
|
||||
_TOKEN_SUBJECT = "dashboard-admin"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -31,27 +40,85 @@ class AdminCredentials:
|
||||
password: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthSettings:
|
||||
admin_id: str
|
||||
admin_password: str
|
||||
signing_secret: str
|
||||
|
||||
@property
|
||||
def credentials(self) -> AdminCredentials:
|
||||
return AdminCredentials(admin_id=self.admin_id, password=self.admin_password)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssuedPersistentToken:
|
||||
value: str
|
||||
issued_at: int
|
||||
expires_at: int
|
||||
max_age: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedPersistentToken:
|
||||
subject: str
|
||||
issued_at: int
|
||||
expires_at: int
|
||||
token_id: 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
|
||||
def _read_required_setting(source: Mapping[str, str], name: str) -> str:
|
||||
try:
|
||||
admin_id = source.get(ADMIN_ID_ENV)
|
||||
password = source.get(ADMIN_PASSWORD_ENV)
|
||||
value = source.get(name)
|
||||
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:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE)
|
||||
|
||||
return AdminCredentials(admin_id=admin_id, password=password)
|
||||
return value
|
||||
|
||||
|
||||
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)
|
||||
def load_admin_credentials(environ: Mapping[str, str] | None = None) -> AdminCredentials:
|
||||
source = os.environ if environ is None else environ
|
||||
return AdminCredentials(
|
||||
admin_id=_read_required_setting(source, ADMIN_ID_ENV),
|
||||
password=_read_required_setting(source, ADMIN_PASSWORD_ENV),
|
||||
)
|
||||
|
||||
|
||||
def require_auth_settings(environ: Mapping[str, str] | None = None) -> AuthSettings:
|
||||
source = os.environ if environ is None else environ
|
||||
settings = AuthSettings(
|
||||
admin_id=_read_required_setting(source, ADMIN_ID_ENV),
|
||||
admin_password=_read_required_setting(source, ADMIN_PASSWORD_ENV),
|
||||
signing_secret=_read_required_setting(source, SIGNING_SECRET_ENV),
|
||||
)
|
||||
if len(settings.signing_secret.encode("utf-8")) < 32:
|
||||
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE)
|
||||
return settings
|
||||
|
||||
|
||||
def load_auth_settings(environ: Mapping[str, str] | None = None) -> AuthSettings | None:
|
||||
try:
|
||||
return require_auth_settings(environ)
|
||||
except AuthenticationConfigurationError:
|
||||
return None
|
||||
|
||||
|
||||
def verify_credentials(
|
||||
credentials: AdminCredentials, submitted_id: str, submitted_password: str
|
||||
) -> bool:
|
||||
if not isinstance(submitted_id, str) or not isinstance(submitted_password, str):
|
||||
return False
|
||||
id_matches = hmac.compare_digest(
|
||||
submitted_id.encode("utf-8"), credentials.admin_id.encode("utf-8")
|
||||
)
|
||||
password_matches = hmac.compare_digest(
|
||||
submitted_password.encode("utf-8"), credentials.password.encode("utf-8")
|
||||
)
|
||||
return id_matches and password_matches
|
||||
|
||||
|
||||
@@ -84,56 +151,170 @@ 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 _derived_signing_key(settings: AuthSettings) -> bytes:
|
||||
return hmac.new(
|
||||
settings.signing_secret.encode("utf-8"),
|
||||
_TOKEN_CONTEXT + settings.admin_id.encode("utf-8") + b"\x00" + settings.admin_password.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
|
||||
def _serializer_for(settings: AuthSettings) -> URLSafeSerializer:
|
||||
return URLSafeSerializer(
|
||||
secret_key=_derived_signing_key(settings),
|
||||
salt=_TOKEN_SALT,
|
||||
signer_kwargs={"digest_method": hashlib.sha256},
|
||||
)
|
||||
|
||||
|
||||
def issue_persistent_token(
|
||||
settings: AuthSettings,
|
||||
*,
|
||||
remember_me: bool,
|
||||
now: float | None = None,
|
||||
token_id: str | None = None,
|
||||
) -> IssuedPersistentToken:
|
||||
issued_at = int(time.time() if now is None else now)
|
||||
ttl = (
|
||||
REMEMBER_ME_TOKEN_TTL_SECONDS if remember_me else DEFAULT_TOKEN_TTL_SECONDS
|
||||
)
|
||||
expires_at = issued_at + ttl
|
||||
jti = secrets.token_urlsafe(16) if token_id is None else token_id
|
||||
payload = {
|
||||
"v": _TOKEN_VERSION,
|
||||
"sub": _TOKEN_SUBJECT,
|
||||
"iat": issued_at,
|
||||
"exp": expires_at,
|
||||
"jti": jti,
|
||||
}
|
||||
return IssuedPersistentToken(
|
||||
value=_serializer_for(settings).dumps(payload),
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
max_age=ttl,
|
||||
)
|
||||
|
||||
|
||||
def verify_persistent_token(
|
||||
settings: AuthSettings | None,
|
||||
token: object,
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> VerifiedPersistentToken | None:
|
||||
if settings is None or not isinstance(token, str) or not token:
|
||||
return None
|
||||
if len(token) > MAX_TOKEN_LENGTH:
|
||||
return None
|
||||
try:
|
||||
payload = _serializer_for(settings).loads(token)
|
||||
except (BadData, TypeError, ValueError):
|
||||
return None
|
||||
if not isinstance(payload, dict) or frozenset(payload) != _TOKEN_FIELDS:
|
||||
return None
|
||||
|
||||
version = payload.get("v")
|
||||
subject = payload.get("sub")
|
||||
issued_at = payload.get("iat")
|
||||
expires_at = payload.get("exp")
|
||||
token_id = payload.get("jti")
|
||||
if (
|
||||
type(version) is not int
|
||||
or version != _TOKEN_VERSION
|
||||
or subject != _TOKEN_SUBJECT
|
||||
or type(issued_at) is not int
|
||||
or type(expires_at) is not int
|
||||
or not isinstance(token_id, str)
|
||||
or not token_id
|
||||
or len(token_id) > 256
|
||||
):
|
||||
return None
|
||||
current_time = int(time.time() if now is None else now)
|
||||
ttl = expires_at - issued_at
|
||||
if ttl not in {DEFAULT_TOKEN_TTL_SECONDS, REMEMBER_ME_TOKEN_TTL_SECONDS}:
|
||||
return None
|
||||
if issued_at > current_time + CLOCK_SKEW_SECONDS or expires_at <= current_time:
|
||||
return None
|
||||
|
||||
return VerifiedPersistentToken(
|
||||
subject=subject,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
token_id=token_id,
|
||||
)
|
||||
|
||||
|
||||
def _request_cookie() -> str | None:
|
||||
try:
|
||||
value = st.context.cookies.get(AUTH_COOKIE_NAME)
|
||||
except Exception:
|
||||
return None
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _query_error() -> str | None:
|
||||
try:
|
||||
value = st.query_params.get("auth_error")
|
||||
except Exception:
|
||||
return None
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _render_login(*, clear_cookie: bool = False) -> None:
|
||||
st.title("관리자 로그인")
|
||||
st.caption("Dashboard를 사용하려면 관리자 자격 증명이 필요합니다.")
|
||||
if _query_error() == "invalid":
|
||||
st.error(INVALID_CREDENTIALS_MESSAGE)
|
||||
elif _query_error() == "unavailable":
|
||||
st.error(CONFIGURATION_ERROR_MESSAGE)
|
||||
st.markdown(
|
||||
"""
|
||||
<form action="/api/auth/login" method="post">
|
||||
<label for="jarca-admin-id">관리자 ID</label>
|
||||
<input id="jarca-admin-id" name="admin_id" type="text" autocomplete="username" required>
|
||||
<label for="jarca-admin-password">비밀번호</label>
|
||||
<input id="jarca-admin-password" name="admin_password" type="password" autocomplete="current-password" required>
|
||||
<label><input name="remember_me" type="checkbox"> 로그인 상태 유지 (30일)</label>
|
||||
<button type="submit">로그인</button>
|
||||
</form>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
if clear_cookie:
|
||||
st.markdown(
|
||||
"<meta http-equiv=\"refresh\" content=\"0;url=/api/auth/cleanup\">",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def _render_logout() -> None:
|
||||
with st.sidebar:
|
||||
st.markdown(
|
||||
"""
|
||||
<form action="/api/auth/logout" method="post">
|
||||
<button type="submit">로그아웃</button>
|
||||
</form>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def require_dashboard_authentication() -> bool:
|
||||
"""Render the authentication boundary and return whether access is allowed."""
|
||||
"""Validate the server-signed cookie before allowing any dashboard access."""
|
||||
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()
|
||||
cookie = _request_cookie()
|
||||
settings = load_auth_settings()
|
||||
if settings is None:
|
||||
log_auth_event("configuration_error", correlation_id)
|
||||
st.title("관리자 로그인")
|
||||
_render_login(clear_cookie=cookie is not None)
|
||||
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:
|
||||
verified = verify_persistent_token(settings, cookie)
|
||||
if verified is None:
|
||||
log_auth_event("authentication_required", correlation_id)
|
||||
_render_login(clear_cookie=cookie is not None)
|
||||
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()
|
||||
log_auth_event("token_accepted", correlation_id)
|
||||
_render_logout()
|
||||
return True
|
||||
|
||||
+42
-8
@@ -4,7 +4,6 @@ import sqlite3
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
import importlib
|
||||
import inspect
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -314,6 +313,9 @@ def inject_app_css() -> None:
|
||||
border-color: rgba(37, 99, 235, 0.48);
|
||||
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
.st-key-dashboard_body:has(.st-key-dashboard_loading [data-testid="stAlert"]) .st-key-dashboard_content {
|
||||
display: none;
|
||||
}
|
||||
.jarca-card {
|
||||
border: 1px solid rgba(226, 232, 240, 0.86);
|
||||
border-radius: 22px;
|
||||
@@ -355,13 +357,45 @@ def render_section_label(icon: str, label: str) -> None:
|
||||
st.markdown(f'<div class="jarca-section-label"><span>{escape(icon)}</span><span>{escape(label)}</span></div>', unsafe_allow_html=True)
|
||||
|
||||
|
||||
def render_sidebar_nav(pages: list[str], current_page: str) -> None:
|
||||
items = []
|
||||
for name in pages:
|
||||
active = " active" if name == current_page else ""
|
||||
href = f"?page={quote(name)}"
|
||||
items.append(f'<a class="jarca-nav-item{active}" href="{href}" target="_self">{escape(name)}</a>')
|
||||
st.markdown(f'<div class="jarca-nav-list">{"".join(items)}</div>', unsafe_allow_html=True)
|
||||
def validate_dashboard_page(page: str | None, pages: list[str]) -> str:
|
||||
return page if page in pages else "요약"
|
||||
|
||||
|
||||
def _sync_dashboard_page_query() -> None:
|
||||
st.query_params["page"] = st.session_state["dashboard_page"]
|
||||
|
||||
|
||||
def render_sidebar_nav(pages: list[str], current_page: str) -> str:
|
||||
if "dashboard_page" not in st.session_state:
|
||||
st.session_state["dashboard_page"] = current_page
|
||||
return st.radio(
|
||||
"메뉴",
|
||||
pages,
|
||||
key="dashboard_page",
|
||||
label_visibility="collapsed",
|
||||
on_change=_sync_dashboard_page_query,
|
||||
)
|
||||
|
||||
|
||||
def render_dashboard_body(page: str, renderer) -> None:
|
||||
body = st.empty()
|
||||
body.empty()
|
||||
try:
|
||||
with body.container():
|
||||
with st.container(key="dashboard_body"):
|
||||
loading = st.empty()
|
||||
with loading.container():
|
||||
with st.container(key="dashboard_loading"):
|
||||
st.info(f"{page} 불러오는 중…")
|
||||
with st.container(key="dashboard_content"):
|
||||
renderer()
|
||||
loading.empty()
|
||||
except Exception:
|
||||
body.empty()
|
||||
with body.container():
|
||||
st.error("페이지를 불러오지 못했습니다. 다시 시도해 주세요.")
|
||||
if st.button("다시 시도", key=f"retry_{page}"):
|
||||
st.rerun()
|
||||
|
||||
|
||||
def parse_optional_int(value: str, field_name: str) -> int | None:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from streamlit import App
|
||||
|
||||
from dashboard.auth_server import create_auth_routes
|
||||
|
||||
APP_SCRIPT = Path(__file__).with_name("app.py")
|
||||
app = App(APP_SCRIPT, routes=create_auth_routes())
|
||||
@@ -0,0 +1,51 @@
|
||||
# k3s 배포
|
||||
|
||||
`base/`는 MCP와 Dashboard를 같은 SQLite PVC에 두는 단일 Deployment 구조입니다. Dashboard 인증 secret은 base manifest에 실제 값으로 저장하지 않습니다.
|
||||
|
||||
## 사전 준비
|
||||
|
||||
namespace와 승인된 secret manager 절차를 준비합니다.
|
||||
|
||||
```bash
|
||||
kubectl apply -f deploy/k8s/base/namespace.yaml
|
||||
```
|
||||
|
||||
`jarca-ledger` namespace의 Dashboard secret에는 다음 key만 둡니다.
|
||||
|
||||
- `admin-id`
|
||||
- `admin-password`
|
||||
- `signing-secret` (UTF-8 32 byte 이상의 독립 random value)
|
||||
|
||||
Registry credential은 `imagePullSecrets`로만 사용합니다. 실제 secret 값, token, cookie를 Git, manifest, command history 또는 문서에 기록하지 마세요.
|
||||
|
||||
## 배포
|
||||
|
||||
검증된 release digest를 외부 environment overlay에서 고정하고 적용합니다.
|
||||
|
||||
```bash
|
||||
kubectl apply -k environments/production/jarca-ledger
|
||||
kubectl -n jarca-ledger rollout status deployment/jarca-ledger
|
||||
kubectl -n jarca-ledger get pods,svc,pvc
|
||||
```
|
||||
|
||||
MCP와 Dashboard container 및 init container는 동일 digest를 사용해야 합니다. SQLite를 공유하므로 replicas는 `1`, update 전략은 `Recreate`입니다.
|
||||
|
||||
## Dashboard 인증 운영
|
||||
|
||||
Frontend container에만 다음 환경변수를 Kubernetes Secret 또는 동등한 secret store에서 주입합니다.
|
||||
|
||||
- `JARCA_DASHBOARD_ADMIN_ID`
|
||||
- `JARCA_DASHBOARD_ADMIN_PASSWORD`
|
||||
- `JARCA_DASHBOARD_SIGNING_SECRET`
|
||||
|
||||
환경변수가 누락·빈 값·read error·짧은 secret이면 Dashboard는 fail-closed합니다. token subject는 고정 비민감 role `dashboard-admin`이며 server-only configured credential binding을 사용합니다. DB-backed session/token registry와 per-token revocation은 없습니다. Password, signing secret 또는 admin ID rotation은 기존 token을 전체 무효화합니다.
|
||||
|
||||
공개 Dashboard origin에는 TLS가 필수입니다. Ingress 또는 reverse proxy는 `Host`, `Origin`, `Referer`, `X-Forwarded-Proto: https`를 보존해야 하며 `FORWARDED_ALLOW_IPS`는 실제 ingress-controller source IP/CIDR로 제한합니다. `*` 또는 불특정 network를 사용하지 마세요. Login과 `/api/auth/logout`은 same-origin HTTPS POST이고 `/api/auth/cleanup` GET은 same-origin HTTPS `Referer`를 검증합니다. Invalid cookie는 cleanup route의 matching `Max-Age=0` deletion 뒤 unauthenticated page로 이동하고, explicit logout도 같은 deletion을 수행합니다. 인증 실패 지연은 rate limiting을 대체하지 않으므로 TLS ingress 경계에 별도 rate limit을 적용합니다.
|
||||
|
||||
Base는 인증 계층이 없는 MCP port `8000`을 Service로 공개하지 않고 NetworkPolicy로 ingress를 제한합니다. NetworkPolicy enforcement는 운영 cluster에서 확인해야 합니다.
|
||||
|
||||
## SQLite와 PVC
|
||||
|
||||
- PVC 전체를 `/data`에 mount하여 SQLite journal/WAL/SHM을 함께 보존합니다.
|
||||
- `replicas: 1`과 `Recreate`를 유지하여 두 Pod가 같은 SQLite를 동시에 열지 않게 합니다.
|
||||
- 별도 저장소로 backup을 내보내며 PVC만 재해 복구로 간주하지 않습니다.
|
||||
@@ -0,0 +1,185 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: jarca-ledger
|
||||
labels:
|
||||
app.kubernetes.io/name: jarca-ledger
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: jarca-ledger
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: jarca-ledger
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
enableServiceLinks: false
|
||||
terminationGracePeriodSeconds: 30
|
||||
imagePullSecrets:
|
||||
- name: jarca-ledger-registry
|
||||
nodeSelector:
|
||||
kubernetes.io/os: linux
|
||||
kubernetes.io/arch: arm64
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
- name: init-db
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- python
|
||||
- -c
|
||||
- >-
|
||||
from jarca_ledger_mcp.service import health_check;
|
||||
result = health_check();
|
||||
assert result["success"], result
|
||||
env:
|
||||
- name: JARCA_LEDGER_DB_PATH
|
||||
value: /data/ledger.sqlite3
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
- name: TMPDIR
|
||||
value: /tmp
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: init-tmp
|
||||
mountPath: /tmp
|
||||
containers:
|
||||
- name: mcp
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: JARCA_LEDGER_DB_PATH
|
||||
value: /data/ledger.sqlite3
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
- name: TMPDIR
|
||||
value: /tmp
|
||||
ports:
|
||||
- name: mcp
|
||||
containerPort: 8000
|
||||
protocol: TCP
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
startupProbe:
|
||||
tcpSocket:
|
||||
port: mcp
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 24
|
||||
readinessProbe:
|
||||
tcpSocket:
|
||||
port: mcp
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
tcpSocket:
|
||||
port: mcp
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 3
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: mcp-tmp
|
||||
mountPath: /tmp
|
||||
- name: dashboard
|
||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.4
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- uvicorn
|
||||
- dashboard.server:app
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --port
|
||||
- "8501"
|
||||
- --proxy-headers
|
||||
env:
|
||||
- name: JARCA_LEDGER_DB_PATH
|
||||
value: /data/ledger.sqlite3
|
||||
- name: HOME
|
||||
value: /tmp
|
||||
- name: TMPDIR
|
||||
value: /tmp
|
||||
- name: JARCA_DASHBOARD_ADMIN_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: jarca-ledger-dashboard-auth
|
||||
key: admin-id
|
||||
- name: JARCA_DASHBOARD_ADMIN_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: jarca-ledger-dashboard-auth
|
||||
key: admin-password
|
||||
- name: JARCA_DASHBOARD_SIGNING_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: jarca-ledger-dashboard-auth
|
||||
key: signing-secret
|
||||
- name: FORWARDED_ALLOW_IPS
|
||||
value: 127.0.0.1
|
||||
ports:
|
||||
- name: dashboard
|
||||
containerPort: 8501
|
||||
protocol: TCP
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /_stcore/health
|
||||
port: dashboard
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 24
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /_stcore/health
|
||||
port: dashboard
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /_stcore/health
|
||||
port: dashboard
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 2
|
||||
failureThreshold: 3
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: dashboard-tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: jarca-ledger-data
|
||||
- name: init-tmp
|
||||
emptyDir: {}
|
||||
- name: mcp-tmp
|
||||
emptyDir: {}
|
||||
- name: dashboard-tmp
|
||||
emptyDir: {}
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: jarca-ledger
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- pvc.yaml
|
||||
- deployment.yaml
|
||||
- services.yaml
|
||||
- networkpolicy.yaml
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: jarca-ledger
|
||||
@@ -0,0 +1,14 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: jarca-ledger-ingress
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: jarca-ledger
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- ports:
|
||||
- protocol: TCP
|
||||
port: 8501
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: jarca-ledger-data
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: local-path
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: jarca-ledger-dashboard
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: jarca-ledger
|
||||
ports:
|
||||
- name: http
|
||||
port: 8501
|
||||
targetPort: dashboard
|
||||
appProtocol: http
|
||||
+7
-11
@@ -31,24 +31,20 @@ services:
|
||||
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}
|
||||
JARCA_DASHBOARD_SIGNING_SECRET: ${JARCA_DASHBOARD_SIGNING_SECRET:?set JARCA_DASHBOARD_SIGNING_SECRET}
|
||||
FORWARDED_ALLOW_IPS: ${JARCA_DASHBOARD_FORWARDED_ALLOW_IPS:-127.0.0.1}
|
||||
STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
volumes:
|
||||
- /volume1/docker/jarca-ledger/data:/data
|
||||
command:
|
||||
- python
|
||||
- -m
|
||||
- streamlit
|
||||
- run
|
||||
- dashboard/app.py
|
||||
- --server.address
|
||||
- uvicorn
|
||||
- dashboard.server:app
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --server.port
|
||||
- --port
|
||||
- "8501"
|
||||
- --server.headless
|
||||
- "true"
|
||||
- --browser.gatherUsageStats
|
||||
- "false"
|
||||
- --proxy-headers
|
||||
healthcheck:
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"schema": "jarca-ledger.review-evidence/v1",
|
||||
"issue": 6,
|
||||
"pull_request": 7,
|
||||
"source_review": 51,
|
||||
"finding": "D6-23",
|
||||
"contract_sha256": "4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe",
|
||||
"verified_at_utc": "2026-10-03T00:10:43Z",
|
||||
"source_revision": "e7242a89f7f1faacbe984713a5ad211bd7bcac2b",
|
||||
"source_tree_clean_before_evidence_write": true,
|
||||
"environment": {
|
||||
"os": "Linux 6.18.34+rpt-rpi-v8",
|
||||
"architecture": "aarch64",
|
||||
"python": "3.13.5",
|
||||
"starlette": "1.3.1",
|
||||
"httpx": "0.28.1",
|
||||
"streamlit": "1.58.0",
|
||||
"uvicorn": "0.49.0",
|
||||
"itsdangerous": "2.2.0",
|
||||
"credentials": "synthetic test-only values; not recorded",
|
||||
"signing_secret": "synthetic test-only value; not recorded"
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"purpose": "Focused route, hardened cookie, event-loop offload, and failed-delay helper verification",
|
||||
"command": "uv run --extra dashboard --extra dev pytest -q tests/test_dashboard_auth_server.py tests/test_dashboard_authentication.py",
|
||||
"exit_code": 0,
|
||||
"result": "29 passed; one Starlette/httpx deprecation warning",
|
||||
"relevant_output": "............................. [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html"
|
||||
},
|
||||
{
|
||||
"purpose": "Real-delay concurrent failed login plus successful login and hardened cookie runtime probe",
|
||||
"command": "uv run --extra dashboard python - <<'PY'\nimport asyncio\nimport json\nimport os\nimport secrets\nimport time\n\nfrom starlette.requests import Request\n\nfrom dashboard import authentication\nfrom dashboard.auth_server import login\n\nadmin_id = \"synthetic-\" + secrets.token_hex(8)\nadmin_password = secrets.token_hex(16)\nos.environ[authentication.ADMIN_ID_ENV] = admin_id\nos.environ[authentication.ADMIN_PASSWORD_ENV] = admin_password\nos.environ[authentication.SIGNING_SECRET_ENV] = secrets.token_hex(32)\n\n\ndef request(password: str) -> Request:\n body = f\"admin_id={admin_id}&admin_password={password}\".encode()\n sent = False\n\n async def receive():\n nonlocal sent\n if sent:\n return {\"type\": \"http.disconnect\"}\n sent = True\n return {\"type\": \"http.request\", \"body\": body, \"more_body\": False}\n\n scope = {\n \"type\": \"http\",\n \"method\": \"POST\",\n \"scheme\": \"https\",\n \"path\": \"/api/auth/login\",\n \"raw_path\": b\"/api/auth/login\",\n \"query_string\": b\"\",\n \"headers\": [\n (b\"host\", b\"testserver\"),\n (b\"origin\", b\"https://testserver\"),\n (b\"content-type\", b\"application/x-www-form-urlencoded\"),\n ],\n \"client\": (\"testclient\", 50000),\n \"server\": (\"testserver\", 443),\n }\n return Request(scope, receive)\n\n\nasync def main() -> None:\n wrong_password = secrets.token_hex(16)\n started = time.monotonic()\n failures = [asyncio.create_task(login(request(wrong_password))) for _ in range(2)]\n await asyncio.sleep(0.1)\n probe_elapsed = time.monotonic() - started\n success_started = time.monotonic()\n success = await login(request(admin_password))\n success_elapsed = time.monotonic() - success_started\n failed = await asyncio.gather(*failures)\n total_elapsed = time.monotonic() - started\n cookie = success.headers.get(\"set-cookie\", \"\")\n assert success.status_code == 303\n assert success.headers[\"location\"] == \"/\"\n assert all(marker in cookie for marker in [\"HttpOnly\", \"Secure\", \"SameSite=lax\", \"Path=/\", \"Max-Age=86400\"])\n assert all(response.status_code == 303 for response in failed)\n assert all(\"set-cookie\" not in response.headers for response in failed)\n assert probe_elapsed < 0.5\n assert success_elapsed < 0.5\n assert 0.95 <= total_elapsed < 1.75\n print(json.dumps({\n \"success\": True,\n \"source_revision\": \"e7242a89f7f1faacbe984713a5ad211bd7bcac2b\",\n \"successful_login_status\": success.status_code,\n \"successful_login_cookie_hardened\": True,\n \"failed_login_statuses\": [response.status_code for response in failed],\n \"failed_login_cookie_absent\": True,\n \"event_loop_probe_elapsed_seconds\": round(probe_elapsed, 6),\n \"successful_login_elapsed_seconds\": round(success_elapsed, 6),\n \"concurrent_failed_logins_total_elapsed_seconds\": round(total_elapsed, 6),\n \"minimum_failed_login_delay_seconds\": 1.0,\n \"credentials_and_cookie_values_recorded\": False,\n }, indent=2))\n\n\nasyncio.run(main())\nPY",
|
||||
"exit_code": 0,
|
||||
"result": {
|
||||
"success": true,
|
||||
"source_revision": "e7242a89f7f1faacbe984713a5ad211bd7bcac2b",
|
||||
"successful_login_status": 303,
|
||||
"successful_login_cookie_hardened": true,
|
||||
"failed_login_statuses": [
|
||||
303,
|
||||
303
|
||||
],
|
||||
"failed_login_cookie_absent": true,
|
||||
"event_loop_probe_elapsed_seconds": 0.10056,
|
||||
"successful_login_elapsed_seconds": 0.001267,
|
||||
"concurrent_failed_logins_total_elapsed_seconds": 1.00614,
|
||||
"minimum_failed_login_delay_seconds": 1.0,
|
||||
"credentials_and_cookie_values_recorded": false
|
||||
},
|
||||
"relevant_output": "{\n \"success\": true,\n \"source_revision\": \"e7242a89f7f1faacbe984713a5ad211bd7bcac2b\",\n \"successful_login_status\": 303,\n \"successful_login_cookie_hardened\": true,\n \"failed_login_statuses\": [\n 303,\n 303\n ],\n \"failed_login_cookie_absent\": true,\n \"event_loop_probe_elapsed_seconds\": 0.10056,\n \"successful_login_elapsed_seconds\": 0.001267,\n \"concurrent_failed_logins_total_elapsed_seconds\": 1.00614,\n \"minimum_failed_login_delay_seconds\": 1.0,\n \"credentials_and_cookie_values_recorded\": false\n}"
|
||||
}
|
||||
],
|
||||
"hashes_sha256": {
|
||||
"dashboard/auth_server.py": "4b991fbe942b573f8be270538b7e9df81bca9ffed3a8982f09db0d95d26e66d1",
|
||||
"dashboard/authentication.py": "63df2e33ef10859039c9b8140e92cbf0c7c8c9fe2d19cfcedbf16637c8cc2596",
|
||||
"tests/test_dashboard_auth_server.py": "8b96421af776b613867a8a5ab5dd962033b39d1cc616ba4e91cf3c4de63a1f0d",
|
||||
"tests/test_dashboard_authentication.py": "1ca7ecc4d4db55c674aa2934d665b121476ff5fa2e1f74071090a67e815af676",
|
||||
"pyproject.toml": "64100d3aa1c2272df2e2139ed435724901f7215ea72e48bee9d73f95e7e53a8d"
|
||||
},
|
||||
"redaction": {
|
||||
"credentials_recorded": false,
|
||||
"signing_secret_recorded": false,
|
||||
"token_or_cookie_value_recorded": false,
|
||||
"request_body_recorded": false,
|
||||
"raw_set_cookie_recorded": false
|
||||
},
|
||||
"notes": [
|
||||
"This artifact is an evidence-only descendant of source_revision; the application and test paths hashed above were not modified before these commands ran.",
|
||||
"The runtime probe exercised the changed streaming login path with synthetic credentials, confirmed hardened 24-hour cookie issuance, retained the real one-second failed-login delay, and showed the event loop and a successful login remained responsive while two failed requests waited.",
|
||||
"This D6-23 evidence does not replace or claim a fresh browser run; D6-22 remains supported by docs/evidence/issue-6-persistent-auth-https.json and the Review #51 change-impact analysis."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"claims": [
|
||||
{
|
||||
"id": "f003-unicode-success",
|
||||
"pointers": [
|
||||
"commands[0].argv",
|
||||
"commands[0].exit_code",
|
||||
"commands[0].output",
|
||||
"commands[0].started_at_utc",
|
||||
"commands[0].finished_at_utc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "f003-generic-delayed-failure",
|
||||
"pointers": [
|
||||
"commands[0].argv",
|
||||
"commands[0].exit_code",
|
||||
"commands[0].output",
|
||||
"commands[0].started_at_utc",
|
||||
"commands[0].finished_at_utc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "f003-malformed-utf8-400-no-cookie",
|
||||
"pointers": [
|
||||
"commands[0].argv",
|
||||
"commands[0].exit_code",
|
||||
"commands[0].output",
|
||||
"commands[0].started_at_utc",
|
||||
"commands[0].finished_at_utc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "full-regression",
|
||||
"pointers": [
|
||||
"commands[1].argv",
|
||||
"commands[1].exit_code",
|
||||
"commands[1].output",
|
||||
"commands[1].started_at_utc",
|
||||
"commands[1].finished_at_utc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static-and-source-integrity",
|
||||
"pointers": [
|
||||
"commands[2].argv",
|
||||
"commands[2].exit_code",
|
||||
"commands[2].output",
|
||||
"commands[3].argv",
|
||||
"commands[3].exit_code",
|
||||
"commands[3].output",
|
||||
"commands[4].argv",
|
||||
"commands[4].exit_code",
|
||||
"commands[4].output"
|
||||
]
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"argv": [
|
||||
".venv/bin/python",
|
||||
"-m",
|
||||
"pytest",
|
||||
"-o",
|
||||
"addopts=",
|
||||
"-q",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
"--basetemp=/tmp/jarca-f003-evidence-focused",
|
||||
"tests/test_dashboard_auth_server.py",
|
||||
"tests/test_dashboard_authentication.py"
|
||||
],
|
||||
"duration_seconds": 11.436193,
|
||||
"exit_code": 0,
|
||||
"expected_exit_code": 0,
|
||||
"finished_at_utc": "2026-10-04T03:55:21.516159Z",
|
||||
"id": "focused-tests",
|
||||
"output": "................................ [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n32 passed, 1 warning in 7.81s\n",
|
||||
"purpose": "F-003 Unicode success, delayed generic mismatch, malformed percent-encoded UTF-8, and credential helper verification",
|
||||
"started_at_utc": "2026-10-04T03:55:10.080020Z"
|
||||
},
|
||||
{
|
||||
"argv": [
|
||||
".venv/bin/python",
|
||||
"-m",
|
||||
"pytest",
|
||||
"-o",
|
||||
"addopts=",
|
||||
"-q",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
"--basetemp=/tmp/jarca-f003-evidence-full"
|
||||
],
|
||||
"duration_seconds": 54.086422,
|
||||
"exit_code": 0,
|
||||
"expected_exit_code": 0,
|
||||
"finished_at_utc": "2026-10-04T03:56:15.670519Z",
|
||||
"id": "full-tests",
|
||||
"output": "........................................................................ [ 59%]\n...................ss........................ss... [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n118 passed, 4 skipped, 1 warning in 49.12s\n",
|
||||
"purpose": "Complete repository regression suite",
|
||||
"started_at_utc": "2026-10-04T03:55:21.584157Z"
|
||||
},
|
||||
{
|
||||
"argv": [
|
||||
".venv/bin/ruff",
|
||||
"check",
|
||||
"--no-cache",
|
||||
"dashboard/authentication.py",
|
||||
"dashboard/auth_server.py",
|
||||
"tests/test_dashboard_auth_server.py",
|
||||
"tests/test_dashboard_authentication.py"
|
||||
],
|
||||
"duration_seconds": 0.025974,
|
||||
"exit_code": 0,
|
||||
"expected_exit_code": 0,
|
||||
"finished_at_utc": "2026-10-04T03:56:15.770278Z",
|
||||
"id": "ruff",
|
||||
"output": "All checks passed!\n",
|
||||
"purpose": "Static lint for the exact F-003 source and test allowlist",
|
||||
"started_at_utc": "2026-10-04T03:56:15.744356Z"
|
||||
},
|
||||
{
|
||||
"argv": [
|
||||
"uv",
|
||||
"lock",
|
||||
"--check",
|
||||
"--offline"
|
||||
],
|
||||
"duration_seconds": 0.059792,
|
||||
"exit_code": 0,
|
||||
"expected_exit_code": 0,
|
||||
"finished_at_utc": "2026-10-04T03:56:15.890498Z",
|
||||
"id": "lock-check",
|
||||
"output": "Resolved 72 packages in 3ms\n",
|
||||
"purpose": "Offline dependency lock consistency",
|
||||
"started_at_utc": "2026-10-04T03:56:15.830762Z"
|
||||
},
|
||||
{
|
||||
"argv": [
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad^",
|
||||
"d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad"
|
||||
],
|
||||
"duration_seconds": 0.033395,
|
||||
"exit_code": 0,
|
||||
"expected_exit_code": 0,
|
||||
"finished_at_utc": "2026-10-04T03:56:15.992735Z",
|
||||
"id": "source-diff-check",
|
||||
"output": "",
|
||||
"purpose": "Whitespace validation for the exact source commit",
|
||||
"started_at_utc": "2026-10-04T03:56:15.959398Z"
|
||||
}
|
||||
],
|
||||
"contract_sha256": "4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe",
|
||||
"environment": {
|
||||
"architecture": "aarch64",
|
||||
"child_environment_keys": [
|
||||
"HOME",
|
||||
"NO_COLOR",
|
||||
"PATH",
|
||||
"PYTHONDONTWRITEBYTECODE"
|
||||
],
|
||||
"credentials": "synthetic test-only values supplied by tests; not recorded",
|
||||
"dependencies": {
|
||||
"httpx": "0.28.1",
|
||||
"itsdangerous": "2.2.0",
|
||||
"pytest": "9.1.1",
|
||||
"starlette": "1.3.1",
|
||||
"streamlit": "1.58.0",
|
||||
"uvicorn": "0.49.0"
|
||||
},
|
||||
"os": "Linux-6.18.34+rpt-rpi-v8-aarch64-with-glibc2.41",
|
||||
"python": "3.13.5",
|
||||
"signing_secret": "synthetic test-only value supplied by tests; not recorded"
|
||||
},
|
||||
"finding": "F-003",
|
||||
"issue": 6,
|
||||
"limitations": [
|
||||
"The focused adapter tests use Starlette TestClient rather than a newly executed external browser or deployed Compose/Kubernetes environment.",
|
||||
"The existing Starlette/httpx deprecation warning is retained and is unrelated to F-003."
|
||||
],
|
||||
"notes": [
|
||||
"This evidence-only descendant binds verification to source_revision; the listed source bytes and Git blob OIDs were unchanged before, during, and after every command.",
|
||||
"No command used credentials, signing secrets, authorization headers, raw cookies, or shell interpretation in argv."
|
||||
],
|
||||
"pull_request": 7,
|
||||
"redaction": {
|
||||
"credentials_recorded": false,
|
||||
"raw_set_cookie_recorded": false,
|
||||
"request_body_recorded": false,
|
||||
"signing_secret_recorded": false,
|
||||
"token_or_cookie_value_recorded": false
|
||||
},
|
||||
"schema": "jarca-ledger.review-evidence/v2",
|
||||
"source_review": 53,
|
||||
"source_revision": "d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad",
|
||||
"source_tree_clean_before_evidence_write": true,
|
||||
"sources": {
|
||||
"dashboard/auth_server.py": {
|
||||
"git_blob_oid": "0546d582805f495c86dcdbb45510431f7699ecfd",
|
||||
"sha256": "94675feffbc0807a632d51a9feea8aa4d8834a37de2d8f32c50aa273d88ddab3"
|
||||
},
|
||||
"dashboard/authentication.py": {
|
||||
"git_blob_oid": "006d3c95661b64924a89aa952ff531095d4684db",
|
||||
"sha256": "260c3419040a883a76ced3fd1a1ff43f86763e608448fff57078c30a54c74213"
|
||||
},
|
||||
"pyproject.toml": {
|
||||
"git_blob_oid": "2892966feaaa8dfa07bf330997afc8315364a672",
|
||||
"sha256": "64100d3aa1c2272df2e2139ed435724901f7215ea72e48bee9d73f95e7e53a8d"
|
||||
},
|
||||
"tests/test_dashboard_auth_server.py": {
|
||||
"git_blob_oid": "0508f54f74185fc4fda341fcdd2574cbadb0d203",
|
||||
"sha256": "6b226b8d0d207a84f788b090de67fe6ddf9e7c8e05218ce18d97aa4badac95f5"
|
||||
},
|
||||
"tests/test_dashboard_authentication.py": {
|
||||
"git_blob_oid": "4d16765c4ab64826144d4880ec27b489a02bc53d",
|
||||
"sha256": "d6758e91b2e275cb22f55929ce0bf4a775feecb23f720fa05151da1ebb4da036"
|
||||
},
|
||||
"uv.lock": {
|
||||
"git_blob_oid": "c92cadb5bf8ae12e603fa956177c5d7f12e58535",
|
||||
"sha256": "9582546bc7c97a25b5d40d66ee814203ae46c13e7263942d02008403abd33c73"
|
||||
}
|
||||
},
|
||||
"verified_at_utc": "2026-10-04T03:56:15.992735Z"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"schema": "jarca-ledger.auth-evidence/v2",
|
||||
"issue": 6,
|
||||
"pull_request": 7,
|
||||
"source_review": 44,
|
||||
"verified_at_utc": "2026-10-02T03:03:47Z",
|
||||
"source_revision": "7bb959859a84f1d1293afd1f0270cca18f4cd576",
|
||||
"source_tree_clean_before_evidence_write": true,
|
||||
"transport": "HTTPS browser origin terminating to a loopback-only Uvicorn test server",
|
||||
"browser_runner": {
|
||||
"interface": "Playwright MCP",
|
||||
"engine": "Chromium 154",
|
||||
"architecture": "arm",
|
||||
"bitness": "64",
|
||||
"platform": "Linux",
|
||||
"secure_context": true
|
||||
},
|
||||
"environment": {
|
||||
"synthetic_database": true,
|
||||
"operating_ledger_used": false,
|
||||
"credentials": "synthetic test-only values; not recorded",
|
||||
"signing_secret": "synthetic test-only value; not recorded",
|
||||
"temporary_https_ingress": true
|
||||
},
|
||||
"local_checks": [
|
||||
{
|
||||
"command": "uv run --extra dashboard --extra dev pytest -q",
|
||||
"exit_code": 0,
|
||||
"result": "111 passed, 4 skipped"
|
||||
},
|
||||
{
|
||||
"command": "uv run --extra dashboard --extra dev ruff check dashboard tests scripts",
|
||||
"exit_code": 0,
|
||||
"result": "All checks passed"
|
||||
},
|
||||
{
|
||||
"command": "uv run --extra dashboard --extra dev python -m compileall -q dashboard scripts tests",
|
||||
"exit_code": 0,
|
||||
"result": "passed"
|
||||
}
|
||||
],
|
||||
"browser_results": {
|
||||
"initial_login_page_visible": true,
|
||||
"login": {
|
||||
"status": 303,
|
||||
"location": "/",
|
||||
"cookie_name": "__Host-jarca_dashboard_auth",
|
||||
"cookie_value": "[REDACTED]",
|
||||
"secure": true,
|
||||
"http_only": true,
|
||||
"same_site": "Lax",
|
||||
"path": "/",
|
||||
"domain_attribute_absent": true,
|
||||
"max_age_seconds": 2592000,
|
||||
"document_cookie_exposed_auth_cookie": false
|
||||
},
|
||||
"restoration": {
|
||||
"new_tab": true,
|
||||
"query_page": "Import",
|
||||
"authenticated_page_visible": true,
|
||||
"login_page_visible": false
|
||||
},
|
||||
"invalid_cookie_cleanup": {
|
||||
"injected_value": "[REDACTED TEST SENTINEL]",
|
||||
"cleanup_status": 303,
|
||||
"location": "/",
|
||||
"matching_cookie_deletion": true,
|
||||
"max_age_seconds": 0,
|
||||
"cookie_count_after_cleanup": 0,
|
||||
"unauthenticated_page_visible": true
|
||||
},
|
||||
"logout": {
|
||||
"status": 303,
|
||||
"location": "/",
|
||||
"matching_cookie_deletion": true,
|
||||
"max_age_seconds": 0,
|
||||
"cookie_count_after_logout": 0,
|
||||
"unauthenticated_page_visible": true,
|
||||
"document_cookie_exposed_auth_cookie": false
|
||||
}
|
||||
},
|
||||
"hashes_sha256": {
|
||||
"scripts/persistent_auth_http_evidence.py": "403bfcd7e281d4b1750841a969a18548a580a5f3f5116abeaf83e71e81832844",
|
||||
"dashboard/authentication.py": "63df2e33ef10859039c9b8140e92cbf0c7c8c9fe2d19cfcedbf16637c8cc2596",
|
||||
"dashboard/auth_server.py": "d986e10d3489938c559d4f11ae20a43a448b39aae8a27978571f193f9df0e8ca",
|
||||
"dashboard/server.py": "633c7fd9ae8a41c656a734be641de7f1733a1636216a9dbe2e50db885ff6ec6e",
|
||||
"pyproject.toml": "64100d3aa1c2272df2e2139ed435724901f7215ea72e48bee9d73f95e7e53a8d"
|
||||
},
|
||||
"notes": [
|
||||
"The evidence artifact is an evidence-only descendant of source_revision; application, test, and configuration paths were not changed after source_revision verification.",
|
||||
"Credentials, signing material, token values, raw cookie values, request bodies, and raw Set-Cookie values were not recorded.",
|
||||
"The actual ARM64 Chromium browser performed login, HttpOnly storage, new-tab restoration, invalid-cookie cleanup, and logout deletion."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Issue #9 authenticated browser evidence
|
||||
|
||||
This directory binds the dashboard menu-transition acceptance evidence to the exact Git head and working-tree files listed in `manifest.json`.
|
||||
|
||||
## Captured transition
|
||||
|
||||
- **Path:** `요약` → `분류 규칙`
|
||||
- **Input:** native Streamlit menu click in an authenticated HTTPS browser session
|
||||
- **Fixture:** generated synthetic SQLite database only
|
||||
- **Sampling:** 35 DOM/computed-style frames at 50 ms intervals
|
||||
- **Viewport:** 1280 × 720
|
||||
- **Browser:** Headless Chrome 154; Client Hints architecture `arm`, bitness `64`
|
||||
- **Fixture host:** Linux aarch64, Python 3.13.5, Streamlit 1.58.0, Uvicorn 0.49.0
|
||||
|
||||
## Verified browser observations
|
||||
|
||||
All twelve manifest checks are true:
|
||||
|
||||
- the HTML document and sidebar markers stayed identical;
|
||||
- `[data-testid="stAppViewContainer"]` retained a non-`none` computed `backgroundImage` and remained visible/rendered in every frame;
|
||||
- `[data-testid="stSidebar"]` retained a non-`none` computed `backgroundImage` and remained visible/rendered in every frame;
|
||||
- computed `backgroundImage`, `backgroundColor`, `visibility`, `display`, `opacity`, and rendered geometry were captured for both surfaces in every frame;
|
||||
- the click and all 35 frames completed inside one uninterrupted Playwright evaluation context, so no full-document navigation occurred;
|
||||
- the target loading state was observed in two frames;
|
||||
- the previous `요약` body was absent in every loading frame;
|
||||
- the unrelated `source=evidence` query key survived;
|
||||
- final URL, selected menu, target body, and cleared loading state agreed.
|
||||
|
||||
`browser-observations.json` contains every frame, body hashes, URL state, markers, computed surface styles, runtime metadata, and explicit limitations. `final-state.jpg` is the final visible browser state and contains no credential or secret.
|
||||
|
||||
## Recheck
|
||||
|
||||
Run the exact validator recorded in `manifest.json`. The captured result was:
|
||||
|
||||
```text
|
||||
exit code: 0
|
||||
PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=35 full_document_loads=0 surfaces=2
|
||||
```
|
||||
|
||||
The browser actions used Playwright MCP. That API reports success/error rather than a numeric process exit code; the manifest records this boundary instead of fabricating an exit status.
|
||||
|
||||
## Evidence boundaries
|
||||
|
||||
- This is synthetic-fixture browser evidence, not operating-ledger evidence.
|
||||
- A temporary HTTPS ingress adapter normalized the request scheme. Repository authentication and same-origin enforcement remained active.
|
||||
- The tunnel URL is intentionally ephemeral and retained only as provenance.
|
||||
- The screenshot supplements, but does not replace, the machine-readable continuous-frame observations.
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"schema": "jarca.issue9.evidence-manifest.v2",
|
||||
"issue": "jsfamily/jarca-ledger-mcp#9",
|
||||
"captured_at": "2026-10-05T08:17:17.108Z",
|
||||
"source": {
|
||||
"git_head_base": "a02aceee416c5770dfd1f35ac94367f3c4e0c8c8",
|
||||
"working_tree": true,
|
||||
"files_sha256": {
|
||||
"dashboard/app.py": "4c7b3a6264b4fb4ae0e445ddc04c625b295443816b198aa5a60615ac44846ff2",
|
||||
"dashboard/common.py": "9a0b9dbb4ca88f27757fc293173821c48a56a30bc23f16b0f24731f246411c5a",
|
||||
"scripts/streamlit_browser_evidence.py": "3cfb449ce1d6fae3abb795727d7ca1bc10dbb759bb811f121abd74fa42993dd7",
|
||||
"tests/test_dashboard_menu_transition.py": "d23c35d88f6289b7c7e84a271df6ff3f6ff1f25a3a885aa696cc93db0b37035e",
|
||||
"tests/test_streamlit_browser_evidence.py": "7f595ccf4009961225328486c19258bb0f7bc7dbfb584c60616860274112724a"
|
||||
}
|
||||
},
|
||||
"execution": {
|
||||
"browser_tool": "mcp__playwright__browser_evaluate + mcp__playwright__browser_run_code_unsafe",
|
||||
"tool_status": "success",
|
||||
"exit_code": null,
|
||||
"exit_code_note": "Playwright MCP reports success/error instead of a numeric process exit code.",
|
||||
"transition": "요약 -> 분류 규칙",
|
||||
"sampling_interval_ms": 50,
|
||||
"frame_count": 35,
|
||||
"viewport": {
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
"browser": "HeadlessChrome/154.0.0.0",
|
||||
"browser_architecture": {
|
||||
"client_hint_architecture": "arm",
|
||||
"client_hint_bitness": "64",
|
||||
"navigator_platform": "Linux x86_64"
|
||||
},
|
||||
"fixture_host_architecture": "aarch64",
|
||||
"python": "3.13.5",
|
||||
"streamlit": "1.58.0",
|
||||
"uvicorn": "0.49.0"
|
||||
},
|
||||
"checks": {
|
||||
"document_identity_stable": true,
|
||||
"sidebar_identity_stable": true,
|
||||
"app_surface_background_stable": true,
|
||||
"sidebar_surface_background_stable": true,
|
||||
"app_surface_visible_all_frames": true,
|
||||
"sidebar_surface_visible_all_frames": true,
|
||||
"surface_backgrounds_observed": true,
|
||||
"zero_full_document_loads": true,
|
||||
"loading_observed": true,
|
||||
"previous_body_absent_while_loading": true,
|
||||
"query_sibling_preserved": true,
|
||||
"final_agreement": true
|
||||
},
|
||||
"artifacts_sha256": {
|
||||
"browser-observations.json": "414b62efb2f40483aae4227896040c1b5d887b2c31333f63046e6f34aec2fe8f",
|
||||
"final-state.jpg": "cc4e1c28463f033ab1c26d1dc27c9bb93c6fc5495321c0cef73fdb1098221234"
|
||||
},
|
||||
"validator": {
|
||||
"command": "python3 -c \"import hashlib,json,math,pathlib; r=pathlib.Path('.'); e=r/'docs/evidence/issue-9-dashboard-menu-transition'; m=json.loads((e/'manifest.json').read_text()); d=json.loads((e/'browser-observations.json').read_text()); frames=d['frames']; surfaces=('stAppViewContainer','stSidebar'); assert all(hashlib.sha256((r/p).read_bytes()).hexdigest()==h for p,h in m['source']['files_sha256'].items()); assert all(hashlib.sha256((e/p).read_bytes()).hexdigest()==h for p,h in m['artifacts_sha256'].items()); assert all(d['checks'].values()) and not d['document_load_events'] and len(frames)==35; assert all(isinstance(f['previous_body_visible'],bool) for f in frames); assert any(f['loading'] and f['previous_body_visible'] is False for f in frames); valid=lambda x: isinstance(x,dict) and str(x.get('backgroundImage','')).strip().lower() not in ('','none') and bool(str(x.get('backgroundColor','')).strip()) and x.get('visibility')=='visible' and x.get('display')!='none' and math.isfinite(float(x.get('opacity','nan'))) and float(x['opacity'])>0 and x.get('rendered') is True; assert all(valid(f['surfaces'][s]) for f in frames for s in surfaces); ss=d['stable_shell']; assert all(isinstance(ss[k]['pre'],str) and ss[k]['pre'] and ss[k]['pre']==ss[k]['post'] for k in ('document_marker','sidebar_marker','background')); assert ss['surfaces']['pre']==frames[0]['surfaces'] and ss['surfaces']['post']==d['post']['surfaces'] and ss['surfaces']['pre']==ss['surfaces']['post']; print('PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=%d full_document_loads=0 surfaces=2' % len(frames))\"",
|
||||
"exit_code": 0,
|
||||
"output": "PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=35 full_document_loads=0 surfaces=2"
|
||||
},
|
||||
"limitations": [
|
||||
"Generated synthetic SQLite fixture only; no operating ledger data was used.",
|
||||
"A temporary HTTPS ingress adapter normalized the trusted request scheme for repository same-origin authentication.",
|
||||
"The ephemeral tunnel URL is expired provenance and is not a rerun target.",
|
||||
"The same-document no-load assertion is bound to the uninterrupted Playwright evaluation context, stable document marker, and empty document-load event record."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"environment": [
|
||||
"os",
|
||||
"kernel",
|
||||
"architecture",
|
||||
"python",
|
||||
"uv"
|
||||
],
|
||||
"command_env_allowlist": [
|
||||
"PATH",
|
||||
"HOME",
|
||||
"TMPDIR",
|
||||
"LANG",
|
||||
"VIRTUAL_ENV",
|
||||
"UV_CACHE_DIR"
|
||||
],
|
||||
"secret_env_names": [],
|
||||
"forbidden_regexes": [],
|
||||
"hash_paths": [
|
||||
"docs/evidence/issue-9-dashboard-menu-transition/README.md",
|
||||
"docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json",
|
||||
"docs/evidence/issue-9-dashboard-menu-transition/final-state.jpg",
|
||||
"docs/evidence/issue-9-dashboard-menu-transition/manifest.json",
|
||||
"docs/project-structure.md",
|
||||
"docs/requirements/req-002_dashboard-menu-transition.md",
|
||||
"docs/runtime-flow.md",
|
||||
"scripts/streamlit_browser_evidence.py",
|
||||
"tests/test_streamlit_browser_evidence.py"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"id": "full-suite",
|
||||
"purpose": "Run the repository full test suite at the rework source revision",
|
||||
"argv": [
|
||||
"uv",
|
||||
"run",
|
||||
"--extra",
|
||||
"dashboard",
|
||||
"--extra",
|
||||
"dev",
|
||||
"pytest"
|
||||
],
|
||||
"expected_exit_code": 0,
|
||||
"max_output_bytes": 65536,
|
||||
"claims": [
|
||||
"full-suite-result"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "browser-evidence-validator",
|
||||
"purpose": "Validate checked-in authenticated browser evidence, source and artifact hashes",
|
||||
"argv": [
|
||||
"python3",
|
||||
"-c",
|
||||
"import hashlib,json,math,pathlib; r=pathlib.Path('.'); e=r/'docs/evidence/issue-9-dashboard-menu-transition'; m=json.loads((e/'manifest.json').read_text()); d=json.loads((e/'browser-observations.json').read_text()); frames=d['frames']; surfaces=('stAppViewContainer','stSidebar'); assert all(hashlib.sha256((r/p).read_bytes()).hexdigest()==h for p,h in m['source']['files_sha256'].items()); assert all(hashlib.sha256((e/p).read_bytes()).hexdigest()==h for p,h in m['artifacts_sha256'].items()); assert all(d['checks'].values()) and not d['document_load_events'] and len(frames)==35; assert all(isinstance(f['previous_body_visible'],bool) for f in frames); assert any(f['loading'] and f['previous_body_visible'] is False for f in frames); valid=lambda x: isinstance(x,dict) and str(x.get('backgroundImage','')).strip().lower() not in ('','none') and bool(str(x.get('backgroundColor','')).strip()) and x.get('visibility')=='visible' and x.get('display')!='none' and math.isfinite(float(x.get('opacity','nan'))) and float(x['opacity'])>0 and x.get('rendered') is True; assert all(valid(f['surfaces'][s]) for f in frames for s in surfaces); ss=d['stable_shell']; assert all(isinstance(ss[k]['pre'],str) and ss[k]['pre'] and ss[k]['pre']==ss[k]['post'] for k in ('document_marker','sidebar_marker','background')); assert ss['surfaces']['pre']==frames[0]['surfaces'] and ss['surfaces']['post']==d['post']['surfaces'] and ss['surfaces']['pre']==ss['surfaces']['post']; print('PASS source_hashes=5 artifact_hashes=2 checks=12/12 frames=%d full_document_loads=0 surfaces=2' % len(frames))"
|
||||
],
|
||||
"expected_exit_code": 0,
|
||||
"max_output_bytes": 8192,
|
||||
"claims": [
|
||||
"browser-evidence-result"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "documentation-inventory",
|
||||
"purpose": "Validate canonical requirement and documentation remediation coverage",
|
||||
"argv": [
|
||||
"python3",
|
||||
"-c",
|
||||
"from pathlib import Path\nimport re\nq=Path('docs/requirements/req-002_dashboard-menu-transition.md').read_text(); p=Path('docs/project-structure.md').read_text(); f=Path('docs/runtime-flow.md').read_text()\nrb=re.split(r'(?=^### REQ-002\\.\\d{2}(?!-AC)\\b)',q,flags=re.M)[1:]; ab=re.split(r'(?=^### REQ-002\\.\\d{2}-AC01\\b)',q,flags=re.M)[1:]\nassert len(rb)==len(ab)==9 and all('**Impact**' in x for x in rb) and all('**Cases**' in x and '**State Invariants**' in x for x in ab)\nassert all(x in q for x in ('REQ-001.05-AC01','REQ-001.07-AC01','REQ-001.08-AC01','REQ-001.09-AC01'))\nassert all(x in p for x in ('dashboard/common.py','inject_app_css()','JARCA_COLORS','JARCA_PLOTLY_TEMPLATE','outside the replaceable body'))\nassert all(x in f for x in ('do not initiate or replay financial writes or external calls','explicit page user actions','no new idempotency contract'))\nprint('PASS requirements=9 impact=9 acceptance_criteria=9 cases=9 state_invariants=9 missing=0 ambiguous=0')"
|
||||
],
|
||||
"expected_exit_code": 0,
|
||||
"max_output_bytes": 8192,
|
||||
"claims": [
|
||||
"documentation-contract-result"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ruff",
|
||||
"purpose": "Run static lint checks",
|
||||
"argv": [
|
||||
"uv",
|
||||
"run",
|
||||
"--extra",
|
||||
"dashboard",
|
||||
"--extra",
|
||||
"dev",
|
||||
"ruff",
|
||||
"check",
|
||||
"dashboard",
|
||||
"tests",
|
||||
"scripts"
|
||||
],
|
||||
"expected_exit_code": 0,
|
||||
"max_output_bytes": 8192,
|
||||
"claims": [
|
||||
"lint-result"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "compile",
|
||||
"purpose": "Compile Python sources and tests",
|
||||
"argv": [
|
||||
"python3",
|
||||
"-m",
|
||||
"compileall",
|
||||
"-q",
|
||||
"dashboard",
|
||||
"tests",
|
||||
"scripts"
|
||||
],
|
||||
"expected_exit_code": 0,
|
||||
"max_output_bytes": 8192,
|
||||
"claims": [
|
||||
"compile-result"
|
||||
]
|
||||
}
|
||||
],
|
||||
"limitations": [
|
||||
"The authenticated browser transition was captured before this clean source commit from byte-identical hashed files; this manifest validates the tracked browser artifacts and their exact source hashes rather than rerunning the browser.",
|
||||
"Gitea commit status and Actions state are authoritative external readbacks and are not produced by this local manifest."
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+169
-62
@@ -1,154 +1,261 @@
|
||||
# Dashboard 관리자 인증 인터페이스 계약
|
||||
# Interface Contract — Dashboard 관리자 인증
|
||||
|
||||
## 목적과 범위
|
||||
|
||||
Streamlit Dashboard의 관리자 credential 입력, 환경 설정 판정, browser session 인증과 로그아웃 호출 규칙을 정의한다.
|
||||
Dashboard 인증 설정, login/cleanup/logout HTTP route, signed cookie와 Streamlit gate의 외부·내부 계약을 정의한다.
|
||||
|
||||
- 포함 범위: Dashboard 환경변수 credential, 로그인·로그아웃 UI, 인증 결과와 오류, 기존 page dispatch 경계
|
||||
- 제외 범위: MCP 인증, 다중 사용자, 영속 session, OIDC·SSO, TLS와 Ingress
|
||||
- 사용 주체: Dashboard 사용자, 배포 운영자, `dashboard/app.py`, Dashboard page view
|
||||
- 포함 범위: 환경변수, HTTPS form POST, cookie 발급·삭제, fail-closed gate
|
||||
- 제외 범위: MCP 인증, OAuth/OIDC, 다중 사용자, DB session/token registry, per-token revocation
|
||||
- 사용 주체: browser, Dashboard ASGI server, Streamlit app, 운영 배포
|
||||
|
||||
## 계약
|
||||
|
||||
### dashboard-auth-config — 관리자 인증 설정
|
||||
### auth-config — 서버 인증 설정
|
||||
|
||||
- 책임: 배포 secret에서 단일 관리자 credential을 fail-closed로 읽는다.
|
||||
- 책임: 현재 configured credential과 signing secret을 server-only로 제공한다.
|
||||
- 종류·프로토콜: process environment
|
||||
- 호출 대상·operation: `dashboard.authentication.load_admin_credentials`
|
||||
- 버전: 1.0
|
||||
- 호출 주체: Dashboard 인증 경계
|
||||
- 인증 방식: 해당 없음; 인증을 구성하는 입력
|
||||
- 필요 권한: Dashboard process environment를 설정할 배포 권한
|
||||
- 호출 대상·operation: `load_auth_settings()`
|
||||
- 버전: 2.0
|
||||
- 호출 주체: Dashboard server
|
||||
- 인증 방식: 없음
|
||||
- 필요 권한: process secret read
|
||||
|
||||
#### 입력
|
||||
|
||||
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `JARCA_DASHBOARD_ADMIN_ID` | 환경변수 | `string` | 필수 | 없음 | 비어 있지 않은 문자열 | 단일 관리자 ID | 예 |
|
||||
| `JARCA_DASHBOARD_ADMIN_PASSWORD` | 환경변수 | `string` | 필수 | 없음 | 비어 있지 않은 평문 password secret | 관리자 password | 예 |
|
||||
| `JARCA_DASHBOARD_ADMIN_ID` | environment | `string` | 필수 | 없음 | non-empty | configured admin identifier | 민감 |
|
||||
| `JARCA_DASHBOARD_ADMIN_PASSWORD` | environment | `string` | 필수 | 없음 | non-empty | configured credential | 민감 |
|
||||
| `JARCA_DASHBOARD_SIGNING_SECRET` | environment | `string` | 필수 | 없음 | UTF-8 32 byte 이상 | signing input | 민감 |
|
||||
|
||||
#### 출력
|
||||
|
||||
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `AdminCredentials` | process memory | `dataclass` | 성공 시 필수 | 두 비어 있지 않은 문자열 | 현재 rerun에서만 사용할 검증 값 | 예 |
|
||||
| `AuthSettings` | server memory | `object` | 성공 시 필수 | current configured values | verifier input; never serialized | 민감 |
|
||||
|
||||
#### 오류
|
||||
|
||||
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|
||||
|---|---|---|---|---|---|
|
||||
| `AUTH_CONFIGURATION_ERROR` | 설정 | 누락, 빈 값, 비문자열 또는 environment read 실패 | 설정 수정 후 가능 | 일반 설정 오류만 표시하고 Dashboard 중단 | 기존 인증 boolean 폐기, 금융 상태 무변경 |
|
||||
| `configuration_unavailable` | configuration | missing/empty/read error/short secret | 수정 후 가능 | generic unavailable | no cookie, no DB |
|
||||
|
||||
### dashboard-login — 관리자 로그인
|
||||
### login — `POST /api/auth/login`
|
||||
|
||||
- 책임: 제출 credential을 검증하고 현재 browser session의 인증 상태를 설정한다.
|
||||
- 종류·프로토콜: Streamlit form rerun
|
||||
- 호출 대상·operation: `dashboard.authentication.require_dashboard_authentication`
|
||||
- 책임: same-origin HTTPS credential을 검증하고 signed cookie를 발급한다.
|
||||
- 종류·프로토콜: HTTPS form POST
|
||||
- 호출 대상·operation: `/api/auth/login`
|
||||
- 버전: 1.0
|
||||
- 호출 주체: 미인증 Dashboard 사용자
|
||||
- 인증 방식: 관리자 ID·password의 constant-time 비교
|
||||
- 필요 권한: 없음
|
||||
- 호출 주체: browser login form
|
||||
- 인증 방식: submitted credential, server-only comparison
|
||||
- 필요 권한: same-origin HTTPS
|
||||
|
||||
#### 입력
|
||||
|
||||
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `admin_id` | Streamlit text input | `string` | 필수 | 빈 문자열 | 사용자 제출 문자열 | 관리자 ID 후보 | 예 |
|
||||
| `password` | Streamlit password input | `string` | 필수 | 빈 문자열 | 사용자 제출 문자열 | 관리자 password 후보 | 예 |
|
||||
| `page` | URL query parameter | `string` | 선택 | `요약` | 기존 Dashboard page 이름 | 인증 성공 후 이동할 page | 아니요 |
|
||||
| `admin_id` | form body | `string` | 필수 | 없음 | bounded form value | submitted ID | 민감 |
|
||||
| `admin_password` | form body | `string` | 필수 | 없음 | bounded form value | submitted password | 민감 |
|
||||
| `remember_me` | form body | `string` | 선택 | off | `on` or absent | choose 30-day TTL | 비민감 |
|
||||
| `Origin` | request header | `URL` | 필수 | 없음 | request `Host`와 scheme·hostname·effective port가 같은 HTTPS origin | CSRF origin 검증 | 비민감 |
|
||||
|
||||
#### 출력
|
||||
|
||||
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `_jarca_dashboard_authenticated` | `st.session_state` | `boolean` | 성공 후 필수 | `true` | 현재 session의 인증 여부 | 아니요 |
|
||||
| 화면 | Streamlit UI | `view` | 필수 | 로그인 또는 인증된 Dashboard | 현재 인증 상태의 사용자 화면 | 금융 화면은 인증 후 민감 |
|
||||
| 인증 event | application log | `string` | 제출 후 필수 | event와 correlation ID | 운영 진단 신호 | 아니요 |
|
||||
| `Set-Cookie` | response header | `cookie` | success | hardened auth cookie, redacted value | persistent auth | 값은 민감 |
|
||||
| `Location` | response header | `string` | success/failure | `/` or generic error path | browser navigation | 비민감 |
|
||||
|
||||
발급 token은 `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti` claim만 가진다. `sub=dashboard-admin`은 관리자 ID가 아닌 고정 비민감 role이고, token 값과 서명은 민감하므로 browser의 hardened cookie에만 저장한다. 성공한 login 시각에 `iat`와 `exp`를 한 번 고정하며 기본 TTL은 24시간, `remember_me=on`이면 30일이다. 활동, 일반 요청, rerun 또는 인증 복원은 token을 재발급하거나 두 시각을 연장하지 않는다.
|
||||
|
||||
#### 오류
|
||||
|
||||
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|
||||
|---|---|---|---|---|---|
|
||||
| `INVALID_CREDENTIALS` | 인증 | ID 또는 password 불일치 | 가능 | 최소 1초 후 동일 일반 오류 표시 | 금융 상태 무변경, credential 입력 제거 |
|
||||
| `AUTH_CONFIGURATION_ERROR` | 설정 | credential source 무효 | 설정 수정 후 가능 | 일반 설정 오류만 표시 | 인증 상태 폐기, 금융 상태 무변경 |
|
||||
| `400` | request | malformed/oversized body | 수정 후 가능 | resubmit valid form | no cookie |
|
||||
| `403` | origin | non-HTTPS or cross-origin | 수정 후 가능 | use same-origin HTTPS | no cookie |
|
||||
| `invalid` | authentication | ID/password mismatch | 가능 | generic error and delay | no cookie |
|
||||
| `unavailable` | configuration | invalid server configuration | config fix 후 | generic unavailable | no cookie |
|
||||
|
||||
### dashboard-logout — 관리자 로그아웃
|
||||
### cleanup — `GET /api/auth/cleanup`
|
||||
|
||||
- 책임: 현재 browser session의 인증 상태를 즉시 폐기한다.
|
||||
- 종류·프로토콜: Streamlit button rerun
|
||||
- 호출 대상·operation: sidebar `로그아웃`
|
||||
- 책임: invalid cookie를 matching scope로 삭제한 뒤 unauthenticated navigation을 완료한다.
|
||||
- 종류·프로토콜: HTTPS navigation GET
|
||||
- 호출 대상·operation: `/api/auth/cleanup`
|
||||
- 버전: 1.0
|
||||
- 호출 주체: 인증된 Dashboard 사용자
|
||||
- 인증 방식: 현재 session의 인증 boolean
|
||||
- 필요 권한: 인증된 session
|
||||
- 호출 주체: same-origin browser/auth gate
|
||||
- 인증 방식: same-origin HTTPS `Referer`; token validity 불필요
|
||||
- 필요 권한: same-origin HTTPS
|
||||
|
||||
#### 입력
|
||||
|
||||
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `로그아웃` | Streamlit button | `click event` | 필수 | 없음 | 단일 click | 인증 종료 요청 | 아니요 |
|
||||
| `Referer` | request header | `URL` | 필수 | 없음 | request origin과 같은 HTTPS authority | navigation origin 검증 | 비민감 |
|
||||
|
||||
#### 출력
|
||||
|
||||
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 인증 상태 | `st.session_state` | `absent` | 필수 | key 제거 | 현재 session 재인증 요구 | 아니요 |
|
||||
| 화면 | Streamlit UI | `view` | 필수 | 로그인 화면 | 로그아웃 완료 | 아니요 |
|
||||
| `Set-Cookie` | response header | `cookie` | always | same name/path/security, `Max-Age=0` | delete stale cookie | 값은 민감 |
|
||||
| `Location` | response header | `string` | success | `/` | unauthenticated page | 비민감 |
|
||||
|
||||
#### 오류
|
||||
|
||||
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|
||||
|---|---|---|---|---|---|
|
||||
| `없음` | 없음 | 없음 | 해당 없음 | 해당 없음 | 인증 boolean 제거 외 금융 상태 무변경 |
|
||||
| `403` | origin | non-HTTPS/cross-origin | 가능 | retry same-origin | no DB |
|
||||
|
||||
### logout — `POST /api/auth/logout`
|
||||
|
||||
- 책임: explicit logout으로 browser cookie를 삭제한다.
|
||||
- 종류·프로토콜: HTTPS form POST
|
||||
- 호출 대상·operation: `/api/auth/logout`
|
||||
- 버전: 1.0
|
||||
- 호출 주체: authenticated browser
|
||||
- 인증 방식: same-origin HTTPS; server-side revocation 없음
|
||||
- 필요 권한: same-origin HTTPS
|
||||
|
||||
#### 입력
|
||||
|
||||
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `none` | none | `none` | 선택 | 없음 | no credential accepted | explicit logout | 없음 |
|
||||
| `Origin` | request header | `URL` | 필수 | 없음 | request `Host`와 scheme·hostname·effective port가 같은 HTTPS origin | CSRF origin 검증 | 비민감 |
|
||||
|
||||
#### 출력
|
||||
|
||||
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `Set-Cookie` | response header | `cookie` | always | matching deletion, `Max-Age=0` | remove browser cookie | 값은 민감 |
|
||||
| `Location` | response header | `string` | success | `/` | unauthenticated page | 비민감 |
|
||||
|
||||
#### 오류
|
||||
|
||||
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|
||||
|---|---|---|---|---|---|
|
||||
| `403` | origin | non-HTTPS/cross-origin | 가능 | retry same-origin | no DB |
|
||||
|
||||
### gate — Dashboard 인증 경계
|
||||
|
||||
- 책임: config and cookie verification before any protected work.
|
||||
- 종류·프로토콜: Python/Streamlit call
|
||||
- 호출 대상·operation: `require_dashboard_authentication()`
|
||||
- 버전: 2.0
|
||||
- 호출 주체: `dashboard.app.main()`
|
||||
- 인증 방식: current config plus signed cookie
|
||||
- 필요 권한: valid token with `sub=dashboard-admin`
|
||||
|
||||
#### 입력
|
||||
|
||||
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `auth_cookie` | request cookie | `string` | success path | absent | current configuration으로 서명을 검증할 수 있고 claim이 정확히 `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti`인 token | browser proof | 값과 서명은 민감; `sub`는 비민감 |
|
||||
|
||||
#### 출력
|
||||
|
||||
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `authenticated` | server result | `bool` | always | true/false | gate decision | 비민감 |
|
||||
|
||||
#### 오류
|
||||
|
||||
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|
||||
|---|---|---|---|---|---|
|
||||
| `unauthenticated` | authentication | absent/invalid/expired/config failure | login 가능 | cleanup then show login | no protected work |
|
||||
|
||||
관리자 ID, password 또는 signing secret 변경은 server-only signing key binding을 바꾸므로 기존 token을 모두 무효화하고 cleanup으로 전이한다. DB session, token registry와 per-token revocation은 만들지 않는다.
|
||||
|
||||
## 호환성
|
||||
|
||||
- 유지할 기존 호출 방식: 인증 성공 후 기존 `page` query parameter와 Dashboard page dispatch를 유지한다.
|
||||
- 버전 변경 정책: 환경변수 이름 또는 session 계약 변경은 requirement revision과 본 문서를 함께 변경한다.
|
||||
- 폐기 정책: 두 환경변수는 대체 계약 승인 전까지 폐기하지 않는다.
|
||||
- 재시도 계약: 설정 오류는 설정 수정 후 rerun, credential 오류는 각 제출마다 독립 재시도한다.
|
||||
- 중복 처리 계약: 반복 실패는 잠금을 만들지 않으며 각 실패에 최소 1초 지연을 적용한다. 로그아웃 재시도는 금융 상태를 변경하지 않는다.
|
||||
- 순서 보장: 설정 판정 → session 판정 → 로그인 또는 로그아웃 → 인증 성공 후에만 DB 경로 취득과 page dispatch 순서를 유지한다.
|
||||
- 유지할 기존 호출 방식: authenticated `page` query parameter, page dispatch, gateway와 MCP behavior
|
||||
- 버전 변경 정책: REQ-001 revision 2.0; session-only boolean을 signed cookie로 대체
|
||||
- 폐기 정책: client-side token storage, DB-backed session, per-token revocation 없음
|
||||
- 재시도 계약: credential 실패는 최소 1초 지연; origin/request 오류는 수정 후 재시도
|
||||
- 중복 처리 계약: login은 새 token 발급 가능; cleanup/logout deletion은 idempotent
|
||||
- 순서 보장: config → cookie verify → cleanup if invalid → DB/page dispatch only if valid
|
||||
- 만료 보장: login 성공 시 고정한 `iat`와 `exp`는 활동·일반 요청·rerun·복원에서 변경하거나 연장하지 않음
|
||||
|
||||
## 예시
|
||||
|
||||
### config-example — 배포 환경변수 이름
|
||||
### example-login — 정상 login
|
||||
|
||||
- 대상 인터페이스: `dashboard-auth-config`
|
||||
- 대상 인터페이스: `login`
|
||||
|
||||
#### 호출
|
||||
|
||||
```text
|
||||
JARCA_DASHBOARD_ADMIN_ID=<secret-injected-admin-id>
|
||||
JARCA_DASHBOARD_ADMIN_PASSWORD=<secret-injected-password>
|
||||
POST /api/auth/login
|
||||
Origin: https://dashboard.example
|
||||
admin_id=<submitted>&admin_password=<submitted>&remember_me=on
|
||||
```
|
||||
|
||||
#### 결과
|
||||
|
||||
```text
|
||||
유효한 두 값: 로그인 form 제공
|
||||
누락 또는 빈 값: 일반 설정 오류와 Dashboard 차단
|
||||
303 Location: /
|
||||
Set-Cookie: __Host-jarca_dashboard_auth=<redacted>; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000
|
||||
```
|
||||
|
||||
### login-failure-example — 잘못된 credential
|
||||
### example-cleanup — invalid cookie cleanup
|
||||
|
||||
- 대상 인터페이스: `dashboard-login`
|
||||
- 대상 인터페이스: `cleanup`
|
||||
|
||||
#### 호출
|
||||
|
||||
```text
|
||||
관리자 ID와 password 제출
|
||||
GET /api/auth/cleanup
|
||||
Referer: https://dashboard.example/
|
||||
```
|
||||
|
||||
#### 결과
|
||||
|
||||
```text
|
||||
최소 1초 후 "관리자 ID 또는 비밀번호가 올바르지 않습니다."
|
||||
303 Location: /
|
||||
Set-Cookie: __Host-jarca_dashboard_auth=; Max-Age=0; Secure; HttpOnly; SameSite=Lax; Path=/
|
||||
```
|
||||
|
||||
## 검증
|
||||
|
||||
- 정상 계약: 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이 없음을 확인
|
||||
- 정상 계약: authentication/server tests와 loopback HTTPS evidence
|
||||
- 실패 계약: wrong credential, invalid configuration, cross-origin, malformed cookie tests
|
||||
- 경계 계약: expiry, rotation, oversized token, same-host different-port origin tests
|
||||
- 구현·스키마·예시 정합성: `tests/test_dashboard_auth_server.py`, `tests/test_dashboard_authentication_app.py`, `tests/test_dashboard_persistent_authentication.py`, source/document redaction search
|
||||
|
||||
## Dashboard 메뉴 전환 계약 — REQ-002
|
||||
|
||||
### dashboard-menu-transition
|
||||
|
||||
- 책임: authenticated Dashboard에서 공통 shell/background를 유지하고 선택된 page body만 교체한다.
|
||||
- 종류·프로토콜: Streamlit rerun, `st.radio` callback, query parameter
|
||||
- 호출 대상·operation: `dashboard.app.main()` → `render_sidebar_nav()` → `render_dashboard_body()`
|
||||
- 버전: 1.0
|
||||
- 호출 주체: authenticated browser
|
||||
- 인증 방식: 기존 `gate` 계약 선행; 메뉴는 인증을 우회하지 않음
|
||||
- 필요 권한: valid Dashboard auth cookie
|
||||
|
||||
#### 상태와 입력
|
||||
|
||||
정확한 menu options/order는 `월정산`, `요약`, `자산/마감`, `거래내역`, `확인필요 큐`, `미분류`, `분류 규칙`, `카테고리`, `계좌`, `Import`이다. selected value는 `st.session_state["dashboard_page"]`에 둔다. 직접 query의 `page`가 known option이면 해당 page를, absent/unknown이면 `요약`을 사용한다.
|
||||
|
||||
#### 출력·불변조건
|
||||
|
||||
- callback은 `st.query_params["page"]`만 갱신하고 `month`, `account_id`, `filter` 등 sibling key를 보존한다.
|
||||
- menu click은 document/path navigation이 아니며 stable `st.empty` body container 안에서 target renderer를 실행한다.
|
||||
- 공통 sidebar, refresh control, CSS/background는 유지하고 body만 target content로 대체한다.
|
||||
- 느린 target에는 `<target> 불러오는 중…`을 표시한 뒤 renderer 완료 시 placeholder를 제거한다.
|
||||
- renderer 예외는 body 안의 `페이지를 불러오지 못했습니다. 다시 시도해 주세요.`와 retry control로 대체하며 예외 상세를 노출하지 않는다.
|
||||
- serialized callback이 여러 번 실행되면 마지막 selection이 query와 body의 최종 상태가 된다.
|
||||
|
||||
#### 오류
|
||||
|
||||
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|
||||
|---|---|---|---|---|---|
|
||||
| `page_fallback` | request | missing/unknown `page` | 가능 | `요약` 표시 | sibling query 유지 |
|
||||
| `renderer_unavailable` | rendering | target renderer exception | 가능 | generic error의 retry 클릭 | previous body 제거, auth/shell 유지 |
|
||||
|
||||
#### 검증 경계
|
||||
|
||||
AppTest는 callback, query mapping, synthetic renderer output과 error/loading event를 검증하지만 실제 browser의 document navigation 부재, paint timing, focus/scroll, network request 또는 perceived shell continuity를 증명하지 않는다. 따라서 `REQ-002.03-AC01`은 authenticated real-browser evidence가 필수이며 AppTest는 companion evidence일 뿐이다. history/back-forward와 path URL은 계약에서 제외한다.
|
||||
|
||||
상세 acceptance target은 `docs/requirements/req-002_dashboard-menu-transition.md`의 `REQ-002.01-AC01`부터 `REQ-002.09-AC01`까지를 따른다.
|
||||
|
||||
+63
-44
@@ -1,101 +1,120 @@
|
||||
# Dashboard 관리자 인증 프로젝트 구조
|
||||
# Project Structure — Dashboard 관리자 인증
|
||||
|
||||
## 목적과 경계
|
||||
|
||||
Dashboard 진입점 앞의 인증 경계, 인증 구현, 기존 page view, test와 배포 설정의 책임 및 단방향 의존을 정의한다.
|
||||
Issue #6 인증을 기존 Dashboard 구조에 배치하고 fail-closed 경계와 server-only secret binding을 유지한다.
|
||||
|
||||
- 포함 경계: `dashboard/`, Dashboard 인증 test, 환경 예시, Compose와 인증 설계 문서
|
||||
- 제외 경계: MCP adapter·server 인증, 가계부 application·SQLite schema 변경
|
||||
- 포함 경계: `dashboard/` 인증 adapter/gate, tests, 운영 문서
|
||||
- 제외 경계: MCP transport, 금융 schema, DB migration, external auth middleware
|
||||
|
||||
## 디렉터리 구조
|
||||
|
||||
### dashboard-runtime — Dashboard runtime
|
||||
### dashboard-source — Dashboard source
|
||||
|
||||
```text
|
||||
dashboard/
|
||||
├── app.py
|
||||
├── server.py
|
||||
├── auth_server.py
|
||||
├── authentication.py
|
||||
├── app.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/server.py` | source | ASGI app 조립 | `app` | route wiring only | source review required | developer |
|
||||
| `dashboard/auth_server.py` | source | login/cleanup/logout response | `create_auth_routes()` | no DB/page imports | source review required | developer |
|
||||
| `dashboard/authentication.py` | source | config, constant-time compare, token verify, gate | `require_dashboard_authentication()` | server-only secret use | source review required | developer |
|
||||
| `dashboard/app.py` | source | auth gate first, then DB path resolution, common shell/sidebar/refresh assembly, and target body renderer dispatch | `main()` | shell/background remain outside the replaceable body; no token signing details | source review required | developer |
|
||||
| `dashboard/common.py` | source | shared Dashboard shell, menu/query transition, stable body container, loading/error/retry rendering, and app background/style | `render_sidebar_nav()`, `render_dashboard_body()`, `inject_app_css()` | no authentication transport or financial write orchestration | source review required | developer |
|
||||
| `dashboard/page_views/` | source | financial UI pages | `page_*` functions | no auth transport calls | source review required | developer |
|
||||
|
||||
### dashboard-tests — Dashboard 인증·회귀 test
|
||||
`dashboard/common.py` owns the shared visual theme through `JARCA_COLORS`, `JARCA_PLOTLY_TEMPLATE`, and `inject_app_css()`. Page renderers consume that theme but do not own or replace the authenticated shell/sidebar background.
|
||||
|
||||
### verification-tests — 인증 검증
|
||||
|
||||
```text
|
||||
tests/
|
||||
├── test_dashboard_authentication.py
|
||||
├── test_dashboard_authentication_app.py
|
||||
└── test_dashboard_app.py
|
||||
├── test_dashboard_persistent_authentication.py
|
||||
├── test_dashboard_auth_server.py
|
||||
├── test_dashboard_menu_transition.py
|
||||
├── test_streamlit_browser_evidence.py
|
||||
└── test_dashboard_authentication_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 유지 | 수동 |
|
||||
| `tests/test_dashboard_authentication.py` | test | credential compare, delay, redaction | pytest tests | test-only settings | source change paired with test | developer |
|
||||
| `tests/test_dashboard_persistent_authentication.py` | test | claims, TTL, tamper, rotation | pytest tests | no real secrets | source change paired with test | developer |
|
||||
| `tests/test_dashboard_auth_server.py` | test | HTTP routes and cookie scope | pytest tests | synthetic test credentials | source change paired with test | developer |
|
||||
| `tests/test_dashboard_menu_transition.py` | test | REQ-002 menu/query/body transition and cache-clear regression | pytest/AppTest | synthetic fixture DB; no real secrets | source change paired with test and browser evidence where required | developer |
|
||||
| `tests/test_streamlit_browser_evidence.py` | test | transition evidence manifest, redaction, TLS fixture and fail-closed validation | pytest | generated synthetic values only; no captured credential values | evidence-harness change paired with test | developer |
|
||||
| `tests/test_dashboard_authentication_app.py` | test | gate ordering and cleanup/UI | AppTest | fixture DB only | source change paired with test | developer |
|
||||
|
||||
### deployment-docs — 배포 설정과 계약 문서
|
||||
### browser-evidence — 실제 전환 관찰
|
||||
|
||||
| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `scripts/streamlit_browser_evidence.py` | verification tool | synthetic DB와 runtime 생성 인증 설정으로 실제 HTTPS Dashboard 전환의 frame/DOM/document/shell 관찰을 수집하고 redacted manifest를 검증 | `--mode transition` | 운영 DB·credential 사용 금지; 성공 증거는 browser/server teardown 뒤에만 확정 | source review and focused test required | developer |
|
||||
| `docs/evidence/issue-9-dashboard-menu-transition/` | evidence artifact | exact source hash에 결합한 authenticated browser frame, screenshot, runtime, validator 결과 | `manifest.json` | synthetic fixture only; credential/token/cookie 값 금지 | immutable after verification except complete regeneration | verification runner |
|
||||
|
||||
### deployment-docs — 운영 문서
|
||||
|
||||
```text
|
||||
.env.example
|
||||
docker-compose.yml
|
||||
docs/requirements/req-001_dashboard-administrator-authentication.md
|
||||
docs/requirements/req-002_dashboard-menu-transition.md
|
||||
docs/evidence/issue-9-dashboard-menu-transition/
|
||||
docs/interface-contract.md
|
||||
docs/runtime-flow.md
|
||||
README.md
|
||||
docs/
|
||||
├── requirements/req-001_dashboard-administrator-authentication.md
|
||||
├── interface-contract.md
|
||||
├── project-structure.md
|
||||
└── runtime-flow.md
|
||||
deploy/k8s/README.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 |
|
||||
| `docs/requirements/req-001_dashboard-administrator-authentication.md` | requirement | approved REQ-001 revision 2.0 | REQ-001 | canonical traceability | update in place | developer |
|
||||
| `docs/requirements/req-002_dashboard-menu-transition.md` | requirement | approved REQ-002 revision 1.0 | REQ-002 | canonical traceability | update in place | developer |
|
||||
| `docs/interface-contract.md` | contract | HTTP/cookie/gate contracts | contract IDs | no implementation secrets | update with API changes | developer |
|
||||
| `docs/runtime-flow.md` | design | runtime states/transitions | flow IDs | no secret values | update with flow changes | developer |
|
||||
| `README.md` | operations | local/deployment constraints | operator commands | no secret values | operational review | developer |
|
||||
| `deploy/k8s/README.md` | operations | k3s secret/TLS procedure | deployment procedure | no secret values | deployment review | developer |
|
||||
|
||||
## 의존 방향
|
||||
|
||||
| 출발 영역 | 대상 영역 | 허용 여부 | 규칙 | 공유 계약 위치 |
|
||||
|---|---|---|---|---|
|
||||
| `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.server` | `dashboard.auth_server` | 허용 | compose ASGI routes only | `docs/interface-contract.md` |
|
||||
| `dashboard.server` | `dashboard.app` | 허용 | app remains protected by gate | `docs/runtime-flow.md` |
|
||||
| `dashboard.app` | `dashboard.authentication` | 허용 | gate precedes DB/page dispatch | `docs/interface-contract.md` |
|
||||
| `dashboard.authentication` | `dashboard.page_views` | 금지 | auth module never imports financial pages | `docs/project-structure.md` |
|
||||
| `dashboard.app` | `dashboard.common` | 허용 | menu/query/body transition helpers only after auth gate | `docs/interface-contract.md` |
|
||||
| `dashboard.auth_server` | `jarca_ledger_mcp` DB | 금지 | login/cleanup/logout are DB-free | `docs/requirements/req-001_dashboard-administrator-authentication.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/`에 추가한다. |
|
||||
| 새 모듈·파일 배치 | auth route/gate changes stay in `dashboard/`; menu transition stays in existing `dashboard.app`/`dashboard.common`; no DB session module |
|
||||
| 이름·패키지 | use existing `dashboard` package and snake_case modules |
|
||||
| 소스·테스트 대응 | auth source behavior requires focused pytest/AppTest coverage; REQ-002 transition behavior requires focused pytest/AppTest targets and real-browser evidence where specified |
|
||||
| 생성 산출물 | evidence may be redacted; never commit credentials, secrets, tokens, or cookie values |
|
||||
|
||||
## 검증
|
||||
|
||||
- 구조 검사 명령:
|
||||
|
||||
```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)"
|
||||
python -m compileall -q dashboard tests scripts
|
||||
```
|
||||
|
||||
- 구조 검사 기대 결과: exit code 0이며 모든 인증 source·test 경로가 존재한다.
|
||||
- 구조 검사 기대 결과: exit 0 and no generated source outside ignored bytecode locations
|
||||
- 정적 분석 명령:
|
||||
|
||||
```text
|
||||
uv run --extra dashboard --extra dev ruff check dashboard tests
|
||||
uv run --extra dashboard --extra dev ruff check dashboard tests scripts
|
||||
```
|
||||
|
||||
- 정적 분석 기대 결과: 새 syntax·undefined-name 오류가 없다.
|
||||
- 문서와 실제 트리의 정합성: base-to-worktree 변경 파일을 본 문서의 세 영역과 대조하고 `docs/data-model.md`와 DB migration이 생성되지 않았는지 확인한다.
|
||||
- 정적 분석 기대 결과: exit 0 with no diagnostics
|
||||
- 문서와 실제 트리의 정합성: every listed source/test/document path exists; dependency table has no forbidden reverse edge; REQ-002 symbols resolve to `dashboard/app.py`, `dashboard/common.py`, and named tests
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schema": "gitea-agent.requirement/v1",
|
||||
"id": "REQ-001",
|
||||
"title": "Dashboard 관리자 인증",
|
||||
"revision": "1.0",
|
||||
"revision": "2.0",
|
||||
"status": "approved",
|
||||
"depends_on": [],
|
||||
"applies_to": ["dashboard"],
|
||||
@@ -15,295 +15,296 @@
|
||||
|
||||
## 1. 목적
|
||||
|
||||
Streamlit Dashboard의 모든 금융 데이터 조회·수정·import 기능 앞에 단일 관리자 인증 경계를 두고, 인증되지 않았거나 인증 설정이 유효하지 않은 요청을 fail-closed한다.
|
||||
Dashboard의 금융 기능 앞에 단일 fail-closed 관리자 인증 경계를 두고, 유효한 인증 cookie가 같은 browser profile의 새 연결에서 복원되도록 한다.
|
||||
|
||||
## 2. 배경
|
||||
|
||||
Dashboard는 SQLite 가계부를 직접 읽고 쓰며 거래 수정, 계좌 변경, PDF import와 import rollback을 제공한다. 기존 진입점은 인증 없이 DB 경로를 얻고 page view를 dispatch했으므로, 외부 노출 시 금융 데이터와 운영 기능을 보호할 애플리케이션 수준의 경계가 필요하다.
|
||||
Issue #1의 관리자 인증을 Issue #6에서 persistent signed cookie와 명시적 logout으로 확장한다. 기존 요구사항의 정체성, 승인 범위, Issue #1과 구현 PR #3의 추적성을 유지하며 revision 2.0으로 갱신한다.
|
||||
|
||||
## 3. 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 모든 Dashboard 페이지의 공통 관리자 로그인과 로그아웃
|
||||
- 환경변수 기반 단일 관리자 credential 검증
|
||||
- 인증 전 DB·gateway·page dispatch 차단
|
||||
- browser session 범위의 인증 상태
|
||||
- 실패 로그인 최소 지연, 일반 오류와 인증 event 관찰 가능성
|
||||
- Compose·환경 예시·운영 문서의 secret 주입 계약
|
||||
- Dashboard 전 기능 앞의 인증 gate와 인증 전 DB 차단
|
||||
- `JARCA_DASHBOARD_ADMIN_ID`, `JARCA_DASHBOARD_ADMIN_PASSWORD`, `JARCA_DASHBOARD_SIGNING_SECRET` 기반 server-only 검증
|
||||
- constant-time credential 비교, 일반 오류, 실패당 최소 1초 지연
|
||||
- same-origin HTTPS login, cleanup, logout route
|
||||
- `Secure`/`HttpOnly`/`SameSite=Lax`/`Path=/` signed cookie와 24시간·30일 absolute TTL
|
||||
- 고정된 비민감 role subject와 설정 credential에 묶인 서명 검증
|
||||
- invalid, expired, tampered, malformed, configuration-failure cookie의 cleanup 및 matching deletion
|
||||
- password, signing-secret, admin-ID 변경에 따른 기존 token 거부
|
||||
- HTTPS, proxy, Kubernetes/Compose 운영 제약과 민감정보 비노출
|
||||
|
||||
### 제외
|
||||
|
||||
- 다중 사용자, 역할 기반 권한과 사용자별 데이터 분리
|
||||
- 회원가입, 비밀번호 찾기와 credential 관리 UI
|
||||
- OIDC, SSO, 외부 IdP와 persistent cookie
|
||||
- SQLite schema와 가계부 도메인 모델 변경
|
||||
- MCP endpoint 인증, TLS, Ingress와 네트워크 경계 전체 구현
|
||||
- 다중 사용자, RBAC, OAuth/OIDC, SSO, MFA, CAPTCHA, 계정 잠금
|
||||
- 외부 인증 proxy로 책임 이전
|
||||
- sliding expiration, DB-backed session/token registry, per-token revocation
|
||||
- MCP endpoint 인증 변경, SQLite schema/data migration
|
||||
- 다른 browser profile·기기 간 로그인 공유
|
||||
|
||||
## 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`
|
||||
- **role subject**: token의 `sub`에 고정 기록하는 비민감 값 `dashboard-admin`; 관리자 ID나 credential이 아니다.
|
||||
- **server-only binding**: signing secret과 현재 설정 credential을 서버에서만 읽어 key를 파생하며 browser에 값을 보내지 않는 방식.
|
||||
- **cleanup**: 인증 실패 cookie를 같은 origin의 cleanup route에서 matching name/path/security 속성으로 삭제하는 절차.
|
||||
- **canonical 근거**: Issue #1, Issue #6, 구현 PR #3 및 #7.
|
||||
|
||||
## 5. 요구사항
|
||||
|
||||
### REQ-001.01 — 공통 인증 경계
|
||||
|
||||
Dashboard는 어떤 DB 경로 취득, page dispatch, DB-backed 렌더링, gateway 호출, upload 또는 rollback 처리보다 먼저 인증을 판정해야 한다. 인증 전에는 로그인 화면만 렌더링해야 한다.
|
||||
모든 DB path, page dispatch, gateway, upload, import보다 먼저 설정과 cookie를 검증하고, 실패하면 로그인 화면만 표시한다.
|
||||
|
||||
**영향 범위**
|
||||
|
||||
- 인터페이스: 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-auth-gate`
|
||||
- 영속성: 없음
|
||||
- 후속 사용 주체: 현재 Dashboard session
|
||||
- 후속 사용 주체: Dashboard page와 gateway
|
||||
- 마이그레이션: 없음
|
||||
- 호환성: 없음
|
||||
- 배포 전제 조건: 평문 password secret은 repository가 아닌 secret store에서 제공
|
||||
- 호환성: 인증 후 기존 page/query 동작 유지
|
||||
- 배포 전제 조건: 유효한 세 환경변수
|
||||
|
||||
### REQ-001.04 — 실패 로그인 응답
|
||||
### REQ-001.02 — 설정과 server-only binding
|
||||
|
||||
잘못된 ID 또는 password의 각 제출은 제출 처리 시작부터 일반 오류 응답 완료까지 최소 1초가 걸려야 한다. 두 실패는 동일한 오류를 표시하고 DB·gateway·상태 변경을 수행하지 않아야 한다. 성공 로그인에는 실패 지연을 적용하지 않으며 애플리케이션 수준 잠금은 두지 않는다.
|
||||
세 환경변수만 사용하고 누락·빈 값·read error·짧은 signing secret이면 fail-closed한다. signing key는 서버에서 configured credential과 secret으로만 파생한다.
|
||||
|
||||
**영향 범위**
|
||||
|
||||
- 인터페이스: 로그인 제출과 오류 응답
|
||||
- 인터페이스: `dashboard-auth-config`, `dashboard-auth-cookie`
|
||||
- 영속성: 없음
|
||||
- 후속 사용 주체: 미인증 Dashboard 사용자
|
||||
- 후속 사용 주체: 인증 verifier
|
||||
- 마이그레이션: 없음
|
||||
- 호환성: 없음
|
||||
- 배포 전제 조건: 새 session·병렬 요청 우회를 제한할 별도 배포 경계 rate limiting 권고
|
||||
- 호환성: Streamlit secrets/fallback 미사용
|
||||
- 배포 전제 조건: secret manager 주입
|
||||
|
||||
### REQ-001.05 — Session과 로그아웃
|
||||
### REQ-001.03 — Credential과 민감정보 비노출
|
||||
|
||||
인증 성공 상태는 현재 Streamlit browser session에서만 유지해야 한다. 같은 session의 rerun은 재로그인 없이 Dashboard를 사용할 수 있고, 명시적 로그아웃은 즉시 인증 상태를 폐기해야 하며 새 session은 다시 인증해야 한다.
|
||||
ID와 password는 constant-time 비교한다. credential, secret, signing material, token, raw cookie와 Set-Cookie 값은 UI, URL, log, 문서, session state에 기록하지 않는다.
|
||||
|
||||
**영향 범위**
|
||||
|
||||
- 인터페이스: `st.session_state`, 로그아웃 button
|
||||
- 영속성: server-side 영속 session 없음
|
||||
- 후속 사용 주체: 현재 browser session
|
||||
- 인터페이스: login form, event log
|
||||
- 영속성: 없음
|
||||
- 후속 사용 주체: 운영자와 감사 도구
|
||||
- 마이그레이션: 없음
|
||||
- 호환성: browser 재시작·새 session의 자동 로그인 없음
|
||||
- 배포 전제 조건: 없음
|
||||
- 호환성: 비민감 correlation ID만 유지
|
||||
- 배포 전제 조건: redacted logging
|
||||
|
||||
### REQ-001.06 — 보호 범위와 기존 기능 호환성
|
||||
### REQ-001.04 — Login 실패 계약
|
||||
|
||||
인증 성공 후 요약, 자산/마감, 거래내역, 확인필요 큐, 미분류, 분류 규칙, 카테고리, 계좌와 Import를 포함한 기존 Dashboard 페이지를 사용할 수 있어야 한다. 이 인증은 Dashboard만 보호하며 MCP endpoint가 인증됐다고 간주해서는 안 된다.
|
||||
ID 또는 password가 틀리면 동일한 일반 오류와 최소 1초 지연을 적용하고 상태를 변경하지 않는다. 성공에는 지연을 적용하지 않는다.
|
||||
|
||||
**영향 범위**
|
||||
|
||||
- 인터페이스: 모든 Dashboard page view
|
||||
- 영속성: 기존 SQLite schema와 데이터 계약 유지
|
||||
- 후속 사용 주체: 인증된 Dashboard 사용자
|
||||
- 인터페이스: `POST /api/auth/login`
|
||||
- 영속성: 없음
|
||||
- 후속 사용 주체: browser
|
||||
- 마이그레이션: 없음
|
||||
- 호환성: 인증 후 기존 Dashboard 동작 유지
|
||||
- 배포 전제 조건: MCP는 별도의 네트워크 또는 후속 인증 요구사항으로 보호
|
||||
- 호환성: 기존 login form 유지
|
||||
- 배포 전제 조건: 외부 rate limiting
|
||||
|
||||
### REQ-001.07 — 배포 설정과 운영 제약
|
||||
### REQ-001.05 — Persistent token
|
||||
|
||||
환경 예시, Compose와 운영 문서는 두 credential 환경변수의 이름·용도·fail-closed 동작을 정의하되 실제 값을 저장하지 않아야 한다. 운영 문서는 1초 지연에 애플리케이션 잠금이 없고 새 session·병렬 요청으로 우회 가능하므로 배포 경계 rate limiting이 필요함을 명시해야 한다.
|
||||
성공 login은 기본 24시간, remember-me 선택 시 30일의 absolute TTL token을 발급한다. `iat`와 `exp`는 고정하며 활동·rerun·복원으로 연장하지 않는다. `sub`는 항상 `dashboard-admin`이다.
|
||||
|
||||
**영향 범위**
|
||||
|
||||
- 인터페이스: `.env`, Compose environment와 운영 문서
|
||||
- 영속성: repository에 credential 값 저장 금지
|
||||
- 후속 사용 주체: 배포 운영자
|
||||
- 마이그레이션: 기존 Dashboard 배포에 secret 주입 필요
|
||||
- 호환성: MCP container의 설정과 포트는 변경하지 않음
|
||||
- 배포 전제 조건: secret manager 또는 동등한 안전한 환경변수 주입
|
||||
- 인터페이스: `dashboard-auth-cookie`
|
||||
- 영속성: browser cookie만
|
||||
- 후속 사용 주체: 새 Streamlit session
|
||||
- 마이그레이션: 기존 session boolean에서 cookie로 전환
|
||||
- 호환성: 같은 browser profile 복원
|
||||
- 배포 전제 조건: HTTPS
|
||||
|
||||
### REQ-001.08 — 인증 관찰 가능성
|
||||
### REQ-001.06 — Cookie 보안 속성
|
||||
|
||||
설정 오류, 로그인 성공·실패와 로그아웃은 session-local correlation ID와 event 이름만 application log에 기록해야 한다. 관리자 ID, password, verifier, 제출 값과 query parameter를 진단 속성으로 기록하지 않아야 한다.
|
||||
Cookie 이름은 `__Host-jarca_dashboard_auth`이며 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, no `Domain`을 사용한다. 일반 요청은 재발급하지 않는다.
|
||||
|
||||
**영향 범위**
|
||||
|
||||
- 인터페이스: application log
|
||||
- 영속성: correlation ID는 현재 session에만 저장
|
||||
- 후속 사용 주체: 운영자
|
||||
- 인터페이스: HTTP response와 browser
|
||||
- 영속성: browser cookie
|
||||
- 후속 사용 주체: browser/Streamlit handshake
|
||||
- 마이그레이션: 없음
|
||||
- 호환성: 기존 금융 데이터 log 계약에 영향 없음
|
||||
- 배포 전제 조건: log 수집기가 민감정보 비수집 계약을 유지
|
||||
- 호환성: host-only cookie
|
||||
- 배포 전제 조건: 공개 origin HTTPS
|
||||
|
||||
### REQ-001.07 — Invalid cookie cleanup
|
||||
|
||||
없음·malformed·oversized·tampered·expired·claim-invalid·rotation-invalid 또는 configuration-failure cookie는 보호 자원에 접근하기 전에 거부한다. 같은 origin cleanup route를 통해 동일 name/path/security 속성과 `Max-Age=0` deletion을 반환한 뒤 unauthenticated page를 표시한다.
|
||||
|
||||
**영향 범위**
|
||||
|
||||
- 인터페이스: `GET /api/auth/cleanup`, `dashboard-auth-gate`
|
||||
- 영속성: 서버 session/registry 없음
|
||||
- 후속 사용 주체: browser
|
||||
- 마이그레이션: 없음
|
||||
- 호환성: stale cookie가 남지 않음
|
||||
- 배포 전제 조건: same-origin HTTPS
|
||||
|
||||
### REQ-001.08 — Login, cleanup, logout
|
||||
|
||||
Login과 logout은 trusted same-origin HTTPS POST만 허용하고 cleanup GET은 same-origin HTTPS `Referer`를 검증한다. logout은 명시적으로 matching deletion을 반환한다. cleanup과 logout은 금융 DB를 변경하지 않는다.
|
||||
|
||||
**영향 범위**
|
||||
|
||||
- 인터페이스: `POST /api/auth/login`, `GET /api/auth/cleanup`, `POST /api/auth/logout`
|
||||
- 영속성: 없음
|
||||
- 후속 사용 주체: browser
|
||||
- 마이그레이션: 없음
|
||||
- 호환성: root redirect 유지
|
||||
- 배포 전제 조건: proxy가 Host/Origin/Proto 보존
|
||||
|
||||
### REQ-001.09 — Rotation과 무상태성
|
||||
|
||||
Token claim은 `v`, 고정 `sub`, `iat`, `exp`, `jti`만 허용한다. password, signing secret 또는 admin ID 변경은 기존 token을 거부한다. per-token revocation이나 DB session은 만들지 않는다.
|
||||
|
||||
**영향 범위**
|
||||
|
||||
- 인터페이스: token verifier
|
||||
- 영속성: revocation/session 저장소 없음
|
||||
- 후속 사용 주체: 운영 rotation 절차
|
||||
- 마이그레이션: 없음
|
||||
- 호환성: 전체 무효화는 rotation으로 수행
|
||||
- 배포 전제 조건: 모든 replica 동일 설정
|
||||
|
||||
### REQ-001.10 — 보호 범위와 운영
|
||||
|
||||
인증 후 기존 page, query parameter, gateway와 MCP 동작을 유지한다. Compose/Kubernetes는 실제 secret을 저장하지 않으며 HTTPS, forwarded-header allowlist와 rate limit을 적용한다.
|
||||
|
||||
**영향 범위**
|
||||
|
||||
- 인터페이스: Dashboard 운영 진입점
|
||||
- 영속성: 기존 SQLite만
|
||||
- 후속 사용 주체: 운영 배포
|
||||
- 마이그레이션: DB migration 없음
|
||||
- 호환성: MCP endpoint는 별도 계약
|
||||
- 배포 전제 조건: TLS와 제한된 proxy CIDR
|
||||
|
||||
### REQ-001.11 — 관찰 가능성
|
||||
|
||||
설정 오류, 인증 필요, token 수락, login 성공/실패/reject, cleanup, logout event와 비민감 correlation ID만 기록한다. 민감 값과 구체적 검증 원인은 기록하지 않는다.
|
||||
|
||||
**영향 범위**
|
||||
|
||||
- 인터페이스: event log
|
||||
- 영속성: 로그에 비민감 메타데이터만
|
||||
- 후속 사용 주체: 운영 관찰 도구
|
||||
- 마이그레이션: 없음
|
||||
- 호환성: 일반 오류 메시지 유지
|
||||
- 배포 전제 조건: redaction 정책
|
||||
|
||||
## 6. 제약과 불변조건
|
||||
|
||||
- 인증 전과 설정 오류·잘못된 credential 상태에서는 금융 DB와 gateway 상태가 변하지 않는다.
|
||||
- Credential은 환경변수에서만 읽고 repository 문서·설정·test fixture에는 test-only 값 외의 실제 값을 기록하지 않는다.
|
||||
- 인증 상태는 boolean 결과만 저장하며 credential 원문 또는 verifier를 session에 보존하지 않는다.
|
||||
- 잘못된 ID와 password는 동일한 사용자 오류와 최소 지연을 사용한다.
|
||||
- Dashboard와 MCP의 인증 경계를 혼동하지 않는다.
|
||||
- 실제 운영 DB 또는 실제 credential을 test에 사용하지 않는다.
|
||||
- `sub`는 비민감 고정 role 값이며 credential에서 파생하거나 관리자 ID를 담지 않는다.
|
||||
- credential, secret, token, cookie 값은 저장·출력·문서화하지 않는다.
|
||||
- 인증 성공 전 DB/gateway/page dispatch는 실행하지 않는다.
|
||||
- 만료는 absolute이며 활동으로 연장하지 않는다.
|
||||
- invalid cookie cleanup과 explicit logout의 deletion은 발급 cookie와 matching scope를 사용한다.
|
||||
- 서버는 per-token revocation과 DB-backed session을 유지하지 않는다.
|
||||
|
||||
## 7. 인수 기준
|
||||
|
||||
### REQ-001.01-AC01 — 미인증 진입 차단
|
||||
### REQ-001.01-AC01 — 인증 전 차단
|
||||
|
||||
**판정 기준**
|
||||
|
||||
미인증 session은 로그인 화면만 표시하며 `get_db_path()`, page dispatch, DB·gateway·upload·rollback을 호출하지 않는다.
|
||||
모든 인증 거부 경로에서 DB, gateway, page dispatch가 실행되지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_authentication_app.py`에서 인증 gate가 거부될 때 DB 경로 함수가 호출되지 않고 Dashboard 본문이 렌더링되지 않는지 검증한다.
|
||||
AppTest와 gate ordering 테스트에서 database access sentinel이 호출되지 않음을 확인한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: 통합
|
||||
- 백엔드: 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를 설정하고 로그인 화면만 존재하는지 확인한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: 통합
|
||||
- 수준: integration
|
||||
- 백엔드: Streamlit AppTest
|
||||
- 케이스: `page=Import`
|
||||
- 케이스: missing config, no cookie, invalid cookie
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Upload와 rollback 미호출
|
||||
거부 시 금융 상태와 DB는 변경되지 않는다.
|
||||
|
||||
### REQ-001.02-AC01 — 환경변수 전용 설정
|
||||
### REQ-001.02-AC01 — 설정 fail-closed
|
||||
|
||||
**판정 기준**
|
||||
|
||||
두 exact 환경변수가 있을 때만 설정을 생성하며 다른 이름이나 Streamlit secrets 값은 사용하지 않는다.
|
||||
세 설정 중 하나라도 유효하지 않으면 일반 설정 오류로 인증을 거부한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_authentication.py`에서 환경 mapping과 무관한 secrets 형태 값을 함께 제공해도 exact 환경변수 값만 선택하는지 검증한다.
|
||||
설정별 누락·빈 값·짧은 secret 테스트를 실행한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: 단위
|
||||
- 수준: unit/integration
|
||||
- 백엔드: pytest
|
||||
- 케이스: 두 값 존재, 대체 source 존재
|
||||
- 케이스: 각 환경변수 누락, 빈 값, short secret
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Credential source 고정
|
||||
cookie 발급과 보호 자원 접근이 없다.
|
||||
|
||||
### REQ-001.02-AC02 — 설정 오류 fail-closed
|
||||
### REQ-001.03-AC01 — 민감정보 비노출
|
||||
|
||||
**판정 기준**
|
||||
|
||||
누락·빈 값·읽기 오류는 동일한 일반 설정 오류로 종료되고 Dashboard 기능을 노출하지 않는다.
|
||||
credential, secret, token, cookie가 UI·URL·log·session state에 나타나지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
단위 test와 AppTest로 각 오류 조합과 로그인 input 미노출을 검증한다.
|
||||
로그·응답·session state와 문서를 redaction 검색한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: 단위·통합
|
||||
- 백엔드: pytest, Streamlit AppTest
|
||||
- 케이스: ID 누락, password 누락, 빈 값, environment read error
|
||||
- 수준: static/integration
|
||||
- 백엔드: pytest, ruff, repository search
|
||||
- 케이스: 성공·실패 login과 invalid cookie
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 인증 boolean을 제거하고 DB 미호출
|
||||
비민감 correlation ID 외 인증 비밀 상태가 없다.
|
||||
|
||||
### REQ-001.03-AC01 — Constant-time 비교
|
||||
### REQ-001.03-AC02 — 두 credential의 constant-time 비교
|
||||
|
||||
**판정 기준**
|
||||
|
||||
ID와 password 검증이 모두 `hmac.compare_digest`를 호출하며 한 필드 실패로 다른 비교를 생략하지 않는다.
|
||||
제출 ID와 password는 각각 `hmac.compare_digest`로 비교하며, 한 필드가 일치하지 않아도 다른 필드의 비교를 생략하지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
비교 함수를 계측한 단위 test에서 성공·실패 모두 두 비교가 수행되는지 확인한다.
|
||||
`tests/test_dashboard_authentication.py::test_verify_credentials_uses_constant_time_comparison_for_both_fields`에서 성공 및 두 필드가 모두 잘못된 경우에 두 비교의 호출 순서와 인자를 계측한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: 단위
|
||||
- 수준: unit
|
||||
- 백엔드: pytest monkeypatch
|
||||
- 케이스: 정상 credential, 잘못된 두 필드
|
||||
- 케이스: 정상 credential, 잘못된 ID와 password
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Password 일반 동등 비교 금지
|
||||
일반 동등 비교나 한 필드 실패에 따른 short-circuit를 사용하지 않는다.
|
||||
|
||||
### REQ-001.03-AC02 — Credential 비노출
|
||||
### REQ-001.04-AC01 — 실패 지연과 일반 오류
|
||||
|
||||
**판정 기준**
|
||||
|
||||
로그인 완료 후 제출 ID와 password가 화면·log payload·query parameter 또는 session state에 남지 않는다.
|
||||
잘못된 ID와 잘못된 password가 같은 일반 오류를 반환하고 각각 최소 1초 지연된다.
|
||||
|
||||
**검증**
|
||||
|
||||
실패·성공 AppTest의 body와 session state 표현 및 log helper 단위 test를 검사한다.
|
||||
두 실패 경로의 elapsed time과 response를 측정한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: 단위·통합
|
||||
- 백엔드: pytest, Streamlit AppTest
|
||||
- 케이스: 성공, 잘못된 ID, 잘못된 password, 설정 오류
|
||||
- 수준: integration
|
||||
- 백엔드: pytest/HTTP client
|
||||
- 케이스: wrong ID, wrong 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 무변경
|
||||
cookie·DB·gateway 상태가 변하지 않는다.
|
||||
|
||||
### REQ-001.04-AC02 — 성공 지연·잠금 없음
|
||||
|
||||
@@ -313,181 +314,213 @@ Password 일반 동등 비교 금지
|
||||
|
||||
**검증**
|
||||
|
||||
단위 test에서 실패 지연의 remaining-time 계산을 검증하고 source·session state 검사에서 failure counter·lock state가 없음을 확인한다.
|
||||
`dashboard/auth_server.py`의 login 경로와 authentication source를 검사하여 `enforce_failed_login_delay`가 credential 실패 분기에만 호출되고 failure counter·lock state가 없음을 확인한다. `tests/test_dashboard_authentication.py`의 `test_failed_login_delay_waits_only_for_remaining_minimum_duration` 및 `test_failed_login_delay_does_not_sleep_after_minimum_duration`, 그리고 `tests/test_dashboard_auth_server.py::test_successful_login_sets_hardened_24_hour_cookie`를 실행한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: 단위·정적
|
||||
- 수준: unit/integration/static
|
||||
- 백엔드: pytest, source inspection
|
||||
- 케이스: 정상 credential, 반복 실패 후 재시도
|
||||
- 케이스: 정상 credential, 실패 지연 helper, 반복 실패 후 재시도
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
애플리케이션 잠금 없음
|
||||
애플리케이션 failure counter와 lock state를 유지하지 않는다.
|
||||
|
||||
### REQ-001.05-AC01 — Session 유지와 새 session 재인증
|
||||
### REQ-001.05-AC01 — Absolute TTL과 고정 subject
|
||||
|
||||
**판정 기준**
|
||||
|
||||
로그인한 AppTest session은 rerun 후 기존 page를 유지하지만 새 AppTest session은 로그인 화면부터 시작한다.
|
||||
기본 24시간과 remember-me 30일 token의 `iat`, `exp`, `sub=dashboard-admin`이 고정된다.
|
||||
|
||||
**검증**
|
||||
|
||||
한 session의 로그인·rerun과 별도 session 생성을 연속 검증한다.
|
||||
token claim, TTL, rerun/non-sliding 테스트를 실행한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: 통합
|
||||
- 백엔드: Streamlit AppTest
|
||||
- 케이스: 동일 session rerun, 새 session
|
||||
- 수준: unit/integration
|
||||
- 백엔드: pytest
|
||||
- 케이스: normal, remember-me, activity, reconnect
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Session 간 인증 상태 격리
|
||||
복원은 token을 갱신하지 않는다.
|
||||
|
||||
### REQ-001.05-AC02 — 로그아웃
|
||||
### REQ-001.06-AC01 — Hardened cookie
|
||||
|
||||
**판정 기준**
|
||||
|
||||
로그아웃 click 직후 인증 boolean이 폐기되고 로그인 화면으로 돌아가며 Dashboard page가 표시되지 않는다.
|
||||
발급 cookie가 이름·Secure·HttpOnly·SameSite·Path·Domain 계약을 모두 만족한다.
|
||||
|
||||
**검증**
|
||||
|
||||
인증된 AppTest에서 로그아웃 button을 click하고 화면 전이를 확인한다.
|
||||
실제 HTTP `Set-Cookie`를 파싱하고 속성을 검사한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: 통합
|
||||
- 백엔드: Streamlit AppTest
|
||||
- 케이스: 인증 후 로그아웃
|
||||
- 수준: integration
|
||||
- 백엔드: Starlette TestClient와 loopback HTTPS
|
||||
- 케이스: login normal/remember
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
인증 상태 폐기, 금융 데이터 무변경
|
||||
Domain attribute가 없고 평문 전송에서 발급되지 않는다.
|
||||
|
||||
### REQ-001.06-AC01 — 인증 후 기존 페이지 호환성
|
||||
### REQ-001.07-AC01 — Invalid cookie cleanup
|
||||
|
||||
**판정 기준**
|
||||
|
||||
Synthetic SQLite fixture와 test-only credential로 인증한 뒤 기존 핵심 페이지가 예외 없이 예상 내용을 렌더링한다.
|
||||
invalid/expired/tampered/config-failure cookie는 보호 자원 전에 거부되고 same-origin cleanup route의 matching deletion 후 unauthenticated page가 표시된다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_app.py`에서 요약, 자산/마감, 분류 규칙, 확인필요 큐와 월정산을 인증 후 검증한다.
|
||||
실제 browser/HTTP 흐름에서 각 invalid cookie와 cleanup response를 확인한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: 회귀 통합
|
||||
- 수준: integration/browser
|
||||
- 백엔드: HTTPS probe와 Streamlit browser evidence
|
||||
- 케이스: malformed, expired, tampered, wrong claims, config failure
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
cleanup은 DB를 변경하지 않으며 stale cookie가 남지 않는다.
|
||||
|
||||
### REQ-001.08-AC01 — Explicit logout
|
||||
|
||||
**판정 기준**
|
||||
|
||||
logout POST가 matching `Max-Age=0` deletion을 반환하고 다음 접근이 unauthenticated다.
|
||||
|
||||
**검증**
|
||||
|
||||
login 후 logout response와 fresh request를 확인한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration/browser
|
||||
- 백엔드: HTTPS probe
|
||||
- 케이스: same-origin logout, cross-origin rejection
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
금융 DB와 token registry는 변경되지 않는다.
|
||||
|
||||
### REQ-001.09-AC01 — Rotation과 무상태성
|
||||
|
||||
**판정 기준**
|
||||
|
||||
configured credential 변경은 기존 token을 거부하며 per-token revocation/DB session 구현이 없다.
|
||||
|
||||
**검증**
|
||||
|
||||
rotation 테스트와 source/schema/diff 검색을 실행한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: unit/static
|
||||
- 백엔드: pytest와 repository search
|
||||
- 케이스: password, signing secret, admin ID rotation
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
검증은 현재 설정과 서명만 사용한다.
|
||||
|
||||
### REQ-001.10-AC01 — 운영 제약
|
||||
|
||||
**판정 기준**
|
||||
|
||||
README와 Kubernetes 문서에 HTTPS, proxy allowlist, rate limit, secret 주입, rotation과 stateless logout이 기록된다.
|
||||
|
||||
**검증**
|
||||
|
||||
문서 검색과 deployment manifest 검토를 실행한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: documentation/static
|
||||
- 백엔드: repository search
|
||||
- 케이스: README, deploy/k8s/README.md
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
실제 credential·secret 값이 문서와 manifest에 없다.
|
||||
|
||||
### REQ-001.10-AC02 — 인증 후 기존 페이지 호환성
|
||||
|
||||
**판정 기준**
|
||||
|
||||
유효한 Dashboard 인증 cookie로 인증하면 기존 page query parameter를 유지한 채 요약, 자산/마감, 분류 규칙, 확인필요 큐와 월정산 핵심 페이지가 예외 없이 예상 내용을 렌더링한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db`와 `tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state`를 실행한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: regression integration
|
||||
- 백엔드: Streamlit AppTest, synthetic SQLite
|
||||
- 케이스: 기존 핵심 page 목록
|
||||
- 케이스: 인증된 핵심 page query와 기존 gateway
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
기존 SQLite schema와 gateway contract 유지
|
||||
기존 SQLite schema, page query와 gateway contract를 변경하지 않는다.
|
||||
|
||||
### REQ-001.06-AC02 — MCP 범위 분리
|
||||
### REQ-001.10-AC03 — MCP 인증 범위 분리
|
||||
|
||||
**판정 기준**
|
||||
|
||||
Dashboard 인증 구현이 MCP server 진입점·transport·port를 변경하지 않고 운영 문서가 MCP 미보호 경계를 명시한다.
|
||||
Dashboard cookie 인증은 MCP endpoint의 인증을 의미하지 않으며 MCP server 진입점, transport와 port를 변경하지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
Base-to-worktree diff와 README를 검사한다.
|
||||
Base-to-head diff에서 MCP 인증·진입점·transport·port를 담당하는 `jarca_ledger_mcp/server.py`, `jarca_ledger_mcp/adapters/mcp/**`, `tests/test_mcp_http_runtime.py`의 불변성을 확인하고 기존 stdio, streamable HTTP와 invalid transport 회귀 test를 실행한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: 정적
|
||||
- 백엔드: Git diff, 문서 검사
|
||||
- 케이스: MCP source·Compose service
|
||||
- 수준: static/regression
|
||||
- 백엔드: Git diff, pytest
|
||||
- 케이스: MCP source, stdio, streamable HTTP, invalid transport
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
MCP 인증 상태 변경 없음
|
||||
Dashboard와 MCP 인증 경계를 결합하지 않으며 MCP 인증 상태를 변경하지 않는다.
|
||||
|
||||
### REQ-001.07-AC01 — Secret 주입 계약
|
||||
### REQ-001.11-AC01 — 안전한 관찰성
|
||||
|
||||
**판정 기준**
|
||||
|
||||
`.env.example`과 Dashboard Compose service에 두 exact 변수 이름이 있고 repository에는 실제 값이 없다.
|
||||
허용 event와 correlation ID만 기록되고 민감 값·구체적 검증 원인은 기록되지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
설정 파일 parsing과 base-to-worktree secret scan을 수행한다.
|
||||
로그 테스트와 redaction 검색을 실행한다.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: 정적
|
||||
- 백엔드: Python·YAML text validation
|
||||
- 케이스: 환경 예시, Compose Dashboard environment
|
||||
- 수준: unit/static
|
||||
- 백엔드: pytest와 repository search
|
||||
- 케이스: config, login, cleanup, logout success/failure
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
실제 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 없음
|
||||
로그에서 credential·secret·token·cookie가 복원되지 않는다.
|
||||
|
||||
## 8. 오류 및 충돌 계약
|
||||
|
||||
- 설정 오류: `관리자 인증 설정을 확인할 수 없습니다.`만 표시하고 로그인 form과 Dashboard를 노출하지 않는다.
|
||||
- Credential 오류: ID와 password 오류를 구분하지 않는 일반 오류를 최소 1초 후 표시한다.
|
||||
- Issue 또는 base drift: 구현 workflow에서 자동 merge·rebase하지 않고 중단한다.
|
||||
- 중복 로그인 제출: 각 제출을 독립적으로 검증하며 성공 후 현재 session의 인증 boolean만 유지한다.
|
||||
- 로그아웃 중복 요청: 인증 상태가 없으면 로그인 화면을 유지하고 금융 상태를 변경하지 않는다.
|
||||
동일 요청에서 설정 오류가 credential 오류보다 우선하며, 모든 인증 실패는 보호 자원 전에 종료된다. invalid cookie는 cleanup deletion을 시도하고, cleanup route가 거부되거나 실패해도 보호 자원은 열지 않는다. cross-origin/plain HTTP 요청은 403, malformed request는 400이다. 동시 login은 application lock 없이 독립 처리한다.
|
||||
|
||||
## 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`를 사용하며 두 비교를 수행한다.
|
||||
Token은 `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti`와 서명만 가지며 browser cookie에만 저장된다. 세 환경변수는 server process에서만 읽는다. 상세 HTTP, cookie, cleanup, logout 계약은 `docs/interface-contract.md`에 정의한다.
|
||||
|
||||
## 10. 하위 호환성
|
||||
|
||||
인증 후 기존 Dashboard page query parameter, SQLite schema, gateway와 화면 기능을 유지한다. Dashboard는 새 credential 설정 없이는 의도적으로 fail-closed한다. MCP server API, transport와 인증 상태는 변경하지 않는다.
|
||||
Issue #1의 Dashboard-only 범위, 기존 page/query/gateway/MCP 동작과 구현 PR #3의 추적성을 유지한다. session-only 인증은 persistent cookie로 대체하며 SQLite schema와 MCP transport는 변경하지 않는다.
|
||||
|
||||
## 11. 추적성
|
||||
|
||||
- 근거 이슈: #1
|
||||
- 구현 PR: #3
|
||||
- 관련 설계 문서: docs/interface-contract.md, docs/project-structure.md, docs/runtime-flow.md
|
||||
- 근거 이슈: `#1`, `#6`
|
||||
- 구현 PR: `#3`, `#7`
|
||||
- 관련 설계 문서: `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md`, `README.md`, `deploy/k8s/README.md`
|
||||
- 관련 요구사항: 없음
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
---
|
||||
{
|
||||
"schema": "gitea-agent.requirement/v1",
|
||||
"id": "REQ-002",
|
||||
"title": "Dashboard 메뉴 전환 시 공통 화면 연속성",
|
||||
"revision": "1.0",
|
||||
"status": "approved",
|
||||
"depends_on": ["REQ-001"],
|
||||
"applies_to": ["dashboard"],
|
||||
"language": "ko"
|
||||
}
|
||||
---
|
||||
|
||||
# REQ-002 — Dashboard 메뉴 전환 시 공통 화면 연속성
|
||||
|
||||
## 1. 목적
|
||||
|
||||
인증된 Dashboard에서 메뉴를 바꿀 때 문서 전체를 이동시키지 않고 공통 shell과 배경을 유지하며 body만 교체한다. 현재 메뉴 선택, URL 상태, 느린 렌더링, 렌더링 실패의 동작을 예측 가능하게 만든다.
|
||||
|
||||
## 2. 배경
|
||||
|
||||
Issue #9의 Dashboard 메뉴 전환 계약이다. REQ-001의 인증 경계, cookie, logout, page dispatch와 기존 금융 페이지 계약을 유지한 채 Streamlit 메뉴 전환을 연속적인 단일 화면으로 제공한다.
|
||||
|
||||
## 3. 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- Streamlit `st.radio` 기반 메뉴의 정확한 10개 label/order와 selected state
|
||||
- 메뉴 클릭 시 document navigation 없이 공통 shell/background를 유지하고 body만 교체
|
||||
- 느린 대상 page 렌더링 중 대상별 loading 표시
|
||||
- renderer 예외의 local generic error와 retry
|
||||
- 빠른 연속 선택에서 latest serialized selection wins
|
||||
- 직접 `?page=` 접근, 누락/unknown page의 `요약` fallback, sibling query key 보존
|
||||
- 기존 auth gate, cookie, logout, finance page, cache-clear 회귀 검증
|
||||
|
||||
### 제외
|
||||
|
||||
- browser history/back-forward 동작, history API 계약, path URL 설계
|
||||
- 인증 방식·cookie 속성·MCP transport·SQLite schema 변경
|
||||
- page renderer의 금융 계산·gateway 의미 변경
|
||||
- 별도 document route 또는 full-document navigation 구현
|
||||
|
||||
## 4. 용어와 기준
|
||||
|
||||
- **공통 shell**: 인증 후 `dashboard/app.py`가 제공하는 sidebar, 메뉴, refresh control 및 앱 배경/CSS.
|
||||
- **body**: 선택된 page renderer가 공통 shell 안에서 채우는 주 콘텐츠 영역.
|
||||
- **latest serialized selection**: Streamlit rerun/콜백 직렬화 순서에서 마지막으로 반영된 `dashboard_page` 선택.
|
||||
- **AppTest**: Streamlit `streamlit.testing.v1.AppTest`의 synthetic runtime 검증. DOM, 실제 browser document navigation, 실제 paint/지연 체감은 검증하지 못한다.
|
||||
- **real-browser evidence**: 실제 browser에서 authenticated session으로 메뉴를 클릭하고 URL/document/body/shell을 관찰해 남기는 증거. AppTest를 대체하지 않으며 AppTest만으로 충족할 수 없는 browser-level 항목에 필수다.
|
||||
|
||||
## 5. 요구사항
|
||||
|
||||
### REQ-002.01 — 메뉴 정체성
|
||||
|
||||
메뉴는 정확히 다음 순서와 label을 유지한다: `월정산`, `요약`, `자산/마감`, `거래내역`, `확인필요 큐`, `미분류`, `분류 규칙`, `카테고리`, `계좌`, `Import`. selected state는 현재 page와 일치한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: `dashboard_page` radio and `page` query parameter
|
||||
- Persistence: none
|
||||
- Consumers: dashboard page renderers
|
||||
- Migration: none
|
||||
- Compatibility: existing labels, order, and selected-state contract
|
||||
- Deployment prerequisites: none
|
||||
|
||||
### REQ-002.02 — URL과 직접 접근
|
||||
|
||||
메뉴 선택은 query의 `page`만 대상 label로 갱신하고 `month`, `account_id`, `filter` 등 sibling query key를 보존한다. 직접 `?page=<known>`은 해당 page를 렌더링하고, missing/unknown 값은 `요약`으로 fallback한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: browser query parameters
|
||||
- Persistence: none
|
||||
- Consumers: direct links and page renderers
|
||||
- Migration: none
|
||||
- Compatibility: sibling query keys and direct page access
|
||||
- Deployment prerequisites: none
|
||||
|
||||
### REQ-002.03 — 연속 화면
|
||||
|
||||
메뉴 클릭은 document navigation을 일으키지 않는다. 공통 shell과 background는 유지하고 body만 선택된 renderer의 내용으로 교체한다. history/back-forward와 path URL은 이 요구사항의 판정 범위가 아니다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: Streamlit shell/body rendering
|
||||
- Persistence: none
|
||||
- Consumers: authenticated dashboard users
|
||||
- Migration: none
|
||||
- Compatibility: existing shell, background, and path behavior
|
||||
- Deployment prerequisites: none
|
||||
|
||||
### REQ-002.04 — 전환 중 표시
|
||||
|
||||
느린 renderer가 실행되는 동안 target page label을 포함한 `불러오는 중…` loading을 대상 body 영역에 표시하고, renderer 완료 후 loading placeholder를 제거한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: target body loading state
|
||||
- Persistence: none
|
||||
- Consumers: dashboard users
|
||||
- Migration: none
|
||||
- Compatibility: existing renderer output after completion
|
||||
- Deployment prerequisites: none
|
||||
|
||||
### REQ-002.05 — local failure와 retry
|
||||
|
||||
target renderer 예외는 공통 shell을 무너뜨리지 않고 body 영역을 generic error와 `다시 시도` control로 교체한다. 예외 상세·secret은 UI에 노출하지 않는다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: body error and retry control
|
||||
- Persistence: none
|
||||
- Consumers: dashboard users and support operators
|
||||
- Migration: none
|
||||
- Compatibility: shell remains available; secrets remain redacted
|
||||
- Deployment prerequisites: none
|
||||
|
||||
### REQ-002.06 — 최신 선택 우선
|
||||
|
||||
연속 메뉴 선택은 serialized callback 순서의 마지막 선택을 query와 body에 반영하며 이전 선택이 마지막 상태를 덮어쓰지 않는다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: serialized menu callback and query state
|
||||
- Persistence: none
|
||||
- Consumers: page selection state
|
||||
- Migration: none
|
||||
- Compatibility: latest-selection ordering contract
|
||||
- Deployment prerequisites: none
|
||||
|
||||
### REQ-002.07 — 인증 경계 보존
|
||||
|
||||
모든 menu/page dispatch는 REQ-001 gate와 cookie 검증 뒤에만 실행된다. no-cookie/invalid-cookie는 page body에 도달하지 않으며 valid cookie는 기존 Dashboard에 접근한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: REQ-001 authentication gate and cookie verification
|
||||
- Persistence: none
|
||||
- Consumers: authenticated dashboard dispatch
|
||||
- Migration: none
|
||||
- Compatibility: REQ-001 expiry, rotation, cleanup, and logout behavior
|
||||
- Deployment prerequisites: existing REQ-001 settings
|
||||
|
||||
### REQ-002.08 — 금융 page 회귀 없음
|
||||
|
||||
요약, 자산/마감, 분류 규칙, 확인필요 큐, 월정산의 기존 synthetic fixture DB 핵심 내용과 기존 page query 계약을 유지한다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: existing page/query contracts
|
||||
- Persistence: existing synthetic fixture DB reads and writes
|
||||
- Consumers: financial page renderers
|
||||
- Migration: none
|
||||
- Compatibility: existing financial calculations and gateway semantics
|
||||
- Deployment prerequisites: existing fixture and page setup
|
||||
|
||||
### REQ-002.09 — 운영 control 회귀 없음
|
||||
|
||||
refresh control은 기존 `clear_cache` callable 계약을 유지하고, 메뉴 전환은 cache-clear 동작을 변경하지 않는다.
|
||||
|
||||
**Impact**
|
||||
|
||||
- Interfaces: `clear_cache` callable and refresh control
|
||||
- Persistence: none
|
||||
- Consumers: dashboard operators
|
||||
- Migration: none
|
||||
- Compatibility: existing cache-clear behavior
|
||||
- Deployment prerequisites: none
|
||||
|
||||
## 6. 제약과 불변조건
|
||||
|
||||
- `dashboard/app.py`의 auth gate와 page dispatch는 유지하며 인증 전에 DB path를 resolve하지 않는다.
|
||||
- 메뉴 클릭은 full document reload 또는 path navigation을 추가하지 않는다.
|
||||
- body 교체는 stable `st.empty` container 안에서 수행하며 공통 shell/background를 삭제하지 않는다.
|
||||
- loading/error/retry는 target body에 국소적으로 표시하고 generic error 외 renderer exception 내용을 공개하지 않는다.
|
||||
- query update는 `page` key만 변경하며 sibling page-specific key를 삭제하거나 정규화하지 않는다.
|
||||
- REQ-001의 auth/cookie/logout, financial gateway/page, cache-clear, MCP 및 SQLite semantics는 변경하지 않는다.
|
||||
|
||||
## 7. 인수 기준
|
||||
|
||||
각 기준은 하나의 명시된 test/evidence target으로 판정한다. AppTest는 synthetic runtime 판정이며 real-browser evidence가 필요한 기준을 대신하지 않는다.
|
||||
|
||||
### REQ-002.01-AC01 — label/order/selected
|
||||
|
||||
**Cases**: positive, unchanged-state
|
||||
|
||||
**State Invariants**: labels/order and selected state remain exact.
|
||||
|
||||
**판정 기준**
|
||||
|
||||
정확한 10개 label과 순서가 radio options로 전달되고 `dashboard_page` selected state가 유지된다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_menu_transition.py::test_dashboard_menu_labels_and_order_are_preserved`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: unit
|
||||
- 백엔드: pytest monkeypatch
|
||||
- 증거: radio options, key, label visibility assertion
|
||||
|
||||
### REQ-002.02-AC01 — query와 fallback
|
||||
|
||||
**Cases**: positive, boundary, unchanged-state
|
||||
|
||||
**State Invariants**: only `page` changes; sibling query keys remain unchanged.
|
||||
|
||||
**판정 기준**
|
||||
|
||||
callback은 `page`만 갱신하고 sibling key를 보존하며, missing/unknown은 `요약`, known direct query는 요청 page를 선택한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_menu_transition.py::test_sidebar_selection_updates_only_page_query_parameter`, `tests/test_dashboard_menu_transition.py::test_missing_or_unknown_page_falls_back_to_overview`, `tests/test_dashboard_menu_transition.py::test_direct_page_query_access_renders_requested_page`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: unit/integration
|
||||
- 백엔드: pytest monkeypatch
|
||||
- 증거: exact query mapping and selected renderer assertion
|
||||
|
||||
### REQ-002.03-AC01 — body-only transition
|
||||
|
||||
**Cases**: positive, unchanged-state
|
||||
|
||||
**State Invariants**: document, shell, and background remain stable; body changes only.
|
||||
|
||||
**판정 기준**
|
||||
|
||||
실제 browser에서 authenticated menu click 후 document URL path와 full-document identity가 바뀌지 않고 공통 sidebar/background가 남으며 target body만 바뀐다. history/back-forward와 path URL은 판정하지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
Required real-browser evidence: `docs/evidence/issue-9-dashboard-menu-transition/`의 authenticated `요약` → `분류 규칙` native click 연속 frame, screenshot, DOM, URL, document/sidebar identity와 background 관찰. `manifest.json`은 정확한 working-tree file hash, runtime viewport·interval·환경·제약, artifact hash 및 validator exit code를 기록한다. 이 실행은 local Chrome 부재 때문에 Playwright MCP browser runner로 수집했으며, 재현 가능한 local fail-closed harness는 `scripts/streamlit_browser_evidence.py --mode transition`과 `tests/test_streamlit_browser_evidence.py`로 유지한다. AppTest companion: `tests/test_dashboard_menu_transition.py::test_streamlit_menu_click_updates_body_and_preserves_page_query_siblings`.
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: browser evidence + integration companion
|
||||
- 백엔드: real browser, Streamlit AppTest
|
||||
- 한계: AppTest는 실제 document navigation, browser paint, focus/scroll, network request, perceived continuity를 증명하지 못한다.
|
||||
|
||||
### REQ-002.04-AC01 — target loading
|
||||
|
||||
**Cases**: positive, boundary
|
||||
|
||||
**State Invariants**: loading appears before completion and is removed after rendering.
|
||||
|
||||
**판정 기준**
|
||||
|
||||
slow renderer 동안 target label loading이 먼저 표시되고 완료 후 placeholder가 제거된다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_menu_transition.py::test_slow_transition_clears_previous_body_and_shows_target_loading`; real-browser frames: `docs/evidence/issue-9-dashboard-menu-transition/browser-observations.json`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: unit + browser evidence
|
||||
- 백엔드: pytest fake Streamlit placeholder, authenticated Playwright browser
|
||||
- 증거: ordered event list와 50 ms 연속 frame에서 `loading=true`, `previous_body_visible=false`, 최종 `loading=false`
|
||||
- 한계: browser evidence는 실제 authenticated transition을 50 ms 간격으로 표본화했으며 loading은 2개 frame에서 관찰됐다. 표본 사이의 모든 순간을 보장하지 않는다.
|
||||
|
||||
### REQ-002.05-AC01 — local error/retry
|
||||
|
||||
**Cases**: negative, positive
|
||||
|
||||
**State Invariants**: shell remains intact; renderer details and secrets are not exposed.
|
||||
|
||||
**판정 기준**
|
||||
|
||||
renderer 예외가 generic error/retry body로 국소 대체되고 예외 상세가 노출되지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_menu_transition.py::test_body_error_is_local_and_retry_rerenders_target`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: unit
|
||||
- 백엔드: pytest monkeypatch
|
||||
- 증거: error text, retry event, secret-detail absence assertion
|
||||
|
||||
### REQ-002.06-AC01 — latest selection
|
||||
|
||||
**Cases**: concurrency, unchanged-state
|
||||
|
||||
**State Invariants**: latest serialized selection is final.
|
||||
|
||||
**판정 기준**
|
||||
|
||||
연속 선택 `거래내역` → `자산/마감` → `Import` 후 query가 마지막 `Import`이고 sibling key가 보존된다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_menu_transition.py::test_rapid_menu_clicks_commit_latest_selection_only`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: unit
|
||||
- 백엔드: pytest monkeypatch
|
||||
- 증거: final query mapping
|
||||
|
||||
### REQ-002.07-AC01 — auth regression
|
||||
|
||||
**Cases**: negative, positive
|
||||
|
||||
**State Invariants**: auth gate precedes DB/page dispatch; cookie semantics remain REQ-001.
|
||||
|
||||
**판정 기준**
|
||||
|
||||
valid cookie가 기존 Dashboard를 열고 cookie가 session state에 저장되지 않으며 gate rejection은 DB/page dispatch 전에 종료된다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state`, `tests/test_dashboard_authentication_app.py::test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration regression
|
||||
- 백엔드: Streamlit AppTest, synthetic fixture DB
|
||||
- 증거: authenticated body and pre-gate forbidden DB sentinel
|
||||
|
||||
### REQ-002.08-AC01 — finance page regression
|
||||
|
||||
**Cases**: positive, unchanged-state
|
||||
|
||||
**State Invariants**: existing financial page results and query contracts remain unchanged.
|
||||
|
||||
**판정 기준**
|
||||
|
||||
기존 핵심 금융 page의 expected text와 synthetic fixture DB 결과가 유지된다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: regression integration
|
||||
- 백엔드: Streamlit AppTest, synthetic SQLite
|
||||
- 증거: page expectation and metric assertions
|
||||
|
||||
### REQ-002.09-AC01 — cache-clear regression
|
||||
|
||||
**Cases**: positive, unchanged-state
|
||||
|
||||
**State Invariants**: `clear_cache` remains callable and menu transition does not alter it.
|
||||
|
||||
**판정 기준**
|
||||
|
||||
refresh control이 callable `clear_cache` 계약을 유지한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_dashboard_menu_transition.py::test_refresh_control_preserves_cache_clear_contract`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: regression unit
|
||||
- 백엔드: pytest
|
||||
- 증거: callable/name assertion
|
||||
|
||||
### Acceptance Criteria canonical fields
|
||||
|
||||
Each Acceptance Criterion has explicit `Cases` and `State Invariants` in addition to the existing Korean verification text.
|
||||
|
||||
| Acceptance Criterion | Cases | State Invariants |
|
||||
|---|---|---|
|
||||
| `REQ-002.01-AC01` | positive, unchanged-state | labels/order and selected state remain exact |
|
||||
| `REQ-002.02-AC01` | positive, boundary, unchanged-state | only `page` changes; sibling query keys remain unchanged |
|
||||
| `REQ-002.03-AC01` | positive, unchanged-state | document, shell, and background remain stable; body changes only |
|
||||
| `REQ-002.04-AC01` | positive, boundary | loading appears before completion and is removed after rendering |
|
||||
| `REQ-002.05-AC01` | negative, positive | shell remains intact; renderer details and secrets are not exposed |
|
||||
| `REQ-002.06-AC01` | concurrency, unchanged-state | latest serialized selection is final |
|
||||
| `REQ-002.07-AC01` | negative, positive | auth gate precedes DB/page dispatch; cookie semantics remain REQ-001 |
|
||||
| `REQ-002.08-AC01` | positive, unchanged-state | existing financial page results and query contracts remain unchanged |
|
||||
| `REQ-002.09-AC01` | positive, unchanged-state | `clear_cache` remains callable and menu transition does not alter it |
|
||||
|
||||
## 8. 오류 및 충돌 계약
|
||||
|
||||
인증 실패는 항상 REQ-001 gate가 우선하며 메뉴·query로 우회할 수 없다. renderer 실패는 해당 body만 generic error/retry로 대체한다. 빠른 선택은 Streamlit serialized callback 순서를 따르며 마지막 serialized selection이 최종 상태다. history/back-forward와 path URL은 요구하지 않는다.
|
||||
|
||||
## 9. 데이터 및 인터페이스 계약
|
||||
|
||||
- `dashboard_page` is the canonical selected menu field; `page` is the only query field written by menu selection.
|
||||
- `month`, `account_id`, `filter`, and other sibling query keys are preserved verbatim.
|
||||
- Body loading, error, retry, and renderer output are local to the stable body container; shell/background are not replaced.
|
||||
|
||||
## 10. 하위호환성
|
||||
|
||||
- Authentication expiry, rotation, invalid-cookie cleanup, and explicit logout remain governed by `REQ-001.05-AC01`, `REQ-001.07-AC01`, `REQ-001.08-AC01`, and `REQ-001.09-AC01`.
|
||||
- Existing exact tests remain the authority: `tests/test_dashboard_persistent_authentication.py::test_token_expiry_is_absolute_and_not_sliding`, `tests/test_dashboard_persistent_authentication.py::test_tampering_and_rotations_invalidate_token`, and `tests/test_dashboard_auth_server.py::test_logout_clears_cookie_with_matching_scope`.
|
||||
- Menu/query reruns/rendering do not initiate or replay financial writes or external calls. Financial writes and external calls remain behind explicit page user actions; this adds no new idempotency contract.
|
||||
|
||||
## 11. 검증 계획
|
||||
|
||||
- Menu transition tests and required browser evidence verify REQ-002 criteria; REQ-001 authentication tests above verify expiry, rotation, and logout regressions.
|
||||
|
||||
### Test Traceability Contract
|
||||
|
||||
Test definitions reference the exact Acceptance Criterion IDs and execution evidence records revision, commit, environment, and result separately.
|
||||
|
||||
## 12. 추적성
|
||||
|
||||
- 근거 이슈: `#9`
|
||||
- 구현 PR: `#10`
|
||||
- 관련 설계 문서: `docs/project-structure.md`, `docs/runtime-flow.md`
|
||||
- 관련 requirement: `REQ-001`
|
||||
- 구현 진입점: `dashboard/app.py::main`, `dashboard/common.py::render_sidebar_nav`, `dashboard/common.py::validate_dashboard_page`, `dashboard/common.py::render_dashboard_body`, `dashboard/common.py::_sync_dashboard_page_query`
|
||||
- 검증 파일: `tests/test_dashboard_menu_transition.py`, `tests/test_streamlit_browser_evidence.py`, `tests/test_dashboard_app.py`, `tests/test_dashboard_authentication_app.py`
|
||||
- browser evidence 도구: `scripts/streamlit_browser_evidence.py --mode transition`
|
||||
|
||||
## 13. 확정된 결정
|
||||
|
||||
- Keep the authenticated shell and background stable while replacing only the body.
|
||||
- Preserve REQ-001 authentication semantics and keep financial writes/external calls behind explicit page actions.
|
||||
|
||||
## 14. Open Questions
|
||||
|
||||
- 없음
|
||||
|
||||
## 15. 승인 기록
|
||||
|
||||
- 상태: `approved`
|
||||
- 승인 주체: existing requirement approval
|
||||
- 승인 근거: `REQ-002` revision `1.0`
|
||||
+108
-54
@@ -1,91 +1,145 @@
|
||||
# Dashboard 관리자 인증 런타임 흐름
|
||||
# Runtime Flow — Dashboard 관리자 인증
|
||||
|
||||
## 목적과 범위
|
||||
|
||||
Streamlit session 시작부터 설정 판정, 로그인 성공·실패, 인증된 page dispatch와 로그아웃까지의 실행 순서와 실패 복구 계약을 정의한다.
|
||||
ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit logout까지의 실행 순서와 상태 계약을 정의한다.
|
||||
|
||||
- 포함 범위: credential 환경변수, login form, 1초 실패 지연, session 인증, logout, 인증 event
|
||||
- 제외 범위: MCP 요청, 영속 session, application lock, 외부 rate limiter 구현
|
||||
- 시작 조건: Streamlit이 `dashboard/app.py`를 새로 실행하거나 rerun함
|
||||
- 성공 종료 상태: 인증된 기존 Dashboard page가 렌더링되거나 로그아웃 후 로그인 화면이 렌더링됨
|
||||
- 실패 종료 상태: 안전한 설정·credential 오류를 표시하고 DB·gateway를 호출하지 않은 미인증 상태
|
||||
- 포함 범위: config, HTTPS routes, cookie verification, Streamlit gate, cleanup/deletion
|
||||
- 제외 범위: MCP request authentication, DB session/token registry, per-token revocation
|
||||
- 시작 조건: trusted ASGI process receives request
|
||||
- 성공 종료 상태: authenticated Dashboard page executes
|
||||
- 실패 종료 상태: matching cookie deletion and unauthenticated login page
|
||||
|
||||
## 흐름
|
||||
|
||||
### dashboard-entry — Session 시작과 인증 경계
|
||||
### startup — ASGI startup
|
||||
|
||||
- 시작 이벤트: 새 browser session 또는 Streamlit rerun
|
||||
- 선행조건: Dashboard process가 시작돼 있음
|
||||
- 실행 방식: 동기
|
||||
- 트랜잭션·영속성 경계: 인증 상태는 현재 `st.session_state`에만 존재하고 SQLite transaction을 열지 않음
|
||||
- 시작 이벤트: process startup
|
||||
- 선행조건: deployment injects configuration without recording values
|
||||
- 실행 방식: synchronous startup/request handling
|
||||
- 트랜잭션·영속성 경계: no auth persistence; existing financial SQLite is untouched
|
||||
|
||||
| 단계 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` |
|
||||
| `startup-01` | Uvicorn | process command | load `dashboard.server:app` | process memory only | none | `startup-02` | `startup-fail` |
|
||||
| `startup-02` | ASGI app | app loaded | mount auth routes and Streamlit runtime | route table only | none | `gate-01` | `startup-fail` |
|
||||
|
||||
### dashboard-login — Login 제출
|
||||
### login — Credential login
|
||||
|
||||
- 시작 이벤트: 미인증 사용자의 로그인 form 제출
|
||||
- 선행조건: 유효한 관리자 환경 설정과 로그인 화면
|
||||
- 실행 방식: 동기
|
||||
- 트랜잭션·영속성 경계: 금융 DB transaction 없음; 성공 시 session boolean만 기록
|
||||
- 시작 이벤트: same-origin HTTPS form POST
|
||||
- 선행조건: valid trusted scheme, origin and bounded body
|
||||
- 실행 방식: synchronous request
|
||||
- 트랜잭션·영속성 경계: no DB transaction; cookie is browser state
|
||||
|
||||
| 단계 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` |
|
||||
| `login-01` | auth server | POST `/api/auth/login` | validate scheme/origin/body | request only | none | `login-02` | `login-reject` |
|
||||
| `login-02` | authentication | validated form | load three settings, fail closed | server memory | secret provider/environment | `login-03` | `config-fail` |
|
||||
| `login-03` | authentication | configured settings | constant-time compare ID/password | no persistent write | none | `login-04` | `login-fail` |
|
||||
| `login-04` | authentication | valid credential | issue exactly `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti`; fix `iat`/`exp` at login success for default 24 hours or `remember_me=on` 30 days | server memory | none | `login-05` | `login-fail` |
|
||||
| `login-05` | auth server | signed token | set hardened cookie with selected absolute expiry and redirect; later activity never reissues or extends it | browser cookie write | response | `gate-01` | `login-fail` |
|
||||
|
||||
### dashboard-logout — Logout
|
||||
### restore — Existing cookie restoration
|
||||
|
||||
- 시작 이벤트: 인증된 사용자의 sidebar 로그아웃 click
|
||||
- 선행조건: 현재 session 인증 boolean true
|
||||
- 실행 방식: 동기
|
||||
- 트랜잭션·영속성 경계: 인증 boolean만 제거하며 금융 DB를 변경하지 않음
|
||||
- 시작 이벤트: new request/session with browser cookie
|
||||
- 선행조건: cookie present or absent; current configuration available
|
||||
- 실행 방식: synchronous gate
|
||||
- 트랜잭션·영속성 경계: no server-side session; financial DB begins only after gate success
|
||||
|
||||
| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `LOGOUT-01` | 인증 모듈 | 로그아웃 click | 인증 boolean 제거와 logout event 기록 | session 인증 상태 삭제 | application log | `LOGOUT-02` | `UNAUTHENTICATED` |
|
||||
| `LOGOUT-02` | Streamlit | 인증 상태 없음 | rerun | 금융 상태 무변경 | 없음 | `ENTRY-01` | `UNAUTHENTICATED` |
|
||||
| `gate-01` | dashboard app | page startup | call authentication before DB path/page dispatch | no DB access | none | `gate-02` | `unauthenticated` |
|
||||
| `gate-02` | authentication | current config and cookie | verify signature, exact `v`/`sub=dashboard-admin`/`iat`/`exp`/`jti` claims, current admin-ID/password/signing-secret binding and absolute `exp`; never refresh or extend on restoration, rerun or activity | no session or token write | none | `dashboard-01` | `cleanup-01` |
|
||||
| `dashboard-01` | dashboard app | accepted token | resolve DB path and dispatch requested page | existing SQLite reads/writes | gateway/page code | `authenticated` | `dashboard-fail` |
|
||||
|
||||
### cleanup — Invalid cookie cleanup
|
||||
|
||||
- 시작 이벤트: gate rejects malformed, expired, tampered, claim-invalid, or config-failure cookie
|
||||
- 선행조건: same-origin HTTPS cleanup request
|
||||
- 실행 방식: synchronous request
|
||||
- 트랜잭션·영속성 경계: no DB or token registry write
|
||||
|
||||
| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `cleanup-01` | browser/auth gate | invalid cookie | navigate to `GET /api/auth/cleanup` | request only | auth route | `cleanup-02` | `cleanup-reject` |
|
||||
| `cleanup-02` | auth server | same-origin HTTPS `Referer` | emit matching deletion (`Max-Age=0`) and root redirect | browser cookie deletion | response | `unauthenticated` | `cleanup-reject` |
|
||||
|
||||
### logout — Explicit logout
|
||||
|
||||
- 시작 이벤트: user submits logout form
|
||||
- 선행조건: same-origin HTTPS POST
|
||||
- 실행 방식: synchronous request
|
||||
- 트랜잭션·영속성 경계: no financial DB change and no per-token revocation
|
||||
|
||||
| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `logout-01` | browser | POST `/api/auth/logout` | validate same-origin HTTPS | request only | none | `logout-02` | `logout-reject` |
|
||||
| `logout-02` | auth server | validated request | matching deletion and root redirect | browser cookie deletion | response | `unauthenticated` | `logout-reject` |
|
||||
|
||||
## 실패와 복구
|
||||
|
||||
| 대상 단계 | 실패 조건 | 추가 쓰기 중단 | 재시도 계약 | 중복 방지 | 보상·수동 복구 | 최종 상태 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `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` |
|
||||
| `login-02` | missing/empty/read-error/short secret | yes | fix deployment config | no cookie | operator injects secret | `config-fail` |
|
||||
| `login-03` | wrong ID/password | yes | retry after generic error and 1s delay | no lock | external rate limit | `login-fail` |
|
||||
| `gate-02` | expired, malformed/tampered claims, or admin-ID/password/signing-secret rotation failure | yes | cleanup then login | matching deletion | user logs in again | `unauthenticated` |
|
||||
| `cleanup-02` | non-HTTPS/cross-origin | yes | retry same origin | deletion is idempotent | browser clears manually if needed | `cleanup-reject` |
|
||||
| `logout-02` | non-HTTPS/cross-origin | yes | retry same origin | deletion is idempotent | browser clears manually if needed | `logout-reject` |
|
||||
|
||||
### 동시성과 순서
|
||||
|
||||
- 동시 실행 허용 여부: 서로 다른 browser session은 독립적으로 실행되며 같은 session은 Streamlit rerun 순서를 따른다.
|
||||
- 잠금·직렬화 단위: 애플리케이션 인증 잠금과 실패 횟수 잠금은 없다. Session state는 browser session 단위다.
|
||||
- 순서 보장: 설정 판정과 인증 gate가 DB 경로 취득·page dispatch보다 항상 먼저 실행된다. 실패 지연 완료 후 오류 rerun이 수행된다.
|
||||
- 충돌 검출·처리: 새 session과 병렬 요청은 1초 지연을 우회할 수 있으므로 배포 경계에서 별도 rate limiting이 필요하다.
|
||||
- 동시 실행 허용 여부: independent requests allowed; no application-wide auth lock
|
||||
- 잠금·직렬화 단위: none for authentication; existing SQLite rules remain for financial writes
|
||||
- 순서 보장: config/cookie gate precedes DB path, gateway and page dispatch
|
||||
- 충돌 검출·처리: token is stateless; password/signing-secret/admin-ID rotation invalidates old claims
|
||||
- 만료 불변조건: login 성공 시 고정한 `iat`와 `exp`는 복원·rerun·활동 동안 바뀌지 않으며 token을 재발급하거나 만료를 연장하지 않음
|
||||
- rotation 불변조건: admin ID, password 또는 signing secret 변경은 이전 설정으로 발급한 모든 token을 무효화하고 `cleanup-01`로 전이
|
||||
|
||||
## 관찰 가능성
|
||||
|
||||
| 신호 종류 | 이름 | 발생 단계·조건 | 상관 식별자 | 진단 속성 | 민감정보 처리 |
|
||||
|---|---|---|---|---|---|
|
||||
| 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 제외 |
|
||||
| event log | `authentication_required` | `gate-01` rejects | non-sensitive correlation ID | event class only | no cookie/token/reason |
|
||||
| event log | `login_succeeded` | `login-05` | non-sensitive correlation ID | TTL class only | no credential/token |
|
||||
| event log | `login_failed` | `login-03` | non-sensitive correlation ID | generic failure class | no ID/password |
|
||||
| event log | `cleanup` | `cleanup-02` | non-sensitive correlation ID | generic cleanup class | no cookie header |
|
||||
| event log | `logout` | `logout-02` | non-sensitive correlation ID | operation result | no token/cookie value |
|
||||
|
||||
## 검증
|
||||
|
||||
- 정상 흐름: 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를 표시한다.
|
||||
- 정상 흐름: auth server tests plus loopback HTTPS login and fresh session restoration
|
||||
- 실패 흐름: configuration, wrong credential, malformed/tampered/expired cookie and origin rejection tests
|
||||
- 경계 흐름: TTL boundary, rotation, oversized token, same-host different-port origin
|
||||
- 동시성 흐름: independent failed submissions and no application lock; ingress rate-limit configuration review
|
||||
- 운영 진입점과 기대 결과: `uvicorn dashboard.server:app --host 0.0.0.0 --port 8501`; valid HTTPS reaches login or authenticated page, invalid cookie receives matching deletion before unauthenticated page
|
||||
|
||||
## 메뉴 전환 — REQ-002
|
||||
|
||||
인증 gate와 기존 `dashboard-01` page dispatch의 선행 순서는 변하지 않는다. valid cookie를 통과한 뒤에만 menu transition flow가 실행되며, auth 실패는 기존 cleanup/unauthenticated 흐름으로 종료된다.
|
||||
|
||||
### transition — authenticated body replacement
|
||||
|
||||
- 시작 이벤트: authenticated browser selects a value in `st.radio(key="dashboard_page")`
|
||||
- 선행조건: `gate-02` success; current page/query resolved
|
||||
- 실행 방식: Streamlit callback and rerun, no document/path navigation
|
||||
- 트랜잭션·영속성 경계: no new persistence; existing financial SQLite rules unchanged
|
||||
|
||||
| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `transition-01` | dashboard app/common | authenticated selected menu | `render_sidebar_nav` reads exact 10 options and selected state | session state `dashboard_page` | Streamlit radio callback | `transition-02` | `dashboard-fail` |
|
||||
| `transition-02` | common callback | selected `dashboard_page` | write query `page` only; preserve sibling query keys | browser query state | Streamlit query params | `transition-03` | `transition-03` |
|
||||
| `transition-03` | dashboard app | validated page | open stable `st.empty` body, show target loading, invoke target renderer | body placeholder only; existing DB reads/writes | page/gateway code | `transition-04` | `transition-error` |
|
||||
| `transition-04` | dashboard app | renderer completed | remove target loading and leave target body; shell/background remain | body replacement only | none | `authenticated` | `dashboard-fail` |
|
||||
| `transition-error` | dashboard app | renderer exception | clear partial body and show generic error/retry locally | body error state only | none | retry reruns target | `authenticated` with shell intact |
|
||||
|
||||
### transition invariants
|
||||
|
||||
- known direct `?page=` selects that page; missing/unknown selects `요약`.
|
||||
- serialized rapid selections commit the latest selection only.
|
||||
- menu transition never runs before auth and never changes cookie/logout/DB schema/MCP semantics.
|
||||
- menu/query reruns and rendering do not initiate or replay financial writes or external calls; those remain behind explicit page user actions, with no new idempotency contract.
|
||||
- history/back-forward and path URL behavior are explicitly outside this flow.
|
||||
|
||||
### transition verification boundary
|
||||
|
||||
`tests/test_dashboard_menu_transition.py` provides exact AppTest/unit evidence for labels, query preservation, fallback/direct access, loading, local error, latest selection, and cache-clear. `tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db` and `tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state` cover finance/auth regressions. AppTest is synthetic and cannot establish actual browser document continuity, paint timing, focus/scroll, or network behavior; `scripts/streamlit_browser_evidence.py --mode transition`의 authenticated HTTPS frame/DOM/document/shell evidence가 REQ-002.03-AC01에 필수다. `tests/test_streamlit_browser_evidence.py`는 이 manifest가 실제 teardown 이후에만 성공하고 document navigation, shell identity drift, 누락 관찰 또는 secret 포함 시 fail-closed하는지 검증한다.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Jarca Ledger MCP server."""
|
||||
|
||||
__version__ = "0.1.1"
|
||||
__version__ = "0.1.4"
|
||||
|
||||
+8
-5
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jarca-ledger-mcp"
|
||||
version = "0.1.1"
|
||||
version = "0.1.4"
|
||||
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -17,11 +17,14 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0.0", "ruff>=0.6.0"]
|
||||
dev = ["pytest>=8.0.0", "ruff>=0.6.0", "websockets>=15.0,<17.0"]
|
||||
dashboard = [
|
||||
"pandas>=2.0.0",
|
||||
"plotly>=5.0.0",
|
||||
"streamlit>=1.30.0",
|
||||
"streamlit>=1.58.0,<1.59.0",
|
||||
"plotly>=5.20.0",
|
||||
"pandas>=2.2.0",
|
||||
"itsdangerous>=2.2.0,<3.0",
|
||||
"starlette>=0.40.0,<2.0",
|
||||
"uvicorn>=0.30.0,<1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CI wrapper for the Streamlit dashboard HTTP smoke gate.
|
||||
"""CI wrapper for the production Dashboard ASGI HTTP smoke gate.
|
||||
|
||||
Creates a synthetic fixture DB, starts Streamlit against it, waits for HTTP 200,
|
||||
and always attempts process cleanup. Output is intentionally status-only.
|
||||
Creates a synthetic fixture DB, starts the authenticated Streamlit ASGI wrapper,
|
||||
waits for HTTP 200, and always attempts process cleanup. Output is intentionally
|
||||
status-only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -26,7 +27,7 @@ from scripts.ci_fixture_db import create_ci_fixture_db
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run Streamlit dashboard HTTP smoke against a synthetic CI fixture DB.")
|
||||
parser = argparse.ArgumentParser(description="Run the Dashboard ASGI HTTP smoke against a synthetic CI fixture DB.")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Loopback host to bind")
|
||||
parser.add_argument("--port", type=int, default=18501, help="Deterministic smoke port")
|
||||
parser.add_argument("--timeout", type=int, default=45, help="Readiness timeout in seconds")
|
||||
@@ -75,21 +76,21 @@ def main() -> int:
|
||||
fixture = create_ci_fixture_db(fixture_path)
|
||||
env = os.environ.copy()
|
||||
env["JARCA_LEDGER_DB_PATH"] = str(fixture_path)
|
||||
env["JARCA_DASHBOARD_ADMIN_ID"] = "a"
|
||||
env["JARCA_DASHBOARD_ADMIN_PASSWORD"] = "p"
|
||||
env["JARCA_DASHBOARD_SIGNING_SECRET"] = "s" * 32
|
||||
env["FORWARDED_ALLOW_IPS"] = "127.0.0.1"
|
||||
env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"streamlit",
|
||||
"run",
|
||||
"dashboard/app.py",
|
||||
"--server.address",
|
||||
"uvicorn",
|
||||
"dashboard.server:app",
|
||||
"--host",
|
||||
args.host,
|
||||
"--server.port",
|
||||
"--port",
|
||||
str(args.port),
|
||||
"--server.headless",
|
||||
"true",
|
||||
"--browser.gatherUsageStats",
|
||||
"false",
|
||||
"--proxy-headers",
|
||||
]
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise persistent-auth HTTP responses without printing secrets or tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
from streamlit.proto.BackMsg_pb2 import BackMsg
|
||||
from streamlit.proto.ClientState_pb2 import ClientState
|
||||
from streamlit.proto.ForwardMsg_pb2 import ForwardMsg
|
||||
from websockets.sync.client import connect
|
||||
|
||||
COOKIE_NAME = "__Host-jarca_dashboard_auth"
|
||||
|
||||
|
||||
def _args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base-url", default="https://127.0.0.1:18506")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _connection(parsed):
|
||||
if parsed.scheme != "https" or parsed.hostname not in {"127.0.0.1", "localhost"}:
|
||||
raise SystemExit("Evidence target must be loopback HTTPS")
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
return http.client.HTTPSConnection(parsed.hostname, parsed.port, context=context, timeout=10)
|
||||
|
||||
|
||||
def _request(parsed, method: str, path: str, *, body: bytes | None = None, headers=None):
|
||||
connection = _connection(parsed)
|
||||
connection.request(method, path, body=body, headers=headers or {})
|
||||
response = connection.getresponse()
|
||||
payload = response.read()
|
||||
result = response.status, {key.lower(): value for key, value in response.getheaders()}, payload
|
||||
connection.close()
|
||||
return result
|
||||
|
||||
|
||||
def _assert_streamlit_restore(parsed, cookie_pair: str) -> int:
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
origin = f"https://{parsed.hostname}:{parsed.port}"
|
||||
websocket_url = f"wss://{parsed.hostname}:{parsed.port}/_stcore/stream"
|
||||
with connect(
|
||||
websocket_url,
|
||||
ssl=context,
|
||||
origin=origin,
|
||||
subprotocols=["streamlit"],
|
||||
additional_headers={"Cookie": cookie_pair},
|
||||
proxy=None,
|
||||
open_timeout=30,
|
||||
) as websocket:
|
||||
back_message = BackMsg()
|
||||
back_message.rerun_script.CopyFrom(
|
||||
ClientState(query_string="page=%EC%9A%94%EC%95%BD")
|
||||
)
|
||||
websocket.send(back_message.SerializeToString())
|
||||
messages: list[str] = []
|
||||
for _ in range(100):
|
||||
raw = websocket.recv(timeout=15)
|
||||
if isinstance(raw, str):
|
||||
continue
|
||||
forward_message = ForwardMsg()
|
||||
forward_message.ParseFromString(raw)
|
||||
messages.append(str(forward_message))
|
||||
if forward_message.HasField("script_finished"):
|
||||
break
|
||||
combined = "\n".join(messages)
|
||||
if "이번 달 수입/지출 현황" not in combined or "관리자 로그인" in combined:
|
||||
raise SystemExit("Signed cookie did not restore the authenticated Streamlit page")
|
||||
return len(messages)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parsed = urlsplit(_args().base_url)
|
||||
admin_id = os.environ["JARCA_DASHBOARD_ADMIN_ID"]
|
||||
password = os.environ["JARCA_DASHBOARD_ADMIN_PASSWORD"]
|
||||
origin = f"https://{parsed.hostname}:{parsed.port}"
|
||||
|
||||
body = urlencode(
|
||||
{"admin_id": admin_id, "admin_password": password, "remember_me": "on"}
|
||||
).encode()
|
||||
status, headers, _ = _request(
|
||||
parsed,
|
||||
"POST",
|
||||
"/api/auth/login",
|
||||
body=body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Content-Length": str(len(body)),
|
||||
"Origin": origin,
|
||||
},
|
||||
)
|
||||
cookie_header = headers.get("set-cookie", "")
|
||||
required = [COOKIE_NAME + "=", "HttpOnly", "Secure", "SameSite=lax", "Path=/", "Max-Age=2592000"]
|
||||
if status != 303 or headers.get("location") != "/" or not all(
|
||||
marker in cookie_header for marker in required
|
||||
):
|
||||
raise SystemExit("Login response did not satisfy the hardened cookie contract")
|
||||
cookie_pair = cookie_header.split(";", 1)[0]
|
||||
|
||||
status, _, _ = _request(
|
||||
parsed,
|
||||
"GET",
|
||||
"/",
|
||||
headers={"Cookie": cookie_pair},
|
||||
)
|
||||
if status != 200:
|
||||
raise SystemExit("Authenticated reconnect request failed")
|
||||
websocket_messages = _assert_streamlit_restore(parsed, cookie_pair)
|
||||
|
||||
status, headers, _ = _request(
|
||||
parsed,
|
||||
"POST",
|
||||
"/api/auth/logout",
|
||||
headers={"Origin": origin, "Cookie": cookie_pair, "Content-Length": "0"},
|
||||
)
|
||||
deletion = headers.get("set-cookie", "")
|
||||
if status != 303 or not all(
|
||||
marker in deletion
|
||||
for marker in [COOKIE_NAME + "=", "HttpOnly", "Secure", "SameSite=lax", "Path=/", "Max-Age=0"]
|
||||
):
|
||||
raise SystemExit("Logout response did not satisfy the cookie deletion contract")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"transport": "loopback HTTPS",
|
||||
"login_status": 303,
|
||||
"reconnect_status": 200,
|
||||
"streamlit_websocket_authenticated": True,
|
||||
"streamlit_messages": websocket_messages,
|
||||
"logout_status": 303,
|
||||
"cookie_name": COOKIE_NAME,
|
||||
"cookie_value": "[REDACTED]",
|
||||
"remember_me_max_age": 2592000,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -14,10 +14,16 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import hashlib
|
||||
import math
|
||||
import secrets
|
||||
import ssl
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
@@ -44,6 +50,13 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--output-dir", default="", help="Evidence output directory. Defaults under docs/batches/BAT-004/evidence/run-<timestamp>.")
|
||||
parser.add_argument("--page", action="append", dest="pages", help="Page to capture. Repeatable. Defaults to core E2E evidence pages.")
|
||||
parser.add_argument("--keep-server", action="store_true", help="Leave Streamlit running after capture for manual inspection.")
|
||||
parser.add_argument("--mode", choices=("capture", "transition"), default="capture", help="Capture mode")
|
||||
parser.add_argument("--cert-file", default="", help="TLS certificate for transition mode")
|
||||
parser.add_argument("--key-file", default="", help="TLS private key for transition mode")
|
||||
parser.add_argument("--from-page", default="요약")
|
||||
parser.add_argument("--to-page", default="분류 규칙")
|
||||
parser.add_argument("--interval-ms", type=int, default=100)
|
||||
parser.add_argument("--frames", type=int, default=8)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -145,12 +158,13 @@ def seed_fixture_db(db_path: Path) -> dict[str, Any]:
|
||||
return {"account_id": account_id, "transaction_ids": transactions, "db_path": str(db_path)}
|
||||
|
||||
|
||||
def wait_for_http(url: str, timeout_seconds: int = 45) -> None:
|
||||
def wait_for_http(url: str, timeout_seconds: int = 45, ssl_context: ssl.SSLContext | None = None) -> None:
|
||||
"""Wait for HTTP readiness, using verified TLS unless explicitly overridden."""
|
||||
deadline = time.time() + timeout_seconds
|
||||
last_error: str | None = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as response:
|
||||
with urllib.request.urlopen(url, timeout=2, context=ssl_context) as response:
|
||||
if response.status == 200:
|
||||
return
|
||||
last_error = f"HTTP {response.status}"
|
||||
@@ -195,6 +209,7 @@ class CdpClient:
|
||||
def __init__(self, ws: websockets.ClientConnection) -> None:
|
||||
self.ws = ws
|
||||
self.next_id = 1
|
||||
self.events: list[dict[str, Any]] = []
|
||||
|
||||
async def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
command_id = self.next_id
|
||||
@@ -207,6 +222,8 @@ class CdpClient:
|
||||
if "error" in payload:
|
||||
raise RuntimeError(f"CDP {method} failed: {payload['error']}")
|
||||
return payload.get("result", {})
|
||||
if payload.get("method"):
|
||||
self.events.append(payload)
|
||||
|
||||
|
||||
async def cdp_capture(ws_url: str, url: str, expected_text: str, screenshot: Path, snapshot: Path) -> dict[str, Any]:
|
||||
@@ -295,10 +312,233 @@ def capture_page(chrome: str, base_url: str, page: str, output_dir: Path, devtoo
|
||||
}
|
||||
|
||||
|
||||
def redact_secrets(value: Any, secrets_to_redact: tuple[str, ...] = ()) -> Any:
|
||||
"""Return JSON-safe evidence with credential-like values removed."""
|
||||
if isinstance(value, dict):
|
||||
return {key: redact_secrets(item, secrets_to_redact) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [redact_secrets(item, secrets_to_redact) for item in value]
|
||||
if isinstance(value, str):
|
||||
result = value
|
||||
for secret in secrets_to_redact:
|
||||
if secret:
|
||||
result = result.replace(secret, "[REDACTED]")
|
||||
return result
|
||||
return value
|
||||
|
||||
|
||||
def _validate_surface_records(records: Any, label: str) -> None:
|
||||
required = ("stAppViewContainer", "stSidebar")
|
||||
if not isinstance(records, dict) or any(not isinstance(records.get(name), dict) for name in required):
|
||||
raise ValueError(f"transition observations require surface records for {label}")
|
||||
for name in required:
|
||||
surface = records[name]
|
||||
if any(key not in surface or surface[key] is None for key in ("backgroundImage", "backgroundColor", "visibility", "display", "opacity", "rendered")):
|
||||
raise ValueError(f"transition observations require complete surface records for {label}")
|
||||
if not str(surface["backgroundImage"]).strip() or str(surface["backgroundImage"]).strip().lower() == "none":
|
||||
raise ValueError(f"transition observations reject empty surface background for {label}")
|
||||
if not str(surface["backgroundColor"]).strip():
|
||||
raise ValueError(f"transition observations reject empty surface background color for {label}")
|
||||
try:
|
||||
opacity = float(surface["opacity"])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"transition observations require valid surface opacity for {label}") from exc
|
||||
if not math.isfinite(opacity) or surface["visibility"] != "visible" or surface["display"] == "none" or opacity <= 0 or surface["rendered"] is not True:
|
||||
raise ValueError(f"transition observations require visible rendered surfaces for {label}")
|
||||
|
||||
|
||||
def validate_transition_manifest(manifest: dict[str, Any], secrets_to_redact: tuple[str, ...] = ()) -> None:
|
||||
"""Fail closed unless transition evidence contains every required observation."""
|
||||
serialized = json.dumps(manifest, ensure_ascii=False)
|
||||
if any(secret and secret in serialized for secret in secrets_to_redact):
|
||||
raise ValueError("secret value present in transition manifest")
|
||||
if manifest.get("success") is not True:
|
||||
raise ValueError("transition manifest is not successful")
|
||||
observations = manifest.get("observations") or {}
|
||||
frames = observations.get("frames")
|
||||
if not isinstance(frames, list) or len(frames) < 2:
|
||||
raise ValueError("transition observations require at least two frames")
|
||||
for index, frame in enumerate(frames):
|
||||
_validate_surface_records(frame.get("surfaces"), f"frame {index}")
|
||||
document_events = observations.get("document_navigation_events")
|
||||
if not isinstance(document_events, list) or document_events:
|
||||
raise ValueError("transition observations require zero document navigation events")
|
||||
shell = observations.get("stable_shell") or {}
|
||||
stable_fields = ("document_marker", "sidebar_marker", "background")
|
||||
if any(not isinstance(shell.get(key), dict) for key in stable_fields):
|
||||
raise ValueError("transition observations require pre/post stable shell markers")
|
||||
if any(
|
||||
not isinstance(values.get(position), str) or not values[position].strip()
|
||||
for values in (shell[key] for key in stable_fields)
|
||||
for position in ("pre", "post")
|
||||
):
|
||||
raise ValueError("transition observations require non-empty stable shell markers")
|
||||
if any(shell[key]["pre"] != shell[key]["post"] for key in stable_fields):
|
||||
raise ValueError("transition observations require stable shell identity and background")
|
||||
stable_surfaces = shell.get("surfaces")
|
||||
if not isinstance(stable_surfaces, dict) or stable_surfaces.get("pre") != frames[0].get("surfaces"):
|
||||
raise ValueError("transition observations require stable shell surface records")
|
||||
_validate_surface_records(stable_surfaces.get("pre"), "stable shell pre")
|
||||
_validate_surface_records(stable_surfaces.get("post"), "stable shell post")
|
||||
if stable_surfaces["pre"] != stable_surfaces["post"] or stable_surfaces["post"] != frames[-1].get("surfaces"):
|
||||
raise ValueError("transition observations require stable shell surface consistency")
|
||||
if observations.get("prior_body_removed") is not True:
|
||||
raise ValueError("transition observations require prior body removal")
|
||||
loading_frames = [frame for frame in frames if frame.get("loading") is True]
|
||||
if observations.get("loading_observed") is not True or not loading_frames:
|
||||
raise ValueError("transition observations require observed loading state")
|
||||
if any(frame.get("prior_body_visible") is not False for frame in loading_frames):
|
||||
raise ValueError("transition observations require prior body absence while loading")
|
||||
if not observations.get("target_page"):
|
||||
raise ValueError("transition observations require target page")
|
||||
if observations.get("final_agreement") is not True:
|
||||
raise ValueError("transition observations require target body agreement")
|
||||
runtime = manifest.get("runtime") or {}
|
||||
if not runtime.get("viewport") or not isinstance(runtime.get("interval_ms"), int):
|
||||
raise ValueError("transition manifest requires viewport and interval")
|
||||
command = manifest.get("command") or {}
|
||||
if command.get("exit_code") != 0 or command.get("teardown") != "complete":
|
||||
raise ValueError("transition manifest requires successful command teardown")
|
||||
source_revision = manifest.get("source_revision")
|
||||
if not isinstance(source_revision, str) or len(source_revision) != 40:
|
||||
raise ValueError("transition manifest requires full source revision")
|
||||
source_files = manifest.get("source_files")
|
||||
if not isinstance(source_files, dict) or not source_files or any(
|
||||
not isinstance(path, str)
|
||||
or not isinstance(digest, str)
|
||||
or len(digest) != 64
|
||||
for path, digest in source_files.items()
|
||||
):
|
||||
raise ValueError("transition manifest requires source files and SHA-256 digests")
|
||||
source_paths = tuple(source_files)
|
||||
if manifest.get("source_revision_kind") != "git-head-base":
|
||||
raise ValueError("transition manifest requires git-head-base revision kind")
|
||||
if source_revision != current_git_revision():
|
||||
raise ValueError("transition manifest source revision does not match current Git HEAD")
|
||||
if source_files != source_file_digests(source_paths):
|
||||
raise ValueError("transition manifest source file digests do not match current files")
|
||||
if manifest.get("source_worktree_dirty") != git_worktree_dirty(source_paths):
|
||||
raise ValueError("transition manifest working-tree state does not match current files")
|
||||
if not isinstance(manifest.get("evidence_command"), list) or not manifest["evidence_command"]:
|
||||
raise ValueError("transition manifest requires evidence command")
|
||||
if not runtime.get("host_architecture") or not runtime.get("browser_user_agent"):
|
||||
raise ValueError("transition manifest requires runtime identity")
|
||||
|
||||
|
||||
def current_git_revision() -> str:
|
||||
return subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def git_worktree_dirty(paths: tuple[str, ...]) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain", "--", *paths],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def source_file_digests(paths: tuple[str, ...]) -> dict[str, str]:
|
||||
for path in paths:
|
||||
candidate = Path(path)
|
||||
if candidate.is_absolute() or ".." in candidate.parts:
|
||||
raise ValueError("source file paths must be repository-relative")
|
||||
return {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() for path in paths}
|
||||
|
||||
|
||||
async def cdp_transition(ws_url: str, base_url: str, from_page: str, to_page: str,
|
||||
screenshot: Path, snapshot: Path, interval_ms: int, frame_count: int,
|
||||
admin_id: str, admin_password: str) -> dict[str, Any]:
|
||||
async with websockets.connect(ws_url, max_size=16 * 1024 * 1024) as ws:
|
||||
cdp = CdpClient(ws)
|
||||
await cdp.call("Page.enable")
|
||||
await cdp.call("Runtime.enable")
|
||||
await cdp.call("Emulation.setDeviceMetricsOverride", {"width": 1440, "height": 1400, "deviceScaleFactor": 1, "mobile": False})
|
||||
await cdp.call("Page.navigate", {"url": base_url})
|
||||
await asyncio.sleep(1)
|
||||
login_expression = """(async () => {
|
||||
const body = new URLSearchParams({admin_id: %s, admin_password: %s});
|
||||
const response = await fetch('/api/auth/login', {method:'POST', credentials:'include', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body});
|
||||
return {status: response.status, location: response.url};
|
||||
})()""" % (json.dumps(admin_id), json.dumps(admin_password))
|
||||
await cdp.call("Runtime.evaluate", {"expression": login_expression, "awaitPromise": True, "returnByValue": True})
|
||||
await cdp.call("Page.navigate", {"url": f"{base_url}/?page={urllib.parse.quote(from_page)}"})
|
||||
deadline = time.time() + 45
|
||||
while time.time() < deadline:
|
||||
check = await cdp.call("Runtime.evaluate", {"expression": "document.body ? document.body.innerText : ''", "returnByValue": True})
|
||||
if from_page in (check.get("result", {}).get("value", "") or ""):
|
||||
break
|
||||
await asyncio.sleep(.5)
|
||||
install = """(() => {
|
||||
window.__evidenceEvents=[];
|
||||
window.__evidenceDocumentMarker ||= crypto.randomUUID();
|
||||
window.__evidencePriorBodyText = (document.querySelector('[data-testid=stMain]') || document.body)?.innerText || '';
|
||||
const sidebar=document.querySelector('[data-testid=stSidebar]');
|
||||
if (sidebar) sidebar.dataset.evidenceMarker ||= crypto.randomUUID();
|
||||
window.__evidenceSurface = (selector) => { const element=document.querySelector(selector); if (!element) return null; const style=getComputedStyle(element); const rect=element.getBoundingClientRect(); return {backgroundImage:style.backgroundImage, backgroundColor:style.backgroundColor, visibility:style.visibility, display:style.display, opacity:style.opacity, rendered:rect.width>0 && rect.height>0 && style.visibility!=='hidden' && style.display!=='none' && Number(style.opacity)>0}; };
|
||||
for (const name of ['pushState','replaceState']) {
|
||||
const old=history[name];
|
||||
history[name]=function(){window.__evidenceEvents.push({type:name,url:String(arguments[2]||location.href)}); return old.apply(this,arguments)};
|
||||
}
|
||||
})()"""
|
||||
await cdp.call("Runtime.evaluate", {"expression": install})
|
||||
cdp.events.clear()
|
||||
observations: list[dict[str, Any]] = []
|
||||
click = """(() => { const labels=[...document.querySelectorAll('label')]; const target=labels.find(x=>x.innerText.trim()===%s); if(target){target.click(); return true;} const input=[...document.querySelectorAll('input')].find(x=>x.value===%s); if(input){input.click(); return true;} return false; })()""" % (json.dumps(to_page), json.dumps(to_page))
|
||||
deadline = time.time() + 45
|
||||
index = 0
|
||||
while time.time() < deadline and (index < frame_count or not observations or observations[-1].get("selected_menu") != to_page):
|
||||
expression = """(() => { const body=document.body; const text=body?.innerText||''; const main=document.querySelector('[data-testid=stMain]')||body; const mainText=main.innerText||''; const sidebar=document.querySelector('[data-testid=stSidebar]'); const selectedInput=document.querySelector('[data-testid=stRadio] input:checked'); const selected=selectedInput?.closest('label')?.innerText?.trim() || document.querySelector('[aria-current=page]')?.textContent?.trim() || ''; const prior=window.__evidencePriorBodyText||''; return {at_ms:%d, selected_menu:selected, body_text:mainText, prior_body_visible:Boolean(prior)&&mainText.includes(prior), document_marker:window.__evidenceDocumentMarker||'', sidebar_marker:sidebar?.dataset.evidenceMarker||'', background:getComputedStyle(document.body).backgroundColor, surfaces:{stAppViewContainer:window.__evidenceSurface('[data-testid=stAppViewContainer]'), stSidebar:window.__evidenceSurface('[data-testid=stSidebar]')}, loading:/불러오는 중/.test(text)}; })()""" % (index * interval_ms)
|
||||
result = await cdp.call("Runtime.evaluate", {"expression": expression, "returnByValue": True})
|
||||
frame = result.get("result", {}).get("value", {})
|
||||
frame["body_text_hash"] = hashlib.sha256(frame.get("body_text", "").encode()).hexdigest()
|
||||
frame.pop("body_text", None)
|
||||
observations.append(frame)
|
||||
if index == 0:
|
||||
await cdp.call("Runtime.evaluate", {"expression": click, "returnByValue": True})
|
||||
index += 1
|
||||
await asyncio.sleep(interval_ms / 1000)
|
||||
final = await cdp.call("Runtime.evaluate", {"expression": "(() => { const main=document.querySelector('[data-testid=stMain]')||document.body; const sidebar=document.querySelector('[data-testid=stSidebar]'); const selectedInput=document.querySelector('[data-testid=stRadio] input:checked'); return {url:location.href, body:main.innerText||'', selected:selectedInput?.closest('label')?.innerText?.trim() || document.querySelector('[aria-current=page]')?.textContent?.trim() || '', document_marker:window.__evidenceDocumentMarker||'', sidebar_marker:sidebar?.dataset.evidenceMarker||'', background:getComputedStyle(document.body).backgroundColor, surfaces:{stAppViewContainer:window.__evidenceSurface('[data-testid=stAppViewContainer]'), stSidebar:window.__evidenceSurface('[data-testid=stSidebar]')}, history_events:window.__evidenceEvents||[], browser_user_agent:navigator.userAgent}; })()", "returnByValue": True})
|
||||
data = final.get("result", {}).get("value", {})
|
||||
html = await cdp.call("Runtime.evaluate", {"expression": "document.documentElement.outerHTML", "returnByValue": True})
|
||||
html_text = html.get("result", {}).get("value", "")
|
||||
snapshot.write_text(redact_secrets(html_text, (admin_id, admin_password)), encoding="utf-8")
|
||||
image = await cdp.call("Page.captureScreenshot", {"format":"png", "fromSurface":True, "captureBeyondViewport":True})
|
||||
screenshot.write_bytes(base64.b64decode(image["data"]))
|
||||
document_events = [{"type": event.get("method"), "params": event.get("params", {})} for event in cdp.events if event.get("method") in ("Page.frameNavigated", "Page.loadEventFired")]
|
||||
pre = observations[0] if observations else {}
|
||||
loading_frames = [frame for frame in observations if frame.get("loading")]
|
||||
return {"frames": observations, "document_navigation_events": document_events, "history_events": data.get("history_events", []), "stable_shell": {"document_marker": {"pre": pre.get("document_marker"), "post": data.get("document_marker")}, "sidebar_marker": {"pre": pre.get("sidebar_marker"), "post": data.get("sidebar_marker")}, "background": {"pre": pre.get("background"), "post": data.get("background")}, "surfaces": {"pre": pre.get("surfaces"), "post": data.get("surfaces")}}, "loading_observed": bool(loading_frames), "prior_body_removed": bool(loading_frames) and all(frame.get("prior_body_visible") is False for frame in loading_frames), "target_page": to_page, "final_agreement": data.get("selected") == to_page and to_page in data.get("body", ""), "final_url": data.get("url", ""), "browser_user_agent": data.get("browser_user_agent", "")}
|
||||
|
||||
|
||||
def transition_capture(chrome: str, base_url: str, from_page: str, to_page: str, output_dir: Path, devtools_port: int,
|
||||
interval_ms: int, frame_count: int,
|
||||
admin_id: str, admin_password: str) -> dict[str, Any]:
|
||||
profile_dir = output_dir / "chrome-profile-transition"
|
||||
screenshot, snapshot = output_dir / "transition.png", output_dir / "transition.dom.html"
|
||||
chrome_cmd = [chrome, "--headless=new", "--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors", f"--remote-debugging-port={devtools_port}", f"--user-data-dir={profile_dir}", "--window-size=1440,1400", "about:blank"]
|
||||
proc = subprocess.Popen(chrome_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
try:
|
||||
ws_url = wait_for_devtools(devtools_port)
|
||||
return asyncio.run(cdp_transition(ws_url, base_url, from_page, to_page, screenshot, snapshot, interval_ms, frame_count, admin_id, admin_password))
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try: proc.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired: proc.kill(); proc.wait(timeout=5)
|
||||
remove_tree_with_retries(profile_dir)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
chrome = require_executable(args.chrome, "Chrome")
|
||||
streamlit = require_executable(args.streamlit, "Streamlit")
|
||||
streamlit = None if args.mode == "transition" else require_executable(args.streamlit, "Streamlit")
|
||||
run_id = datetime.now().strftime("run-%Y%m%d-%H%M%S")
|
||||
output_dir = Path(args.output_dir) if args.output_dir else DEFAULT_OUTPUT_ROOT / run_id
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -308,56 +548,105 @@ def main() -> int:
|
||||
env = os.environ.copy()
|
||||
env["JARCA_LEDGER_DB_PATH"] = str(db_path.resolve())
|
||||
env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
||||
base_url = f"http://{args.host}:{args.port}"
|
||||
server_cmd = [
|
||||
streamlit,
|
||||
"run",
|
||||
"dashboard/app.py",
|
||||
"--server.address",
|
||||
args.host,
|
||||
"--server.port",
|
||||
str(args.port),
|
||||
"--server.headless",
|
||||
"true",
|
||||
"--browser.gatherUsageStats",
|
||||
"false",
|
||||
]
|
||||
transition_secrets: tuple[str, ...] = ()
|
||||
if args.mode == "transition":
|
||||
if not args.cert_file or not args.key_file:
|
||||
raise SystemExit("transition mode requires --cert-file and --key-file")
|
||||
env["JARCA_DASHBOARD_ADMIN_ID"] = secrets.token_urlsafe(18)
|
||||
env["JARCA_DASHBOARD_ADMIN_PASSWORD"] = secrets.token_urlsafe(32)
|
||||
env["JARCA_DASHBOARD_SIGNING_SECRET"] = secrets.token_hex(32)
|
||||
transition_secrets = (env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"], env["JARCA_DASHBOARD_SIGNING_SECRET"])
|
||||
scheme = "https" if args.mode == "transition" else "http"
|
||||
base_url = f"{scheme}://{args.host}:{args.port}"
|
||||
if args.mode == "transition":
|
||||
server_cmd = [sys.executable, "-m", "uvicorn", "dashboard.server:app", "--host", args.host, "--port", str(args.port), "--ssl-certfile", args.cert_file, "--ssl-keyfile", args.key_file]
|
||||
else:
|
||||
server_cmd = [
|
||||
streamlit, "run", "dashboard/app.py", "--server.address", args.host,
|
||||
"--server.port", str(args.port), "--server.headless", "true",
|
||||
"--browser.gatherUsageStats", "false",
|
||||
]
|
||||
|
||||
server = subprocess.Popen(server_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, start_new_session=True)
|
||||
manifest: dict[str, Any] = {
|
||||
"success": False,
|
||||
"mode": args.mode,
|
||||
"base_url": base_url,
|
||||
"output_dir": str(output_dir.resolve()),
|
||||
"fixture": fixture,
|
||||
"fixture": {"db_path": fixture["db_path"]},
|
||||
"chrome": chrome,
|
||||
"streamlit": streamlit,
|
||||
"pages": [],
|
||||
"server_command": server_cmd,
|
||||
}
|
||||
error: str | None = None
|
||||
try:
|
||||
wait_for_http(base_url)
|
||||
for index, page in enumerate(args.pages or DEFAULT_PAGES):
|
||||
manifest["pages"].append(capture_page(chrome, base_url, page, output_dir, devtools_port=19222 + index))
|
||||
manifest["success"] = True
|
||||
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
wait_for_http(base_url, ssl_context=ssl._create_unverified_context() if args.mode == "transition" else None)
|
||||
if args.mode == "transition":
|
||||
observations = transition_capture(chrome, base_url, args.from_page, args.to_page, output_dir, 19222, args.interval_ms, args.frames, env["JARCA_DASHBOARD_ADMIN_ID"], env["JARCA_DASHBOARD_ADMIN_PASSWORD"])
|
||||
source_paths = (
|
||||
"dashboard/app.py",
|
||||
"dashboard/common.py",
|
||||
"scripts/streamlit_browser_evidence.py",
|
||||
)
|
||||
manifest.update(
|
||||
{
|
||||
"observations": observations,
|
||||
"runtime": {
|
||||
"viewport": {"width": 1440, "height": 1400},
|
||||
"interval_ms": args.interval_ms,
|
||||
"frame_count": args.frames,
|
||||
"host_architecture": platform.machine(),
|
||||
"python_version": platform.python_version(),
|
||||
"browser_user_agent": observations.get("browser_user_agent", ""),
|
||||
},
|
||||
"source_revision": current_git_revision(),
|
||||
"source_revision_kind": "git-head-base",
|
||||
"source_worktree_dirty": git_worktree_dirty(source_paths),
|
||||
"source_files": source_file_digests(source_paths),
|
||||
"evidence_command": ["scripts/streamlit_browser_evidence.py", *sys.argv[1:]],
|
||||
"limitations": ["TLS uses caller-provided local certificate"],
|
||||
}
|
||||
)
|
||||
else:
|
||||
for index, page in enumerate(args.pages or DEFAULT_PAGES):
|
||||
manifest["pages"].append(capture_page(chrome, base_url, page, output_dir, devtools_port=19222 + index))
|
||||
except Exception as exc: # noqa: BLE001 - failure is recorded in redacted evidence
|
||||
error = str(exc)
|
||||
finally:
|
||||
log_tail = ""
|
||||
if server.stdout is not None:
|
||||
try:
|
||||
time.sleep(0.5)
|
||||
if server.poll() is not None:
|
||||
log_tail = server.stdout.read()[-4000:]
|
||||
except Exception:
|
||||
log_tail = ""
|
||||
manifest["server_log_tail"] = log_tail
|
||||
(output_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
if not args.keep_server and server.poll() is None:
|
||||
os.killpg(server.pid, signal.SIGTERM)
|
||||
try:
|
||||
server.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(server.pid, signal.SIGKILL)
|
||||
server.wait(timeout=5)
|
||||
returncode = server.poll()
|
||||
teardown = "complete" if not args.keep_server and returncode is not None else "incomplete"
|
||||
effective_exit = 0 if returncode in (0, -signal.SIGTERM) else returncode
|
||||
manifest["server_log_tail"] = ""
|
||||
manifest["command"] = {"exit_code": effective_exit, "teardown": teardown}
|
||||
if error:
|
||||
manifest["error"] = error
|
||||
if args.mode == "transition" and error is None and teardown == "complete" and effective_exit == 0:
|
||||
try:
|
||||
manifest["success"] = True
|
||||
validate_transition_manifest(manifest, transition_secrets)
|
||||
except Exception as exc: # noqa: BLE001 - preserve failed manifest
|
||||
manifest["success"] = False
|
||||
manifest["error"] = str(exc)
|
||||
elif args.mode == "capture" and error is None and (
|
||||
(args.keep_server and returncode is None)
|
||||
or (teardown == "complete" and effective_exit == 0)
|
||||
):
|
||||
manifest["success"] = True
|
||||
safe_manifest = redact_secrets(manifest, transition_secrets)
|
||||
manifest_path = output_dir / "manifest.json"
|
||||
temporary_path = output_dir / ".manifest.json.tmp"
|
||||
temporary_path.write_text(json.dumps(safe_manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary_path.replace(manifest_path)
|
||||
print(json.dumps(safe_manifest, ensure_ascii=False, indent=2))
|
||||
return 0 if manifest.get("success") is True else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -6,7 +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 dashboard import authentication
|
||||
from scripts.ci_fixture_db import create_ci_fixture_db
|
||||
|
||||
|
||||
@@ -44,8 +44,13 @@ 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")
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
|
||||
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
|
||||
settings = authentication.load_auth_settings()
|
||||
assert settings is not None
|
||||
token = authentication.issue_persistent_token(settings, remember_me=False).value
|
||||
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
|
||||
|
||||
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
|
||||
page_expectations = {
|
||||
@@ -61,10 +66,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from http.cookies import SimpleCookie
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.requests import Request
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from dashboard import authentication
|
||||
from dashboard.auth_server import create_auth_routes, login
|
||||
|
||||
|
||||
ADMIN_ID = "admin"
|
||||
ADMIN_PASSWORD = "correct horse battery staple"
|
||||
SIGNING_SECRET = "s" * 64
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configured_auth(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID)
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD)
|
||||
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET)
|
||||
monkeypatch.setattr(authentication.time, "sleep", lambda _seconds: None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
from starlette.applications import Starlette
|
||||
|
||||
return TestClient(
|
||||
Starlette(routes=create_auth_routes()), base_url="https://testserver"
|
||||
)
|
||||
|
||||
|
||||
def _cookie_from(response) -> SimpleCookie[str]:
|
||||
cookie: SimpleCookie[str] = SimpleCookie()
|
||||
cookie.load(response.headers["set-cookie"])
|
||||
return cookie
|
||||
|
||||
|
||||
def test_successful_login_sets_hardened_24_hour_cookie(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
|
||||
headers={"origin": "https://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/"
|
||||
header = response.headers["set-cookie"]
|
||||
assert f"{authentication.AUTH_COOKIE_NAME}=" in header
|
||||
assert "HttpOnly" in header
|
||||
assert "Secure" in header
|
||||
assert "SameSite=lax" in header
|
||||
assert "Path=/" in header
|
||||
assert f"Max-Age={authentication.DEFAULT_TOKEN_TTL_SECONDS}" in header
|
||||
assert ADMIN_PASSWORD not in header
|
||||
assert SIGNING_SECRET not in header
|
||||
|
||||
|
||||
def test_remember_me_sets_30_day_cookie(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={
|
||||
"admin_id": ADMIN_ID,
|
||||
"admin_password": ADMIN_PASSWORD,
|
||||
"remember_me": "on",
|
||||
},
|
||||
headers={"origin": "https://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert (
|
||||
f"Max-Age={authentication.REMEMBER_ME_TOKEN_TTL_SECONDS}"
|
||||
in response.headers["set-cookie"]
|
||||
)
|
||||
|
||||
|
||||
def test_unicode_credentials_can_login_through_adapter(client: TestClient, monkeypatch) -> None:
|
||||
unicode_id = "管理员"
|
||||
unicode_password = "päss🔐"
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, unicode_id)
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, unicode_password)
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": unicode_id, "admin_password": unicode_password},
|
||||
headers={"origin": "https://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/"
|
||||
assert f"{authentication.AUTH_COOKIE_NAME}=" in response.headers["set-cookie"]
|
||||
|
||||
|
||||
def test_unicode_credential_mismatch_is_generic_and_delayed(
|
||||
client: TestClient, monkeypatch
|
||||
) -> None:
|
||||
import time as real_time
|
||||
|
||||
unicode_id = "管理员"
|
||||
unicode_password = "päss🔐"
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, unicode_id)
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, unicode_password)
|
||||
monkeypatch.setattr(authentication.time, "sleep", real_time.sleep)
|
||||
|
||||
started = real_time.monotonic()
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": unicode_id, "admin_password": "неверный🔒"},
|
||||
headers={"origin": "https://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
elapsed = real_time.monotonic() - started
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/?auth_error=invalid"
|
||||
assert "set-cookie" not in response.headers
|
||||
assert elapsed >= authentication.MINIMUM_FAILURE_DELAY_SECONDS * 0.9
|
||||
|
||||
|
||||
def test_invalid_credentials_do_not_set_cookie_and_use_general_error(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": ADMIN_ID, "admin_password": "wrong"},
|
||||
headers={"origin": "https://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/?auth_error=invalid"
|
||||
assert "set-cookie" not in response.headers
|
||||
assert "password" not in response.headers["location"]
|
||||
assert ADMIN_ID not in response.headers["location"]
|
||||
|
||||
|
||||
def test_login_rejects_malformed_percent_encoded_utf8_without_cookie(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
content=b"admin_id=%FF&admin_password=wrong",
|
||||
headers={
|
||||
"origin": "https://testserver",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "set-cookie" not in response.headers
|
||||
|
||||
|
||||
def test_missing_configuration_fails_closed(client: TestClient, monkeypatch) -> None:
|
||||
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV)
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
|
||||
headers={"origin": "https://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/?auth_error=unavailable"
|
||||
assert "set-cookie" not in response.headers
|
||||
|
||||
|
||||
def test_cross_site_login_is_rejected(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
|
||||
headers={"origin": "https://attacker.example"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "set-cookie" not in response.headers
|
||||
|
||||
|
||||
def test_different_port_is_not_same_origin(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
|
||||
headers={"origin": "https://testserver:444"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "set-cookie" not in response.headers
|
||||
|
||||
|
||||
def test_plain_http_is_rejected_even_with_spoofed_https_origin() -> None:
|
||||
from starlette.applications import Starlette
|
||||
|
||||
with TestClient(
|
||||
Starlette(routes=create_auth_routes()), base_url="http://testserver"
|
||||
) as http_client:
|
||||
response = http_client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
|
||||
headers={
|
||||
"origin": "https://testserver",
|
||||
"x-forwarded-proto": "https",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "set-cookie" not in response.headers
|
||||
|
||||
|
||||
def test_logout_clears_cookie_with_matching_scope(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/auth/logout",
|
||||
headers={"origin": "https://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
header = response.headers["set-cookie"]
|
||||
assert f"{authentication.AUTH_COOKIE_NAME}=" in header
|
||||
assert "Max-Age=0" in header
|
||||
assert "HttpOnly" in header
|
||||
assert "Secure" in header
|
||||
assert "SameSite=lax" in header
|
||||
assert "Path=/" in header
|
||||
|
||||
|
||||
def test_cleanup_route_clears_cookie_and_redirects(client: TestClient) -> None:
|
||||
response = client.get(
|
||||
"/api/auth/cleanup",
|
||||
headers={"referer": "https://testserver/"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/"
|
||||
header = response.headers["set-cookie"]
|
||||
assert f"{authentication.AUTH_COOKIE_NAME}=" in header
|
||||
assert "Max-Age=0" in header
|
||||
assert "HttpOnly" in header
|
||||
assert "Secure" in header
|
||||
assert "SameSite=lax" in header
|
||||
assert "Path=/" in header
|
||||
|
||||
|
||||
def test_cleanup_route_rejects_cross_origin_navigation(client: TestClient) -> None:
|
||||
response = client.get(
|
||||
"/api/auth/cleanup",
|
||||
headers={"referer": "https://attacker.example/"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "set-cookie" not in response.headers
|
||||
|
||||
|
||||
def test_cookie_token_round_trip(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
|
||||
headers={"origin": "https://testserver"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
cookie = _cookie_from(response)
|
||||
token = cookie[authentication.AUTH_COOKIE_NAME].value
|
||||
|
||||
assert authentication.verify_persistent_token(
|
||||
authentication.load_auth_settings(), token
|
||||
) is not None
|
||||
|
||||
|
||||
def _streaming_login_request(
|
||||
chunks: list[bytes],
|
||||
) -> tuple[Request, Callable[[], int]]:
|
||||
messages = [
|
||||
{
|
||||
"type": "http.request",
|
||||
"body": chunk,
|
||||
"more_body": index < len(chunks) - 1,
|
||||
}
|
||||
for index, chunk in enumerate(chunks)
|
||||
]
|
||||
calls = 0
|
||||
|
||||
async def receive():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return messages.pop(0)
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"scheme": "https",
|
||||
"path": "/api/auth/login",
|
||||
"raw_path": b"/api/auth/login",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
(b"host", b"testserver"),
|
||||
(b"origin", b"https://testserver"),
|
||||
(b"content-type", b"application/x-www-form-urlencoded"),
|
||||
],
|
||||
"client": ("testclient", 50000),
|
||||
"server": ("testserver", 443),
|
||||
}
|
||||
return Request(scope, receive), lambda: calls
|
||||
|
||||
|
||||
def test_login_offloads_failed_login_delay_from_event_loop(monkeypatch) -> None:
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
probe_ran = threading.Event()
|
||||
probe_before_release = threading.Event()
|
||||
|
||||
def blocking_delay(*, started_at):
|
||||
entered.set()
|
||||
if probe_ran.wait(timeout=1):
|
||||
probe_before_release.set()
|
||||
release.set()
|
||||
|
||||
monkeypatch.setattr(authentication, "enforce_failed_login_delay", blocking_delay)
|
||||
|
||||
async def exercise() -> None:
|
||||
request, _ = _streaming_login_request([b"admin_id=admin&admin_password=wrong"])
|
||||
task = asyncio.create_task(login(request))
|
||||
|
||||
async def probe() -> None:
|
||||
probe_ran.set()
|
||||
|
||||
asyncio.create_task(probe())
|
||||
assert await asyncio.to_thread(entered.wait, 1)
|
||||
release.set()
|
||||
response = await task
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/?auth_error=invalid"
|
||||
assert "set-cookie" not in response.headers
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert probe_before_release.is_set()
|
||||
|
||||
|
||||
def test_login_rejects_exactly_one_byte_over_streaming_limit(monkeypatch) -> None:
|
||||
def unexpected_verification(*_args, **_kwargs):
|
||||
raise AssertionError("oversized body reached credential verification")
|
||||
|
||||
monkeypatch.setattr(authentication, "verify_credentials", unexpected_verification)
|
||||
request, calls = _streaming_login_request([b"x" * 4097])
|
||||
response = asyncio.run(login(request))
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "set-cookie" not in response.headers
|
||||
assert calls() == 1
|
||||
|
||||
|
||||
def test_login_accepts_body_at_streaming_limit() -> None:
|
||||
prefix = b"admin_id=admin&admin_password="
|
||||
body = prefix + (b"x" * (4096 - len(prefix)))
|
||||
assert len(body) == 4096
|
||||
request, calls = _streaming_login_request([body])
|
||||
response = asyncio.run(login(request))
|
||||
|
||||
assert response.status_code == 303
|
||||
assert calls() == 1
|
||||
|
||||
|
||||
def test_login_stops_reading_stream_after_limit_is_exceeded() -> None:
|
||||
request, calls = _streaming_login_request([b"x" * 4096, b"y", b"z"])
|
||||
response = asyncio.run(login(request))
|
||||
|
||||
assert response.status_code == 400
|
||||
assert calls() == 2
|
||||
@@ -63,20 +63,26 @@ def test_load_admin_credentials_fails_closed_when_environment_cannot_be_read():
|
||||
|
||||
def test_verify_credentials_uses_constant_time_comparison_for_both_fields(monkeypatch):
|
||||
credentials = authentication.AdminCredentials(ADMIN_ID, ADMIN_PASSWORD)
|
||||
comparisons: list[tuple[str, str]] = []
|
||||
comparisons: list[tuple[bytes, bytes]] = []
|
||||
|
||||
def record_compare(left: str, right: str) -> bool:
|
||||
def record_compare(left: bytes, right: bytes) -> 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)]
|
||||
assert comparisons == [
|
||||
(ADMIN_ID.encode("utf-8"), ADMIN_ID.encode("utf-8")),
|
||||
(ADMIN_PASSWORD.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")),
|
||||
]
|
||||
|
||||
comparisons.clear()
|
||||
assert authentication.verify_credentials(credentials, "wrong-id", "wrong-password") is False
|
||||
assert comparisons == [("wrong-id", ADMIN_ID), ("wrong-password", ADMIN_PASSWORD)]
|
||||
assert comparisons == [
|
||||
(b"wrong-id", ADMIN_ID.encode("utf-8")),
|
||||
(b"wrong-password", ADMIN_PASSWORD.encode("utf-8")),
|
||||
]
|
||||
|
||||
|
||||
def test_failed_login_delay_waits_only_for_remaining_minimum_duration():
|
||||
|
||||
@@ -2,9 +2,7 @@ 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]))
|
||||
@@ -14,31 +12,40 @@ from dashboard import authentication
|
||||
|
||||
ADMIN_ID = "test-admin"
|
||||
ADMIN_PASSWORD = "test-password"
|
||||
SIGNING_SECRET = "s" * 64
|
||||
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)
|
||||
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:
|
||||
def _configure(monkeypatch) -> authentication.AuthSettings:
|
||||
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)
|
||||
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET)
|
||||
settings = authentication.load_auth_settings()
|
||||
assert settings is not None
|
||||
return settings
|
||||
|
||||
|
||||
def _app_with_cookie(monkeypatch, token: str | None, *, page: str = "요약") -> AppTest:
|
||||
_configure(monkeypatch)
|
||||
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
|
||||
at = AppTest.from_file(str(APP_PATH), default_timeout=30)
|
||||
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
|
||||
|
||||
@@ -46,18 +53,20 @@ def test_missing_authentication_configuration_fails_closed(monkeypatch, tmp_path
|
||||
create_ci_fixture_db(fixture)
|
||||
monkeypatch.delenv(authentication.ADMIN_ID_ENV, raising=False)
|
||||
monkeypatch.delenv(authentication.ADMIN_PASSWORD_ENV, raising=False)
|
||||
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV, raising=False)
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
|
||||
|
||||
at = AppTest.from_file(str(APP_PATH), default_timeout=10).run()
|
||||
at = AppTest.from_file(str(APP_PATH), default_timeout=30).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):
|
||||
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)
|
||||
@@ -71,7 +80,7 @@ def test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects(
|
||||
|
||||
|
||||
def test_query_parameter_cannot_bypass_login_or_reach_import_page(monkeypatch):
|
||||
at = _configured_app(monkeypatch, page="Import")
|
||||
at = _app_with_cookie(monkeypatch, None, page="Import")
|
||||
|
||||
assert not at.exception
|
||||
assert "관리자 로그인" in _body_text(at)
|
||||
@@ -79,96 +88,96 @@ def test_query_parameter_cannot_bypass_login_or_reach_import_page(monkeypatch):
|
||||
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)
|
||||
def test_login_form_posts_credentials_to_server_side_adapter(monkeypatch):
|
||||
at = _app_with_cookie(monkeypatch, None)
|
||||
body = _body_text(at)
|
||||
|
||||
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 'action="/api/auth/login"' in body
|
||||
assert 'method="post"' in body
|
||||
assert 'name="admin_id"' in body
|
||||
assert 'name="admin_password"' in body
|
||||
assert 'name="remember_me"' in body
|
||||
assert ADMIN_ID not in repr(at.session_state)
|
||||
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 SIGNING_SECRET not in repr(at.session_state)
|
||||
|
||||
|
||||
def test_invalid_cookie_blocks_database_and_page_dispatch(monkeypatch):
|
||||
at = _app_with_cookie(monkeypatch, "malformed-token")
|
||||
|
||||
assert not at.exception
|
||||
assert "관리자 로그인" in _body_text(at)
|
||||
assert "이번 달 수입/지출 현황" not in _body_text(at)
|
||||
assert "/api/auth/cleanup" in _body_text(at)
|
||||
|
||||
|
||||
def test_successful_login_persists_for_current_session_and_logout_clears_it(monkeypatch, tmp_path):
|
||||
def test_no_cookie_does_not_render_cookie_cleanup_navigation(monkeypatch):
|
||||
at = _app_with_cookie(monkeypatch, None)
|
||||
|
||||
assert "/api/auth/cleanup" not in _body_text(at)
|
||||
|
||||
|
||||
def test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state(
|
||||
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)
|
||||
settings = _configure(monkeypatch)
|
||||
token = authentication.issue_persistent_token(
|
||||
settings, remember_me=False, now=1_700_000_000
|
||||
).value
|
||||
monkeypatch.setattr(authentication.time, "time", lambda: 1_700_000_001)
|
||||
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
|
||||
|
||||
at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD)
|
||||
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
|
||||
|
||||
assert not at.exception
|
||||
assert "이번 달 수입/지출 현황" in _body_text(at)
|
||||
assert authentication.INVALID_CREDENTIALS_MESSAGE not in _body_text(at)
|
||||
assert 'action="/api/auth/logout"' in _body_text(at)
|
||||
assert token not in repr(at.session_state)
|
||||
assert ADMIN_PASSWORD not in repr(at.session_state)
|
||||
assert SIGNING_SECRET not in repr(at.session_state)
|
||||
|
||||
at = at.run()
|
||||
|
||||
def test_password_rotation_invalidates_existing_cookie(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))
|
||||
settings = _configure(monkeypatch)
|
||||
token = authentication.issue_persistent_token(settings, remember_me=True).value
|
||||
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
|
||||
|
||||
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
|
||||
assert "이번 달 수입/지출 현황" in _body_text(at)
|
||||
|
||||
logout_button = next(button for button in at.button if button.label == "로그아웃")
|
||||
logout_button.click()
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "rotated-password")
|
||||
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):
|
||||
def test_signing_secret_rotation_invalidates_existing_cookie(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))
|
||||
settings = _configure(monkeypatch)
|
||||
token = authentication.issue_persistent_token(settings, remember_me=True).value
|
||||
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
|
||||
|
||||
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
|
||||
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
|
||||
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)
|
||||
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "r" * 64)
|
||||
at = at.run()
|
||||
|
||||
assert authentication.CONFIGURATION_ERROR_MESSAGE in _body_text(at)
|
||||
assert "관리자 로그인" in _body_text(at)
|
||||
assert "/api/auth/cleanup" 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)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from dashboard import app, authentication, common
|
||||
from scripts.ci_fixture_db import create_ci_fixture_db
|
||||
|
||||
|
||||
PAGES = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
|
||||
|
||||
|
||||
def test_dashboard_menu_labels_and_order_are_preserved(monkeypatch):
|
||||
calls = {}
|
||||
|
||||
def radio(label, options, **kwargs):
|
||||
calls.update(label=label, options=list(options), kwargs=kwargs)
|
||||
return "요약"
|
||||
|
||||
monkeypatch.setattr(common.st, "radio", radio)
|
||||
assert common.render_sidebar_nav(PAGES, "요약") == "요약"
|
||||
assert calls["options"] == PAGES
|
||||
assert calls["kwargs"]["key"] == "dashboard_page"
|
||||
assert calls["kwargs"]["label_visibility"] == "collapsed"
|
||||
|
||||
|
||||
def test_sidebar_selection_updates_only_page_query_parameter(monkeypatch):
|
||||
query = {"page": "요약", "month": "2026-06", "account_id": "checking"}
|
||||
state = {"dashboard_page": "거래내역"}
|
||||
callback = {}
|
||||
|
||||
monkeypatch.setattr(common.st, "query_params", query)
|
||||
monkeypatch.setattr(common.st, "session_state", state)
|
||||
monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"])
|
||||
common.render_sidebar_nav(PAGES, "요약")
|
||||
callback["fn"]()
|
||||
|
||||
assert query == {"page": "거래내역", "month": "2026-06", "account_id": "checking"}
|
||||
|
||||
|
||||
def test_missing_or_unknown_page_falls_back_to_overview():
|
||||
assert common.validate_dashboard_page(None, PAGES) == "요약"
|
||||
assert common.validate_dashboard_page("not-a-page", PAGES) == "요약"
|
||||
assert common.validate_dashboard_page("거래내역", PAGES) == "거래내역"
|
||||
|
||||
|
||||
def test_direct_page_query_access_renders_requested_page(monkeypatch):
|
||||
selected = {}
|
||||
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True)
|
||||
monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3")
|
||||
monkeypatch.setattr(app, "inject_app_css", lambda: None)
|
||||
monkeypatch.setattr(app.st, "query_params", {"page": "계좌"})
|
||||
monkeypatch.setattr(app.st, "session_state", {"dashboard_page": "요약"})
|
||||
|
||||
def capture_sidebar_page(pages, page):
|
||||
selected["sidebar_page"] = page
|
||||
selected["session_page"] = app.st.session_state["dashboard_page"]
|
||||
return page
|
||||
|
||||
monkeypatch.setattr(app, "render_sidebar_nav", capture_sidebar_page)
|
||||
monkeypatch.setattr(app.st, "sidebar", pytest.importorskip("contextlib").nullcontext())
|
||||
monkeypatch.setattr(app.st, "button", lambda *args, **kwargs: False)
|
||||
monkeypatch.setattr(app, "page_accounts", lambda db: selected.setdefault("db", db))
|
||||
for name in PAGES:
|
||||
if name != "계좌":
|
||||
monkeypatch.setattr(app, {"월정산": "page_monthly_settlement", "요약": "page_overview", "자산/마감": "page_assets", "거래내역": "page_transactions", "확인필요 큐": "page_cross_transfer_review", "미분류": "page_uncategorized", "분류 규칙": "page_rules", "카테고리": "page_categories", "Import": "page_imports"}[name], lambda db: None)
|
||||
monkeypatch.setattr(app, "render_dashboard_body", lambda page, renderer: renderer())
|
||||
|
||||
app.main()
|
||||
assert selected == {
|
||||
"sidebar_page": "계좌",
|
||||
"session_page": "계좌",
|
||||
"db": "fixture.sqlite3",
|
||||
}
|
||||
|
||||
|
||||
def test_streamlit_menu_click_updates_body_and_preserves_page_query_siblings(tmp_path, monkeypatch):
|
||||
fixture = tmp_path / "fixture.sqlite3"
|
||||
create_ci_fixture_db(fixture)
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
|
||||
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
|
||||
settings = authentication.load_auth_settings()
|
||||
assert settings is not None
|
||||
token = authentication.issue_persistent_token(settings, remember_me=False).value
|
||||
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
|
||||
|
||||
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
|
||||
at = AppTest.from_file(str(app_path), default_timeout=10)
|
||||
at.query_params["page"] = "요약"
|
||||
at.query_params["month"] = "2026-06"
|
||||
at.run()
|
||||
|
||||
at.radio(key="dashboard_page").set_value("분류 규칙").run()
|
||||
|
||||
assert not at.exception
|
||||
assert at.query_params["page"] == ["분류 규칙"]
|
||||
assert at.query_params["month"] == ["2026-06"]
|
||||
body_text = "\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption))
|
||||
assert "현재 분류 규칙" in body_text
|
||||
|
||||
|
||||
def test_slow_transition_clears_previous_body_and_shows_target_loading(monkeypatch):
|
||||
events = []
|
||||
monkeypatch.setattr(common.st, "empty", lambda: _FakePlaceholder(events))
|
||||
monkeypatch.setattr(common.st, "container", lambda **kwargs: _FakeKeyedContainer(events, kwargs["key"]))
|
||||
monkeypatch.setattr(common.st, "info", lambda message: events.append(("info", message)))
|
||||
common.render_dashboard_body("거래내역", lambda: events.append(("render", "거래내역")))
|
||||
assert events[0] == ("empty", "placeholder")
|
||||
assert ("open", "dashboard_body") in events
|
||||
assert ("open", "dashboard_loading") in events
|
||||
assert ("open", "dashboard_content") in events
|
||||
assert events.index(("info", "거래내역 불러오는 중…")) < events.index(("render", "거래내역"))
|
||||
assert events.index(("render", "거래내역")) < len(events) - 1
|
||||
assert events.count(("empty", "placeholder")) >= 2
|
||||
|
||||
|
||||
def test_body_error_is_local_and_retry_rerenders_target(monkeypatch):
|
||||
events = []
|
||||
monkeypatch.setattr(common.st, "empty", lambda: _FakePlaceholder(events))
|
||||
monkeypatch.setattr(common.st, "container", lambda **kwargs: _FakeKeyedContainer(events, kwargs["key"]))
|
||||
monkeypatch.setattr(common.st, "info", lambda message: events.append(("info", message)))
|
||||
monkeypatch.setattr(common.st, "error", lambda message: events.append(("error", message)))
|
||||
monkeypatch.setattr(common.st, "button", lambda *args, **kwargs: events.append(("retry", args[0])) or True)
|
||||
monkeypatch.setattr(common.st, "rerun", lambda: events.append(("rerun", "target")))
|
||||
common.render_dashboard_body("요약", lambda: (_ for _ in ()).throw(RuntimeError("secret details")))
|
||||
assert ("error", "페이지를 불러오지 못했습니다. 다시 시도해 주세요.") in events
|
||||
assert ("rerun", "target") in events
|
||||
assert all("secret details" not in str(event) for event in events)
|
||||
|
||||
|
||||
def test_rapid_menu_clicks_commit_latest_selection_only(monkeypatch):
|
||||
query = {"page": "요약", "filter": "pending"}
|
||||
state = {"dashboard_page": "요약"}
|
||||
callback = {}
|
||||
monkeypatch.setattr(common.st, "query_params", query)
|
||||
monkeypatch.setattr(common.st, "session_state", state)
|
||||
monkeypatch.setattr(common.st, "radio", lambda *args, **kwargs: callback.setdefault("fn", kwargs["on_change"]) or state["dashboard_page"])
|
||||
common.render_sidebar_nav(PAGES, "요약")
|
||||
for page in ("거래내역", "자산/마감", "Import"):
|
||||
state["dashboard_page"] = page
|
||||
callback["fn"]()
|
||||
assert query == {"page": "Import", "filter": "pending"}
|
||||
|
||||
|
||||
def test_refresh_control_preserves_cache_clear_contract(monkeypatch):
|
||||
events = []
|
||||
|
||||
class ExpectedRerun(BaseException):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: True)
|
||||
monkeypatch.setattr(app, "get_db_path", lambda: "fixture.sqlite3")
|
||||
monkeypatch.setattr(app, "inject_app_css", lambda: None)
|
||||
monkeypatch.setattr(app.st, "query_params", {"page": "요약"})
|
||||
monkeypatch.setattr(app, "render_sidebar_nav", lambda pages, page: page)
|
||||
monkeypatch.setattr(app.st, "sidebar", pytest.importorskip("contextlib").nullcontext())
|
||||
monkeypatch.setattr(app.st, "button", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(app, "clear_cache", lambda: events.append("clear_cache"))
|
||||
|
||||
def rerun():
|
||||
events.append("rerun")
|
||||
raise ExpectedRerun
|
||||
|
||||
monkeypatch.setattr(app.st, "rerun", rerun)
|
||||
|
||||
with pytest.raises(ExpectedRerun):
|
||||
app.main()
|
||||
assert events == ["clear_cache", "rerun"]
|
||||
|
||||
|
||||
class _FakePlaceholder:
|
||||
def __init__(self, events):
|
||||
self.events = events
|
||||
|
||||
def container(self):
|
||||
return _FakeContainer(self.events)
|
||||
|
||||
def info(self, message):
|
||||
self.events.append(("info", message))
|
||||
|
||||
def empty(self):
|
||||
self.events.append(("empty", "placeholder"))
|
||||
|
||||
|
||||
class _FakeContainer:
|
||||
def __init__(self, events):
|
||||
self.events = events
|
||||
|
||||
def __enter__(self):
|
||||
self.events.append(("container", "open"))
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.events.append(("container", "close"))
|
||||
return False
|
||||
|
||||
|
||||
class _FakeKeyedContainer:
|
||||
def __init__(self, events, key):
|
||||
self.events = events
|
||||
self.key = key
|
||||
|
||||
def __enter__(self):
|
||||
self.events.append(("open", self.key))
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.events.append(("close", self.key))
|
||||
return False
|
||||
|
||||
def empty(self):
|
||||
self.events.append(("empty", self.key))
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT_DIR))
|
||||
|
||||
from dashboard import authentication
|
||||
|
||||
|
||||
ADMIN_ID = "admin"
|
||||
ADMIN_PASSWORD = "correct horse battery staple"
|
||||
SIGNING_SECRET = "s" * 64
|
||||
|
||||
|
||||
def _settings() -> authentication.AuthSettings:
|
||||
return authentication.AuthSettings(
|
||||
admin_id=ADMIN_ID,
|
||||
admin_password=ADMIN_PASSWORD,
|
||||
signing_secret=SIGNING_SECRET,
|
||||
)
|
||||
|
||||
|
||||
def test_load_auth_settings_requires_signing_secret(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID)
|
||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD)
|
||||
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV, raising=False)
|
||||
|
||||
assert authentication.load_auth_settings() is None
|
||||
|
||||
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET)
|
||||
assert authentication.load_auth_settings() == _settings()
|
||||
|
||||
|
||||
def test_issue_and_verify_default_token_without_refresh() -> None:
|
||||
settings = _settings()
|
||||
issued = authentication.issue_persistent_token(
|
||||
settings,
|
||||
remember_me=False,
|
||||
now=1_700_000_000,
|
||||
token_id="fixed-token-id",
|
||||
)
|
||||
|
||||
verified = authentication.verify_persistent_token(
|
||||
settings,
|
||||
issued.value,
|
||||
now=1_700_000_001,
|
||||
)
|
||||
|
||||
assert verified is not None
|
||||
assert verified.issued_at == 1_700_000_000
|
||||
assert verified.expires_at == 1_700_086_400
|
||||
assert issued.max_age == authentication.DEFAULT_TOKEN_TTL_SECONDS
|
||||
assert verified.token_id == "fixed-token-id"
|
||||
assert ADMIN_PASSWORD not in issued.value
|
||||
assert SIGNING_SECRET not in issued.value
|
||||
|
||||
|
||||
def test_remember_me_token_uses_30_day_absolute_expiry() -> None:
|
||||
issued = authentication.issue_persistent_token(
|
||||
_settings(),
|
||||
remember_me=True,
|
||||
now=1_700_000_000,
|
||||
token_id="fixed-token-id",
|
||||
)
|
||||
|
||||
assert issued.expires_at == 1_702_592_000
|
||||
assert issued.max_age == authentication.REMEMBER_ME_TOKEN_TTL_SECONDS
|
||||
|
||||
|
||||
def test_token_expiry_is_absolute_and_not_sliding() -> None:
|
||||
settings = _settings()
|
||||
issued = authentication.issue_persistent_token(
|
||||
settings,
|
||||
remember_me=False,
|
||||
now=1_700_000_000,
|
||||
token_id="fixed-token-id",
|
||||
)
|
||||
|
||||
assert authentication.verify_persistent_token(
|
||||
settings, issued.value, now=issued.expires_at - 1
|
||||
) is not None
|
||||
assert authentication.verify_persistent_token(
|
||||
settings, issued.value, now=issued.expires_at
|
||||
) is None
|
||||
|
||||
|
||||
def test_tampering_and_rotations_invalidate_token() -> None:
|
||||
settings = _settings()
|
||||
issued = authentication.issue_persistent_token(
|
||||
settings,
|
||||
remember_me=True,
|
||||
now=1_700_000_000,
|
||||
token_id="fixed-token-id",
|
||||
)
|
||||
replacement = "A" if issued.value[-1] != "A" else "B"
|
||||
tampered = issued.value[:-1] + replacement
|
||||
|
||||
assert authentication.verify_persistent_token(
|
||||
settings, tampered, now=1_700_000_001
|
||||
) is None
|
||||
assert authentication.verify_persistent_token(
|
||||
replace(settings, signing_secret="n" * 64),
|
||||
issued.value,
|
||||
now=1_700_000_001,
|
||||
) is None
|
||||
assert authentication.verify_persistent_token(
|
||||
replace(settings, admin_password="rotated password"),
|
||||
issued.value,
|
||||
now=1_700_000_001,
|
||||
) is None
|
||||
assert authentication.verify_persistent_token(
|
||||
replace(settings, admin_id="rotated-admin"),
|
||||
issued.value,
|
||||
now=1_700_000_001,
|
||||
) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{},
|
||||
{"v": 2, "sub": ADMIN_ID, "iat": 1, "exp": 2, "jti": "x"},
|
||||
{"v": 1, "sub": "", "iat": 1, "exp": 2, "jti": "x"},
|
||||
{"v": 1, "sub": ADMIN_ID, "iat": "1", "exp": 2, "jti": "x"},
|
||||
{"v": 1, "sub": ADMIN_ID, "iat": 2, "exp": 2, "jti": "x"},
|
||||
{"v": 1, "sub": ADMIN_ID, "iat": 1, "exp": 2, "jti": ""},
|
||||
{"v": 1, "sub": ADMIN_ID, "iat": 1, "exp": 2, "jti": "x", "extra": 1},
|
||||
],
|
||||
)
|
||||
def test_claim_schema_is_strict(payload: dict[str, object]) -> None:
|
||||
token = authentication._serializer_for(_settings()).dumps(payload)
|
||||
|
||||
assert authentication.verify_persistent_token(
|
||||
_settings(), token, now=1
|
||||
) is None
|
||||
|
||||
|
||||
def test_oversized_and_malformed_tokens_are_rejected() -> None:
|
||||
settings = _settings()
|
||||
|
||||
assert authentication.verify_persistent_token(settings, "x" * 10_000, now=1) is None
|
||||
assert authentication.verify_persistent_token(settings, "not-a-token", now=1) is None
|
||||
|
||||
|
||||
def test_payload_does_not_encode_secrets() -> None:
|
||||
settings = replace(_settings(), admin_id="configured-admin-identity")
|
||||
issued = authentication.issue_persistent_token(
|
||||
settings,
|
||||
remember_me=False,
|
||||
now=1_700_000_000,
|
||||
token_id="fixed-token-id",
|
||||
)
|
||||
payload = authentication._serializer_for(settings).loads(issued.value)
|
||||
|
||||
assert set(payload) == {"v", "sub", "iat", "exp", "jti"}
|
||||
assert "password" not in json.dumps(payload).lower()
|
||||
assert "secret" not in json.dumps(payload).lower()
|
||||
assert settings.admin_id not in json.dumps(payload)
|
||||
|
||||
|
||||
def test_remember_me_token_expires_at_exact_30_day_boundary() -> None:
|
||||
settings = _settings()
|
||||
issued = authentication.issue_persistent_token(
|
||||
settings, remember_me=True, now=1_700_000_000, token_id="boundary-token-id"
|
||||
)
|
||||
|
||||
assert authentication.verify_persistent_token(
|
||||
settings, issued.value, now=issued.expires_at - 1
|
||||
) is not None
|
||||
assert authentication.verify_persistent_token(
|
||||
settings, issued.value, now=issued.expires_at
|
||||
) is None
|
||||
@@ -0,0 +1,229 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
import streamlit_browser_evidence as evidence
|
||||
|
||||
|
||||
SECRET_VALUES = ("synthetic-password", "a" * 64, "cookie-value")
|
||||
SURFACE_STYLE = {
|
||||
"backgroundImage": "linear-gradient(rgb(1, 2, 3), rgb(4, 5, 6))",
|
||||
"backgroundColor": "rgb(255, 255, 255)",
|
||||
"visibility": "visible",
|
||||
"display": "block",
|
||||
"opacity": "1",
|
||||
"rendered": True,
|
||||
}
|
||||
|
||||
|
||||
def surface_records() -> dict[str, dict[str, object]]:
|
||||
return {
|
||||
"stAppViewContainer": dict(SURFACE_STYLE),
|
||||
"stSidebar": dict(SURFACE_STYLE),
|
||||
}
|
||||
|
||||
|
||||
def test_redact_secrets_removes_values_from_nested_manifest() -> None:
|
||||
payload = {"url": "https://127.0.0.1/?token=cookie-value", "nested": ["synthetic-password", {"key": "a" * 64}]}
|
||||
redacted = evidence.redact_secrets(payload, SECRET_VALUES)
|
||||
serialized = json.dumps(redacted)
|
||||
assert all(secret not in serialized for secret in SECRET_VALUES)
|
||||
assert redacted["nested"][0] == "[REDACTED]"
|
||||
|
||||
|
||||
def test_transition_requires_synthetic_fixture_database(tmp_path: Path) -> None:
|
||||
with pytest.raises(SystemExit, match="fixture.sqlite3"):
|
||||
evidence.assert_synthetic_db_path(tmp_path / "operating.sqlite3")
|
||||
|
||||
|
||||
def test_validate_transition_manifest_requires_observations() -> None:
|
||||
manifest = {
|
||||
"mode": "transition",
|
||||
"success": True,
|
||||
"observations": {"frames": [], "navigation_events": []},
|
||||
}
|
||||
with pytest.raises(ValueError, match="frames"):
|
||||
evidence.validate_transition_manifest(manifest)
|
||||
|
||||
|
||||
def complete_manifest() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": "transition",
|
||||
"success": True,
|
||||
"observations": {
|
||||
"frames": [
|
||||
{
|
||||
"at_ms": 0,
|
||||
"selected_menu": "요약",
|
||||
"body_text_hash": "hash",
|
||||
"loading": False,
|
||||
"prior_body_visible": True,
|
||||
"surfaces": surface_records(),
|
||||
},
|
||||
{
|
||||
"at_ms": 100,
|
||||
"selected_menu": "분류 규칙",
|
||||
"body_text_hash": "hash2",
|
||||
"loading": True,
|
||||
"prior_body_visible": False,
|
||||
"surfaces": surface_records(),
|
||||
},
|
||||
],
|
||||
"document_navigation_events": [],
|
||||
"history_events": [{"type": "pushState", "url": "/?page=rules"}],
|
||||
"stable_shell": {
|
||||
"document_marker": {"pre": "doc-1", "post": "doc-1"},
|
||||
"sidebar_marker": {"pre": "side-1", "post": "side-1"},
|
||||
"background": {"pre": "rgb(255, 255, 255)", "post": "rgb(255, 255, 255)"},
|
||||
"surfaces": {"pre": surface_records(), "post": surface_records()},
|
||||
},
|
||||
"loading_observed": True,
|
||||
"prior_body_removed": True,
|
||||
"target_page": "분류 규칙",
|
||||
"final_agreement": True,
|
||||
},
|
||||
"runtime": {
|
||||
"viewport": {"width": 1440, "height": 1400},
|
||||
"interval_ms": 50,
|
||||
"host_architecture": "aarch64",
|
||||
"browser_user_agent": "Synthetic Browser",
|
||||
},
|
||||
"source_revision": evidence.current_git_revision(),
|
||||
"source_revision_kind": "git-head-base",
|
||||
"source_worktree_dirty": evidence.git_worktree_dirty(("dashboard/app.py", "dashboard/common.py")),
|
||||
"source_files": evidence.source_file_digests(("dashboard/app.py", "dashboard/common.py")),
|
||||
"evidence_command": ["scripts/streamlit_browser_evidence.py", "--mode", "transition"],
|
||||
"command": {"exit_code": 0, "teardown": "complete"},
|
||||
"limitations": [],
|
||||
}
|
||||
|
||||
|
||||
def test_validate_transition_manifest_accepts_complete_secret_free_shape() -> None:
|
||||
evidence.validate_transition_manifest(complete_manifest())
|
||||
|
||||
|
||||
def test_wait_for_http_uses_verified_tls_by_default_and_fixture_context_opt_in() -> None:
|
||||
response = type("Response", (), {"status": 200, "__enter__": lambda self: self, "__exit__": lambda *args: None})()
|
||||
with patch.object(evidence.urllib.request, "urlopen", return_value=response) as urlopen:
|
||||
evidence.wait_for_http("https://example.test")
|
||||
assert urlopen.call_args.kwargs["context"] is None
|
||||
fixture_context = ssl._create_unverified_context()
|
||||
with patch.object(evidence.urllib.request, "urlopen", return_value=response) as urlopen:
|
||||
evidence.wait_for_http("https://127.0.0.1", ssl_context=fixture_context)
|
||||
assert urlopen.call_args.kwargs["context"] is fixture_context
|
||||
|
||||
|
||||
def test_validate_transition_manifest_rejects_fabricated_navigation_event() -> None:
|
||||
manifest = complete_manifest()
|
||||
manifest["observations"]["document_navigation_events"] = [{"type": "load", "url": "/"}]
|
||||
with pytest.raises(ValueError, match="document navigation"):
|
||||
evidence.validate_transition_manifest(manifest)
|
||||
|
||||
|
||||
def test_validate_transition_manifest_requires_observed_loading_without_prior_body() -> None:
|
||||
no_loading = complete_manifest()
|
||||
no_loading["observations"]["loading_observed"] = False
|
||||
with pytest.raises(ValueError, match="loading"):
|
||||
evidence.validate_transition_manifest(no_loading)
|
||||
|
||||
stale_body = complete_manifest()
|
||||
stale_body["observations"]["frames"][1]["prior_body_visible"] = True
|
||||
with pytest.raises(ValueError, match="prior body"):
|
||||
evidence.validate_transition_manifest(stale_body)
|
||||
|
||||
|
||||
def test_validate_transition_manifest_requires_pre_post_shell_identity() -> None:
|
||||
manifest = complete_manifest()
|
||||
manifest["observations"]["stable_shell"] = {"sidebar": True, "background": "#fff"}
|
||||
with pytest.raises(ValueError, match="shell"):
|
||||
evidence.validate_transition_manifest(manifest)
|
||||
|
||||
|
||||
def test_validate_transition_manifest_rejects_unselected_target_body_agreement() -> None:
|
||||
manifest = complete_manifest()
|
||||
manifest["observations"]["final_agreement"] = False
|
||||
with pytest.raises(ValueError, match="agreement"):
|
||||
evidence.validate_transition_manifest(manifest)
|
||||
|
||||
|
||||
def test_validate_transition_manifest_rejects_attestation_before_teardown() -> None:
|
||||
manifest = complete_manifest()
|
||||
manifest["command"] = {"exit_code": 0, "teardown": "running"}
|
||||
with pytest.raises(ValueError, match="teardown"):
|
||||
evidence.validate_transition_manifest(manifest)
|
||||
|
||||
|
||||
def test_validate_transition_manifest_requires_exact_source_and_runtime_identity() -> None:
|
||||
manifest = complete_manifest()
|
||||
manifest.pop("source_files")
|
||||
with pytest.raises(ValueError, match="source files"):
|
||||
evidence.validate_transition_manifest(manifest)
|
||||
|
||||
manifest = complete_manifest()
|
||||
manifest["runtime"].pop("browser_user_agent")
|
||||
with pytest.raises(ValueError, match="runtime identity"):
|
||||
evidence.validate_transition_manifest(manifest)
|
||||
|
||||
manifest = complete_manifest()
|
||||
manifest["source_files"]["dashboard/app.py"] = "0" * 64
|
||||
with pytest.raises(ValueError, match="source file digests"):
|
||||
evidence.validate_transition_manifest(manifest)
|
||||
|
||||
|
||||
def test_transition_manifest_rejects_secret_text() -> None:
|
||||
manifest = {"mode": "transition", "success": False, "error": "synthetic-password"}
|
||||
with pytest.raises(ValueError, match="secret"):
|
||||
evidence.validate_transition_manifest(manifest, SECRET_VALUES)
|
||||
|
||||
|
||||
def test_validate_transition_manifest_requires_surface_style_evidence_on_every_frame() -> None:
|
||||
manifest = complete_manifest()
|
||||
manifest["observations"]["frames"][1]["surfaces"].pop("stSidebar")
|
||||
with pytest.raises(ValueError, match="surface"):
|
||||
evidence.validate_transition_manifest(manifest)
|
||||
|
||||
|
||||
def test_validate_transition_manifest_requires_stable_surface_style_evidence() -> None:
|
||||
manifest = complete_manifest()
|
||||
manifest["observations"]["stable_shell"]["surfaces"]["post"]["stSidebar"]["opacity"] = "0"
|
||||
with pytest.raises(ValueError, match="surface"):
|
||||
evidence.validate_transition_manifest(manifest)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
(
|
||||
("backgroundImage", "none"),
|
||||
("backgroundColor", ""),
|
||||
("visibility", "hidden"),
|
||||
("display", "none"),
|
||||
("opacity", "0"),
|
||||
("opacity", "NaN"),
|
||||
("rendered", False),
|
||||
),
|
||||
)
|
||||
def test_validate_transition_manifest_rejects_non_visible_surface_styles(field: str, value: object) -> None:
|
||||
manifest = complete_manifest()
|
||||
observations = manifest["observations"]
|
||||
for frame in observations["frames"]:
|
||||
frame["surfaces"]["stAppViewContainer"][field] = value
|
||||
observations["stable_shell"]["surfaces"]["pre"]["stAppViewContainer"][field] = value
|
||||
observations["stable_shell"]["surfaces"]["post"]["stAppViewContainer"][field] = value
|
||||
with pytest.raises(ValueError, match="surface"):
|
||||
evidence.validate_transition_manifest(manifest)
|
||||
|
||||
|
||||
def test_validate_transition_manifest_rejects_missing_stable_shell_markers() -> None:
|
||||
manifest = complete_manifest()
|
||||
manifest["observations"]["stable_shell"]["document_marker"] = {"pre": None, "post": None}
|
||||
with pytest.raises(ValueError, match="shell"):
|
||||
evidence.validate_transition_manifest(manifest)
|
||||
@@ -469,7 +469,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "jarca-ledger-mcp"
|
||||
version = "0.1.1"
|
||||
version = "0.1.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mcp" },
|
||||
@@ -481,27 +481,35 @@ dependencies = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
dashboard = [
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "pandas" },
|
||||
{ name = "plotly" },
|
||||
{ name = "starlette" },
|
||||
{ name = "streamlit" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "itsdangerous", marker = "extra == 'dashboard'", specifier = ">=2.2.0,<3.0" },
|
||||
{ name = "mcp", specifier = ">=1.0.0" },
|
||||
{ name = "msoffcrypto-tool", specifier = ">=5.4.0" },
|
||||
{ name = "openpyxl", specifier = ">=3.1.0" },
|
||||
{ name = "pandas", marker = "extra == 'dashboard'", specifier = ">=2.0.0" },
|
||||
{ name = "plotly", marker = "extra == 'dashboard'", specifier = ">=5.0.0" },
|
||||
{ name = "pandas", marker = "extra == 'dashboard'", specifier = ">=2.2.0" },
|
||||
{ name = "plotly", marker = "extra == 'dashboard'", specifier = ">=5.20.0" },
|
||||
{ name = "pymupdf", specifier = ">=1.24.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0" },
|
||||
{ name = "streamlit", marker = "extra == 'dashboard'", specifier = ">=1.30.0" },
|
||||
{ name = "starlette", marker = "extra == 'dashboard'", specifier = ">=0.40.0,<2.0" },
|
||||
{ name = "streamlit", marker = "extra == 'dashboard'", specifier = ">=1.58.0,<1.59.0" },
|
||||
{ name = "uvicorn", marker = "extra == 'dashboard'", specifier = ">=0.30.0,<1.0" },
|
||||
{ name = "websockets", marker = "extra == 'dev'", specifier = ">=15.0,<17.0" },
|
||||
]
|
||||
provides-extras = ["dev", "dashboard"]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user