diff --git a/src/govoplan_files/backend/router.py b/src/govoplan_files/backend/router.py index ff02ead..e27dacf 100644 --- a/src/govoplan_files/backend/router.py +++ b/src/govoplan_files/backend/router.py @@ -109,6 +109,8 @@ from govoplan_files.backend.storage.connector_credential_store import ( create_connector_credential_row, get_connector_credential_row, list_database_connector_credentials, + resolve_reusable_connector_credential, + reusable_credential_id, update_connector_credential_row, ) from govoplan_files.backend.storage.connector_credential_deletion import ( @@ -742,11 +744,25 @@ def _credential_row_for_profile( *, credential_profile_id: str | None, provider: str, + profile_id: str | None = None, + scope_type: str = "tenant", + scope_id: str | None = None, include_disabled: bool = False, ): credential_id = (credential_profile_id or "").strip() if not credential_id: return None + if reusable_credential_id(credential_id): + if not profile_id: + raise FileStorageError("File connection id is required for reusable credentials") + return resolve_reusable_connector_credential( + session, + tenant_id=principal.tenant_id, + credential_ref=credential_id, + profile_id=profile_id, + scope_type=scope_type, + scope_id=scope_id, + ) row = get_connector_credential_row( session, tenant_id=principal.tenant_id, @@ -2904,6 +2920,9 @@ def create_connector_profile( principal, credential_profile_id=payload.credential_profile_id, provider=payload.provider, + profile_id=payload.id, + scope_type=payload.scope_type, + scope_id=payload.scope_id, ) _ensure_connector_configuration_allowed( session, @@ -3156,6 +3175,9 @@ def update_connector_profile( principal, credential_profile_id=credential_profile_id, provider=provider, + profile_id=row.id, + scope_type=row.scope_type, + scope_id=row.scope_id, include_disabled=True, ) _ensure_connector_configuration_allowed( diff --git a/src/govoplan_files/backend/storage/connector_credential_store.py b/src/govoplan_files/backend/storage/connector_credential_store.py index 76c2544..546b2fe 100644 --- a/src/govoplan_files/backend/storage/connector_credential_store.py +++ b/src/govoplan_files/backend/storage/connector_credential_store.py @@ -5,8 +5,17 @@ from dataclasses import dataclass from typing import Any from sqlalchemy.orm import Session +from sqlalchemy import inspect from govoplan_core.core.policy import normalize_policy_scope_type, policy_source_path +from govoplan_core.security.credential_envelopes import ( + CredentialAccessContext, + CredentialEnvelope, + CredentialEnvelopeError, + ResolvedCredentialEnvelope, + list_credential_envelopes, + resolve_credential_envelope, +) from govoplan_core.security.secrets import decrypt_secret, encrypt_secret from govoplan_files.backend.db.models import FileConnectorCredential from govoplan_files.backend.storage.common import FileStorageError @@ -14,6 +23,8 @@ from govoplan_files.backend.storage.connector_deployment import reject_api_contr from govoplan_files.backend.storage.connector_policy import ConnectorPolicySource, connector_policy_sources_from_payload from govoplan_files.backend.storage.connector_profiles import supported_connector_providers +CORE_CREDENTIAL_ENVELOPE_PREFIX = "credential-envelope:" + @dataclass(frozen=True, slots=True) class ConnectorCredential: @@ -33,6 +44,7 @@ class ConnectorCredential: policy_sources: tuple[ConnectorPolicySource, ...] = () metadata: Mapping[str, Any] | None = None source_kind: str = "database" + has_secret: bool = False @property def source_path(self) -> str: @@ -58,7 +70,7 @@ class ConnectorCredential: return True # Environment references are deliberately unavailable to API-managed # credential rows. Legacy rows remain visible but fail closed. - return bool(self.secret_ref or self.password_value or self.token_value) + return bool(self.has_secret or self.secret_ref or self.password_value or self.token_value) def to_response(self) -> dict[str, Any]: return { @@ -85,7 +97,159 @@ def list_database_connector_credentials( tenant_id: str, include_disabled: bool = False, ) -> list[ConnectorCredential]: - return [connector_credential_from_row(row) for row in list_connector_credential_rows(session, tenant_id=tenant_id, include_disabled=include_disabled)] + local = [ + connector_credential_from_row(row) + for row in list_connector_credential_rows( + session, + tenant_id=tenant_id, + include_disabled=include_disabled, + ) + ] + return [ + *local, + *list_reusable_connector_credentials( + session, + tenant_id=tenant_id, + include_disabled=include_disabled, + ), + ] + + +def reusable_credential_reference(credential_id: str) -> str: + return f"{CORE_CREDENTIAL_ENVELOPE_PREFIX}{credential_id}" + + +def reusable_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 file_credential_context( + *, + tenant_id: str, + profile_id: str | None = None, + scope_type: str = "tenant", + scope_id: str | None = None, + administrative: bool = False, +) -> CredentialAccessContext: + return CredentialAccessContext( + tenant_id=tenant_id, + user_id=scope_id if scope_type == "user" else None, + group_ids=frozenset({scope_id}) if scope_type == "group" and scope_id else frozenset(), + target_scope_type=scope_type, + target_scope_id=scope_id or tenant_id, + module_id="files", + server_ref=f"files:{profile_id}" if profile_id else None, + administrative=administrative, + ) + + +def list_reusable_connector_credentials( + session: Session, + *, + tenant_id: str, + include_disabled: bool = False, +) -> list[ConnectorCredential]: + if not inspect(session.get_bind()).has_table(CredentialEnvelope.__tablename__): + return [] + context = file_credential_context(tenant_id=tenant_id, administrative=True) + return [ + connector_credential_from_envelope(row) + for row in list_credential_envelopes( + session, + context=context, + include_inactive=include_disabled, + ) + ] + + +def resolve_reusable_connector_credential( + session: Session, + *, + tenant_id: str, + credential_ref: str, + profile_id: str, + scope_type: str, + scope_id: str | None, +) -> ConnectorCredential: + credential_id = reusable_credential_id(credential_ref) + if credential_id is None: + raise FileStorageError("Reusable credential reference is invalid") + try: + resolved = resolve_credential_envelope( + session, + credential_id=credential_id, + context=file_credential_context( + tenant_id=tenant_id, + profile_id=profile_id, + scope_type=scope_type, + scope_id=scope_id, + ), + ) + except CredentialEnvelopeError as exc: + raise FileStorageError("Reusable credential is unavailable to this file connection") from exc + return connector_credential_from_resolved_envelope( + resolved, + scope_type=scope_type, + scope_id=scope_id, + ) + + +def connector_credential_from_envelope(row: CredentialEnvelope) -> ConnectorCredential: + return ConnectorCredential( + id=reusable_credential_reference(row.id), + label=row.name, + scope_type=row.scope_type, + scope_id=row.scope_id, + enabled=row.is_active, + credential_mode=_envelope_credential_mode(row.credential_kind, row.secret_keys), + username=_clean_public_value(row.public_data, "username"), + source_kind="credential_envelope", + has_secret=bool(row.secret_data_encrypted), + metadata={ + "credential_envelope_id": row.id, + "credential_kind": row.credential_kind, + "allowed_modules": list(row.allowed_modules or []), + "allowed_server_refs": list(row.allowed_server_refs or []), + "inherit_to_lower_scopes": bool(row.inherit_to_lower_scopes), + "revision": row.revision, + }, + ) + + +def connector_credential_from_resolved_envelope( + row: ResolvedCredentialEnvelope, + *, + scope_type: str, + scope_id: str | None, +) -> ConnectorCredential: + return ConnectorCredential( + id=reusable_credential_reference(row.id), + label=row.name, + scope_type=scope_type, + scope_id=scope_id, + enabled=True, + credential_mode=_envelope_credential_mode(row.credential_kind, row.secret_data), + username=_clean_public_value(row.public_data, "username"), + password_value=_first_secret(row.secret_data, "password", "secret"), + token_value=_first_secret( + row.secret_data, + "access_token", + "bearer_token", + "token", + "api_key", + ), + source_kind="credential_envelope", + has_secret=bool(row.secret_data), + metadata={ + "credential_envelope_id": row.id, + "credential_kind": row.credential_kind, + "inherit_to_lower_scopes": True, + "revision": row.revision, + }, + ) def list_connector_credential_rows( @@ -348,6 +512,27 @@ def _policy_source_response(source: ConnectorPolicySource) -> dict[str, Any]: } +def _envelope_credential_mode(kind: str, secret_values: Mapping[str, Any] | list[str]) -> str: + keys = {str(key) for key in secret_values} + if kind in {"token", "oauth2", "api_key"} or keys.intersection( + {"access_token", "bearer_token", "token", "api_key"} + ): + return "token" + return "basic" + + +def _clean_public_value(values: Mapping[str, Any] | None, key: str) -> str | None: + return _clean((values or {}).get(key)) + + +def _first_secret(values: Mapping[str, Any], *keys: str) -> str | None: + for key in keys: + value = _clean(values.get(key)) + if value is not None: + return value + return None + + def _clean(value: object) -> str | None: if value is None: return None diff --git a/src/govoplan_files/backend/storage/connector_profile_store.py b/src/govoplan_files/backend/storage/connector_profile_store.py index ca2d730..f29cb6b 100644 --- a/src/govoplan_files/backend/storage/connector_profile_store.py +++ b/src/govoplan_files/backend/storage/connector_profile_store.py @@ -10,7 +10,13 @@ from govoplan_core.security.secrets import decrypt_secret, encrypt_secret from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile from govoplan_files.backend.storage.common import FileStorageError from govoplan_files.backend.storage.connector_deployment import reject_api_controlled_deployment_references -from govoplan_files.backend.storage.connector_credential_store import connector_credential_from_row, credential_rows_by_id +from govoplan_files.backend.storage.connector_credential_store import ( + ConnectorCredential, + connector_credential_from_row, + credential_rows_by_id, + resolve_reusable_connector_credential, + reusable_credential_id, +) from govoplan_files.backend.storage.connector_policy import connector_policy_sources_from_payload from govoplan_files.backend.storage.connector_profiles import ConnectorProfile, supported_connector_providers @@ -50,10 +56,28 @@ def select_database_connector_profiles( profile_ids = {row.id for row in rows} if row_visible is not None: rows = [row for row in rows if row_visible(row)] - credential_ids = {_clean(row.credential_profile_id) for row in rows if _clean(row.credential_profile_id)} + credential_ids = { + _clean(row.credential_profile_id) + for row in rows + if _clean(row.credential_profile_id) and not reusable_credential_id(row.credential_profile_id) + } credentials = credential_rows_by_id(session, tenant_id=tenant_id, credential_ids={item for item in credential_ids if item}, include_disabled=include_disabled) return ( - [connector_profile_from_row(row, credential_row=credentials.get(row.credential_profile_id or "")) for row in rows], + [ + connector_profile_from_row( + row, + credential_row=( + _resolve_profile_reusable_credential( + session, + tenant_id=tenant_id, + row=row, + ) + if reusable_credential_id(row.credential_profile_id) + else credentials.get(row.credential_profile_id or "") + ), + ) + for row in rows + ], profile_ids, ) @@ -73,7 +97,10 @@ def list_connector_profile_rows( return query.order_by(FileConnectorProfile.scope_type.asc(), FileConnectorProfile.label.asc()).all() -def connector_profile_from_row(row: FileConnectorProfile, credential_row: FileConnectorCredential | None = None) -> ConnectorProfile: +def connector_profile_from_row( + row: FileConnectorProfile, + credential_row: FileConnectorCredential | ConnectorCredential | None = None, +) -> ConnectorProfile: policy_sources = [] if row.policy: policy_sources = connector_policy_sources_from_payload({ @@ -82,9 +109,18 @@ def connector_profile_from_row(row: FileConnectorProfile, credential_row: FileCo "label": row.label, "policy": row.policy, }) - credential = connector_credential_from_row(credential_row) if credential_row is not None else None + credential = ( + credential_row + if isinstance(credential_row, ConnectorCredential) + else connector_credential_from_row(credential_row) + if credential_row is not None + else None + ) if credential: policy_sources.extend(credential.policy_sources) + metadata = dict(row.metadata_ or {}) + if reusable_credential_id(row.credential_profile_id) and credential is None: + metadata["credential_unavailable"] = True return ConnectorProfile( id=row.id, label=row.label, @@ -105,11 +141,30 @@ def connector_profile_from_row(row: FileConnectorProfile, credential_row: FileCo token_value=credential.token_value if credential else decrypt_secret(row.token_encrypted), capabilities=tuple(_string_list(row.capabilities)), policy_sources=tuple(policy_sources), - metadata=dict(row.metadata_ or {}), + metadata=metadata, source_kind="database", ) +def _resolve_profile_reusable_credential( + session: Session, + *, + tenant_id: str, + row: FileConnectorProfile, +) -> ConnectorCredential | None: + try: + return resolve_reusable_connector_credential( + session, + tenant_id=tenant_id, + credential_ref=row.credential_profile_id or "", + profile_id=row.id, + scope_type=row.scope_type, + scope_id=row.scope_id, + ) + except FileStorageError: + return None + + def get_connector_profile_row( session: Session, *, diff --git a/tests/test_connector_profile_store.py b/tests/test_connector_profile_store.py index f6c76b2..fca0415 100644 --- a/tests/test_connector_profile_store.py +++ b/tests/test_connector_profile_store.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import unittest from unittest.mock import patch @@ -8,6 +9,7 @@ from sqlalchemy.orm import sessionmaker from govoplan_access.backend.db import models as access_models # noqa: F401 - resolve Files user foreign keys from govoplan_core.db.base import Base +from govoplan_core.security.credential_envelopes import CredentialEnvelope from govoplan_core.security.secrets import decrypt_secret, encrypt_secret from govoplan_files.backend.db.models import FileConnectorProfile from govoplan_files.backend.storage.common import FileStorageError @@ -20,7 +22,13 @@ from govoplan_files.backend.storage.connector_profile_store import ( class ConnectorProfileStoreUpdateTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite:///:memory:") - Base.metadata.create_all(self.engine, tables=[FileConnectorProfile.__table__]) + Base.metadata.create_all( + self.engine, + tables=[ + FileConnectorProfile.__table__, + CredentialEnvelope.__table__, + ], + ) self.session = sessionmaker(bind=self.engine)() self.row = FileConnectorProfile( id="profile-1", @@ -171,6 +179,50 @@ class ConnectorProfileStoreUpdateTests(unittest.TestCase): self.assertEqual([self.row.id], [profile.id for profile in profiles]) self.assertEqual("original-password", profiles[0].password_value) + def test_reusable_credential_resolves_and_revocation_keeps_profile_visible(self) -> None: + credential = CredentialEnvelope( + id="shared-credential", + tenant_id="tenant-1", + scope_type="tenant", + scope_id="tenant-1", + name="Shared WebDAV login", + credential_kind="username_password", + public_data={"username": "ada"}, + secret_data_encrypted=encrypt_secret(json.dumps({"password": "secret"})), + secret_keys=["password"], + allowed_modules=["files"], + allowed_server_refs=[], + inherit_to_lower_scopes=True, + is_active=True, + revision="revision-1", + ) + self.row.credential_profile_id = "credential-envelope:shared-credential" + self.row.username = None + self.row.password_encrypted = None + self.row.token_encrypted = None + self.session.add(credential) + self.session.commit() + + profile = list_database_connector_profiles( + self.session, + tenant_id="tenant-1", + )[0] + + self.assertEqual("ada", profile.username) + self.assertEqual("secret", profile.password_value) + self.assertTrue(profile.credentials_configured) + + credential.is_active = False + self.session.commit() + profile = list_database_connector_profiles( + self.session, + tenant_id="tenant-1", + )[0] + + self.assertIsNone(profile.password_value) + self.assertFalse(profile.credentials_configured) + self.assertTrue(profile.metadata["credential_unavailable"]) + if __name__ == "__main__": unittest.main() diff --git a/webui/src/features/files/FileConnectorSettingsPanel.tsx b/webui/src/features/files/FileConnectorSettingsPanel.tsx index 835a273..833611a 100644 --- a/webui/src/features/files/FileConnectorSettingsPanel.tsx +++ b/webui/src/features/files/FileConnectorSettingsPanel.tsx @@ -225,6 +225,14 @@ export default function FileConnectorSettingsPanel({ () => credentials.filter((credential) => connectorBelongsToEditableScope(credential, scopeType, activeScopeId)), [activeScopeId, credentials, scopeType] ); + const selectableCredentials = useMemo( + () => credentials.filter((credential) => + connectorBelongsToEditableScope(credential, scopeType, activeScopeId) || + credential.source_kind === "credential_envelope" && + reusableCredentialAvailableAtScope(credential, scopeType, activeScopeId) + ), + [activeScopeId, credentials, scopeType] + ); useEffect(() => { resetDeltaWatermark(deltaKey); @@ -424,7 +432,7 @@ export default function FileConnectorSettingsPanel({ endpoint_url: cleanOrNull(draft.endpointUrl), base_path: cleanOrNull(draft.basePath), enabled: draft.enabled, - credential_profile_id: cleanOrNull(credentialProfileIdForDraft(draft, scopedCredentials)), + credential_profile_id: cleanOrNull(credentialProfileIdForDraft(draft, selectableCredentials)), credential_mode: draft.credentialMode, capabilities, policy: policyFromDraft(draft), @@ -694,7 +702,7 @@ export default function FileConnectorSettingsPanel({ const panelTitle = title ?? i18nMessage("i18n:govoplan-files.value_file_connections", { value0: scopeLabel(scopeType) }); const selectedCredentialIds = new Set(scopedProfiles.map((profile) => profile.credential_profile_id).filter((value): value is string => Boolean(value))); const firstCompatibleProfileByCredential = new Map(); - for (const credential of scopedCredentials) { + for (const credential of selectableCredentials) { const selectedProfile = scopedProfiles.find((profile) => profile.credential_profile_id === credential.id); const fallbackProfile = selectedProfile ?? scopedProfiles.find((profile) => !selectedCredentialIds.has(credential.id) && credentialMatchesProfile(credential, profile)); if (fallbackProfile) firstCompatibleProfileByCredential.set(credential.id, fallbackProfile.id); @@ -717,8 +725,8 @@ export default function FileConnectorSettingsPanel({ const credentialTestUnsupported = Boolean(credentialTestProfile && credentialTestProfile.provider !== "webdav" && credentialTestProfile.provider !== "nextcloud"); const credentialTestDisabled = !credentialDraft || saving || testingCredentialLogin || !credentialTestProfile || credentialTestUnsupported; const credentialTestTooltip = credentialTestHelpText(credentialTestProfile, credentialTestUnsupported); - const profileCredentialOptions = draft ? credentialOptionsForProfileDraft(scopedCredentials, draft) : []; - const profileCredentialSelectValue = draft ? credentialProfileIdForDraft(draft, scopedCredentials) : ""; + const profileCredentialOptions = draft ? credentialOptionsForProfileDraft(selectableCredentials, draft) : []; + const profileCredentialSelectValue = draft ? credentialProfileIdForDraft(draft, selectableCredentials) : ""; const connectorColumns: ConnectionTreeColumn[] = [ { id: "connection", @@ -755,7 +763,7 @@ export default function FileConnectorSettingsPanel({ function connectorChildren(row: ConnectorTreeRow): ConnectorTreeRow[] { if (row.kind !== "profile") return []; - return scopedCredentials. + return selectableCredentials. filter((credential) => firstCompatibleProfileByCredential.get(credential.id) === row.profile.id). map((credential) => ({ kind: "credential" as const, id: `credential:${row.profile.id}:${credential.id}`, credential, profile: row.profile })); } @@ -1432,10 +1440,39 @@ function connectorCredentialDraftKey(draft: ConnectorCredentialDraft): string { function credentialOptionsForProfileDraft(credentials: FileConnectorCredential[], draft: ConnectorProfileDraft): FileConnectorCredential[] { return credentials.filter((credential) => credential.id === draft.credentialProfileId || - credential.enabled && (!credential.provider || credential.provider === draft.provider) + credential.enabled && + (!credential.provider || credential.provider === draft.provider) && + reusableCredentialAllowsProfile(credential, draft.id) ); } +function reusableCredentialAvailableAtScope( + credential: FileConnectorCredential, + scopeType: ConnectorScope, + scopeId: string | null +): boolean { + if (connectorBelongsToEditableScope(credential, scopeType, scopeId)) return true; + if (credential.source_kind !== "credential_envelope") return false; + const inherited = credential.metadata?.inherit_to_lower_scopes === true; + if (!inherited) return false; + if (credential.scope_type === "system") return scopeType !== "system"; + if (credential.scope_type === "tenant") { + return scopeType === "group" || scopeType === "user"; + } + return false; +} + +function reusableCredentialAllowsProfile( + credential: FileConnectorCredential, + profileId: string +): boolean { + if (credential.source_kind !== "credential_envelope") return true; + const refs = Array.isArray(credential.metadata?.allowed_server_refs) + ? credential.metadata.allowed_server_refs.filter((value): value is string => typeof value === "string") + : []; + return refs.length === 0 || refs.includes(`files:${profileId}`); +} + function credentialProfileIdForDraft(draft: ConnectorProfileDraft, credentials: FileConnectorCredential[]): string { const options = credentialOptionsForProfileDraft(credentials, draft); if (draft.credentialProfileId && options.some((credential) => credential.id === draft.credentialProfileId)) {