Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,9 @@
`source_uid` values, signed-session organization scope, and persisted
writeback eligibility, not sequential CalDAV or WebDAV account ids.
Missing writeback eligibility must fail closed. Browser-visible source ids
must not reveal account primary keys, and provider mutations remain future
work until connector execution can enforce capability, consent, and
ETag/If-Match checks.
must not reveal or be deterministically derived from account primary keys, and
provider mutations remain future work until connector execution can enforce
capability, consent, and ETag/If-Match checks.
- Self-sent knowledge extraction must first prove true self-to-self addressing,
stay idempotent per source email, preserve email/thread provenance, and store
only plain-text task titles. Do not create unlinked knowledge tasks from raw
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,8 @@ enforce ETag/If-Match and owner capability checks.
WebDAV writeback and self-sent knowledge materialization use
`webdav_accounts.source_uid` as the browser-visible source id, scope lookup by
the signed session organization, honor persisted `writeback_enabled`
eligibility, and reject legacy `target_account_id` payloads.
eligibility, reject legacy `target_account_id` payloads, and keep sequential
`account_id` values internal-only.

## Operations and release docs

Expand Down
3 changes: 2 additions & 1 deletion backend/scripts/bootstrap_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ def schema_backfill_sql():
text(
"UPDATE webdav_accounts "
"SET source_uid = 'webdav_src_' || md5("
"account_id::text || ':' || user_id || ':' || server_url"
"random()::text || ':' || clock_timestamp()::text || ':' || "
"user_id || ':' || server_url"
") "
"WHERE source_uid IS NULL OR source_uid = ''"
),
Expand Down
3 changes: 3 additions & 0 deletions backend/tests/test_bootstrap_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,11 @@ def test_schema_backfill_adds_threading_columns_for_existing_tables(monkeypatch)
"update webdav_accounts set source_uid" in statement
and "webdav_src_" in statement
and "md5" in statement
and "random()::text" in statement
and "clock_timestamp()::text" in statement
for statement in statements
)
assert not any("account_id::text" in statement for statement in statements)
assert any(
"alter table webdav_accounts alter column source_uid set not null"
in statement
Expand Down
120 changes: 100 additions & 20 deletions backend/tests/test_webdav_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from core.config import settings
from main import app
from api.auth import get_auth_context
from api.auth import get_auth_context, get_current_user
from db.models import TicketTask
from db.session import get_db
from services.webdav_service import WebDavService, webdav_service
Expand Down Expand Up @@ -150,14 +150,78 @@ def auth_client():
) as client:
yield client


def _request_with_signed_session(method: str, path: str, json_body: dict | None = None):
previous_secret = settings.AUTH_SESSION_HMAC_SECRET
original_overrides = dict(app.dependency_overrides)
settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET)
token = _signed_session_token(_valid_session_payload())
app.dependency_overrides.pop(get_auth_context, None)
app.dependency_overrides.pop(get_current_user, None)
try:
with TestClient(app) as client:
return client.request(
method,
path,
json=json_body,
headers={"Authorization": f"Bearer {token}"},
)
finally:
settings.AUTH_SESSION_HMAC_SECRET = previous_secret
app.dependency_overrides.clear()
app.dependency_overrides.update(original_overrides)


def _request_without_signed_session(
method: str,
path: str,
json_body: dict | None = None,
):
original_overrides = dict(app.dependency_overrides)
app.dependency_overrides.pop(get_auth_context, None)
app.dependency_overrides.pop(get_current_user, None)
try:
with TestClient(app) as client:
return client.request(
method,
path,
json=json_body,
headers={
"X-User-Id": "alice",
"X-Organization-Id": "org-acme",
},
)
finally:
app.dependency_overrides.clear()
app.dependency_overrides.update(original_overrides)


def test_get_webdav_accounts(auth_client):
response = auth_client.get("/api/webdav/accounts")
assert response.status_code == 200, response.text
body = response.json()
assert len(body) > 0
assert body[0]["source_id"] == "webdav_src_demo_primary"
assert body[0]["server_url"] == "https://webdav.naruon.net"
assert body[0]["username"] == "demo_user"
assert body[0]["writeback_enabled"] is True
assert "account_id" not in body[0]


def test_get_webdav_accounts_accepts_signed_bearer_session():
response = _request_with_signed_session("GET", "/api/webdav/accounts")

assert response.status_code == 200, response.text
body = response.json()
assert body[0]["source_id"] == "webdav_src_demo_primary"
assert "account_id" not in body[0]


def test_webdav_routes_reject_public_identity_headers_without_signed_session():
response = _request_without_signed_session("GET", "/api/webdav/accounts")

assert response.status_code == 401
assert response.json()["detail"] == "Authentication required"

