diff --git a/AGENTS.md b/AGENTS.md index 3f5b2646e..2a9f10c65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,9 +132,9 @@ `source_uid` values, signed-session organization scope, and persisted writeback eligibility, not sequential CalDAV or WebDAV account ids. Missing writeback eligibility must fail closed. Browser-visible source ids - must not reveal account primary keys, and provider mutations remain future - work until connector execution can enforce capability, consent, and - ETag/If-Match checks. + must not reveal or be deterministically derived from account primary keys, and + provider mutations remain future work until connector execution can enforce + capability, consent, and ETag/If-Match checks. - Self-sent knowledge extraction must first prove true self-to-self addressing, stay idempotent per source email, preserve email/thread provenance, and store only plain-text task titles. Do not create unlinked knowledge tasks from raw diff --git a/README.md b/README.md index b8126a661..6f2c5ad9d 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,8 @@ enforce ETag/If-Match and owner capability checks. WebDAV writeback and self-sent knowledge materialization use `webdav_accounts.source_uid` as the browser-visible source id, scope lookup by the signed session organization, honor persisted `writeback_enabled` -eligibility, and reject legacy `target_account_id` payloads. +eligibility, reject legacy `target_account_id` payloads, and keep sequential +`account_id` values internal-only. ## Operations and release docs diff --git a/backend/scripts/bootstrap_db.py b/backend/scripts/bootstrap_db.py index c296a6df2..053fe42ea 100644 --- a/backend/scripts/bootstrap_db.py +++ b/backend/scripts/bootstrap_db.py @@ -109,7 +109,8 @@ def schema_backfill_sql(): text( "UPDATE webdav_accounts " "SET source_uid = 'webdav_src_' || md5(" - "account_id::text || ':' || user_id || ':' || server_url" + "random()::text || ':' || clock_timestamp()::text || ':' || " + "user_id || ':' || server_url" ") " "WHERE source_uid IS NULL OR source_uid = ''" ), diff --git a/backend/tests/test_bootstrap_db.py b/backend/tests/test_bootstrap_db.py index 13f6ada5e..60d8e27c6 100644 --- a/backend/tests/test_bootstrap_db.py +++ b/backend/tests/test_bootstrap_db.py @@ -150,8 +150,11 @@ def test_schema_backfill_adds_threading_columns_for_existing_tables(monkeypatch) "update webdav_accounts set source_uid" in statement and "webdav_src_" in statement and "md5" in statement + and "random()::text" in statement + and "clock_timestamp()::text" in statement for statement in statements ) + assert not any("account_id::text" in statement for statement in statements) assert any( "alter table webdav_accounts alter column source_uid set not null" in statement diff --git a/backend/tests/test_webdav_api.py b/backend/tests/test_webdav_api.py index 076448e52..3655a0edc 100644 --- a/backend/tests/test_webdav_api.py +++ b/backend/tests/test_webdav_api.py @@ -16,7 +16,7 @@ from core.config import settings from main import app -from api.auth import get_auth_context +from api.auth import get_auth_context, get_current_user from db.models import TicketTask from db.session import get_db from services.webdav_service import WebDavService, webdav_service @@ -150,14 +150,78 @@ def auth_client(): ) as client: yield client + +def _request_with_signed_session(method: str, path: str, json_body: dict | None = None): + previous_secret = settings.AUTH_SESSION_HMAC_SECRET + original_overrides = dict(app.dependency_overrides) + settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) + token = _signed_session_token(_valid_session_payload()) + app.dependency_overrides.pop(get_auth_context, None) + app.dependency_overrides.pop(get_current_user, None) + try: + with TestClient(app) as client: + return client.request( + method, + path, + json=json_body, + headers={"Authorization": f"Bearer {token}"}, + ) + finally: + settings.AUTH_SESSION_HMAC_SECRET = previous_secret + app.dependency_overrides.clear() + app.dependency_overrides.update(original_overrides) + + +def _request_without_signed_session( + method: str, + path: str, + json_body: dict | None = None, +): + original_overrides = dict(app.dependency_overrides) + app.dependency_overrides.pop(get_auth_context, None) + app.dependency_overrides.pop(get_current_user, None) + try: + with TestClient(app) as client: + return client.request( + method, + path, + json=json_body, + headers={ + "X-User-Id": "alice", + "X-Organization-Id": "org-acme", + }, + ) + finally: + app.dependency_overrides.clear() + app.dependency_overrides.update(original_overrides) + + def test_get_webdav_accounts(auth_client): response = auth_client.get("/api/webdav/accounts") assert response.status_code == 200, response.text body = response.json() assert len(body) > 0 + assert body[0]["source_id"] == "webdav_src_demo_primary" assert body[0]["server_url"] == "https://webdav.naruon.net" assert body[0]["username"] == "demo_user" assert body[0]["writeback_enabled"] is True + assert "account_id" not in body[0] + + +def test_get_webdav_accounts_accepts_signed_bearer_session(): + response = _request_with_signed_session("GET", "/api/webdav/accounts") + + assert response.status_code == 200, response.text + body = response.json() + assert body[0]["source_id"] == "webdav_src_demo_primary" + assert "account_id" not in body[0] + + +def test_webdav_routes_reject_public_identity_headers_without_signed_session(): + response = _request_without_signed_session("GET", "/api/webdav/accounts") + + assert response.status_code == 401 + assert response.json()["detail"] == "Authentication required" def test_get_project_folders(auth_client): response = auth_client.get("/api/webdav/folders") @@ -176,6 +240,20 @@ def test_get_webdav_writeback_intent(auth_client): assert body["source_id"] == "webdav_src_demo_primary" assert body["server_url"] == "https://webdav.naruon.net" assert body["provenance"] == "server-authoritative" + assert "account_id" not in body + + +def test_get_webdav_writeback_intent_accepts_signed_bearer_session(): + response = _request_with_signed_session( + "POST", + "/api/webdav/writeback-intent", + {"target_source_id": "webdav_src_demo_primary"}, + ) + + assert response.status_code == 200, response.text + body = response.json() + assert body["source_id"] == "webdav_src_demo_primary" + assert "account_id" not in body def test_get_webdav_writeback_intent_with_target_account(auth_client): @@ -234,6 +312,7 @@ def test_get_self_sent_knowledge_webdav_intent(auth_client): assert "related_email_id" not in body assert "user_id" not in body assert "organization_id" not in body + assert "account_id" not in body def test_self_sent_knowledge_webdav_intent_requires_source_task(auth_client): @@ -279,27 +358,18 @@ def test_self_sent_knowledge_webdav_intent_requires_connected_webdav_account(): def test_get_self_sent_knowledge_webdav_intent_accepts_signed_bearer_session(): - previous_secret = settings.AUTH_SESSION_HMAC_SECRET - previous_override = app.dependency_overrides.pop(get_auth_context, None) - settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) - token = _signed_session_token(_valid_session_payload()) - try: - with TestClient(app) as client: - response = client.post( - "/api/webdav/knowledge-materialization-intent", - json={ - "target_source_id": "webdav_src_demo_primary", - "source_task_id": "task-self-knowledge", - }, - headers={"Authorization": f"Bearer {token}"}, - ) - finally: - settings.AUTH_SESSION_HMAC_SECRET = previous_secret - if previous_override is not None: - app.dependency_overrides[get_auth_context] = previous_override + response = _request_with_signed_session( + "POST", + "/api/webdav/knowledge-materialization-intent", + { + "target_source_id": "webdav_src_demo_primary", + "source_task_id": "task-self-knowledge", + }, + ) assert response.status_code == 200, response.text assert response.json()["task_id"] == "task-self-knowledge" + assert "account_id" not in response.json() @pytest.mark.asyncio @@ -562,19 +632,28 @@ async def override_real_db(): async with session_factory() as session: yield session + previous_override = app.dependency_overrides.pop(get_auth_context, None) + previous_secret = settings.AUTH_SESSION_HMAC_SECRET + settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET) app.dependency_overrides[get_db] = override_real_db + token = _signed_session_token( + _valid_session_payload(sub=user_id, org="org-acme", workspace="workspace-org-acme") + ) try: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", - headers={"X-User-Id": user_id, "X-Organization-Id": "org-acme"}, + headers={"Authorization": f"Bearer {token}"}, ) as client: response = await client.post( "/api/webdav/writeback-intent", json={"target_source_id": source_uid}, ) finally: + settings.AUTH_SESSION_HMAC_SECRET = previous_secret + if previous_override is not None: + app.dependency_overrides[get_auth_context] = previous_override app.dependency_overrides.pop(get_db, None) async with engine.begin() as conn: await conn.execute( @@ -591,6 +670,7 @@ async def override_real_db(): assert body["source_id"] == source_uid assert body["server_url"] == "https://real-webdav.naruon.net" assert body["provenance"] == "server-authoritative" + assert "account_id" not in body @pytest.mark.asyncio diff --git a/docs/operations/source-of-truth-and-writeback-sovereignty.md b/docs/operations/source-of-truth-and-writeback-sovereignty.md index 761754acb..4b1026e67 100644 --- a/docs/operations/source-of-truth-and-writeback-sovereignty.md +++ b/docs/operations/source-of-truth-and-writeback-sovereignty.md @@ -49,8 +49,10 @@ ticket tasks, DB-backed CalDAV intent source selection through opaque `calendar_writeback_sources.source_uid` rows exposed to the Calendar workspace through a signed source-registry read, and WebDAV intent selection through opaque, organization-scoped `webdav_accounts.source_uid` rows with persisted -writeback eligibility. Real CalDAV/WebDAV mutation, ETag-aware WebDAV/Notes -provider execution for synthesized knowledge, POP3 runtime sync, durable +writeback eligibility; sequential `webdav_accounts.account_id` values remain +internal and are not browser-visible source identifiers. Real CalDAV/WebDAV +mutation, ETag-aware WebDAV/Notes provider execution for synthesized knowledge, +POP3 runtime sync, durable reply-tracking notifications, configurable SLA policy storage, and source-id filtered sender graph views remain episode work and must preserve this registry boundary. diff --git a/docs/plans/2026-05-27-webdav-opaque-source-id.md b/docs/plans/2026-05-27-webdav-opaque-source-id.md index ff32eb187..ec9e844ce 100644 --- a/docs/plans/2026-05-27-webdav-opaque-source-id.md +++ b/docs/plans/2026-05-27-webdav-opaque-source-id.md @@ -8,12 +8,17 @@ opaque `webdav_accounts.source_uid` values, matching the CalDAV source-id rule. ## Scope -- Add/backfill `webdav_accounts.source_uid` and a unique index. +- Add/backfill `webdav_accounts.source_uid` and a unique index. Legacy rows get + non-sequential opaque values; the browser-visible id is not derived from + `account_id`. - Scope WebDAV source lookup by `organization_id` from the signed session and honor persisted `writeback_enabled` eligibility. - Replace `target_account_id` requests with `target_source_id`. - Return `source_id` as a string in WebDAV writeback and materialization intent responses. +- Verify `/accounts`, `/writeback-intent`, and + `/knowledge-materialization-intent` omit `account_id` and work through signed + bearer sessions without public identity headers. - Keep provider writes out of scope; these endpoints still create intent metadata only. - Keep Strix on direct OpenAI Platform credentials only. Do not use GitHub diff --git a/frontend/src/app/data/page.test.tsx b/frontend/src/app/data/page.test.tsx index c9c81297e..6ebbb68bd 100644 --- a/frontend/src/app/data/page.test.tsx +++ b/frontend/src/app/data/page.test.tsx @@ -103,6 +103,7 @@ describe("DataPage", () => { container?.remove(); container = null; localStorage.clear(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -177,6 +178,43 @@ describe("DataPage", () => { expect(container.textContent).toContain("https://webdav.naruon.net"); }); + it("keeps WebDAV writeback disabled when account loading fails", async () => { + localStorage.setItem("naruon_session_token", "signed-webdav-session"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const path = String(input); + if (path === "/api/webdav/accounts") { + throw new Error("signed-webdav-session should not be logged"); + } + if (path === "/api/webdav/folders") return jsonResponse([]); + if (path === "/api/webdav/writeback-intent") throw new Error("writeback should stay disabled"); + return jsonResponse({}); + }); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await act(async () => { + await Promise.resolve(); + }); + + const button = Array.from(container.querySelectorAll("button")).find((candidate) => + candidate.textContent?.includes("WebDAV intent 승인 점검"), + ) as HTMLButtonElement | undefined; + expect(button).toBeDefined(); + expect(button?.disabled).toBe(true); + button?.click(); + + expect(fetchMock.mock.calls.some(([input]) => String(input) === "/api/webdav/writeback-intent")).toBe(false); + expect(container.textContent).toContain("WebDAV 원본 계정 목록을 확인하지 못했습니다."); + expect(JSON.stringify(consoleError.mock.calls)).toContain("WebDAV accounts fetch error"); + expect(JSON.stringify(consoleError.mock.calls)).not.toContain("signed-webdav-session"); + }); + it("creates a signed unique email thread intent without public identity headers", async () => { localStorage.setItem("naruon_session_token", "signed-email-dedupe-session"); const fetchMock = mockWebdavFetch(); diff --git a/frontend/src/components/DataLayout.tsx b/frontend/src/components/DataLayout.tsx index 5fccad768..1f36acacc 100644 --- a/frontend/src/components/DataLayout.tsx +++ b/frontend/src/components/DataLayout.tsx @@ -14,7 +14,8 @@ type WebdavWritebackIntentResponse = { message?: string | null; }; -type WritebackStatus = 'idle' | 'loading' | 'success' | 'no_source' | 'auth' | 'error'; +type WritebackStatus = 'idle' | 'loading' | 'success' | 'no_source' | 'fetch_error' | 'auth' | 'error'; +type WebdavAccountStatus = 'loading' | 'ready' | 'error'; type UniqueThreadIntentResponse = { status: string; @@ -61,6 +62,12 @@ function getApiErrorStatus(error: unknown) { return null; } +function getSafeErrorSummary(error: unknown) { + const status = getApiErrorStatus(error); + const errorName = error instanceof Error ? error.name : typeof error; + return { status, error_name: errorName.slice(0, 40) }; +} + export function DataLayout() { const [activeTab, setActiveTab] = useState<'문서 저장소' | '수집 파이프라인' | '임베딩' | '품질 점검'>('문서 저장소'); @@ -78,6 +85,7 @@ export function DataLayout() { } const [webdavAccounts, setWebdavAccounts] = useState([]); + const [webdavAccountStatus, setWebdavAccountStatus] = useState('loading'); const [projectFolders, setProjectFolders] = useState([]); const [writebackStatus, setWritebackStatus] = useState('idle'); const [writebackResult, setWritebackResult] = useState(null); @@ -86,22 +94,38 @@ export function DataLayout() { useEffect(() => { apiClient.get('/api/webdav/accounts') - .then(data => Array.isArray(data) && setWebdavAccounts(data)) - .catch(console.error); + .then((data) => { + if (!Array.isArray(data)) throw new Error('Invalid WebDAV accounts response'); + setWebdavAccounts(data); + setWebdavAccountStatus('ready'); + }) + .catch((error: unknown) => { + console.error('WebDAV accounts fetch error', getSafeErrorSummary(error)); + setWebdavAccounts([]); + setWebdavAccountStatus('error'); + }); apiClient.get('/api/webdav/folders') .then(data => Array.isArray(data) && setProjectFolders(data)) - .catch(console.error); + .catch((error: unknown) => console.error('WebDAV folders fetch error', getSafeErrorSummary(error))); }, []); const requestWebdavWritebackIntent = useCallback(async () => { setWritebackStatus('loading'); setWritebackResult(null); try { + if (webdavAccountStatus !== 'ready') { + setWritebackStatus('fetch_error'); + return; + } const targetSourceId = webdavAccounts.find(acc => acc.writeback_enabled)?.source_id; + if (!targetSourceId) { + setWritebackStatus('no_source'); + return; + } const result = await apiClient.post( '/api/webdav/writeback-intent', - targetSourceId ? { target_source_id: targetSourceId } : {}, + { target_source_id: targetSourceId }, ); setWritebackResult(result); setWritebackStatus('success'); @@ -115,7 +139,7 @@ export function DataLayout() { setWritebackStatus('error'); } } - }, [webdavAccounts]); + }, [webdavAccounts, webdavAccountStatus]); const requestUniqueThreadIntent = useCallback(async () => { setUniqueThreadStatus('loading'); @@ -134,6 +158,8 @@ export function DataLayout() { }, []); const isWritebackLoading = writebackStatus === 'loading'; + const isWebdavSourceLoading = webdavAccountStatus === 'loading'; + const canRequestWebdavWriteback = webdavAccountStatus === 'ready'; const isUniqueThreadLoading = uniqueThreadStatus === 'loading'; return ( @@ -217,8 +243,9 @@ export function DataLayout() { @@ -229,9 +256,15 @@ export function DataLayout() {

아직 WebDAV provider write는 실행하지 않았습니다. 원본 source와 충돌 조건만 확인합니다.

)} {writebackStatus === 'loading' &&

WebDAV writeback intent 요청 중입니다.

} + {writebackStatus === 'idle' && webdavAccountStatus === 'error' && ( +

WebDAV 원본 계정 목록을 확인하지 못했습니다.

+ )} {writebackStatus === 'no_source' && (

writeback 가능한 고객 WebDAV 원본 계정이 없어 intent를 만들 수 없습니다.

)} + {writebackStatus === 'fetch_error' && ( +

WebDAV 원본 계정 목록을 확인하지 못해 intent를 만들 수 없습니다.

+ )} {writebackStatus === 'auth' && (

signed session이 필요합니다. 공개 identity header로는 WebDAV intent를 만들 수 없습니다.

)}