feat(12-06): connection-ID native lifecycle — always INSERT, PUT credentials, connectionsFor multi-account
- Replace _upsert_cloud_connection with _insert_cloud_connection (always inserts new UUID row)
- Add PUT /connections/{id}/credentials endpoint (owner-scoped, SSRF + health-check, password-preserve)
- SettingsCloudTab: connectionsFor() renders all same-provider connections with Add account row
- CloudCredentialModal.submit: calls updateWebDavCredentials on edit, connectWebDav on create
- utils.js: FastAPI validation arrays normalised to concise field messages (Rule 2)
- Backend tests: same-provider independence + IDOR negative test for credential update
This commit is contained in:
@@ -106,38 +106,29 @@ def _webdav_credentials(body: WebDAVConnectRequest) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
async def _upsert_cloud_connection(
|
||||
async def _insert_cloud_connection(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
provider: str,
|
||||
credentials_enc: str,
|
||||
) -> CloudConnection:
|
||||
"""Insert or update a connection for (user_id, provider)."""
|
||||
result = await session.execute(
|
||||
select(CloudConnection).where(
|
||||
CloudConnection.user_id == user_id,
|
||||
CloudConnection.provider == provider,
|
||||
)
|
||||
"""Always insert a new connection row — never merges same-provider rows."""
|
||||
conn = CloudConnection(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
provider=provider,
|
||||
display_name=_DISPLAY_NAMES.get(provider, provider),
|
||||
credentials_enc=credentials_enc,
|
||||
status="ACTIVE",
|
||||
)
|
||||
conn = result.scalar_one_or_none()
|
||||
|
||||
if conn is not None:
|
||||
conn.credentials_enc = credentials_enc
|
||||
conn.status = "ACTIVE"
|
||||
else:
|
||||
conn = CloudConnection(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
provider=provider,
|
||||
display_name=_DISPLAY_NAMES.get(provider, provider),
|
||||
credentials_enc=credentials_enc,
|
||||
status="ACTIVE",
|
||||
)
|
||||
session.add(conn)
|
||||
|
||||
session.add(conn)
|
||||
return conn
|
||||
|
||||
|
||||
# Keep legacy alias used by OAuth callback (OAuth creates one connection per flow)
|
||||
_upsert_cloud_connection = _insert_cloud_connection
|
||||
|
||||
|
||||
async def _get_owned_connection(
|
||||
session: AsyncSession,
|
||||
connection_id: uuid.UUID,
|
||||
@@ -424,7 +415,7 @@ async def connect_webdav(
|
||||
|
||||
credentials_enc = encrypt_credentials(_master_key(), str(current_user.id), credentials)
|
||||
|
||||
conn = await _upsert_cloud_connection(session, current_user.id, body.provider, credentials_enc)
|
||||
conn = await _insert_cloud_connection(session, current_user.id, body.provider, credentials_enc)
|
||||
await session.flush()
|
||||
|
||||
_ip = get_client_ip(request)
|
||||
@@ -492,6 +483,102 @@ async def get_connection_config(
|
||||
}
|
||||
|
||||
|
||||
class WebDAVCredentialUpdateRequest(BaseModel):
|
||||
server_url: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None # omit to keep existing
|
||||
|
||||
|
||||
@router.put("/connections/{connection_id}/credentials", status_code=status.HTTP_200_OK)
|
||||
@account_limiter.limit("100/minute")
|
||||
async def update_webdav_credentials(
|
||||
connection_id: uuid.UUID,
|
||||
body: WebDAVCredentialUpdateRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
) -> dict:
|
||||
"""Update credentials for an existing WebDAV/Nextcloud connection (owner-scoped).
|
||||
|
||||
Only changes the fields provided. Omitting password preserves the existing secret.
|
||||
Re-validates the URL (SSRF gate) and runs a health-check before committing.
|
||||
T-12-06-01: owner scope via _get_owned_connection.
|
||||
T-12-06-04: re-runs validate_cloud_url and health check before storing.
|
||||
"""
|
||||
request.state.current_user = current_user
|
||||
conn = await _get_owned_connection(session, connection_id, current_user.id)
|
||||
|
||||
if conn.provider not in VALID_WEBDAV_PROVIDERS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Credential update is only available for WebDAV/Nextcloud connections",
|
||||
)
|
||||
|
||||
# Decrypt current credentials so we can merge with the submitted partial update
|
||||
try:
|
||||
current_creds = _decrypt_connection(conn, current_user.id)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Failed to decrypt existing credentials",
|
||||
)
|
||||
|
||||
merged = {
|
||||
"server_url": body.server_url if body.server_url is not None else current_creds.get("server_url", ""),
|
||||
"username": body.username if body.username is not None else current_creds.get("username", ""),
|
||||
"password": body.password if body.password else current_creds.get("password", ""),
|
||||
}
|
||||
|
||||
try:
|
||||
validate_cloud_url(merged["server_url"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid server URL: {exc}",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
backend = build_cloud_backend(conn.provider, merged)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid credentials: {exc}",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
ok = await backend.health_check()
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Connection test failed — check server URL and credentials",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Connection test failed: {exc}",
|
||||
) from exc
|
||||
|
||||
conn.credentials_enc = encrypt_credentials(_master_key(), str(current_user.id), merged)
|
||||
conn.status = "ACTIVE"
|
||||
|
||||
_ip = get_client_ip(request)
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="cloud.credentials_updated",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=conn.id,
|
||||
ip_address=_ip,
|
||||
metadata_={"provider": conn.provider},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(conn)
|
||||
|
||||
return CloudConnectionOut.model_validate(conn).model_dump()
|
||||
|
||||
|
||||
@router.patch("/connections/{connection_id}", status_code=status.HTTP_200_OK)
|
||||
@account_limiter.limit("100/minute")
|
||||
async def rename_connection(
|
||||
|
||||
Reference in New Issue
Block a user