def test_get_project_folders(auth_client):
response = auth_client.get("/api/webdav/folders")
Expand All @@ -176,6 +240,20 @@ def test_get_webdav_writeback_intent(auth_client):
assert body["source_id"] == "webdav_src_demo_primary"
assert body["server_url"] == "https://webdav.naruon.net"
assert body["provenance"] == "server-authoritative"
assert "account_id" not in body


def test_get_webdav_writeback_intent_accepts_signed_bearer_session():
response = _request_with_signed_session(
"POST",
"/api/webdav/writeback-intent",
{"target_source_id": "webdav_src_demo_primary"},
)

assert response.status_code == 200, response.text
body = response.json()
assert body["source_id"] == "webdav_src_demo_primary"
assert "account_id" not in body


def test_get_webdav_writeback_intent_with_target_account(auth_client):
Expand Down Expand Up @@ -234,6 +312,7 @@ def test_get_self_sent_knowledge_webdav_intent(auth_client):
assert "related_email_id" not in body
assert "user_id" not in body
assert "organization_id" not in body
assert "account_id" not in body


def test_self_sent_knowledge_webdav_intent_requires_source_task(auth_client):
Expand Down Expand Up @@ -279,27 +358,18 @@ def test_self_sent_knowledge_webdav_intent_requires_connected_webdav_account():


def test_get_self_sent_knowledge_webdav_intent_accepts_signed_bearer_session():
previous_secret = settings.AUTH_SESSION_HMAC_SECRET
previous_override = app.dependency_overrides.pop(get_auth_context, None)
settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET)
token = _signed_session_token(_valid_session_payload())
try:
with TestClient(app) as client:
response = client.post(
"/api/webdav/knowledge-materialization-intent",
json={
"target_source_id": "webdav_src_demo_primary",
"source_task_id": "task-self-knowledge",
},
headers={"Authorization": f"Bearer {token}"},
)
finally:
settings.AUTH_SESSION_HMAC_SECRET = previous_secret
if previous_override is not None:
app.dependency_overrides[get_auth_context] = previous_override
response = _request_with_signed_session(
"POST",
"/api/webdav/knowledge-materialization-intent",
{
"target_source_id": "webdav_src_demo_primary",
"source_task_id": "task-self-knowledge",
},
)

assert response.status_code == 200, response.text
assert response.json()["task_id"] == "task-self-knowledge"
assert "account_id" not in response.json()


@pytest.mark.asyncio
Expand Down Expand Up @@ -562,19 +632,28 @@ async def override_real_db():
async with session_factory() as session:
yield session

