From dc902f0761aab4ef3d012b6db8b1745ebe2bb320 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 28 May 2026 02:22:49 +0900 Subject: [PATCH 1/3] Use opaque WebDAV writeback source ids --- AGENTS.md | 10 +-- README.md | 3 + backend/api/webdav.py | 27 ++++--- backend/db/models.py | 7 ++ backend/scripts/bootstrap_db.py | 13 +++ backend/services/webdav_service.py | 59 +++++++++----- backend/tests/test_bootstrap_db.py | 31 +++++++- backend/tests/test_webdav_api.py | 79 +++++++++++++------ ...urce-of-truth-and-writeback-sovereignty.md | 5 +- ...6-05-27-data-webdav-writeback-intent-ui.md | 6 +- ...self-sent-webdav-materialization-intent.md | 5 +- .../2026-05-27-webdav-opaque-source-id.md | 28 +++++++ frontend/src/app/data/page.test.tsx | 8 +- frontend/src/app/tasks/page.test.tsx | 2 +- frontend/src/components/DataLayout.tsx | 10 +-- frontend/src/components/TasksLayout.tsx | 2 +- frontend/tests/e2e/dashboard-branding.spec.ts | 8 +- frontend/tests/e2e/helpers.ts | 6 +- 18 files changed, 228 insertions(+), 81 deletions(-) create mode 100644 docs/plans/2026-05-27-webdav-opaque-source-id.md diff --git a/AGENTS.md b/AGENTS.md index 99fe9dddf..942720584 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,11 +104,11 @@ server-authoritative source selection and provenance. Do not wire browser actions back to legacy `/api/calendar/sync` unless a trusted backend credential dependency and source-owner contract are explicitly in scope. -- Calendar writeback source selection must resolve through opaque - `calendar_writeback_sources.source_uid` registry rows, not sequential CalDAV - or WebDAV account ids. 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. +- Calendar and WebDAV writeback source selection must resolve through opaque + `source_uid` values, not sequential CalDAV or WebDAV account ids. + 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. - 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 49a8925a9..a11f331bf 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,9 @@ provenance. Calendar source selection now reads opaque CalDAV account ids. The browser no longer claims `/api/calendar/sync` success from the mail-detail action path; direct provider writes stay deferred until connector execution can 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 and reject legacy +`target_account_id` payloads. ## Operations and release docs diff --git a/backend/api/webdav.py b/backend/api/webdav.py index e2948630e..7e7f2421d 100644 --- a/backend/api/webdav.py +++ b/backend/api/webdav.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from sqlalchemy.ext.asyncio import AsyncSession from typing import List @@ -17,7 +17,7 @@ } class WebdavAccountResponse(BaseModel): - account_id: int + source_id: str server_url: str username: str @@ -27,11 +27,13 @@ class ProjectFolderResponse(BaseModel): webdav_path: str class WritebackIntentRequest(BaseModel): - target_account_id: int | None = None + model_config = ConfigDict(extra="forbid") + + target_source_id: str | None = None class WritebackIntentResponse(BaseModel): intent: str - source_id: int | None + source_id: str | None server_url: str | None requires_if_match: bool provenance: str @@ -39,8 +41,10 @@ class WritebackIntentResponse(BaseModel): message: str | None = None class KnowledgeMaterializationIntentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + source_task_id: str - target_account_id: int | None = None + target_source_id: str | None = None class KnowledgeMaterializationIntentResponse(BaseModel): intent: str @@ -49,7 +53,7 @@ class KnowledgeMaterializationIntentResponse(BaseModel): source_type: str source_email_id: str | None source_thread_id: str | None - source_id: int | None + source_id: str | None server_url: str | None target_path: str requires_if_match: bool @@ -63,7 +67,11 @@ async def get_webdav_accounts( db: AsyncSession = Depends(get_db), ): user_id = auth_context.user_id - return await webdav_service.get_connected_accounts_from_db(db, user_id) + return await webdav_service.get_connected_accounts_from_db( + db, + user_id, + auth_context.organization_id, + ) @router.get("/folders", response_model=List[ProjectFolderResponse]) async def get_project_folders( @@ -83,7 +91,8 @@ async def get_webdav_writeback_intent( result = await webdav_service.determine_webdav_writeback_intent_from_db( db, user_id, - target_account_id=req.target_account_id, + auth_context.organization_id, + target_source_id=req.target_source_id, ) if result.get("status") == "error": raise HTTPException(status_code=422, detail=result.get("message")) @@ -103,7 +112,7 @@ async def get_knowledge_materialization_intent( auth_context.user_id, auth_context.organization_id, req.source_task_id, - target_account_id=req.target_account_id, + target_source_id=req.target_source_id, ) if result.get("status") == "error": status_code = WEB_DAV_ERROR_STATUS_CODES.get( diff --git a/backend/db/models.py b/backend/db/models.py index 6532b3562..97514e000 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -426,6 +426,13 @@ class WebdavAccount(Base): __tablename__ = "webdav_accounts" id: Mapped[int] = mapped_column("account_id", primary_key=True) + source_uid: Mapped[str] = mapped_column( + String, + unique=True, + index=True, + default=lambda: f"webdav_src_{uuid.uuid4().hex}", + nullable=False, + ) user_id: Mapped[str] = mapped_column(String, index=True, nullable=False) server_url: Mapped[str] = mapped_column(String, nullable=False) username: Mapped[str] = mapped_column(String, nullable=False) diff --git a/backend/scripts/bootstrap_db.py b/backend/scripts/bootstrap_db.py index e77df7f13..c4b91aea3 100644 --- a/backend/scripts/bootstrap_db.py +++ b/backend/scripts/bootstrap_db.py @@ -45,6 +45,7 @@ def schema_backfill_sql(): "created_at timestamptz DEFAULT CURRENT_TIMESTAMP" ")" ), + text("ALTER TABLE webdav_accounts ADD COLUMN IF NOT EXISTS source_uid varchar"), text("ALTER TABLE tenant_configs ADD COLUMN IF NOT EXISTS pop3_username varchar"), text("ALTER TABLE tenant_configs ADD COLUMN IF NOT EXISTS pop3_password varchar"), text("ALTER TABLE emails ADD COLUMN IF NOT EXISTS in_reply_to varchar"), @@ -81,6 +82,18 @@ def schema_backfill_sql(): "ON calendar_writeback_sources " "(user_id, organization_id, source_protocol)" ), + text( + "UPDATE webdav_accounts " + "SET source_uid = 'webdav_src_' || md5(" + "account_id::text || ':' || user_id || ':' || server_url" + ") " + "WHERE source_uid IS NULL OR source_uid = ''" + ), + text("ALTER TABLE webdav_accounts ALTER COLUMN source_uid SET NOT NULL"), + text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_webdav_accounts_source_uid " + "ON webdav_accounts (source_uid)" + ), text("ALTER TABLE emails DROP CONSTRAINT IF EXISTS emails_message_id_key"), text( "ALTER TABLE sender_relationships " diff --git a/backend/services/webdav_service.py b/backend/services/webdav_service.py index a090a0db7..fadf2545e 100644 --- a/backend/services/webdav_service.py +++ b/backend/services/webdav_service.py @@ -29,9 +29,10 @@ def __init__(self): self._mock_accounts = { "demo_user": [ { - "account_id": 1, + "source_id": "webdav_src_demo_primary", "server_url": "https://webdav.naruon.net", - "username": "demo_user" + "username": "demo_user", + "writeback_enabled": True, } ] } @@ -67,16 +68,23 @@ async def get_connected_accounts_from_db( self, session: AsyncSession, user_id: str, + organization_id: str | None = None, ) -> List[Dict[str, Any]]: - stmt = select(WebdavAccount).where(WebdavAccount.user_id == user_id) + del organization_id + stmt = select( + WebdavAccount.source_uid, + WebdavAccount.server_url, + WebdavAccount.username, + ).where(WebdavAccount.user_id == user_id) result = await session.execute(stmt) return [ { - "account_id": account.id, - "server_url": account.server_url, - "username": account.username, + "source_id": source_uid, + "server_url": server_url, + "username": username, + "writeback_enabled": True, } - for account in result.scalars().all() + for source_uid, server_url, username in result.all() ] async def get_project_folders_from_db( @@ -103,26 +111,31 @@ def sync_attachments_to_folder(self, email_id: str, project_name: str) -> bool: # Mock implementation: in reality, this would download from storage and upload via webdavclient3 return True - def determine_webdav_writeback_intent(self, user_id: str, target_account_id: int | None = None) -> Dict[str, Any]: + def determine_webdav_writeback_intent( + self, user_id: str, target_source_id: str | None = None + ) -> Dict[str, Any]: """ Server-authoritative WebDAV writeback source selection. """ accounts = self.get_connected_accounts(user_id) return self.determine_webdav_writeback_intent_from_accounts( accounts, - target_account_id=target_account_id, + target_source_id=target_source_id, ) async def determine_webdav_writeback_intent_from_db( self, session: AsyncSession, user_id: str, - target_account_id: int | None = None, + organization_id: str | None = None, + target_source_id: str | None = None, ) -> Dict[str, Any]: - accounts = await self.get_connected_accounts_from_db(session, user_id) + accounts = await self.get_connected_accounts_from_db( + session, user_id, organization_id + ) return self.determine_webdav_writeback_intent_from_accounts( accounts, - target_account_id=target_account_id, + target_source_id=target_source_id, ) async def determine_knowledge_materialization_intent_from_db( @@ -131,7 +144,7 @@ async def determine_knowledge_materialization_intent_from_db( user_id: str, organization_id: str | None, source_task_id: str, - target_account_id: int | None = None, + target_source_id: str | None = None, ) -> Dict[str, Any]: task_result = await session.execute( select(TicketTask, Email.message_id) @@ -172,7 +185,8 @@ async def determine_knowledge_materialization_intent_from_db( result = await self.determine_webdav_writeback_intent_from_db( session, user_id, - target_account_id=target_account_id, + organization_id, + target_source_id=target_source_id, ) if result.get("status") == "error": return result @@ -196,20 +210,23 @@ async def determine_knowledge_materialization_intent_from_db( def determine_webdav_writeback_intent_from_accounts( self, accounts: List[Dict[str, Any]], - target_account_id: int | None = None, + target_source_id: str | None = None, ) -> Dict[str, Any]: - if not accounts: + writable_accounts = [ + account for account in accounts if account.get("writeback_enabled", True) + ] + if not writable_accounts: return { "status": "error", "error_code": "no_webdav_account", "message": "No connected WebDAV accounts found.", } - selected_account = accounts[0] - if target_account_id is not None: + selected_account = writable_accounts[0] + if target_source_id is not None: selected_account = None - for acc in accounts: - if acc["account_id"] == target_account_id: + for acc in writable_accounts: + if acc["source_id"] == target_source_id: selected_account = acc break if selected_account is None: @@ -221,7 +238,7 @@ def determine_webdav_writeback_intent_from_accounts( return { "intent": "writeback", - "source_id": selected_account["account_id"], + "source_id": selected_account["source_id"], "server_url": selected_account["server_url"], "requires_if_match": True, "provenance": "server-authoritative" diff --git a/backend/tests/test_bootstrap_db.py b/backend/tests/test_bootstrap_db.py index 7634581f7..b32bfb124 100644 --- a/backend/tests/test_bootstrap_db.py +++ b/backend/tests/test_bootstrap_db.py @@ -1,5 +1,5 @@ from scripts.bootstrap_db import schema_backfill_sql -from db.models import CalendarWritebackSource, SenderRelationship +from db.models import CalendarWritebackSource, SenderRelationship, WebdavAccount def test_schema_backfill_adds_threading_columns_for_existing_tables(monkeypatch): @@ -117,6 +117,27 @@ def test_schema_backfill_adds_threading_columns_for_existing_tables(monkeypatch) in statement for statement in statements ) + assert any( + "alter table webdav_accounts add column if not exists source_uid" + in statement + for statement in statements + ) + assert any( + "update webdav_accounts set source_uid" in statement + and "webdav_src_" in statement + and "md5" in statement + for statement in statements + ) + assert any( + "alter table webdav_accounts alter column source_uid set not null" + in statement + for statement in statements + ) + assert any( + "create unique index if not exists uq_webdav_accounts_source_uid" + in statement + for statement in statements + ) assert any( "create index if not exists ix_llm_providers_organization_id" in statement for statement in statements @@ -232,3 +253,11 @@ def test_calendar_writeback_source_model_uses_two_word_names(): "created_at", } assert all("_" in column_name for column_name in column_names) + + +def test_webdav_account_model_exposes_opaque_source_uid(): + column_names = {column.name for column in WebdavAccount.__table__.columns} + + assert "source_uid" in column_names + assert WebdavAccount.__table__.c.source_uid.nullable is False + assert WebdavAccount.__table__.c.source_uid.unique is True diff --git a/backend/tests/test_webdav_api.py b/backend/tests/test_webdav_api.py index 3f2cd91d7..dd1c2f9f2 100644 --- a/backend/tests/test_webdav_api.py +++ b/backend/tests/test_webdav_api.py @@ -62,12 +62,14 @@ def _valid_session_payload(**overrides: object) -> dict[str, object]: @pytest.fixture(autouse=True) def stub_webdav_service(monkeypatch): - async def fake_accounts(db, user_id): + async def fake_accounts(db, user_id, organization_id=None): + del organization_id return [ { - "account_id": 1, + "source_id": "webdav_src_demo_primary", "server_url": "https://webdav.naruon.net", "username": "demo_user", + "writeback_enabled": True, } ] if user_id == "alice" else [] @@ -77,10 +79,10 @@ async def fake_folders(db, user_id): {"folder_id": 2, "project_name": "Marketing Assets", "webdav_path": "/Projects/Marketing_Assets"} ] if user_id == "alice" else [] - async def fake_intent(db, user_id, target_account_id=None): + async def fake_intent(db, user_id, organization_id=None, target_source_id=None): return webdav_service.determine_webdav_writeback_intent_from_accounts( await fake_accounts(db, user_id), - target_account_id=target_account_id, + target_source_id=target_source_id, ) async def fake_knowledge_intent( @@ -88,7 +90,7 @@ async def fake_knowledge_intent( user_id, organization_id, source_task_id, - target_account_id=None, + target_source_id=None, ): if source_task_id == "task-email": return { @@ -102,7 +104,12 @@ async def fake_knowledge_intent( "error_code": "not_found", "message": "Self-sent knowledge task was not found.", } - result = await fake_intent(db, user_id, target_account_id=target_account_id) + result = await fake_intent( + db, + user_id, + organization_id=organization_id, + target_source_id=target_source_id, + ) if result.get("status") == "error": return result return { @@ -165,7 +172,7 @@ def test_get_webdav_writeback_intent(auth_client): body = response.json() assert body["intent"] == "writeback" assert body["requires_if_match"] is True - assert body["source_id"] == 1 + assert body["source_id"] == "webdav_src_demo_primary" assert body["server_url"] == "https://webdav.naruon.net" assert body["provenance"] == "server-authoritative" @@ -173,12 +180,12 @@ def test_get_webdav_writeback_intent(auth_client): def test_get_webdav_writeback_intent_with_target_account(auth_client): response = auth_client.post( "/api/webdav/writeback-intent", - json={"target_account_id": 1}, + json={"target_source_id": "webdav_src_demo_primary"}, ) assert response.status_code == 200, response.text body = response.json() assert body["intent"] == "writeback" - assert body["source_id"] == 1 + assert body["source_id"] == "webdav_src_demo_primary" assert body["server_url"] == "https://webdav.naruon.net" assert body["requires_if_match"] is True assert body["provenance"] == "server-authoritative" @@ -187,17 +194,28 @@ def test_get_webdav_writeback_intent_with_target_account(auth_client): def test_get_webdav_writeback_intent_with_invalid_target_account(auth_client): response = auth_client.post( "/api/webdav/writeback-intent", - json={"target_account_id": 999}, + json={"target_source_id": "webdav_src_missing"}, ) assert response.status_code == 422 assert response.json()["detail"] == "Requested WebDAV account was not found." +def test_webdav_writeback_rejects_legacy_target_account_id(auth_client): + response = auth_client.post( + "/api/webdav/writeback-intent", + json={"target_account_id": 1}, + ) + + assert response.status_code == 422 + assert response.json()["detail"][0]["type"] == "extra_forbidden" + assert response.json()["detail"][0]["loc"] == ["body", "target_account_id"] + + def test_get_self_sent_knowledge_webdav_intent(auth_client): response = auth_client.post( "/api/webdav/knowledge-materialization-intent", json={ - "target_account_id": 1, + "target_source_id": "webdav_src_demo_primary", "source_task_id": "task-self-knowledge", }, ) @@ -269,7 +287,7 @@ def test_get_self_sent_knowledge_webdav_intent_accepts_signed_bearer_session(): response = client.post( "/api/webdav/knowledge-materialization-intent", json={ - "target_account_id": 1, + "target_source_id": "webdav_src_demo_primary", "source_task_id": "task-self-knowledge", }, headers={"Authorization": f"Bearer {token}"}, @@ -383,8 +401,8 @@ def test_get_webdav_writeback_intent_no_accounts(): @pytest.mark.asyncio async def test_webdav_writeback_intent_real_postgres_smoke(monkeypatch): - account_id = 10_000 + (uuid.uuid4().int % 1_000_000) - user_id = f"webdav-smoke-{account_id}" + source_uid = f"webdav_src_{uuid.uuid4().hex[:24]}" + user_id = f"webdav-smoke-{uuid.uuid4().hex[:12]}" monkeypatch.setattr( webdav_service, "get_connected_accounts_from_db", @@ -411,6 +429,7 @@ async def test_webdav_writeback_intent_real_postgres_smoke(monkeypatch): """ CREATE TABLE IF NOT EXISTS webdav_accounts ( account_id INTEGER PRIMARY KEY, + source_uid VARCHAR UNIQUE NOT NULL, user_id VARCHAR NOT NULL, server_url VARCHAR NOT NULL, username VARCHAR NOT NULL, @@ -424,16 +443,17 @@ async def test_webdav_writeback_intent_real_postgres_smoke(monkeypatch): text( """ DELETE FROM webdav_accounts - WHERE account_id = :account_id + WHERE source_uid = :source_uid """ ), - {"account_id": account_id}, + {"source_uid": source_uid}, ) await conn.execute( text( """ INSERT INTO webdav_accounts ( account_id, + source_uid, user_id, server_url, username, @@ -441,6 +461,7 @@ async def test_webdav_writeback_intent_real_postgres_smoke(monkeypatch): ) VALUES ( :account_id, + :source_uid, :user_id, :server_url, :username, @@ -449,7 +470,8 @@ async def test_webdav_writeback_intent_real_postgres_smoke(monkeypatch): """ ), { - "account_id": account_id, + "account_id": 10_000 + (uuid.uuid4().int % 1_000_000), + "source_uid": source_uid, "user_id": user_id, "server_url": "https://real-webdav.naruon.net", "username": user_id, @@ -487,20 +509,23 @@ async def override_real_db(): ) as client: response = await client.post( "/api/webdav/writeback-intent", - json={"target_account_id": account_id}, + json={"target_source_id": source_uid}, ) finally: app.dependency_overrides.pop(get_db, None) async with engine.begin() as conn: await conn.execute( - text("DELETE FROM webdav_accounts WHERE account_id = :account_id"), - {"account_id": account_id}, + text( + "DELETE FROM webdav_accounts " + "WHERE source_uid = :source_uid" + ), + {"source_uid": source_uid}, ) await engine.dispose() assert response.status_code == 200, response.text body = response.json() - assert body["source_id"] == account_id + assert body["source_id"] == source_uid assert body["server_url"] == "https://real-webdav.naruon.net" assert body["provenance"] == "server-authoritative" @@ -515,7 +540,7 @@ async def test_knowledge_materialization_intent_real_postgres_endpoint_smoke( task_uid = f"task{uuid.uuid4().hex[:28]}" message_id = f"" thread_id = f"thread-knowledge-{smoke_id}" - account_id = smoke_id + 1 + source_uid = f"webdav_src_{uuid.uuid4().hex[:24]}" monkeypatch.setattr( webdav_service, "get_connected_accounts_from_db", @@ -605,6 +630,7 @@ async def test_knowledge_materialization_intent_real_postgres_endpoint_smoke( """ CREATE TABLE webdav_accounts ( account_id INTEGER PRIMARY KEY, + source_uid VARCHAR UNIQUE NOT NULL, user_id VARCHAR NOT NULL, server_url VARCHAR NOT NULL, username VARCHAR NOT NULL, @@ -685,6 +711,7 @@ async def test_knowledge_materialization_intent_real_postgres_endpoint_smoke( """ INSERT INTO webdav_accounts ( account_id, + source_uid, user_id, server_url, username, @@ -692,6 +719,7 @@ async def test_knowledge_materialization_intent_real_postgres_endpoint_smoke( ) VALUES ( :account_id, + :source_uid, :user_id, :server_url, :username, @@ -700,7 +728,8 @@ async def test_knowledge_materialization_intent_real_postgres_endpoint_smoke( """ ), { - "account_id": account_id, + "account_id": smoke_id + 1, + "source_uid": source_uid, "user_id": user_id, "server_url": "https://real-webdav.naruon.net", "username": user_id, @@ -737,7 +766,7 @@ async def override_real_db(): response = await client.post( "/api/webdav/knowledge-materialization-intent", json={ - "target_account_id": account_id, + "target_source_id": source_uid, "source_task_id": task_uid, }, ) @@ -759,6 +788,6 @@ async def override_real_db(): assert body["source_type"] == "self_sent_knowledge" assert body["source_email_id"] == message_id assert body["source_thread_id"] == thread_id - assert body["source_id"] == account_id + assert body["source_id"] == source_uid assert body["target_path"] == f"/Naruon/Notes/{task_uid}.md" assert body["provider_write_executed"] is False diff --git a/docs/operations/source-of-truth-and-writeback-sovereignty.md b/docs/operations/source-of-truth-and-writeback-sovereignty.md index a34ab301d..47bd01563 100644 --- a/docs/operations/source-of-truth-and-writeback-sovereignty.md +++ b/docs/operations/source-of-truth-and-writeback-sovereignty.md @@ -43,8 +43,9 @@ Implemented slices currently cover signed task/mail provenance, dedupe/threading contracts, source-linked ticket tasks, self-sent knowledge capture into idempotent ticket tasks, deterministic sender ontology action hints, generic WebDAV writeback intent, self-sent knowledge WebDAV/Notes materialization -intent, and DB-backed CalDAV intent source selection through opaque -`calendar_writeback_sources.source_uid` rows. Real CalDAV/WebDAV mutation, +intent, DB-backed CalDAV intent source selection through opaque +`calendar_writeback_sources.source_uid` rows, and WebDAV intent selection through +opaque `webdav_accounts.source_uid` rows. Real CalDAV/WebDAV mutation, ETag-aware WebDAV/Notes provider execution for synthesized knowledge, POP3 runtime sync, durable reply-tracking notifications, and source-id filtered sender graph views remain episode work and must preserve this registry diff --git a/docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md b/docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md index d1ae07a06..8842c56f1 100644 --- a/docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md +++ b/docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md @@ -12,6 +12,8 @@ AI-organized files or attachments are written back to their own storage. - Customer WebDAV accounts remain the writeback target. - The browser calls `POST /api/webdav/writeback-intent` through the signed `apiClient` session path. +- The browser chooses optional targets with opaque `target_source_id`; legacy + sequential `target_account_id` payloads fail closed. - The UI shows intent, source id, server URL, If-Match requirement, and provenance only. It does not claim provider write execution. - Public identity headers must not be emitted by browser write requests. @@ -20,7 +22,8 @@ AI-organized files or attachments are written back to their own storage. - `/data` now exposes a WebDAV writeback intent approval panel. - The intent panel uses the first connected customer WebDAV account as the - target source and fails closed when no source or signed session is available. + target source through `webdav_accounts.source_uid` and fails closed when no + source or signed session is available. - Data repository cards now use responsive grid tracks so mobile verification does not depend on desktop-only three-column layouts. - Unit and Playwright tests assert signed `Authorization: Bearer` handling and @@ -43,4 +46,3 @@ Screenshots to inspect: - `data-webdav-writeback-intent-desktop.png` - `data-webdav-writeback-intent-mobile.png` - `data-webdav-writeback-intent-mobile-scroll.png` - diff --git a/docs/plans/2026-05-27-self-sent-webdav-materialization-intent.md b/docs/plans/2026-05-27-self-sent-webdav-materialization-intent.md index 760e309c3..9d3e7a1c4 100644 --- a/docs/plans/2026-05-27-self-sent-webdav-materialization-intent.md +++ b/docs/plans/2026-05-27-self-sent-webdav-materialization-intent.md @@ -16,9 +16,12 @@ from an unscoped backend path. - Same-tenant non-self-sent tasks return `422`; other-owner or missing tasks return `404`. - The backend chooses the customer WebDAV source through the existing - server-authoritative account lookup and returns intent metadata only: + server-authoritative account lookup using opaque `webdav_accounts.source_uid` + values and returns intent metadata only: `target_path`, `server_url`, `requires_if_match`, `provenance`, `provider_write_executed=false`, and the audit event name. +- Clients may pass `target_source_id`; legacy sequential `target_account_id` + payloads are rejected. - Actual DAV `PUT`, ETag negotiation, Notes object synthesis, and provider writeback remain future connector execution work. diff --git a/docs/plans/2026-05-27-webdav-opaque-source-id.md b/docs/plans/2026-05-27-webdav-opaque-source-id.md new file mode 100644 index 000000000..61b6ccc64 --- /dev/null +++ b/docs/plans/2026-05-27-webdav-opaque-source-id.md @@ -0,0 +1,28 @@ +# WebDAV Opaque Source ID Slice + +## Goal + +Stop exposing sequential WebDAV account primary keys through browser writeback +contracts. WebDAV writeback and self-sent knowledge materialization should use +opaque `webdav_accounts.source_uid` values, matching the CalDAV source-id rule. + +## Scope + +- Add/backfill `webdav_accounts.source_uid` and a unique index. +- Replace `target_account_id` requests with `target_source_id`. +- Return `source_id` as a string in WebDAV writeback and materialization intent + responses. +- 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 + Models. + +## Verification + +```bash +python3 -m pytest backend/tests/test_webdav_api.py backend/tests/test_bootstrap_db.py -q +cd frontend && npm test -- --run src/app/data/page.test.tsx src/app/tasks/page.test.tsx +``` + +Browser evidence remains the Data and Tasks E2E screenshots for desktop, mobile, +and mobile-scroll writeback surfaces. diff --git a/frontend/src/app/data/page.test.tsx b/frontend/src/app/data/page.test.tsx index 4344389ab..961720a4e 100644 --- a/frontend/src/app/data/page.test.tsx +++ b/frontend/src/app/data/page.test.tsx @@ -36,7 +36,7 @@ function mockWebdavFetch() { if (path === "/api/webdav/accounts") { return jsonResponse([ { - account_id: 1, + source_id: "webdav_src_primary", server_url: "https://webdav.naruon.net", username: "demo_user", }, @@ -55,7 +55,7 @@ function mockWebdavFetch() { void init; return jsonResponse({ intent: "writeback", - source_id: 1, + source_id: "webdav_src_primary", server_url: "https://webdav.naruon.net", requires_if_match: true, provenance: "server-authoritative", @@ -169,7 +169,9 @@ describe("DataPage", () => { ]) { expect(requestHeaders[publicHeader]).toBeUndefined(); } - expect(JSON.parse(String(init?.body))).toEqual({ target_account_id: 1 }); + expect(JSON.parse(String(init?.body))).toEqual({ + target_source_id: "webdav_src_primary", + }); expect(container.textContent).toContain("server-authoritative"); expect(container.textContent).toContain("https://webdav.naruon.net"); }); diff --git a/frontend/src/app/tasks/page.test.tsx b/frontend/src/app/tasks/page.test.tsx index eb7a8ec2f..e35f103ab 100644 --- a/frontend/src/app/tasks/page.test.tsx +++ b/frontend/src/app/tasks/page.test.tsx @@ -189,7 +189,7 @@ describe("TasksPage", () => { source_type: "self_sent_knowledge", source_email_id: "", source_thread_id: "thread-self-note", - source_id: 1, + source_id: "webdav_src_primary", server_url: "https://webdav.naruon.net", target_path: "/Naruon/Notes/task-self-knowledge.md", requires_if_match: true, diff --git a/frontend/src/components/DataLayout.tsx b/frontend/src/components/DataLayout.tsx index 7c4a9514d..8a72b6cc9 100644 --- a/frontend/src/components/DataLayout.tsx +++ b/frontend/src/components/DataLayout.tsx @@ -6,7 +6,7 @@ import { apiClient } from '@/lib/api-client'; type WebdavWritebackIntentResponse = { intent: string; - source_id: number | null; + source_id: string | null; server_url: string | null; requires_if_match: boolean; provenance: string; @@ -65,7 +65,7 @@ export function DataLayout() { const [activeTab, setActiveTab] = useState<'문서 저장소' | '수집 파이프라인' | '임베딩' | '품질 점검'>('문서 저장소'); interface WebdavAccount { - account_id: number; + source_id: string; server_url: string; username: string; } @@ -97,10 +97,10 @@ export function DataLayout() { setWritebackStatus('loading'); setWritebackResult(null); try { - const targetAccountId = webdavAccounts[0]?.account_id; + const targetSourceId = webdavAccounts[0]?.source_id; const result = await apiClient.post( '/api/webdav/writeback-intent', - typeof targetAccountId === 'number' ? { target_account_id: targetAccountId } : {}, + targetSourceId ? { target_source_id: targetSourceId } : {}, ); setWritebackResult(result); setWritebackStatus('success'); @@ -185,7 +185,7 @@ export function DataLayout() { {webdavAccounts.map(acc => ( -
+
{acc.server_url} ({acc.username})
diff --git a/frontend/src/components/TasksLayout.tsx b/frontend/src/components/TasksLayout.tsx index f9ed95c24..fc039b2cf 100644 --- a/frontend/src/components/TasksLayout.tsx +++ b/frontend/src/components/TasksLayout.tsx @@ -39,7 +39,7 @@ type KnowledgeMaterializationIntent = { source_type: 'self_sent_knowledge'; source_email_id: string | null; source_thread_id: string | null; - source_id: number | null; + source_id: string | null; server_url: string | null; target_path: string; requires_if_match: boolean; diff --git a/frontend/tests/e2e/dashboard-branding.spec.ts b/frontend/tests/e2e/dashboard-branding.spec.ts index 7be19eab0..33d052fc6 100644 --- a/frontend/tests/e2e/dashboard-branding.spec.ts +++ b/frontend/tests/e2e/dashboard-branding.spec.ts @@ -550,8 +550,10 @@ test('renders data WebDAV writeback intent status without direct provider writes return url.pathname === '/api/webdav/writeback-intent' && request.method() === 'POST'; }); await page.getByRole('button', { name: 'WebDAV intent 승인 점검' }).click(); - const desktopHeaders = (await desktopWritebackRequest).headers(); + const desktopWritebackCall = await desktopWritebackRequest; + const desktopHeaders = desktopWritebackCall.headers(); expect(desktopHeaders.authorization).toBe(`Bearer ${expectedNaruonToken}`); + expect(desktopWritebackCall.postDataJSON()).toEqual({ target_source_id: 'webdav_src_primary' }); for (const headerName of publicIdentityHeaders) { expect(desktopHeaders[headerName]).toBeUndefined(); } @@ -572,8 +574,10 @@ test('renders data WebDAV writeback intent status without direct provider writes return url.pathname === '/api/webdav/writeback-intent' && request.method() === 'POST'; }); await page.getByRole('button', { name: 'WebDAV intent 승인 점검' }).click(); - const mobileHeaders = (await mobileWritebackRequest).headers(); + const mobileWritebackCall = await mobileWritebackRequest; + const mobileHeaders = mobileWritebackCall.headers(); expect(mobileHeaders.authorization).toBe(`Bearer ${expectedNaruonToken}`); + expect(mobileWritebackCall.postDataJSON()).toEqual({ target_source_id: 'webdav_src_primary' }); for (const headerName of publicIdentityHeaders) { expect(mobileHeaders[headerName]).toBeUndefined(); } diff --git a/frontend/tests/e2e/helpers.ts b/frontend/tests/e2e/helpers.ts index 81394a5a7..c291d1d96 100644 --- a/frontend/tests/e2e/helpers.ts +++ b/frontend/tests/e2e/helpers.ts @@ -322,7 +322,7 @@ export async function mockDashboardApi(page: Page, onApiRequest?: (path: string) if (path === '/api/webdav/accounts' && request.method() === 'GET') { await fulfillJson(route, [ { - account_id: 1, + source_id: 'webdav_src_primary', server_url: 'https://webdav.naruon.net', username: 'demo_user', }, @@ -349,7 +349,7 @@ export async function mockDashboardApi(page: Page, onApiRequest?: (path: string) if (path === '/api/webdav/writeback-intent' && request.method() === 'POST') { await fulfillJson(route, { intent: 'writeback', - source_id: 1, + source_id: 'webdav_src_primary', server_url: 'https://webdav.naruon.net', requires_if_match: true, provenance: 'server-authoritative', @@ -393,7 +393,7 @@ export async function mockDashboardApi(page: Page, onApiRequest?: (path: string) source_type: 'self_sent_knowledge', source_email_id: '', source_thread_id: 'thread-self-note', - source_id: 1, + source_id: 'webdav_src_primary', server_url: 'https://webdav.naruon.net', target_path: '/Naruon/Notes/task-self-knowledge.md', requires_if_match: true, From c79fdd28f93509eb10e3195c706a9923196a6345 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 28 May 2026 02:48:36 +0900 Subject: [PATCH 2/3] Scope WebDAV writeback sources by tenant --- AGENTS.md | 3 +- README.md | 5 +- backend/api/webdav.py | 1 + backend/db/models.py | 2 + backend/scripts/bootstrap_db.py | 12 ++++ backend/services/webdav_service.py | 13 ++-- backend/tests/test_bootstrap_db.py | 18 ++++++ backend/tests/test_webdav_api.py | 62 +++++++++++++++++-- ...urce-of-truth-and-writeback-sovereignty.md | 3 +- ...6-05-27-data-webdav-writeback-intent-ui.md | 5 +- ...self-sent-webdav-materialization-intent.md | 3 +- .../2026-05-27-webdav-opaque-source-id.md | 12 +++- frontend/src/app/data/page.test.tsx | 1 + frontend/src/components/DataLayout.tsx | 5 +- frontend/tests/e2e/helpers.ts | 1 + 15 files changed, 127 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 942720584..0cc871334 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,8 @@ actions back to legacy `/api/calendar/sync` unless a trusted backend credential dependency and source-owner contract are explicitly in scope. - Calendar and WebDAV writeback source selection must resolve through opaque - `source_uid` values, not sequential CalDAV or WebDAV account ids. + `source_uid` values, signed-session organization scope, and persisted + writeback eligibility, not sequential CalDAV or WebDAV account ids. 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. diff --git a/README.md b/README.md index a11f331bf..5149840ad 100644 --- a/README.md +++ b/README.md @@ -259,8 +259,9 @@ CalDAV account ids. The browser no longer claims `/api/calendar/sync` success from the mail-detail action path; direct provider writes stay deferred until connector execution can 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 and reject legacy -`target_account_id` payloads. +`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. ## Operations and release docs diff --git a/backend/api/webdav.py b/backend/api/webdav.py index 7e7f2421d..2d6370be0 100644 --- a/backend/api/webdav.py +++ b/backend/api/webdav.py @@ -20,6 +20,7 @@ class WebdavAccountResponse(BaseModel): source_id: str server_url: str username: str + writeback_enabled: bool class ProjectFolderResponse(BaseModel): folder_id: int diff --git a/backend/db/models.py b/backend/db/models.py index 97514e000..f1d62e0c7 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -434,9 +434,11 @@ class WebdavAccount(Base): nullable=False, ) user_id: Mapped[str] = mapped_column(String, index=True, nullable=False) + organization_id: Mapped[str | None] = mapped_column(String, index=True, nullable=True) server_url: Mapped[str] = mapped_column(String, nullable=False) username: Mapped[str] = mapped_column(String, nullable=False) credentials_encrypted: Mapped[str] = mapped_column(EncryptedString, nullable=False) + writeback_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) created_at: Mapped[datetime.datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.datetime.now(datetime.timezone.utc), diff --git a/backend/scripts/bootstrap_db.py b/backend/scripts/bootstrap_db.py index c4b91aea3..3fd2d80dc 100644 --- a/backend/scripts/bootstrap_db.py +++ b/backend/scripts/bootstrap_db.py @@ -46,6 +46,14 @@ def schema_backfill_sql(): ")" ), text("ALTER TABLE webdav_accounts ADD COLUMN IF NOT EXISTS source_uid varchar"), + text( + "ALTER TABLE webdav_accounts " + "ADD COLUMN IF NOT EXISTS organization_id varchar" + ), + text( + "ALTER TABLE webdav_accounts " + "ADD COLUMN IF NOT EXISTS writeback_enabled boolean NOT NULL DEFAULT false" + ), text("ALTER TABLE tenant_configs ADD COLUMN IF NOT EXISTS pop3_username varchar"), text("ALTER TABLE tenant_configs ADD COLUMN IF NOT EXISTS pop3_password varchar"), text("ALTER TABLE emails ADD COLUMN IF NOT EXISTS in_reply_to varchar"), @@ -94,6 +102,10 @@ def schema_backfill_sql(): "CREATE UNIQUE INDEX IF NOT EXISTS uq_webdav_accounts_source_uid " "ON webdav_accounts (source_uid)" ), + text( + "CREATE INDEX IF NOT EXISTS ix_webdav_accounts_organization_id " + "ON webdav_accounts (organization_id)" + ), text("ALTER TABLE emails DROP CONSTRAINT IF EXISTS emails_message_id_key"), text( "ALTER TABLE sender_relationships " diff --git a/backend/services/webdav_service.py b/backend/services/webdav_service.py index fadf2545e..96c64061f 100644 --- a/backend/services/webdav_service.py +++ b/backend/services/webdav_service.py @@ -70,21 +70,26 @@ async def get_connected_accounts_from_db( user_id: str, organization_id: str | None = None, ) -> List[Dict[str, Any]]: - del organization_id stmt = select( WebdavAccount.source_uid, WebdavAccount.server_url, WebdavAccount.username, - ).where(WebdavAccount.user_id == user_id) + WebdavAccount.writeback_enabled, + ).where( + WebdavAccount.user_id == user_id, + WebdavAccount.organization_id == organization_id + if organization_id is not None + else WebdavAccount.organization_id.is_(None), + ) result = await session.execute(stmt) return [ { "source_id": source_uid, "server_url": server_url, "username": username, - "writeback_enabled": True, + "writeback_enabled": bool(writeback_enabled), } - for source_uid, server_url, username in result.all() + for source_uid, server_url, username, writeback_enabled in result.all() ] async def get_project_folders_from_db( diff --git a/backend/tests/test_bootstrap_db.py b/backend/tests/test_bootstrap_db.py index b32bfb124..346e64430 100644 --- a/backend/tests/test_bootstrap_db.py +++ b/backend/tests/test_bootstrap_db.py @@ -122,6 +122,16 @@ def test_schema_backfill_adds_threading_columns_for_existing_tables(monkeypatch) in statement for statement in statements ) + assert any( + "alter table webdav_accounts add column if not exists organization_id" + in statement + for statement in statements + ) + assert any( + "alter table webdav_accounts add column if not exists writeback_enabled" + in statement + for statement in statements + ) assert any( "update webdav_accounts set source_uid" in statement and "webdav_src_" in statement @@ -138,6 +148,11 @@ def test_schema_backfill_adds_threading_columns_for_existing_tables(monkeypatch) in statement for statement in statements ) + assert any( + "create index if not exists ix_webdav_accounts_organization_id" + in statement + for statement in statements + ) assert any( "create index if not exists ix_llm_providers_organization_id" in statement for statement in statements @@ -259,5 +274,8 @@ def test_webdav_account_model_exposes_opaque_source_uid(): column_names = {column.name for column in WebdavAccount.__table__.columns} assert "source_uid" in column_names + assert "organization_id" in column_names + assert "writeback_enabled" in column_names assert WebdavAccount.__table__.c.source_uid.nullable is False assert WebdavAccount.__table__.c.source_uid.unique is True + assert WebdavAccount.__table__.c.writeback_enabled.nullable is False diff --git a/backend/tests/test_webdav_api.py b/backend/tests/test_webdav_api.py index dd1c2f9f2..cf7ccf8ae 100644 --- a/backend/tests/test_webdav_api.py +++ b/backend/tests/test_webdav_api.py @@ -157,6 +157,7 @@ def test_get_webdav_accounts(auth_client): assert len(body) > 0 assert body[0]["server_url"] == "https://webdav.naruon.net" assert body[0]["username"] == "demo_user" + assert body[0]["writeback_enabled"] is True def test_get_project_folders(auth_client): response = auth_client.get("/api/webdav/folders") @@ -399,6 +400,24 @@ def test_get_webdav_writeback_intent_no_accounts(): assert response.json()["detail"] == "No connected WebDAV accounts found." +def test_webdav_writeback_intent_skips_disabled_accounts(): + svc = WebDavService() + result = svc.determine_webdav_writeback_intent_from_accounts( + [ + { + "source_id": "webdav_src_disabled", + "server_url": "https://webdav.naruon.net", + "username": "demo_user", + "writeback_enabled": False, + } + ], + target_source_id="webdav_src_disabled", + ) + + assert result["status"] == "error" + assert result["error_code"] == "no_webdav_account" + + @pytest.mark.asyncio async def test_webdav_writeback_intent_real_postgres_smoke(monkeypatch): source_uid = f"webdav_src_{uuid.uuid4().hex[:24]}" @@ -430,15 +449,36 @@ async def test_webdav_writeback_intent_real_postgres_smoke(monkeypatch): CREATE TABLE IF NOT EXISTS webdav_accounts ( account_id INTEGER PRIMARY KEY, source_uid VARCHAR UNIQUE NOT NULL, + organization_id VARCHAR, user_id VARCHAR NOT NULL, server_url VARCHAR NOT NULL, username VARCHAR NOT NULL, credentials_encrypted VARCHAR NOT NULL, + writeback_enabled BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ) """ ) ) + await conn.execute( + text( + "ALTER TABLE webdav_accounts " + "ADD COLUMN IF NOT EXISTS source_uid VARCHAR" + ) + ) + await conn.execute( + text( + "ALTER TABLE webdav_accounts " + "ADD COLUMN IF NOT EXISTS organization_id VARCHAR" + ) + ) + await conn.execute( + text( + "ALTER TABLE webdav_accounts " + "ADD COLUMN IF NOT EXISTS writeback_enabled " + "BOOLEAN NOT NULL DEFAULT FALSE" + ) + ) await conn.execute( text( """ @@ -454,28 +494,34 @@ async def test_webdav_writeback_intent_real_postgres_smoke(monkeypatch): INSERT INTO webdav_accounts ( account_id, source_uid, + organization_id, user_id, server_url, username, - credentials_encrypted + credentials_encrypted, + writeback_enabled ) VALUES ( :account_id, :source_uid, + :organization_id, :user_id, :server_url, :username, - :credentials_encrypted + :credentials_encrypted, + :writeback_enabled ) """ ), { "account_id": 10_000 + (uuid.uuid4().int % 1_000_000), "source_uid": source_uid, + "organization_id": "org-acme", "user_id": user_id, "server_url": "https://real-webdav.naruon.net", "username": user_id, "credentials_encrypted": "test-only-placeholder", + "writeback_enabled": True, }, ) except ( @@ -631,10 +677,12 @@ async def test_knowledge_materialization_intent_real_postgres_endpoint_smoke( CREATE TABLE webdav_accounts ( account_id INTEGER PRIMARY KEY, source_uid VARCHAR UNIQUE NOT NULL, + organization_id VARCHAR, user_id VARCHAR NOT NULL, server_url VARCHAR NOT NULL, username VARCHAR NOT NULL, credentials_encrypted VARCHAR NOT NULL, + writeback_enabled BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ) """ @@ -712,28 +760,34 @@ async def test_knowledge_materialization_intent_real_postgres_endpoint_smoke( INSERT INTO webdav_accounts ( account_id, source_uid, + organization_id, user_id, server_url, username, - credentials_encrypted + credentials_encrypted, + writeback_enabled ) VALUES ( :account_id, :source_uid, + :organization_id, :user_id, :server_url, :username, - :credentials_encrypted + :credentials_encrypted, + :writeback_enabled ) """ ), { "account_id": smoke_id + 1, "source_uid": source_uid, + "organization_id": "org-acme", "user_id": user_id, "server_url": "https://real-webdav.naruon.net", "username": user_id, "credentials_encrypted": "test-only-placeholder", + "writeback_enabled": True, }, ) except Exception: diff --git a/docs/operations/source-of-truth-and-writeback-sovereignty.md b/docs/operations/source-of-truth-and-writeback-sovereignty.md index 47bd01563..cd1b04f7d 100644 --- a/docs/operations/source-of-truth-and-writeback-sovereignty.md +++ b/docs/operations/source-of-truth-and-writeback-sovereignty.md @@ -45,7 +45,8 @@ idempotent ticket tasks, deterministic sender ontology action hints, generic WebDAV writeback intent, self-sent knowledge WebDAV/Notes materialization intent, DB-backed CalDAV intent source selection through opaque `calendar_writeback_sources.source_uid` rows, and WebDAV intent selection through -opaque `webdav_accounts.source_uid` rows. Real CalDAV/WebDAV mutation, +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 reply-tracking notifications, and source-id filtered sender graph views remain episode work and must preserve this registry diff --git a/docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md b/docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md index 8842c56f1..00475e0e4 100644 --- a/docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md +++ b/docs/plans/2026-05-27-data-webdav-writeback-intent-ui.md @@ -22,8 +22,9 @@ AI-organized files or attachments are written back to their own storage. - `/data` now exposes a WebDAV writeback intent approval panel. - The intent panel uses the first connected customer WebDAV account as the - target source through `webdav_accounts.source_uid` and fails closed when no - source or signed session is available. + target source through `webdav_accounts.source_uid`, only chooses accounts with + persisted `writeback_enabled=true`, and fails closed when no eligible source + or signed session is available. - Data repository cards now use responsive grid tracks so mobile verification does not depend on desktop-only three-column layouts. - Unit and Playwright tests assert signed `Authorization: Bearer` handling and diff --git a/docs/plans/2026-05-27-self-sent-webdav-materialization-intent.md b/docs/plans/2026-05-27-self-sent-webdav-materialization-intent.md index 9d3e7a1c4..d371d0b40 100644 --- a/docs/plans/2026-05-27-self-sent-webdav-materialization-intent.md +++ b/docs/plans/2026-05-27-self-sent-webdav-materialization-intent.md @@ -17,7 +17,8 @@ from an unscoped backend path. return `404`. - The backend chooses the customer WebDAV source through the existing server-authoritative account lookup using opaque `webdav_accounts.source_uid` - values and returns intent metadata only: + values, the signed session `organization_id`, and persisted + `writeback_enabled` eligibility. It returns intent metadata only: `target_path`, `server_url`, `requires_if_match`, `provenance`, `provider_write_executed=false`, and the audit event name. - Clients may pass `target_source_id`; legacy sequential `target_account_id` 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 61b6ccc64..ff32eb187 100644 --- a/docs/plans/2026-05-27-webdav-opaque-source-id.md +++ b/docs/plans/2026-05-27-webdav-opaque-source-id.md @@ -9,6 +9,8 @@ opaque `webdav_accounts.source_uid` values, matching the CalDAV source-id rule. ## Scope - Add/backfill `webdav_accounts.source_uid` and a unique index. +- 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. @@ -24,5 +26,11 @@ python3 -m pytest backend/tests/test_webdav_api.py backend/tests/test_bootstrap_ cd frontend && npm test -- --run src/app/data/page.test.tsx src/app/tasks/page.test.tsx ``` -Browser evidence remains the Data and Tasks E2E screenshots for desktop, mobile, -and mobile-scroll writeback surfaces. +Browser evidence to inspect: + +- `data-webdav-writeback-intent-desktop.png` +- `data-webdav-writeback-intent-mobile.png` +- `data-webdav-writeback-intent-mobile-scroll.png` +- `self-sent-knowledge-webdav-intent-desktop.png` +- `self-sent-knowledge-webdav-intent-mobile.png` +- `self-sent-knowledge-webdav-intent-mobile-scroll.png` diff --git a/frontend/src/app/data/page.test.tsx b/frontend/src/app/data/page.test.tsx index 961720a4e..c9c81297e 100644 --- a/frontend/src/app/data/page.test.tsx +++ b/frontend/src/app/data/page.test.tsx @@ -39,6 +39,7 @@ function mockWebdavFetch() { source_id: "webdav_src_primary", server_url: "https://webdav.naruon.net", username: "demo_user", + writeback_enabled: true, }, ]); } diff --git a/frontend/src/components/DataLayout.tsx b/frontend/src/components/DataLayout.tsx index 8a72b6cc9..5fccad768 100644 --- a/frontend/src/components/DataLayout.tsx +++ b/frontend/src/components/DataLayout.tsx @@ -68,6 +68,7 @@ export function DataLayout() { source_id: string; server_url: string; username: string; + writeback_enabled: boolean; } interface ProjectFolder { @@ -97,7 +98,7 @@ export function DataLayout() { setWritebackStatus('loading'); setWritebackResult(null); try { - const targetSourceId = webdavAccounts[0]?.source_id; + const targetSourceId = webdavAccounts.find(acc => acc.writeback_enabled)?.source_id; const result = await apiClient.post( '/api/webdav/writeback-intent', targetSourceId ? { target_source_id: targetSourceId } : {}, @@ -229,7 +230,7 @@ export function DataLayout() { )} {writebackStatus === 'loading' &&

WebDAV writeback intent 요청 중입니다.

} {writebackStatus === 'no_source' && ( -

연결된 고객 WebDAV 원본 계정이 없어 writeback intent를 만들 수 없습니다.

+

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

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

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

diff --git a/frontend/tests/e2e/helpers.ts b/frontend/tests/e2e/helpers.ts index c291d1d96..e4232a08e 100644 --- a/frontend/tests/e2e/helpers.ts +++ b/frontend/tests/e2e/helpers.ts @@ -325,6 +325,7 @@ export async function mockDashboardApi(page: Page, onApiRequest?: (path: string) source_id: 'webdav_src_primary', server_url: 'https://webdav.naruon.net', username: 'demo_user', + writeback_enabled: true, }, ]); return; From bb9258ec17ca70efb1c216bc4a8c3239f81c79fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 28 May 2026 03:36:00 +0900 Subject: [PATCH 3/3] Fail closed on missing WebDAV writeback eligibility --- AGENTS.md | 7 ++++--- backend/services/webdav_service.py | 2 +- backend/tests/test_webdav_api.py | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0cc871334..f70e8934e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,9 +107,10 @@ - Calendar and WebDAV writeback source selection must resolve through opaque `source_uid` values, signed-session organization scope, and persisted writeback eligibility, not sequential CalDAV or WebDAV account ids. - 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. + 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. - 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/backend/services/webdav_service.py b/backend/services/webdav_service.py index 96c64061f..b66b4461b 100644 --- a/backend/services/webdav_service.py +++ b/backend/services/webdav_service.py @@ -218,7 +218,7 @@ def determine_webdav_writeback_intent_from_accounts( target_source_id: str | None = None, ) -> Dict[str, Any]: writable_accounts = [ - account for account in accounts if account.get("writeback_enabled", True) + account for account in accounts if account.get("writeback_enabled", False) ] if not writable_accounts: return { diff --git a/backend/tests/test_webdav_api.py b/backend/tests/test_webdav_api.py index cf7ccf8ae..076448e52 100644 --- a/backend/tests/test_webdav_api.py +++ b/backend/tests/test_webdav_api.py @@ -418,6 +418,23 @@ def test_webdav_writeback_intent_skips_disabled_accounts(): assert result["error_code"] == "no_webdav_account" +def test_webdav_writeback_intent_fails_closed_without_eligibility(): + svc = WebDavService() + result = svc.determine_webdav_writeback_intent_from_accounts( + [ + { + "source_id": "webdav_src_missing_eligibility", + "server_url": "https://webdav.naruon.net", + "username": "demo_user", + } + ], + target_source_id="webdav_src_missing_eligibility", + ) + + assert result["status"] == "error" + assert result["error_code"] == "no_webdav_account" + + @pytest.mark.asyncio async def test_webdav_writeback_intent_real_postgres_smoke(monkeypatch): source_uid = f"webdav_src_{uuid.uuid4().hex[:24]}"