From eab24750f9c2ff9e3bf98261030f35438feb7925 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 28 Jul 2026 19:33:08 +0200 Subject: [PATCH] Integrate address sources with credential envelopes --- src/govoplan_addresses/backend/router.py | 22 +++ src/govoplan_addresses/backend/schemas.py | 20 +++ src/govoplan_addresses/backend/service.py | 164 ++++++++++++++++-- tests/test_addresses_service.py | 44 +++++ webui/src/api/addresses.ts | 41 ++++- .../features/addressbook/AddressBookPage.tsx | 91 +++++++++- 6 files changed, 362 insertions(+), 20 deletions(-) diff --git a/src/govoplan_addresses/backend/router.py b/src/govoplan_addresses/backend/router.py index bbcb37a..3193b0a 100644 --- a/src/govoplan_addresses/backend/router.py +++ b/src/govoplan_addresses/backend/router.py @@ -41,6 +41,8 @@ from govoplan_addresses.backend.schemas import ( AddressCardDavDiscoveryRequest, AddressCardDavDiscoveryResponse, AddressCardDavSourceCreateRequest, + AddressCredentialEnvelopeListResponse, + AddressCredentialEnvelopeResponse, AddressSyncAttemptFinishRequest, AddressSyncConflictCreateRequest, AddressSyncConflictListResponse, @@ -69,6 +71,7 @@ from govoplan_addresses.backend.schemas import ( ) from govoplan_addresses.backend.service import ( AddressBookError, + available_address_credentials, address_book_contact_counts, address_list_entry_counts, create_address_book, @@ -639,6 +642,25 @@ def api_discover_carddav_address_books( raise _error(AddressBookError(str(exc))) from exc +@router.get("/credentials", response_model=AddressCredentialEnvelopeListResponse) +def api_list_address_credentials( + source_id: str | None = Query(default=None), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "addresses:sync:write") + return AddressCredentialEnvelopeListResponse( + credentials=[ + AddressCredentialEnvelopeResponse.model_validate(item) + for item in available_address_credentials( + session, + principal, + source_id=source_id, + ) + ] + ) + + @router.post("/address-books/{book_id}/carddav/sources", response_model=AddressSyncSourceResponse, status_code=status.HTTP_201_CREATED) def api_create_carddav_sync_source( book_id: str, diff --git a/src/govoplan_addresses/backend/schemas.py b/src/govoplan_addresses/backend/schemas.py index 0772080..7f70b36 100644 --- a/src/govoplan_addresses/backend/schemas.py +++ b/src/govoplan_addresses/backend/schemas.py @@ -439,6 +439,26 @@ class AddressCardDavDiscoveryResponse(BaseModel): address_books: list[AddressCardDavAddressBookResponse] +class AddressCredentialEnvelopeResponse(BaseModel): + id: str + scope_type: str + scope_id: str | None = None + name: str + description: str | None = None + credential_kind: str + public_data: dict[str, Any] = Field(default_factory=dict) + secret_keys: list[str] = Field(default_factory=list) + secret_configured: bool = False + allowed_modules: list[str] = Field(default_factory=list) + inherit_to_lower_scopes: bool = False + is_active: bool = True + revision: str + + +class AddressCredentialEnvelopeListResponse(BaseModel): + credentials: list[AddressCredentialEnvelopeResponse] = Field(default_factory=list) + + class AddressCardDavSourceCreateRequest(BaseModel): collection_url: str = Field(min_length=1, max_length=2000) display_name: str | None = Field(default=None, max_length=255) diff --git a/src/govoplan_addresses/backend/service.py b/src/govoplan_addresses/backend/service.py index 4d3eff4..68a103e 100644 --- a/src/govoplan_addresses/backend/service.py +++ b/src/govoplan_addresses/backend/service.py @@ -15,6 +15,14 @@ from govoplan_core.auth import ApiPrincipal from govoplan_core.audit.logging import audit_event from govoplan_core.core.change_sequence import record_change from govoplan_core.db.base import utcnow +from govoplan_core.security.credential_envelopes import ( + CredentialAccessContext, + CredentialEnvelopeError, + ResolvedCredentialEnvelope, + credential_envelope_summary, + list_credential_envelopes, + resolve_credential_envelope, +) from govoplan_core.security.secrets import decrypt_secret, encrypt_secret from govoplan_addresses.backend.carddav import ( AddressCardDAVAddressBook, @@ -68,6 +76,39 @@ class AddressBookError(ValueError): pass +CORE_CREDENTIAL_ENVELOPE_PREFIX = "credential-envelope:" + + +def address_credential_context( + *, + tenant_id: str, + source_id: str | None = None, +) -> CredentialAccessContext: + return CredentialAccessContext( + tenant_id=tenant_id, + target_scope_type="tenant", + target_scope_id=tenant_id, + module_id="addresses", + server_ref=f"addresses:{source_id}" if source_id else None, + ) + + +def available_address_credentials( + session: Session, + principal: ApiPrincipal, + *, + source_id: str | None = None, +) -> list[dict[str, Any]]: + context = address_credential_context( + tenant_id=principal.tenant_id, + source_id=source_id, + ) + return [ + credential_envelope_summary(row) + for row in list_credential_envelopes(session, context=context) + ] + + @dataclass(frozen=True, slots=True) class VCardImportResult: contacts: list[Contact] @@ -693,6 +734,8 @@ def create_carddav_sync_source( payload: AddressCardDavSourceCreateRequest, ) -> AddressSyncSource: _assert_no_caller_carddav_credential_ref(payload.credential_ref) + if payload.credential_ref and (payload.password is not None or payload.bearer_token is not None): + raise AddressBookError("Select a reusable credential or enter a new secret, not both.") collection_url = ensure_collection_url(payload.collection_url) display_name = _trim(payload.display_name) or "CardDAV address book" metadata = _carddav_metadata( @@ -700,10 +743,10 @@ def create_carddav_sync_source( username=payload.username, password=_secret_value(payload.password), bearer_token=_secret_value(payload.bearer_token), - credential_ref=None, + credential_ref=payload.credential_ref, collection_url=collection_url, ) - return create_sync_source( + source = create_sync_source( session, principal, address_book_id, @@ -720,6 +763,19 @@ def create_carddav_sync_source( ), trusted_connector_metadata=True, ) + reusable = _resolve_core_address_credential( + session, + tenant_id=principal.tenant_id, + source_id=source.id, + credential_ref=payload.credential_ref, + ) + if reusable is not None: + source_metadata = dict(source.metadata_ or {}) + auth = dict(source_metadata.get("carddav") or {}) + auth["username"] = _credential_username(reusable) or auth.get("username") + source_metadata["carddav"] = auth + source.metadata_ = source_metadata + return source def preview_sync_source( @@ -1131,8 +1187,6 @@ def _plan_matching_carddav_revision( etag=item.etag, ), ) - - def _plan_carddav_remote_update( *, item: AddressCardDAVObject, @@ -1618,7 +1672,23 @@ def _carddav_client_from_payload( password = _secret_value(payload.password) bearer_token = _secret_value(payload.bearer_token) credential_ref = payload.credential_ref if payload.credential_ref is not None else auth.get("credential_ref") - secret = _resolve_carddav_secret(auth_type=auth_type, password=password, bearer_token=bearer_token, credential_ref=credential_ref, encrypted=auth.get("secret_encrypted")) + reusable = _resolve_core_address_credential( + session, + tenant_id=principal.tenant_id, + source_id=source.id if source else None, + credential_ref=credential_ref, + ) + username = username or _credential_username(reusable) + secret = _resolve_carddav_secret( + session=session, + tenant_id=principal.tenant_id, + source_id=source.id if source else None, + auth_type=auth_type, + password=password, + bearer_token=bearer_token, + credential_ref=credential_ref, + encrypted=auth.get("secret_encrypted"), + ) return _new_carddav_client(url, auth_type=auth_type, username=username, secret=secret) @@ -1629,12 +1699,21 @@ def _carddav_client_for_source( password: str | None = None, bearer_token: str | None = None, ) -> AddressCardDAVClient: - del session metadata = dict(sync_source.metadata_ or {}) auth = dict(metadata.get("carddav") or {}) auth_type = str(auth.get("auth_type") or "none") username = auth.get("username") + reusable = _resolve_core_address_credential( + session, + tenant_id=sync_source.tenant_id or "", + source_id=sync_source.id, + credential_ref=auth.get("credential_ref"), + ) + username = username or _credential_username(reusable) secret = _resolve_carddav_secret( + session=session, + tenant_id=sync_source.tenant_id or "", + source_id=sync_source.id, auth_type=auth_type, password=password, bearer_token=bearer_token, @@ -1683,6 +1762,9 @@ def _carddav_metadata( def _resolve_carddav_secret( *, + session: Session, + tenant_id: str, + source_id: str | None, auth_type: str, password: str | None, bearer_token: str | None, @@ -1694,14 +1776,74 @@ def _resolve_carddav_secret( if auth_type == "bearer" and bearer_token: return bearer_token if credential_ref: - raise AddressBookError( - "The CardDAV credential reference is not a server-owned credential; provide a replacement password or token" + reusable = _resolve_core_address_credential( + session, + tenant_id=tenant_id, + source_id=source_id, + credential_ref=credential_ref, ) + if reusable is None: + raise AddressBookError( + "The CardDAV credential reference is not a server-owned credential or visible reusable credential envelope." + ) + return _credential_secret(reusable, auth_type=auth_type) if encrypted: return decrypt_secret(encrypted) return None +def _core_credential_id(credential_ref: str | None) -> str | None: + if not credential_ref or not credential_ref.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX): + return None + value = credential_ref.removeprefix(CORE_CREDENTIAL_ENVELOPE_PREFIX).strip() + return value or None + + +def _resolve_core_address_credential( + session: Session, + *, + tenant_id: str, + source_id: str | None, + credential_ref: str | None, +) -> ResolvedCredentialEnvelope | None: + credential_id = _core_credential_id(credential_ref) + if credential_id is None: + return None + try: + return resolve_credential_envelope( + session, + credential_id=credential_id, + context=address_credential_context( + tenant_id=tenant_id, + source_id=source_id, + ), + ) + except CredentialEnvelopeError as exc: + raise AddressBookError( + "The selected reusable credential is unavailable to this CardDAV source." + ) from exc + + +def _credential_username(credential: ResolvedCredentialEnvelope | None) -> str | None: + if credential is None: + return None + value = credential.public_data.get("username") + return _trim(value) + + +def _credential_secret(credential: ResolvedCredentialEnvelope, *, auth_type: str) -> str | None: + keys = ( + ("password", "secret", "token") + if auth_type == "basic" + else ("access_token", "bearer_token", "token", "password", "secret") + ) + for key in keys: + value = credential.secret_data.get(key) + if value is not None and str(value): + return str(value) + return None + + def resolve_trusted_deployment_carddav_credential_ref(credential_ref: str) -> str | None: """Resolve env-backed credentials only for trusted deployment code. @@ -1724,6 +1866,7 @@ def public_address_sync_metadata(metadata: object) -> dict[str, Any]: if not isinstance(auth, dict): return payload had_credential = bool(auth.get("secret_encrypted") or auth.get("credential_ref")) + auth["credential_envelope_id"] = _core_credential_id(auth.get("credential_ref")) auth.pop("secret_encrypted", None) auth.pop("credential_ref", None) auth["has_credential"] = had_credential @@ -1731,9 +1874,10 @@ def public_address_sync_metadata(metadata: object) -> dict[str, Any]: def _assert_no_caller_carddav_credential_ref(credential_ref: str | None) -> None: - if _trim(credential_ref): + value = _trim(credential_ref) + if value and not value.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX): raise AddressBookError( - "Caller-supplied credential references are not accepted; provide a password or bearer token" + "Caller-supplied credential references are accepted only for reusable credential envelopes." ) diff --git a/tests/test_addresses_service.py b/tests/test_addresses_service.py index 27fdb15..11909a4 100644 --- a/tests/test_addresses_service.py +++ b/tests/test_addresses_service.py @@ -10,6 +10,10 @@ from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.people import CAPABILITY_ADDRESSES_PEOPLE_SEARCH, PeopleSearchProvider from govoplan_core.db.base import Base from govoplan_core.db.base import utcnow +from govoplan_core.security.credential_envelopes import ( + CredentialEnvelope, + create_credential_envelope, +) from govoplan_addresses.backend.carddav import AddressCardDAVObject, AddressCardDAVReportResult, AddressCardDAVWriteResult from govoplan_addresses.backend.capabilities import ( CAPABILITY_ADDRESSES_CONTACT_WRITER, @@ -88,6 +92,7 @@ from govoplan_addresses.backend.service import ( finish_sync_attempt, update_sync_source, resolve_trusted_deployment_carddav_credential_ref, + _carddav_client_for_source, ) @@ -181,6 +186,7 @@ class AddressServiceTest(unittest.TestCase): AddressSyncConflict.__table__, AddressSyncDiagnostic.__table__, ChangeSequenceEntry.__table__, + CredentialEnvelope.__table__, ], ) self.session = sessionmaker(bind=self.engine)() @@ -845,6 +851,44 @@ END:VCARD ), ) + def test_carddav_source_can_use_reusable_core_credential(self) -> None: + book = create_address_book( + self.session, + self.principal, + AddressBookCreateRequest(scope_type="user", name="Shared credential"), + ) + credential = create_credential_envelope( + self.session, + tenant_id="tenant-1", + scope_type="tenant", + scope_id="tenant-1", + name="Shared DAV login", + credential_kind="username_password", + public_data={"username": "ada"}, + secret_data={"password": "secret"}, + allowed_modules=["addresses"], + inherit_to_lower_scopes=True, + ) + source = create_carddav_sync_source( + self.session, + self.principal, + book.id, + AddressCardDavSourceCreateRequest( + collection_url="https://dav.example.test/addressbooks/personal/", + auth_type="basic", + credential_ref=f"credential-envelope:{credential.id}", + ), + ) + self.session.commit() + + client = _carddav_client_for_source(self.session, source) + response_auth = _sync_source_response(source).metadata["carddav"] + + self.assertEqual(client.username, "ada") + self.assertEqual(client.password, "secret") + self.assertEqual(response_auth["credential_envelope_id"], credential.id) + self.assertTrue(response_auth["has_credential"]) + source = create_carddav_sync_source( self.session, self.principal, diff --git a/webui/src/api/addresses.ts b/webui/src/api/addresses.ts index 090259e..47144d5 100644 --- a/webui/src/api/addresses.ts +++ b/webui/src/api/addresses.ts @@ -216,6 +216,22 @@ export type AddressCardDavAddressBook = { sync_token?: string | null; }; +export type AddressCredentialEnvelope = { + id: string; + scope_type: string; + scope_id?: string | null; + name: string; + description?: string | null; + credential_kind: string; + public_data: Record; + secret_keys: string[]; + secret_configured: boolean; + allowed_modules: string[]; + inherit_to_lower_scopes: boolean; + is_active: boolean; + revision: string; +}; + export type AddressSyncPlanStats = { created: number; updated: number; @@ -305,6 +321,10 @@ type AddressCardDavDiscoveryResponse = { address_books: AddressCardDavAddressBook[]; }; +type AddressCredentialEnvelopeListResponse = { + credentials: AddressCredentialEnvelope[]; +}; + type AddressSyncDiagnosticListResponse = { diagnostics: AddressSyncDiagnostic[]; }; @@ -450,7 +470,14 @@ export async function listAddressSyncSources( export function discoverCardDavAddressBooks( settings: ApiSettings, - payload: { url: string; auth_type: "none" | "basic" | "bearer"; username?: string | null; password?: string | null; bearer_token?: string | null } + payload: { + url: string; + auth_type: "none" | "basic" | "bearer"; + username?: string | null; + password?: string | null; + bearer_token?: string | null; + credential_ref?: string | null; + } ): Promise { return apiFetch(settings, "/api/v1/addresses/carddav/discover", { method: "POST", @@ -468,6 +495,7 @@ export function createCardDavSyncSource( username?: string | null; password?: string | null; bearer_token?: string | null; + credential_ref?: string | null; sync_direction: "read_only" | "import" | "export" | "two_way"; read_only?: boolean | null; sync_token?: string | null; @@ -481,6 +509,17 @@ export function createCardDavSyncSource( }); } +export async function listAddressCredentials( + settings: ApiSettings, + sourceId?: string | null +): Promise { + const response = await apiFetch( + settings, + `/api/v1/addresses/credentials${queryString({ source_id: sourceId })}` + ); + return response.credentials; +} + export function updateAddressSyncSource( settings: ApiSettings, syncSourceId: string, diff --git a/webui/src/features/addressbook/AddressBookPage.tsx b/webui/src/features/addressbook/AddressBookPage.tsx index d02a294..350788f 100644 --- a/webui/src/features/addressbook/AddressBookPage.tsx +++ b/webui/src/features/addressbook/AddressBookPage.tsx @@ -39,6 +39,7 @@ import { exportContactVcard, importAddressBookVcards, listAddressBooks, + listAddressCredentials, listAddressListEntries, listAddressLists, listAddressSyncConflicts, @@ -59,6 +60,7 @@ import { type AddressCardDavAddressBook, type AddressBook, type AddressBookScope, + type AddressCredentialEnvelope, type AddressList, type AddressListEntry, type AddressSyncConflict, @@ -144,6 +146,7 @@ type CardDavFormState = { collection_url: string; display_name: string; auth_type: "none" | "basic" | "bearer"; + credential_envelope_id: string; username: string; password: string; bearer_token: string; @@ -200,6 +203,7 @@ const EMPTY_CARDDAV_FORM: CardDavFormState = { collection_url: "", display_name: "", auth_type: "basic", + credential_envelope_id: "", username: "", password: "", bearer_token: "", @@ -643,6 +647,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) const [cardDavOpen, setCardDavOpen] = useState(false); const [cardDavForm, setCardDavForm] = useState(EMPTY_CARDDAV_FORM); const [cardDavDiscovery, setCardDavDiscovery] = useState([]); + const [cardDavCredentials, setCardDavCredentials] = useState([]); + const [cardDavCredentialsError, setCardDavCredentialsError] = useState(""); const [syncInspector, setSyncInspector] = useState(null); const [syncPlan, setSyncPlan] = useState(null); const [syncDiagnostics, setSyncDiagnostics] = useState([]); @@ -662,6 +668,37 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) const canReadSync = hasScope(auth, "addresses:sync:read"); const canWriteSync = hasScope(auth, "addresses:sync:write"); + useEffect(() => { + if (!cardDavOpen || !canWriteSync) { + setCardDavCredentials([]); + setCardDavCredentialsError(""); + return; + } + let active = true; + listAddressCredentials(settings) + .then((credentials) => { + if (active) { + setCardDavCredentials(credentials); + setCardDavCredentialsError(""); + } + }) + .catch((err) => { + if (active) { + setCardDavCredentials([]); + setCardDavCredentialsError(errorMessage(err)); + } + }); + return () => { + active = false; + }; + }, [ + canWriteSync, + cardDavOpen, + settings.accessToken, + settings.apiBaseUrl, + settings.apiKey + ]); + useEffect(() => { if (auth.groups_loaded || !onAuthChange) return; let active = true; @@ -1513,16 +1550,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) function openCardDavDialog() { setCardDavForm(EMPTY_CARDDAV_FORM); setCardDavDiscovery([]); + setCardDavCredentialsError(""); setCardDavOpen(true); } function cardDavPayload() { + const credentialRef = cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id + ? `credential-envelope:${cardDavForm.credential_envelope_id}` + : null; return { url: cardDavForm.collection_url, auth_type: cardDavForm.auth_type, username: cardDavForm.username || null, - password: cardDavForm.password || null, - bearer_token: cardDavForm.bearer_token || null + password: credentialRef ? null : cardDavForm.password || null, + bearer_token: credentialRef ? null : cardDavForm.bearer_token || null, + credential_ref: credentialRef }; } @@ -1561,8 +1603,11 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) display_name: cardDavForm.display_name || selectedBook.name, auth_type: cardDavForm.auth_type, username: cardDavForm.username || null, - password: cardDavForm.password || null, - bearer_token: cardDavForm.bearer_token || null, + password: cardDavForm.credential_envelope_id ? null : cardDavForm.password || null, + bearer_token: cardDavForm.credential_envelope_id ? null : cardDavForm.bearer_token || null, + credential_ref: cardDavForm.auth_type !== "none" && cardDavForm.credential_envelope_id + ? `credential-envelope:${cardDavForm.credential_envelope_id}` + : null, sync_direction: cardDavForm.sync_direction, read_only: cardDavForm.sync_direction === "read_only" || cardDavForm.sync_direction === "import" }); @@ -2261,17 +2306,45 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props) setCardDavForm((current) => ({ ...current, display_name: event.target.value }))} placeholder={selectedBook?.name ?? "CardDAV"} /> + {cardDavForm.auth_type !== "none" && + + + + } + {cardDavCredentialsError && {cardDavCredentialsError}} {cardDavForm.auth_type === "basic" &&
- setCardDavForm((current) => ({ ...current, username: event.target.value }))} /> - - - setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" /> + setCardDavForm((current) => ({ ...current, username: event.target.value }))} disabled={Boolean(cardDavForm.credential_envelope_id)} /> + {!cardDavForm.credential_envelope_id && + + setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" /> + + }
} - {cardDavForm.auth_type === "bearer" && + {cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id && setCardDavForm((current) => ({ ...current, bearer_token: value }))} autoComplete="new-password" />