diff --git a/AGENTS.md b/AGENTS.md index 99fe9dddf..f70e8934e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,11 +104,13 @@ 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, 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. - 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..5149840ad 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,10 @@ 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, 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 e2948630e..2d6370be0 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,9 +17,10 @@ } class WebdavAccountResponse(BaseModel): - account_id: int + source_id: str server_url: str username: str + writeback_enabled: bool class ProjectFolderResponse(BaseModel): folder_id: int @@ -27,11 +28,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 +42,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 +54,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 +68,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 +92,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 +113,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..f1d62e0c7 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -426,10 +426,19 @@ 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) + 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 e77df7f13..3fd2d80dc 100644 --- a/backend/scripts/bootstrap_db.py +++ b/backend/scripts/bootstrap_db.py @@ -45,6 +45,15 @@ 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 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"), @@ -81,6 +90,22 @@ 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( + "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 a090a0db7..b66b4461b 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,28 @@ 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) + stmt = select( + WebdavAccount.source_uid, + WebdavAccount.server_url, + WebdavAccount.username, + 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 [ { - "account_id": account.id, - "server_url": account.server_url, - "username": account.username, + "source_id": source_uid, + "server_url": server_url, + "username": username, + "writeback_enabled": bool(writeback_enabled), } - for account in result.scalars().all() + for source_uid, server_url, username, writeback_enabled in result.all() ] async def get_project_folders_from_db( @@ -103,26 +116,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 +149,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 +190,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 +215,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", False) + ] + 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 +243,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..346e64430 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,42 @@ 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( + "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 + 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_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 @@ -232,3 +268,14 @@ 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 "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 3f2cd91d7..076448e52 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 { @@ -150,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") @@ -165,7 +173,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 +181,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 +195,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 +288,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}"}, @@ -381,10 +400,45 @@ 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" + + +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): - 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,49 +465,80 @@ 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( """ 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, + 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": account_id, + "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 ( @@ -487,20 +572,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 +603,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,10 +693,13 @@ 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 ) """ @@ -685,26 +776,35 @@ 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": account_id, + "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: @@ -737,7 +837,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 +859,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..cd1b04f7d 100644 --- a/docs/operations/source-of-truth-and-writeback-sovereignty.md +++ b/docs/operations/source-of-truth-and-writeback-sovereignty.md @@ -43,8 +43,10 @@ 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, 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 d1ae07a06..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 @@ -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,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 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 @@ -43,4 +47,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..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 @@ -16,9 +16,13 @@ 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, 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` + 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..ff32eb187 --- /dev/null +++ b/docs/plans/2026-05-27-webdav-opaque-source-id.md @@ -0,0 +1,36 @@ +# 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. +- 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. +- 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 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 4344389ab..c9c81297e 100644 --- a/frontend/src/app/data/page.test.tsx +++ b/frontend/src/app/data/page.test.tsx @@ -36,9 +36,10 @@ 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", + writeback_enabled: true, }, ]); } @@ -55,7 +56,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 +170,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..5fccad768 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,9 +65,10 @@ export function DataLayout() { const [activeTab, setActiveTab] = useState<'문서 저장소' | '수집 파이프라인' | '임베딩' | '품질 점검'>('문서 저장소'); interface WebdavAccount { - account_id: number; + source_id: string; server_url: string; username: string; + writeback_enabled: boolean; } interface ProjectFolder { @@ -97,10 +98,10 @@ export function DataLayout() { setWritebackStatus('loading'); setWritebackResult(null); try { - const targetAccountId = webdavAccounts[0]?.account_id; + const targetSourceId = webdavAccounts.find(acc => acc.writeback_enabled)?.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 +186,7 @@ export function DataLayout() { {webdavAccounts.map(acc => ( -
+
{acc.server_url} ({acc.username})
@@ -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/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..e4232a08e 100644 --- a/frontend/tests/e2e/helpers.ts +++ b/frontend/tests/e2e/helpers.ts @@ -322,9 +322,10 @@ 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', + writeback_enabled: true, }, ]); return; @@ -349,7 +350,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 +394,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,