18 Commits
Author SHA1 Message Date
gitea-release-agent ffd943fe65 chore(release): prepare v0.1.3
Merge the independently reviewed v0.1.3 release preparation.
2026-10-04 07:34:51 +00:00
genichin 28527a0914 [verified] chore(release): prepare v0.1.3 2026-10-04 16:32:23 +09:00
genichin 6fe2594c09 Merge pull request 'feat(dashboard): persist administrator login' (#7) from gitea-agent/issue-6-dashboard-persistent-auth into main
Reviewed-on: #7
Reviewed-by: gitea-spec-agent <gitea-spec-agent@mail.jsfamily.quest>
Reviewed-by: gitea-review-agent <gitea-review-agent@mail.jsfamily.quest>
2026-10-04 07:13:11 +00:00
gitea-impl-agent 6a35775cca [verified] docs(evidence): record F-003 verification 2026-10-04 03:56:53 +00:00
gitea-impl-agent d84ed8ff2a [verified] fix(auth): support Unicode credentials 2026-10-04 03:53:47 +00:00
gitea-impl-agent 755c26865c docs(auth): preserve login runtime evidence 2026-10-03 00:17:44 +00:00
gitea-impl-agent e7242a89f7 fix(auth): bound asynchronous login handling 2026-10-02 12:28:52 +00:00
gitea-impl-agent 3311866431 docs(auth): restore successful login contract 2026-10-02 07:35:01 +00:00
gitea-impl-agent 8544ea30f4 docs(auth): restore legacy acceptance contracts 2026-10-02 06:09:11 +00:00
gitea-impl-agent 7056e012a7 docs(auth): complete persistent token contracts 2026-10-02 04:48:06 +00:00
gitea-impl-agent 9bc64699cb docs(evidence): bind persistent auth browser run 2026-10-02 03:04:24 +00:00
gitea-impl-agent 7bb959859a fix(dashboard): address persistent auth review 2026-10-02 03:02:23 +00:00
gitea-impl-agent 91e106d75c docs(requirements): link persistent auth PR 2026-10-01 13:58:32 +00:00
gitea-impl-agent c5eaddb4f3 feat(dashboard): persist administrator login 2026-10-01 13:56:22 +00:00
gitea-release-agent 7bea8b368e feat(deploy): run v0.1.2 as non-root
Merge the independently reviewed non-root image and hardened k3s deployment contract.
2026-09-30 11:44:59 +00:00
genichin ee3e3852ee [verified] feat(deploy): run v0.1.2 as non-root 2026-09-30 20:41:56 +09:00
gitea-release-agent bf746e0cf3 chore(release): reconcile v0.1.1 with main
Integrate the verified v0.1.1 release history before v0.1.2 preparation.
2026-09-30 08:06:38 +00:00
genichin 7a120bb068 chore(release): prepare v0.1.1 2026-09-30 16:03:30 +09:00
33 changed files with 2661 additions and 744 deletions
+3
View File
@@ -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
+102
View File
@@ -0,0 +1,102 @@
schema: gitea-agent/release/v1
version:
sources:
- path: pyproject.toml
format: toml
selector: project.version
- path: jarca_ledger_mcp/__init__.py
format: python-assignment
selector: __version__
lockfiles:
- path: uv.lock
package: jarca-ledger-mcp
tag_template: "v{version}"
prepare:
runtime:
python: "3.12"
commands:
- name: sync
argv: [uv, sync, --python, "3.12", --frozen, --extra, dev, --extra, dashboard]
- name: lint
argv: [uv, run, ruff, check, .]
- name: test
argv: [uv, run, pytest]
- name: dashboard-http-smoke
argv: [uv, run, python, scripts/ci_dashboard_http_smoke.py, --host, 127.0.0.1, --port, "18501", --timeout, "45"]
- name: db-safety-smoke
argv: [uv, run, python, scripts/ci_db_safety_smoke.py, --timeout, "45"]
artifacts:
- type: container
name: jarca-ledger-mcp
platform: linux/arm64
build:
adapter: docker-buildx
context: .
dockerfile: Dockerfile
pull: true
local_tag_template: "jarca-ledger-mcp:release-{commit}"
labels:
org.opencontainers.image.source: "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp"
org.opencontainers.image.revision: "{commit}"
org.opencontainers.image.version: "{version}"
smoke:
- type: image-platform
expected_os: linux
expected_architecture: arm64
- type: container-command
environment:
JARCA_LEDGER_DB_PATH: /data/ledger.sqlite3
argv:
- python
- -c
- >-
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}"
command: default
expected: healthy
timeout_seconds: 90
- type: container-http
name_template: "jarca-dashboard-{run_id}"
command:
- uvicorn
- dashboard.server:app
- --host
- 0.0.0.0
- --port
- "8501"
- --proxy-headers
container_port: 8501
path: /_stcore/health
expected_status: 200
expected_body: ok
timeout_seconds: 90
publish:
adapter: gitea-container-registry
registry: gitea.jsfamily.quest
repository: jsfamily/jarca-ledger-mcp
credentials:
username: authenticated-gitea-login
token_env: GITEA_TOKEN
tags:
- "{tag}"
- "sha-{commit}"
overwrite: false
verify:
- digest
- platform
- org.opencontainers.image.revision
-134
View File
@@ -1,134 +0,0 @@
name: Verify and publish ARM64 image
on:
pull_request:
paths:
- Dockerfile
- .dockerignore
- pyproject.toml
- uv.lock
- jarca_ledger_mcp/**
- dashboard/**
- .github/workflows/publish-image.yml
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: image-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify-arm64:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- uses: docker/setup-buildx-action@v3
- name: Build ARM64 verification image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
load: true
push: false
tags: jarca-ledger-mcp:arm64-verify
cache-from: type=gha,scope=jarca-ledger-arm64
cache-to: type=gha,mode=max,scope=jarca-ledger-arm64
- name: Verify image platform, imports, and SQLite
run: |
set -euo pipefail
test "$(docker image inspect jarca-ledger-mcp:arm64-verify --format '{{.Architecture}}')" = arm64
docker run --rm --platform linux/arm64 \
-e JARCA_LEDGER_DB_PATH=/tmp/ledger.sqlite3 \
jarca-ledger-mcp:arm64-verify \
python -c 'import platform, cryptography, numpy, pandas, pyarrow, pymupdf; from jarca_ledger_mcp import service; result=service.health_check(); assert platform.machine() == "aarch64"; assert result["success"], result; print(result["schema_version"])'
- name: Verify MCP and Dashboard startup
run: |
set -euo pipefail
trap 'docker rm -f jarca-mcp-smoke jarca-dashboard-smoke >/dev/null 2>&1 || true' EXIT
docker run -d --name jarca-mcp-smoke --platform linux/arm64 \
jarca-ledger-mcp:arm64-verify
for _ in $(seq 1 60); do
status=$(docker inspect --format '{{.State.Health.Status}}' jarca-mcp-smoke)
test "$status" = healthy && break
test "$status" = unhealthy && { docker logs jarca-mcp-smoke; exit 1; }
sleep 1
done
test "$(docker inspect --format '{{.State.Health.Status}}' jarca-mcp-smoke)" = healthy
docker run -d --name jarca-dashboard-smoke --platform linux/arm64 \
-p 127.0.0.1::8501 jarca-ledger-mcp:arm64-verify \
python -m streamlit run dashboard/app.py \
--server.address 0.0.0.0 --server.port 8501 \
--server.headless true --browser.gatherUsageStats false
port=$(docker port jarca-dashboard-smoke 8501/tcp | sed 's/.*://')
code=000
for _ in $(seq 1 90); do
code=$(curl -sS -o /tmp/dashboard-health -w '%{http_code}' \
"http://127.0.0.1:${port}/_stcore/health" || true)
test "$code" = 200 && break
sleep 1
done
test "$code" = 200
test "$(tr -d '\n' < /tmp/dashboard-health)" = ok
publish:
if: startsWith(github.ref, 'refs/tags/v')
needs: verify-arm64
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish immutable ARM64 image
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
push: true
tags: |
ghcr.io/genichin/jarca-ledger-mcp:${{ github.ref_name }}
ghcr.io/genichin/jarca-ledger-mcp:sha-${{ github.sha }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=${{ github.ref_name }}
cache-from: type=gha,scope=jarca-ledger-arm64
cache-to: type=gha,mode=max,scope=jarca-ledger-arm64
provenance: mode=max
sbom: true
- name: Report published digest
run: |
echo "ghcr.io/genichin/jarca-ledger-mcp:${GITHUB_REF_NAME}@${{ steps.build.outputs.digest }}"
+13 -3
View File
@@ -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"]
+18 -12
View File
@@ -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를 설정합니다.
- 배포 이미지는 가변 태그 대신 검증된 GHCR digest로 고정합니다.
- 배포 이미지는 가변 태그 대신 검증된 `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가 시작을 거부합니다. GHCR 릴리즈를 배포할 때는 두 서비스의 `image`를 검증된 동일 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 후 저장하는 흐름으로 확장합니다.
+191
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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())
+51
View File
@@ -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만 재해 복구로 간주하지 않습니다.
+185
View File
@@ -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.3
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.3
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.3
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: {}
+9
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: jarca-ledger
+14
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jarca-ledger-data
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 1Gi
+13
View File
@@ -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
View File
@@ -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."
]
}
+131 -62
View File
@@ -1,154 +1,223 @@
# 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
+45 -45
View File
@@ -1,101 +1,101 @@
# 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
├── common.py
├── app.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 | gate then page dispatch | `main()` | no token signing details | 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
### 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_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_authentication_app.py` | test | gate ordering and cleanup/UI | AppTest | fixture DB only | source change paired with test | developer |
### deployment-docs — 배포 설정과 계약 문서
### deployment-docs — 운영 문서
```text
.env.example
docker-compose.yml
docs/requirements/req-001_dashboard-administrator-authentication.md
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/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.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/`; no DB session module |
| 이름·패키지 | use existing `dashboard` package and snake_case modules |
| 소스·테스트 대응 | auth source behavior requires focused pytest/AppTest coverage |
| 생성 산출물 | 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
@@ -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`
- 관련 요구사항: 없음
+77 -54
View File
@@ -1,91 +1,114 @@
# 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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Jarca Ledger MCP server."""
__version__ = "0.1.0"
__version__ = "0.1.3"
+8 -5
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jarca-ledger-mcp"
version = "0.1.0"
version = "0.1.3"
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]
+14 -13
View File
@@ -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,
+153
View File
@@ -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()
+8 -7
View File
@@ -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
+386
View File
@@ -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
+10 -4
View File
@@ -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():
+85 -76
View File
@@ -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,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
Generated
+12 -4
View File
@@ -469,7 +469,7 @@ wheels = [
[[package]]
name = "jarca-ledger-mcp"
version = "0.1.0"
version = "0.1.3"
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"]