Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93eea839c8 | |||
| b667e7ff0e | |||
| 0fd8f6e02a |
@@ -735,14 +735,14 @@ returning different content or credentials.
|
|||||||
|
|
||||||
| Area | Implemented now | Planned or explicitly outside the current boundary |
|
| Area | Implemented now | Planned or explicitly outside the current boundary |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Managed storage | Local durable-root backend, fallback read roots, tenant blob deduplication, checksums | Operational S3 after pinned SDK transport ([#34](https://git.add-ideas.de/add-ideas/govoplan-files/issues/34)); automated integrity/orphan reconciliation ([#36](https://git.add-ideas.de/add-ideas/govoplan-files/issues/36)) |
|
| Managed storage | Local durable-root backend, fallback read roots, tenant blob deduplication, checksums | Operational S3 after pinned SDK transport ([#34](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/34)); automated integrity/orphan reconciliation ([#36](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/36)) |
|
||||||
| Upload | Bounded direct upload, drag-and-drop UI, ZIP spool/extract limits, explicit conflicts | Malware scanning, quotas, type policy, resumable/chunked upload |
|
| Upload | Bounded direct upload, drag-and-drop UI, ZIP spool/extract limits, explicit conflicts | Malware scanning, quotas, type policy, resumable/chunked upload |
|
||||||
| Organization | Folders, bulk rename preview/apply, move/copy, drag-and-drop, ZIP download, pattern resolution | General file-history UI and user-driven append-version/restore |
|
| Organization | Folders, bulk rename preview/apply, move/copy, drag-and-drop, ZIP download, pattern resolution | General file-history UI and user-driven append-version/restore |
|
||||||
| Sharing | User/group/tenant/campaign grant or permission update through API; campaign linkage display | Share revocation/expiry and complete general share-management UI ([#37](https://git.add-ideas.de/add-ideas/govoplan-files/issues/37)) |
|
| Sharing | User/group/tenant/campaign grant or permission update through API; campaign linkage display | Share revocation/expiry and complete general share-management UI ([#37](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/37)) |
|
||||||
| Deletion/retention | Soft-delete assets/folders/spaces; immediate audited connector-secret scrubbing | File restore API, hard purge, retention policy, legal hold, and blob GC ([#38](https://git.add-ideas.de/add-ideas/govoplan-files/issues/38)) |
|
| Deletion/retention | Soft-delete assets/folders/spaces; immediate audited connector-secret scrubbing | File restore API, hard purge, retention policy, legal hold, and blob GC ([#38](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/38)) |
|
||||||
| Connector governance | Scoped profiles/credentials/policies, effective source explanation, separate credentials, linked user/group spaces | Provider-owned external secret lifecycle; API `secret_ref` remains rejected |
|
| Connector governance | Scoped profiles/credentials/policies, effective source explanation, separate credentials, linked user/group spaces | Provider-owned external secret lifecycle; API `secret_ref` remains rejected |
|
||||||
| HTTP connectors | Pinned, bounded, no-redirect Seafile and WebDAV/Nextcloud browse/import/manual sync | Background/folder sync, remote mutation, long-running transfer workers |
|
| HTTP connectors | Pinned, bounded, no-redirect Seafile and WebDAV/Nextcloud browse/import/manual sync | Background/folder sync, remote mutation, long-running transfer workers |
|
||||||
| SMB and S3 connectors | Provider descriptors and browse/import logic | Live access only after SDK connections plus DFS referrals/redirects are validated and pinned ([SMB #35](https://git.add-ideas.de/add-ideas/govoplan-files/issues/35), [S3 #34](https://git.add-ideas.de/add-ideas/govoplan-files/issues/34)) |
|
| SMB and S3 connectors | Provider descriptors and browse/import logic | Live access only after SDK connections plus DFS referrals/redirects are validated and pinned ([SMB #35](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/35), [S3 #34](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/34)) |
|
||||||
| Other providers | Reserved SharePoint/OneDrive keys and NFS/local descriptors | Graph/OAuth/provider paging, NFS deployment integration, DMS connectors |
|
| Other providers | Reserved SharePoint/OneDrive keys and NFS/local descriptors | Graph/OAuth/provider paging, NFS deployment integration, DMS connectors |
|
||||||
| Connector spaces | User/group link, browse, manual selected-file sync, edit/disable/delete | Background sync, remote writes/deletes, full conflict-reporting jobs |
|
| Connector spaces | User/group link, browse, manual selected-file sync, edit/disable/delete | Background sync, remote writes/deletes, full conflict-reporting jobs |
|
||||||
| Profile capabilities | Stored and displayed | Enforce capability flags as an independent operation gate |
|
| Profile capabilities | Stored and displayed | Enforce capability flags as an independent operation gate |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from govoplan_core.core.modules import (
|
|||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
ModuleInterfaceProvider,
|
ModuleInterfaceProvider,
|
||||||
@@ -22,6 +23,7 @@ from govoplan_core.core.modules import (
|
|||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_files.backend.change_tracking import register_files_change_tracking
|
from govoplan_files.backend.change_tracking import register_files_change_tracking
|
||||||
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
|
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
|
||||||
@@ -192,7 +194,22 @@ manifest = ModuleManifest(
|
|||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="files",
|
module_id="files",
|
||||||
package_name="@govoplan/files-webui",
|
package_name="@govoplan/files-webui",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/files",
|
||||||
|
component="FilesPage",
|
||||||
|
required_any=("files:file:read",),
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
),
|
||||||
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
|
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(id="files.admin.system-connectors", module_id="files", kind="section", label="System file connections", order=75),
|
||||||
|
ViewSurface(id="files.admin.tenant-connectors", module_id="files", kind="section", label="Tenant file connections", order=65),
|
||||||
|
ViewSurface(id="files.admin.group-connectors", module_id="files", kind="section", label="Group file connections", order=65),
|
||||||
|
ViewSurface(id="files.admin.user-connectors", module_id="files", kind="section", label="User file connections", order=65),
|
||||||
|
ViewSurface(id="files.settings.connectors", module_id="files", kind="section", label="Personal file connections", order=20),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const fileConnectorAdminSections: AdminSectionsUiCapability = {
|
|||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "system-file-connectors",
|
id: "system-file-connectors",
|
||||||
|
surfaceId: "files.admin.system-connectors",
|
||||||
label: "i18n:govoplan-files.file_connections.1e362326",
|
label: "i18n:govoplan-files.file_connections.1e362326",
|
||||||
group: "SYSTEM",
|
group: "SYSTEM",
|
||||||
order: 75,
|
order: 75,
|
||||||
@@ -30,6 +31,7 @@ const fileConnectorAdminSections: AdminSectionsUiCapability = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tenant-file-connectors",
|
id: "tenant-file-connectors",
|
||||||
|
surfaceId: "files.admin.tenant-connectors",
|
||||||
label: "i18n:govoplan-files.file_connections.1e362326",
|
label: "i18n:govoplan-files.file_connections.1e362326",
|
||||||
group: "TENANT",
|
group: "TENANT",
|
||||||
order: 65,
|
order: 65,
|
||||||
@@ -49,6 +51,13 @@ export const filesModule: PlatformWebModule = {
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
translations,
|
translations,
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "files.admin.system-connectors", moduleId: "files", kind: "section", label: "System file connections", order: 75 },
|
||||||
|
{ id: "files.admin.tenant-connectors", moduleId: "files", kind: "section", label: "Tenant file connections", order: 65 },
|
||||||
|
{ id: "files.admin.group-connectors", moduleId: "files", kind: "section", label: "Group file connections", order: 65 },
|
||||||
|
{ id: "files.admin.user-connectors", moduleId: "files", kind: "section", label: "User file connections", order: 65 },
|
||||||
|
{ id: "files.settings.connectors", moduleId: "files", kind: "section", label: "Personal file connections", order: 20 }
|
||||||
|
],
|
||||||
navItems: [{ to: "/files", label: "i18n:govoplan-files.files.6ce6c512", iconName: "folder", anyOf: fileRead, order: 40 }],
|
navItems: [{ to: "/files", label: "i18n:govoplan-files.files.6ce6c512", iconName: "folder", anyOf: fileRead, order: 40 }],
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) }],
|
{ path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) }],
|
||||||
|
|||||||
Reference in New Issue
Block a user