Integrate file connectors with credential envelopes
This commit is contained in:
@@ -109,6 +109,8 @@ from govoplan_files.backend.storage.connector_credential_store import (
|
|||||||
create_connector_credential_row,
|
create_connector_credential_row,
|
||||||
get_connector_credential_row,
|
get_connector_credential_row,
|
||||||
list_database_connector_credentials,
|
list_database_connector_credentials,
|
||||||
|
resolve_reusable_connector_credential,
|
||||||
|
reusable_credential_id,
|
||||||
update_connector_credential_row,
|
update_connector_credential_row,
|
||||||
)
|
)
|
||||||
from govoplan_files.backend.storage.connector_credential_deletion import (
|
from govoplan_files.backend.storage.connector_credential_deletion import (
|
||||||
@@ -742,11 +744,25 @@ def _credential_row_for_profile(
|
|||||||
*,
|
*,
|
||||||
credential_profile_id: str | None,
|
credential_profile_id: str | None,
|
||||||
provider: str,
|
provider: str,
|
||||||
|
profile_id: str | None = None,
|
||||||
|
scope_type: str = "tenant",
|
||||||
|
scope_id: str | None = None,
|
||||||
include_disabled: bool = False,
|
include_disabled: bool = False,
|
||||||
):
|
):
|
||||||
credential_id = (credential_profile_id or "").strip()
|
credential_id = (credential_profile_id or "").strip()
|
||||||
if not credential_id:
|
if not credential_id:
|
||||||
return None
|
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(
|
row = get_connector_credential_row(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
@@ -2904,6 +2920,9 @@ def create_connector_profile(
|
|||||||
principal,
|
principal,
|
||||||
credential_profile_id=payload.credential_profile_id,
|
credential_profile_id=payload.credential_profile_id,
|
||||||
provider=payload.provider,
|
provider=payload.provider,
|
||||||
|
profile_id=payload.id,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
)
|
)
|
||||||
_ensure_connector_configuration_allowed(
|
_ensure_connector_configuration_allowed(
|
||||||
session,
|
session,
|
||||||
@@ -3156,6 +3175,9 @@ def update_connector_profile(
|
|||||||
principal,
|
principal,
|
||||||
credential_profile_id=credential_profile_id,
|
credential_profile_id=credential_profile_id,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
|
profile_id=row.id,
|
||||||
|
scope_type=row.scope_type,
|
||||||
|
scope_id=row.scope_id,
|
||||||
include_disabled=True,
|
include_disabled=True,
|
||||||
)
|
)
|
||||||
_ensure_connector_configuration_allowed(
|
_ensure_connector_configuration_allowed(
|
||||||
|
|||||||
@@ -5,8 +5,17 @@ from dataclasses import dataclass
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
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.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_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
from govoplan_files.backend.db.models import FileConnectorCredential
|
from govoplan_files.backend.db.models import FileConnectorCredential
|
||||||
from govoplan_files.backend.storage.common import FileStorageError
|
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_policy import ConnectorPolicySource, connector_policy_sources_from_payload
|
||||||
from govoplan_files.backend.storage.connector_profiles import supported_connector_providers
|
from govoplan_files.backend.storage.connector_profiles import supported_connector_providers
|
||||||
|
|
||||||
|
CORE_CREDENTIAL_ENVELOPE_PREFIX = "credential-envelope:"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ConnectorCredential:
|
class ConnectorCredential:
|
||||||
@@ -33,6 +44,7 @@ class ConnectorCredential:
|
|||||||
policy_sources: tuple[ConnectorPolicySource, ...] = ()
|
policy_sources: tuple[ConnectorPolicySource, ...] = ()
|
||||||
metadata: Mapping[str, Any] | None = None
|
metadata: Mapping[str, Any] | None = None
|
||||||
source_kind: str = "database"
|
source_kind: str = "database"
|
||||||
|
has_secret: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def source_path(self) -> str:
|
def source_path(self) -> str:
|
||||||
@@ -58,7 +70,7 @@ class ConnectorCredential:
|
|||||||
return True
|
return True
|
||||||
# Environment references are deliberately unavailable to API-managed
|
# Environment references are deliberately unavailable to API-managed
|
||||||
# credential rows. Legacy rows remain visible but fail closed.
|
# 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]:
|
def to_response(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -85,7 +97,159 @@ def list_database_connector_credentials(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
include_disabled: bool = False,
|
include_disabled: bool = False,
|
||||||
) -> list[ConnectorCredential]:
|
) -> 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(
|
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:
|
def _clean(value: object) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -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.db.models import FileConnectorCredential, FileConnectorProfile
|
||||||
from govoplan_files.backend.storage.common import FileStorageError
|
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_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_policy import connector_policy_sources_from_payload
|
||||||
from govoplan_files.backend.storage.connector_profiles import ConnectorProfile, supported_connector_providers
|
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}
|
profile_ids = {row.id for row in rows}
|
||||||
if row_visible is not None:
|
if row_visible is not None:
|
||||||
rows = [row for row in rows if row_visible(row)]
|
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)
|
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 (
|
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,
|
profile_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,7 +97,10 @@ def list_connector_profile_rows(
|
|||||||
return query.order_by(FileConnectorProfile.scope_type.asc(), FileConnectorProfile.label.asc()).all()
|
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 = []
|
policy_sources = []
|
||||||
if row.policy:
|
if row.policy:
|
||||||
policy_sources = connector_policy_sources_from_payload({
|
policy_sources = connector_policy_sources_from_payload({
|
||||||
@@ -82,9 +109,18 @@ def connector_profile_from_row(row: FileConnectorProfile, credential_row: FileCo
|
|||||||
"label": row.label,
|
"label": row.label,
|
||||||
"policy": row.policy,
|
"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:
|
if credential:
|
||||||
policy_sources.extend(credential.policy_sources)
|
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(
|
return ConnectorProfile(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
label=row.label,
|
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),
|
token_value=credential.token_value if credential else decrypt_secret(row.token_encrypted),
|
||||||
capabilities=tuple(_string_list(row.capabilities)),
|
capabilities=tuple(_string_list(row.capabilities)),
|
||||||
policy_sources=tuple(policy_sources),
|
policy_sources=tuple(policy_sources),
|
||||||
metadata=dict(row.metadata_ or {}),
|
metadata=metadata,
|
||||||
source_kind="database",
|
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(
|
def get_connector_profile_row(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
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_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.db.base import Base
|
||||||
|
from govoplan_core.security.credential_envelopes import CredentialEnvelope
|
||||||
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
from govoplan_files.backend.db.models import FileConnectorProfile
|
from govoplan_files.backend.db.models import FileConnectorProfile
|
||||||
from govoplan_files.backend.storage.common import FileStorageError
|
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):
|
class ConnectorProfileStoreUpdateTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.engine = create_engine("sqlite:///:memory:")
|
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.session = sessionmaker(bind=self.engine)()
|
||||||
self.row = FileConnectorProfile(
|
self.row = FileConnectorProfile(
|
||||||
id="profile-1",
|
id="profile-1",
|
||||||
@@ -171,6 +179,50 @@ class ConnectorProfileStoreUpdateTests(unittest.TestCase):
|
|||||||
self.assertEqual([self.row.id], [profile.id for profile in profiles])
|
self.assertEqual([self.row.id], [profile.id for profile in profiles])
|
||||||
self.assertEqual("original-password", profiles[0].password_value)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -225,6 +225,14 @@ export default function FileConnectorSettingsPanel({
|
|||||||
() => credentials.filter((credential) => connectorBelongsToEditableScope(credential, scopeType, activeScopeId)),
|
() => credentials.filter((credential) => connectorBelongsToEditableScope(credential, scopeType, activeScopeId)),
|
||||||
[activeScopeId, credentials, scopeType]
|
[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(() => {
|
useEffect(() => {
|
||||||
resetDeltaWatermark(deltaKey);
|
resetDeltaWatermark(deltaKey);
|
||||||
@@ -424,7 +432,7 @@ export default function FileConnectorSettingsPanel({
|
|||||||
endpoint_url: cleanOrNull(draft.endpointUrl),
|
endpoint_url: cleanOrNull(draft.endpointUrl),
|
||||||
base_path: cleanOrNull(draft.basePath),
|
base_path: cleanOrNull(draft.basePath),
|
||||||
enabled: draft.enabled,
|
enabled: draft.enabled,
|
||||||
credential_profile_id: cleanOrNull(credentialProfileIdForDraft(draft, scopedCredentials)),
|
credential_profile_id: cleanOrNull(credentialProfileIdForDraft(draft, selectableCredentials)),
|
||||||
credential_mode: draft.credentialMode,
|
credential_mode: draft.credentialMode,
|
||||||
capabilities,
|
capabilities,
|
||||||
policy: policyFromDraft(draft),
|
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 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 selectedCredentialIds = new Set(scopedProfiles.map((profile) => profile.credential_profile_id).filter((value): value is string => Boolean(value)));
|
||||||
const firstCompatibleProfileByCredential = new Map<string, string>();
|
const firstCompatibleProfileByCredential = new Map<string, string>();
|
||||||
for (const credential of scopedCredentials) {
|
for (const credential of selectableCredentials) {
|
||||||
const selectedProfile = scopedProfiles.find((profile) => profile.credential_profile_id === credential.id);
|
const selectedProfile = scopedProfiles.find((profile) => profile.credential_profile_id === credential.id);
|
||||||
const fallbackProfile = selectedProfile ?? scopedProfiles.find((profile) => !selectedCredentialIds.has(credential.id) && credentialMatchesProfile(credential, profile));
|
const fallbackProfile = selectedProfile ?? scopedProfiles.find((profile) => !selectedCredentialIds.has(credential.id) && credentialMatchesProfile(credential, profile));
|
||||||
if (fallbackProfile) firstCompatibleProfileByCredential.set(credential.id, fallbackProfile.id);
|
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 credentialTestUnsupported = Boolean(credentialTestProfile && credentialTestProfile.provider !== "webdav" && credentialTestProfile.provider !== "nextcloud");
|
||||||
const credentialTestDisabled = !credentialDraft || saving || testingCredentialLogin || !credentialTestProfile || credentialTestUnsupported;
|
const credentialTestDisabled = !credentialDraft || saving || testingCredentialLogin || !credentialTestProfile || credentialTestUnsupported;
|
||||||
const credentialTestTooltip = credentialTestHelpText(credentialTestProfile, credentialTestUnsupported);
|
const credentialTestTooltip = credentialTestHelpText(credentialTestProfile, credentialTestUnsupported);
|
||||||
const profileCredentialOptions = draft ? credentialOptionsForProfileDraft(scopedCredentials, draft) : [];
|
const profileCredentialOptions = draft ? credentialOptionsForProfileDraft(selectableCredentials, draft) : [];
|
||||||
const profileCredentialSelectValue = draft ? credentialProfileIdForDraft(draft, scopedCredentials) : "";
|
const profileCredentialSelectValue = draft ? credentialProfileIdForDraft(draft, selectableCredentials) : "";
|
||||||
const connectorColumns: ConnectionTreeColumn<ConnectorTreeRow>[] = [
|
const connectorColumns: ConnectionTreeColumn<ConnectorTreeRow>[] = [
|
||||||
{
|
{
|
||||||
id: "connection",
|
id: "connection",
|
||||||
@@ -755,7 +763,7 @@ export default function FileConnectorSettingsPanel({
|
|||||||
|
|
||||||
function connectorChildren(row: ConnectorTreeRow): ConnectorTreeRow[] {
|
function connectorChildren(row: ConnectorTreeRow): ConnectorTreeRow[] {
|
||||||
if (row.kind !== "profile") return [];
|
if (row.kind !== "profile") return [];
|
||||||
return scopedCredentials.
|
return selectableCredentials.
|
||||||
filter((credential) => firstCompatibleProfileByCredential.get(credential.id) === row.profile.id).
|
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 }));
|
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[] {
|
function credentialOptionsForProfileDraft(credentials: FileConnectorCredential[], draft: ConnectorProfileDraft): FileConnectorCredential[] {
|
||||||
return credentials.filter((credential) =>
|
return credentials.filter((credential) =>
|
||||||
credential.id === draft.credentialProfileId ||
|
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 {
|
function credentialProfileIdForDraft(draft: ConnectorProfileDraft, credentials: FileConnectorCredential[]): string {
|
||||||
const options = credentialOptionsForProfileDraft(credentials, draft);
|
const options = credentialOptionsForProfileDraft(credentials, draft);
|
||||||
if (draft.credentialProfileId && options.some((credential) => credential.id === draft.credentialProfileId)) {
|
if (draft.credentialProfileId && options.some((credential) => credential.id === draft.credentialProfileId)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user