previous_override = app.dependency_overrides.pop(get_auth_context, None)
previous_secret = settings.AUTH_SESSION_HMAC_SECRET
settings.AUTH_SESSION_HMAC_SECRET = SecretStr(TEST_SESSION_HMAC_SECRET)
app.dependency_overrides[get_db] = override_real_db
token = _signed_session_token(
_valid_session_payload(sub=user_id, org="org-acme", workspace="workspace-org-acme")
)
try:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
headers={"X-User-Id": user_id, "X-Organization-Id": "org-acme"},
headers={"Authorization": f"Bearer {token}"},
) as client:
response = await client.post(
"/api/webdav/writeback-intent",
json={"target_source_id": source_uid},
)
finally:
settings.AUTH_SESSION_HMAC_SECRET = previous_secret
if previous_override is not None:
app.dependency_overrides[get_auth_context] = previous_override
app.dependency_overrides.pop(get_db, None)
async with engine.begin() as conn:
await conn.execute(
Expand All @@ -591,6 +670,7 @@ async def override_real_db():
assert body["source_id"] == source_uid
assert body["server_url"] == "https://real-webdav.naruon.net"
assert body["provenance"] == "server-authoritative"
assert "account_id" not in body


@pytest.mark.asyncio
Expand Down
6 changes: 4 additions & 2 deletions docs/operations/source-of-truth-and-writeback-sovereignty.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ ticket tasks, DB-backed CalDAV intent source selection through opaque
`calendar_writeback_sources.source_uid` rows exposed to the Calendar workspace
through a signed source-registry read, and WebDAV intent selection through
opaque, organization-scoped `webdav_accounts.source_uid` rows with persisted
writeback eligibility. Real CalDAV/WebDAV mutation, ETag-aware WebDAV/Notes
provider execution for synthesized knowledge, POP3 runtime sync, durable
writeback eligibility; sequential `webdav_accounts.account_id` values remain
internal and are not browser-visible source identifiers. Real CalDAV/WebDAV
mutation, ETag-aware WebDAV/Notes provider execution for synthesized knowledge,
POP3 runtime sync, durable
reply-tracking notifications, configurable SLA policy storage, and source-id
filtered sender graph views remain episode work and must preserve this registry
boundary.
Expand Down
7 changes: 6 additions & 1 deletion docs/plans/2026-05-27-webdav-opaque-source-id.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@ opaque `webdav_accounts.source_uid` values, matching the CalDAV source-id rule.

## Scope

- Add/backfill `webdav_accounts.source_uid` and a unique index.
- Add/backfill `webdav_accounts.source_uid` and a unique index. Legacy rows get
non-sequential opaque values; the browser-visible id is not derived from
`account_id`.
- Scope WebDAV source lookup by `organization_id` from the signed session and
honor persisted `writeback_enabled` eligibility.
- Replace `target_account_id` requests with `target_source_id`.
- Return `source_id` as a string in WebDAV writeback and materialization intent
responses.
- Verify `/accounts`, `/writeback-intent`, and
`/knowledge-materialization-intent` omit `account_id` and work through signed
bearer sessions without public identity headers.
- Keep provider writes out of scope; these endpoints still create intent
metadata only.
- Keep Strix on direct OpenAI Platform credentials only. Do not use GitHub
Expand Down
26 changes: 22 additions & 4 deletions frontend/src/components/DataLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type WebdavWritebackIntentResponse = {
};

type WritebackStatus = 'idle' | 'loading' | 'success' | 'no_source' | 'auth' | 'error';
type WebdavAccountStatus = 'loading' | 'ready' | 'error';

type UniqueThreadIntentResponse = {
status: string;
Expand Down Expand Up @@ -78,6 +79,7 @@ export function DataLayout() {
}

const [webdavAccounts, setWebdavAccounts] = useState<WebdavAccount[]>([]);
const [webdavAccountStatus, setWebdavAccountStatus] = useState<WebdavAccountStatus>('loading');
const [projectFolders, setProjectFolders] = useState<ProjectFolder[]>([]);
const [writebackStatus, setWritebackStatus] = useState<WritebackStatus>('idle');
const [writebackResult, setWritebackResult] = useState<WebdavWritebackIntentResponse | null>(null);
Expand All @@ -86,8 +88,15 @@ export function DataLayout() {

useEffect(() => {
apiClient.get<WebdavAccount[]>('/api/webdav/accounts')
.then(data => Array.isArray(data) && setWebdavAccounts(data))
.catch(console.error);
.then((data) => {
if (Array.isArray(data)) setWebdavAccounts(data);
setWebdavAccountStatus('ready');
})
.catch((error: unknown) => {
console.error(error);
setWebdavAccounts([]);
setWebdavAccountStatus('error');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

apiClient.get<ProjectFolder[]>('/api/webdav/folders')
.then(data => Array.isArray(data) && setProjectFolders(data))
Expand All @@ -99,9 +108,13 @@ export function DataLayout() {
setWritebackResult(null);
try {
const targetSourceId = webdavAccounts.find(acc => acc.writeback_enabled)?.source_id;
if (!targetSourceId) {
setWritebackStatus('no_source');
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const result = await apiClient.post<WebdavWritebackIntentResponse>(
'/api/webdav/writeback-intent',
targetSourceId ? { target_source_id: targetSourceId } : {},
{ target_source_id: targetSourceId },
);
setWritebackResult(result);
setWritebackStatus('success');
Expand Down Expand Up @@ -134,6 +147,7 @@ export function DataLayout() {
}, []);

const isWritebackLoading = writebackStatus === 'loading';
const isWebdavSourceLoading = webdavAccountStatus === 'loading';
const isUniqueThreadLoading = uniqueThreadStatus === 'loading';

return (
Expand Down Expand Up @@ -217,7 +231,8 @@ export function DataLayout() {
<button
type="button"
onClick={() => void requestWebdavWritebackIntent()}
disabled={isWritebackLoading}
disabled={isWritebackLoading || isWebdavSourceLoading}
aria-busy={isWebdavSourceLoading || isWritebackLoading}
className="w-full whitespace-nowrap rounded-xl bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-wait disabled:opacity-60 sm:w-auto"
>
WebDAV intent 승인 점검
Expand All @@ -229,6 +244,9 @@ export function DataLayout() {
<p className="text-muted-foreground">아직 WebDAV provider write는 실행하지 않았습니다. 원본 source와 충돌 조건만 확인합니다.</p>
)}
{writebackStatus === 'loading' && <p className="font-bold text-primary">WebDAV writeback intent 요청 중입니다.</p>}
{writebackStatus === 'idle' && webdavAccountStatus === 'error' && (
<p className="font-bold text-red-700">WebDAV 원본 계정 목록을 확인하지 못했습니다.</p>
)}
{writebackStatus === 'no_source' && (
<p className="font-bold text-amber-700">writeback 가능한 고객 WebDAV 원본 계정이 없어 intent를 만들 수 없습니다.</p>
)}
Expand Down
Loading